UNPKG

matterbridge-shelly

Version:
308 lines (307 loc) 16 kB
import EventEmitter from 'node:events'; import { promises as fs } from 'node:fs'; import path from 'node:path'; import { AnsiLogger, BLUE, CYAN, db, debugStringify, er, hk, idn, ign, nf, rs, zb } from 'matterbridge/logger'; import mdns from 'multicast-dns'; export class MdnsScanner extends EventEmitter { devices = new Map(); discoveredDevices = new Map(); log; scanner; _isScanning = false; scannerTimeout; queryTimeout; queryInterval; _dataPath = 'temp'; constructor(logLevel = "info") { super(); this.log = new AnsiLogger({ logName: 'ShellyMdnsScanner', logTimestampFormat: 4, logLevel }); } set dataPath(path) { this._dataPath = path; } get isScanning() { return this._isScanning; } sendQuery() { this.scanner?.query([ { name: '_http._tcp.local', type: 'PTR' }, { name: '_shelly._tcp.local', type: 'PTR' }, { name: '_services._dns-sd._udp.local', type: 'PTR' }, ]); this.scanner?.query([{ name: '_shelly._tcp.local', type: 'PTR', class: 'IN' }]); this.log.debug('Sent mDNS query for shelly devices.'); } start(scannerTimeout, queryTimeout, mdnsInterface, type, debug = false) { if (this._isScanning) return; this._isScanning = true; if (mdnsInterface && mdnsInterface !== '' && type && (type === 'udp4' || type === 'udp6')) { const mdnsOptions = {}; mdnsOptions.interface = mdnsInterface; mdnsOptions.bind = mdnsOptions.interface; mdnsOptions.type = type; mdnsOptions.ip = type === 'udp4' ? '224.0.0.251' : 'ff02::fb'; mdnsOptions.port = 5353; mdnsOptions.multicast = true; mdnsOptions.reuseAddr = true; this.log.info(`Starting MdnsScanner for shelly devices (interface ${mdnsOptions.interface} bind ${mdnsOptions.bind} type ${mdnsOptions.type} ip ${mdnsOptions.ip}) for shelly devices...`); this.scanner = mdns(mdnsOptions); } else { this.log.info('Starting MdnsScanner for shelly devices...'); this.scanner = mdns(); } this.scanner.on('response', async (response, rinfo) => { let port = 80; let gen = 1; this.devices.set(rinfo.address, rinfo.address); if (debug) this.log.debug(`Mdns response from ${ign} ${rinfo.address} family ${rinfo.family} port ${rinfo.port} ${rs}${db} id ${response.id} flags ${response.flags}`); if (debug) this.log.debug(`--- response.questions[${response.questions.length}] ---`); for (const q of response.questions) { if (debug) this.log.debug(`[${idn}${q.type}${rs}${db}] Name: ${CYAN}${q.name}${db} class: ${CYAN}${q.class}${db}`); } if (debug) this.log.debug(`--- response.answers[${response.answers.length}] ---`); for (const a of response.answers) { if (a.type === 'SRV' && (a.name.startsWith('shelly') || a.name.startsWith('Shelly'))) { port = a.data.port; } if (a.type === 'TXT' && (a.name.startsWith('shelly') || a.name.startsWith('Shelly'))) { if (a.data.toString().includes('gen=2')) gen = 2; if (a.data.toString().includes('gen=3')) gen = 3; if (a.data.toString().includes('gen=4')) gen = 4; } } for (const a of response.answers) { if (debug && a.type === 'PTR') { this.log.debug(`[${idn}${a.type}${rs}${db}] Name: ${CYAN}${a.name}${db} data: ${typeof a.data === 'string' ? a.data : debugStringify(a.data)}`); } if (debug && a.type === 'TXT') { if (typeof a.data === 'string') this.log.debug(`[${idn}${a.type}${rs}${db}] Name: ${CYAN}${a.name}${db} data: ${a.data}`); else if (Buffer.isBuffer(a.data)) this.log.debug(`[${idn}${a.type}${rs}${db}] Name: ${CYAN}${a.name}${db} data: ${a.data.toString()}`); else if (Array.isArray(a.data)) this.log.debug(`[${idn}${a.type}${rs}${db}] Name: ${CYAN}${a.name}${db} data: ${a.data.map((d) => d.toString()).join(', ')}`); } if (debug && a.type === 'SRV') { this.log.debug(`[${idn}${a.type}${rs}${db}] Name: ${CYAN}${a.name}${db} target: ${a.data.target} port: ${a.data.port} priority: ${a.data.priority} weight: ${a.data.weight}`); } if (debug && a.type === 'NSEC') { this.log.debug(`[${idn}${a.type}${rs}${db}] Name: ${CYAN}${a.name}${db} data: ${typeof a.data === 'string' ? a.data : debugStringify(a.data)}`); } if (debug && a.type === 'A') { this.log.debug(`[${idn}${a.type}${rs}${db}] Name: ${CYAN}${a.name}${db} data: ${typeof a.data === 'string' ? a.data : debugStringify(a.data)}`); } if (debug && a.type === 'A' && (a.name.startsWith('shelly') || a.name.startsWith('Shelly'))) { this.log.debug(`[${BLUE}${a.type}${db}] Name: ${CYAN}${a.name}${db} data: ${typeof a.data === 'string' ? a.data : debugStringify(a.data)}`); } if (a.type === 'A' && (a.name.startsWith('shelly') || a.name.startsWith('Shelly'))) { const deviceId = this.normalizeShellyId(a.name); if (deviceId && (!this.discoveredDevices.has(deviceId) || this.discoveredDevices.get(deviceId)?.host !== a.data)) { this.log.debug(`MdnsScanner discovered shelly gen: ${CYAN}${gen}${nf} device id: ${hk}${deviceId}${nf} host: ${zb}${a.data}${nf} port: ${zb}${port}${nf}`); this.discoveredDevices.set(deviceId, { id: deviceId, host: a.data, port, gen }); this.emit('discovered', { id: deviceId, host: a.data, port, gen }); if (debug || process.argv.includes('testMdnsScanner')) { this.saveResponse(deviceId, response); } } } } if (debug) this.log.debug(`--- response.additionals[${response.additionals.length}] ---`); for (const a of response.additionals) { if (a.type === 'SRV' && (a.name.startsWith('shelly') || a.name.startsWith('Shelly'))) { port = a.data.port; } if (a.type === 'TXT' && (a.name.startsWith('shelly') || a.name.startsWith('Shelly'))) { if (a.data.toString().includes('gen=2')) gen = 2; if (a.data.toString().includes('gen=3')) gen = 3; if (a.data.toString().includes('gen=4')) gen = 4; } } for (const a of response.additionals) { if (debug && a.type === 'PTR') { this.log.debug(`[${idn}${a.type}${rs}${db}] Name: ${CYAN}${a.name}${db} data: ${typeof a.data === 'string' ? a.data : debugStringify(a.data)}`); } if (debug && a.type === 'TXT') { if (typeof a.data === 'string') this.log.debug(`[${idn}${a.type}${rs}${db}] Name: ${CYAN}${a.name}${db} data: ${a.data}`); else if (Buffer.isBuffer(a.data)) this.log.debug(`[${idn}${a.type}${rs}${db}] Name: ${CYAN}${a.name}${db} data: ${a.data.toString()}`); else if (Array.isArray(a.data)) this.log.debug(`[${idn}${a.type}${rs}${db}] Name: ${CYAN}${a.name}${db} data: ${a.data.map((d) => d.toString()).join(', ')}`); } if (debug && a.type === 'SRV') { this.log.debug(`[${idn}${a.type}${rs}${db}] Name: ${CYAN}${a.name}${db} target: ${a.data.target} port: ${a.data.port} priority: ${a.data.priority} weight: ${a.data.weight}`); } if (debug && a.type === 'NSEC') { this.log.debug(`[${idn}${a.type}${rs}${db}] Name: ${CYAN}${a.name}${db} data: ${typeof a.data === 'string' ? a.data : debugStringify(a.data)}`); } if (debug && a.type === 'A') { this.log.debug(`[${idn}${a.type}${rs}${db}] Name: ${CYAN}${a.name}${db} data: ${typeof a.data === 'string' ? a.data : debugStringify(a.data)}`); } if (a.type === 'A' && (a.name.startsWith('shelly') || a.name.startsWith('Shelly'))) { const deviceId = this.normalizeShellyId(a.name); if (deviceId && (!this.discoveredDevices.has(deviceId) || this.discoveredDevices.get(deviceId)?.host !== a.data)) { this.log.debug(`MdnsScanner discovered shelly gen: ${CYAN}${gen}${nf} device id: ${hk}${deviceId}${nf} host: ${zb}${a.data}${nf} port: ${zb}${port}${nf}`); this.discoveredDevices.set(deviceId, { id: deviceId, host: a.data, port, gen }); this.emit('discovered', { id: deviceId, host: a.data, port, gen }); if (debug || process.argv.includes('testMdnsScanner')) { this.saveResponse(deviceId, response); } } } } if (debug) this.log.debug(`--- response.authorities[${response.authorities.length}] ---`); if (debug) this.log.debug(`--- end ---\n`); }); this.scanner.on('query', (query, rinfo) => { if (debug) this.log.debug(`Mdns query from ${idn} ${rinfo.address} family ${rinfo.family} port ${rinfo.port} ${rs}${db} id ${query.id} flags ${query.flags}`); if (debug) this.log.debug(`--- query.questions[${query.questions.length}] ---`); for (const q of query.questions) { if (debug) this.log.debug(`[${ign}${q.type}${rs}${db}] Name: ${CYAN}${q.name}${db} class: ${CYAN}${q.class}${db}`); this.emit('query', { type: q.type, name: q.name, class: q.class }); } if (debug) this.log.debug(`--- query.answers[${query.answers.length}] ---`); if (debug) this.log.debug(`--- query.additionals[${query.additionals.length}] ---`); if (debug) this.log.debug(`--- query.authorities[${query.authorities.length}] ---`); if (debug) this.log.debug(`--- end ---\n`); }); this.scanner.on('error', async (err) => { this.log.error(`Error in mDNS query service: ${err.message}`); }); this.scanner.on('warning', async (err) => { this.log.warn(`Warning in mDNS query service: ${err.message}`); }); this.scanner.on('ready', async () => { this.log.debug(`The mDNS socket is bound`); this.log.info(`MdnsScanner for shelly devices is listening on port 5353...`); }); this.sendQuery(); this.queryInterval = setInterval(() => { this.sendQuery(); }, 60 * 1000); if (scannerTimeout && scannerTimeout > 0) { this.scannerTimeout = setTimeout(() => { this.stop(); }, scannerTimeout); } if (queryTimeout && queryTimeout > 0) { this.queryTimeout = setTimeout(() => { if (this.queryInterval) clearInterval(this.queryInterval); this.queryInterval = undefined; this.log.info('Stopped MdnsScanner query service for shelly devices.'); }, queryTimeout); } this.log.info('Started MdnsScanner for shelly devices.'); } stop(keepAlive = false) { this.log.info('Stopping MdnsScanner for shelly devices...'); if (this.scannerTimeout) clearTimeout(this.scannerTimeout); this.scannerTimeout = undefined; if (this.queryTimeout) clearTimeout(this.queryTimeout); this.queryTimeout = undefined; if (this.queryInterval) clearTimeout(this.queryInterval); this.queryInterval = undefined; this._isScanning = false; if (keepAlive) return; this.scanner?.removeAllListeners(); this.scanner?.destroy(); this.scanner = undefined; this.removeAllListeners(); this.logPeripheral(); this.log.info('Stopped MdnsScanner for shelly devices.'); } normalizeShellyId(shellyId) { const parts = shellyId.replace('.local', '').split('-'); if (parts.length < 2) return undefined; const mac = parts.pop(); if (!mac) return undefined; const name = parts.join('-'); return name.toLowerCase() + '-' + mac.toUpperCase(); } logPeripheral() { this.log.debug(`Discovered ${this.devices.size} devices:`); const sortedDevices = Array.from(this.devices).sort((a, b) => { const hostA = a[1].toLowerCase(); const hostB = b[1].toLowerCase(); if (hostA >= hostB) return 1; else return -1; }); for (const [name, host] of sortedDevices) { this.log.debug(`- host: ${zb}${host}${nf}`); } this.log.info(`Discovered ${this.discoveredDevices.size} shelly devices:`); const sortedDiscoveredDevices = Array.from(this.discoveredDevices).sort((a, b) => { const idA = a[1].id; const idB = b[1].id; if (idA >= idB) return 1; else return -1; }); for (const [name, { id, host, port, gen }] of sortedDiscoveredDevices) { this.log.info(`- id: ${hk}${name}${nf} host: ${zb}${host}${nf} port: ${zb}${port}${nf} gen: ${CYAN}${gen}${nf}`); } return this.discoveredDevices.size; } async saveResponse(shellyId, response) { const responseFile = path.join(this._dataPath, `${shellyId}.mdns.json`); try { await fs.mkdir(this._dataPath, { recursive: true }); this.log.debug(`Successfully created directory ${this._dataPath}`); for (const a of response.answers) { if (a.type === 'TXT') { if (Buffer.isBuffer(a.data)) a.data = a.data.toString(); if (Array.isArray(a.data)) a.data = a.data.map((d) => (Buffer.isBuffer(d) ? d.toString() : d)); } } for (const a of response.additionals) { if (a.type === 'TXT') { if (Buffer.isBuffer(a.data)) a.data = a.data.toString(); if (Array.isArray(a.data)) a.data = a.data.map((d) => (Buffer.isBuffer(d) ? d.toString() : d)); } } await fs.writeFile(responseFile, JSON.stringify(response, null, 2), 'utf8'); this.log.debug(`Saved shellyId ${hk}${shellyId}${db} response file ${CYAN}${responseFile}${db}`); return Promise.resolve(); } catch (err) { this.log.error(`Error saving shellyId ${hk}${shellyId}${er} response file ${CYAN}${responseFile}${er}: ${err instanceof Error ? err.message : err}`); return Promise.reject(err); } } }