matterbridge-shelly
Version:
Matterbridge shelly plugin
308 lines (307 loc) • 16 kB
JavaScript
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);
}
}
}