sharp-echonet 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +167 -0
- package/data/ki-ux75.json +354 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +42 -0
- package/dist/decode-only.d.ts +11 -0
- package/dist/decode-only.js +9 -0
- package/dist/decode.d.ts +40 -0
- package/dist/decode.js +87 -0
- package/dist/echonet.d.ts +51 -0
- package/dist/echonet.js +154 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +4 -0
- package/dist/read.d.ts +28 -0
- package/dist/read.js +63 -0
- package/dist/table.d.ts +78 -0
- package/dist/table.data.d.ts +2 -0
- package/dist/table.data.js +354 -0
- package/dist/table.js +27 -0
- package/package.json +56 -0
package/dist/decode.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { fieldsOf, hexEpc, table } from "./table.js";
|
|
2
|
+
const RANK = {
|
|
3
|
+
confirmed: 3,
|
|
4
|
+
"offset-confirmed": 2,
|
|
5
|
+
probable: 1,
|
|
6
|
+
};
|
|
7
|
+
function readUint(edt, offset, size) {
|
|
8
|
+
let n = 0;
|
|
9
|
+
for (let i = 0; i < size; i++)
|
|
10
|
+
n = n * 256 + edt[offset + i];
|
|
11
|
+
return n;
|
|
12
|
+
}
|
|
13
|
+
function decodeField(edt, f) {
|
|
14
|
+
// A short response means a different firmware laid the property out
|
|
15
|
+
// differently. Returning nothing beats returning a number from the wrong place.
|
|
16
|
+
if (offsetOutOfRange(edt, f))
|
|
17
|
+
return undefined;
|
|
18
|
+
switch (f.type) {
|
|
19
|
+
case "flag":
|
|
20
|
+
return edt[f.offset] === Number(f.trueValue ?? "0xff");
|
|
21
|
+
case "bits":
|
|
22
|
+
return edt[f.offset] >> 4;
|
|
23
|
+
default:
|
|
24
|
+
return readUint(edt, f.offset, f.size);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function offsetOutOfRange(edt, f) {
|
|
28
|
+
return f.offset + f.size > edt.length;
|
|
29
|
+
}
|
|
30
|
+
/** Decode one vendor property (0xF1, 0xF2, 0xF3) into named fields. */
|
|
31
|
+
export function decodeProperty(epc, edt, options = {}) {
|
|
32
|
+
const floor = RANK[options.minConfidence ?? "confirmed"];
|
|
33
|
+
const out = [];
|
|
34
|
+
for (const f of fieldsOf(epc)) {
|
|
35
|
+
if (RANK[f.confidence] < floor)
|
|
36
|
+
continue;
|
|
37
|
+
const usable = f.usable !== false;
|
|
38
|
+
if (!usable && !options.includeUnusable)
|
|
39
|
+
continue;
|
|
40
|
+
const value = decodeField(edt, f);
|
|
41
|
+
if (value === undefined)
|
|
42
|
+
continue;
|
|
43
|
+
out.push({
|
|
44
|
+
name: f.name,
|
|
45
|
+
value,
|
|
46
|
+
...(f.unit ? { unit: f.unit } : {}),
|
|
47
|
+
confidence: f.confidence,
|
|
48
|
+
usable,
|
|
49
|
+
...(f.vendorSlot ? { vendorSlot: f.vendorSlot } : {}),
|
|
50
|
+
...(f.note ? { note: f.note } : {}),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Decode a whole read into one flat object keyed by field name.
|
|
57
|
+
*
|
|
58
|
+
* Properties the table does not describe are skipped rather than guessed at.
|
|
59
|
+
*/
|
|
60
|
+
export function decodeAll(props, options = {}) {
|
|
61
|
+
const out = {};
|
|
62
|
+
for (const [epc, edt] of props) {
|
|
63
|
+
if (!table.properties[hexEpc(epc)])
|
|
64
|
+
continue;
|
|
65
|
+
for (const field of decodeProperty(epc, edt, options))
|
|
66
|
+
out[field.name] = field;
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Whether the tank needs a refill.
|
|
72
|
+
*
|
|
73
|
+
* Returns undefined when the byte is missing, never false. "I could not read it"
|
|
74
|
+
* and "there is water" are different answers, and collapsing them means a dry
|
|
75
|
+
* machine reports as full.
|
|
76
|
+
*
|
|
77
|
+
* Anything other than the water-present value counts as empty. Only two values
|
|
78
|
+
* have ever been observed, so an unrecognised third one is more safely treated
|
|
79
|
+
* as a refill than as a full tank.
|
|
80
|
+
*/
|
|
81
|
+
export function waterTankEmpty(props) {
|
|
82
|
+
const edt = props.get(0xf2);
|
|
83
|
+
const field = fieldsOf(0xf2).find((f) => f.name === "waterPresent");
|
|
84
|
+
if (!edt || !field || edt.length <= field.offset)
|
|
85
|
+
return undefined;
|
|
86
|
+
return edt[field.offset] !== Number(field.trueValue ?? "0xff");
|
|
87
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export declare const EL_PORT = 3610;
|
|
2
|
+
export declare const EL_MULTICAST = "224.0.23.0";
|
|
3
|
+
/** Air cleaner, class group 0x01, class 0x35, instance 1. */
|
|
4
|
+
export declare const EOJ_AIR_CLEANER: Uint8Array<ArrayBuffer>;
|
|
5
|
+
/** Node profile. The target for discovery. */
|
|
6
|
+
export declare const EOJ_NODE_PROFILE: Uint8Array<ArrayBuffer>;
|
|
7
|
+
export interface Frame {
|
|
8
|
+
tid: number;
|
|
9
|
+
seoj: Uint8Array;
|
|
10
|
+
deoj: Uint8Array;
|
|
11
|
+
esv: number;
|
|
12
|
+
props: Map<number, Uint8Array>;
|
|
13
|
+
}
|
|
14
|
+
export declare function buildFrame(tid: number, deoj: Uint8Array, esv: number, epcs: number[]): Buffer;
|
|
15
|
+
export declare function parseFrame(b: Uint8Array): Frame | null;
|
|
16
|
+
/** Instance list from EPC 0xD6: one count byte, then three bytes per object. */
|
|
17
|
+
export declare function parseInstanceList(edt: Uint8Array): Uint8Array[];
|
|
18
|
+
export interface ClientOptions {
|
|
19
|
+
/** Milliseconds to wait for one reply. */
|
|
20
|
+
timeoutMs?: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A minimal ECHONET Lite client.
|
|
24
|
+
*
|
|
25
|
+
* Two things about this protocol cost real time to discover, so they are worth
|
|
26
|
+
* stating plainly:
|
|
27
|
+
*
|
|
28
|
+
* - Replies come back to port 3610, not to the port the request went out from.
|
|
29
|
+
* Waiting on an ephemeral port times out every time.
|
|
30
|
+
* - Joining the multicast group is unnecessary. Discovery goes out as multicast
|
|
31
|
+
* and devices answer by unicast.
|
|
32
|
+
*
|
|
33
|
+
* Because 3610 is a shared port, hold one client for the life of the process and
|
|
34
|
+
* demultiplex replies by transaction id rather than opening a socket per request.
|
|
35
|
+
*/
|
|
36
|
+
export declare class EchonetClient {
|
|
37
|
+
private socket;
|
|
38
|
+
private tid;
|
|
39
|
+
private pending;
|
|
40
|
+
private timeoutMs;
|
|
41
|
+
private ready;
|
|
42
|
+
constructor(options?: ClientOptions);
|
|
43
|
+
private nextTid;
|
|
44
|
+
/** Read properties from one device. Unreadable EPCs come back with empty data. */
|
|
45
|
+
get(ip: string, epcs: number[], deoj?: Uint8Array): Promise<Map<number, Uint8Array>>;
|
|
46
|
+
/** Ask a node which objects it hosts. */
|
|
47
|
+
instances(ip: string): Promise<Uint8Array[]>;
|
|
48
|
+
/** Multicast for air cleaners and collect the addresses that answer. */
|
|
49
|
+
discover(windowMs?: number): Promise<string[]>;
|
|
50
|
+
close(): void;
|
|
51
|
+
}
|
package/dist/echonet.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { createSocket } from "node:dgram";
|
|
2
|
+
export const EL_PORT = 3610;
|
|
3
|
+
export const EL_MULTICAST = "224.0.23.0";
|
|
4
|
+
const EHD1 = 0x10;
|
|
5
|
+
const EHD2 = 0x81;
|
|
6
|
+
const ESV_GET = 0x62;
|
|
7
|
+
const ESV_GET_RES = 0x72;
|
|
8
|
+
const ESV_GET_SNA = 0x52;
|
|
9
|
+
/** Air cleaner, class group 0x01, class 0x35, instance 1. */
|
|
10
|
+
export const EOJ_AIR_CLEANER = Uint8Array.from([0x01, 0x35, 0x01]);
|
|
11
|
+
/** Node profile. The target for discovery. */
|
|
12
|
+
export const EOJ_NODE_PROFILE = Uint8Array.from([0x0e, 0xf0, 0x01]);
|
|
13
|
+
/** Controller. What this library calls itself. */
|
|
14
|
+
const EOJ_CONTROLLER = Uint8Array.from([0x05, 0xff, 0x01]);
|
|
15
|
+
const EPC_INSTANCE_LIST = 0xd6;
|
|
16
|
+
export function buildFrame(tid, deoj, esv, epcs) {
|
|
17
|
+
const head = [EHD1, EHD2, tid >> 8, tid & 0xff, ...EOJ_CONTROLLER, ...deoj, esv, epcs.length];
|
|
18
|
+
const body = [];
|
|
19
|
+
for (const epc of epcs)
|
|
20
|
+
body.push(epc, 0);
|
|
21
|
+
return Buffer.from([...head, ...body]);
|
|
22
|
+
}
|
|
23
|
+
export function parseFrame(b) {
|
|
24
|
+
if (b.length < 12 || b[0] !== EHD1 || b[1] !== EHD2)
|
|
25
|
+
return null;
|
|
26
|
+
const props = new Map();
|
|
27
|
+
const opc = b[11];
|
|
28
|
+
let i = 12;
|
|
29
|
+
for (let n = 0; n < opc; n++) {
|
|
30
|
+
if (i + 2 > b.length)
|
|
31
|
+
return null;
|
|
32
|
+
const epc = b[i];
|
|
33
|
+
const pdc = b[i + 1];
|
|
34
|
+
i += 2;
|
|
35
|
+
if (i + pdc > b.length)
|
|
36
|
+
return null;
|
|
37
|
+
props.set(epc, b.slice(i, i + pdc));
|
|
38
|
+
i += pdc;
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
tid: (b[2] << 8) | b[3],
|
|
42
|
+
seoj: b.slice(4, 7),
|
|
43
|
+
deoj: b.slice(7, 10),
|
|
44
|
+
esv: b[10],
|
|
45
|
+
props,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** Instance list from EPC 0xD6: one count byte, then three bytes per object. */
|
|
49
|
+
export function parseInstanceList(edt) {
|
|
50
|
+
if (edt.length < 1)
|
|
51
|
+
return [];
|
|
52
|
+
const out = [];
|
|
53
|
+
for (let i = 0; i < edt[0]; i++) {
|
|
54
|
+
const off = 1 + i * 3;
|
|
55
|
+
if (off + 3 > edt.length)
|
|
56
|
+
break;
|
|
57
|
+
out.push(edt.slice(off, off + 3));
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* A minimal ECHONET Lite client.
|
|
63
|
+
*
|
|
64
|
+
* Two things about this protocol cost real time to discover, so they are worth
|
|
65
|
+
* stating plainly:
|
|
66
|
+
*
|
|
67
|
+
* - Replies come back to port 3610, not to the port the request went out from.
|
|
68
|
+
* Waiting on an ephemeral port times out every time.
|
|
69
|
+
* - Joining the multicast group is unnecessary. Discovery goes out as multicast
|
|
70
|
+
* and devices answer by unicast.
|
|
71
|
+
*
|
|
72
|
+
* Because 3610 is a shared port, hold one client for the life of the process and
|
|
73
|
+
* demultiplex replies by transaction id rather than opening a socket per request.
|
|
74
|
+
*/
|
|
75
|
+
export class EchonetClient {
|
|
76
|
+
socket;
|
|
77
|
+
tid = 0;
|
|
78
|
+
pending = new Map();
|
|
79
|
+
timeoutMs;
|
|
80
|
+
ready;
|
|
81
|
+
constructor(options = {}) {
|
|
82
|
+
this.timeoutMs = options.timeoutMs ?? 3000;
|
|
83
|
+
this.socket = createSocket({ type: "udp4", reuseAddr: true });
|
|
84
|
+
this.socket.on("message", (msg, rinfo) => {
|
|
85
|
+
const frame = parseFrame(msg);
|
|
86
|
+
if (!frame)
|
|
87
|
+
return;
|
|
88
|
+
this.pending.get(frame.tid)?.({ from: rinfo.address, frame });
|
|
89
|
+
});
|
|
90
|
+
this.ready = new Promise((resolve, reject) => {
|
|
91
|
+
this.socket.once("error", reject);
|
|
92
|
+
this.socket.bind(EL_PORT, () => resolve());
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
nextTid() {
|
|
96
|
+
this.tid = (this.tid + 1) & 0xffff || 1;
|
|
97
|
+
return this.tid;
|
|
98
|
+
}
|
|
99
|
+
/** Read properties from one device. Unreadable EPCs come back with empty data. */
|
|
100
|
+
async get(ip, epcs, deoj = EOJ_AIR_CLEANER) {
|
|
101
|
+
await this.ready;
|
|
102
|
+
const tid = this.nextTid();
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
const timer = setTimeout(() => {
|
|
105
|
+
this.pending.delete(tid);
|
|
106
|
+
reject(new Error(`${ip} did not answer`));
|
|
107
|
+
}, this.timeoutMs);
|
|
108
|
+
this.pending.set(tid, ({ from, frame }) => {
|
|
109
|
+
if (from !== ip)
|
|
110
|
+
return;
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
this.pending.delete(tid);
|
|
113
|
+
if (frame.esv !== ESV_GET_RES && frame.esv !== ESV_GET_SNA) {
|
|
114
|
+
reject(new Error(`unexpected ESV 0x${frame.esv.toString(16)}`));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
resolve(frame.props);
|
|
118
|
+
});
|
|
119
|
+
this.socket.send(buildFrame(tid, deoj, ESV_GET, epcs), EL_PORT, ip);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/** Ask a node which objects it hosts. */
|
|
123
|
+
async instances(ip) {
|
|
124
|
+
const props = await this.get(ip, [EPC_INSTANCE_LIST], EOJ_NODE_PROFILE);
|
|
125
|
+
const edt = props.get(EPC_INSTANCE_LIST);
|
|
126
|
+
return edt ? parseInstanceList(edt) : [];
|
|
127
|
+
}
|
|
128
|
+
/** Multicast for air cleaners and collect the addresses that answer. */
|
|
129
|
+
async discover(windowMs = 2000) {
|
|
130
|
+
await this.ready;
|
|
131
|
+
const tid = this.nextTid();
|
|
132
|
+
const found = new Set();
|
|
133
|
+
this.pending.set(tid, ({ from, frame }) => {
|
|
134
|
+
const edt = frame.props.get(EPC_INSTANCE_LIST);
|
|
135
|
+
if (!edt)
|
|
136
|
+
return;
|
|
137
|
+
const hit = parseInstanceList(edt).some((eoj) => eoj[0] === 0x01 && eoj[1] === 0x35);
|
|
138
|
+
if (hit)
|
|
139
|
+
found.add(from);
|
|
140
|
+
});
|
|
141
|
+
const pkt = buildFrame(tid, EOJ_NODE_PROFILE, ESV_GET, [EPC_INSTANCE_LIST]);
|
|
142
|
+
// UDP drops happen, so ask more than once.
|
|
143
|
+
for (let i = 0; i < 3; i++) {
|
|
144
|
+
this.socket.send(pkt, EL_PORT, EL_MULTICAST);
|
|
145
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
146
|
+
}
|
|
147
|
+
await new Promise((r) => setTimeout(r, windowMs));
|
|
148
|
+
this.pending.delete(tid);
|
|
149
|
+
return [...found];
|
|
150
|
+
}
|
|
151
|
+
close() {
|
|
152
|
+
this.socket.close();
|
|
153
|
+
}
|
|
154
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { table, fieldsOf, matchesProductCode, hexEpc } from "./table.ts";
|
|
2
|
+
export type { Table, FieldSpec, PropertySpec, Confidence } from "./table.ts";
|
|
3
|
+
export { decodeProperty, decodeAll, waterTankEmpty } from "./decode.ts";
|
|
4
|
+
export type { DecodedField, DecodeOptions } from "./decode.ts";
|
|
5
|
+
export { EchonetClient, EOJ_AIR_CLEANER, EOJ_NODE_PROFILE, EL_PORT, EL_MULTICAST, buildFrame, parseFrame, parseInstanceList, } from "./echonet.ts";
|
|
6
|
+
export type { Frame, ClientOptions } from "./echonet.ts";
|
|
7
|
+
export { read } from "./read.ts";
|
|
8
|
+
export type { Reading } from "./read.ts";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { table, fieldsOf, matchesProductCode, hexEpc } from "./table.js";
|
|
2
|
+
export { decodeProperty, decodeAll, waterTankEmpty } from "./decode.js";
|
|
3
|
+
export { EchonetClient, EOJ_AIR_CLEANER, EOJ_NODE_PROFILE, EL_PORT, EL_MULTICAST, buildFrame, parseFrame, parseInstanceList, } from "./echonet.js";
|
|
4
|
+
export { read } from "./read.js";
|
package/dist/read.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type DecodedField, type DecodeOptions } from "./decode.ts";
|
|
2
|
+
import { EchonetClient } from "./echonet.ts";
|
|
3
|
+
export interface Reading {
|
|
4
|
+
power: "on" | "off" | undefined;
|
|
5
|
+
watts: number | undefined;
|
|
6
|
+
fault: boolean | undefined;
|
|
7
|
+
airPollutionDetected: boolean | undefined;
|
|
8
|
+
/** Undefined when the byte could not be read, which is not the same as full. */
|
|
9
|
+
waterTankEmpty: boolean | undefined;
|
|
10
|
+
fields: Record<string, DecodedField>;
|
|
11
|
+
raw: Map<number, Uint8Array>;
|
|
12
|
+
}
|
|
13
|
+
export interface ReadOptions extends DecodeOptions {
|
|
14
|
+
client?: EchonetClient;
|
|
15
|
+
/**
|
|
16
|
+
* Read EPC 0x8C first and refuse to decode hardware the table does not cover.
|
|
17
|
+
* Leave this on unless you are deliberately probing another model.
|
|
18
|
+
*/
|
|
19
|
+
checkModel?: boolean;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Read one air purifier and decode what the table knows.
|
|
23
|
+
*
|
|
24
|
+
* The model check is on by default because these offsets were established on one
|
|
25
|
+
* model and one firmware. Applying them to different hardware would produce
|
|
26
|
+
* numbers that look plausible and are wrong, which is worse than no reading.
|
|
27
|
+
*/
|
|
28
|
+
export declare function read(ip: string, options?: ReadOptions): Promise<Reading>;
|
package/dist/read.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { decodeAll, waterTankEmpty } from "./decode.js";
|
|
2
|
+
import { EchonetClient } from "./echonet.js";
|
|
3
|
+
import { matchesProductCode } from "./table.js";
|
|
4
|
+
const EPC_OPERATION_STATUS = 0x80;
|
|
5
|
+
const EPC_INSTANT_POWER = 0x84;
|
|
6
|
+
const EPC_FAULT = 0x88;
|
|
7
|
+
const EPC_PRODUCT_CODE = 0x8c;
|
|
8
|
+
const EPC_AIR_POLLUTION = 0xc0;
|
|
9
|
+
const VENDOR_EPCS = [0xf1, 0xf2, 0xf3];
|
|
10
|
+
/**
|
|
11
|
+
* Read one air purifier and decode what the table knows.
|
|
12
|
+
*
|
|
13
|
+
* The model check is on by default because these offsets were established on one
|
|
14
|
+
* model and one firmware. Applying them to different hardware would produce
|
|
15
|
+
* numbers that look plausible and are wrong, which is worse than no reading.
|
|
16
|
+
*/
|
|
17
|
+
export async function read(ip, options = {}) {
|
|
18
|
+
const client = options.client ?? new EchonetClient();
|
|
19
|
+
const owned = !options.client;
|
|
20
|
+
try {
|
|
21
|
+
const epcs = [
|
|
22
|
+
EPC_OPERATION_STATUS,
|
|
23
|
+
EPC_INSTANT_POWER,
|
|
24
|
+
EPC_FAULT,
|
|
25
|
+
EPC_AIR_POLLUTION,
|
|
26
|
+
...VENDOR_EPCS,
|
|
27
|
+
];
|
|
28
|
+
if (options.checkModel !== false)
|
|
29
|
+
epcs.push(EPC_PRODUCT_CODE);
|
|
30
|
+
const props = await client.get(ip, epcs);
|
|
31
|
+
if (options.checkModel !== false) {
|
|
32
|
+
const code = props.get(EPC_PRODUCT_CODE);
|
|
33
|
+
if (!code || !matchesProductCode(code)) {
|
|
34
|
+
throw new Error(`${ip} reports a product code this table does not cover. Pass checkModel: false to decode anyway.`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const status = props.get(EPC_OPERATION_STATUS);
|
|
38
|
+
const power = status?.length === 1 ? (status[0] === 0x30 ? "on" : "off") : undefined;
|
|
39
|
+
const watts = byteLen(props.get(EPC_INSTANT_POWER), 2)
|
|
40
|
+
? (props.get(EPC_INSTANT_POWER)[0] << 8) | props.get(EPC_INSTANT_POWER)[1]
|
|
41
|
+
: undefined;
|
|
42
|
+
return {
|
|
43
|
+
power,
|
|
44
|
+
watts,
|
|
45
|
+
fault: flag(props.get(EPC_FAULT)),
|
|
46
|
+
airPollutionDetected: flag(props.get(EPC_AIR_POLLUTION)),
|
|
47
|
+
waterTankEmpty: waterTankEmpty(props),
|
|
48
|
+
fields: decodeAll(props, options),
|
|
49
|
+
raw: props,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
if (owned)
|
|
54
|
+
client.close();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function byteLen(v, n) {
|
|
58
|
+
return v?.length === n;
|
|
59
|
+
}
|
|
60
|
+
/** ECHONET spells these as 0x41 for yes and 0x42 for no. */
|
|
61
|
+
function flag(v) {
|
|
62
|
+
return v?.length === 1 ? v[0] === 0x41 : undefined;
|
|
63
|
+
}
|
package/dist/table.d.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How much weight a field carries.
|
|
3
|
+
*
|
|
4
|
+
* The distinction matters more than it looks. Two of the byte positions in this
|
|
5
|
+
* table were nearly published as flags before a second experiment showed they
|
|
6
|
+
* were constants that differ per unit. Anything short of `confirmed` should be
|
|
7
|
+
* treated as a lead, not a reading.
|
|
8
|
+
*/
|
|
9
|
+
export type Confidence =
|
|
10
|
+
/** Matched on two units at aligned timestamps, or moved with a deliberate change. */
|
|
11
|
+
"confirmed"
|
|
12
|
+
/** The byte position is certain, but the value is not trustworthy on this model. */
|
|
13
|
+
| "offset-confirmed"
|
|
14
|
+
/** Placed by the ordering of neighbouring fields, never seen to move. */
|
|
15
|
+
| "probable";
|
|
16
|
+
export interface FieldSpec {
|
|
17
|
+
offset: number;
|
|
18
|
+
size: number;
|
|
19
|
+
name: string;
|
|
20
|
+
type: "uint" | "flag" | "bits";
|
|
21
|
+
unit?: string;
|
|
22
|
+
/** Value that makes a `flag` field true, as a hex string such as `0xff`. */
|
|
23
|
+
trueValue?: string;
|
|
24
|
+
bits?: string;
|
|
25
|
+
vendorSlot?: string;
|
|
26
|
+
confidence: Confidence;
|
|
27
|
+
/** False when the field exists but this model cannot produce a meaningful value. */
|
|
28
|
+
usable?: boolean;
|
|
29
|
+
note?: string;
|
|
30
|
+
}
|
|
31
|
+
export interface PropertySpec {
|
|
32
|
+
length?: number;
|
|
33
|
+
fields?: FieldSpec[];
|
|
34
|
+
unresolved?: {
|
|
35
|
+
vendorSlot?: string;
|
|
36
|
+
name?: string;
|
|
37
|
+
note: string;
|
|
38
|
+
}[];
|
|
39
|
+
note?: string;
|
|
40
|
+
}
|
|
41
|
+
export interface Table {
|
|
42
|
+
model: string;
|
|
43
|
+
/** EPC 0x8C (product code) as lowercase hex, used to refuse unknown hardware. */
|
|
44
|
+
productCode: string;
|
|
45
|
+
firmware: string[];
|
|
46
|
+
eoj: string;
|
|
47
|
+
standardVersion: string;
|
|
48
|
+
note: string;
|
|
49
|
+
spec: Record<string, boolean | number>;
|
|
50
|
+
standardProperties: Record<string, {
|
|
51
|
+
name: string;
|
|
52
|
+
unit?: string;
|
|
53
|
+
size?: number;
|
|
54
|
+
note?: string;
|
|
55
|
+
}>;
|
|
56
|
+
properties: Record<string, PropertySpec>;
|
|
57
|
+
traps: {
|
|
58
|
+
where: string;
|
|
59
|
+
claim?: string;
|
|
60
|
+
reality: string;
|
|
61
|
+
}[];
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* data/ki-ux75.json is the source of truth. This reads the TypeScript generated
|
|
65
|
+
* from it rather than the file itself, so that decoding works where there is no
|
|
66
|
+
* filesystem.
|
|
67
|
+
*/
|
|
68
|
+
export declare const table: Table;
|
|
69
|
+
/** Every field of one property, in byte order. */
|
|
70
|
+
export declare function fieldsOf(epc: number): FieldSpec[];
|
|
71
|
+
export declare function hexEpc(epc: number): string;
|
|
72
|
+
/**
|
|
73
|
+
* Whether a product code belongs to the hardware this table describes.
|
|
74
|
+
*
|
|
75
|
+
* EPC 0x8C carries the product code as ASCII padded with zero bytes, so
|
|
76
|
+
* `KIUX75` arrives as `4b4955583735000000000000`. Compare only the prefix.
|
|
77
|
+
*/
|
|
78
|
+
export declare function matchesProductCode(productCode: Uint8Array | string): boolean;
|