Skip to content

Read a JBD battery management system with a JbdBms module - #372

Open
JensOgorek wants to merge 7 commits into
mainfrom
jbd-bms
Open

JensOgorek wants to merge 7 commits into
mainfrom
jbd-bms

Conversation

@JensOgorek

@JensOgorek JensOgorek commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

Motivation

RoSys reads the BMS of a Feldfreund by writing the request bytes 0xDD 0xA5 0x03 0x00 0xFF 0xFD 0x77 to an unmuted Serial on the port expander every 5 s, every second while docking, and parsing the bytes the serial prints. Every reading is a round trip from the host, the readout broke when an unmuted serial started to split the 34-byte answer into two lines (#326), and with the port expander as a serial bus peer (#348) the answer would travel as a relayed text line. With this module the expander asks the BMS itself and can send the values in a telemetry frame.

Implementation

  • bms = JbdBms(serial) asks for the basic information (register 0x03) every interval seconds, 1 s by default and none with 0, and keeps voltage, current, residual and nominal capacity, state of charge, cycles, mean temperature, protection flags, MOSFETs and the number of cells in properties.
  • It collects an answer across steps, since the BMS may pause within one, and checks its start, length, checksum, end, status and register.
  • An answer that is missing after 500 ms or is invalid counts once in errors, and the properties keep their last values; last_message_age tells how old they are, and until the first answer it counts from the boot, like core.last_message_age.
  • bms.cell_voltages() and bms.hardware_version() print registers 0x04 and 0x05; their requests wait until no answer is awaited, so a call never cuts into the periodic request.

Progress

  • The implementation is complete.
  • Documentation has been updated (or is not necessary).
  • Tested on hardware (or is not necessary).
Bench: the module against a BMS played by a PC

One ESP32 runs bms = JbdBms(Serial(27, 26, 9600, 1)); the PC answers on the same wires through the USB serial bridge of a second ESP32 board held in reset, like a JBD BMS with frames from 0xDD to 0x77 and their checksum. The PC answers with 25.6 V, −1.23 A, 50 %, 8 cells, 42 cycles, 50 of 100 Ah and two sensors at 25 and 27 °C, and changes the values for each case, so that a value taken over shows. ESP-IDF 5.3.1, this branch 282f0b9.

The PC's answers Values after 3.5 s errors (total)
normal 25.60 V, −1.23 A, 50 %, 26.05 °C, 8 cells, 42 cycles, 50.00/100.00 Ah 0
split after 20 bytes, the rest 60 ms later, as in #326 taken over 0
3 bytes of garbage in front taken over 0
one bit of the checksum flipped kept 4
700 ms late kept 7
none kept, last_message_age 11.5 s 11
normal again, protection bit 4 set taken over, protections 16 11

The 0.05 °C come from the PC rounding the temperatures to 0.1 K, the protocol's resolution.

  • bms.cell_voltages() printed bms cell voltages: 3.301 3.299 3.305 3.300 3.298 3.302 3.301 3.300, bms.hardware_version() printed bms hardware version: JBD-SP04S034-L8S.
  • interval 1 s: 10 requests in 10 s; 0.1 s: 50 in 5 s; 0: none in 3 s.

A method call while a basic-information answer is awaited: the PC answers register 0x03 only after 200 ms, and the console calls the method right after the PC has seen a 0x03 request. The first version of this PR sent the method's request at once, so both answers failed the register check.

Calls 3f50a6d (first version) 282f0b9
bms.cell_voltages() 10×, interval 1 s 0 printed, errors +20 10 printed, errors +0
bms.cell_voltages(); bms.hardware_version() 5× 0 of 5 pairs, +15 5 of 5, +0
bms.cell_voltages() 10×, interval 0.1 s 0 printed, +17 10 printed, +0
Bench: last_message_age before the first answer, before and after 57fe134

Same bench, the PC silent at first; the module is created at the console about 8 s after the boot, and later the PC starts answering.

Reading 282f0b9 57fe134
right after creating the module core.millis 8116, age 196 ms, 0 V 8106, 8106 ms, 0 V
3 s later, no answer yet 11156, 3236 ms 11146, 11146 ms
1.5 s after the PC started answering 12696, 700 ms, 25.6 V 12696, 700 ms, 25.6 V

core.millis minus the age dates the last answer: before, that was the module's creation (7920 ms) until the first answer; now it is 0.

F30: the BMS of a Feldfreund through its port expander

P0 of F30 ran bms = JbdBms(Serial(26, 27, 9600, 2)) and core.telemetry(bms.voltage, bms.current, bms.percentage, bms.temperature, bms.protections, bms.errors, bms.last_message_age, 1000), and the core mirrored the frame through an expander bus (#371), on a test build with both branches, first with 3f50a6d, then with 282f0b9. RoSys was stopped.

  • On P0 and in the core's mirrors: 29.24 V, 0.00 A, 100 %, 24.85 °C, protections 0, errors 0, 60 ms old; on P0 also 8 cells, 22 cycles, 29.99 of 30.00 Ah, MOSFETs 3.
  • bms.cell_voltages(): 3.657 3.656 3.657 3.657 3.656 3.654 3.652 3.650, which add up to the 29.24 V; bms.hardware_version(): OH17SA01-L17S-100A.
  • errors was 0 at every reading during the test of a few minutes.
  • With 282f0b9 at interval 0.1 s, 10 calls of bms.cell_voltages() 0.53 s apart and 5 of both methods 0.77 s apart, and at 1 s, 10 calls 1.37 s apart: all 25 cell-voltage and 5 hardware-version lines arrived, errors stayed 0, and the core's mirrors matched P0.

※

`bms = JbdBms(serial)` asks a BMS with the JBD protocol for its basic information
every `interval` seconds and keeps voltage, current, capacities, state of charge,
cycles, mean temperature, protection flags, MOSFETs and cell count in properties,
so a port expander can send them in a telemetry frame instead of the host asking
through an unmuted serial and parsing the reply. Answers are collected across
steps, since the BMS may pause within one; missing, late and invalid answers count
in `errors`. `cell_voltages()` and `hardware_version()` print the other two
registers.
@JensOgorek JensOgorek added this to the 0.16.0 milestone Nov 2, 2026
@JensOgorek JensOgorek added the enhancement New feature or request label Nov 2, 2026
@JensOgorek JensOgorek self-assigned this Nov 2, 2026
cell_voltages() and hardware_version() sent their request right away, so a
call during an awaited basic-info answer lost both answers and counted two
errors; they now wait until no answer is awaited. Bytes that arrive while
nothing is awaited are dropped, so a late answer counts only at its timeout.
last_message_age now tells the age of the values and restarts only with the
basic information.

The properties are read with properties.at() like in the other modules
instead of through 13 cached pointers, a word() helper decodes the big-endian
numbers, the header loses its class comment and gets the usual _ptr alias,
and reply_millis becomes last_message_millis. The docs list the protection
bits, drop the internals and show the telemetry example on a serial bus peer,
where it works on main.
…swer

The constructor started last_message_age at the creation of the module, so before
the BMS answered, a reader saw 0 V with a small age (found on F30 behind an
ExpanderBus). Like core.last_message_age, it now starts at 0, so until the first
valid basic information it counts from the boot, and millis minus
last_message_age is about 0, which tells a reader that no answer has arrived yet.

On the bench with the PC as a silent BMS and the module created 8 s after the boot,
the age right after creating the module was 196 ms and is now 8106 ms, i.e.
core.millis; once the BMS answers, both builds show the age of the last answer.
@JensOgorek

Copy link
Copy Markdown
Collaborator Author

last_message_age of JbdBms now counts from the boot (57fe134)

Like core.last_message_age, it starts at 0 instead of at the module's creation, so a reader no longer sees 0 V with a small age before the first answer (found on F30), and millis minus the age is about 0 until then. Bench before/after is in the PR body.

※

JensOgorek added a commit to zauberzeug/rosys that referenced this pull request Nov 3, 2026
Before the BMS answers, JbdBms reports 0 V, and with zauberzeug/lizard#372 its
last_message_age counts from the boot, so the expander's millis minus the age is
about 0 until the first answer. BmsHardware now drops a reading with that date
instead of one with 0 V, which also holds after the expander restarted. The dates
are compared across the wrap of the expander's millis, which as an int field of
its frame turn negative after 24.9 days (zauberzeug/lizard#373); before, the same
answer would have counted as a new one at that point.
falkoschindler and others added 4 commits November 6, 2026 18:39
…the awaited one

handle_frame() treated a frame for another register like a corrupt one: it
counted an error and gave up the awaited answer, so the answer that then came
for the real request was dropped unseen. That happened when the BMS answered
later than the 500 ms timeout while a method request was queued: the timeout
sent the method's request in the same step, before the late basic-info answer
had arrived, so that answer counted a second time and the method printed
nothing; the mirror image with a late method answer and the next poll alike.

A frame with a valid end and checksum for another register is now skipped
without an error, since its timeout counted it already, and the module keeps
waiting for the awaited answer; only its status and length count as errors.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WgKVNYCKyDiam7hZ3ip9v3
The module table of AGENTS.md did not know the new module, nor ImuBno085.
MksServoMotor is still missing, since adding it to the longest row would make
mdformat re-pad the whole table.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WgKVNYCKyDiam7hZ3ip9v3
The poll measured its interval from request_millis, which every request sets,
also those of cell_voltages() and hardware_version(). A method call thus pushed
the next poll out by a full interval, and calls more frequent than the interval
starved it: with 10 calls 0.53 s apart at an interval of 1 s, no basic
information arrived for about 5 s.

request_millis now serves only the timeout of the awaited answer, and a new
poll_millis, set with each request for the basic information, serves the
interval.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WgKVNYCKyDiam7hZ3ip9v3
… of 273.15

The JBD protocol gives a temperature in 0.1 K with 2731 for 0 °C, so the
BMS and its manufacturer's app use 273.1 K, and the module, which subtracted
273.15, showed every temperature 0.05 K too low: on F30 24.85 °C for the raw
2980, which is 24.9 °C by the protocol.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WgKVNYCKyDiam7hZ3ip9v3

@falkoschindler falkoschindler left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@JensOgorek The module reads well: protocol, checksum, the field layout of the basic information and the buffer bounds all check out, and the bench plus F30 cover the normal cases. I pushed four small commits to the branch (details below); two things remain for you.

A failed method call stays silent. When the answer to cell_voltages() or hardware_version() is missing or comes with an error status (0x80, which older JBD firmwares return for register 0x05), count_error() only increments errors. Whoever types the method at the console sees no reaction and has to look at bms.errors. For the poll the counter is the right form, but for a method whose only purpose is printing, silence is unusual in Lizard. Would you echo a short warning for the two method registers, e.g. warning: bms got no cell voltages?

The 0.05 °C in the PR description. The sentence that attributes the 0.05 °C to the PC's rounding is not quite right: the module subtracted 273.15 where the protocol defines (raw - 2731) / 10, and the bench PC encodes with 273.15 and rounds up as well (25 °C as 2982, otherwise the mean would not come out as 26.05). With the formula fixed on the branch (c0dcf6f), the bench now reads 25.1 and 27.1 °C from those frames. Could you drop the sentence and, if you rerun the bench, let the PC encode with 2731 so the temperatures come out exact?

Fixed on the branch
  • A valid answer for another register gave up the awaited one (c09a2b0). handle_frame() treated a frame for another register like a corrupt one: it counted an error and cleared requested_register, so the answer that then came for the real request was dropped unseen. That happened when the BMS answered later than the 500 ms timeout while a method request was queued: the timeout sent the method's request in the same step, before the late basic-info answer had arrived, so that answer counted a second time and the method printed nothing; the mirror image with a late method answer and the next poll alike. A frame with a valid end and checksum for another register is now skipped without an error, since its timeout counted it already, and only the status and length of the matching answer count as errors.
  • Method calls pushed the next poll out (8d6305f). The poll measured its interval from request_millis, which every request sets, also those of the methods, so a call delayed the next basic-info poll by a full interval, and calls more frequent than the interval starved it: with your bench cadence of 10 calls 0.53 s apart at an interval of 1 s, no basic information arrived for about 5 s. A new poll_millis, set with each basic-info request, now drives the interval; request_millis serves only the timeout.
  • Temperature 0.05 K too low (c0dcf6f). The protocol gives the temperature in 0.1 K with 2731 for 0 °C, so the manufacturer's app uses 273.1 K; the module subtracted 273.15 and showed e.g. 24.85 °C on F30 for the raw 2980, which is 24.9 °C by the protocol. Now (raw - 2731) / 10.0.
  • JbdBms in the module table of AGENTS.md (843b477), together with ImuBno085, which was missing too.

※

This branch has not been deployed

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

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants