// Unit discovery over mDNS, via the host's avahi.
//
// Units (firmware proto >= 2) advertise _cthuloid._tcp on their command port.
// That name is specific to these lights, so no probe connection is needed to
// tell a unit from any other device -- which matters, because a probe to a
// unit on old single-client firmware would evict whoever is driving it.
// Units on older firmware advertise only telnet/23 and are added by hand.
//
// `avahi-browse` without -t keeps running and prints every change, so this is
// event-driven: no polling. bare-mdns-discovery exists but labels itself an
// experimental generated prototype; the system avahi is already resolving
// these units on stortron.
import EventEmitter from 'bare-events'
import { spawn } from 'bare-subprocess'
export const DEFAULT_COMMAND = ['avahi-browse', '--resolve', '--parsable', '--no-db-lookup', '_cthuloid._tcp']
// avahi's parsable output escapes bytes as \DDD (decimal) and specials as \c.
function unescape (s) {
return s.replace(/\\(\d{3}|.)/g, (_, e) => (e.length === 3 ? String.fromCharCode(Number(e)) : e))
}
// '=' resolved, '-' removed; '+' (seen, not yet resolved) is ignored because
// it carries no address. IPv4 only: the units speak IPv4.
export function parseAvahiLine (line) {
const f = line.split(';')
if (f[0] === '-' && f.length >= 6) {
if (f[2] !== 'IPv4') return null
return { type: 'down', name: unescape(f[3]) }
}
if (f[0] !== '=' || f.length < 10 || f[2] !== 'IPv4') return null
const port = Number(f[8])
if (!Number.isInteger(port)) return null
const txt = f.slice(9).join(';')
const m = /"proto=(\d+)"/.exec(txt)
return {
type: 'up',
name: unescape(f[3]),
host: unescape(f[6]),
addr: f[7],
port,
proto: m ? Number(m[1]) : 0
}
}
export class Discovery extends EventEmitter {
constructor ({ command = DEFAULT_COMMAND, respawnMs = 5000 } = {}) {
super()
this.command = command
this.respawnMs = respawnMs
this.available = true
this.reason = ''
this._proc = null
this._running = false
this._timer = null
this._buf = ''
}
start () {
if (this._running) return
this._running = true
this._spawn()
}
stop () {
this._running = false
clearTimeout(this._timer)
this._timer = null
const p = this._proc
this._proc = null
if (p) {
try { p.kill() } catch {}
}
}
_spawn () {
this._buf = ''
// Launched via `sh -c 'exec "$0" "$@"'` rather than directly. In
// bare-subprocess 6.1.0 a spawn of a missing binary throws ENOENT AFTER
// creating its stdio pipes and never closes them, so the process can then
// never exit. sh always exists; exec replaces it with the real command (no
// extra process); a missing command becomes exit 127, reported below.
let proc
try {
proc = spawn('/bin/sh', ['-c', 'exec "$0" "$@"', ...this.command], { stdio: ['ignore', 'pipe', 'pipe'] })
} catch (err) {
return this._unavailable(err.message || String(err))
}
this._proc = proc
let stderr = ''
proc.stdout.on('data', (d) => this._onData(d))
proc.stderr.on('data', (d) => { stderr += d })
proc.on('error', (err) => this._unavailable(err.message || String(err)))
proc.on('exit', (code) => {
if (this._proc !== proc) return
this._proc = null
// avahi restarted or the daemon is down: try again, and say why meanwhile.
if (code !== 0) this._unavailable((stderr.trim() || 'exited with ' + code))
if (this._running) this._timer = setTimeout(() => this._running && this._spawn(), this.respawnMs)
})
}
_unavailable (reason) {
this.available = false
this.reason = reason
this.emit('unavailable', reason)
}
_onData (d) {
if (!this.available) {
this.available = true
this.reason = ''
this.emit('available')
}
this._buf += d.toString()
let i
while ((i = this._buf.indexOf('\n')) !== -1) {
const line = this._buf.slice(0, i)
this._buf = this._buf.slice(i + 1)
const ev = parseAvahiLine(line)
if (ev) this.emit(ev.type, ev)
}
}
}