Skip to content

fix!: write settings at byte-direct addresses, not word indices - #33

Merged
basmeerman merged 3 commits into
mainfrom
fix/32-byte-direct-write-addressing
Sep 19, 2026
Merged

basmeerman merged 3 commits into
mainfrom
fix/32-byte-direct-write-addressing

Conversation

@basmeerman

Copy link
Copy Markdown
Owner

Closes #32.

The bug

RegisterDef.address carries two meanings. For reads it is a word index into the settings block (base + spec_byte/2) — exactly what decode_register_value needs to slice a bulk read that starts at byte 0, so every read path is correct today. For writes, write_executor passed that same value to write_registers() as a literal Modbus address.

The firmware addresses this block by byte offset, so every numeric write landed at half the intended offset — inside a different parameter:

parameter table wrote into
max_charge_current 0x1016 balance_trigger_voltage
max_discharge_current 0x101c cell_soc0_voltage
balance_starting_voltage 0x1042 discharge_overcurrent_protection_recovery_time

Only the parameter at byte 0 was unaffected, because 0 halves to itself. Several of the victims are safety thresholds, so with basic writes enabled the bridge silently rewrote protection limits.

scripts/hw_testbench.py --collision-report prints the full table with no hardware attached.

The fix

Add jk_settings.write_address(reg) returning base + spec_byte, and use it at the one wire site. Read paths are untouched — their word-index semantics are already right, and they are covered by an integration test that decodes a real capture.

This deliberately does not rewrite the table to byte offsets as the issue proposed. That meaning is load-bearing for decode_register_value, bms_runner, both dump scripts and four test modules; changing it would touch every read consumer to fix one line.

Second fix: _safe_write_register now falls back from FC06 to a one-register FC16 write. PB2A16S20P firmware 15.41 rejects FC06 against the packed-bit register 0x1114 with illegal-data-address, although FC03 reads that register and FC16 writes it. Packed-bit writes were never reaching the BMS.

Evidence

Three independent sources agreed on the rule before any hardware was touched:

  • BMS_1_sweep.txt — a read requested at 0x1478 returns the bytes at offset 0x78 of the 0x1400 window, so start address = base + byte offset.
  • jean-luc1203/jkbms-rs485-addon flows.json, node function 4 — FC06 to 0x1504 for RCVTime, whose spec byte offset in the 0x1400 block is 0x104.
  • docs/FIELD_MATRIX.md — phinix writes 0x1070/0x1074/0x1078 for the switches at spec bytes 0x70/0x74/0x78.

Then on real hardware (BMS 1, JK_PB2A16S20P fw 15.41, pack disconnected):

  • 35/35 parameters — 32 numerics + 3 packed bits — written, read back, checked against the whole 120-word window for side effects, and restored.
  • The shipped write_executor path driven end to end on the same pack: numeric at wire 0x1020, packed bit 0x3200 → 0x3240 with siblings preserved, no /error topics, pack restored.
  • Pack verified back at baseline on a fresh connection after every run.

Raw evidence in scripts/captures/hw_evidence/.

Two firmware quirks found, neither an addressing fault

  • FC06 unsupported. All 30 successful writes used FC16; all 3 FC06 writes failed. Handled by the fallback above.
  • Ceilings. max_discharge_current (200.0 A) and max_balance_current (2.0 A) sat exactly at their firmware limits, so perturbing upward returned illegal-data-value. The PB2A in the model name is a 2 A balancer. The bench now retries downward.

Also in this PR

scripts/hw_testbench.py — read / perturb / verify / side-effect-check / restore bench, plus docs/HW_TESTBENCH.md. Read-only by default; writes need --write --confirm-writes; a pack-current interlock refuses to write while current flows; a snapshot is written before the first write; restore replays the captured raw words rather than re-encoding; a failed restore aborts the run.

It computes addresses independently of production on purpose, so it validates the fix rather than inheriting it. A test pins the two implementations together.

The bench never writes at the un-corrected address — that lands on an unidentified register, so the bug is demonstrated arithmetically instead.

Checks

ruff clean · mypy strict clean (13 files) · 651 tests · 100% branch coverage

🤖 Generated with Claude Code

basmeerman and others added 3 commits September 19, 2026 10:30
RegisterDef.address is a word index into the settings block (base + spec_byte/2), which is exactly what decode_register_value needs to slice a bulk read starting at byte 0. write_executor passed that value straight to write_registers as a wire address, so every numeric write landed at half the intended byte offset - inside a different parameter, frequently a safety threshold. max_charge_current wrote into balance_trigger_voltage.

Add jk_settings.write_address() (block base + spec byte offset) and use it at the single wire site. Every read path keeps using .address unchanged, because its word-index meaning is already correct there.

Also fall back from FC06 to a one-register FC16 write. PB2A16S20P firmware 15.41 rejects FC06 against the packed-bit register 0x1114 with illegal-data-address, although FC03 reads that register and FC16 writes it - so packed-bit writes never reached the BMS at all.

Verified on real hardware (BMS 1, JK_PB2A16S20P fw 15.41): all 32 numeric parameters and 3 packed bits written, read back, checked against the whole 120-word window for side effects, and restored. The shipped write_executor path was then driven end to end on the same pack. Evidence in scripts/captures/hw_evidence/, runbook in docs/HW_TESTBENCH.md.

Adds scripts/hw_testbench.py, the harness that produced that evidence, with its own address arithmetic kept deliberately independent of production so it validates rather than inherits.

Refs #32

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The evidence README described pack 1's 3.50 V cell over-voltage recovery as a standing difference from packs 2-5. It was aligned to 3.58 V at the owner's request, leaving pack 6's 360 Ah capacity as the bank's only remaining intentional difference.

The baseline JSONs deliberately keep the original as-found state - that is what makes them a valid restore source.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pack 1's cell over-voltage recovery was raised from 3.50 V to 3.58 V to match packs 2-5. Re-read the pack and stored that capture rather than editing the values in place: these files are hardware captures, so a hand-edited one would assert a reading that never happened.

Pack 1 is now byte-identical to pack 2 across the whole settings window. The original as-found capture is kept locally in the gitignored hw_snapshots/ directory as the restore source for the old value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@basmeerman
basmeerman merged commit 8bdeeb7 into main Sep 19, 2026
4 checks passed
@basmeerman
basmeerman deleted the fix/32-byte-direct-write-addressing branch September 19, 2026 08:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Settings writes use the read address base: every write lands on a different register (safety thresholds silently modified)

1 participant