# CoolLEDX / CoolLED1248 BLE LED sign device spec
# Copyright 2026 Pigs Can Fly Labs LLC
# SPDX-License-Identifier: Apache-2.0

device:
  name: "CoolLEDX LED Sign"
  manufacturer: "Juntong Technology"
  manufacturer_status: "unsupported"
  protocol: "ble"
  notes: >
    The most common unbranded BLE LED sign platform: car rear-window, bike, backpack,
    badge and banner signs sold with no brand name, only a screenshot of the
    CoolLED1248 app (com.jtkj.led1248, ~350k installs).

    GENERATIONS: at least seven hardware generations share the "CoolLED*" advertising
    name — CoolLED (fixed 48x12), CoolLEDA (fixed 32x16), CoolLEDX (variable, 96x16
    typical), CoolLEDS, CoolLEDM, CoolLEDU, CoolLEDUD/iLedBike, CoolLEDMX, CoolLEDUX.
    Two protocol families: "basic" (CoolLEDX and earlier — mapped below) and "advanced"
    (CoolLEDM and later — same 0x01/0x03 framing and 0x02 escaping, but nearly all
    command values differ and the text/image payload encodings are opaque). A client
    MUST branch on the advertised name.

    FRAMING (basic protocol): 0x01 | escape(uint16_be payload_length || payload) | 0x03.
    Because 0x01 and 0x03 are frame delimiters, any byte below 0x04 anywhere inside the
    escaped region is replaced by a 0x02 prefix plus (byte + 4):
      0x00 -> 02 04, 0x01 -> 02 05, 0x02 -> 02 06, 0x03 -> 02 07.
    Escape 0x02 first when transforming a buffer or the newly-inserted escape prefixes
    get double-escaped. Note the schema's `framing.length_prefix` flag below records the
    length prefix; the escaping layer has no schema representation and is described here.

    GEOMETRY IS ADVERTISED, NOT CONFIGURED: the BLE advertisement's manufacturer data
    carries [0-5] MAC, [6] height, [7-8] width (uint16 BE), [9] color mode
    (0=mono, 1=7-color, 2=full RGB), [10] firmware version. Manufacturer data shorter
    than 11 bytes means the device is not a usable CoolLED*. Clients should render
    content to the advertised geometry rather than asking the user for a panel size.

    ERROR CODES (notify): 0x00 success, 0x01 transmission failed, 0x02 device
    abnormality, 0x03 data error, 0x04 data length error, 0x05 data ID error,
    0x06 data checksum error.

    CONFIDENCE: service/characteristic UUIDs, advertisement layout, frame structure,
    escaping rules and the CoolLEDX command bytes for music/text/image/animation/mode/
    speed/brightness/switch are HIGH (independent community driver, built from BLE HCI
    snoops). Command bytes marked "unconfirmed" below (0x0A transfer, 0x11 show icon,
    0x12 power down, 0x13 button on, 0x15 mirror) are LOW — they come from secondary
    references and have not been observed on the wire. 0x0C invert, 0x0D clear, 0x1F
    query and 0x23 initialize were observed on a CoolLEDM, not a CoolLEDX.
    The text/image/animation payload bodies are client-rendered bitmaps and are NOT
    specified here; see the coolledx-driver render module.
    Source: github.com/UpDryTwist/coolledx-driver (original RE credited to CrimsonClyde).

  identification:
    # EXACT, not a "CoolLED" prefix. A prefix match would also admit CoolLEDM /
    # CoolLEDU / CoolLEDMX / CoolLEDUX, which speak the advanced protocol: the
    # commands in this spec would be silently wrong on them. The unmapped
    # generations are listed under `variants` for documentation only and are
    # deliberately NOT discoverable until their command tables are recovered.
    local_name_prefix: "CoolLEDX"
    service_uuids:
      - "0000fff0-0000-1000-8000-00805f9b34fb"

  discovery:
    methods:
      - type: "ble_scan"
        ble:
          local_name:
            match: "exact"
            value: "CoolLEDX"
          service_uuids:
            - "0000fff0-0000-1000-8000-00805f9b34fb"
    identity:
      stable_keys: ["address"]
      display: "local_name"
    static_ip_required: false

  setup:
    required: false
    confidence: "medium"
    notes: >
      No provisioning. These signs are sold unbranded and identified only by the
      CoolLED* advertising name; a client needs the generation, not any
      credential, so identification does all the work setup would elsewhere.
    methods:
      - type: "ble_direct"
        verified: false
        description: >
          No provisioning step: the device advertises as soon as it is powered
          on and accepts a connection from any central. There is no account, no
          network credential exchange and no pairing PIN, so a replacement
          client only has to scan, connect and write.
        ble:
          advertised_name: "CoolLEDX"
          pairing_required: false
        steps:
          - action: "Power the device and scan for its advertisement."
            actor: "client"
            request:
              protocol: "ble_gatt"
          - action: "Connect and run the documented initialization sequence, if the spec defines one."
            actor: "client"
    factory_reset:
      confidence: "low"
      effect: >
        No credential state to clear. Power-cycling drops the current
        connection, which is the actual remedy for the common failure mode:
        the device already being connected to another central.
      procedures:
        - name: "Power cycle"
          steps:
            - action: "Remove power (or battery) for a few seconds and reapply."
              actor: "user"
              expect: "Device advertises again and accepts a new connection."
    rejoin:
      in_place_supported: true
      requires_factory_reset: false
      notes: >
        Nothing binds this device to a network or an owner, so switching
        controllers is just connecting from the new one. If the old client is
        an Android or iOS phone, remove the device from the OS Bluetooth list
        as well — a cached bond can keep the phone reconnecting automatically
        and holding the single available link.
    credentials:
      wifi_passphrase_protection: "not_applicable"
      stored_on_device: []
      issued_to_client: []
      notes: >
        Anything within radio range can connect and issue commands. Physical
        proximity is the only access control.

  variants:
    # Only CoolLEDX is discoverable (see `discovery` above). The rest are
    # documented so a consumer can recognise what it has found and refuse to
    # drive it, not so it can be auto-matched into this spec.
    - model: "CoolLEDX"
      identification:
        local_name_prefix: "CoolLEDX"
      notes: "Variable geometry, basic protocol. Fully mapped — the only variant this spec drives."
    - model: "CoolLED"
      identification:
        local_name_prefix: "CoolLED"
      notes: >
        Original, fixed 48x12, monochrome. Nominally basic protocol, but no
        implementation has been verified against one. Not discoverable.
    - model: "CoolLEDA"
      identification:
        local_name_prefix: "CoolLEDA"
      notes: >
        Fixed 32x16. Nominally basic protocol, unverified against hardware.
        Not discoverable.
    - model: "CoolLEDS"
      identification:
        local_name_prefix: "CoolLEDS"
      notes: "Basic-protocol family per upstream, unverified. Not discoverable."
    - model: "CoolLEDM"
      identification:
        local_name_prefix: "CoolLEDM"
      notes: >
        ADVANCED protocol. Command table NOT mapped — the basic-protocol commands
        in this spec must NOT be sent to it. Not discoverable.
    - model: "CoolLEDU"
      identification:
        local_name_prefix: "CoolLEDU"
      notes: >
        ADVANCED protocol, not mapped. CoolLEDUD / iLedBike is the bike variant.
        Not discoverable.
    - model: "CoolLEDMX"
      identification:
        local_name_prefix: "CoolLEDMX"
      notes: "ADVANCED protocol, not mapped. Believed to be the commercial signs. Not discoverable."
    - model: "CoolLEDUX"
      identification:
        local_name_prefix: "CoolLEDUX"
      notes: "Extends the advanced protocol, not mapped. Not discoverable."

services:
  - uuid: "0000fff0-0000-1000-8000-00805f9b34fb"
    name: "CoolLEDX Control Service"
    characteristics:
      - uuid: "0000fff1-0000-1000-8000-00805f9b34fb"
        name: "Control"
        properties: ["write", "notify"]
        framing:
          length_prefix: true
        notes: >
          Every command is wrapped as 0x01 | escape(uint16_be length || payload) | 0x03.
          The `value`/`template` byte sequences below are the UNWRAPPED payloads: apply
          the length prefix, escaping and delimiters before writing. Responses arrive as
          notifications on this same characteristic.
        commands:
          set_music_bars:
            description: >
              CoolLEDX: music visualizer, 8 bar heights followed by 8 bar colors
              (16 payload bytes after the command byte). CoolLEDM sends 8 bytes total.
            template: [0x01, "{bars}"]
            parameters:
              bars:
                type: "bytes"
          set_text:
            description: "Set text. Payload is a client-rendered text bitmap sized to the advertised geometry."
            template: [0x02, "{bitmap}"]
            parameters:
              bitmap:
                type: "bytes"
          set_image:
            description: "Set a still image. Payload is a client-rendered bitmap sized to the advertised geometry."
            template: [0x03, "{bitmap}"]
            parameters:
              bitmap:
                type: "bytes"
          set_animation:
            description: "Set a multi-frame animation. Payload is client-rendered frames."
            template: [0x04, "{frames}"]
            parameters:
              frames:
                type: "bytes"
          set_icon:
            description: "Set an icon payload."
            template: [0x05, "{icon}"]
            parameters:
              icon:
                type: "bytes"
          set_mode:
            description: "Set display mode."
            template: [0x06, "{mode}"]
            parameters:
              mode:
                type: "uint8"
                min: 0
                max: 255
          set_speed:
            description: "Set animation speed, 0x00-0xFF."
            template: [0x07, "{speed}"]
            parameters:
              speed:
                type: "uint8"
                min: 0
                max: 255
          set_brightness:
            description: "Set brightness, 0x00-0xFF."
            template: [0x08, "{brightness}"]
            parameters:
              brightness:
                type: "uint8"
                min: 0
                max: 255
          power_on:
            description: "Switch the sign on (command 0x09)."
            value: [0x09, 0x01]
          power_off:
            description: "Switch the sign off (command 0x09)."
            value: [0x09, 0x00]
          invert_display:
            description: "Invert the display. Confirmed on CoolLEDM; unverified on CoolLEDX."
            template: [0x0C, "{inverted}"]
            parameters:
              inverted:
                type: "uint8"
                min: 0
                max: 1
          query_status:
            description: >
              Query (0x1F). A CoolLEDM replied 01 ff 00 01 00. Purpose not fully
              established; useful as a liveness/handshake probe.
            value: [0x1F]
          initialize:
            description: >
              Initialize with a battery-level byte (0x23). Confirmed on CoolLEDM.
            template: [0x23, "{battery_level}"]
            parameters:
              battery_level:
                type: "uint8"
                min: 0
                max: 255
          power_down:
            description: "UNCONFIRMED (secondary source): power down (0x12)."
            value: [0x12]
          show_icon:
            description: "UNCONFIRMED (secondary source): show icon (0x11)."
            value: [0x11]
          mirror_display:
            description: "UNCONFIRMED (secondary source): mirror or similar (0x15)."
            value: [0x15]

entities:
  - platform: "light"
    name: "LED Sign"
    features: ["brightness"]
    state_characteristic: "0000fff1-0000-1000-8000-00805f9b34fb"
    commands:
      turn_on: "power_on"
      turn_off: "power_off"
      set_brightness: "set_brightness"
  - platform: "number"
    name: "Animation Speed"
    state_characteristic: "0000fff1-0000-1000-8000-00805f9b34fb"
    commands:
      set_value: "set_speed"
