# Nanjing Fardriver ND-series motor controller device spec
# Copyright 2026 Pigs Can Fly Labs LLC
# SPDX-License-Identifier: Apache-2.0

device:
  name: "Fardriver ND-series Motor Controller"
  manufacturer: "Nanjing Fardriver Controller Co."
  manufacturer_status: "active"
  protocol: "ble"
  notes: >
    Sine-wave (FOC) brushless motor controller used across the Chinese
    light-EV supply chain: QS Motor hub-motor kits, e-motorcycle builds, and
    classic-scooter EV conversions. Reached over BLE by the vendor "FarDriver"
    Android app, which shows live speed / pack voltage / current / RPM /
    temperatures / SOC and writes tunable parameters (gear-mode curves, speed
    limit, regen, throttle response). This is the "connected by app" layer on
    app-tethered scooter conversions, so it is the local-first target: a rider
    dashboard and a mode switch that do not depend on a vendor app.

    TELEMETRY FRAMING (derived from community RE; verification `reported`):
    the controller free-runs 16-byte status frames as BLE notifications.
    Layout: byte 0 = 0xAA magic; byte 1 = 6-bit block ID + 2-bit flags;
    bytes 2..13 = 12 payload bytes; bytes 14..15 = CRC16. When the ID field is
    < 0x37 it indexes a 55-entry table of flash addresses spanning 0x00..0xFA;
    the payload is the mirror of that region. Reassembling frames yields a
    512-byte (0x200) device-memory image from which the fields below are
    decoded by struct offset -- these are offsets into that reassembled image,
    NOT offsets into a single frame. Frames rotate through nearly all
    addresses, with hot blocks repeated every 3-4 frames.
    CRC16: poly 0x8005, init 0x7F3C, refin true, refout true, xorout false
    (implemented vendor-side as a dual hi/lo lookup table seeded a=0x3C,
    b=0x7F). Writes use a shorter variable-length form (typically 8 bytes)
    with flags=1: magic, length, address pair, payload, CRC.

    FLAG (per-unit, must be confirmed on YOUR controller): the BLE service and
    characteristic UUIDs are NOT constant across units. Fardriver controllers
    ship with different bolt-on BLE bridge modules, so the transport UUIDs vary
    by module even when the framing above is identical. The UUIDs recorded in
    this spec are the commonly observed HM-10-style pair and are a STARTING
    POINT, not a guarantee -- confirm with an nRF Connect scan before trusting
    them. VERIFICATION: framing/CRC and field offsets `reported` (documented by
    community work, not reproduced by us); UUIDs `hypothesis` (per-unit, seen in
    one community implementation). Nothing here is `confirmed` -- no capture has
    been taken from a physical unit.

    WRITE PATH (documented, flagged advanced): parameter writes use a shorter
    variable-length frame with flags=1 -- magic 0xAA, computed length, address
    pair, payload, CRC16 (same CRC as above, computed over the frame). System
    commands write 0x88 XX to address 0xA0 (self-balance, data gather, reset).
    Every write command in this spec carries `advanced: true`: they change
    speed limiting, regen and current limits on a road-going vehicle. That is a
    signpost, not a gate -- consumers should keep them available behind a
    deliberate action and show the reason at that moment, not hide them.
    Writes are excluded from autodetection -- discovery is scan-and-read only.
    The frame SHAPE is `reported` (derived from community RE, not reproduced
    here); exact per-parameter payload encodings and CRC byte order are
    `hypothesis` and must be confirmed against an HCI capture of the vendor app
    on your own unit, on a stand, with the wheel off the ground.

    VEHICLE CONTEXT: documented because it is the app layer behind
    app-connected classic-scooter EV conversion kits (e.g. Retrospective
    Classic "Project:E" Vespa/Lambretta conversions, 72 V / 4 kW hub motor,
    keyless ignition, "connected by app"). Those vendors publish no app name
    and no controller brand, so the specific pairing is INFERRED from the
    kit's published bill of materials (sine-wave controller, QS-style 4 kW hub
    motor, app-adjustable speed restriction/derestriction) matching the
    Fardriver ND-series feature set. The vendor attribution is therefore
    `hypothesis`. Confirm per-scooter with a BLE scan -- see the device doc page
    for the confirmation procedure and for the Votol EM-series alternative.
  # NO automatic identification or discovery block, deliberately.
  #
  # The only advertised signal we have is service 0xFFE0, which is the generic
  # HM-10/HM-19 BLE-UART service, not a Fardriver signature. It is already
  # claimed as the sole identification signal by motool-slacker.yaml, and
  # SP107E LED controllers advertise it too, so a registry matcher keying on
  # it would classify one advertisement as several different devices -- and
  # could then associate Fardriver telemetry, or an advanced write, with
  # entirely unrelated hardware. This spec also rates its own UUIDs LOW
  # confidence (they vary per BLE bridge module), which makes the signal
  # doubly unsafe to match on.
  #
  # Restore automatic identification only once a discriminating signal exists:
  # a unique local-name prefix, manufacturer-specific advertisement data, or a
  # read-only framing probe (subscribe and confirm 16-byte 0xAA status frames).
  # Until then this device is documented for manual selection only.

  setup:
    required: false
    confidence: "medium"
    notes: >
      Nothing to provision. HM-10-style BLE bridges use Just Works or no
      pairing at all, so a client connects and subscribes with no account,
      no key exchange and no bonding step. Confidence is medium rather than
      high because this has not been confirmed on a physical unit -- and the
      bridge module varies per controller, so a unit with a different module
      could behave differently.
    methods:
      - type: "ble_direct"
        description: >
          Connect to the controller's BLE bridge and enable notifications on
          the telemetry characteristic. Telemetry begins immediately; no
          handshake or keep-alive is required.
        verified: false
    factory_reset:
      applicable: false
      effect: >
        No BLE-side factory reset exists. The controller has a system command
        that includes a reset operation, but that resets controller
        parameters, not any pairing or provisioning state -- and it is an
        advanced, unverified write, not a recovery procedure. Nothing about
        the connection needs clearing because nothing is stored.
    rejoin:
      requires_factory_reset: false
      notes: >
        No network binding and no bonding, so a new phone simply connects.
        Nothing to migrate when the owner or the handset changes.
    credentials:
      wifi_passphrase_protection: "not_applicable"
      notes: >
        No WiFi and no account. BLE range is the access control, which is to
        say there is effectively none -- a consideration for a device whose
        spec carries advanced writes.

services:
  - uuid: "0000ffe0-0000-1000-8000-00805f9b34fb"
    name: "Fardriver BLE Bridge Service"
    characteristics:
      - uuid: "0000ffec-0000-1000-8000-00805f9b34fb"
        name: "Telemetry Notify"
        properties: ["notify"]
      - uuid: "0000ffe1-0000-1000-8000-00805f9b34fb"
        name: "Serial Data"
        properties: ["read", "write", "write_without_response", "notify"]
        notes: >
          Write frames: [0xAA, length, addr_hi, addr_lo, payload..., crc_lo, crc_hi]
          with flags=1 in the length/ID nibble; typically 8 bytes total. CRC16 is
          poly 0x8005, init 0x7F3C, refin/refout true, computed over the frame.
          `{length}` and `{crc}` are DERIVED by the consumer from the rest of the
          frame, not supplied by the caller; they are declared as parameters only
          so a generic serializer knows their width. `{data}` is the caller's
          little-endian register payload.
          CRC byte order is unverified -- confirm against a capture before use.
          All commands here are advanced: they retune a road-going vehicle.
        commands:
          write_parameter:
            description: >
              Generic parameter write to a controller register. `address` is the
              16-bit register address (see the config register table in the device
              doc); `data` is the little-endian value for that register.
            verification: "hypothesis"
            advanced: true
            advanced_reason: >
              Writes a controller register directly. Depending on the address this
              changes current limits, regen strength or speed limiting on a
              road-going vehicle; wrong values can overheat the motor or
              controller, and lifting a speed limit can change what licence and
              insurance apply. Read and note the register's current value first --
              that is your restore path. Validate on a stand with the wheel off
              the ground.
            template: [0xAA, "{length}", "{addr_hi}", "{addr_lo}", "{data}", "{crc}"]
            parameters:
              length:
                type: "uint8"
                min: 4
                max: 32
              addr_hi:
                type: "uint8"
                min: 0
                max: 255
              addr_lo:
                type: "uint8"
                min: 0
                max: 255
              data:
                type: "bytes"
                min: 1
                max: 26
              crc:
                type: "bytes"
                min: 2
                max: 2
          system_command:
            description: >
              System command: writes 0x88 followed by a subcommand byte to address
              0xA0. Known subcommands cover self-balance/calibration, data gather
              and controller reset; the exact subcommand byte values must be
              captured from the vendor app before use.
            verification: "hypothesis"
            advanced: true
            advanced_reason: >
              Triggers a controller-level operation such as calibration or reset.
              A mistimed self-balance or reset can leave the controller unable to
              drive the motor until it is reconfigured, so archive the parameter
              registers you care about before sending one. Vehicle stationary,
              on a stand -- never while moving.
            template: [0xAA, "{length}", 0x00, 0xA0, 0x88, "{subcommand}", "{crc}"]
            parameters:
              length:
                type: "uint8"
                min: 4
                max: 16
              subcommand:
                type: "uint8"
                min: 0
                max: 255
              crc:
                type: "bytes"
                min: 2
                max: 2

entities:
  - platform: "sensor"
    name: "Pack Voltage"
    device_class: "voltage"
    unit: "V"
    state_characteristic: "0000ffec-0000-1000-8000-00805f9b34fb"
  - platform: "sensor"
    name: "Line Current"
    device_class: "current"
    unit: "A"
    state_characteristic: "0000ffec-0000-1000-8000-00805f9b34fb"
  - platform: "sensor"
    name: "Battery State of Charge"
    device_class: "battery"
    unit: "%"
    state_characteristic: "0000ffec-0000-1000-8000-00805f9b34fb"
  - platform: "sensor"
    name: "Motor RPM"
    unit: "rpm"
    state_characteristic: "0000ffec-0000-1000-8000-00805f9b34fb"
  - platform: "sensor"
    name: "Vehicle Speed"
    device_class: "speed"
    unit: "km/h"
    state_characteristic: "0000ffec-0000-1000-8000-00805f9b34fb"
  - platform: "sensor"
    name: "Controller Temperature"
    device_class: "temperature"
    unit: "C"
    state_characteristic: "0000ffec-0000-1000-8000-00805f9b34fb"
  - platform: "sensor"
    name: "Motor Temperature"
    device_class: "temperature"
    unit: "C"
    state_characteristic: "0000ffec-0000-1000-8000-00805f9b34fb"
  - platform: "sensor"
    name: "Throttle Depth"
    unit: "%"
    state_characteristic: "0000ffec-0000-1000-8000-00805f9b34fb"
  - platform: "sensor"
    name: "Ride Mode"
    state_characteristic: "0000ffec-0000-1000-8000-00805f9b34fb"
