diff --git a/devdocs/devbin-backwards-compatibility.md b/devdocs/devbin-backwards-compatibility.md new file mode 100644 index 0000000..bc32216 --- /dev/null +++ b/devdocs/devbin-backwards-compatibility.md @@ -0,0 +1,105 @@ +# Devbin Backwards Compatibility + +## Context + +RaftJS is used by both Axiom and Cog. Axiom was already using the current devbin payload layout, but Cog firmware v1.9.5 is already in production and publishes device data using an older RaftCore record body layout. The app therefore has to keep supporting the newer Axiom/Cog format while remaining compatible with Cog v1.9.5 devices in the field. + +The failure mode seen in Axiom Experiment App was: + +- Cog connected successfully over BLE. +- The live preview received binary device messages. +- Accelerometer data did not appear. +- The browser logged malformed sample warnings and previously could surface errors such as `RangeError: Offset is outside the bounds of the DataView`. + +The root cause was a format mismatch. RaftJS assumed that devbin records contained a device sequence byte followed by length-prefixed samples. Cog v1.9.5 sends fixed-size raw samples without the device sequence byte. When parsed as the newer format, the first timestamp byte was interpreted as a sample length, so the parser read the wrong boundaries and eventually tried to decode attributes past the end of a sample. + +## Supported Record Layouts + +### Current Format + +Current devbin frames use: + +```text +[msgType:2] +[devbin envelope: magic/version, topicIndex, envelopeSeq] +[recordLen:2] +[statusBus:1] +[address:4] +[devTypeIdx:2] +[deviceSeq:1] +[sampleLen:1][sampleData:sampleLen]... +``` + +`sampleData` contains the poll-result timestamp followed by the device payload. This is the format used by current Axiom builds and newer Raft firmware. + +### Cog v1.9.5 Legacy Format + +Cog v1.9.5 uses the older record body: + +```text +[msgType:2] +optional [devbin envelope] +[recordLen:2] +[statusBus:1] +[address:4] +[devTypeIdx:2] +[timestamp:2][payload:fixedSize]... +``` + +There is no per-device sequence byte and no per-sample length byte. Samples are decoded using the fixed payload size derived from the device type metadata. + +In testing, Cog v1.9.5 was observed sending a hybrid shape: the newer `DB` devbin envelope was present, but each record still used the legacy raw sample body. The parser must therefore not infer the record body format from the envelope alone. + +## RaftJS Parser Behavior + +`DeviceManager.handleClientMsgBinary` now supports two record payload modes: + +- `lengthPrefixed`: current records with `deviceSeq` and `[sampleLen][sampleData]`. +- `legacyRaw`: Cog v1.9.5 records with fixed-size `[timestamp][payload]` samples and no `deviceSeq`. + +The parser first locates the record stream using the message prefix and optional devbin envelope. After it has the `devTypeIdx`, it fetches the device type info and validates the actual sample layout against the metadata. This lets it correctly identify the Cog v1.9.5 hybrid case where the frame has the current envelope but the record body is legacy raw. + +Malformed samples are bounded to their record/sample range before decoding. If a sample cannot be decoded, RaftJS skips that sample and emits a throttled warning rather than throwing from `DataView`. + +## Legacy Sample Size + +For legacy raw records, the fixed sample size is: + +```text +2-byte timestamp + payload size +``` + +The payload size is normally derived from the sum of the attribute struct sizes in `resp.a`. If a custom response handler is used, or if the schema cannot be sized safely, RaftJS falls back to `resp.b`. + +This schema-derived sizing is required for Cog v1.9.5 light sensor records because that firmware reports a doubled light payload size in metadata while the actual raw record contains one fixed payload matching the attribute schema. + +## Direct Device Key Compatibility + +Cog v1.9.5 publishes multiple direct-connected devices on bus `0`, address `0`. In the current key scheme this collapses to a single `0_0` device and causes metadata collisions, for example LightSensors and Power sharing the same key. + +For legacy raw records only, RaftJS appends the device type index to direct bus/address zero records: + +```text +0_0_ +``` + +This keeps legacy Cog direct devices distinct while preserving the existing key behavior for Axiom and newer length-prefixed records. + +Command paths should use the stored `DeviceState.busName` and `DeviceState.deviceAddress`, not only parse the displayed device key. This avoids sending commands to an address such as `0_2` when the compatibility key is `0_0_2`. + +## Verification + +The compatibility behavior is covered by `src/RaftDeviceManager.test.ts`: + +- current length-prefixed records decode correctly +- Cog v1.9.5 raw accelerometer records decode correctly +- Cog v1.9.5 raw records inside a devbin envelope decode correctly +- legacy direct devices with bus/address `0_0` stay distinct by device type index + +The real-device validation used Axiom Experiment App: + +- Cog v1.9.5 connected over BLE as `Robotical Cog` +- live `MXC400xXC` `ax`, `ay`, and `az` samples appeared in simple mode +- no page errors were observed during the live-preview watch +- an Axiom real-device connection still decoded live LSM6DS data, confirming the current length-prefixed path remained intact + diff --git a/notes/web-ble-reconnect-retry.md b/notes/web-ble-reconnect-retry.md new file mode 100644 index 0000000..b1754a3 --- /dev/null +++ b/notes/web-ble-reconnect-retry.md @@ -0,0 +1,69 @@ +# Web BLE Reconnect Recovery + +Date: 2026-04-29 + +## Issue + +The Axiom offline data logging UI can be refreshed while Web Bluetooth is still +connected. In that path the browser may report `gatt.connect()` success and then +disconnect during `getPrimaryService()`, producing an error like: + +```text +GATT Server is disconnected. Cannot retrieve services. (Re)connect first with `device.gatt.connect`. +``` + +Before this change, `RaftChannelBLE.web.ts` treated a failed primary-service +lookup as a terminal failure and returned `false` immediately. That meant a +transient reconnect race could fail the whole app-level connection attempt even +though the device was still running and still advertising. + +The harsher path is a browser hard refresh while BLE is still connected. During +page unload, the normal app disconnect path cannot reliably complete because it +sends a graceful BLE command and waits asynchronously. If the page disappears +before that finishes, the browser can keep the GATT connection in a half-closed +state long enough for the next immediate reconnect to fail. + +## Solution + +`RaftChannelBLE.connect()` now keeps the existing connection retry loop active +for primary-service lookup failures: + +- If no supported primary service is found and more connection attempts remain, + it disconnects any still-open GATT connection. +- It waits briefly before retrying. +- It only returns `false` after the final service lookup attempt fails. + +This preserves the previous final failure behavior while allowing transient +Web Bluetooth/GATT cleanup races to recover inside the raftjs channel. + +`RaftConnector` also now exposes `disconnectForPageUnload()`. It is deliberately +smaller than the normal `disconnect()` path: + +- It disables automatic lost-connection retry. +- It detaches the current channel from the connector immediately. +- It starts the channel-level GATT disconnect without waiting for the normal + graceful BLE command sequence. + +The Axiom app uses this from `beforeunload`/`pagehide` so a browser hard refresh +still starts Web Bluetooth cleanup before the page is replaced. Normal user +disconnects continue to use the existing graceful path. + +## Validation + +Validation was done from `Axiom-Experiment-App` against real Axiom hardware +(`Axiom009_adcf1e`) with firmware serial logs open on `/dev/cu.usbmodem2101`. + +The diagnostic flow was: + +1. Connect over Web Bluetooth. +2. Start Axiom offline data logging for LSM6DS at 1 Hz. +3. Hard-refresh the browser without issuing the app-level Disconnect command. +4. Immediately reconnect over Web Bluetooth. +5. Confirm the offline logger is still active and points to the same log file. +6. Stop the session and delete the generated e2e log. + +The firmware logs showed no reboot or panic during the refresh/reconnect flow. +The logger stayed active across the browser refresh. The page-unload run showed +the Axiom app calling the immediate GATT disconnect path, the firmware observing +`BLE connection change isConn NO`, and reconnect reporting the same active log +with additional samples. diff --git a/src/RaftAttributeHandler.ts b/src/RaftAttributeHandler.ts index 5d67d95..369895c 100644 --- a/src/RaftAttributeHandler.ts +++ b/src/RaftAttributeHandler.ts @@ -23,12 +23,13 @@ export default class AttributeHandler { private POLL_RESULT_RESOLUTION_US = 100; public processMsgAttrGroup(msgBuffer: Uint8Array, msgBufIdx: number, deviceTimeline: DeviceTimeline, pollRespMetadata: DeviceTypePollRespMetadata, - devAttrsState: DeviceAttributesState, maxDataPoints: number): number { + devAttrsState: DeviceAttributesState, maxDataPoints: number, msgEndIdx = msgBuffer.length): number { // console.log(`processMsgAttrGroup msg ${msgHexStr} timestamp ${timestamp} origTimestamp ${origTimestamp} msgBufIdx ${msgBufIdx}`) + const boundedMsgEndIdx = Math.min(Math.max(msgEndIdx, msgBufIdx), msgBuffer.length); // Extract msg timestamp - const { newBufIdx, timestampUs } = this.extractTimestampAndAdvanceIdx(msgBuffer, msgBufIdx, deviceTimeline); + const { newBufIdx, timestampUs } = this.extractTimestampAndAdvanceIdx(msgBuffer, msgBufIdx, deviceTimeline, boundedMsgEndIdx); if (newBufIdx < 0) return -1; msgBufIdx = newBufIdx; @@ -41,7 +42,7 @@ export default class AttributeHandler { if ("c" in pollRespMetadata) { // Extract attribute values using custom handler - newAttrValues = this._customAttrHandler.handleAttr(pollRespMetadata, msgBuffer, msgBufIdx); + newAttrValues = this._customAttrHandler.handleAttr(pollRespMetadata, msgBuffer, msgBufIdx, boundedMsgEndIdx); // Apply per-attribute transforms that the custom handler doesn't handle for (let attrIdx = 0; attrIdx < pollRespMetadata.a.length && attrIdx < newAttrValues.length; attrIdx++) { @@ -88,10 +89,9 @@ export default class AttributeHandler { // console.log(`RaftAttrHdlr.processMsgAttrGroup attr ${attrDef.n} msgBufIdx ${msgBufIdx} timestampUs ${timestampUs} attrDef ${JSON.stringify(attrDef)}`); // Process the attribute - const { values, newMsgBufIdx } = this.processMsgAttribute(attrDef, msgBuffer, msgBufIdx, msgDataStartIdx); + const { values, newMsgBufIdx } = this.processMsgAttribute(attrDef, msgBuffer, msgBufIdx, msgDataStartIdx, boundedMsgEndIdx); if (newMsgBufIdx < 0) { - newAttrValues.push([]); - continue; + return -1; } msgBufIdx = newMsgBufIdx; newAttrValues.push(values); @@ -107,7 +107,7 @@ export default class AttributeHandler { // Check if any attributes were added (in addition to timestamp) if (newAttrValues.length === 0) { console.warn(`DeviceManager msg attrGroup ${JSON.stringify(pollRespMetadata)} newAttrValues ${newAttrValues} is empty`); - return msgDataStartIdx+pollRespSizeBytes; + return -1; } // All attributes must have the same number of new values @@ -175,6 +175,11 @@ export default class AttributeHandler { const alpha = deviceTimeline.emaCalibrationPolls < 20 ? 0.3 : 0.05; deviceTimeline.emaIntervalUs = alpha * instantIntervalUs + (1.0 - alpha) * deviceTimeline.emaIntervalUs; + } else if (numNewDataPoints === 1) { + const instantIntervalUs = timestampUs - deviceTimeline.emaPrevPollTimeUs; + if (Number.isFinite(instantIntervalUs) && instantIntervalUs > 0) { + deviceTimeline.emaIntervalUs = instantIntervalUs; + } } deviceTimeline.emaPrevPollTimeUs = timestampUs; deviceTimeline.emaCalibrationPolls++; @@ -186,7 +191,9 @@ export default class AttributeHandler { ? deviceTimeline.timestampsUs[deviceTimeline.timestampsUs.length - 1] : -Infinity; for (let i = 0; i < numNewDataPoints; i++) { - timestampsUs[i] = deviceTimeline.emaLastSampleTimeUs + (i + 1) * deviceTimeline.emaIntervalUs; + timestampsUs[i] = numNewDataPoints === 1 + ? timestampUs + : deviceTimeline.emaLastSampleTimeUs + (i + 1) * deviceTimeline.emaIntervalUs; // Ensure monotonically increasing timestamps if (i === 0 && timestampsUs[0] <= lastTimeUs) { timestampsUs[0] = lastTimeUs + 1; @@ -196,7 +203,7 @@ export default class AttributeHandler { } // Advance the piecewise model cursor past all samples in this batch if (deviceTimeline.emaCalibrated && numNewDataPoints > 0) { - deviceTimeline.emaLastSampleTimeUs += numNewDataPoints * deviceTimeline.emaIntervalUs; + deviceTimeline.emaLastSampleTimeUs = timestampsUs[timestampsUs.length - 1]; } // Check if timeline points need to be discarded @@ -265,18 +272,21 @@ export default class AttributeHandler { } } - private processMsgAttribute(attrDef: DeviceTypeAttribute, msgBuffer: Uint8Array, msgBufIdx: number, msgDataStartIdx: number): { values: (number | string)[], newMsgBufIdx: number} { + private processMsgAttribute(attrDef: DeviceTypeAttribute, msgBuffer: Uint8Array, msgBufIdx: number, msgDataStartIdx: number, msgEndIdx: number): { values: (number | string)[], newMsgBufIdx: number} { // Current field message string index let curFieldBufIdx = msgBufIdx; let attrUsesAbsPos = false; + const boundedMsgEndIdx = Math.min(Math.max(msgEndIdx, msgDataStartIdx), msgBuffer.length); + const attrTypesOnly = attrDef.t; + const numBytesConsumed = structSizeOf(attrTypesOnly); // Check for "at" field which means absolute position in the buffer if (attrDef.at !== undefined) { // Handle both single value and array of byte positions if (Array.isArray(attrDef.at)) { // Create a new buffer for non-contiguous data extraction - const elemSize = structSizeOf(attrDef.t); + const elemSize = numBytesConsumed; const bytesForType = new Uint8Array(elemSize); // Zero out the buffer @@ -285,14 +295,16 @@ export default class AttributeHandler { // Copy bytes from the specified positions for (let i = 0; i < attrDef.at.length && i < elemSize; i++) { const sourceIdx = msgDataStartIdx + attrDef.at[i]; - if (sourceIdx < msgBuffer.length) { - bytesForType[i] = msgBuffer[sourceIdx]; + if (sourceIdx >= boundedMsgEndIdx) { + return { values: [], newMsgBufIdx: -1 }; } + bytesForType[i] = msgBuffer[sourceIdx]; } // Use this buffer for attribute extraction msgBuffer = bytesForType; curFieldBufIdx = 0; + msgEndIdx = bytesForType.length; } else { // Standard absolute position in the buffer curFieldBufIdx = msgDataStartIdx + attrDef.at; @@ -301,16 +313,15 @@ export default class AttributeHandler { } // Check if outside bounds of message - if (curFieldBufIdx >= msgBuffer.length) { + const attrEndIdx = curFieldBufIdx + numBytesConsumed; + const effectiveMsgEndIdx = Math.min(Math.max(msgEndIdx, curFieldBufIdx), msgBuffer.length); + if (curFieldBufIdx >= effectiveMsgEndIdx || attrEndIdx > effectiveMsgEndIdx) { // console.warn(`DeviceManager msg outside bounds msgBuffer ${msgBuffer} attrName ${attrDef.n}`); return { values: [], newMsgBufIdx: -1 }; } - // Attribute type - const attrTypesOnly = attrDef.t; - // Slice into buffer - const attrBuf = msgBuffer.slice(curFieldBufIdx); + const attrBuf = msgBuffer.slice(curFieldBufIdx, effectiveMsgEndIdx); // Check if a mask is used and the value is signed const maskOnSignedValue = "m" in attrDef && isAttrTypeSigned(attrTypesOnly); @@ -319,9 +330,6 @@ export default class AttributeHandler { const unpackValues = structUnpack(maskOnSignedValue ? attrTypesOnly.toUpperCase() : attrTypesOnly, attrBuf); let attrValues = unpackValues as (number | string)[]; - // Get number of bytes consumed - const numBytesConsumed = structSizeOf(attrTypesOnly); - // Check if any values are strings (from 's' format) — skip numeric transforms for those const hasStringValues = attrValues.some(v => typeof v === 'string'); @@ -443,11 +451,12 @@ export default class AttributeHandler { return value; } - private extractTimestampAndAdvanceIdx(msgBuffer: Uint8Array, msgBufIdx: number, timestampWrapHandler: DeviceTimeline): + private extractTimestampAndAdvanceIdx(msgBuffer: Uint8Array, msgBufIdx: number, timestampWrapHandler: DeviceTimeline, msgEndIdx = msgBuffer.length): { newBufIdx: number, timestampUs: number } { + const boundedMsgEndIdx = Math.min(Math.max(msgEndIdx, msgBufIdx), msgBuffer.length); // Check there are enough bytes for the timestamp - if (msgBufIdx + this.POLL_RESULT_TIMESTAMP_SIZE > msgBuffer.length) { + if (msgBufIdx + this.POLL_RESULT_TIMESTAMP_SIZE > boundedMsgEndIdx) { return { newBufIdx: -1, timestampUs: 0 }; } @@ -514,4 +523,4 @@ export default class AttributeHandler { return false; } -} \ No newline at end of file +} diff --git a/src/RaftChannelBLE.web.ts b/src/RaftChannelBLE.web.ts index 7058906..8617ebf 100644 --- a/src/RaftChannelBLE.web.ts +++ b/src/RaftChannelBLE.web.ts @@ -41,6 +41,7 @@ export default class RaftChannelBLE implements RaftChannel { // Connected flag and retries private _isConnected = false; private readonly _maxConnRetries = 3; + private readonly _connRetryDelayMs = 500; // Event listener fn private _eventListenerFn: ((event: Event) => void) | null = null; @@ -120,6 +121,18 @@ export default class RaftChannelBLE implements RaftChannel { return this._bleDevice || ""; } + private async disconnectGattBeforeRetry(): Promise { + if (!this._bleDevice?.gatt?.connected) { + return; + } + + try { + await this._bleDevice.gatt.disconnect(); + } catch (error) { + RaftLog.warn(`RaftChannelBLE.connect - cannot disconnect before retry ${error}`); + } + } + // Connect to a device async connect(locator: string | object, _connectorOptions: ConnectorOptions): Promise { // RaftLog.debug(`Selected device: ${deviceID}`); @@ -159,10 +172,19 @@ export default class RaftChannelBLE implements RaftChannel { } if (!service) { + if (connRetry === this._maxConnRetries - 1) { + RaftLog.warn( + `RaftChannelBLE.connect - cannot get primary service - attempt #${connRetry + 1} - giving up` + ); + return false; + } + RaftLog.warn( - `RaftChannelBLE.connect - cannot get primary service - giving up` + `RaftChannelBLE.connect - cannot get primary service - attempt #${connRetry + 1} - retrying` ); - return false; + await this.disconnectGattBeforeRetry(); + await new Promise(resolve => setTimeout(resolve, this._connRetryDelayMs)); + continue; } RaftLog.debug( `RaftChannelBLE.connect - found service: ${service.uuid}` @@ -391,4 +413,4 @@ export default class RaftChannelBLE implements RaftChannel { return null as T; } -} \ No newline at end of file +} diff --git a/src/RaftChannelSimulated.ts b/src/RaftChannelSimulated.ts index c19a34e..a3c25c8 100644 --- a/src/RaftChannelSimulated.ts +++ b/src/RaftChannelSimulated.ts @@ -23,6 +23,8 @@ interface SimulatedDeviceInfo { export default class RaftChannelSimulated implements RaftChannel { + private readonly POLL_RESULT_TIMESTAMP_RESOLUTION_US = 100; + // Message handler private _raftMsgHandler: RaftMsgHandler | null = null; @@ -292,8 +294,10 @@ export default class RaftChannelSimulated implements RaftChannel { const dataView = new DataView(dataBuffer); let bytePos = 0; - // Add 16 bit big endian deviceTimeMs mod 65536 to the buffer - dataView.setUint16(bytePos, deviceTimeMs % 65536, false); + // Add 16-bit big-endian timestamp ticks. Poll timestamps are decoded in + // 100us units by RaftAttributeHandler. + const timestampTicks = Math.floor((deviceTimeMs * 1000) / this.POLL_RESULT_TIMESTAMP_RESOLUTION_US) % 65536; + dataView.setUint16(bytePos, timestampTicks, false); bytePos += 2; const handledByCustomGenerator = this._fillCustomRawData( @@ -1174,4 +1178,4 @@ export default class RaftChannelSimulated implements RaftChannel { } }; -} \ No newline at end of file +} diff --git a/src/RaftConnector.ts b/src/RaftConnector.ts index e34f144..84c38d4 100644 --- a/src/RaftConnector.ts +++ b/src/RaftConnector.ts @@ -380,6 +380,23 @@ export default class RaftConnector { } } + disconnectForPageUnload(): void { + this._retryIfLostIsConnected = false; + + if (!this._raftChannel) { + return; + } + + const channelToDisconnect = this._raftChannel; + this._raftChannel = null; + + try { + void channelToDisconnect.disconnect(); + } catch (error) { + RaftLog.warn(`RaftConnector.disconnectForPageUnload failed ${error}`); + } + } + // Mark: Tx Message handling ----------------------------------------------------------------------------------------- /** diff --git a/src/RaftCustomAttrHandler.ts b/src/RaftCustomAttrHandler.ts index 597dc24..600fc3a 100644 --- a/src/RaftCustomAttrHandler.ts +++ b/src/RaftCustomAttrHandler.ts @@ -24,7 +24,7 @@ export default class CustomAttrHandler { private _jsFunctionCache = new Map(); - public handleAttr(pollRespMetadata: DeviceTypePollRespMetadata, msgBuffer: Uint8Array, msgBufIdx: number): number[][] { + public handleAttr(pollRespMetadata: DeviceTypePollRespMetadata, msgBuffer: Uint8Array, msgBufIdx: number, msgEndIdx = msgBuffer.length): number[][] { // Number of bytes in each message const numMsgBytes = pollRespMetadata.b; @@ -50,7 +50,8 @@ export default class CustomAttrHandler { // pollRespMetadata.b and the bytes actually available — variable-length // samples may be shorter than b, and the last sample in a frame may not // have b bytes remaining in the buffer. - const availableBytes = Math.min(numMsgBytes, msgBuffer.length - msgBufIdx); + const boundedMsgEndIdx = Math.min(Math.max(msgEndIdx, msgBufIdx), msgBuffer.length); + const availableBytes = Math.min(numMsgBytes, boundedMsgEndIdx - msgBufIdx); if (availableBytes <= 0) { return []; } diff --git a/src/RaftDeviceManager.test.ts b/src/RaftDeviceManager.test.ts new file mode 100644 index 0000000..91680d4 --- /dev/null +++ b/src/RaftDeviceManager.test.ts @@ -0,0 +1,164 @@ +import { DeviceManager } from "./RaftDeviceManager"; +import { DeviceTypeInfo } from "./RaftDeviceInfo"; +import RaftSystemUtils from "./RaftSystemUtils"; + +function makeTypeInfo(name: string, respBytes: number, attrs: Array<{ n: string; t: string }>): DeviceTypeInfo { + return { + name, + desc: name, + manu: "Robotical", + type: name, + resp: { + b: respBytes, + a: attrs.map(attr => ({ ...attr, u: "", r: [0, 0] })) + } + }; +} + +async function makeDeviceManager(typeInfos: Record): Promise { + const msgHandler = { + sendRICRESTURL: jest.fn(async (cmd: string) => { + const deviceType = new URLSearchParams(cmd.split("?")[1]).get("type"); + const devinfo = deviceType ? typeInfos[deviceType] : undefined; + return devinfo ? { rslt: "ok", devinfo } : { rslt: "fail" }; + }) + }; + const systemUtils = { + getMsgHandler: () => msgHandler, + getPublishTopicName: () => "devbin" + } as unknown as RaftSystemUtils; + + const deviceManager = new DeviceManager(); + await deviceManager.setup(systemUtils); + return deviceManager; +} + +describe("DeviceManager binary devbin parsing", () => { + const accelInfo = makeTypeInfo("MXC400xXC", 7, [ + { n: "x", t: ">h" }, + { n: "y", t: ">h" }, + { n: "z", t: ">h" }, + { n: "status", t: "B" } + ]); + + it("decodes current length-prefixed records", async () => { + const deviceManager = await makeDeviceManager({ "4": accelInfo }); + const rxMsg = Uint8Array.from([ + 0x00, 0x80, + 0xDB, 0xFF, 0x00, + 0x00, 0x12, + 0x81, + 0x00, 0x00, 0x00, 0x15, + 0x00, 0x04, + 0x05, + 0x09, + 0x00, 0x01, + 0x00, 0x01, + 0x00, 0x02, + 0x00, 0x03, + 0x04 + ]); + + await deviceManager.handleClientMsgBinary(rxMsg); + + const deviceState = deviceManager.getDeviceState("1_15"); + expect(deviceState.deviceType).toBe("MXC400xXC"); + expect(deviceState.deviceTimeline.totalSamplesAdded).toBe(1); + expect(deviceState.deviceAttributes.x.values).toEqual([1]); + expect(deviceState.deviceAttributes.y.values).toEqual([2]); + expect(deviceState.deviceAttributes.z.values).toEqual([3]); + expect(deviceState.deviceAttributes.status.values).toEqual([4]); + }); + + it("decodes Cog v1.9.5 legacy raw accelerometer records", async () => { + const deviceManager = await makeDeviceManager({ "4": accelInfo }); + const rxMsg = Uint8Array.from([ + 0x00, 0x80, + 0x00, 0x10, + 0x81, + 0x00, 0x00, 0x00, 0x15, + 0x00, 0x04, + 0x00, 0x01, + 0x00, 0x01, + 0x00, 0x02, + 0x00, 0x03, + 0x04 + ]); + + await deviceManager.handleClientMsgBinary(rxMsg); + + const deviceState = deviceManager.getDeviceState("1_15"); + expect(deviceState.deviceType).toBe("MXC400xXC"); + expect(deviceState.deviceTimeline.totalSamplesAdded).toBe(1); + expect(deviceState.deviceAttributes.x.values).toEqual([1]); + expect(deviceState.deviceAttributes.y.values).toEqual([2]); + expect(deviceState.deviceAttributes.z.values).toEqual([3]); + expect(deviceState.deviceAttributes.status.values).toEqual([4]); + }); + + it("decodes Cog v1.9.5 legacy raw records inside a devbin envelope", async () => { + const deviceManager = await makeDeviceManager({ "4": accelInfo }); + const rxMsg = Uint8Array.from([ + 0x00, 0x80, + 0xDB, 0xFF, 0x00, + 0x00, 0x10, + 0x81, + 0x00, 0x00, 0x00, 0x15, + 0x00, 0x04, + 0x00, 0x01, + 0x00, 0x01, + 0x00, 0x02, + 0x00, 0x03, + 0x04 + ]); + + await deviceManager.handleClientMsgBinary(rxMsg); + + const deviceState = deviceManager.getDeviceState("1_15"); + expect(deviceState.deviceType).toBe("MXC400xXC"); + expect(deviceState.deviceTimeline.totalSamplesAdded).toBe(1); + expect(deviceState.deviceAttributes.x.values).toEqual([1]); + expect(deviceState.deviceAttributes.y.values).toEqual([2]); + expect(deviceState.deviceAttributes.z.values).toEqual([3]); + expect(deviceState.deviceAttributes.status.values).toEqual([4]); + }); + + it("keeps Cog v1.9.5 direct device records distinct when bus and address are both zero", async () => { + const lightInfo = makeTypeInfo("LightSensors", 16, [ + { n: "ch0", t: ">H" }, + { n: "ch1", t: ">H" }, + { n: "ch2", t: ">H" }, + { n: "ch3", t: ">H" } + ]); + const powerInfo = makeTypeInfo("Power", 1, [ + { n: "battery", t: "B" } + ]); + const deviceManager = await makeDeviceManager({ "2": lightInfo, "3": powerInfo }); + const rxMsg = Uint8Array.from([ + 0x00, 0x80, + 0x00, 0x11, + 0x80, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x02, + 0x00, 0x01, + 0x00, 0x0a, + 0x00, 0x0b, + 0x00, 0x0c, + 0x00, 0x0d, + 0x00, 0x0a, + 0x80, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x03, + 0x00, 0x02, + 0x63 + ]); + + await deviceManager.handleClientMsgBinary(rxMsg); + + const devicesState = deviceManager.getDevicesState(); + expect(devicesState["0_0_2"].deviceType).toBe("LightSensors"); + expect(devicesState["0_0_2"].deviceAttributes.ch0.values).toEqual([10]); + expect(devicesState["0_0_3"].deviceType).toBe("Power"); + expect(devicesState["0_0_3"].deviceAttributes.battery.values).toEqual([99]); + }); +}); diff --git a/src/RaftDeviceManager.ts b/src/RaftDeviceManager.ts index 96e4443..efa01b2 100644 --- a/src/RaftDeviceManager.ts +++ b/src/RaftDeviceManager.ts @@ -10,11 +10,11 @@ import { DeviceAttributeState, DeviceAttributesState, DevicesState, DeviceState, DeviceStats, DeviceOnlineState, formatDeviceAddrHex, getDeviceKey, parseDeviceKey } from "./RaftDeviceStates"; import { DeviceMsgJson } from "./RaftDeviceMsg"; import { RaftOKFail } from './RaftTypes'; -import { DeviceTypeInfo, DeviceTypeAction, DeviceTypeInfoRecs, RaftDevTypeInfoResponse, SampleRateResult, getActionMapHex } from "./RaftDeviceInfo"; +import { DeviceTypeInfo, DeviceTypeAction, DeviceTypeInfoRecs, DeviceTypePollRespMetadata, RaftDevTypeInfoResponse, SampleRateResult, getActionMapHex } from "./RaftDeviceInfo"; import AttributeHandler from "./RaftAttributeHandler"; import RaftSystemUtils from "./RaftSystemUtils"; import RaftDeviceMgrIF from "./RaftDeviceMgrIF"; -import { structPack } from "./RaftStruct"; +import { structPack, structSizeOf } from "./RaftStruct"; // import RaftUtils from "./RaftUtils"; export interface DeviceDecodedData { @@ -33,6 +33,8 @@ interface DeviceStatsInternal extends DeviceStats { windowEvents: Array<{ timeMs: number; samples: number }>; } +type BinaryRecordPayloadFormat = "lengthPrefixed" | "legacyRaw"; + export class DeviceManager implements RaftDeviceMgrIF{ // Max data points to store @@ -70,6 +72,7 @@ export class DeviceManager implements RaftDeviceMgrIF{ // Device stats (sample counts, rates) private _statsWindowMs = 5000; private _deviceStats: { [deviceKey: string]: DeviceStatsInternal } = {}; + private _malformedSampleWarnLastMs: { [warningKey: string]: number } = {}; public getDevicesState(): DevicesState { return this._devicesState; @@ -217,7 +220,7 @@ export class DeviceManager implements RaftDeviceMgrIF{ // The rxMsg passed to this function has a 2-byte message type prefix (e.g. 0x0080) // added by the transport layer. After that prefix comes a devbin frame: // - // Devbin envelope (3 bytes): + // Current devbin envelope (3 bytes): // Byte 0: magic+version 0xDB (valid range 0xDB–0xDF) // Byte 1: topicIndex 0x00–0xFE = topic index; 0xFF = no topic // Byte 2: envelopeSeqNum uint8, wrapping — detects whole-frame drops @@ -230,6 +233,12 @@ export class DeviceManager implements RaftDeviceMgrIF{ // Byte 9: deviceSeqNum uint8, wrapping — per-device drop detection // Bytes 10+: samples length-prefixed: [sampleLen(1B)][sampleData(sampleLen B)] × N // + // Backwards compatibility: + // Cog v1.9.5 is already in production and sends the older RaftCore devbin layout: + // no 3-byte envelope, no deviceSeqNum byte, and raw fixed-size samples + // [timestamp(2B)][payload] × N. Keep that path separate so current Axiom/Cog + // frames continue to use the length-prefixed parser above. + // // Example message (two device records; first record has two samples): // 0080 DB 01 07 0018 81 0000076a 000b 2a 07feff0000010008 07185707931400 01 000e 80 00000000 001f 05 05030001af01 // ^^^^ ^^^^ @@ -263,6 +272,7 @@ export class DeviceManager implements RaftDeviceMgrIF{ // Message layout constants const msgTypeLen = 2; // Transport-layer message type prefix (first two bytes, e.g. 0x0080) const devbinEnvelopeLen = 3; // Devbin envelope: magic+version (1B) + topicIndex (1B) + envelopeSeqNum (1B) + const legacyDevbinEnvelopeLen = 2; // Intermediate/legacy envelope: magic+version (1B) + topicIndex (1B) const devbinMagicMin = 0xDB; const devbinMagicMax = 0xDF; const recordLenLen = 2; // Per-record length prefix (uint16 big-endian) @@ -270,15 +280,17 @@ export class DeviceManager implements RaftDeviceMgrIF{ const deviceAddrLen = 4; // Device address (uint32 big-endian) const devTypeIdxLen = 2; // Device type index (uint16 big-endian) const deviceSeqNumLen = 1; // Per-device sequence counter - const recordHeaderLen = busInfoLen + deviceAddrLen + devTypeIdxLen + deviceSeqNumLen; // = 8, minimum record body + const currentRecordHeaderLen = busInfoLen + deviceAddrLen + devTypeIdxLen + deviceSeqNumLen; // = 8, minimum record body + const legacyRecordHeaderLen = busInfoLen + deviceAddrLen + devTypeIdxLen; // = 7, Cog v1.9.5 record body header // console.log(`DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} rxMsg.length ${rxMsg.length} rxMsg ${RaftUtils.bufferToHex(rxMsg)}`); // Start after the message type let msgPos = msgTypeLen; + let payloadFormat: BinaryRecordPayloadFormat = "legacyRaw"; // Check for devbin envelope (magic+version + topicIndex) - if (rxMsg.length >= msgTypeLen + devbinEnvelopeLen) { + if (rxMsg.length >= msgTypeLen + legacyDevbinEnvelopeLen) { const envelopeMagicVer = rxMsg[msgTypeLen]; if ((envelopeMagicVer & 0xF0) === 0xD0) { if ((envelopeMagicVer < devbinMagicMin) || (envelopeMagicVer > devbinMagicMax)) { @@ -294,8 +306,21 @@ export class DeviceManager implements RaftDeviceMgrIF{ } } - msgPos += devbinEnvelopeLen; + const currentMsgPos = msgTypeLen + devbinEnvelopeLen; + const legacyMsgPos = msgTypeLen + legacyDevbinEnvelopeLen; + if (this.hasValidRecordAt(rxMsg, currentMsgPos, recordLenLen, currentRecordHeaderLen)) { + msgPos = currentMsgPos; + payloadFormat = "lengthPrefixed"; + } else if (this.hasValidRecordAt(rxMsg, legacyMsgPos, recordLenLen, legacyRecordHeaderLen)) { + msgPos = legacyMsgPos; + payloadFormat = "legacyRaw"; + } else { + console.warn(`DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} invalid devbin envelope payload`); + return; + } } + } else if (this.hasValidRecordAt(rxMsg, msgPos, recordLenLen, currentRecordHeaderLen)) { + payloadFormat = "lengthPrefixed"; } // Iterate through device records @@ -303,14 +328,14 @@ export class DeviceManager implements RaftDeviceMgrIF{ // Check minimum length for record length prefix + record header const remainingLen = rxMsg.length - msgPos; - if (remainingLen < recordLenLen + recordHeaderLen) { - console.warn(`DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} invalid length ${rxMsg.length} < ${recordLenLen + recordHeaderLen + msgPos}`); + if (remainingLen < recordLenLen + legacyRecordHeaderLen) { + console.warn(`DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} invalid length ${rxMsg.length} < ${recordLenLen + legacyRecordHeaderLen + msgPos}`); return; } // Get the record body length (bytes that follow the 2-byte length prefix) const recordLen = (rxMsg[msgPos] << 8) + rxMsg[msgPos + 1]; - if (recordLen > remainingLen - recordLenLen) { + if ((recordLen < legacyRecordHeaderLen) || (recordLen > remainingLen - recordLenLen)) { console.warn(`DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} invalid msgPos ${msgPos} recordLen ${recordLen} remainingAfterLenBytes ${remainingLen - recordLenLen}`); return; } @@ -322,7 +347,6 @@ export class DeviceManager implements RaftDeviceMgrIF{ const statusByte = rxMsg[recordPos]; const busNum = statusByte & 0x0f; const isOnline = (statusByte & 0x80) !== 0; - const isPendingDeletion = (statusByte & 0x40) !== 0; recordPos += busInfoLen; // Device address (uint32 big-endian) @@ -333,9 +357,28 @@ export class DeviceManager implements RaftDeviceMgrIF{ const devTypeIdx = (rxMsg[recordPos] << 8) + rxMsg[recordPos + 1]; recordPos += devTypeIdxLen; - // Per-device sequence counter (reserved for future drop detection) - // const deviceSeqNum = rxMsg[recordPos]; - recordPos += deviceSeqNumLen; + const commonRecordHeaderEndPos = recordPos; + const samplesEndPos = msgPos + recordLenLen + recordLen; + let recordPayloadFormat = payloadFormat; + let recordHeaderLen = recordPayloadFormat === "lengthPrefixed" ? currentRecordHeaderLen : legacyRecordHeaderLen; + const resolvedDeviceTypeInfo = await this.getDeviceTypeInfo(busNum.toString(), devTypeIdx.toString()); + if (resolvedDeviceTypeInfo?.resp) { + recordPayloadFormat = this.resolveRecordPayloadFormat(rxMsg, commonRecordHeaderEndPos, + samplesEndPos, resolvedDeviceTypeInfo.resp, recordPayloadFormat, deviceSeqNumLen); + recordHeaderLen = recordPayloadFormat === "lengthPrefixed" ? currentRecordHeaderLen : legacyRecordHeaderLen; + } + if (recordLen < recordHeaderLen) { + console.warn(`DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} invalid msgPos ${msgPos} recordLen ${recordLen} recordHeaderLen ${recordHeaderLen}`); + return; + } + + const isPendingDeletion = (recordPayloadFormat === "lengthPrefixed") && ((statusByte & 0x40) !== 0); + recordPos = commonRecordHeaderEndPos; + if (recordPayloadFormat === "lengthPrefixed") { + // Per-device sequence counter (reserved for future drop detection) + // const deviceSeqNum = rxMsg[recordPos]; + recordPos += deviceSeqNumLen; + } let pollDataPos = recordPos; @@ -345,7 +388,7 @@ export class DeviceManager implements RaftDeviceMgrIF{ // Format device address as canonical hex and build device key const devAddrHex = formatDeviceAddrHex(devAddr); - const deviceKey = getDeviceKey(busNum.toString(), devAddrHex); + const deviceKey = this.getBinaryDeviceKey(busNum, devAddrHex, devTypeIdx, recordPayloadFormat); // Update the last update time this._deviceLastUpdateTime[deviceKey] = Date.now(); @@ -361,7 +404,7 @@ export class DeviceManager implements RaftDeviceMgrIF{ if (!(deviceKey in this._devicesState) || (this._devicesState[deviceKey].deviceTypeInfo === undefined)) { // Get the device type info - const deviceTypeInfo = await this.getDeviceTypeInfo(busNum.toString(), devTypeIdx.toString()); + const deviceTypeInfo = resolvedDeviceTypeInfo ?? await this.getDeviceTypeInfo(busNum.toString(), devTypeIdx.toString()); // Debug // console.log(`DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} pollDataPos ${pollDataPos} busNum ${busNum} devAddr 0x${devAddr.toString(16)} devTypeIdx ${devTypeIdx} deviceTypeInfo ${JSON.stringify(deviceTypeInfo)}`); @@ -418,39 +461,89 @@ export class DeviceManager implements RaftDeviceMgrIF{ // Iterate over attributes in the group const pollRespMetadata = deviceState.deviceTypeInfo!.resp!; - // Process length-prefixed samples within this record - const samplesEndPos = msgPos + recordLenLen + recordLen; + // Process samples within this record const attrLengthsBefore = this.snapshotAttrLengths(deviceState.deviceAttributes, pollRespMetadata); const timelineLenBefore = deviceState.deviceTimeline.timestampsUs.length; const totalSamplesBefore = deviceState.deviceTimeline.totalSamplesAdded; - while (pollDataPos < samplesEndPos) { + if (recordPayloadFormat === "lengthPrefixed") { + while (pollDataPos < samplesEndPos) { - // Read sample length prefix - if (pollDataPos >= rxMsg.length) { - console.warn(`DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} pollDataPos ${pollDataPos} exceeds message length ${rxMsg.length}`); - break; - } - const sampleLen = rxMsg[pollDataPos]; - pollDataPos += 1; + // Read sample length prefix + if (pollDataPos >= rxMsg.length) { + console.warn(`DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} pollDataPos ${pollDataPos} exceeds message length ${rxMsg.length}`); + break; + } + const sampleLen = rxMsg[pollDataPos]; + pollDataPos += 1; - if (sampleLen === 0 || pollDataPos + sampleLen > samplesEndPos) { - break; - } + if (sampleLen === 0 || pollDataPos + sampleLen > samplesEndPos) { + break; + } - const newMsgBufIdx = this._attributeHandler.processMsgAttrGroup(rxMsg, pollDataPos, - deviceState.deviceTimeline, pollRespMetadata, - deviceState.deviceAttributes, - this._maxDatapointsToStore); + const sampleStartPos = pollDataPos; + const sampleEndPos = pollDataPos + sampleLen; + const newMsgBufIdx = this._attributeHandler.processMsgAttrGroup(rxMsg, sampleStartPos, + deviceState.deviceTimeline, pollRespMetadata, + deviceState.deviceAttributes, + this._maxDatapointsToStore, + sampleEndPos); + + if (newMsgBufIdx < 0) + { + this.warnMalformedSample( + `${deviceKey}:${devTypeIdx}:lengthPrefixed`, + `DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} skipped malformed sample ` + + `device=${deviceKey} devTypeIdx=${devTypeIdx} sampleLen=${sampleLen} respBytes=${pollRespMetadata.b}` + ); + pollDataPos += sampleLen; + continue; + } - if (newMsgBufIdx < 0) - { - console.warn(`DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} processMsgAttrGroup failed newMsgBufIdx ${newMsgBufIdx}`); - break; + // Advance by sampleLen regardless of how much processMsgAttrGroup consumed + pollDataPos += sampleLen; + deviceState.stateChanged = true; } + } else { + const legacySampleLen = this.getLegacyRawSampleLen(pollRespMetadata); + if (legacySampleLen <= 0) { + this.warnMalformedSample( + `${deviceKey}:${devTypeIdx}:legacyRawLen`, + `DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} invalid legacy sample length ` + + `device=${deviceKey} devTypeIdx=${devTypeIdx} respBytes=${pollRespMetadata.b}` + ); + } else { + while (pollDataPos + legacySampleLen <= samplesEndPos) { + const sampleStartPos = pollDataPos; + const sampleEndPos = pollDataPos + legacySampleLen; + const newMsgBufIdx = this._attributeHandler.processMsgAttrGroup(rxMsg, sampleStartPos, + deviceState.deviceTimeline, pollRespMetadata, + deviceState.deviceAttributes, + this._maxDatapointsToStore, + sampleEndPos); + + if (newMsgBufIdx < 0) + { + this.warnMalformedSample( + `${deviceKey}:${devTypeIdx}:legacyRaw`, + `DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} skipped malformed legacy sample ` + + `device=${deviceKey} devTypeIdx=${devTypeIdx} sampleLen=${legacySampleLen} respBytes=${pollRespMetadata.b}` + ); + pollDataPos += legacySampleLen; + continue; + } + + pollDataPos += legacySampleLen; + deviceState.stateChanged = true; + } - // Advance by sampleLen regardless of how much processMsgAttrGroup consumed - pollDataPos += sampleLen; - deviceState.stateChanged = true; + if (pollDataPos < samplesEndPos) { + this.warnMalformedSample( + `${deviceKey}:${devTypeIdx}:legacyRawRemainder`, + `DevMan.handleClientMsgBinary debugIdx ${debugMsgIndex} skipped trailing legacy sample bytes ` + + `device=${deviceKey} devTypeIdx=${devTypeIdx} remaining=${samplesEndPos - pollDataPos} sampleLen=${legacySampleLen} respBytes=${pollRespMetadata.b}` + ); + } + } } // Inform decoded-data callbacks @@ -783,6 +876,131 @@ export class DeviceManager implements RaftDeviceMgrIF{ } } + private hasValidRecordAt(rxMsg: Uint8Array, msgPos: number, recordLenLen: number, recordHeaderLen: number): boolean { + if (msgPos === rxMsg.length) { + return true; + } + if (msgPos < 0 || msgPos > rxMsg.length) { + return false; + } + const remainingLen = rxMsg.length - msgPos; + if (remainingLen < recordLenLen + recordHeaderLen) { + return false; + } + const recordLen = (rxMsg[msgPos] << 8) + rxMsg[msgPos + 1]; + return (recordLen >= recordHeaderLen) && (recordLen <= remainingLen - recordLenLen); + } + + private resolveRecordPayloadFormat(rxMsg: Uint8Array, commonRecordHeaderEndPos: number, samplesEndPos: number, + pollRespMetadata: DeviceTypePollRespMetadata, preferredFormat: BinaryRecordPayloadFormat, + deviceSeqNumLen: number): BinaryRecordPayloadFormat { + const lengthPrefixedStartPos = commonRecordHeaderEndPos + deviceSeqNumLen; + const lengthPrefixedValid = this.areLengthPrefixedSamplesValid(rxMsg, lengthPrefixedStartPos, samplesEndPos, pollRespMetadata); + const legacySampleLen = this.getLegacyRawSampleLen(pollRespMetadata); + const legacyRawValid = this.areLegacyRawSamplesValid(commonRecordHeaderEndPos, samplesEndPos, legacySampleLen); + + if (legacyRawValid && !lengthPrefixedValid) { + return "legacyRaw"; + } + if (lengthPrefixedValid && !legacyRawValid) { + return "lengthPrefixed"; + } + if (lengthPrefixedValid && legacyRawValid) { + return preferredFormat; + } + return preferredFormat; + } + + private areLengthPrefixedSamplesValid(rxMsg: Uint8Array, pollDataPos: number, samplesEndPos: number, + pollRespMetadata: DeviceTypePollRespMetadata): boolean { + if ((pollDataPos < 0) || (pollDataPos > samplesEndPos) || (samplesEndPos > rxMsg.length)) { + return false; + } + if (pollDataPos === samplesEndPos) { + return true; + } + + const fixedSampleLen = pollRespMetadata.c ? 0 : this.getLegacyRawSampleLen(pollRespMetadata); + let sampleCount = 0; + while (pollDataPos < samplesEndPos) { + const sampleLen = rxMsg[pollDataPos]; + pollDataPos += 1; + if ((sampleLen === 0) || (pollDataPos + sampleLen > samplesEndPos)) { + return false; + } + if ((fixedSampleLen > 0) && (sampleLen !== fixedSampleLen)) { + return false; + } + pollDataPos += sampleLen; + sampleCount++; + } + return sampleCount > 0; + } + + private areLegacyRawSamplesValid(pollDataPos: number, samplesEndPos: number, legacySampleLen: number): boolean { + if ((legacySampleLen <= 0) || (pollDataPos < 0) || (pollDataPos > samplesEndPos)) { + return false; + } + const payloadLen = samplesEndPos - pollDataPos; + return (payloadLen > 0) && (payloadLen % legacySampleLen === 0); + } + + private getBinaryDeviceKey(busNum: number, devAddrHex: string, devTypeIdx: number, payloadFormat: BinaryRecordPayloadFormat): string { + const baseDeviceKey = getDeviceKey(busNum.toString(), devAddrHex); + if ((payloadFormat === "legacyRaw") && (busNum === 0) && (devAddrHex === "0")) { + return `${baseDeviceKey}_${devTypeIdx}`; + } + return baseDeviceKey; + } + + private getLegacyRawSampleLen(pollRespMetadata: DeviceTypePollRespMetadata): number { + const legacyTimestampLen = 2; + return legacyTimestampLen + this.getPollRespPayloadSize(pollRespMetadata); + } + + private getPollRespPayloadSize(pollRespMetadata: DeviceTypePollRespMetadata): number { + if (pollRespMetadata.c) { + return pollRespMetadata.b; + } + + let attrPayloadLen = 0; + for (const attrDef of pollRespMetadata.a) { + if (!attrDef.t) { + return pollRespMetadata.b; + } + try { + attrPayloadLen += structSizeOf(attrDef.t); + } catch { + return pollRespMetadata.b; + } + } + + // Cog v1.9.5 light metadata reports the direct-sensor payload size doubled, + // but the legacy raw record contains one fixed payload matching the attribute schema. + if ((attrPayloadLen > 0) && (pollRespMetadata.b > 0) && (attrPayloadLen <= pollRespMetadata.b)) { + return attrPayloadLen; + } + return pollRespMetadata.b; + } + + private parseDeviceKeyForCommand(deviceKey: string): { bus: string; addr: string } { + const deviceState = this._devicesState[deviceKey]; + if (deviceState) { + return { bus: deviceState.busName, addr: deviceState.deviceAddress }; + } + return parseDeviceKey(deviceKey); + } + + private warnMalformedSample(warningKey: string, message: string): void { + const nowMs = Date.now(); + const lastWarnMs = this._malformedSampleWarnLastMs[warningKey] ?? 0; + if (nowMs - lastWarnMs < 5000) { + return; + } + this._malformedSampleWarnLastMs[warningKey] = nowMs; + console.warn(message); + } + //////////////////////////////////////////////////////////////////////////// // Send action to device //////////////////////////////////////////////////////////////////////////// @@ -815,7 +1033,7 @@ export class DeviceManager implements RaftDeviceMgrIF{ const mappedHex = getActionMapHex(mapEntry); // Map values may contain &-separated multi-writes (e.g. "1048&114C&0a26") const writes = mappedHex.split('&'); - const { bus: devBus, addr: devAddr } = parseDeviceKey(deviceKey); + const { bus: devBus, addr: devAddr } = this.parseDeviceKeyForCommand(deviceKey); try { const msgHandler = this._systemUtils?.getMsgHandler(); if (!msgHandler) return false; @@ -864,7 +1082,7 @@ export class DeviceManager implements RaftDeviceMgrIF{ writeHexStr = (action.w ? action.w : "") + writeHexStr + (action.wz ? action.wz : ""); // Parse the device key into bus and address components - const { bus: devBus, addr: devAddr } = parseDeviceKey(deviceKey); + const { bus: devBus, addr: devAddr } = this.parseDeviceKeyForCommand(deviceKey); // Send the action to the server const cmd = "devman/cmdraw?bus=" + devBus + "&addr=" + devAddr + "&hexWr=" + writeHexStr; @@ -947,7 +1165,7 @@ export class DeviceManager implements RaftDeviceMgrIF{ const numSamples = options?.numSamples ?? 1; const intervalUs = options?.intervalUs ?? Math.max(5000, samplePeriodUs); - const { bus: devBus, addr: devAddr } = parseDeviceKey(deviceKey); + const { bus: devBus, addr: devAddr } = this.parseDeviceKeyForCommand(deviceKey); const cmd = `devman/devconfig?bus=${devBus}&addr=${devAddr}&intervalUs=${intervalUs}&numSamples=${numSamples}`; try { @@ -1022,7 +1240,7 @@ export class DeviceManager implements RaftDeviceMgrIF{ } // Send single devconfig call with all parameters - const { bus: devBus, addr: devAddr } = parseDeviceKey(deviceKey); + const { bus: devBus, addr: devAddr } = this.parseDeviceKeyForCommand(deviceKey); const cmd = `devman/devconfig?bus=${devBus}&addr=${devAddr}&sampleRateHz=${actualRate}&intervalUs=${intervalUs}&numSamples=${numSamples}`; try {