bt-sensors-plugin-sk 1.3.3 → 1.3.4-beta
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/AI-DEV.md +784 -0
- package/README.md +5 -0
- package/index.js +25 -1
- package/package.json +2 -2
- package/sensor_classes/HumsienkBMS.js +385 -0
package/AI-DEV.md
ADDED
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
# AI-Assisted Development Guide for Bluetooth Battery Sensors
|
|
2
|
+
|
|
3
|
+
This guide documents the AI-assisted development process used to create Bluetooth battery sensor implementations for the SignalK bt-sensors-plugin-sk. Follow this process to add support for new Bluetooth battery devices.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
This plugin architecture allows adding new Bluetooth battery sensors by creating sensor class files that inherit from [`BTSensor.js`](BTSensor.js:1). Each sensor class handles device-specific Bluetooth communication and data parsing, then maps the data to SignalK paths.
|
|
8
|
+
|
|
9
|
+
## Prerequisites
|
|
10
|
+
|
|
11
|
+
Before starting, gather the following information about your Bluetooth battery:
|
|
12
|
+
|
|
13
|
+
### 1. Device Documentation
|
|
14
|
+
- Manufacturer's name and device model
|
|
15
|
+
- Bluetooth specifications (BLE services, characteristics, UUIDs)
|
|
16
|
+
- Data packet format and protocol documentation
|
|
17
|
+
- Available metrics (voltage, current, SOC, temperature, etc.)
|
|
18
|
+
|
|
19
|
+
### 2. Development Tools
|
|
20
|
+
- Access to the physical device for testing
|
|
21
|
+
- **Android device with Bluetooth debugging enabled** (CRITICAL - see section below)
|
|
22
|
+
- Bluetooth scanning tools (`bluetoothctl`, nRF Connect app, etc.)
|
|
23
|
+
- **Vendor's mobile app** for the battery (if available)
|
|
24
|
+
- Sample data packets (advertising data, characteristic reads)
|
|
25
|
+
|
|
26
|
+
### 3. Technical Details
|
|
27
|
+
- Service UUIDs the device advertises
|
|
28
|
+
- Characteristic UUIDs for reading data
|
|
29
|
+
- Data encoding format (byte order, scaling factors, units)
|
|
30
|
+
- Any CRC/checksum algorithms used
|
|
31
|
+
- Whether device uses advertising data or GATT connection
|
|
32
|
+
|
|
33
|
+
## Critical First Step: Capture Real Bluetooth Data
|
|
34
|
+
|
|
35
|
+
### Using Android Bluetooth HCI Snoop Log (ESSENTIAL)
|
|
36
|
+
|
|
37
|
+
**This is the most important step** - Before writing any code, you need to capture actual Bluetooth communication from the device. The most effective method is using Android's built-in Bluetooth debugging with the vendor's app.
|
|
38
|
+
|
|
39
|
+
**Why this matters:**
|
|
40
|
+
- Reveals the exact protocol the device uses
|
|
41
|
+
- Shows actual commands and responses
|
|
42
|
+
- Eliminates guesswork about byte layouts
|
|
43
|
+
- Provides test data for validation
|
|
44
|
+
|
|
45
|
+
**Step-by-step process:**
|
|
46
|
+
|
|
47
|
+
1. **Enable Developer Options on Android:**
|
|
48
|
+
- Go to Settings → About Phone
|
|
49
|
+
- Tap "Build Number" 7 times
|
|
50
|
+
- Go back to Settings → Developer Options
|
|
51
|
+
|
|
52
|
+
2. **Enable Bluetooth HCI Snoop Log:**
|
|
53
|
+
- In Developer Options, enable "Bluetooth HCI snoop log"
|
|
54
|
+
- This captures ALL Bluetooth traffic to a file
|
|
55
|
+
|
|
56
|
+
3. **Use the Vendor's App:**
|
|
57
|
+
- Install the battery manufacturer's official app
|
|
58
|
+
- Connect to your battery
|
|
59
|
+
- Navigate through all screens showing battery data
|
|
60
|
+
- Trigger all functions (refresh, settings, etc.)
|
|
61
|
+
- Let it run for 30-60 seconds
|
|
62
|
+
|
|
63
|
+
4. **Extract the Log File:**
|
|
64
|
+
```bash
|
|
65
|
+
# Pull the Bluetooth log from Android device via ADB
|
|
66
|
+
adb pull /sdcard/Android/data/btsnoop_hci.log
|
|
67
|
+
|
|
68
|
+
# Or from newer Android versions:
|
|
69
|
+
adb bugreport
|
|
70
|
+
# Then extract: FS/data/misc/bluetooth/logs/btsnoop_hci.log
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
5. **Analyze with Wireshark:**
|
|
74
|
+
- Open btsnoop_hci.log in Wireshark
|
|
75
|
+
- Filter for your device's MAC address: `bluetooth.addr == XX:XX:XX:XX:XX:XX`
|
|
76
|
+
- Look for:
|
|
77
|
+
- **ATT (Attribute Protocol) packets** - GATT read/write/notify operations
|
|
78
|
+
- **Advertising packets** - Manufacturer/Service data broadcasts
|
|
79
|
+
- **Command/response patterns** - Protocol structure
|
|
80
|
+
|
|
81
|
+
**What to look for in Wireshark:**
|
|
82
|
+
|
|
83
|
+
For GATT devices:
|
|
84
|
+
- `ATT Write Request` - Commands sent by app to device
|
|
85
|
+
- `ATT Handle Value Notification` - Data pushed from device
|
|
86
|
+
- `ATT Read Response` - Data read from characteristics
|
|
87
|
+
- Note the characteristic handles (0x00XX) and UUIDs
|
|
88
|
+
|
|
89
|
+
For Advertising devices:
|
|
90
|
+
- `Advertising Data` packets
|
|
91
|
+
- Manufacturer Data field with ID and hex bytes
|
|
92
|
+
- Service Data field with UUID and hex bytes
|
|
93
|
+
|
|
94
|
+
**Real Example - HSC14F Battery:**
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
Wireshark Filter: bluetooth.addr == AA:BB:CC:DD:EE:FF
|
|
98
|
+
|
|
99
|
+
Frame 142: ATT Write Request, Handle: 0x000e
|
|
100
|
+
Data: A5 5A 00 FF 01 03
|
|
101
|
+
(This is the "read battery info" command)
|
|
102
|
+
|
|
103
|
+
Frame 145: ATT Handle Value Notification, Handle: 0x000b
|
|
104
|
+
Data: A5 5A 00 01 01 0C E4 10 0E 55 19 [...]
|
|
105
|
+
(Device's response with battery data)
|
|
106
|
+
|
|
107
|
+
Vendor app displayed at this moment:
|
|
108
|
+
- Voltage: 13.2V (bytes 6-7: 0x0CE4 = 3300 * 0.01 / 2 cells)
|
|
109
|
+
- Current: 10.5A (bytes 8-9: 0x100E = 4110 * 0.01 / 4)
|
|
110
|
+
- SOC: 85% (byte 10: 0x55 = 85)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**Prompt to AI after capturing:**
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
I've captured Bluetooth HCI snoop log from my [DEVICE_NAME] using the vendor's
|
|
117
|
+
Android app. Here's what Wireshark shows:
|
|
118
|
+
|
|
119
|
+
[FOR GATT DEVICES:]
|
|
120
|
+
Service UUID: 0000ffe0-0000-1000-8000-00805f9b34fb
|
|
121
|
+
Characteristic UUID: 0000ffe1-0000-1000-8000-00805f9b34fb
|
|
122
|
+
|
|
123
|
+
Write Request to device:
|
|
124
|
+
A5 5A 00 FF 01 03
|
|
125
|
+
|
|
126
|
+
Notification Response:
|
|
127
|
+
A5 5A 00 01 01 0C E4 10 0E 55 19 00 01 02 [...]
|
|
128
|
+
|
|
129
|
+
When this packet arrived, the vendor app displayed:
|
|
130
|
+
- Voltage: 13.2V
|
|
131
|
+
- Current: 10.5A
|
|
132
|
+
- SOC: 85%
|
|
133
|
+
- Temperature: 25°C
|
|
134
|
+
|
|
135
|
+
Please help me:
|
|
136
|
+
1. Understand the protocol structure
|
|
137
|
+
2. Map hex bytes to displayed values
|
|
138
|
+
3. Determine byte positions, endianness, and scaling
|
|
139
|
+
4. Create the sensor class implementation
|
|
140
|
+
|
|
141
|
+
[FOR ADVERTISING DEVICES:]
|
|
142
|
+
Advertising Data shows:
|
|
143
|
+
Manufacturer Data (0x1234): AB CD 0C E4 10 0E 55 01 F4
|
|
144
|
+
|
|
145
|
+
When this was captured, device display showed:
|
|
146
|
+
- Voltage: 13.2V
|
|
147
|
+
- Current: 10.5A
|
|
148
|
+
- SOC: 85%
|
|
149
|
+
|
|
150
|
+
Please decode this packet format.
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Alternative: Using nRF Connect (If No Vendor App Available)
|
|
154
|
+
|
|
155
|
+
If there's no vendor app:
|
|
156
|
+
|
|
157
|
+
1. **Install nRF Connect** (Nordic Semiconductor app for Android/iOS)
|
|
158
|
+
2. **Scan and connect** to your battery
|
|
159
|
+
3. **Explore all services and characteristics**
|
|
160
|
+
4. **Read values** from readable characteristics
|
|
161
|
+
5. **Enable notifications** on notifiable characteristics
|
|
162
|
+
6. **Screenshot hex values** and note what they represent
|
|
163
|
+
|
|
164
|
+
**Prompt to AI:**
|
|
165
|
+
```
|
|
166
|
+
Using nRF Connect, I found on [DEVICE_NAME]:
|
|
167
|
+
|
|
168
|
+
Service: 0000ffe0-0000-1000-8000-00805f9b34fb
|
|
169
|
+
Characteristics:
|
|
170
|
+
- 0000ffe1-... (Read, Notify)
|
|
171
|
+
Current value: A5 5A 00 01 01 0C E4 10 0E 55
|
|
172
|
+
|
|
173
|
+
Battery label shows: 13.2V, 10.5A, 85%
|
|
174
|
+
|
|
175
|
+
How do I decode this to get those values?
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Step-by-Step AI-Assisted Development Process
|
|
179
|
+
|
|
180
|
+
### Step 1: Analyze Existing Similar Implementations
|
|
181
|
+
|
|
182
|
+
**Prompt to AI:**
|
|
183
|
+
```
|
|
184
|
+
I want to add support for a [DEVICE_NAME] Bluetooth battery to the bt-sensors-plugin-sk.
|
|
185
|
+
Please review the sensor_classes directory and identify existing battery sensor
|
|
186
|
+
implementations that are similar. Show me the key patterns used for:
|
|
187
|
+
- Device identification (matchManufacturerData or identify methods)
|
|
188
|
+
- Bluetooth service/characteristic UUIDs
|
|
189
|
+
- Data packet parsing
|
|
190
|
+
- SignalK path mapping
|
|
191
|
+
- GATT vs advertising data approaches
|
|
192
|
+
|
|
193
|
+
Focus on these example files:
|
|
194
|
+
- sensor_classes/VictronSmartLithium.js
|
|
195
|
+
- sensor_classes/JBDBMS.js
|
|
196
|
+
- sensor_classes/RenogyBattery.js
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
**What AI should examine:**
|
|
200
|
+
- [`VictronSmartLithium.js`](sensor_classes/VictronSmartLithium.js:1) - Advertising data approach with encryption
|
|
201
|
+
- [`JBDBMS.js`](sensor_classes/JBDBMS.js:1) - GATT connection with command/response protocol
|
|
202
|
+
- [`RenogyBattery.js`](sensor_classes/RenogyBattery.js:1) - GATT with Modbus-style registers
|
|
203
|
+
- [`BTSensor.js`](BTSensor.js:1) - Base class showing required methods
|
|
204
|
+
|
|
205
|
+
### Step 2: Determine Communication Method
|
|
206
|
+
|
|
207
|
+
**Prompt to AI:**
|
|
208
|
+
```
|
|
209
|
+
My device [DOES/DOES NOT] require a GATT connection. It provides data via:
|
|
210
|
+
- [X] Advertising packets (manufacturer data or service data)
|
|
211
|
+
- [ ] GATT characteristic notifications
|
|
212
|
+
- [ ] GATT characteristic reads (polling)
|
|
213
|
+
|
|
214
|
+
Here's the Bluetooth scan data I captured:
|
|
215
|
+
[Paste output from bluetoothctl or nRF Connect]
|
|
216
|
+
|
|
217
|
+
Which approach should I use: advertising-based like VictronSmartLithium,
|
|
218
|
+
or GATT-based like JBDBMS or RenogyBattery?
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### Step 3: Create Initial Sensor Class File
|
|
222
|
+
|
|
223
|
+
**Prompt to AI:**
|
|
224
|
+
```
|
|
225
|
+
Create a new sensor class file for [DEVICE_NAME] based on the following specifications:
|
|
226
|
+
|
|
227
|
+
Device: [DEVICE_NAME]
|
|
228
|
+
Manufacturer: [MANUFACTURER]
|
|
229
|
+
Communication method: [Advertising/GATT]
|
|
230
|
+
|
|
231
|
+
[IF ADVERTISING:]
|
|
232
|
+
Manufacturer ID: [0xXXXX]
|
|
233
|
+
The device advertises data in manufacturer data with this format:
|
|
234
|
+
[Describe byte layout]
|
|
235
|
+
|
|
236
|
+
[IF GATT:]
|
|
237
|
+
Service UUID: [UUID]
|
|
238
|
+
Characteristics:
|
|
239
|
+
- Read: [UUID] - [description]
|
|
240
|
+
- Notify: [UUID] - [description]
|
|
241
|
+
- Write: [UUID] - [description if applicable]
|
|
242
|
+
|
|
243
|
+
The device provides these metrics:
|
|
244
|
+
- Voltage (range: X-Y V)
|
|
245
|
+
- Current (range: -X to +Y A)
|
|
246
|
+
- State of Charge (0-100%)
|
|
247
|
+
- Temperature (range: X-Y °C)
|
|
248
|
+
- [Other metrics...]
|
|
249
|
+
|
|
250
|
+
Please create sensor_classes/[DeviceName].js following the pattern used in
|
|
251
|
+
[VictronSmartLithium.js/JBDBMS.js/RenogyBattery.js], including:
|
|
252
|
+
1. Class definition extending BTSensor
|
|
253
|
+
2. Static Domain property set to BTSensor.SensorDomains.electrical
|
|
254
|
+
3. Static identify() method for device identification
|
|
255
|
+
4. Static ImageFile property
|
|
256
|
+
5. initSchema() method with metadata
|
|
257
|
+
6. Data parsing methods
|
|
258
|
+
7. SignalK path defaults
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
**Expected AI actions:**
|
|
262
|
+
- Creates new file in `sensor_classes/`
|
|
263
|
+
- Implements basic class structure
|
|
264
|
+
- Sets up UUID constants (if GATT)
|
|
265
|
+
- Defines device identification logic
|
|
266
|
+
- Creates default SignalK path mappings
|
|
267
|
+
|
|
268
|
+
### Step 4: Implement Device Identification
|
|
269
|
+
|
|
270
|
+
**For Advertising-based Devices:**
|
|
271
|
+
|
|
272
|
+
**Prompt to AI:**
|
|
273
|
+
```
|
|
274
|
+
I have Bluetooth advertising data from [DEVICE_NAME]:
|
|
275
|
+
Manufacturer Data: [paste hex dump, e.g., "AB CD 01 02 03 04..."]
|
|
276
|
+
or
|
|
277
|
+
Service Data (UUID [UUID]): [paste hex dump]
|
|
278
|
+
|
|
279
|
+
The manufacturer ID should be: [0xXXXX]
|
|
280
|
+
|
|
281
|
+
Please implement the static identify(device) method to correctly identify this device.
|
|
282
|
+
Use the pattern from VictronSensor.js which checks manufacturer ID and additional
|
|
283
|
+
identifying bytes if needed.
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
**For GATT Devices:**
|
|
287
|
+
|
|
288
|
+
**Prompt to AI:**
|
|
289
|
+
```
|
|
290
|
+
My GATT device advertises the service UUID: [UUID]
|
|
291
|
+
|
|
292
|
+
Please implement the static identify(device) method that:
|
|
293
|
+
1. Checks if the device advertises this service UUID
|
|
294
|
+
2. Returns the class if matched, null otherwise
|
|
295
|
+
|
|
296
|
+
Use this pattern:
|
|
297
|
+
static async identify(device){
|
|
298
|
+
const serviceUUIDs = await BTSensor.getDeviceProp(device, 'UUIDs')
|
|
299
|
+
if (serviceUUIDs && serviceUUIDs.includes('[UUID]'))
|
|
300
|
+
return this
|
|
301
|
+
return null
|
|
302
|
+
}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
### Step 5: Implement Data Parsing (Advertising Method)
|
|
306
|
+
|
|
307
|
+
**Prompt to AI:**
|
|
308
|
+
```
|
|
309
|
+
The [DEVICE_NAME] sends data in manufacturer data packets with this format:
|
|
310
|
+
|
|
311
|
+
Byte 0-1: Manufacturer ID (0xXXXX, little-endian)
|
|
312
|
+
Byte 2-3: Voltage (0.01V units, unsigned 16-bit LE)
|
|
313
|
+
Byte 4-5: Current (0.01A units, signed 16-bit LE, positive=charging)
|
|
314
|
+
Byte 6: State of Charge (%, unsigned 8-bit, 0-100)
|
|
315
|
+
Byte 7-8: Temperature (0.1°C units, signed 16-bit LE)
|
|
316
|
+
[Continue for all fields...]
|
|
317
|
+
|
|
318
|
+
Sample packet: [provide actual hex dump, e.g., "CD AB 10 0D E8 03 64 5A 01"]
|
|
319
|
+
|
|
320
|
+
Please implement the propertiesChanged() method that:
|
|
321
|
+
1. Calls super.propertiesChanged(props) first
|
|
322
|
+
2. Extracts manufacturer data
|
|
323
|
+
3. Parses the buffer using the read functions defined in initSchema()
|
|
324
|
+
4. Calls emitValuesFrom(buffer) to emit all values
|
|
325
|
+
|
|
326
|
+
Also update initSchema() with the correct read functions for each path using:
|
|
327
|
+
.read = (buffer) => { return buffer.readUInt16LE(2) / 100 } // for voltage at byte 2
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
**Common Buffer Reading Methods:**
|
|
331
|
+
- `buffer.readUInt16LE(offset)` - Unsigned 16-bit little-endian
|
|
332
|
+
- `buffer.readInt16LE(offset)` - Signed 16-bit little-endian
|
|
333
|
+
- `buffer.readUInt8(offset)` - Unsigned 8-bit
|
|
334
|
+
- `buffer.readInt8(offset)` - Signed 8-bit
|
|
335
|
+
- `buffer.readUInt32LE(offset)` - Unsigned 32-bit little-endian
|
|
336
|
+
- Use `BE` suffix for big-endian variants
|
|
337
|
+
|
|
338
|
+
### Step 6: Implement GATT Communication (GATT Method)
|
|
339
|
+
|
|
340
|
+
**Prompt to AI:**
|
|
341
|
+
```
|
|
342
|
+
My GATT device uses this protocol:
|
|
343
|
+
Service UUID: [UUID]
|
|
344
|
+
Notify Characteristic: [UUID] - Sends battery data
|
|
345
|
+
Write Characteristic: [UUID] - Accepts commands
|
|
346
|
+
Read Characteristic: [UUID] - Returns data on request
|
|
347
|
+
|
|
348
|
+
Command format: [describe, e.g., "0xDD 0xA5 CMD 0x00 0xFF CHECKSUM 0x77"]
|
|
349
|
+
Response format: [describe structure]
|
|
350
|
+
|
|
351
|
+
Please implement:
|
|
352
|
+
1. initSchema() that connects to the device and sets up characteristics
|
|
353
|
+
2. initGATTConnection() override if needed
|
|
354
|
+
3. initGATTNotifications() or initGATTInterval() depending on polling vs notifications
|
|
355
|
+
4. emitGATT() method that requests data and parses responses
|
|
356
|
+
5. Helper methods like sendReadFunctionRequest() if needed
|
|
357
|
+
|
|
358
|
+
Follow the pattern from JBDBMS.js which uses command/response protocol.
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
**For devices that need polling:**
|
|
362
|
+
|
|
363
|
+
**Prompt to AI:**
|
|
364
|
+
```
|
|
365
|
+
Add GATT parameters to support polling:
|
|
366
|
+
- In initSchema(), call this.addGATTParameter() for pollFreq
|
|
367
|
+
- Set hasGATT() to return true
|
|
368
|
+
- Set usingGATT() to return this.useGATT
|
|
369
|
+
- Implement initGATTInterval() to poll at specified frequency
|
|
370
|
+
- Implement emitGATT() to read and emit all values
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
### Step 7: Define SignalK Path Mappings
|
|
374
|
+
|
|
375
|
+
**Prompt to AI:**
|
|
376
|
+
```
|
|
377
|
+
Please implement proper SignalK path defaults in initSchema() for this battery.
|
|
378
|
+
Map the parsed values to these SignalK paths:
|
|
379
|
+
|
|
380
|
+
Use addDefaultPath() for standard paths:
|
|
381
|
+
- Voltage → electrical.batteries.voltage
|
|
382
|
+
- Current → electrical.batteries.current
|
|
383
|
+
- SOC → electrical.batteries.capacity.stateOfCharge
|
|
384
|
+
- Temperature → electrical.batteries.temperature
|
|
385
|
+
- Remaining capacity → electrical.batteries.capacity.remaining
|
|
386
|
+
- Cycles → electrical.batteries.cycles
|
|
387
|
+
|
|
388
|
+
Use addMetadatum() for device-specific paths:
|
|
389
|
+
- Cell voltages → electrical.batteries.{batteryID}.cell[N].voltage
|
|
390
|
+
- Protection status → electrical.batteries.{batteryID}.protectionStatus
|
|
391
|
+
- [Other custom metrics]
|
|
392
|
+
|
|
393
|
+
Add parameter for battery ID:
|
|
394
|
+
this.addDefaultParam("batteryID").default="house"
|
|
395
|
+
|
|
396
|
+
Ensure all paths use proper template variables like {batteryID} where appropriate.
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
**SignalK Path Reference:**
|
|
400
|
+
```
|
|
401
|
+
electrical.batteries.{id}.voltage // V
|
|
402
|
+
electrical.batteries.{id}.current // A (positive = charging)
|
|
403
|
+
electrical.batteries.{id}.temperature // K (Kelvin)
|
|
404
|
+
electrical.batteries.{id}.capacity.stateOfCharge // ratio (0-1)
|
|
405
|
+
electrical.batteries.{id}.capacity.remaining // C (coulombs/amp-hours)
|
|
406
|
+
electrical.batteries.{id}.capacity.actual // C (total capacity)
|
|
407
|
+
electrical.batteries.{id}.cycles // count
|
|
408
|
+
electrical.batteries.{id}.lifetimeDischarge // C
|
|
409
|
+
electrical.batteries.{id}.lifetimeRecharge // C
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
### Step 8: Add Device Image and Description
|
|
413
|
+
|
|
414
|
+
**Prompt to AI:**
|
|
415
|
+
```
|
|
416
|
+
I have an image for [DEVICE_NAME] saved as: public/images/[DeviceName].webp
|
|
417
|
+
|
|
418
|
+
Please update the sensor class to include:
|
|
419
|
+
1. static ImageFile = "[DeviceName].webp"
|
|
420
|
+
2. static Description = "[Brief description of device]"
|
|
421
|
+
3. static Manufacturer = "[Manufacturer Name]"
|
|
422
|
+
|
|
423
|
+
Follow the pattern from other sensor classes.
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
### Step 9: Register the Sensor Class
|
|
427
|
+
|
|
428
|
+
**Prompt to AI:**
|
|
429
|
+
```
|
|
430
|
+
Please help me register the new [DeviceName] sensor class in the plugin:
|
|
431
|
+
1. Check classLoader.js to see the pattern for importing and registering classes
|
|
432
|
+
2. Add the appropriate require() statement
|
|
433
|
+
3. Add it to the class map
|
|
434
|
+
4. Ensure it's in the correct order for identification
|
|
435
|
+
|
|
436
|
+
Show me the exact changes needed to classLoader.js.
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
### Step 10: Test with Real Device
|
|
440
|
+
|
|
441
|
+
**Prompt to AI:**
|
|
442
|
+
```
|
|
443
|
+
I'm testing with the actual [DEVICE_NAME] device. Here's what I'm seeing:
|
|
444
|
+
|
|
445
|
+
Debug output:
|
|
446
|
+
[Paste logs, errors, or unexpected behavior]
|
|
447
|
+
|
|
448
|
+
Expected values from device display:
|
|
449
|
+
- Voltage: [X]V
|
|
450
|
+
- Current: [X]A
|
|
451
|
+
- SOC: [X]%
|
|
452
|
+
- Temperature: [X]°C
|
|
453
|
+
|
|
454
|
+
Actual parsed values:
|
|
455
|
+
- Voltage: [Y]V
|
|
456
|
+
- Current: [Y]A
|
|
457
|
+
- SOC: [Y]%
|
|
458
|
+
- Temperature: [Y]°C
|
|
459
|
+
|
|
460
|
+
Please help debug:
|
|
461
|
+
1. Are the byte offsets correct?
|
|
462
|
+
2. Is the byte order (endianness) correct?
|
|
463
|
+
3. Are scaling factors correct?
|
|
464
|
+
4. Are signed vs unsigned interpretations correct?
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
**Common Issues & Fixes:**
|
|
468
|
+
- **Values 256x too large/small**: Wrong endianness (LE vs BE)
|
|
469
|
+
- **Negative values where shouldn't be**: Using UInt instead of Int
|
|
470
|
+
- **Values in wrong range**: Incorrect scaling factor
|
|
471
|
+
- **Values always same**: Wrong byte offset or not updating
|
|
472
|
+
|
|
473
|
+
### Step 11: Add Error Handling
|
|
474
|
+
|
|
475
|
+
**Prompt to AI:**
|
|
476
|
+
```
|
|
477
|
+
Please add robust error handling to the [DeviceName] class for:
|
|
478
|
+
|
|
479
|
+
For advertising-based devices:
|
|
480
|
+
1. Check manufacturer data exists before parsing
|
|
481
|
+
2. Validate buffer length before reading
|
|
482
|
+
3. Handle out-of-range values gracefully
|
|
483
|
+
4. Add try-catch in propertiesChanged()
|
|
484
|
+
|
|
485
|
+
For GATT devices:
|
|
486
|
+
1. Add timeouts for response waits
|
|
487
|
+
2. Validate checksums if protocol uses them
|
|
488
|
+
3. Handle disconnections gracefully
|
|
489
|
+
4. Add retry logic for failed reads
|
|
490
|
+
5. Implement deactivateGATT() cleanup
|
|
491
|
+
|
|
492
|
+
Follow the error handling patterns in JBDBMS.js and BTSensor.js.
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
### Step 12: Documentation and Testing
|
|
496
|
+
|
|
497
|
+
**Prompt to AI:**
|
|
498
|
+
```
|
|
499
|
+
Please add comprehensive documentation to the [DeviceName] class:
|
|
500
|
+
|
|
501
|
+
1. Add JSDoc comments for all methods explaining:
|
|
502
|
+
- Parameters and return values
|
|
503
|
+
- Protocol details
|
|
504
|
+
- Data format specifics
|
|
505
|
+
|
|
506
|
+
2. Add a comment block at the top documenting the data format:
|
|
507
|
+
/*
|
|
508
|
+
[DeviceName] Data Format
|
|
509
|
+
Byte X-Y: [Field] (units, format, range)
|
|
510
|
+
...
|
|
511
|
+
*/
|
|
512
|
+
|
|
513
|
+
3. Create a _test() usage example showing how to test parsing with sample data
|
|
514
|
+
|
|
515
|
+
4. Document any device-specific quirks or limitations
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
## Complete Example Workflow
|
|
519
|
+
|
|
520
|
+
Here's a condensed example conversation for adding an "AcmeBattery BT-100":
|
|
521
|
+
|
|
522
|
+
```
|
|
523
|
+
User: I want to add support for the Acme BT-100 Bluetooth battery. It uses
|
|
524
|
+
advertising data with manufacturer ID 0x1234. Here's a sample packet:
|
|
525
|
+
34 12 10 0D E8 03 64 5A 01
|
|
526
|
+
|
|
527
|
+
AI: [Analyzes structure, creates initial class file]
|
|
528
|
+
|
|
529
|
+
User: The packet format is:
|
|
530
|
+
Bytes 0-1: Manufacturer ID (0x1234)
|
|
531
|
+
Bytes 2-3: Voltage in 0.01V (little-endian, so 0x0D10 = 3344 = 33.44V)
|
|
532
|
+
Bytes 4-5: Current in 0.01A (signed LE, 0x03E8 = 1000 = 10.00A)
|
|
533
|
+
Byte 6: SOC (0x64 = 100 = 100%)
|
|
534
|
+
Bytes 7-8: Temp in 0.1°C (signed LE, 0x015A = 346 = 34.6°C)
|
|
535
|
+
|
|
536
|
+
AI: [Implements propertiesChanged() and read functions with correct parsing]
|
|
537
|
+
|
|
538
|
+
User: Testing shows voltage correct but current is backwards (negative when charging)
|
|
539
|
+
|
|
540
|
+
AI: [Fixes current sign interpretation]
|
|
541
|
+
|
|
542
|
+
User: Perfect! Now add the image reference and register it.
|
|
543
|
+
|
|
544
|
+
AI: [Updates ImageFile, modifies classLoader.js]
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
## Bluetooth Scanning Commands
|
|
548
|
+
|
|
549
|
+
### Using bluetoothctl
|
|
550
|
+
```bash
|
|
551
|
+
# Start scanning
|
|
552
|
+
bluetoothctl
|
|
553
|
+
scan on
|
|
554
|
+
|
|
555
|
+
# Wait for device to appear, then:
|
|
556
|
+
info [MAC_ADDRESS]
|
|
557
|
+
|
|
558
|
+
# Look for:
|
|
559
|
+
# - ManufacturerData: key [ID] value [hex bytes]
|
|
560
|
+
# - ServiceData: key [UUID] value [hex bytes]
|
|
561
|
+
# - UUIDs: [list of service UUIDs]
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
### Using hcitool/hcidump
|
|
565
|
+
```bash
|
|
566
|
+
# Terminal 1: Start scan
|
|
567
|
+
sudo hcitool lescan
|
|
568
|
+
|
|
569
|
+
# Terminal 2: Capture advertising data
|
|
570
|
+
sudo hcidump --raw
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
## Testing Your Implementation
|
|
574
|
+
|
|
575
|
+
### Static Testing (No Device Required)
|
|
576
|
+
```javascript
|
|
577
|
+
const MyBattery = require('./sensor_classes/MyBattery.js')
|
|
578
|
+
|
|
579
|
+
// Test with sample packet
|
|
580
|
+
const sampleData = "34 12 10 0D E8 03 64 5A 01"
|
|
581
|
+
MyBattery._test(sampleData)
|
|
582
|
+
|
|
583
|
+
// Should output:
|
|
584
|
+
// voltage=33.44
|
|
585
|
+
// current=10.00
|
|
586
|
+
// SOC=1.00
|
|
587
|
+
// temperature=307.75
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
### Live Testing Checklist
|
|
591
|
+
- [ ] Device correctly identified during scan
|
|
592
|
+
- [ ] Connection establishes (if GATT)
|
|
593
|
+
- [ ] All metrics parse with correct values
|
|
594
|
+
- [ ] **Values match vendor app exactly** (critical!)
|
|
595
|
+
- [ ] **Parsed values match HCI snoop captured data**
|
|
596
|
+
- [ ] Values also match device's physical display (if available)
|
|
597
|
+
- [ ] Reconnection works after going out of range
|
|
598
|
+
- [ ] SignalK paths appear in data browser
|
|
599
|
+
- [ ] Units are correct (Kelvin for temp, etc.)
|
|
600
|
+
- [ ] No memory leaks over extended operation
|
|
601
|
+
|
|
602
|
+
### Validating Against Captured HCI Data
|
|
603
|
+
|
|
604
|
+
After implementing your sensor class, verify it parses the same packets you captured:
|
|
605
|
+
|
|
606
|
+
**Extract specific packets from your capture:**
|
|
607
|
+
```bash
|
|
608
|
+
# Use tshark to extract hex data from your captured log
|
|
609
|
+
tshark -r btsnoop_hci.log -Y "btatt && bluetooth.addr == XX:XX:XX:XX:XX:XX" \
|
|
610
|
+
-T fields -e btatt.value
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
**Test with captured data:**
|
|
614
|
+
```javascript
|
|
615
|
+
const MyBattery = require('./sensor_classes/MyBattery.js')
|
|
616
|
+
|
|
617
|
+
// Use actual packet from your HCI capture
|
|
618
|
+
const capturedPacket = "A5 5A 00 01 01 0C E4 10 0E 55 19 00 01 02"
|
|
619
|
+
MyBattery._test(capturedPacket)
|
|
620
|
+
|
|
621
|
+
// Output should match what vendor app showed:
|
|
622
|
+
// voltage=13.2
|
|
623
|
+
// current=10.5
|
|
624
|
+
// SOC=0.85
|
|
625
|
+
// temperature=298.15
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
**Prompt to AI if values don't match:**
|
|
629
|
+
```
|
|
630
|
+
My implementation parses this HCI-captured packet:
|
|
631
|
+
A5 5A 00 01 01 0C E4 10 0E 55
|
|
632
|
+
|
|
633
|
+
And produces:
|
|
634
|
+
- voltage=33.0V (WRONG)
|
|
635
|
+
- current=10.5A (CORRECT)
|
|
636
|
+
|
|
637
|
+
But the vendor app showed voltage=13.2V when this packet was sent.
|
|
638
|
+
|
|
639
|
+
Bytes 6-7 are: 0C E4 (hex)
|
|
640
|
+
0x0CE4 = 3300 decimal
|
|
641
|
+
|
|
642
|
+
The device is a 2-cell battery. Help me debug the voltage parsing.
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
**AI will likely identify:** You need to divide by number of cells, or the scaling factor is different, or byte order is reversed.
|
|
646
|
+
|
|
647
|
+
## Common Data Parsing Patterns
|
|
648
|
+
|
|
649
|
+
### Temperature Conversions
|
|
650
|
+
```javascript
|
|
651
|
+
// Celsius to Kelvin
|
|
652
|
+
.read = (buffer) => { return buffer.readInt16LE(offset) / 10 + 273.15 }
|
|
653
|
+
|
|
654
|
+
// Handle "not available" values
|
|
655
|
+
.read = (buffer) => {
|
|
656
|
+
const val = buffer.readInt8(offset)
|
|
657
|
+
return val === 0x7F ? NaN : val + 273.15
|
|
658
|
+
}
|
|
659
|
+
```
|
|
660
|
+
|
|
661
|
+
### Current Direction
|
|
662
|
+
```javascript
|
|
663
|
+
// Positive = charging, negative = discharging
|
|
664
|
+
.read = (buffer) => { return buffer.readInt16LE(offset) / 100 }
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
### State of Charge
|
|
668
|
+
```javascript
|
|
669
|
+
// Percent to ratio (0-1)
|
|
670
|
+
.read = (buffer) => { return buffer.readUInt8(offset) / 100 }
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
### Bit Flags
|
|
674
|
+
```javascript
|
|
675
|
+
.read = (buffer) => {
|
|
676
|
+
const byte = buffer.readUInt8(offset)
|
|
677
|
+
return {
|
|
678
|
+
charging: (byte & 0x01) !== 0,
|
|
679
|
+
discharging: (byte & 0x02) !== 0,
|
|
680
|
+
balancing: (byte & 0x04) !== 0,
|
|
681
|
+
fault: (byte & 0x80) !== 0
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
## Advanced Topics
|
|
687
|
+
|
|
688
|
+
### Encryption
|
|
689
|
+
Some devices (like Victron) encrypt advertising data. If your device uses encryption:
|
|
690
|
+
|
|
691
|
+
**Prompt to AI:**
|
|
692
|
+
```
|
|
693
|
+
My device uses AES-CTR encryption with a 128-bit key. The encrypted data starts
|
|
694
|
+
at byte 8 of the manufacturer data. The nonce/counter is in bytes 0-7.
|
|
695
|
+
|
|
696
|
+
Please implement:
|
|
697
|
+
1. A decrypt() method using Node.js crypto module
|
|
698
|
+
2. Update propertiesChanged() to decrypt before parsing
|
|
699
|
+
3. Add encryptionKey parameter to configuration
|
|
700
|
+
|
|
701
|
+
Follow the pattern from VictronSensor.js.
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
### Multiple Cell Voltages
|
|
705
|
+
For batteries with multiple cells:
|
|
706
|
+
|
|
707
|
+
**Prompt to AI:**
|
|
708
|
+
```
|
|
709
|
+
The battery has [N] cells. Cell voltages are packed into the data:
|
|
710
|
+
- Starting at byte X
|
|
711
|
+
- Each cell is 2 bytes, little-endian
|
|
712
|
+
- Values are in millivolts (1mV = 0.001V)
|
|
713
|
+
|
|
714
|
+
Please:
|
|
715
|
+
1. Add a loop in initSchema() to create cell voltage paths dynamically
|
|
716
|
+
2. Create read functions for each cell
|
|
717
|
+
3. Use template {batteryID} in paths like:
|
|
718
|
+
electrical.batteries.{batteryID}.cell[N].voltage
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
### Checksum Validation
|
|
722
|
+
For protocols with checksums:
|
|
723
|
+
|
|
724
|
+
**Prompt to AI:**
|
|
725
|
+
```
|
|
726
|
+
The protocol includes a checksum at bytes [X-Y]:
|
|
727
|
+
- Type: [CRC-16/sum/XOR]
|
|
728
|
+
- Calculated over bytes [A-B]
|
|
729
|
+
- [Describe algorithm]
|
|
730
|
+
|
|
731
|
+
Please implement a validateChecksum() function and call it before parsing data.
|
|
732
|
+
Reject invalid packets with this.debug() message.
|
|
733
|
+
|
|
734
|
+
See JBDBMS.js checkSum() function as reference.
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
## Troubleshooting Guide
|
|
738
|
+
|
|
739
|
+
| Problem | Likely Cause | Solution Prompt |
|
|
740
|
+
|---------|-------------|-----------------|
|
|
741
|
+
| Device not appearing in scan | UUID filter, not advertising | "Check if identify() method is correct. Show me how to debug device detection." |
|
|
742
|
+
| Values are NaN | Wrong byte offsets, invalid data | "Values showing NaN. Help me verify byte offsets and add validation." |
|
|
743
|
+
| Connection fails | Wrong service UUID, device busy | "GATT connection failing with error: [error]. What should I check?" |
|
|
744
|
+
| Values incorrect magnitude | Wrong scaling factor | "Values are 100x too large. Check my scaling factors." |
|
|
745
|
+
| Negative when should be positive | Unsigned vs signed | "Current showing negative when charging. Fix my Int/UInt usage." |
|
|
746
|
+
| Updates stop after a while | Memory leak, no cleanup | "Device stops updating after 5 minutes. Check my cleanup in stopListening()." |
|
|
747
|
+
|
|
748
|
+
## Resources
|
|
749
|
+
|
|
750
|
+
- [SignalK Specification](http://signalk.org/specification/latest/doc/vesselsBranch.html)
|
|
751
|
+
- [Bluetooth GATT Specifications](https://www.bluetooth.com/specifications/specs/)
|
|
752
|
+
- [Node.js Buffer Documentation](https://nodejs.org/api/buffer.html)
|
|
753
|
+
- Plugin README: [`README.md`](README.md:1)
|
|
754
|
+
- Development Guide: [`sensor_classes/DEVELOPMENT.md`](sensor_classes/DEVELOPMENT.md:1)
|
|
755
|
+
- Base Sensor Class: [`BTSensor.js`](BTSensor.js:1)
|
|
756
|
+
|
|
757
|
+
## Summary
|
|
758
|
+
|
|
759
|
+
The AI-assisted development process follows this pattern:
|
|
760
|
+
|
|
761
|
+
1. **Research** - Analyze similar implementations
|
|
762
|
+
2. **Identify** - Determine communication method (advertising vs GATT)
|
|
763
|
+
3. **Create** - Generate skeleton sensor class
|
|
764
|
+
4. **Implement** - Add device identification logic
|
|
765
|
+
5. **Parse** - Implement data parsing with correct byte operations
|
|
766
|
+
6. **Map** - Create SignalK path mappings with defaults
|
|
767
|
+
7. **Register** - Add to classLoader.js
|
|
768
|
+
8. **Test** - Verify with real hardware
|
|
769
|
+
9. **Debug** - Fix scaling, offsets, byte order issues
|
|
770
|
+
10. **Refine** - Add error handling and documentation
|
|
771
|
+
11. **Complete** - Test thoroughly and contribute back
|
|
772
|
+
|
|
773
|
+
By providing clear, specific prompts with actual device data (hex dumps, protocol specs, test results), AI can effectively assist in creating robust, maintainable sensor implementations that follow the established patterns in this codebase.
|
|
774
|
+
|
|
775
|
+
## Key Success Factors
|
|
776
|
+
|
|
777
|
+
1. **Have real device data** - Actual advertising packets or GATT responses
|
|
778
|
+
2. **Know the protocol** - Byte layout, scaling factors, data types
|
|
779
|
+
3. **Test iteratively** - Make small changes, test each one
|
|
780
|
+
4. **Compare values** - Match against device's own display
|
|
781
|
+
5. **Use existing patterns** - Don't reinvent, follow proven implementations
|
|
782
|
+
6. **Document thoroughly** - Help the next developer
|
|
783
|
+
|
|
784
|
+
Good luck adding your Bluetooth battery! 🔋⚡
|
package/README.md
CHANGED
package/index.js
CHANGED
|
@@ -178,6 +178,11 @@ module.exports = function (app) {
|
|
|
178
178
|
minimum: 10,
|
|
179
179
|
maximum: 3600
|
|
180
180
|
},
|
|
181
|
+
inactivityTimeout: {title: "Inactivity timeout (in seconds). If no contact with any sensors for this period, the Bluetooth adapter will be recycled.",
|
|
182
|
+
type: "integer", default: 60,
|
|
183
|
+
minimum: 0,
|
|
184
|
+
maximum: 3600
|
|
185
|
+
},
|
|
181
186
|
discoveryInterval: {title: "Scan for new devices interval (in seconds-- 0 for no new device scanning)",
|
|
182
187
|
type: "integer",
|
|
183
188
|
default: 10,
|
|
@@ -203,6 +208,8 @@ module.exports = function (app) {
|
|
|
203
208
|
plugin.started=true
|
|
204
209
|
var adapterID=options.adapter
|
|
205
210
|
var foundConfiguredDevices=0
|
|
211
|
+
var lastContactDelta=Infinity
|
|
212
|
+
|
|
206
213
|
|
|
207
214
|
if (Object.keys(options).length==0){ //empty config means initial startup. save defaults and enabled=true.
|
|
208
215
|
let json = {configuration:{adapter:"hci0", transport:"le", discoveryTimeout:30, discoveryInterval:10}, enabled:true, enableDebug:false}
|
|
@@ -498,6 +505,10 @@ module.exports = function (app) {
|
|
|
498
505
|
s.listen()
|
|
499
506
|
if (config.active)
|
|
500
507
|
await s.activate(config, plugin)
|
|
508
|
+
else {
|
|
509
|
+
s.unsetError()
|
|
510
|
+
s.setState("DORMANT")
|
|
511
|
+
}
|
|
501
512
|
removeSensorFromList(s)
|
|
502
513
|
addSensorToList(s)
|
|
503
514
|
})
|
|
@@ -753,15 +764,28 @@ module.exports = function (app) {
|
|
|
753
764
|
}
|
|
754
765
|
const minTimeout=Math.min(...deviceConfigs.map((dc)=>dc?.discoveryTimeout??options.discoveryTimeout))
|
|
755
766
|
const intervalTimeout = ((minTimeout==Infinity)?(options?.discoveryTimeout??plugin.schema.properties.discoveryTimeout.default):minTimeout)*1000
|
|
756
|
-
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
deviceHealthID = setInterval( async ()=> {
|
|
757
771
|
sensorMap.forEach((sensor)=>{
|
|
758
772
|
const config = getDeviceConfig(sensor.getMacAddress())
|
|
759
773
|
const dt = config?.discoveryTimeout??options.discoveryTimeout
|
|
760
774
|
const lc=sensor.elapsedTimeSinceLastContact()
|
|
775
|
+
if (lc<lastContactDelta) //get min last contact delta
|
|
776
|
+
lastContactDelta=lc
|
|
761
777
|
if (lc > dt) {
|
|
762
778
|
updateSensor(sensor)
|
|
763
779
|
}
|
|
764
780
|
})
|
|
781
|
+
if (sensorMap.size && lastContactDelta > options.inactivityTimeout)
|
|
782
|
+
{
|
|
783
|
+
|
|
784
|
+
plugin.debug(`No contact with any sensors for ${lastContactDelta} seconds. Recycling Bluetooth adapter.`)
|
|
785
|
+
await adapter.setPowered(false)
|
|
786
|
+
await adapter.setPowered(true)
|
|
787
|
+
}
|
|
788
|
+
|
|
765
789
|
}, intervalTimeout)
|
|
766
790
|
|
|
767
791
|
if (!options.hasOwnProperty("discoveryInterval" )) //no config -- first run
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bt-sensors-plugin-sk",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.4-beta",
|
|
4
4
|
"description": "Bluetooth Sensors for Signalk - see https://www.npmjs.com/package/bt-sensors-plugin-sk#supported-sensors for a list of supported sensors",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"dependencies": {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"@jellybrick/dbus-next": "^0.10.3",
|
|
14
14
|
"int24": "^0.0.1",
|
|
15
15
|
"kaitai-struct": "^0.10.0",
|
|
16
|
-
"@naugehyde/node-ble":"^1.13.
|
|
16
|
+
"@naugehyde/node-ble":"^1.13.5",
|
|
17
17
|
"semver": "^7.7.1",
|
|
18
18
|
"lru-cache": "^11.1.0"
|
|
19
19
|
},
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
const BTSensor = require("../BTSensor");
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HSC14F Battery Management System Sensor Class
|
|
5
|
+
*
|
|
6
|
+
* Manufacturer: BMC (HumsiENK branded)
|
|
7
|
+
* Protocol: Custom BLE protocol using AA prefix commands
|
|
8
|
+
*
|
|
9
|
+
* Discovered protocol details:
|
|
10
|
+
* - TX Handle: 0x000c (write commands to battery)
|
|
11
|
+
* - RX Handle: 0x000e (receive notifications from battery)
|
|
12
|
+
* - Command format: aa [CMD] 00 [CMD] 00
|
|
13
|
+
* - Multi-part responses (some commands send 2-3 notifications)
|
|
14
|
+
*
|
|
15
|
+
* Key Commands:
|
|
16
|
+
* - 0x00: Handshake
|
|
17
|
+
* - 0x21: Real-time battery data (voltage, current, SOC, temps) - PRIMARY
|
|
18
|
+
* - 0x22: Individual cell voltages
|
|
19
|
+
* - 0x23: Battery status/warnings
|
|
20
|
+
* - 0x20: Configuration data
|
|
21
|
+
* - 0x10: Manufacturer name
|
|
22
|
+
* - 0x11: Model number
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
class HumsienkBMS extends BTSensor {
|
|
26
|
+
static Domain = BTSensor.SensorDomains.electrical;
|
|
27
|
+
|
|
28
|
+
// Discovered actual UUIDs from device
|
|
29
|
+
static TX_RX_SERVICE = "00000001-0000-1000-8000-00805f9b34fb";
|
|
30
|
+
static WRITE_CHAR_UUID = "00000002-0000-1000-8000-00805f9b34fb";
|
|
31
|
+
static NOTIFY_CHAR_UUID = "00000003-0000-1000-8000-00805f9b34fb";
|
|
32
|
+
|
|
33
|
+
static identify(device) {
|
|
34
|
+
// HSC14F batteries advertise with this service UUID
|
|
35
|
+
// Further identification would require GATT connection to read manufacturer/model
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
static ImageFile = "JBDBMS.webp"; // Using similar BMS image for now
|
|
40
|
+
static Manufacturer = "BMC (HumsiENK)";
|
|
41
|
+
static Description = "HSC14F LiFePO4 Battery Management System";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Create HSC14F command
|
|
45
|
+
* Format: aa [CMD] 00 [CMD] 00
|
|
46
|
+
*/
|
|
47
|
+
hsc14fCommand(command) {
|
|
48
|
+
return [0xaa, command, 0x00, command, 0x00];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Send command to battery
|
|
53
|
+
* HSC14F requires Write Request (0x12) not Write Command (0x52)
|
|
54
|
+
*/
|
|
55
|
+
async sendCommand(command) {
|
|
56
|
+
this.debug(`Sending command 0x${command.toString(16)} to ${this.getName()}`);
|
|
57
|
+
return await this.txChar.writeValue(
|
|
58
|
+
Buffer.from(this.hsc14fCommand(command))
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async initSchema() {
|
|
63
|
+
super.initSchema();
|
|
64
|
+
this.addDefaultParam("batteryID");
|
|
65
|
+
|
|
66
|
+
// Number of cells parameter - configurable (default 4 for LiFePO4)
|
|
67
|
+
if (this.numberOfCells === undefined) {
|
|
68
|
+
this.numberOfCells = 4;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
this.addParameter("numberOfCells", {
|
|
72
|
+
title: "Number of cells",
|
|
73
|
+
description: "Number of cells in the battery (typically 4 for 12V LiFePO4)",
|
|
74
|
+
type: "number",
|
|
75
|
+
isRequired: true,
|
|
76
|
+
default: this.numberOfCells,
|
|
77
|
+
minimum: 1,
|
|
78
|
+
maximum: 16,
|
|
79
|
+
multipleOf: 1
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Voltage
|
|
83
|
+
this.addDefaultPath("voltage", "electrical.batteries.voltage").read = (
|
|
84
|
+
buffer
|
|
85
|
+
) => {
|
|
86
|
+
// Bytes 3-4: voltage in mV, little-endian
|
|
87
|
+
return buffer.readUInt16LE(3) / 1000;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// Current - CORRECTED based on buffer analysis
|
|
91
|
+
this.addDefaultPath("current", "electrical.batteries.current").read = (
|
|
92
|
+
buffer
|
|
93
|
+
) => {
|
|
94
|
+
// Bytes 7-8: current in milliamps, signed little-endian
|
|
95
|
+
// Negative = charging, Positive = discharging
|
|
96
|
+
return buffer.readInt16LE(7) / 1000;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// State of Charge
|
|
100
|
+
this.addDefaultPath(
|
|
101
|
+
"SOC",
|
|
102
|
+
"electrical.batteries.capacity.stateOfCharge"
|
|
103
|
+
).read = (buffer) => {
|
|
104
|
+
// Byte 11: SOC percentage (0-100)
|
|
105
|
+
return buffer.readUInt8(11) / 100;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// Temperature 1 (ENV temperature) - CORRECTED byte position
|
|
109
|
+
this.addMetadatum("temp1", "K", "Battery Environment Temperature", (buffer) => {
|
|
110
|
+
// Byte 27: Environment temperature in °C
|
|
111
|
+
const tempC = buffer.readUInt8(27);
|
|
112
|
+
return 273.15 + tempC; // Convert to Kelvin
|
|
113
|
+
}).default = "electrical.batteries.{batteryID}.temperature";
|
|
114
|
+
|
|
115
|
+
// Temperature 2 (MOS temperature) - CORRECTED byte position
|
|
116
|
+
this.addMetadatum("temp2", "K", "Battery MOS Temperature", (buffer) => {
|
|
117
|
+
// Byte 28: MOS (MOSFET) temperature in °C
|
|
118
|
+
const tempC = buffer.readUInt8(28);
|
|
119
|
+
return 273.15 + tempC;
|
|
120
|
+
}).default = "electrical.batteries.{batteryID}.mosfetTemperature";
|
|
121
|
+
|
|
122
|
+
// Temperature 3 (Sensor) - CORRECTED byte position
|
|
123
|
+
this.addMetadatum("temp3", "K", "Battery Sensor Temperature", (buffer) => {
|
|
124
|
+
// Byte 29: Additional temperature sensor in °C
|
|
125
|
+
const tempC = buffer.readUInt8(29);
|
|
126
|
+
return 273.15 + tempC;
|
|
127
|
+
}).default = "electrical.batteries.{batteryID}.sensorTemperature";
|
|
128
|
+
|
|
129
|
+
// Manufacturer (from command 0x10)
|
|
130
|
+
this.addMetadatum(
|
|
131
|
+
"manufacturer",
|
|
132
|
+
"",
|
|
133
|
+
"Battery Manufacturer",
|
|
134
|
+
(buffer) => {
|
|
135
|
+
// Response: aa 10 03 42 4d 43 ...
|
|
136
|
+
// ASCII bytes starting at position 3
|
|
137
|
+
const len = buffer.readUInt8(2);
|
|
138
|
+
return buffer.toString("ascii", 3, 3 + len);
|
|
139
|
+
}
|
|
140
|
+
).default = "electrical.batteries.{batteryID}.manufacturer";
|
|
141
|
+
|
|
142
|
+
// Model (from command 0x11)
|
|
143
|
+
this.addMetadatum("model", "", "Battery Model", (buffer) => {
|
|
144
|
+
// Response: aa 11 0a 42 4d 43 2d 30 34 53 30 30 31 ...
|
|
145
|
+
const len = buffer.readUInt8(2);
|
|
146
|
+
return buffer.toString("ascii", 3, 3 + len);
|
|
147
|
+
}).default = "electrical.batteries.{batteryID}.model";
|
|
148
|
+
|
|
149
|
+
// Cell voltages (from command 0x22)
|
|
150
|
+
// Number of cells is configurable via numberOfCells parameter
|
|
151
|
+
for (let i = 0; i < this.numberOfCells; i++) {
|
|
152
|
+
this.addMetadatum(
|
|
153
|
+
`cell${i}Voltage`,
|
|
154
|
+
"V",
|
|
155
|
+
`Cell ${i + 1} voltage`,
|
|
156
|
+
(buffer) => {
|
|
157
|
+
// Cell voltages: aa 22 30 6a 0d 58 0d 8f 0d 34 0d ...
|
|
158
|
+
// Starting at byte 3, each cell is 2 bytes little-endian in mV
|
|
159
|
+
return buffer.readUInt16LE(3 + i * 2) / 1000;
|
|
160
|
+
}
|
|
161
|
+
).default = `electrical.batteries.{batteryID}.cell${i}.voltage`;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
hasGATT() {
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
usingGATT() {
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async initGATTNotifications() {
|
|
174
|
+
// Don't use notifications for polling mode
|
|
175
|
+
// The parent class initGATTInterval will handle periodic connections
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async emitGATT() {
|
|
179
|
+
try {
|
|
180
|
+
await this.getAndEmitBatteryInfo();
|
|
181
|
+
} catch (e) {
|
|
182
|
+
this.debug(`Failed to emit battery info for ${this.getName()}: ${e}`);
|
|
183
|
+
throw e; // Re-throw so parent can handle reconnection
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Get cell voltages after a short delay
|
|
187
|
+
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
await this.getAndEmitCellVoltages();
|
|
191
|
+
} catch (e) {
|
|
192
|
+
this.debug(`Failed to emit cell voltages for ${this.getName()}: ${e}`);
|
|
193
|
+
throw e; // Re-throw so parent can handle reconnection
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Get buffer response from battery command
|
|
199
|
+
* HSC14F sends multi-part responses for some commands
|
|
200
|
+
*/
|
|
201
|
+
async getBuffer(command) {
|
|
202
|
+
let result = Buffer.alloc(256);
|
|
203
|
+
let offset = 0;
|
|
204
|
+
let lastPacketTime = Date.now();
|
|
205
|
+
|
|
206
|
+
// Set up listener first
|
|
207
|
+
const responsePromise = new Promise((resolve, reject) => {
|
|
208
|
+
const timer = setTimeout(() => {
|
|
209
|
+
clearTimeout(timer);
|
|
210
|
+
if (completionTimer) clearTimeout(completionTimer);
|
|
211
|
+
this.rxChar.removeAllListeners("valuechanged");
|
|
212
|
+
reject(
|
|
213
|
+
new Error(
|
|
214
|
+
`Response timed out (+10s) from HSC14F device ${this.getName()}.`
|
|
215
|
+
)
|
|
216
|
+
);
|
|
217
|
+
}, 10000);
|
|
218
|
+
|
|
219
|
+
let completionTimer = null;
|
|
220
|
+
|
|
221
|
+
const valChanged = (buffer) => {
|
|
222
|
+
// HSC14F responses start with 0xaa followed by command byte
|
|
223
|
+
if (offset === 0 && (buffer[0] !== 0xaa || buffer[1] !== command)) {
|
|
224
|
+
this.debug(
|
|
225
|
+
`Invalid buffer from ${this.getName()}, expected command 0x${command.toString(
|
|
226
|
+
16
|
|
227
|
+
)}, got 0x${buffer[0].toString(16)} 0x${buffer[1].toString(16)}`
|
|
228
|
+
);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
buffer.copy(result, offset);
|
|
233
|
+
offset += buffer.length;
|
|
234
|
+
lastPacketTime = Date.now();
|
|
235
|
+
|
|
236
|
+
// Clear any existing completion timer
|
|
237
|
+
if (completionTimer) clearTimeout(completionTimer);
|
|
238
|
+
|
|
239
|
+
// Wait 200ms after last packet to consider response complete
|
|
240
|
+
// This allows multi-packet responses to assemble properly
|
|
241
|
+
completionTimer = setTimeout(() => {
|
|
242
|
+
result = Uint8Array.prototype.slice.call(result, 0, offset);
|
|
243
|
+
this.rxChar.removeAllListeners("valuechanged");
|
|
244
|
+
clearTimeout(timer);
|
|
245
|
+
resolve(result);
|
|
246
|
+
}, 200);
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
// Set up listener BEFORE sending command
|
|
250
|
+
this.rxChar.on("valuechanged", valChanged);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// Small delay to ensure listener is attached
|
|
254
|
+
await new Promise(r => setTimeout(r, 100));
|
|
255
|
+
|
|
256
|
+
// Send the command
|
|
257
|
+
try {
|
|
258
|
+
await this.sendCommand(command);
|
|
259
|
+
} catch (err) {
|
|
260
|
+
this.rxChar.removeAllListeners("valuechanged");
|
|
261
|
+
throw err;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Wait for response
|
|
265
|
+
return responsePromise;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async initGATTConnection(isReconnecting = false) {
|
|
269
|
+
await super.initGATTConnection(isReconnecting);
|
|
270
|
+
|
|
271
|
+
// Set up GATT characteristics
|
|
272
|
+
const gattServer = await this.device.gatt();
|
|
273
|
+
const txRxService = await gattServer.getPrimaryService(
|
|
274
|
+
this.constructor.TX_RX_SERVICE
|
|
275
|
+
);
|
|
276
|
+
this.rxChar = await txRxService.getCharacteristic(
|
|
277
|
+
this.constructor.NOTIFY_CHAR_UUID
|
|
278
|
+
);
|
|
279
|
+
this.txChar = await txRxService.getCharacteristic(
|
|
280
|
+
this.constructor.WRITE_CHAR_UUID
|
|
281
|
+
);
|
|
282
|
+
await this.rxChar.startNotifications();
|
|
283
|
+
|
|
284
|
+
return this;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Get and emit main battery data (voltage, current, SOC, temp)
|
|
289
|
+
* Uses command 0x21
|
|
290
|
+
*/
|
|
291
|
+
async getAndEmitBatteryInfo() {
|
|
292
|
+
return this.getBuffer(0x21).then((buffer) => {
|
|
293
|
+
// Debug logging to verify buffer received
|
|
294
|
+
this.debug(`Command 0x21 response: ${buffer.length} bytes, hex: ${buffer.slice(0, 30).toString('hex')}`);
|
|
295
|
+
|
|
296
|
+
["voltage", "current", "SOC", "temp1", "temp2", "temp3"].forEach((tag) => {
|
|
297
|
+
this.emitData(tag, buffer);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Get and emit individual cell voltages
|
|
304
|
+
* Uses command 0x22
|
|
305
|
+
*/
|
|
306
|
+
async getAndEmitCellVoltages() {
|
|
307
|
+
return this.getBuffer(0x22).then((buffer) => {
|
|
308
|
+
// Debug logging to verify buffer received
|
|
309
|
+
this.debug(`Command 0x22 response: ${buffer.length} bytes, hex: ${buffer.slice(0, 30).toString('hex')}`);
|
|
310
|
+
|
|
311
|
+
for (let i = 0; i < this.numberOfCells; i++) {
|
|
312
|
+
this.emitData(`cell${i}Voltage`, buffer);
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async initGATTInterval() {
|
|
318
|
+
// Get static info once (manufacturer, model) at first connection
|
|
319
|
+
try {
|
|
320
|
+
const mfgBuffer = await this.getBuffer(0x10);
|
|
321
|
+
this.emitData("manufacturer", mfgBuffer);
|
|
322
|
+
|
|
323
|
+
const modelBuffer = await this.getBuffer(0x11);
|
|
324
|
+
this.emitData("model", modelBuffer);
|
|
325
|
+
} catch (e) {
|
|
326
|
+
this.debug(`Failed to get device info: ${e.message}`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Get first poll data before disconnecting
|
|
330
|
+
try {
|
|
331
|
+
await this.emitGATT();
|
|
332
|
+
} catch (error) {
|
|
333
|
+
this.debug(`Initial poll failed: ${error.message}`);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Disconnect after initial data collection
|
|
337
|
+
await this.deactivateGATT().catch((e) => {
|
|
338
|
+
this.debug(`Error deactivating GATT Connection: ${e.message}`);
|
|
339
|
+
});
|
|
340
|
+
this.setState("WAITING");
|
|
341
|
+
|
|
342
|
+
// Set up polling interval - reconnect, poll, disconnect
|
|
343
|
+
this.intervalID = setInterval(async () => {
|
|
344
|
+
try {
|
|
345
|
+
this.setState("CONNECTING");
|
|
346
|
+
await this.initGATTConnection(true);
|
|
347
|
+
await this.emitGATT();
|
|
348
|
+
} catch (error) {
|
|
349
|
+
// Check if device has been removed from BlueZ cache
|
|
350
|
+
if (error.message && error.message.includes("interface not found in proxy object")) {
|
|
351
|
+
this.debug(`Device removed from BlueZ cache. Clearing stale connection state.`);
|
|
352
|
+
this.setError(`Device out of range or removed from Bluetooth cache. Waiting for rediscovery...`);
|
|
353
|
+
|
|
354
|
+
// Clear the interval to stop futile reconnection attempts
|
|
355
|
+
if (this.intervalID) {
|
|
356
|
+
clearInterval(this.intervalID);
|
|
357
|
+
this.intervalID = null;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Set state to indicate waiting for rediscovery
|
|
361
|
+
this.setState("OUT_OF_RANGE");
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
this.debug(error);
|
|
366
|
+
this.setError(`Unable to emit values for device: ${error.message}`);
|
|
367
|
+
} finally {
|
|
368
|
+
await this.deactivateGATT().catch((e) => {
|
|
369
|
+
// Suppress errors when device is already removed from BlueZ
|
|
370
|
+
if (!e.message || !e.message.includes("interface not found")) {
|
|
371
|
+
this.debug(`Error deactivating GATT Connection: ${e.message}`);
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
this.setState("WAITING");
|
|
375
|
+
}
|
|
376
|
+
}, this.pollFreq * 1000);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async deactivateGATT() {
|
|
380
|
+
await this.stopGATTNotifications(this.rxChar);
|
|
381
|
+
await super.deactivateGATT();
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
module.exports = HumsienkBMS;
|