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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Soh Satoh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,167 @@
1
+ # sharp-echonet
2
+
3
+ Read a Sharp humidifying air purifier over ECHONET Lite on the local network, and
4
+ decode the vendor-specific properties that carry most of what the machine knows.
5
+
6
+ Worked out on two **KI-UX75** units, firmware `SHARP_B02`.
7
+
8
+ ## Why this exists
9
+
10
+ The standard side of ECHONET Lite gives almost nothing on this hardware. The air
11
+ cleaner class (`0x0135`) defines six class properties and KI-UX75 implements
12
+ three: power, air flow rate, and a dirty-air flag. Temperature, humidity, particle
13
+ count, filter usage and the water tank all sit in the vendor properties `0xF1` to
14
+ `0xF3`, which are not documented anywhere.
15
+
16
+ The standard does define a six-step water level for humidifiers, at EPC `0xC5` of
17
+ the humidifier class. KI-UX75 never announces a humidifier object, so that route
18
+ is closed even though the machine humidifies.
19
+
20
+ ## Install
21
+
22
+ ```
23
+ npm install sharp-echonet
24
+ ```
25
+
26
+ Node 18 or newer, no runtime dependencies.
27
+
28
+ ## Use
29
+
30
+ ```ts
31
+ import { read } from "sharp-echonet";
32
+
33
+ const r = await read("192.168.1.20");
34
+
35
+ r.power; // "on"
36
+ r.waterTankEmpty; // false, or undefined when the byte was unreadable
37
+ r.fields.roomTemperature.value; // 28
38
+ r.fields.numberOfParticles.value; // 1101
39
+ ```
40
+
41
+ ```ts
42
+ import { EchonetClient } from "sharp-echonet";
43
+
44
+ const client = new EchonetClient();
45
+ const ips = await client.discover();
46
+ client.close();
47
+ ```
48
+
49
+ ```
50
+ npx sharp-echonet discover
51
+ npx sharp-echonet read 192.168.1.20
52
+ npx sharp-echonet table
53
+ ```
54
+
55
+ `read` checks the product code (EPC `0x8C`) first and refuses hardware the table
56
+ does not cover, because offsets from one model would produce plausible-looking
57
+ wrong numbers on another. Pass `checkModel: false` to probe anyway.
58
+
59
+ ## What it reads
60
+
61
+ | Field | Property | Notes |
62
+ | --- | --- | --- |
63
+ | `roomTemperature` | `0xF1[3]` | degrees C |
64
+ | `roomHumidity` | `0xF1[4]` | per cent |
65
+ | `brightnessRaw` | `0xF1[2]` | 0 to 255, not lux, non-linear |
66
+ | `lightingLevel` | `0xF1[8]` | three steps, from the top four bits |
67
+ | `totalOperatingTime` | `0xF1[11:15]` | minutes |
68
+ | `cadrUsed` | `0xF1[21:25]` | cumulative volume of air cleaned |
69
+ | `dustFilterUsed` | `0xF1[29:31]` | compare against `dustFilterLimit` of 3000 |
70
+ | `smellFilterUsed` | `0xF1[31:33]` | |
71
+ | `humidFilterUsed` | `0xF1[35:37]` | |
72
+ | `totalHumidificationAmount` | `0xF1[38:40]` | |
73
+ | `numberOfParticles` | `0xF1[40:43]` | particles per litre |
74
+ | `waterPresent` | `0xF2[19]` | `0xff` while the tank has water |
75
+ | `lightSensorFlag` | `0xF2[20]` | `0xff` in a lit room |
76
+ | `humidificationEnabled` | `0xF3[15]` | the setting, not whether it humidifies now |
77
+
78
+ The full table, unresolved fields included, ships as
79
+ [`data/ki-ux75.json`](data/ki-ux75.json) and is importable on its own:
80
+
81
+ ```ts
82
+ import table from "sharp-echonet/table" with { type: "json" };
83
+ ```
84
+
85
+ Porting it to another language is a short job, which is why it is data rather
86
+ than code.
87
+
88
+ Where there is no UDP, such as an edge runtime, fetch the bytes elsewhere and
89
+ unpack them through the decode-only entry point, which pulls in no node built-ins:
90
+
91
+ ```ts
92
+ import { decodeAll, waterTankEmpty } from "sharp-echonet/decode";
93
+ ```
94
+
95
+ ## Confidence
96
+
97
+ Fields carry a confidence level, and `read` returns only `confirmed` ones unless
98
+ asked otherwise.
99
+
100
+ - `confirmed`: matched on two units at aligned timestamps, or moved when
101
+ something was deliberately changed.
102
+ - `probable`: the position follows from its neighbours and was never seen to move.
103
+ - `offset-confirmed`: the byte position is certain, the value is not trustworthy
104
+ on this model.
105
+
106
+ That last case is real. The vendor app names `0xF1[27:29]` as a PM2.5 reading and
107
+ the bytes do move, but the device reports no PM2.5 sensor, so publishing it as a
108
+ measurement would be inventing a number.
109
+
110
+ ```ts
111
+ await read(ip, { minConfidence: "probable", includeUnusable: true });
112
+ ```
113
+
114
+ ## Traps
115
+
116
+ - `0xF3[5]` and `0xF2[39]` read `0x00` on one unit and `0x01` on the other, which
117
+ makes them look like flags. They are per-unit constants, and neither moved when
118
+ the tank was refilled or humidification was toggled. Comparing two machines is
119
+ not enough to call a byte a flag.
120
+ - `0xF1[42]` drifts and never returns, so alone it reads as noise. It is the low
121
+ byte of the three-byte particle count at offset 40.
122
+ - `0xF1[14]` counts up once a minute, `0xF1[24]` roughly every 43 seconds.
123
+ - Writes to `0xA0` are answered with ESV `0x71` and then ignored, and power draw
124
+ does not change. Power is the only write this hardware honours.
125
+
126
+ ## The tank
127
+
128
+ Nothing on the vendor side reports a refill: the machine keeps running, the app
129
+ has no such wording, and `0xF2[19]` is the one slot in its group the app never
130
+ reads. Only the panel indicator shows it.
131
+
132
+ The byte was pinned by switching humidification off while water remained, which
133
+ separates having water from humidifying. Those states overlap completely
134
+ otherwise, since a dry machine cannot humidify. Pulling the tank out does not
135
+ change it, so the sensor appears to watch the tray and routine cleaning will not
136
+ raise a false refill.
137
+
138
+ Anything other than `0xff` counts as empty. Only two values have ever been seen,
139
+ and treating an unknown third one as a full tank would fail silently in the one
140
+ direction that matters.
141
+
142
+ ## Protocol notes
143
+
144
+ - Replies arrive on port 3610, not on the port the request went out from. Waiting
145
+ on an ephemeral port times out every time.
146
+ - Joining the multicast group is unnecessary. Discovery goes out as multicast and
147
+ devices answer by unicast.
148
+ - Since 3610 is shared, keep one `EchonetClient` per process and let it
149
+ demultiplex replies by transaction id.
150
+
151
+ ## Scope and disclaimer
152
+
153
+ This project has no connection with Sharp Corporation and is neither endorsed nor
154
+ supported by it. Product and company names belong to their owners.
155
+
156
+ The offsets were derived by observing the author's own devices on the author's own
157
+ network. They are not published by the manufacturer, they are not a specification,
158
+ and a firmware update can invalidate any of them. Treat the table as findings that
159
+ held on two units at a point in time. The product code check exists so that a
160
+ mismatch fails loudly instead of returning numbers from the wrong place.
161
+
162
+ The library reads. Power is the only write the hardware honours and it is not
163
+ wrapped here. No credentials are included and nothing depends on the
164
+ manufacturer's cloud at runtime.
165
+
166
+ Provided as is, without warranty of any kind, under the MIT licence. Anyone using
167
+ it does so at their own risk.
@@ -0,0 +1,354 @@
1
+ {
2
+ "model": "KIUX75",
3
+ "productCode": "4b4955583735",
4
+ "firmware": [
5
+ "SHARP_B02"
6
+ ],
7
+ "eoj": "0x013501",
8
+ "standardVersion": "I",
9
+ "note": "Field offsets for the vendor-specific properties of the Sharp KI-UX75 humidifying air purifier. Confirmed against two units. Offsets are byte positions inside the EDT of the given EPC.",
10
+ "spec": {
11
+ "hasDustSensor": true,
12
+ "hasPM25Sensor": false,
13
+ "hasCO2Sensor": false,
14
+ "hasLightSensor": true,
15
+ "hasHumidFunc": true,
16
+ "dustFilterLimit": 3000
17
+ },
18
+ "standardProperties": {
19
+ "0x80": {
20
+ "name": "operationStatus",
21
+ "note": "0x30 on, 0x31 off. Readable and writable."
22
+ },
23
+ "0x84": {
24
+ "name": "instantaneousPower",
25
+ "unit": "W",
26
+ "size": 2
27
+ },
28
+ "0x88": {
29
+ "name": "faultStatus",
30
+ "note": "0x41 fault, 0x42 normal"
31
+ },
32
+ "0xA0": {
33
+ "name": "airFlowRate",
34
+ "note": "Writes are acknowledged with ESV 0x71 and then ignored. Power draw does not change."
35
+ },
36
+ "0xC0": {
37
+ "name": "airPollutionDetected",
38
+ "note": "0x41 detected, 0x42 not detected"
39
+ }
40
+ },
41
+ "properties": {
42
+ "0xF1": {
43
+ "length": 43,
44
+ "fields": [
45
+ {
46
+ "offset": 2,
47
+ "size": 1,
48
+ "name": "brightnessRaw",
49
+ "type": "uint",
50
+ "confidence": "confirmed",
51
+ "note": "Not lux. A non-linear 0-255 scale: 199-202 fully lit, 145 at dusk with no lights, 0 in the dark. The vendor app does not read this byte; it uses the three-level flag at offset 8 instead."
52
+ },
53
+ {
54
+ "offset": 3,
55
+ "size": 1,
56
+ "name": "roomTemperature",
57
+ "type": "uint",
58
+ "unit": "degC",
59
+ "vendorSlot": "k1.s1",
60
+ "confidence": "confirmed"
61
+ },
62
+ {
63
+ "offset": 4,
64
+ "size": 1,
65
+ "name": "roomHumidity",
66
+ "type": "uint",
67
+ "unit": "%",
68
+ "vendorSlot": "k1.s2",
69
+ "confidence": "confirmed"
70
+ },
71
+ {
72
+ "offset": 8,
73
+ "size": 1,
74
+ "name": "lightingLevel",
75
+ "type": "bits",
76
+ "bits": "high 4",
77
+ "vendorSlot": "k1.s15",
78
+ "confidence": "confirmed",
79
+ "note": "The vendor app reads the top four bits and maps them to off / dark / bright."
80
+ },
81
+ {
82
+ "offset": 11,
83
+ "size": 4,
84
+ "name": "totalOperatingTime",
85
+ "type": "uint",
86
+ "unit": "minutes",
87
+ "vendorSlot": "k1.s3",
88
+ "confidence": "confirmed",
89
+ "note": "Advanced by exactly 30 across a 30 minute gap."
90
+ },
91
+ {
92
+ "offset": 15,
93
+ "size": 2,
94
+ "name": "pciUnit1Used",
95
+ "type": "uint",
96
+ "vendorSlot": "k1.s4",
97
+ "confidence": "confirmed"
98
+ },
99
+ {
100
+ "offset": 17,
101
+ "size": 2,
102
+ "name": "pciUnit2Used",
103
+ "type": "uint",
104
+ "vendorSlot": "k1.s5",
105
+ "confidence": "confirmed"
106
+ },
107
+ {
108
+ "offset": 21,
109
+ "size": 4,
110
+ "name": "cadrUsed",
111
+ "type": "uint",
112
+ "vendorSlot": "k1.s6",
113
+ "confidence": "confirmed",
114
+ "note": "Cumulative volume of air cleaned. Rises monotonically."
115
+ },
116
+ {
117
+ "offset": 27,
118
+ "size": 2,
119
+ "name": "pm25Value",
120
+ "type": "uint",
121
+ "vendorSlot": "k1.s7",
122
+ "confidence": "offset-confirmed",
123
+ "usable": false,
124
+ "note": "The vendor app calls this pm25Value but gates it on hasPM25Sensor, which is false for this model. The bytes move, but do not publish them as a PM2.5 reading."
125
+ },
126
+ {
127
+ "offset": 29,
128
+ "size": 2,
129
+ "name": "dustFilterUsed",
130
+ "type": "uint",
131
+ "vendorSlot": "k1.s8",
132
+ "confidence": "confirmed",
133
+ "note": "Compare against spec.dustFilterLimit for remaining life."
134
+ },
135
+ {
136
+ "offset": 31,
137
+ "size": 2,
138
+ "name": "smellFilterUsed",
139
+ "type": "uint",
140
+ "vendorSlot": "k1.s9",
141
+ "confidence": "confirmed"
142
+ },
143
+ {
144
+ "offset": 35,
145
+ "size": 2,
146
+ "name": "humidFilterUsed",
147
+ "type": "uint",
148
+ "vendorSlot": "k1.s10",
149
+ "confidence": "confirmed"
150
+ },
151
+ {
152
+ "offset": 37,
153
+ "size": 1,
154
+ "name": "agIonFilterUsed",
155
+ "type": "uint",
156
+ "vendorSlot": "k1.s11",
157
+ "confidence": "confirmed"
158
+ },
159
+ {
160
+ "offset": 38,
161
+ "size": 2,
162
+ "name": "totalHumidificationAmount",
163
+ "type": "uint",
164
+ "vendorSlot": "k1.s12",
165
+ "confidence": "confirmed"
166
+ },
167
+ {
168
+ "offset": 40,
169
+ "size": 3,
170
+ "name": "numberOfParticles",
171
+ "type": "uint",
172
+ "unit": "particles/L",
173
+ "vendorSlot": "k1.s14",
174
+ "confidence": "confirmed",
175
+ "note": "Matched the vendor's own figure at an aligned timestamp, then again on the second unit."
176
+ }
177
+ ],
178
+ "unresolved": [
179
+ {
180
+ "vendorSlot": "k1.s13",
181
+ "name": "co2Concentration",
182
+ "note": "This model has no CO2 sensor, so the field is always zero and its offset cannot be pinned."
183
+ }
184
+ ]
185
+ },
186
+ "0xF2": {
187
+ "length": 41,
188
+ "fields": [
189
+ {
190
+ "offset": 16,
191
+ "size": 1,
192
+ "name": "pm25Level",
193
+ "type": "uint",
194
+ "vendorSlot": "k2.s3",
195
+ "confidence": "confirmed",
196
+ "usable": false,
197
+ "note": "Tracked the vendor value across four timestamps. Still gated on hasPM25Sensor for this model."
198
+ },
199
+ {
200
+ "offset": 17,
201
+ "size": 1,
202
+ "name": "airSummaryLevel",
203
+ "type": "uint",
204
+ "vendorSlot": "k2.s4",
205
+ "confidence": "confirmed"
206
+ },
207
+ {
208
+ "offset": 18,
209
+ "size": 1,
210
+ "name": "humidIconStatus",
211
+ "type": "uint",
212
+ "vendorSlot": "k2.s5",
213
+ "confidence": "confirmed"
214
+ },
215
+ {
216
+ "offset": 19,
217
+ "size": 1,
218
+ "name": "waterPresent",
219
+ "type": "flag",
220
+ "trueValue": "0xff",
221
+ "vendorSlot": "k2.s6",
222
+ "confidence": "confirmed",
223
+ "note": "0xff while the machine has water, 0x00 once it wants a refill. This is the only slot in the group that the vendor app never reads, and no vendor screen shows a refill state. Treat anything other than 0xff as empty so that an unseen third value does not fail silent."
224
+ },
225
+ {
226
+ "offset": 20,
227
+ "size": 1,
228
+ "name": "lightSensorFlag",
229
+ "type": "flag",
230
+ "trueValue": "0xff",
231
+ "vendorSlot": "k2.s7",
232
+ "confidence": "confirmed",
233
+ "note": "0xff in a lit room, 0x00 in the dark."
234
+ },
235
+ {
236
+ "offset": 24,
237
+ "size": 1,
238
+ "name": "humidButtonStatus",
239
+ "type": "uint",
240
+ "vendorSlot": "k2.s11",
241
+ "confidence": "confirmed",
242
+ "note": "Changed from 0x00 to 0x81 at the moment humidification was switched back on."
243
+ },
244
+ {
245
+ "offset": 25,
246
+ "size": 1,
247
+ "name": "pciVisible",
248
+ "type": "uint",
249
+ "vendorSlot": "k2.s12",
250
+ "confidence": "confirmed"
251
+ },
252
+ {
253
+ "offset": 14,
254
+ "size": 1,
255
+ "name": "smellLevel",
256
+ "type": "uint",
257
+ "vendorSlot": "k2.s1",
258
+ "confidence": "probable",
259
+ "note": "Placed by the ordering that holds for slots 3 through 12. Not observed changing."
260
+ },
261
+ {
262
+ "offset": 15,
263
+ "size": 1,
264
+ "name": "dustLevel",
265
+ "type": "uint",
266
+ "vendorSlot": "k2.s2",
267
+ "confidence": "probable",
268
+ "note": "Same reasoning as smellLevel."
269
+ }
270
+ ],
271
+ "unresolved": [
272
+ {
273
+ "vendorSlot": "k2.s13 - k2.s17",
274
+ "note": "Slots 1 through 12 sit at offsets 14 through 25 in order, but slot 16 (volumeSetting) does not land where that run predicts, so the tail is laid out differently. Left unresolved rather than guessed."
275
+ }
276
+ ]
277
+ },
278
+ "0xF3": {
279
+ "length": 27,
280
+ "fields": [
281
+ {
282
+ "offset": 15,
283
+ "size": 1,
284
+ "name": "humidificationEnabled",
285
+ "type": "flag",
286
+ "trueValue": "0xff",
287
+ "confidence": "confirmed",
288
+ "note": "The setting, not whether the machine is humidifying right now. A unit with an empty tank still reports 0xff while the setting is on. Writes are not reflected."
289
+ },
290
+ {
291
+ "offset": 4,
292
+ "size": 1,
293
+ "name": "driveState",
294
+ "type": "uint",
295
+ "vendorSlot": "k3.s1",
296
+ "confidence": "probable",
297
+ "note": "The vendor app reads this slot for drive mode, running state and dust filter life."
298
+ }
299
+ ],
300
+ "unresolved": [
301
+ {
302
+ "vendorSlot": "k3.s2",
303
+ "offset": 8,
304
+ "note": "The vendor app reads this slot, but what it means is not established."
305
+ },
306
+ {
307
+ "vendorSlot": "k3.s6",
308
+ "offset": 13,
309
+ "note": "Position matched on a single snapshot. Meaning unknown."
310
+ },
311
+ {
312
+ "vendorSlot": "rest of 0xF3",
313
+ "note": "Matched from a single snapshot where most bytes were zero."
314
+ }
315
+ ]
316
+ },
317
+ "0xF4": {
318
+ "note": "Every byte was zero on both units across every reading."
319
+ }
320
+ },
321
+ "traps": [
322
+ {
323
+ "where": "0xF3[5]",
324
+ "claim": "Looks like a water or mode flag: 0x00 on one unit and 0x01 on the other.",
325
+ "reality": "A per-unit constant. It did not move when the tank was refilled or when humidification was switched on and off."
326
+ },
327
+ {
328
+ "where": "0xF2[39]",
329
+ "claim": "Same shape, 0x00 against 0x01 between units.",
330
+ "reality": "Also a per-unit constant."
331
+ },
332
+ {
333
+ "where": "0xF1[42]",
334
+ "claim": "Drifts constantly and never returns, so it reads as noise.",
335
+ "reality": "The low byte of the three byte particle count at offset 40. Read the whole field."
336
+ },
337
+ {
338
+ "where": "0xF1[14]",
339
+ "reality": "Increments once a minute."
340
+ },
341
+ {
342
+ "where": "0xF1[24]",
343
+ "reality": "Increments roughly every 43 seconds."
344
+ },
345
+ {
346
+ "where": "0xA0",
347
+ "reality": "The device answers a write with ESV 0x71 and then ignores it. Instantaneous power does not budge."
348
+ },
349
+ {
350
+ "where": "humidifier class 0x0139",
351
+ "reality": "The standard defines a six step water level at EPC 0xC5, but this unit lists only 0x013501 in its instance list and does not answer that object at all."
352
+ }
353
+ ]
354
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+ import { EchonetClient } from "./echonet.js";
3
+ import { read } from "./read.js";
4
+ import { table } from "./table.js";
5
+ function usage() {
6
+ console.error(`sharp-echonet <command>
7
+
8
+ discover find air purifiers on the local network
9
+ read <ip> [--all] read one purifier; --all includes unconfirmed fields
10
+ table print what this build knows about ${table.model}
11
+ `);
12
+ process.exit(1);
13
+ }
14
+ const [command, ...rest] = process.argv.slice(2);
15
+ if (command === "discover") {
16
+ const client = new EchonetClient();
17
+ const found = await client.discover();
18
+ client.close();
19
+ if (found.length === 0) {
20
+ console.error("nothing answered. Some networks block multicast between clients.");
21
+ process.exit(1);
22
+ }
23
+ for (const ip of found)
24
+ console.log(ip);
25
+ }
26
+ else if (command === "read") {
27
+ const ip = rest[0];
28
+ if (!ip)
29
+ usage();
30
+ const all = rest.includes("--all");
31
+ const reading = await read(ip, {
32
+ ...(all ? { minConfidence: "probable", includeUnusable: true } : {}),
33
+ });
34
+ const { raw, ...rest2 } = reading;
35
+ console.log(JSON.stringify({ ...rest2, fields: Object.fromEntries(Object.entries(reading.fields)) }, null, 2));
36
+ }
37
+ else if (command === "table") {
38
+ console.log(JSON.stringify(table, null, 2));
39
+ }
40
+ else {
41
+ usage();
42
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Decoding on its own, pulling in no node built-ins.
3
+ *
4
+ * The root entry point carries the UDP client and therefore `node:dgram`. Where
5
+ * there is no UDP, such as an edge runtime, the bytes have to be fetched
6
+ * elsewhere and unpacked here, so that path is kept separate.
7
+ */
8
+ export { table, fieldsOf, matchesProductCode, hexEpc } from "./table.ts";
9
+ export type { Table, FieldSpec, PropertySpec, Confidence } from "./table.ts";
10
+ export { decodeProperty, decodeAll, waterTankEmpty } from "./decode.ts";
11
+ export type { DecodedField, DecodeOptions } from "./decode.ts";
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Decoding on its own, pulling in no node built-ins.
3
+ *
4
+ * The root entry point carries the UDP client and therefore `node:dgram`. Where
5
+ * there is no UDP, such as an edge runtime, the bytes have to be fetched
6
+ * elsewhere and unpacked here, so that path is kept separate.
7
+ */
8
+ export { table, fieldsOf, matchesProductCode, hexEpc } from "./table.js";
9
+ export { decodeProperty, decodeAll, waterTankEmpty } from "./decode.js";
@@ -0,0 +1,40 @@
1
+ import { type Confidence } from "./table.ts";
2
+ export interface DecodedField {
3
+ name: string;
4
+ value: number | boolean;
5
+ unit?: string;
6
+ confidence: Confidence;
7
+ /** False when the field exists but this model cannot produce a meaningful value. */
8
+ usable: boolean;
9
+ vendorSlot?: string;
10
+ note?: string;
11
+ }
12
+ export interface DecodeOptions {
13
+ /**
14
+ * Lowest confidence to return. Defaults to `confirmed`, so callers get only
15
+ * fields that survived a second experiment unless they ask for more.
16
+ */
17
+ minConfidence?: Confidence;
18
+ /** Include fields whose value this model cannot produce. Off by default. */
19
+ includeUnusable?: boolean;
20
+ }
21
+ /** Decode one vendor property (0xF1, 0xF2, 0xF3) into named fields. */
22
+ export declare function decodeProperty(epc: number, edt: Uint8Array, options?: DecodeOptions): DecodedField[];
23
+ /**
24
+ * Decode a whole read into one flat object keyed by field name.
25
+ *
26
+ * Properties the table does not describe are skipped rather than guessed at.
27
+ */
28
+ export declare function decodeAll(props: Map<number, Uint8Array>, options?: DecodeOptions): Record<string, DecodedField>;
29
+ /**
30
+ * Whether the tank needs a refill.
31
+ *
32
+ * Returns undefined when the byte is missing, never false. "I could not read it"
33
+ * and "there is water" are different answers, and collapsing them means a dry
34
+ * machine reports as full.
35
+ *
36
+ * Anything other than the water-present value counts as empty. Only two values
37
+ * have ever been observed, so an unrecognised third one is more safely treated
38
+ * as a refill than as a full tank.
39
+ */
40
+ export declare function waterTankEmpty(props: Map<number, Uint8Array>): boolean | undefined;