Skip to content

Refactor servo packet handling to reuse buffers#348

Closed
meganetaaan wants to merge 1 commit intodev/v1.0from
codex/refactor-callback-to-pass-buffer-and-length
Closed

Refactor servo packet handling to reuse buffers#348
meganetaaan wants to merge 1 commit intodev/v1.0from
codex/refactor-callback-to-pass-buffer-and-length

Conversation

@meganetaaan
Copy link
Copy Markdown
Collaborator

@meganetaaan meganetaaan commented Nov 14, 2025

Summary

  • refactor servo packet handlers to pass reusable payload buffers to callbacks and avoid per-message slicing
  • update servo drivers to consume buffer/length responses, returning typed arrays with improved checksum handling and read validation
  • add a shared payload buffer helper, TypeScript test configuration, and unit tests plus npm scripts/ignores for the new test build output

Testing

  • npm run test

Codex Task

Summary by CodeRabbit

  • New Features

    • Added unit testing infrastructure with new test scripts and configuration.
  • Tests

    • Introduced unit tests for payload buffer management to verify allocation behavior and data handling.
  • Chores

    • Updated build configuration to ignore generated test artifacts.
    • Enhanced internal data handling mechanisms for improved robustness in driver communication.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Nov 14, 2025

Walkthrough

Introduces a new PayloadBuffer utility class for binary buffer management and refactors firmware drivers (Dynamixel, RS30X, SCServo) to use buffer-based payload handling with explicit lengths instead of numeric arrays. Adds test infrastructure with npm scripts and TypeScript test configuration, updates gitignore for build artifacts.

Changes

Cohort / File(s) Summary
Build Configuration
firmware/.gitignore, firmware/package.json, firmware/tsconfig.test.json
Added ignore patterns for dist/ and dist-tests/ directories; introduced npm scripts test and test:unit for running unit tests with TypeScript compilation; created test-specific TypeScript configuration extending base config with output to dist-tests/ and test file includes.
Payload Buffer Infrastructure
firmware/stackchan/drivers/payload-buffer.ts
Introduced new PayloadBuffer class with constructor, ensureCapacity(), and copyFrom() methods for dynamic buffer management and data copying.
Payload Buffer Tests
firmware/tests/unit/payload-buffer.test.ts
Added unit tests verifying copyFrom() behavior including capacity reuse and buffer growth scenarios with content and reference identity verification.
Driver Refactoring: Callback & Checksum Updates
firmware/stackchan/drivers/dynamixel.ts, firmware/stackchan/drivers/rs30x.ts, firmware/stackchan/drivers/scservo.ts
Updated callback signatures from (bytes: number[]) to (buffer: Uint8Array, length: number) and refactored checksum functions to accept buffer with start/end indices; integrated PayloadBuffer into packet handlers and command resolution paths; replaced slice() with subarray() for payload handling; changed internal send/command methods to return Uint8Array | undefined instead of number[]; added validation checks for insufficient payload lengths in read methods.

Sequence Diagram(s)

sequenceDiagram
    participant Caller
    participant PacketHandler
    participant PayloadBuffer
    participant Callback
    
    Note over PacketHandler,PayloadBuffer: New Flow: Buffer-based Payloads
    Caller->>PacketHandler: onPacketReceived(frame)
    PacketHandler->>PayloadBuffer: copyFrom(frame, length)
    PayloadBuffer-->>PacketHandler: Uint8Array view
    PacketHandler->>Callback: callback(buffer, length)
    Callback->>Caller: process binary payload
    
    rect rgb(240, 248, 255)
    Note over PacketHandler,Callback: Replaces old flow: numeric array callbacks
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

  • Driver refactoring across three files: Parallel changes to dynamixel.ts, rs30x.ts, and scservo.ts with callback signature updates require verification that each correctly handles the new buffer/length pattern.
  • Payload extraction logic: Ensure PayloadBuffer.copyFrom() offset and length handling is correct in all three drivers, especially around checksum boundary adjustments.
  • Validation logic additions: New minimum-length checks and error handling in read methods (readModelNumber, readFirmwareVersion, etc.) need verification for correctness and consistency.
  • Promise resolution changes: Verify that command resolution now correctly passes Uint8Array | undefined and handles timeout cases properly.
  • Test coverage: Verify payload-buffer.test.ts adequately exercises edge cases around buffer growth and reuse.

Possibly related PRs

  • Feature/no external #306: Consolidates external servo drivers (RS30X, SCServo) into the repo; this PR's payload-buffer refactoring and callback signature changes directly support that integration by providing a unified binary payload mechanism.

Suggested reviewers

  • stc1988

Poem

🐰 Buffers of data, payloads so keen,
No more numeric arrays in between,
A PayloadBuffer hops down the lane,
Callbacks now copy—what a refrain!
Binary whispers, lengths aligned,
Drivers unified, drivers refined! 🎉

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly and specifically summarizes the main change: refactoring servo packet handling to reuse buffers. This accurately reflects the primary objective of the changeset across multiple driver files and supporting infrastructure.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch codex/refactor-callback-to-pass-buffer-and-length

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
firmware/stackchan/drivers/dynamixel.ts (1)

487-498: readOffsetAngle sign and payload decoding are inconsistent with other reads

Two problems here:

  1. values[0] & 0x8000 can never be non-zero because values[0] is a single byte (0–255); the sign bit is in the combined 16‑bit value, not in a standalone 8‑bit value.
  2. Like readModelNumber, this method appears to treat values[0]/values[1] as data, but elsewhere values[1] is the status byte and data starts at values[2].

A safer implementation that follows the same layout as the other reads would be:

   async readOffsetAngle(): Promise<number> {
     const values = await this.#sendCommand(INSTRUCTION.READ, ADDRESS.HOMING_OFFSET, 2)
-    if (values == null || values.length < 2) {
-      throw new Error('failed to read offset angle')
-    }
-    const isCcw = Boolean(values[0] & 0x8000)
-    let offset = ((values[1] & 0x7fff) << 8) | values[0]
+    if (values == null || values.length < 4) {
+      throw new Error('failed to read offset angle')
+    }
+    if (values[1] !== 0) {
+      throw new Error(`servo returned error code: ${values[1]}`)
+    }
+    const raw = values[2] | (values[3] << 8)
+    const isCcw = Boolean(raw & 0x8000)
+    let offset = raw & 0x7fff
     if (isCcw) {
       offset *= -1
     }
     return offset
   }

This both fixes the impossible 0x8000 mask on a byte and aligns index usage with the known status/data layout.

firmware/stackchan/drivers/scservo.ts (1)

249-257: readOffsetAngle doesn’t handle timeouts and uses an impossible bit mask

Two issues here:

  1. #sendCommand now resolves to Uint8Array | undefined, but readOffsetAngle assumes a non‑undefined array. On timeout, values will be undefined and values[0] will throw.
  2. As in the Dynamixel case, values[0] & 0x8000 can never be non‑zero because values[0] is a byte; the sign bit lives in the combined 16‑bit value.

A safer implementation that matches how you encode the offset ((Number(isCcw) << 15) | (a & 0x7fff) and send via le) would be:

   async readOffsetAngle(): Promise<number> {
     const values = await this.#sendCommand(COMMAND.READ, ADDRESS.OFFSET, 2)
-    const isCcw = Boolean(values[0] & 0x8000)
-    let offset = ((values[0] & 0x7fff) << 8) | values[1]
+    if (values == null || values.length < 2) {
+      throw new Error('failed to read offset angle')
+    }
+    const raw = (values[0] << 8) | values[1]
+    const isCcw = Boolean(raw & 0x8000)
+    let offset = raw & 0x7fff
     if (isCcw) {
       offset *= -1
     }
     return offset
   }

This adds the missing timeout/length guard and correctly interprets the sign bit from the combined 16‑bit value.

🧹 Nitpick comments (5)
firmware/package.json (1)

15-16: Make test script more portable and confirm Node test runner support

The test flow itself looks good (clean → compile with tsconfig.test.jsonnode --test on dist-tests/tests/unit). Two minor concerns:

  • rm -rf dist-tests is POSIX-specific and will break on Windows shells. If cross‑platform support matters, consider using a Node-based cleanup (node -e "import('node:fs/promises').then(fs => fs.rm('dist-tests',{recursive:true,force:true}))") or a tool like rimraf.
  • Ensure your CI/dev environment runs on a Node version that supports node --test with ESM (and the NodeNext module kind you’ve configured).
firmware/tsconfig.test.json (1)

1-16: Test tsconfig wiring looks consistent (small optional cleanup)

The test config matches the new test pipeline: it compiles payload-buffer.ts plus tests/unit/**/*.ts into dist-tests with Node/ESM settings and Node types, which fits the node --test dist-tests/tests/unit invocation.

If you want to trim noise, you can drop the explicit "exclude": [] since it’s redundant when "include" is already scoped.

firmware/stackchan/drivers/payload-buffer.ts (1)

1-20: Consider using Uint8Array#set for clearer and possibly faster copies

The current implementation is correct and matches the tests. If you’d like to simplify and lean on built-in typed-array ops, you could implement copyFrom like this:

-  copyFrom(source: Uint8Array, length: number, offset = 0): Uint8Array {
-    this.ensureCapacity(length)
-    for (let i = 0; i < length; i++) {
-      this.#buffer[i] = source[offset + i]
-    }
-    return this.#buffer.subarray(0, length)
-  }
+  copyFrom(source: Uint8Array, length: number, offset = 0): Uint8Array {
+    this.ensureCapacity(length)
+    this.#buffer.set(source.subarray(offset, offset + length), 0)
+    return this.#buffer.subarray(0, length)
+  }

Behavior is the same as long as callers pass valid offset/length, but the intent is a bit clearer and avoids the manual loop.

firmware/stackchan/drivers/rs30x.ts (1)

67-79: Align checksum helper documentation and naming with new signature

checksum now operates over a Uint8Array slice defined by [start, end):

function checksum(buffer: Uint8Array, start: number, end: number): number {
  let sum = 0
  for (let i = start; i < end; i++) {
    sum ^= buffer[i]
  }
  return sum
}

and is called as:

const cs = checksum(rxBuf, 2, this.#idx - 1) & 0xff
this.#txBuf[idx] = checksum(this.#txBuf, 2, idx)

The existing comment still refers to a “packet array except checksum”. For future readers, consider updating the JSDoc to describe the (buffer, start, endExclusive) parameters explicitly and maybe rename endendExclusive to make the half-open range clear.

Also applies to: 232-233

firmware/stackchan/drivers/scservo.ts (1)

5-6: PacketHandler + checksum refactor looks correct

  • The new (buffer: Uint8Array, length: number) callback shape and #payloadBuffer integration avoid per-message allocations while still decoupling from the RX buffer.
  • checksum(buffer, length) is used consistently:
    • On RX: checksum(rxBuf, this.#idx - 1) sums bytes [2 .. this.#idx - 2] and compares against the last byte (rxBuf[this.#idx - 1]).
    • On TX: checksum(this.#txBuf, idx) sums [2 .. idx - 1] and writes the one‑byte checksum at idx.
  • The send path with Promise<Uint8Array | undefined> and timeout handling mirrors the Dynamixel pattern and is sound.

Only nit: the commented trace line still refers to arr instead of buffer, but that’s cosmetic.

Also applies to: 63-145, 152-160, 208-234

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 20fa936 and f8ab2e7.

📒 Files selected for processing (8)
  • firmware/.gitignore (1 hunks)
  • firmware/package.json (1 hunks)
  • firmware/stackchan/drivers/dynamixel.ts (13 hunks)
  • firmware/stackchan/drivers/payload-buffer.ts (1 hunks)
  • firmware/stackchan/drivers/rs30x.ts (8 hunks)
  • firmware/stackchan/drivers/scservo.ts (8 hunks)
  • firmware/tests/unit/payload-buffer.test.ts (1 hunks)
  • firmware/tsconfig.test.json (1 hunks)
🧰 Additional context used
🧠 Learnings (4)
📚 Learning: 2025-06-19T01:00:25.501Z
Learnt from: meganetaaan
Repo: stack-chan/stack-chan PR: 330
File: firmware/tests/services/mcp-client-service/main.ts:72-77
Timestamp: 2025-06-19T01:00:25.501Z
Learning: In the stack-chan project, scripts under the tests directory are not intended for automated testing but serve as manual testing/development tools. They don't implement explicit cleanup, and this will be considered when implementing proper automated testing in the future.

Applied to files:

  • firmware/.gitignore
  • firmware/package.json
📚 Learning: 2025-06-19T01:00:25.789Z
Learnt from: meganetaaan
Repo: stack-chan/stack-chan PR: 330
File: firmware/tests/services/mcp-client-service/main.ts:72-77
Timestamp: 2025-06-19T01:00:25.789Z
Learning: In the stack-chan project, scripts under the tests/ directory are not intended for automated testing but rather for manual testing and demonstration purposes. These scripts do not perform explicit cleanup operations, which is intentional for their current use case. Cleanup considerations will be addressed collectively when moving towards automated testing.

Applied to files:

  • firmware/.gitignore
  • firmware/package.json
📚 Learning: 2024-10-05T16:15:44.408Z
Learnt from: meganetaaan
Repo: stack-chan/stack-chan PR: 298
File: firmware/stackchan/drivers/dynamixel.ts:451-451
Timestamp: 2024-10-05T16:15:44.408Z
Learning: `dynamixel.ts`の`readFirmwareVersion()`メソッドでは、`values[1]`がステータスコードを表し、`values[1] === 0`がコマンド成功を意味する。

Applied to files:

  • firmware/stackchan/drivers/dynamixel.ts
📚 Learning: 2025-06-04T16:04:50.265Z
Learnt from: meganetaaan
Repo: stack-chan/stack-chan PR: 323
File: firmware/stackchan/services/mcp-server/mcp-server.ts:222-253
Timestamp: 2025-06-04T16:04:50.265Z
Learning: In the MCP server implementation (firmware/stackchan/services/mcp-server/mcp-server.ts), the user prefers to keep the message handler code minimal and simple, with validation logic integrated at the Tool definition level rather than in the message handler itself.

Applied to files:

  • firmware/stackchan/drivers/scservo.ts
🧬 Code graph analysis (5)
firmware/stackchan/drivers/payload-buffer.ts (1)
firmware/stackchan/ble/beacon-packet.ts (4)
  • BeaconDataPacket (16-102)
  • payload (99-101)
  • major (81-83)
  • constructor (26-35)
firmware/tests/unit/payload-buffer.test.ts (1)
firmware/stackchan/drivers/payload-buffer.ts (1)
  • PayloadBuffer (1-21)
firmware/stackchan/drivers/dynamixel.ts (2)
firmware/stackchan/drivers/payload-buffer.ts (1)
  • PayloadBuffer (1-21)
firmware/stackchan/drivers/scservo.ts (2)
  • command (208-234)
  • id (204-206)
firmware/stackchan/drivers/rs30x.ts (1)
firmware/stackchan/drivers/payload-buffer.ts (1)
  • PayloadBuffer (1-21)
firmware/stackchan/drivers/scservo.ts (1)
firmware/stackchan/drivers/payload-buffer.ts (1)
  • PayloadBuffer (1-21)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: build
  • GitHub Check: build
  • GitHub Check: build
🔇 Additional comments (6)
firmware/.gitignore (1)

5-6: Ignore dist and dist-tests artifacts under firmware

Adding /dist/ and /dist-tests/ here is consistent with the new build/test outputs and helps keep generated files out of version control. No issues spotted.

firmware/tests/unit/payload-buffer.test.ts (1)

1-32: Good coverage of PayloadBuffer reuse and growth semantics

The tests accurately exercise copyFrom under both “enough capacity” and “needs to grow once” scenarios, including buffer identity checks and offset handling. They align well with the intended behavior of PayloadBuffer.

firmware/stackchan/drivers/rs30x.ts (1)

89-91: PayloadBuffer integration in RS30X/PacketHandler looks sound (verify external usages of response buffers)

The refactor to (buffer: Uint8Array, length: number) callbacks plus PayloadBuffer reuse in both PacketHandler and RS30X looks consistent:

  • RX path computes payloadLength = this.#idx - 8 and copies from rxBuf offset 7, which correctly skips header/metadata and excludes the checksum.
  • PacketHandler uses a single PayloadBuffer(32) and reuses it across packets; RS30X then copies into its own #responseBuffer, so callers of RS30X see a buffer that’s independent of the RX buffer but still reused per instance.
  • The promise queue + timeout handling in #sendCommand remains ordered and matches the new callback signature.

Given that responses now come from a reusable buffer (#responseBuffer), any external code that stores the returned Uint8Array beyond the lifetime of a single command will see its contents change on subsequent commands. Within this file, readStatus() consumes the response synchronously and doesn’t leak it, so it’s safe. Please double-check other call sites to ensure they don’t assume responses are immutable per call.

Also applies to: 126-138, 156-159, 165-166, 177-195, 223-251, 308-317

firmware/stackchan/drivers/dynamixel.ts (2)

5-6: PayloadBuffer wiring and promise/timeout flow look solid

  • PacketHandler’s switch to (buffer: Uint8Array, length: number) callbacks with an internal #payloadBuffer and Dynamixel’s own #responseBuffer keeps allocations down while avoiding reuse of the shared RX buffer.
  • The #promises queue, Timer.set/Timer.clear, and #onCommandRead resolution logic correctly handle timeout vs. response and avoid double-resolving or leaking entries.

No issues from a correctness or reliability perspective; the refactor is a net improvement.

Also applies to: 90-182, 210-253, 261-315


467-481: Read-side guards and error handling are consistent and reasonable

  • readFirmwareVersion, readPresentCurrent, readPresentVelocity, and readPresentPosition all now:
    • Guard against undefined responses from #sendCommand.
    • Enforce a minimum payload length before indexing.
    • Use values[1] as the status/error byte and return structured Maybe results.

This matches the established convention for Dynamixel status packets and makes the read APIs more robust against truncated or missing responses.

Also applies to: 504-524, 531-549, 555-573

firmware/stackchan/drivers/scservo.ts (1)

351-364: readStatus guard + payload usage look good

  • The new values == null || values.length < 15 guard correctly protects against timeouts and truncated responses from #sendCommand.
  • With PacketHandler slicing payload from index 5, treating values[0]/[1] as the low/high bytes of the position word is consistent with the encoding used elsewhere in this driver.

No additional issues here.

Comment on lines 455 to 461
async readModelNumber(): Promise<number> {
const values = await this.#sendCommand(INSTRUCTION.READ, ADDRESS.MODEL_NUMBER, 2)
if (values == null || values.length < 2) {
throw new Error('failed to read model number')
}
return el(values[0], values[1])
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

readModelNumber is likely reading the wrong bytes

Given readFirmwareVersion and the present-value readers treat values[1] as the status code and values[2+] as actual data, using values[0]/values[1] as the model-number bytes will include the instruction and status instead of the payload. This will produce an incorrect model number whenever the call succeeds.

Consider tightening the length check and decoding from the data bytes after the status, e.g.:

   async readModelNumber(): Promise<number> {
     const values = await this.#sendCommand(INSTRUCTION.READ, ADDRESS.MODEL_NUMBER, 2)
-    if (values == null || values.length < 2) {
-      throw new Error('failed to read model number')
-    }
-    return el(values[0], values[1])
+    if (values == null || values.length < 4) {
+      throw new Error('failed to read model number')
+    }
+    if (values[1] !== 0) {
+      throw new Error(`servo returned error code: ${values[1]}`)
+    }
+    const low = values[2]
+    const high = values[3]
+    return (high << 8) | low
   }

This matches the “values[1] is status, payload starts at index 2” convention used elsewhere.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async readModelNumber(): Promise<number> {
const values = await this.#sendCommand(INSTRUCTION.READ, ADDRESS.MODEL_NUMBER, 2)
if (values == null || values.length < 2) {
throw new Error('failed to read model number')
}
return el(values[0], values[1])
}
async readModelNumber(): Promise<number> {
const values = await this.#sendCommand(INSTRUCTION.READ, ADDRESS.MODEL_NUMBER, 2)
if (values == null || values.length < 4) {
throw new Error('failed to read model number')
}
if (values[1] !== 0) {
throw new Error(`servo returned error code: ${values[1]}`)
}
const low = values[2]
const high = values[3]
return (high << 8) | low
}
🤖 Prompt for AI Agents
In firmware/stackchan/drivers/dynamixel.ts around lines 455 to 461,
readModelNumber currently decodes bytes at values[0]/values[1] which include the
instruction/status bytes; tighten the length check to require at least 4 bytes
(instruction + status + 2 payload bytes) and decode the model number from the
payload at values[2] and values[3] (e.g. use el(values[2], values[3])); keep the
existing error throw when the response is too short.

@meganetaaan
Copy link
Copy Markdown
Collaborator Author

#352 で管理します

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant