OverviewFilesCommitsIssuesDocsReleasesPeers

cthuloid / webui / lib / discovery.js

126 lines · javascript ref:
// 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)
    }
  }
}