Finish Finder implementation

This can correctly find and return IntelliCenter units on the local network now.

Cleaned up a bunch of Finder code and added back in "debug" module logging.

Fixed up types in Dns module so we can use instanceof
This commit is contained in:
2025-01-02 17:03:28 -06:00
parent 4ccdf837d9
commit b186561d24
13 changed files with 401 additions and 340 deletions

23
dist/dns.d.ts vendored
View File

@ -1,40 +1,31 @@
export declare const TypeTxt = 16;
export declare const TypePtr = 12;
export declare const TypeSrv = 33;
export declare const TypeA = 1;
export declare class Question {
name: string;
type: number;
class: number;
endOffset: number;
constructor(_name: string, _type: number, _cls: number, _endOffset: number);
}
export interface Record {
interface: string | undefined;
export declare abstract class Record {
type: number;
ttlSeconds: number;
name: string;
endOffset: number;
}
export interface PtrRecord extends Record {
interface: "ptr";
export declare class PtrRecord extends Record {
domain: string;
}
export interface TxtRecord extends Record {
interface: "txt";
export declare class TxtRecord extends Record {
text: string;
}
export interface SrvRecord extends Record {
interface: "srv";
export declare class SrvRecord extends Record {
priority: number;
weight: number;
port: number;
target: string;
}
export interface ARecord extends Record {
interface: "a";
export declare class ARecord extends Record {
address: number;
addressStr: string;
get addressStr(): string;
}
export declare function ipToString(ip: number): string;
export declare function GetDNSQuestion(msg: Buffer, startOffset: number): Question;
export declare function GetDNSAnswer(msg: Buffer, startOffset: number): Record | undefined;

121
dist/dns.js vendored
View File

@ -1,19 +1,45 @@
export const TypeTxt = 16;
export const TypePtr = 12;
export const TypeSrv = 33;
export const TypeA = 1;
const TypeTxt = 16;
const TypePtr = 12;
const TypeSrv = 33;
const TypeA = 1;
export class Question {
name = "";
type = 0;
class = 0;
endOffset = 0;
constructor(_name, _type, _cls, _endOffset) {
this.name = _name;
this.type = _type;
this.class = _cls;
this.endOffset = _endOffset;
}
export class Record {
type = 0;
ttlSeconds = 0;
name = "";
endOffset = -1;
}
export class PtrRecord extends Record {
domain = "";
}
export class TxtRecord extends Record {
text = "";
}
export class SrvRecord extends Record {
priority = 0;
weight = 0;
port = 0;
target = "";
}
export class ARecord extends Record {
address = 0;
get addressStr() {
return ipToString(this.address);
}
}
export function ipToString(ip) {
const o1 = (ip >> 24) & 0xff;
const o2 = (ip >> 16) & 0xff;
const o3 = (ip >> 8) & 0xff;
const o4 = (ip >> 0) & 0xff;
const addressStr = `${o1.toString()}.${o2.toString()}.${o3.toString()}.${o4.toString()}`;
return addressStr;
}
class dnsAnswerParseResult {
name = "";
endOffset = 0;
@ -58,7 +84,12 @@ export function GetDNSQuestion(msg, startOffset) {
offset += 2;
const cls = msg.readUInt16BE(offset);
offset += 2;
return new Question(parsedResult.name, type, cls, offset);
const ret = new Question();
ret.name = parsedResult.name;
ret.type = type;
ret.class = cls;
ret.endOffset = offset;
return ret;
}
export function GetDNSAnswer(msg, startOffset) {
let offset = startOffset;
@ -74,26 +105,22 @@ export function GetDNSAnswer(msg, startOffset) {
switch (type) {
case TypePtr: {
const domainResult = parseDnsName(msg, offset);
const ret = {
interface: "ptr",
type: type,
ttlSeconds: ttlSeconds,
name: parsedResult.name,
endOffset: offset + rDataLength,
domain: domainResult.name,
};
const ret = new PtrRecord();
ret.type = type;
ret.ttlSeconds = ttlSeconds;
ret.name = parsedResult.name;
ret.endOffset = offset + rDataLength;
ret.domain = domainResult.name;
return ret;
}
case TypeTxt: {
const textResult = parseDnsName(msg, offset);
const ret = {
interface: "txt",
type: type,
ttlSeconds: ttlSeconds,
name: parsedResult.name,
endOffset: offset + rDataLength,
text: textResult.name,
};
const ret = new TxtRecord();
ret.type = type;
ret.ttlSeconds = ttlSeconds;
ret.name = parsedResult.name;
ret.endOffset = offset + rDataLength;
ret.text = textResult.name;
return ret;
}
case TypeSrv: {
@ -101,35 +128,25 @@ export function GetDNSAnswer(msg, startOffset) {
const weight = msg.readUInt16BE(offset + 2);
const port = msg.readUInt16BE(offset + 4);
const targetResult = parseDnsName(msg, offset + 6);
const ret = {
interface: "srv",
type: type,
ttlSeconds: ttlSeconds,
name: parsedResult.name,
endOffset: offset + rDataLength,
priority: priority,
weight: weight,
port: port,
target: targetResult.name,
};
const ret = new SrvRecord();
ret.type = type;
ret.ttlSeconds = ttlSeconds;
ret.name = parsedResult.name;
ret.endOffset = offset + rDataLength;
ret.priority = priority;
ret.weight = weight;
ret.port = port;
ret.target = targetResult.name;
return ret;
}
case TypeA: {
const o1 = msg.readUInt8(offset);
const o2 = msg.readUInt8(offset + 1);
const o3 = msg.readUInt8(offset + 2);
const o4 = msg.readUInt8(offset + 3);
const address = (o1 << 24) | (o2 << 16) | (o3 << 8) | (o4 << 0);
const addressStr = `${o1.toString()}.${o2.toString()}.${o3.toString()}.${o4.toString()}`;
const ret = {
interface: "a",
type: type,
ttlSeconds: ttlSeconds,
name: parsedResult.name,
endOffset: offset + rDataLength,
address: address,
addressStr: addressStr,
};
const address = msg.readUInt32BE(offset);
const ret = new ARecord();
ret.type = type;
ret.ttlSeconds = ttlSeconds;
ret.name = parsedResult.name;
ret.endOffset = offset + rDataLength;
ret.address = address;
return ret;
}
default:

2
dist/dns.js.map vendored

File diff suppressed because one or more lines are too long

27
dist/finder.d.ts vendored
View File

@ -1,13 +1,32 @@
import * as dgram from "dgram";
import { EventEmitter } from "events";
export declare class UnitInfo {
name: string;
hostname: string;
port: number;
address: number;
get addressStr(): string;
constructor(_name: string, _hostname: string, _port: number, _address: number);
}
export declare class FindUnits extends EventEmitter {
constructor();
private finder;
private bound;
private message;
private units;
/**
* Begins a search and returns immediately. Must close the finder with close() when done with all searches.
*/
search(): void;
searchAsync(searchTimeMs?: number): Promise<void>;
foundServer(msg: Buffer, remote: dgram.RemoteInfo): void;
sendServerBroadcast(): void;
/**
* Searches for the given amount of time. Must close the finder with close() when done with all searches.
* @param searchTimeMs the number of milliseconds to search before giving up and returning found results (default: 5000)
* @returns Promise resolving to a list of discovered units, if any.
*/
searchAsync(searchTimeMs?: number): Promise<UnitInfo[]>;
private foundServer;
private sendServerBroadcast;
/**
* Closes the finder socket.
*/
close(): void;
}

138
dist/finder.js vendored
View File

@ -1,35 +1,28 @@
import * as dgram from "dgram";
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { createSocket } from "dgram";
import { EventEmitter } from "events";
import { setTimeout as setTimeoutSync } from "timers";
import os from "os";
import { GetDNSAnswer, GetDNSQuestion } from "./dns.js";
import debug from "debug";
import { ARecord, GetDNSAnswer, GetDNSQuestion, ipToString, PtrRecord, SrvRecord, } from "./dns.js";
const debugFind = debug("ic:find");
export class UnitInfo {
name;
hostname;
port;
address;
get addressStr() {
return ipToString(this.address);
}
constructor(_name, _hostname, _port, _address) {
this.name = _name;
this.hostname = _hostname;
this.port = _port;
this.address = _address;
}
}
export class FindUnits extends EventEmitter {
constructor() {
super();
const infs = os.networkInterfaces();
const localIps = [];
let localIp = "127.0.0.1";
Object.keys(infs).forEach((key) => {
infs[key]?.forEach((iface) => {
if (iface.internal) {
return;
}
if (iface.family !== "IPv4") {
return;
}
localIps.push(iface.address);
});
});
if (localIps.length === 0) {
console.error(`no local interfaces found, can't search for controllers.`);
// todo: emit error
}
else {
localIp = localIps[0];
}
if (localIps.length > 1) {
console.log(`found ${localIps.length.toString()} local IPs, using the first one for SSDP search (${localIp})`);
}
// construct mDNS packet to ping for intellicenter controllers
this.message = Buffer.alloc(34);
let offset = 0;
@ -48,7 +41,7 @@ export class FindUnits extends EventEmitter {
offset = this.message.writeUInt8(0, offset); // no more strings
offset = this.message.writeUInt16BE(0x000c, offset); // type: ptr
this.message.writeUInt16BE(1, offset); // class: IN
this.finder = dgram.createSocket("udp4");
this.finder = createSocket("udp4");
this.finder
.on("listening", () => {
this.finder.setBroadcast(true);
@ -58,23 +51,25 @@ export class FindUnits extends EventEmitter {
this.sendServerBroadcast();
}
})
.on("message", (msg, remote) => {
this.foundServer(msg, remote);
.on("message", (msg) => {
this.foundServer(msg);
})
.on("close", () => {
// debugFind("closed");
console.log("closed");
debugFind("Finder socket closed.");
this.emit("close");
})
.on("error", (e) => {
// debugFind("error: %O", e);
console.log("errored");
debugFind("Finder socket error: %O", e);
this.emit("error", e);
});
}
finder;
bound = false;
message;
units = [];
/**
* Begins a search and returns immediately. Must close the finder with close() when done with all searches.
*/
search() {
if (!this.bound) {
this.finder.bind();
@ -83,27 +78,29 @@ export class FindUnits extends EventEmitter {
this.sendServerBroadcast();
}
}
/**
* Searches for the given amount of time. Must close the finder with close() when done with all searches.
* @param searchTimeMs the number of milliseconds to search before giving up and returning found results (default: 5000)
* @returns Promise resolving to a list of discovered units, if any.
*/
async searchAsync(searchTimeMs) {
const p = new Promise((resolve) => {
// debugFind("IntelliCenter finder searching for local units...");
setTimeoutSync(() => {
// if (units.length === 0) {
// debugFind("No units found searching locally.");
// }
setTimeout(() => {
if (this.units.length === 0) {
debugFind("No units found searching locally.");
}
this.removeAllListeners();
resolve(0);
resolve(this.units);
}, searchTimeMs ?? 5000);
this.on("serverFound", () => {
// debugFind(`IntelliCenter found unit ${JSON.stringify(unit)}`);
console.log("found");
// units.push(unit);
this.on("serverFound", (unit) => {
debugFind(" found: %o", unit);
this.units.push(unit);
});
this.search();
});
return Promise.resolve(p);
return p;
}
foundServer(msg, remote) {
// debugFind("found something");
foundServer(msg) {
let flags = 0;
if (msg.length > 4) {
flags = msg.readUInt16BE(2);
@ -128,6 +125,7 @@ export class FindUnits extends EventEmitter {
if (msg.length >= 8) {
answers = msg.readUInt16BE(6);
}
const records = [];
if (answers > 0) {
for (let i = 0; i < answers; i++) {
if (msg.length <= nextAnswerOffset) {
@ -138,45 +136,33 @@ export class FindUnits extends EventEmitter {
if (!answer) {
break;
}
records.push(answer);
nextAnswerOffset = answer.endOffset;
if (answer.interface === "a") {
console.log("a record:", answer);
}
}
}
const str = msg.toString();
console.log(str);
if (msg.length >= 40) {
const server = {
address: remote.address,
type: msg.readInt32LE(0),
port: msg.readInt16LE(8),
gatewayType: msg.readUInt8(10),
gatewaySubtype: msg.readUInt8(11),
gatewayName: msg.toString("utf8", 12, 29),
};
// debugFind(
// " type: " +
// server.type +
// ", host: " +
// server.address +
// ":" +
// server.port +
// ", identified as " +
// server.gatewayName,
// );
if (server.type === 2) {
this.emit("serverFound", server);
if (records.find((r) => r.name.startsWith("Pentair -i"))) {
const srv = records.find((r) => r instanceof SrvRecord);
const a = records.find((r) => r instanceof ARecord);
if (!srv || !a) {
return;
}
const unit = new UnitInfo(srv.name, a.name, srv.port, a.address);
this.emit("serverFound", unit);
}
else {
// debugFind(" unexpected message");
debugFind(" found something that wasn't an IntelliCenter unit: %s", records
.filter((r) => r instanceof PtrRecord)
.map((r) => r.domain)
.join(", "));
}
}
sendServerBroadcast() {
this.finder.send(this.message, 0, this.message.length, 5353, "224.0.0.251");
// debugFind("Looking for IntelliCenter hosts...");
debugFind("Looking for IntelliCenter hosts...");
}
/**
* Closes the finder socket.
*/
close() {
this.finder.close();
}

2
dist/finder.js.map vendored

File diff suppressed because one or more lines are too long

18
dist/index.js vendored
View File

@ -4,12 +4,20 @@ import { v4 as uuidv4 } from "uuid";
import { FindUnits } from "./finder.js";
console.log("searching...");
const f = new FindUnits();
await f.searchAsync(5000);
// temp. replace with the IP of your device
const endpoint = "10.0.0.41";
const units = await f.searchAsync(1000);
f.close();
console.log("Discovered units:", units);
if (units.length === 0) {
throw new Error("no IntelliCenter units found, exiting.");
}
if (units.length > 1) {
throw new Error(`found more than one IntelliCenter unit, unsure which one to use. ${JSON.stringify(units)}`);
}
const endpoint = units[0].addressStr;
const port = units[0].port;
let pingTimeout;
console.log("connecting to intellicenter device at", endpoint);
const client = new WebSocket(`ws://${endpoint}:6680`);
console.log("connecting to intellicenter device at", endpoint, "port", port);
const client = new WebSocket(`ws://${endpoint}:${port.toString()}`);
const heartbeat = () => {
clearTimeout(pingTimeout);
pingTimeout = setTimeout(() => {

2
dist/index.js.map vendored
View File

@ -1 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,OAAO,EAAE,SAAS,EAAE,MAAM,IAAI,CAAC;AAC/B,OAAO,EAAE,EAAE,IAAI,MAAM,EAAE,MAAM,MAAM,CAAC;AACpC,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAExC,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;AAC5B,MAAM,CAAC,GAAG,IAAI,SAAS,EAAE,CAAC;AAC1B,MAAM,CAAC,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC;AAE1B,2CAA2C;AAC3C,MAAM,QAAQ,GAAG,WAAW,CAAC;AAE7B,IAAI,WAA0C,CAAC;AAE/C,OAAO,CAAC,GAAG,CAAC,uCAAuC,EAAE,QAAQ,CAAC,CAAC;AAC/D,MAAM,MAAM,GAAG,IAAI,SAAS,CAAC,QAAQ,QAAQ,OAAO,CAAC,CAAC;AAEtD,MAAM,SAAS,GAAG,GAAG,EAAE;IACrB,YAAY,CAAC,WAAW,CAAC,CAAC;IAE1B,WAAW,GAAG,UAAU,CAAC,GAAG,EAAE;QAC5B,MAAM,CAAC,SAAS,EAAE,CAAC;IACrB,CAAC,EAAE,KAAK,GAAG,IAAI,CAAC,CAAC;AACnB,CAAC,CAAC;AAEF,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,CAAC,KAAK,CAAC,CAAC;AAClC,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;AAC7B,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;AAC7B,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE;IACtB,YAAY,CAAC,WAAW,CAAC,CAAC;AAC5B,CAAC,CAAC,CAAC;AAEH,MAAM,CAAC,EAAE,CAAC,SAAS,EAAE,CAAC,GAAW,EAAE,EAAE;IACnC,MAAM,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,8DAA8D;IAC1G,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC;AAChD,CAAC,CAAC,CAAC;AACH,MAAM,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;IACpC,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;IAC7B,MAAM,CAAC,IAAI,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAC/B,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE;IACX,OAAO,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;IACzB,OAAO,CAAC,GAAG,CAAC,oBAAoB,CAAC,CAAC;IAClC,MAAM,GAAG,GAAG;QACV,SAAS,EAAE,EAAE;QACb,UAAU,EAAE;YACV;gBACE,MAAM,EAAE,OAAO;gBACf,IAAI,EAAE;oBACJ,KAAK;oBACL,MAAM;oBACN,KAAK;oBACL,QAAQ;oBACR,UAAU;oBACV,MAAM;oBACN,SAAS;oBACT,MAAM;oBACN,OAAO;oBACP,OAAO;oBACP,QAAQ;oBACR,OAAO;oBACP,QAAQ;oBACR,SAAS;oBACT,OAAO;oBACP,MAAM;oBACN,MAAM;oBACN,OAAO;oBACP,SAAS;oBACT,QAAQ;oBACR,UAAU;oBACV,IAAI;oBACJ,OAAO;oBACP,SAAS;iBACV;aACF;SACF;QACD,OAAO,EAAE,cAAc;QACvB,SAAS,EAAE,MAAM,EAAE;KACpB,CAAC;IACF,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC;IACjC,MAAM,CAAC,KAAK,EAAE,CAAC;AACjB,CAAC,CAAC,CAAC"}
{"version":3,"file":"index.js","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"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"}

158
dns.ts
View File

@ -1,52 +1,52 @@
export const TypeTxt = 16;
export const TypePtr = 12;
export const TypeSrv = 33;
export const TypeA = 1;
const TypeTxt = 16;
const TypePtr = 12;
const TypeSrv = 33;
const TypeA = 1;
export class Question {
public name = "";
public type = 0;
public class = 0;
public endOffset = 0;
}
constructor(_name: string, _type: number, _cls: number, _endOffset: number) {
this.name = _name;
this.type = _type;
this.class = _cls;
this.endOffset = _endOffset;
export abstract class Record {
public type = 0;
public ttlSeconds = 0;
public name = "";
public endOffset = -1;
}
export class PtrRecord extends Record {
public domain = "";
}
export class TxtRecord extends Record {
public text = "";
}
export class SrvRecord extends Record {
public priority = 0;
public weight = 0;
public port = 0;
public target = "";
}
export class ARecord extends Record {
public address = 0;
public get addressStr(): string {
return ipToString(this.address);
}
}
export interface Record {
interface: string | undefined;
type: number;
ttlSeconds: number;
name: string;
endOffset: number;
}
export interface PtrRecord extends Record {
interface: "ptr";
domain: string;
}
export interface TxtRecord extends Record {
interface: "txt";
text: string;
}
export interface SrvRecord extends Record {
interface: "srv";
priority: number;
weight: number;
port: number;
target: string;
}
export interface ARecord extends Record {
interface: "a";
address: number;
addressStr: string;
export function ipToString(ip: number): string {
const o1 = (ip >> 24) & 0xff;
const o2 = (ip >> 16) & 0xff;
const o3 = (ip >> 8) & 0xff;
const o4 = (ip >> 0) & 0xff;
const addressStr = `${o1.toString()}.${o2.toString()}.${o3.toString()}.${o4.toString()}`;
return addressStr;
}
class dnsAnswerParseResult {
@ -99,7 +99,13 @@ export function GetDNSQuestion(msg: Buffer, startOffset: number): Question {
offset += 2;
const cls = msg.readUInt16BE(offset);
offset += 2;
return new Question(parsedResult.name, type, cls, offset);
const ret = new Question();
ret.name = parsedResult.name;
ret.type = type;
ret.class = cls;
ret.endOffset = offset;
return ret;
}
export function GetDNSAnswer(
@ -121,27 +127,25 @@ export function GetDNSAnswer(
switch (type) {
case TypePtr: {
const domainResult = parseDnsName(msg, offset);
const ret: PtrRecord = {
interface: "ptr",
type: type,
ttlSeconds: ttlSeconds,
name: parsedResult.name,
endOffset: offset + rDataLength,
domain: domainResult.name,
};
const ret = new PtrRecord();
ret.type = type;
ret.ttlSeconds = ttlSeconds;
ret.name = parsedResult.name;
ret.endOffset = offset + rDataLength;
ret.domain = domainResult.name;
return ret;
}
case TypeTxt: {
const textResult = parseDnsName(msg, offset);
const ret: TxtRecord = {
interface: "txt",
type: type,
ttlSeconds: ttlSeconds,
name: parsedResult.name,
endOffset: offset + rDataLength,
text: textResult.name,
};
const ret = new TxtRecord();
ret.type = type;
ret.ttlSeconds = ttlSeconds;
ret.name = parsedResult.name;
ret.endOffset = offset + rDataLength;
ret.text = textResult.name;
return ret;
}
@ -151,37 +155,27 @@ export function GetDNSAnswer(
const port = msg.readUInt16BE(offset + 4);
const targetResult = parseDnsName(msg, offset + 6);
const ret: SrvRecord = {
interface: "srv",
type: type,
ttlSeconds: ttlSeconds,
name: parsedResult.name,
endOffset: offset + rDataLength,
priority: priority,
weight: weight,
port: port,
target: targetResult.name,
};
const ret = new SrvRecord();
ret.type = type;
ret.ttlSeconds = ttlSeconds;
ret.name = parsedResult.name;
ret.endOffset = offset + rDataLength;
ret.priority = priority;
ret.weight = weight;
ret.port = port;
ret.target = targetResult.name;
return ret;
}
case TypeA: {
const o1 = msg.readUInt8(offset);
const o2 = msg.readUInt8(offset + 1);
const o3 = msg.readUInt8(offset + 2);
const o4 = msg.readUInt8(offset + 3);
const address = (o1 << 24) | (o2 << 16) | (o3 << 8) | (o4 << 0);
const addressStr = `${o1.toString()}.${o2.toString()}.${o3.toString()}.${o4.toString()}`;
const address = msg.readUInt32BE(offset);
const ret: ARecord = {
interface: "a",
type: type,
ttlSeconds: ttlSeconds,
name: parsedResult.name,
endOffset: offset + rDataLength,
address: address,
addressStr: addressStr,
};
const ret = new ARecord();
ret.type = type;
ret.ttlSeconds = ttlSeconds;
ret.name = parsedResult.name;
ret.endOffset = offset + rDataLength;
ret.address = address;
return ret;
}

176
finder.ts
View File

@ -1,41 +1,48 @@
import * as dgram from "dgram";
import { Socket } from "dgram";
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { createSocket, Socket } from "dgram";
import { EventEmitter } from "events";
import { setTimeout as setTimeoutSync } from "timers";
import os from "os";
import { GetDNSAnswer, GetDNSQuestion } from "./dns.js";
import debug from "debug";
import {
ARecord,
GetDNSAnswer,
GetDNSQuestion,
ipToString,
PtrRecord,
Record,
SrvRecord,
} from "./dns.js";
const debugFind = debug("ic:find");
export class UnitInfo {
public name: string;
public hostname: string;
public port: number;
public address: number;
public get addressStr(): string {
return ipToString(this.address);
}
public constructor(
_name: string,
_hostname: string,
_port: number,
_address: number,
) {
this.name = _name;
this.hostname = _hostname;
this.port = _port;
this.address = _address;
}
}
export class FindUnits extends EventEmitter {
constructor() {
super();
const infs = os.networkInterfaces();
const localIps: string[] = [];
let localIp = "127.0.0.1";
Object.keys(infs).forEach((key) => {
infs[key]?.forEach((iface) => {
if (iface.internal) {
return;
}
if (iface.family !== "IPv4") {
return;
}
localIps.push(iface.address);
});
});
if (localIps.length === 0) {
console.error(`no local interfaces found, can't search for controllers.`);
// todo: emit error
} else {
localIp = localIps[0];
}
if (localIps.length > 1) {
console.log(
`found ${localIps.length.toString()} local IPs, using the first one for SSDP search (${localIp})`,
);
}
// construct mDNS packet to ping for intellicenter controllers
this.message = Buffer.alloc(34);
let offset = 0;
@ -55,7 +62,7 @@ export class FindUnits extends EventEmitter {
offset = this.message.writeUInt16BE(0x000c, offset); // type: ptr
this.message.writeUInt16BE(1, offset); // class: IN
this.finder = dgram.createSocket("udp4");
this.finder = createSocket("udp4");
this.finder
.on("listening", () => {
this.finder.setBroadcast(true);
@ -66,17 +73,15 @@ export class FindUnits extends EventEmitter {
this.sendServerBroadcast();
}
})
.on("message", (msg, remote) => {
this.foundServer(msg, remote);
.on("message", (msg) => {
this.foundServer(msg);
})
.on("close", () => {
// debugFind("closed");
console.log("closed");
debugFind("Finder socket closed.");
this.emit("close");
})
.on("error", (e) => {
// debugFind("error: %O", e);
console.log("errored");
debugFind("Finder socket error: %O", e);
this.emit("error", e);
});
}
@ -84,8 +89,12 @@ export class FindUnits extends EventEmitter {
private finder: Socket;
private bound = false;
private message: Buffer;
private units: UnitInfo[] = [];
search() {
/**
* Begins a search and returns immediately. Must close the finder with close() when done with all searches.
*/
public search() {
if (!this.bound) {
this.finder.bind();
} else {
@ -93,33 +102,34 @@ export class FindUnits extends EventEmitter {
}
}
public async searchAsync(searchTimeMs?: number): Promise<void> {
const p = new Promise((resolve) => {
// debugFind("IntelliCenter finder searching for local units...");
setTimeoutSync(() => {
// if (units.length === 0) {
// debugFind("No units found searching locally.");
// }
/**
* Searches for the given amount of time. Must close the finder with close() when done with all searches.
* @param searchTimeMs the number of milliseconds to search before giving up and returning found results (default: 5000)
* @returns Promise resolving to a list of discovered units, if any.
*/
public async searchAsync(searchTimeMs?: number): Promise<UnitInfo[]> {
const p = new Promise<UnitInfo[]>((resolve) => {
setTimeout(() => {
if (this.units.length === 0) {
debugFind("No units found searching locally.");
}
this.removeAllListeners();
resolve(0);
resolve(this.units);
}, searchTimeMs ?? 5000);
this.on("serverFound", () => {
// debugFind(`IntelliCenter found unit ${JSON.stringify(unit)}`);
console.log("found");
// units.push(unit);
this.on("serverFound", (unit: UnitInfo) => {
debugFind(" found: %o", unit);
this.units.push(unit);
});
this.search();
});
return Promise.resolve(p) as Promise<void>;
return p;
}
foundServer(msg: Buffer, remote: dgram.RemoteInfo) {
// debugFind("found something");
private foundServer(msg: Buffer) {
let flags = 0;
if (msg.length > 4) {
flags = msg.readUInt16BE(2);
@ -149,6 +159,7 @@ export class FindUnits extends EventEmitter {
answers = msg.readUInt16BE(6);
}
const records: Record[] = [];
if (answers > 0) {
for (let i = 0; i < answers; i++) {
if (msg.length <= nextAnswerOffset) {
@ -163,50 +174,39 @@ export class FindUnits extends EventEmitter {
break;
}
records.push(answer);
nextAnswerOffset = answer.endOffset;
if (answer.interface === "a") {
console.log("a record:", answer);
}
}
}
const str = msg.toString();
console.log(str);
if (msg.length >= 40) {
const server = {
address: remote.address,
type: msg.readInt32LE(0),
port: msg.readInt16LE(8),
gatewayType: msg.readUInt8(10),
gatewaySubtype: msg.readUInt8(11),
gatewayName: msg.toString("utf8", 12, 29),
};
// debugFind(
// " type: " +
// server.type +
// ", host: " +
// server.address +
// ":" +
// server.port +
// ", identified as " +
// server.gatewayName,
// );
if (server.type === 2) {
this.emit("serverFound", server);
if (records.find((r) => r.name.startsWith("Pentair -i"))) {
const srv = records.find((r) => r instanceof SrvRecord);
const a = records.find((r) => r instanceof ARecord);
if (!srv || !a) {
return;
}
const unit = new UnitInfo(srv.name, a.name, srv.port, a.address);
this.emit("serverFound", unit);
} else {
// debugFind(" unexpected message");
debugFind(
" found something that wasn't an IntelliCenter unit: %s",
records
.filter((r) => r instanceof PtrRecord)
.map((r) => r.domain)
.join(", "),
);
}
}
sendServerBroadcast() {
private sendServerBroadcast() {
this.finder.send(this.message, 0, this.message.length, 5353, "224.0.0.251");
// debugFind("Looking for IntelliCenter hosts...");
debugFind("Looking for IntelliCenter hosts...");
}
/**
* Closes the finder socket.
*/
public close() {
this.finder.close();
}

View File

@ -6,15 +6,27 @@ import { FindUnits } from "./finder.js";
console.log("searching...");
const f = new FindUnits();
await f.searchAsync(5000);
const units = await f.searchAsync(1000);
f.close();
console.log("Discovered units:", units);
// temp. replace with the IP of your device
const endpoint = "10.0.0.41";
if (units.length === 0) {
throw new Error("no IntelliCenter units found, exiting.");
}
if (units.length > 1) {
throw new Error(
`found more than one IntelliCenter unit, unsure which one to use. ${JSON.stringify(units)}`,
);
}
const endpoint = units[0].addressStr;
const port = units[0].port;
let pingTimeout: ReturnType<typeof setTimeout>;
console.log("connecting to intellicenter device at", endpoint);
const client = new WebSocket(`ws://${endpoint}:6680`);
console.log("connecting to intellicenter device at", endpoint, "port", port);
const client = new WebSocket(`ws://${endpoint}:${port.toString()}`);
const heartbeat = () => {
clearTimeout(pingTimeout);

49
package-lock.json generated
View File

@ -9,14 +9,17 @@
"version": "0.0.1",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"uuid": "^11.0.3",
"ws": "^8.18.0"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@types/debug": "^4.1.12",
"@types/ws": "^8.5.13",
"eslint": "^9.17.0",
"prettier": "3.4.2",
"supports-color": "^10.0.0",
"typescript": "^5.7.2",
"typescript-eslint": "^8.19.0"
},
@ -255,6 +258,16 @@
"node": ">= 8"
}
},
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
"integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/ms": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
@ -269,6 +282,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/ms": {
"version": "0.7.34",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz",
"integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.10.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz",
@ -630,6 +650,19 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chalk/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -676,7 +709,6 @@
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@ -1231,7 +1263,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/natural-compare": {
@ -1501,16 +1532,16 @@
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.0.0.tgz",
"integrity": "sha512-HRVVSbCCMbj7/kdWF9Q+bbckjBHLtHMEoJWlkmYzzdwhYMkjkOwubLM6t7NbWKjgKamGDrWL1++KrjUO1t9oAQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/to-regex-range": {

View File

@ -30,14 +30,17 @@
"lint": "eslint . && prettier . --check"
},
"dependencies": {
"debug": "^4.4.0",
"uuid": "^11.0.3",
"ws": "^8.18.0"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@types/debug": "^4.1.12",
"@types/ws": "^8.5.13",
"eslint": "^9.17.0",
"prettier": "3.4.2",
"supports-color": "^10.0.0",
"typescript": "^5.7.2",
"typescript-eslint": "^8.19.0"
},