@robotical/raftjs 2.1.3 → 2.1.5

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.
Files changed (32) hide show
  1. package/devdocs/decode-overrun-investigation.md +167 -0
  2. package/devdocs/message-panel-design.md +320 -0
  3. package/dist/react-native/RaftAttributeHandler.d.ts +10 -1
  4. package/dist/react-native/RaftAttributeHandler.js +60 -8
  5. package/dist/react-native/RaftAttributeHandler.js.map +1 -1
  6. package/dist/react-native/RaftChannelWebSerial.js +23 -1
  7. package/dist/react-native/RaftChannelWebSerial.js.map +1 -1
  8. package/dist/react-native/RaftConnector.js +23 -0
  9. package/dist/react-native/RaftConnector.js.map +1 -1
  10. package/dist/react-native/RaftDeviceInfo.d.ts +5 -1
  11. package/dist/react-native/RaftDeviceManager.js +68 -32
  12. package/dist/react-native/RaftDeviceManager.js.map +1 -1
  13. package/dist/web/RaftAttributeHandler.d.ts +10 -1
  14. package/dist/web/RaftAttributeHandler.js +60 -8
  15. package/dist/web/RaftAttributeHandler.js.map +1 -1
  16. package/dist/web/RaftChannelWebSerial.js +23 -1
  17. package/dist/web/RaftChannelWebSerial.js.map +1 -1
  18. package/dist/web/RaftConnector.js +23 -0
  19. package/dist/web/RaftConnector.js.map +1 -1
  20. package/dist/web/RaftDeviceInfo.d.ts +5 -1
  21. package/dist/web/RaftDeviceManager.js +68 -32
  22. package/dist/web/RaftDeviceManager.js.map +1 -1
  23. package/examples/dashboard/src/DeviceActionsForm.tsx +3 -0
  24. package/examples/dashboard/src/DeviceLineChart.tsx +5 -4
  25. package/examples/dashboard/src/DevicePanel.tsx +3 -0
  26. package/package.json +1 -1
  27. package/src/RaftAttributeHandler.ts +89 -9
  28. package/src/RaftChannelWebSerial.ts +23 -2
  29. package/src/RaftConnector.ts +30 -0
  30. package/src/RaftDeviceInfo.ts +5 -1
  31. package/src/RaftDeviceManager.ts +78 -33
  32. package/src/main.ts +1 -1
@@ -0,0 +1,167 @@
1
+ # Decode Overrun Error Investigation
2
+
3
+ ## Summary
4
+
5
+ The raftjs example dashboard is receiving an `AttributeHandler decode overrun` error when processing light sensor data from a Cog device. The firmware is sending 9 bytes of sensor data, but the registered schema expects only 8 bytes.
6
+
7
+ ## Error Details
8
+
9
+ **Error Message:**
10
+ ```
11
+ AttributeHandler decode overrun (msgBuffer):
12
+ deviceKey=0_0
13
+ deviceType=Cog Light Sensors
14
+ debugMsgIndex=1
15
+ attr.n=amb0
16
+ attr.t=>H
17
+ attrTypeSize=2
18
+ curFieldBufIdx=45
19
+ msgBuffer.length=46
20
+ sampleStartIdx=37
21
+ sampleEndIdx=46
22
+ availableInSample=1
23
+ availableInBuffer=1
24
+ ```
25
+
26
+ **Key Facts:**
27
+ - Message buffer total length: 46 bytes
28
+ - Sample data location: bytes 37-46 (9 bytes)
29
+ - Declared sample size in schema: 8 bytes (`pollRespMetadata.b=8`)
30
+ - **Mismatch: Firmware sends 9 bytes, schema expects 8 bytes**
31
+
32
+ ## Message Structure Analysis
33
+
34
+ ### Binary Message Format
35
+ The firmware sends a binary message with this structure:
36
+ 1. **Timestamp** (2 bytes, big-endian) - handled separately before attribute decoding
37
+ 2. **Sample length** (1 byte) - value is 9, meaning 9 bytes of sensor data follow
38
+ 3. **Sensor data** (9 bytes) - the actual data being decoded
39
+ - IR sensor 0 (ir0): `5c 60` (0x5c60)
40
+ - IR sensor 1 (ir1): `00 05` (0x0005)
41
+ - IR sensor 2 (ir2): `03 00` (0x0300)
42
+ - Ambient sensor 0 (amb0): `02 57` (0x0257)
43
+ - **Extra byte**: `01` (unknown origin)
44
+
45
+ ### Expected vs Actual Schema
46
+
47
+ **Schema Definition (from firmware):**
48
+ ```json
49
+ {
50
+ "name": "Cog Light Sensors",
51
+ "desc": "Light Sensors",
52
+ "type": "RoboCogLightV1",
53
+ "resp": {
54
+ "b": 8,
55
+ "a": [
56
+ {"n": "ir0", "t": ">H", "u": "", "r": [0, 4095], "d": 1, "f": ".0f"},
57
+ {"n": "ir1", "t": ">H", "u": "", "r": [0, 4095], "d": 1, "f": ".0f"},
58
+ {"n": "ir2", "t": ">H", "u": "", "r": [918, 2259], "d": 1, "f": ".0f"},
59
+ {"n": "amb0", "t": ">H", "u": "L", "r": [0, 4095], "d": 1, "f": ".0f"}
60
+ ]
61
+ }
62
+ }
63
+ ```
64
+
65
+ **Expected data (based on schema):**
66
+ - ir0: 2 bytes
67
+ - ir1: 2 bytes
68
+ - ir2: 2 bytes
69
+ - amb0: 2 bytes
70
+ - **Total: 8 bytes**
71
+
72
+ **Actual data received:**
73
+ - All 4 fields as expected (8 bytes)
74
+ - **Plus 1 extra byte** = 9 bytes total
75
+
76
+ ## Root Cause
77
+
78
+ The firmware's `DeviceLightSensors::formDeviceDataResponse()` function in `components/DeviceLightSensors/DeviceLightSensors.cpp` is generating sensor data, and there is a mismatch between:
79
+
80
+ 1. **What the firmware sends**: 9 bytes of sensor data (timestamp + data in the binary message)
81
+ 2. **What the schema declares**: 8 bytes of sensor data (`"b": 8`)
82
+
83
+ The schema is generated dynamically by the firmware in `getDeviceTypeRecord()` based on the number of configured sensors:
84
+ - 3 IR sensors × 2 bytes = 6 bytes
85
+ - 1 ambient sensor × 2 bytes = 2 bytes
86
+ - **Total: 8 bytes** (correct calculation)
87
+
88
+ But the actual `formDeviceDataResponse()` is sending 9 bytes.
89
+
90
+ ## Investigation Points
91
+
92
+ ### 1. Extra Byte Origin
93
+ The mysterious `01` byte at the end needs to be identified:
94
+ - Is it padding?
95
+ - Is it a sample count or indicator?
96
+ - Is it an uninitialized buffer value?
97
+ - Was it added in a recent firmware change?
98
+
99
+ **Check:**
100
+ - `git log -p components/DeviceLightSensors/DeviceLightSensors.cpp` around `formDeviceDataResponse()`
101
+ - Look for recent changes that add bytes (new sensor data, flags, etc.)
102
+ - Compare the byte count calculation in `getDeviceTypeRecord()` with actual `formDeviceDataResponse()` logic
103
+
104
+ ### 2. Binary Message Encoding
105
+ The binary message encoding happens in `RaftDevice::genBinaryDataMsg()` (in RaftCore). Verify:
106
+ - Is the framework adding extra metadata or padding?
107
+ - Are sample boundaries being correctly calculated?
108
+ - Is there a mismatch between the sample length byte and actual data written?
109
+
110
+ **Check:**
111
+ - `RaftDevice::genBinaryDataMsg()` implementation
112
+ - Recent changes to binary message encoding
113
+ - Sample length byte calculation
114
+
115
+ ### 3. Configuration/Build State
116
+ - Check if the firmware was built with debug flags that add extra bytes
117
+ - Verify that sensor configuration hasn't changed (more sensors added?)
118
+ - Check if conditional compilation (#ifdef) is affecting byte counts
119
+
120
+ ## raftjs Side
121
+
122
+ The raftjs library correctly detects the mismatch through the diagnostic context added in recent commits:
123
+ - `AttrDecodeDiagContext` interface tracks sample boundaries
124
+ - `RaftAttributeHandler.processMsgAttribute()` bounds-checks against these boundaries
125
+ - `RaftDeviceManager` passes diagnostic context with `sampleStartIdx` and `sampleEndIdx`
126
+
127
+ This is working as intended—the error indicates a real firmware/schema mismatch, not a bug in raftjs.
128
+
129
+ ## Recommended Fixes
130
+
131
+ ### Short Term (Workaround)
132
+ Update the schema to match reality:
133
+ ```json
134
+ "resp": {
135
+ "b": 9, // Changed from 8 to 9
136
+ ...
137
+ }
138
+ ```
139
+ This will suppress the error but doesn't fix the root cause.
140
+
141
+ ### Long Term (Proper Fix)
142
+ 1. **Identify the source** of the extra byte in `formDeviceDataResponse()`
143
+ 2. **Either:**
144
+ - Remove the extra byte if it's unintended
145
+ - Properly account for it in the schema and sensor processing if it's intentional
146
+ 3. **Update documentation** if there's a reason for the extra byte
147
+ 4. **Add unit tests** to prevent this mismatch in future changes
148
+
149
+ ## Related Code Locations
150
+
151
+ **Firmware:**
152
+ - Schema definition: `RoboticalCogFW/components/DeviceLightSensors/DeviceLightSensors.cpp` - `getDeviceTypeRecord()`
153
+ - Data encoding: `RoboticalCogFW/components/DeviceLightSensors/DeviceLightSensors.cpp` - `formDeviceDataResponse()`
154
+ - Binary message: `RoboticalCogFW/raftdevlibs/RaftCore/components/core/RaftDevice/RaftDevice.cpp` - `genBinaryDataMsg()`
155
+
156
+ **raftjs:**
157
+ - Attribute handler: `raftjs/src/RaftAttributeHandler.ts` - `processMsgAttribute()`
158
+ - Device manager: `raftjs/src/RaftDeviceManager.ts` - `handleClientMsgBinary()`
159
+ - Diagnostic logging: Added in recent commit with `AttrDecodeDiagContext`
160
+
161
+ ## Next Steps
162
+
163
+ 1. Run `git log -p` on DeviceLightSensors to find when the extra byte was introduced
164
+ 2. Check the firmware build/compile to ensure no debug additions
165
+ 3. Examine `formDeviceDataResponse()` line-by-line against sensor configuration
166
+ 4. Test with a debugger to verify what bytes are actually being transmitted
167
+ 5. Once identified, decide whether to remove the byte or update the schema
@@ -0,0 +1,320 @@
1
+ # Dashboard Message Panel — Cog-to-Cog IR Comms
2
+
3
+ ## Purpose
4
+
5
+ Add a **Message Panel** to the RaftJS example dashboard so a developer can exercise the
6
+ cog-to-cog IR communications feature described in the Cog firmware design document
7
+ (`RoboticalCogFW/devdocs/cog-to-cog-ir-comms-design.md`).
8
+
9
+ The panel sits alongside the existing Command Panel and provides:
10
+
11
+ - A text box to enter an ASCII message.
12
+ - A drop-down list of destinations to send the message to. Initially the only entry is the
13
+ IR channel of a Cog (side 2, the validated comms axis).
14
+ - A button to send the message.
15
+ - A scrolling log of messages received over the same IR channel by the connected device.
16
+
17
+ **No code has been changed yet.** This document records the investigation and describes the
18
+ work required to implement the panel.
19
+
20
+ ## Background
21
+
22
+ ### How the firmware exposes IR comms
23
+
24
+ The Cog firmware feature is implemented by the `CogIRComms` SysMod and exposed through the
25
+ standard RaftCore REST endpoint mechanism, so the same endpoints work over BLE RICREST,
26
+ WebSocket, and serial. The relevant endpoints (from the firmware design doc, "Useful REST
27
+ commands" and the 2026-05-15 validation section) are:
28
+
29
+ | Endpoint | Purpose |
30
+ | --- | --- |
31
+ | `ircomms/status` | Returns state plus rx/tx counters and config. Use it to confirm the feature is built/enabled. |
32
+ | `ircomms/tx?side=2&type=<0-15>&seq=<0-15>&payload=<0-255>` | Send a tiny diagnostic packet (single payload byte). |
33
+ | `ircomms/send?side=2&type=<0-15>&seq=<0-15>&hex=<even-length hex, up to 160 chars>` | Send a framed datagram with an arbitrary payload (up to 80 bytes). **This is the endpoint the Message Panel should use.** |
34
+ | `ircomms/rx?pop=1` | Pop the oldest received tiny packet. |
35
+ | `ircomms/rx?popFrame=1` | Pop the oldest received framed datagram. |
36
+ | `ircomms/rx?clear=1` | Clear the receive queue. |
37
+ | `ircomms/cfg?frameMarkUs=<us>&frameSpaceUs=<us>` | Runtime frame-timing tweak (not needed for v1). |
38
+
39
+ The framed payload is the right fit for an ASCII message: `frameMaxPayloadBytes` is 80, i.e.
40
+ 160 hex characters, validated on hardware up to 48 bytes with overhead headroom.
41
+
42
+ Side 2 (`IRTX2`/`IRRX2`, mux channel 2) is the only validated bidirectional axis; side 1 did
43
+ not couple in the tested orientations. The destination drop-down should therefore default to
44
+ side 2.
45
+
46
+ ### How the firmware notifies of received messages
47
+
48
+ When `rxReportEnable` is true (it is in the default `Cog1` systype config), each CRC-valid
49
+ received message also produces a **RICREST report frame** sent through `SysManager`. For the
50
+ tiny-packet diagnostic the report looks like:
51
+
52
+ ```json
53
+ {"msgType":"ircomms","msgName":"rx","rxMs":11134,"type":7,"seq":8,"payload":55,"crc":55883}
54
+ ```
55
+
56
+ The firmware design doc's "Receive Notification Recommendation" section proposes a richer
57
+ report for framed messages with `source`, `type`, and a `payload` field. The exact shape of
58
+ the report emitted for **framed** `ircomms/send` datagrams is not pinned down in the firmware
59
+ doc — see [Open questions](#open-questions-firmware-coordination).
60
+
61
+ ### How RaftJS sends and receives
62
+
63
+ The dashboard talks to one device at a time through a single `RaftConnector` instance
64
+ (`ConnManager.getInstance().getConnector()`).
65
+
66
+ **Sending** — `CommandPanel.tsx` already shows the pattern:
67
+
68
+ ```ts
69
+ connManager.getConnector().sendRICRESTMsg(commandName, params)
70
+ ```
71
+
72
+ `sendRICRESTMsg(commandName, params)` (in `src/RaftConnector.ts:412`) builds a query string
73
+ from `params`, URL-encodes each value, appends it to `commandName`, sends it as a RICREST URL
74
+ message, and resolves to the parsed JSON response (typed `RaftOKFail`, but the full JSON of
75
+ the device response is returned). So a framed send is simply:
76
+
77
+ ```ts
78
+ const resp = await connManager.getConnector().sendRICRESTMsg('ircomms/send', {
79
+ side: 2, type: 1, seq: seqNo, hex: asciiToHex(messageText),
80
+ });
81
+ // resp e.g. { rslt: "ok", seq: 42, queued: 1 }
82
+ ```
83
+
84
+ Polling the receive queue uses the same call:
85
+
86
+ ```ts
87
+ const resp = await connManager.getConnector().sendRICRESTMsg('ircomms/rx', { popFrame: 1 });
88
+ ```
89
+
90
+ **Receiving reports** — `RaftMsgHandler` decodes `MSG_TYPE_REPORT` frames, parses the JSON,
91
+ and dispatches it to every callback registered via `reportMsgCallbacksSet`
92
+ (`src/RaftMsgHandler.ts:144`). The connector exposes the handler publicly:
93
+
94
+ ```ts
95
+ const handler = connManager.getConnector().getRaftMsgHandler(); // RaftConnector.ts:220
96
+ handler.reportMsgCallbacksSet('messagePanel', async (report) => { ... });
97
+ // on unmount:
98
+ handler.reportMsgCallbacksDelete('messagePanel');
99
+ ```
100
+
101
+ The callback receives a `RaftReportMsg` object (exported from `src/main.ts` via
102
+ `export * from './RaftTypes'`). Note `RaftReportMsg` only declares a fixed set of fields;
103
+ the `ircomms` report carries extra fields (`type`, `seq`, `payload`, `crc`, `rxMs`, …) that
104
+ must be read by extending/casting the type.
105
+
106
+ The connector already registers an internal `"eventHandler"` report callback for `sysevent`
107
+ shutdown handling — adding a second `"messagePanel"` callback is independent and supported.
108
+
109
+ ## Proposed design
110
+
111
+ ### New component: `MessagePanel.tsx`
112
+
113
+ Add `examples/dashboard/src/MessagePanel.tsx`, structured like `CommandPanel.tsx`:
114
+
115
+ - A two-column `info-box` (reuse the existing `info-boxes` / `info-box` / `info-columns`
116
+ layout classes).
117
+ - **Left column** — message composer: ASCII text input, destination `<select>`, Send button.
118
+ - **Right column** — received-message log: a scrollable list, newest last (or newest first),
119
+ with a Clear button.
120
+
121
+ Suggested layout:
122
+
123
+ ```text
124
+ +-------------------------- Message Panel ---------------------------+
125
+ | Compose | Received (IR side 2) |
126
+ | [ Enter ASCII message_________ ] | 12:01:03 src? "hello" |
127
+ | Destination: [ Cog IR side 2 v ] | 12:01:09 src? "ping back" |
128
+ | [ Send Message ] | ... |
129
+ | status: queued seq 42 | [ Clear log ] |
130
+ +--------------------------------------------------------------------+
131
+ ```
132
+
133
+ ### Destination drop-down (data-driven)
134
+
135
+ Make the destination list a small array so future destinations are easy to add:
136
+
137
+ ```ts
138
+ interface IRDestination {
139
+ label: string; // shown in the drop-down
140
+ endpoint: string; // e.g. 'ircomms/send'
141
+ baseParams: object; // e.g. { side: 2, type: 1 }
142
+ }
143
+
144
+ const destinations: IRDestination[] = [
145
+ { label: 'Cog IR (side 2)', endpoint: 'ircomms/send', baseParams: { side: 2, type: 1 } },
146
+ ];
147
+ ```
148
+
149
+ The `seq` and `hex` parameters are added per-send. Future entries could target side 1, the
150
+ tiny-packet `ircomms/tx` endpoint, or other transports without changing the panel logic.
151
+
152
+ ### Send path
153
+
154
+ 1. Read the ASCII text from the input.
155
+ 2. Validate: non-empty, and ≤ 80 characters (160 hex chars — the `ircomms/send` limit). Show
156
+ an inline error otherwise.
157
+ 3. Convert ASCII → hex (see [helpers](#asciihex-helpers)).
158
+ 4. Maintain a rolling `seq` counter. The validated diagnostic commands use `seq` in the
159
+ `0-15` range, so wrap with `seq = (seq + 1) & 0x0f`.
160
+ 5. Call `sendRICRESTMsg(dest.endpoint, { ...dest.baseParams, seq, hex })`.
161
+ 6. Show the result (`resp.rslt`, `resp.seq`, `resp.queued`) as a small status line, and warn
162
+ on `rslt !== 'ok'`. Keep a local sent-message history (optional, mirrors CommandPanel's
163
+ command history / arrow-key recall).
164
+
165
+ ### Receive path
166
+
167
+ The connected device receives IR messages from *another* Cog automatically via its wake-probe
168
+ path and queues them. The dashboard surfaces them in the log. Two mechanisms are available:
169
+
170
+ **A. Polling `ircomms/rx?popFrame=1` (recommended primary mechanism for v1).**
171
+
172
+ - On a timer (e.g. every 750 ms) while connected, call `sendRICRESTMsg('ircomms/rx', { popFrame: 1 })`.
173
+ - If the response contains a frame, decode its payload hex → ASCII and append a log entry,
174
+ then poll again immediately to drain the queue; stop when empty.
175
+ - This reliably returns the full payload regardless of report-frame shape, which is why it is
176
+ preferred for the first implementation.
177
+
178
+ **B. Report subscription (low-latency enhancement).**
179
+
180
+ - Register a `messagePanel` report callback and filter for
181
+ `report.msgType?.toLowerCase() === 'ircomms' && report.msgName?.toLowerCase() === 'rx'`.
182
+ - Use it either to display the message directly (if the report includes the payload) or
183
+ simply to trigger an immediate drain of `ircomms/rx?popFrame=1` instead of waiting for the
184
+ next poll tick.
185
+
186
+ **Recommendation:** implement (A) first for a working, reliable panel. Add (B) as a latency
187
+ improvement once the firmware report shape for framed messages is confirmed. Both can run
188
+ together — the report callback just triggers an early poll.
189
+
190
+ A "Clear log" button should clear the local log; optionally also call `ircomms/rx?clear=1` to
191
+ clear the device-side queue so stale messages are not re-polled.
192
+
193
+ ### ASCII/hex helpers
194
+
195
+ `ConnManager` already has `hexStringToBytes()`. Two more small pure functions are needed —
196
+ put them in `MessagePanel.tsx` or a shared `examples/dashboard/src/utils.ts`:
197
+
198
+ ```ts
199
+ // 'hi' -> '6869'
200
+ function asciiToHex(text: string): string {
201
+ return Array.from(text, c => c.charCodeAt(0).toString(16).padStart(2, '0')).join('');
202
+ }
203
+
204
+ // '6869' -> 'hi' (non-printable bytes shown as '.', or escaped)
205
+ function hexToAscii(hex: string): string {
206
+ const out = (hex.match(/.{1,2}/g) ?? []).map(b => parseInt(b, 16));
207
+ return out.map(c => (c >= 0x20 && c < 0x7f) ? String.fromCharCode(c) : '.').join('');
208
+ }
209
+ ```
210
+
211
+ Restrict the input to the ASCII printable range (0x20–0x7e) on send, or document that
212
+ non-ASCII characters are encoded as their lower byte only.
213
+
214
+ ### CSS
215
+
216
+ Reuse `command-input` and `send-command-button` for the input and button. Add a few classes
217
+ to `examples/dashboard/src/styles.css` for the new elements, following the existing dark
218
+ theme (`#333`/`#444` backgrounds, `#666` borders):
219
+
220
+ - `.message-destination-select` — the drop-down.
221
+ - `.message-log` — scrollable container (`max-height`, `overflow-y: auto`).
222
+ - `.message-log-entry` — one received message (timestamp + text).
223
+ - `.message-status` — the send-result status line.
224
+
225
+ ### Integration into `Main.tsx`
226
+
227
+ Render `<MessagePanel />` immediately after `<CommandPanel />` inside the
228
+ `connected-panel` block (`examples/dashboard/src/Main.tsx:178`). It only makes sense when
229
+ connected, which that block already guarantees.
230
+
231
+ Optionally gate the panel so it only appears for Cog devices: on mount, call
232
+ `ircomms/status` once and only enable the panel (or show a "not available on this device"
233
+ note) if it returns `rslt: "ok"`. This avoids a confusing dead panel when connected to a
234
+ Marty or generic device. For a first cut the panel can always render and simply report
235
+ failures from `sendRICRESTMsg`.
236
+
237
+ ## Open questions / firmware coordination
238
+
239
+ These should be confirmed against the actual firmware build before or during implementation:
240
+
241
+ 1. **Framed receive report shape.** The firmware doc shows the tiny-packet report
242
+ (`payload` as a single number). It does not pin down the report emitted for framed
243
+ `ircomms/send` datagrams. For mechanism (B) to display message text directly, the
244
+ `ircomms`/`rx` report should include the payload as `payloadHex` (or an escaped ASCII
245
+ string). If it does not, the panel must rely on polling (mechanism A). **Recommended
246
+ firmware change:** include `payloadHex` and `len` in the framed-receive report.
247
+ 2. **`ircomms/rx?popFrame=1` response fields.** Confirm the exact JSON field names returned
248
+ (the firmware doc variously mentions `frameHex`, `payloadHex`, `rxnext`/`rxpeek`,
249
+ `rx?popFrame`). The panel's decode step depends on the actual field name, and on what is
250
+ returned when the queue is empty (`rslt: "ok"` with no frame vs `rslt: "fail"`).
251
+ 3. **`text=` parameter.** Some diagnostic examples use `ircomms/send?...&text=hi` directly.
252
+ If `text=` is reliably supported, the dashboard could skip hex conversion. The validated
253
+ command list uses `hex=`, so `hex=` is the safer default.
254
+ 4. **Device prerequisites.** The connected Cog must be running a build with `CogIRComms`
255
+ compiled in and `CogIRComms.enable = 1` (and `rxReportEnable = 1` for mechanism B) in its
256
+ systype config. The dashboard cannot set these — they are firmware build/config
257
+ preconditions. Surface `ircomms/status` so the user can see whether the feature is live.
258
+ 5. **Two devices needed for a real test.** The dashboard connects to one Cog. Sends leave
259
+ that Cog over IR; the receive log shows what that Cog received over IR from a *second*
260
+ Cog. A meaningful round-trip test needs two Cogs physically aligned on side 2, and
261
+ typically two dashboard instances (one per Cog) — or the second Cog echoing messages.
262
+ 6. **`seq` range.** The framed envelope carries a 1-byte sequence number, but the validated
263
+ diagnostic commands constrain `seq` to `0-15`. Wrap the rolling counter at 16 unless
264
+ firmware confirms the full byte range is accepted by `ircomms/send`.
265
+
266
+ ## Implementation steps
267
+
268
+ 1. Add `asciiToHex` / `hexToAscii` helpers (in `MessagePanel.tsx` or a shared util).
269
+ 2. Create `examples/dashboard/src/MessagePanel.tsx`:
270
+ - State: message text, selected destination, rolling `seq`, send-status, received log.
271
+ - Send handler → `sendRICRESTMsg('ircomms/send', {...})`.
272
+ - Receive: poll `ircomms/rx?popFrame=1` on an interval via `useEffect`/`setInterval`
273
+ (clean up on unmount); decode hex payload → ASCII; append to log.
274
+ - Optional: register/deregister a `messagePanel` report callback for low-latency
275
+ notification.
276
+ 3. Add the destination array (one entry: Cog IR side 2).
277
+ 4. Add CSS classes to `styles.css`.
278
+ 5. Wire `<MessagePanel />` into `Main.tsx` after `<CommandPanel />`.
279
+ 6. (Optional) Gate the panel on an `ircomms/status` probe.
280
+
281
+ ## Related: feature-gating panels on capability probes
282
+
283
+ The Message Panel will probe `ircomms/status` once on connect to decide whether to render
284
+ (see step 6 above). The same pattern should be applied to the existing **datalog** panels
285
+ (`LogConfigPanel`, `LoggingPanel`, `LogFilesPanel`), because some firmwares do not support
286
+ that API and the dashboard currently produces noisy console errors against them:
287
+
288
+ ```text
289
+ _handleResponseMessages RICREST rslt fail msgNum 115
290
+ resp {"req":"datalog?action=status","rslt":"fail","error":"failUnknownAPI"}
291
+ ```
292
+
293
+ Proposed work (separate from but parallel to the Message Panel):
294
+
295
+ 1. On (re)connect, issue a single `datalog?action=status` (or equivalent capability) call.
296
+ 2. Cache the result as a "datalog supported" boolean on `ConnManager` or a small capability
297
+ context, keyed off the current connection.
298
+ 3. Conditionally render `LogConfigPanel` / `LoggingPanel` / `LogFilesPanel` and suppress any
299
+ periodic datalog polling when the capability is absent.
300
+ 4. Reset the cached capability on disconnect so reconnecting to a different device re-probes.
301
+
302
+ A small shared helper — e.g. `useFeatureSupported(probeApi: string): boolean | undefined` —
303
+ would let both the Message Panel and the datalog panels use the same gating pattern, and
304
+ keep `failUnknownAPI` errors out of the console for any feature that is not built into the
305
+ connected firmware.
306
+
307
+ ## Testing
308
+
309
+ - **Single device, no peer:** connect to one Cog, send a message, confirm `sendRICRESTMsg`
310
+ resolves with `rslt: "ok"` and the status line updates. The receive log stays empty.
311
+ - **Two devices:** align two Cogs side-2-to-side-2 (~10 mm gap, per the firmware validation
312
+ setup). Connect a dashboard to each. Send from one and confirm the message appears in the
313
+ other's receive log with the correct decoded ASCII text.
314
+ - **Round-trip:** send in both directions; confirm sequence numbers advance and no messages
315
+ are dropped.
316
+ - **Edge cases:** empty message rejected; over-length message (>80 chars) rejected with a
317
+ clear error; non-printable received bytes rendered safely; panel behaves correctly across
318
+ disconnect/reconnect (timers and report callbacks cleaned up).
319
+ - **Regression:** confirm the existing Command Panel and other panels are unaffected by the
320
+ added report callback and polling.
@@ -1,13 +1,22 @@
1
1
  import { DeviceTypePollRespMetadata } from "./RaftDeviceInfo";
2
2
  import { DeviceAttributesState, DeviceTimeline } from "./RaftDeviceStates";
3
+ export interface AttrDecodeDiagContext {
4
+ deviceKey?: string;
5
+ deviceType?: string;
6
+ debugMsgIndex?: number;
7
+ sampleStartIdx?: number;
8
+ sampleEndIdx?: number;
9
+ }
3
10
  export default class AttributeHandler {
4
11
  private _customAttrHandler;
5
12
  private POLL_RESULT_TIMESTAMP_SIZE;
6
13
  private POLL_RESULT_WRAP_VALUE;
7
14
  private POLL_RESULT_RESOLUTION_US;
8
- processMsgAttrGroup(msgBuffer: Uint8Array, msgBufIdx: number, deviceTimeline: DeviceTimeline, pollRespMetadata: DeviceTypePollRespMetadata, devAttrsState: DeviceAttributesState, maxDataPoints: number, msgEndIdx?: number): number;
15
+ processMsgAttrGroup(msgBuffer: Uint8Array, msgBufIdx: number, deviceTimeline: DeviceTimeline, pollRespMetadata: DeviceTypePollRespMetadata, devAttrsState: DeviceAttributesState, maxDataPoints: number, msgEndIdxOrDiagCtx?: number | AttrDecodeDiagContext, diagCtx?: AttrDecodeDiagContext): number;
9
16
  private validateAttributes;
10
17
  private processMsgAttribute;
18
+ private _overrunWarnSeen;
19
+ private warnAttrOverrun;
11
20
  private signExtend;
12
21
  private extractTimestampAndAdvanceIdx;
13
22
  private isValueInRangeString;
@@ -12,6 +12,7 @@ const tslib_1 = require("tslib");
12
12
  const RaftCustomAttrHandler_1 = tslib_1.__importDefault(require("./RaftCustomAttrHandler"));
13
13
  const RaftDeviceInfo_1 = require("./RaftDeviceInfo");
14
14
  const RaftStruct_1 = require("./RaftStruct");
15
+ const RaftUtils_1 = tslib_1.__importDefault(require("./RaftUtils"));
15
16
  class AttributeHandler {
16
17
  constructor() {
17
18
  // Custom attribute handler
@@ -20,8 +21,21 @@ class AttributeHandler {
20
21
  this.POLL_RESULT_TIMESTAMP_SIZE = 2;
21
22
  this.POLL_RESULT_WRAP_VALUE = this.POLL_RESULT_TIMESTAMP_SIZE === 2 ? 65536 : 4294967296;
22
23
  this.POLL_RESULT_RESOLUTION_US = 100;
24
+ // One-shot detailed warning when an attribute decode would overrun the
25
+ // sample/message bounds. Includes the exact bytes and schema so the
26
+ // firmware vs. registered device-type schema can be reconciled.
27
+ this._overrunWarnSeen = new Set();
23
28
  }
24
- processMsgAttrGroup(msgBuffer, msgBufIdx, deviceTimeline, pollRespMetadata, devAttrsState, maxDataPoints, msgEndIdx = msgBuffer.length) {
29
+ processMsgAttrGroup(msgBuffer, msgBufIdx, deviceTimeline, pollRespMetadata, devAttrsState, maxDataPoints, msgEndIdxOrDiagCtx = msgBuffer.length, diagCtx) {
30
+ var _a;
31
+ // Merge rationale: Robotical's devbin compatibility parser needs an
32
+ // explicit sample boundary; upstream's diagnostics need per-sample
33
+ // context. Accept both call styles so malformed samples are skipped
34
+ // without losing useful overrun warnings.
35
+ const msgEndIdx = typeof msgEndIdxOrDiagCtx === "number"
36
+ ? msgEndIdxOrDiagCtx
37
+ : (_a = msgEndIdxOrDiagCtx.sampleEndIdx) !== null && _a !== void 0 ? _a : msgBuffer.length;
38
+ const effectiveDiagCtx = typeof msgEndIdxOrDiagCtx === "number" ? diagCtx : msgEndIdxOrDiagCtx;
25
39
  // console.log(`processMsgAttrGroup msg ${msgHexStr} timestamp ${timestamp} origTimestamp ${origTimestamp} msgBufIdx ${msgBufIdx}`)
26
40
  const boundedMsgEndIdx = Math.min(Math.max(msgEndIdx, msgBufIdx), msgBuffer.length);
27
41
  // Extract msg timestamp
@@ -33,6 +47,11 @@ class AttributeHandler {
33
47
  const msgDataStartIdx = msgBufIdx;
34
48
  // New attribute values (in order as they appear in the attributes JSON)
35
49
  let newAttrValues = [];
50
+ // Tracks whether any individual attribute decode failed in the non-custom path.
51
+ // When true, the detailed per-attribute overrun warning has already been emitted
52
+ // by processMsgAttribute, so we suppress the redundant downstream length/empty
53
+ // warnings that would otherwise fire every poll.
54
+ let attrDecodeFailed = false;
36
55
  if ("c" in pollRespMetadata) {
37
56
  // Extract attribute values using custom handler
38
57
  newAttrValues = this._customAttrHandler.handleAttr(pollRespMetadata, msgBuffer, msgBufIdx, boundedMsgEndIdx);
@@ -71,11 +90,12 @@ class AttributeHandler {
71
90
  if (!("t" in attrDef)) {
72
91
  console.warn(`DeviceManager msg unknown msgBuffer ${msgBuffer} tsUs ${timestampUs} attrDef ${JSON.stringify(attrDef)}`);
73
92
  newAttrValues.push([]);
93
+ attrDecodeFailed = true;
74
94
  continue;
75
95
  }
76
96
  // console.log(`RaftAttrHdlr.processMsgAttrGroup attr ${attrDef.n} msgBufIdx ${msgBufIdx} timestampUs ${timestampUs} attrDef ${JSON.stringify(attrDef)}`);
77
97
  // Process the attribute
78
- const { values, newMsgBufIdx } = this.processMsgAttribute(attrDef, msgBuffer, msgBufIdx, msgDataStartIdx, boundedMsgEndIdx);
98
+ const { values, newMsgBufIdx } = this.processMsgAttribute(attrDef, msgBuffer, msgBufIdx, msgDataStartIdx, boundedMsgEndIdx, pollRespMetadata, effectiveDiagCtx);
79
99
  if (newMsgBufIdx < 0) {
80
100
  return -1;
81
101
  }
@@ -97,13 +117,17 @@ class AttributeHandler {
97
117
  const numNewDataPoints = newAttrValues[0].length;
98
118
  for (let i = 1; i < newAttrValues.length; i++) {
99
119
  if (newAttrValues[i].length !== numNewDataPoints) {
100
- console.warn(`DeviceManager msg attrGroup ${pollRespMetadata} attrName ${pollRespMetadata.a[i].n} newAttrValues ${newAttrValues} do not have the same length`);
120
+ if (!attrDecodeFailed) {
121
+ console.warn(`DeviceManager msg attrGroup ${JSON.stringify(pollRespMetadata)} attrName ${pollRespMetadata.a[i].n} newAttrValues lengths ${newAttrValues.map(v => v.length).join(",")} do not match`);
122
+ }
101
123
  return msgDataStartIdx + pollRespSizeBytes;
102
124
  }
103
125
  }
104
126
  // All attributes in the schema should have values
105
127
  if (newAttrValues.length !== pollRespMetadata.a.length) {
106
- console.warn(`DeviceManager msg attrGroup ${pollRespMetadata} newAttrValues ${newAttrValues} length does not match attrGroup.a length`);
128
+ if (!attrDecodeFailed) {
129
+ console.warn(`DeviceManager msg attrGroup ${JSON.stringify(pollRespMetadata)} newAttrValues length ${newAttrValues.length} does not match attrGroup.a length ${pollRespMetadata.a.length}`);
130
+ }
107
131
  return msgDataStartIdx + pollRespSizeBytes;
108
132
  }
109
133
  // Add the new attribute values to the device state
@@ -238,7 +262,7 @@ class AttributeHandler {
238
262
  }
239
263
  }
240
264
  }
241
- processMsgAttribute(attrDef, msgBuffer, msgBufIdx, msgDataStartIdx, msgEndIdx) {
265
+ processMsgAttribute(attrDef, msgBuffer, msgBufIdx, msgDataStartIdx, msgEndIdx, pollRespMetadata, diagCtx) {
242
266
  // Current field message string index
243
267
  let curFieldBufIdx = msgBufIdx;
244
268
  let attrUsesAbsPos = false;
@@ -257,7 +281,8 @@ class AttributeHandler {
257
281
  // Copy bytes from the specified positions
258
282
  for (let i = 0; i < attrDef.at.length && i < elemSize; i++) {
259
283
  const sourceIdx = msgDataStartIdx + attrDef.at[i];
260
- if (sourceIdx >= boundedMsgEndIdx) {
284
+ if (sourceIdx < msgDataStartIdx || sourceIdx >= boundedMsgEndIdx) {
285
+ this.warnAttrOverrun(attrDef, msgBuffer, sourceIdx, elemSize, msgDataStartIdx, true, pollRespMetadata, diagCtx, sourceIdx >= msgBuffer.length ? "msgBuffer" : "sample");
261
286
  return { values: [], newMsgBufIdx: -1 };
262
287
  }
263
288
  bytesForType[i] = msgBuffer[sourceIdx];
@@ -273,11 +298,13 @@ class AttributeHandler {
273
298
  }
274
299
  attrUsesAbsPos = true;
275
300
  }
276
- // Check if outside bounds of message
301
+ // Merge rationale: keep Robotical's hard sample bounds as the source of
302
+ // truth, but emit upstream's one-shot diagnostic when a schema tries to
303
+ // read beyond the sample or buffer.
277
304
  const attrEndIdx = curFieldBufIdx + numBytesConsumed;
278
305
  const effectiveMsgEndIdx = Math.min(Math.max(msgEndIdx, curFieldBufIdx), msgBuffer.length);
279
306
  if (curFieldBufIdx >= effectiveMsgEndIdx || attrEndIdx > effectiveMsgEndIdx) {
280
- // console.warn(`DeviceManager msg outside bounds msgBuffer ${msgBuffer} attrName ${attrDef.n}`);
307
+ this.warnAttrOverrun(attrDef, msgBuffer, curFieldBufIdx, numBytesConsumed, msgDataStartIdx, attrUsesAbsPos, pollRespMetadata, diagCtx, attrEndIdx > msgBuffer.length ? "msgBuffer" : "sample");
281
308
  return { values: [], newMsgBufIdx: -1 };
282
309
  }
283
310
  // Slice into buffer
@@ -379,6 +406,31 @@ class AttributeHandler {
379
406
  // Return the value
380
407
  return { values: attrValues, newMsgBufIdx: msgBufIdx };
381
408
  }
409
+ warnAttrOverrun(attrDef, msgBuffer, curFieldBufIdx, attrTypeSize, msgDataStartIdx, attrUsesAbsPos, pollRespMetadata, diagCtx, overrunOf) {
410
+ var _a, _b, _c, _d, _e, _f;
411
+ const sampleStart = (_a = diagCtx === null || diagCtx === void 0 ? void 0 : diagCtx.sampleStartIdx) !== null && _a !== void 0 ? _a : msgDataStartIdx;
412
+ const sampleEnd = diagCtx === null || diagCtx === void 0 ? void 0 : diagCtx.sampleEndIdx;
413
+ const dedupeKey = `${(_b = diagCtx === null || diagCtx === void 0 ? void 0 : diagCtx.deviceKey) !== null && _b !== void 0 ? _b : "?"}|${(_c = diagCtx === null || diagCtx === void 0 ? void 0 : diagCtx.deviceType) !== null && _c !== void 0 ? _c : "?"}|${attrDef.n}|${attrDef.t}|${attrUsesAbsPos ? attrDef.at : "rel"}`;
414
+ if (this._overrunWarnSeen.has(dedupeKey)) {
415
+ return;
416
+ }
417
+ this._overrunWarnSeen.add(dedupeKey);
418
+ const sampleHex = sampleEnd !== undefined
419
+ ? RaftUtils_1.default.bufferToHex(msgBuffer.slice(sampleStart, sampleEnd))
420
+ : "<unknown sample bounds>";
421
+ const availableInSample = sampleEnd !== undefined ? Math.max(0, sampleEnd - curFieldBufIdx) : -1;
422
+ const availableInBuffer = Math.max(0, msgBuffer.length - curFieldBufIdx);
423
+ console.warn(`AttributeHandler decode overrun (${overrunOf}): ` +
424
+ `deviceKey=${(_d = diagCtx === null || diagCtx === void 0 ? void 0 : diagCtx.deviceKey) !== null && _d !== void 0 ? _d : "?"} deviceType=${(_e = diagCtx === null || diagCtx === void 0 ? void 0 : diagCtx.deviceType) !== null && _e !== void 0 ? _e : "?"} ` +
425
+ `debugMsgIndex=${(_f = diagCtx === null || diagCtx === void 0 ? void 0 : diagCtx.debugMsgIndex) !== null && _f !== void 0 ? _f : "?"} attr.n=${attrDef.n} attr.t=${attrDef.t} ` +
426
+ `attrTypeSize=${attrTypeSize} attrUsesAbsPos=${attrUsesAbsPos} attr.at=${JSON.stringify(attrDef.at)} ` +
427
+ `curFieldBufIdx=${curFieldBufIdx} msgBuffer.length=${msgBuffer.length} ` +
428
+ `sampleStartIdx=${sampleStart} sampleEndIdx=${sampleEnd !== null && sampleEnd !== void 0 ? sampleEnd : "?"} ` +
429
+ `availableInSample=${availableInSample} availableInBuffer=${availableInBuffer} ` +
430
+ `sampleHex=${sampleHex} ` +
431
+ `pollRespMetadata.b=${pollRespMetadata === null || pollRespMetadata === void 0 ? void 0 : pollRespMetadata.b} ` +
432
+ `schema=${JSON.stringify(pollRespMetadata === null || pollRespMetadata === void 0 ? void 0 : pollRespMetadata.a)}`);
433
+ }
382
434
  signExtend(value, mask) {
383
435
  const signBitMask = (mask + 1) >> 1;
384
436
  const signBit = value & signBitMask;