# Bafang BBS01 / BBS02 / BBSHD mid-drive motor device spec
# Copyright 2026 Pigs Can Fly Labs LLC
# SPDX-License-Identifier: Apache-2.0

device:
  name: "Bafang BBS02 Mid-Drive"
  manufacturer: "Suzhou Bafang Electric Motor Science-Technology"
  manufacturer_status: "active"
  protocol: "uart"
  notes: >
    36-48 V mid-drive conversion kit sharing a controller architecture and
    configuration protocol with the BBS01 and BBSHD. No radio and no official
    app: Bafang Go / BESST targets the newer CAN-bus M-series (M500/M510/M600),
    not this family. Everything that talks to a BBS02 does so over 1200-baud
    UART on the display harness -- via a USB programming cable, or via an
    aftermarket BLE bridge display (EggRider V2, app com.eggbikes.EggRider,
    whose own BLE UUIDs are undocumented).

    This is a request/response CONFIGURATION protocol, not a telemetry stream.
    Reads return parameter blocks; writes replace a whole block. Contrast the
    Tongsheng TSDZ2, which is a continuous stream at 9600 baud -- a different
    family entirely, not a variant.

    WRITE SEMANTICS: a write replaces the ENTIRE block. Always read the block
    first and keep a copy: a partial edit means writing back fields you did not
    intend to change, and the archived block is the restore path.

    CHECKSUM ASYMMETRY (most common cause of rejected writes): read responses
    sum every preceding byte; write requests sum only the SECOND code byte, the
    length byte and the data bytes -- skipping the first code byte.

    VERIFICATION: `reported` throughout -- block sizes, field order and
    encodings are corroborated across three independent implementations
    (OpenBafangTool and two bafang-python forks), but nothing here has been read
    back from a physical unit by us.

    KNOWN AMBIGUITY: sources give write length bytes 0x24 (basic) and 0x11
    (pedal) where the corresponding read responses carry 0x18 (24) and 0x0B
    (11). 0x24 and 0x11 are exactly 24 and 11 written as if decimal were hex,
    which looks like a documentation slip propagated between forks -- but it
    could be a real write-side convention. The write messages below therefore
    omit a literal length byte rather than assert a wrong one. Capture a
    vendor-tool write before filling it in.

  setup:
    required: false
    confidence: "medium"
    notes: >
      Nothing to provision. The motor has no radio, no account and no pairing step. A cable or a bridge display is connected and the protocol starts immediately.
    methods:
      - type: "wired"
        description: >
          Connect the USB programming cable (or a BLE bridge display such as the EggRider V2) to the display harness. No handshake, pairing or credential exchange precedes normal reads.
        verified: false
    factory_reset:
      applicable: false
      effect: >
        No factory reset exists. The controller holds configuration blocks that are overwritten by writing them, not cleared by a reset procedure -- which is exactly why archiving the blocks before a write is the documented restore path. Inventing a reset here would be worse than recording that there is none.
    rejoin:
      requires_factory_reset: false
      notes: >
        No network binding exists, so there is nothing to rejoin. Moving the
        vehicle between owners or workshops changes nothing about how the bus
        is reached.
    credentials:
      wifi_passphrase_protection: "not_applicable"
      notes: >
        No WiFi, no account and no passphrase: physical access to the harness
        is the only credential.

bus:
  link:
    type: "uart"
    baud: 1200
    framing: "8N1"
    logic_level: "TTL"
    verification: "reported"
    wiring:
      - signal: "controller/display serial line (shared harness)"
        connector: "main harness display connector"
        notes: "Reached in practice through the Bafang USB programming cable; a USB isolator is recommended."
        verification: "reported"
  style: "request_response"
  checksum:
    algorithm: "sum8-mod256"
    scope: >
      Read response: sum of ALL preceding bytes, mod 256. Write request: sum of
      the SECOND code byte, the length byte and all data bytes, mod 256 -- the
      first code byte is EXCLUDED. Write response: sum of code and data, mod
      256. This asymmetry is the classic reason a first write implementation is
      silently rejected.
    verification: "reported"
  messages:
    - name: "read_device_info"
      description: >
        Device info / connect. Note this read is NOT a bare opcode: it carries
        two payload bytes and a checksum, where 0x05 = (0x51 + 0x04 + 0xB0) mod
        256. Most other reads are bare -- do not generalise from them to this one.
      direction: "to_device"
      request: "11 51 04 B0 05"
      response: "51 10"
      writes: false
      verification: "reported"
      payload_offset: 2
      fields:
        - offset: 0
          length: 4
          name: "manufacturer"
          type: "string"
          encoding: "ASCII, e.g. 'HZXT'"
          verification: "reported"
        - offset: 4
          length: 4
          name: "model"
          type: "string"
          encoding: "ASCII, e.g. 'SZZ6'"
          verification: "reported"
        - offset: 8
          length: 2
          name: "hardware_version"
          type: "string"
          encoding: "ASCII"
          verification: "reported"
        - offset: 10
          length: 4
          name: "firmware_version"
          type: "string"
          encoding: "ASCII"
          verification: "reported"
        - offset: 14
          length: 1
          name: "voltage_setting"
          type: "uint8"
          encoding: "0x00=24V, 0x01=36V, 0x02=48V, 0x03=60V"
          verification: "reported"
        - offset: 15
          length: 1
          name: "max_current"
          type: "uint8"
          unit: "A"
          verification: "reported"

    - name: "read_firmware_version"
      description: "Firmware version string, e.g. 'CRX10B4812E010026.3'."
      direction: "to_device"
      request: "11 50"
      writes: false
      verification: "reported"

    - name: "read_basic_parameters"
      description: >
        Basic parameter block: 24 data bytes (27-byte frame including code,
        length and checksum).
      direction: "to_device"
      request: "11 52"
      response: "52 18"
      writes: false
      verification: "reported"
      payload_offset: 2
      fields:
        - offset: 0
          length: 1
          name: "low_battery_protect"
          type: "uint8"
          unit: "V"
          verification: "reported"
        - offset: 1
          length: 1
          name: "current_limit"
          type: "uint8"
          unit: "A"
          verification: "reported"
        - offset: 2
          length: 1
          array_len: 10
          name: "assist_current_limit"
          type: "uint8"
          unit: "%"
          encoding: "one byte per assist level 0-9, consecutive"
          notes: >
            TEN CONSECUTIVE BYTES, not interleaved with the speed limits below.
            Parsing this region as (current, speed) pairs puts every value on
            the wrong assist level and reads half of them as the wrong quantity,
            silently. Corroborated across two independent implementations.
          verification: "reported"
        - offset: 12
          length: 1
          array_len: 10
          name: "assist_speed_limit"
          type: "uint8"
          unit: "%"
          encoding: "one byte per assist level 0-9, consecutive, following the current limits"
          verification: "reported"
        - offset: 22
          length: 1
          name: "wheel_diameter"
          type: "uint8"
          encoding: "0x1F-0x3C for 16in-30in; 0x37 = 700C"
          verification: "reported"
        - offset: 23
          length: 1
          name: "speedmeter_model"
          type: "bitfield"
          encoding: "bits 1-2: 00 external, 01 internal, 10 motor phase"
          verification: "reported"

    - name: "read_pedal_assist_parameters"
      description: "Pedal assist block: 11 data bytes."
      direction: "to_device"
      request: "11 53"
      response: "53 0B"
      writes: false
      verification: "reported"
      payload_offset: 2
      fields:
        - offset: 0
          length: 1
          name: "pedal_sensor_type"
          type: "uint8"
          encoding: "0x00 none, 0x01 DH-12, 0x02 BB-32, 0x03 double-signal-24"
          verification: "reported"
        - offset: 1
          length: 1
          name: "designated_assist"
          type: "uint8"
          encoding: "0x00-0x09, or 0xFF = follow display"
          verification: "reported"
        - offset: 2
          length: 1
          name: "speed_limit"
          type: "uint8"
          unit: "km/h"
          encoding: "0x0F-0x28, or 0xFF = follow display"
          verification: "reported"
        - offset: 3
          length: 1
          name: "start_current"
          type: "uint8"
          unit: "%"
          encoding: "0x00-0x64"
          verification: "reported"
        - offset: 4
          length: 1
          name: "slow_start_mode"
          type: "uint8"
          encoding: "0x01-0x08"
          verification: "reported"
        - offset: 5
          length: 1
          name: "startup_degree"
          type: "uint8"
          encoding: "pedal signal count before assist engages"
          verification: "reported"
        - offset: 6
          length: 1
          name: "work_mode"
          type: "uint8"
          encoding: "0x0A-0x50 (angular speed x10)"
          verification: "reported"
        - offset: 7
          length: 1
          name: "time_of_stop"
          type: "uint8"
          unit: "ms"
          scale: 10
          verification: "reported"
        - offset: 8
          length: 1
          name: "current_decay"
          type: "uint8"
          encoding: "0x01-0x08"
          verification: "reported"
        - offset: 9
          length: 1
          name: "stop_decay"
          type: "uint8"
          unit: "ms"
          scale: 10
          verification: "reported"
        - offset: 10
          length: 1
          name: "keep_current"
          type: "uint8"
          unit: "%"
          verification: "reported"

    - name: "read_throttle_parameters"
      description: "Throttle block: 6 data bytes."
      direction: "to_device"
      request: "11 54"
      response: "54 06"
      writes: false
      verification: "reported"
      payload_offset: 2
      fields:
        - offset: 0
          length: 1
          name: "start_voltage"
          type: "uint8"
          unit: "mV"
          scale: 100
          verification: "reported"
        - offset: 1
          length: 1
          name: "end_voltage"
          type: "uint8"
          unit: "mV"
          scale: 100
          verification: "reported"
        - offset: 2
          length: 1
          name: "mode"
          type: "uint8"
          encoding: "0x00 speed, 0x01 current"
          verification: "reported"
        - offset: 3
          length: 1
          name: "designated_assist"
          type: "uint8"
          encoding: "0x00-0x09, or 0xFF = follow display"
          verification: "reported"
        - offset: 4
          length: 1
          name: "speed_limit"
          type: "uint8"
          unit: "km/h"
          encoding: "0x0F-0x28, or 0xFF = follow display"
          verification: "reported"
        - offset: 5
          length: 1
          name: "start_current"
          type: "uint8"
          unit: "%"
          verification: "reported"

    - name: "read_error_codes"
      description: "Active error codes; empty byte array when no faults."
      direction: "to_device"
      request: "14 15"
      writes: false
      verification: "reported"

    - name: "read_serial_number"
      description: "Serial number, ASCII."
      direction: "to_device"
      request: "14 14"
      writes: false
      verification: "reported"

    - name: "write_basic_parameters"
      description: >
        Write the 24-byte basic block. Payload layout is identical to
        read_basic_parameters. The length byte is deliberately not asserted
        here -- see the KNOWN AMBIGUITY note in device.notes.
      direction: "to_device"
      request: "16 52"
      writes: true
      advanced: true
      advanced_reason: >
        Sets the current limit and low-voltage cutoff. Current limits are
        thermal limits: above the motor's rating this cooks the nylon primary
        gear or the controller MOSFETs, and the motor will not stop you.
        Lowering the low-voltage cutoff below the pack's real cutoff drives
        cells into over-discharge. Read and archive the block first -- the write
        replaces all 24 bytes, so the saved copy is your restore path.
      verification: "reported"

    - name: "write_pedal_assist_parameters"
      description: >
        Write the 11-byte pedal assist block. Payload layout is identical to
        read_pedal_assist_parameters.
      direction: "to_device"
      request: "16 53"
      writes: true
      advanced: true
      advanced_reason: >
        Sets assist speed limiting and start behaviour. Raising the speed limit
        can move the bike out of pedelec/EAPC classification, changing what
        licence, insurance and road access apply. Read and archive the block
        first; the write replaces all 11 bytes.
      verification: "reported"

    - name: "write_throttle_parameters"
      description: >
        Write the 6-byte throttle block. Payload layout is identical to
        read_throttle_parameters.
      direction: "to_device"
      request: "16 54"
      writes: true
      advanced: true
      advanced_reason: >
        Sets throttle behaviour including throttle-from-zero and its speed
        limit, which is the setting most likely to change the bike's legal
        classification. Read and archive the block first; the write replaces all
        6 bytes.
      verification: "reported"

    - name: "write_serial_number"
      description: "Write the serial number (ASCII)."
      direction: "to_device"
      request: "17 01"
      writes: true
      advanced: true
      advanced_reason: >
        Effectively irreversible, and can break warranty and dealer-tool
        workflows. It has legitimate uses -- restoring identity after a
        controller swap is a normal repair-bench job -- but there is no undo, so
        record the existing value before changing it.
      verification: "reported"
