Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ See section 3.
| 3 | device id (mesh node id) [confirmed] |
| 4 | power state [confirmed] |
| 5 | brightness 0-100 [confirmed] |
| 6 | color temp; values above 100 mean the bulb is in RGB mode [confirmed] |
| 6 | mode: 0-100 = white, color temp [confirmed]; `FE` = RGB [confirmed]; `80`+index-1 = light show index running, `E3` = Cyber (index `43`); `A0`+index-1 = music show index running [confirmed on type 75, see run modes in section 5] |
| 7-9 | R G B [confirmed] |
| 10 | 0 = stale data, 1 = fresh [inferred] |
| 11-18 | unknown (some differ for a bulb grouped with a dimmer) [unknown] |
Expand Down Expand Up @@ -140,10 +140,31 @@ sends; field meanings inferred]
| brightness | `F0` (`D2` on Sol lamp) | `10` | `11 02 01 <0-100> FF FF FF FF` (Sol: `11 02 <bri> 00 00`) |
| color temp | `F0` (`E2` on Sol lamp) | `10` | `11 02 01 FF <temp> 00 00 00` (Sol: `11 02 05 <temp> 00`) |
| RGB | `F0` | `10` | `11 02 01 FF FE <R> <G> <B>` |
| light show | `E2` | `0E` | `11 02 07 01 <b1> <b2>` |
| run mode (light / music show) | `E2` | `0E` | `11 02 07 <mode> <index> <any>` (see run modes below) |
A command is sent to up to `CYNC_CMD_BROADCASTS` randomly chosen connected devices (any bridge can relay to the BTLE
mesh). All [inferred]. Fan commands go through brightness/percentage mappings (see `set_fan_*`).

### Run modes (light shows, music shows) [confirmed on type 75, fw 1.0.516; other types untested]
Measured one command at a time (2026-10-25): each command sent, the device's status read back (0x43 record byte 6), and the
Cync app's highlighted tile and the light itself checked by eye. Code: `cync_lan/effects.py`.
- `<mode>`: `00` exit the running show, `01` light show, `02` music show. Mode values first published by homebridge-cync-app
(`src/cync/tcp-client.ts`, `buildRunModePacket`).
- `<any>`: the last byte did not matter for any show tested (homebridge sends a random nonce there). [confirmed]
- Light show index (app tile name): 1 Candle, 2 Rainbow, 3 Fireworks, 4 Volcanic, 5 Aurora, 6 Happy Holidays,
7 Red White Blue, 8 Vegas, 9 Party Time, `43` Cyber. The app shows Cyber third, so tile order is not index order.
`64` did nothing. Fireworks is per deviceType: `3A` on the devices `FACTORY_EFFECTS_BYTES` was built from, but on
type 75 `3A` did nothing (also with last byte `DA`) and `03` runs it (`LIGHT_SHOW_ID_OVERRIDES`).
- Music show index: 1 Midnight, 2 Earth Tones, 3 Heat Wave, 4 Solar Flare, 5 Breeze, 6 Tropical, 7 Spectrum, 8 Supernova.
Index 9 never answered.
- Custom shows (the app's Custom tab) start at index 10 in both modes. With every slot the app allows filled, light shows ran
at 10-32 and music shows at 10-19; 33 / 20 did nothing. Custom tile order in the app is not slot order. The command carries
only the slot number: the show itself is stored when the user saves it in the app. Deleting a custom show did not renumber
the later ones. A newly saved show took a freed slot. [confirmed]
- Status byte 6 reports the running show: `80`+index-1 for light shows (`E3` for Cyber), `A0`+index-1 for music shows, for
presets and custom slots alike. A deleted slot still answered with its code once, then the device went back to the
previous show about 5 s later (seen once). [inferred for the deleted-slot case]
- The light-show code range `80`-`9F` ends where music codes begin, which is probably why custom light slots stop at 32. [inferred]

## 6. Device types
`metadata/model_info.py` maps the cloud `deviceType` number to capabilities. Mesh info record byte `[2]` of the first
record equals this number. Types with special handling in the parser: 96 (motion: recently_seen byte = motion),
Expand All @@ -162,6 +183,8 @@ record equals this number. Types with special handling in the parser: 96 (motion
10. Whether the 0x73 checksum rule excludes the `F8` byte by design or by accident.

## 8. Changelog
- 2026-10-26: run modes (light / music shows, custom slots, exit) and status byte 6 as the running-show code, measured on a
type 75 (nathandlawrence-bot). Fireworks is index 3 on type 75 (`3A` elsewhere).
- 2026-10-22 (later): code pointers updated after the `devices.py` split; `FA D9` ack note; session-behaviour notes. No new protocol facts yet, the next real additions should come from hardware logs (look for "dropping packet that failed to parse" lines, each carries the packet hex) and the APK pass.
- 2026-10-22: first draft, from code read of `devices.py` / `builder.py` and the old `packet_structure.md`.
`packet_structure.md` still holds the raw captures; fold it into this file when the APK pass is done.
13 changes: 13 additions & 0 deletions src/cync_lan/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
"CYNC_EXPORT_PORT",
"CYNC_UUID_PATH",
"FACTORY_EFFECTS_BYTES",
"LIGHT_SHOW_ID_OVERRIDES",
"light_show_id",
"LOCAL_TZ",
"CYNC_CONFIG_FILE_PATH",
"CYNC_CLOUD_AUTH_PATH",
Expand Down Expand Up @@ -216,6 +218,17 @@
"party_time": (int(0x09), int(0x06)),
}

# Light-show IDs that differ by deviceType. 0x3A runs Fireworks on the devices the table above was built from, but on a type 75
# (24' Cafe Lights, fw 1.0.516) it did nothing, even with the same last byte 0xDA; there 0x03 runs Fireworks (status 0x82).
LIGHT_SHOW_ID_OVERRIDES: Dict[int, Dict[str, int]] = {
75: {"fireworks": 0x03},
}


def light_show_id(show: str, dev_type: Optional[int]) -> int:
"""The run-mode index for a factory light show on this deviceType."""
return LIGHT_SHOW_ID_OVERRIDES.get(dev_type, {}).get(show, FACTORY_EFFECTS_BYTES[show][0])

ORIGIN_STRUCT = {
"name": "cync-lan",
"sw_version": CYNC_VERSION,
Expand Down
14 changes: 8 additions & 6 deletions src/cync_lan/devices/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
CYNC_RAW,
FACTORY_EFFECTS_BYTES,
)
from cync_lan.effects import RUN_MODE_EXIT, effect_command, run_mode_payload
from cync_lan.metadata.model_info import (
DeviceClassification,
DeviceTypeInfo,
Expand Down Expand Up @@ -731,24 +732,25 @@ async def set_rgb(
async def set_lightshow(self, show: str, sub_id: Optional[int] = None):
lp = f"{self.lp}set_lightshow:"
show = show.casefold()
if show not in FACTORY_EFFECTS_BYTES:
target = effect_command(show, self.type)
if target is None:
logger.error(f"{lp} Invalid effect: {show}")
return

chosen = FACTORY_EFFECTS_BYTES[show]
mode, index = target
op = 0xE2
cmd_ = 0x0E
_sub_id = sub_id if sub_id is not None else 0x00

# Payload: 0x11, 0x02, 0x07, 0x01, byte1, byte2 (6 bytes)
payload = struct.pack(">BBBBBB", 0x11, 0x02, 0x07, 0x01, chosen[0], chosen[1])
# Payload: 0x11, 0x02, 0x07, <run mode>, <show index>, <any byte> (6 bytes), see cync_lan.effects
last = FACTORY_EFFECTS_BYTES[show][1] if show in FACTORY_EFFECTS_BYTES else random.randint(0, 255)
payload = run_mode_payload(mode, index, last)
m_cb = ControlMessageCallback(
msg_id=0x00,
message=None,
sent_at=0.0,
# set to black to make it standout
callback=partial(
g.mqtt_client.update_rgb, self, (0, 0, 0)
g.mqtt_client.update_effect, self, None if mode == RUN_MODE_EXIT else show
),
)
await self.send_command(op, cmd_, _sub_id, payload, m_cb, lp)
Expand Down
104 changes: 104 additions & 0 deletions src/cync_lan/effects.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""Run modes: light shows, music shows and the active-effect code.

Command: op 0xE2, cmd 0x0E, inner payload ``11 02 07 <mode> <index> <byte>`` (what ``set_lightshow`` sends). Mode 0 exits the
running show, 1 runs a light show, 2 a music show. Any last byte works for every show tested (homebridge-cync-app sends a random
nonce there, ``buildRunModePacket``).

Status: byte 6 of a 0x43 status record (the "color temp" byte) says what is running: 0-100 white temperature, 0xFE RGB,
0x80 + index - 1 a light show (Cyber, index 0x43, reports 0xE3), 0xA0 + index - 1 a music show.

Light-show indexes come from FACTORY_EFFECTS_BYTES, per deviceType (Fireworks is 0x03 on type 75, see
const.LIGHT_SHOW_ID_OVERRIDES). Measured one by one on a deviceType 75 (24' Cafe Lights, fw 1.0.516): each command sent, the status code read back, the Cync app's
highlighted tile and the string itself checked. Custom shows (the app's Custom tab) start at index 10 in both modes; with every
slot the app allows filled, light shows ran at 10-32 and music shows at 10-19, and 33 / 20 did nothing. Index 9 in music mode
never answered. Other device types are untested. See docs/protocol.md.
"""

from typing import Dict, List, Optional, Tuple

from cync_lan.const import FACTORY_EFFECTS_BYTES, light_show_id

RUN_MODE_EXIT = 0x00
RUN_MODE_LIGHT_SHOW = 0x01
RUN_MODE_MUSIC_SHOW = 0x02

STOP_SHOW = "stop_show"
CUSTOM_FIRST_INDEX = 10
CUSTOM_LIGHT_SHOW_SLOTS = 23 # indexes 10-32
CUSTOM_MUSIC_SHOW_SLOTS = 10 # indexes 10-19 (the app allows 10)

LIGHT_SHOW_IDS: Dict[str, int] = {name: ids[0] for name, ids in FACTORY_EFFECTS_BYTES.items()}


def light_show_ids(dev_type: Optional[int] = None) -> Dict[str, int]:
"""Factory light-show indexes for this deviceType (Fireworks differs on type 75, see const.LIGHT_SHOW_ID_OVERRIDES)."""
return {name: light_show_id(name, dev_type) for name in FACTORY_EFFECTS_BYTES}

MUSIC_SHOW_IDS: Dict[str, int] = {
"music_midnight": 1,
"music_earth_tones": 2,
"music_heat_wave": 3,
"music_solar_flare": 4,
"music_breeze": 5,
"music_tropical": 6,
"music_spectrum": 7,
"music_supernova": 8,
}
_CUSTOM = (
("custom_light_", RUN_MODE_LIGHT_SHOW, CUSTOM_LIGHT_SHOW_SLOTS),
("custom_music_", RUN_MODE_MUSIC_SHOW, CUSTOM_MUSIC_SHOW_SLOTS),
)
_STATUS_BASE = {RUN_MODE_LIGHT_SHOW: 0x80, RUN_MODE_MUSIC_SHOW: 0xA0}
_CYBER_STATUS = 0xE3


def effect_names(dynamic: bool) -> List[str]:
"""The HA effect list. Every color light gets the factory light shows; dynamic (Dynamic Effects) devices also get the music
shows and the user's custom show slots, the only kind tested so far."""
names = list(LIGHT_SHOW_IDS)
if dynamic:
names += [f"custom_light_{n}" for n in range(1, CUSTOM_LIGHT_SHOW_SLOTS + 1)]
names += list(MUSIC_SHOW_IDS)
names += [f"custom_music_{n}" for n in range(1, CUSTOM_MUSIC_SHOW_SLOTS + 1)]
return names + [STOP_SHOW]


def effect_command(name: str, dev_type: Optional[int] = None) -> Optional[Tuple[int, int]]:
"""Effect name -> (run mode, index) on this deviceType, or None if it is not a known effect."""
name = name.casefold()
if name == STOP_SHOW:
return RUN_MODE_EXIT, 0x00
lights = light_show_ids(dev_type)
if name in lights:
return RUN_MODE_LIGHT_SHOW, lights[name]
if name in MUSIC_SHOW_IDS:
return RUN_MODE_MUSIC_SHOW, MUSIC_SHOW_IDS[name]
for prefix, mode, slots in _CUSTOM:
slot = name[len(prefix):]
if name.startswith(prefix) and slot.isdigit() and 1 <= int(slot) <= slots:
return mode, CUSTOM_FIRST_INDEX + int(slot) - 1
return None


def effect_from_status(code: Optional[int], dev_type: Optional[int] = None) -> Optional[str]:
"""Status byte 6 -> the running effect's name; None for white (0-100), RGB (0xFE) or a code no known effect reports."""
if code is None:
return None
if code == _CYBER_STATUS:
return "cyber"
for mode, base in _STATUS_BASE.items():
if base <= code < base + 0x20:
index = code - base + 1
table = light_show_ids(dev_type) if mode == RUN_MODE_LIGHT_SHOW else MUSIC_SHOW_IDS
for name, idx in table.items():
if idx == index:
return name
for prefix, m, slots in _CUSTOM:
if m == mode and CUSTOM_FIRST_INDEX <= index < CUSTOM_FIRST_INDEX + slots:
return f"{prefix}{index - CUSTOM_FIRST_INDEX + 1}"
return None


def run_mode_payload(mode: int, index: int, last: int) -> bytes:
"""Inner payload of a run-mode command: ``11 02 07 <mode> <index> <last>``."""
return bytes([0x11, 0x02, 0x07, mode & 0xFF, index & 0xFF, last & 0xFF])
18 changes: 18 additions & 0 deletions src/cync_lan/metadata/model_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,15 @@ def model_string(self) -> str:
# protocol=DeviceProtocol(TCP=True, MATTER=True),
# capabilities=SwitchCapabilities(dimmable=True, color=True, tunable_white=True),
# ),
127: DeviceTypeInfo(
type=DeviceClassification.LIGHT,
model_name="Dynamic Effects Outdoor Smart Eave Lights",
model_id="CEV100CDOD",
protocol=DeviceProtocol(TCP=True),
capabilities=LightCapabilities(color=True, tunable_white=True, dynamic=True),
characteristics=LightCharacteristics(min_kelvin=2000, max_kelvin=7000),
confirmed_by="nathandlawrence-bot",
),
128: DeviceTypeInfo(
type=DeviceClassification.LIGHT,
model_name="Dimmable A19 Bulb",
Expand Down Expand Up @@ -771,6 +780,15 @@ def model_string(self) -> str:
characteristics=LightCharacteristics(reveal=True, lumens=1000, cri=95, wattage=16, min_kelvin=2000, max_kelvin=7000),
confirmed_by="baudneo",
),
171: DeviceTypeInfo(
type=DeviceClassification.LIGHT,
model_name="Full Color A19 Bulb (Matter)",
model_id="CLEDA199CD1MS",
protocol=DeviceProtocol(TCP=True, MATTER=True),
capabilities=LightCapabilities(color=True, tunable_white=True),
characteristics=LightCharacteristics(lumens=800, wattage=9.5, min_kelvin=2000, max_kelvin=7000),
confirmed_by="nathandlawrence-bot",
),
224: DeviceTypeInfo(
type=DeviceClassification.THERMOSTAT,
model_name="Thermostat",
Expand Down
21 changes: 18 additions & 3 deletions src/cync_lan/mqtt_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,12 @@
CYNC_TOPIC,
CYNC_VERSION,
DEVICE_LWT_MSG,
FACTORY_EFFECTS_BYTES,
MQTT_DEAD,
MQTT_DEBUG,
ORIGIN_STRUCT,
)
from cync_lan.devices import CyncDevice
from cync_lan.effects import effect_from_status, effect_names
from cync_lan.metadata.model_info import DeviceClassification, device_type_map
from cync_lan.stats import stats
from cync_lan.structs import EntityState, FanSpeed, GlobalObject
Expand Down Expand Up @@ -907,6 +907,15 @@ async def update_temperature(
)
return False

async def update_effect(
self, node: CyncDevice, effect: Optional[str], sub_id: Optional[int] = None
) -> bool:
"""Publish the show that was just started (None = stopped). Intended for callbacks."""
node.online = True
return await self.pub_entity_state(
node, json.dumps({"state": "ON", "effect": effect}).encode(), sub_id
)

async def update_rgb(
self, node: CyncDevice, rgb: tuple[int, int, int], sub_id: Optional[int] = None
) -> bool:
Expand Down Expand Up @@ -1067,9 +1076,13 @@ async def parse_entity_state(
entity.temperature,
node
)
if node.supports_rgb:
# the same byte names a running light / music show (cync_lan.effects); None clears it in HA
mqtt_dev_state["effect"] = effect_from_status(entity.temperature, node.type)
mqtt_dev_state = json.dumps(mqtt_dev_state).encode()

if hasattr(node.metadata.capabilities, "motion") and node.metadata.capabilities.motion:
# metadata is None for a deviceType model_info does not know yet; the HA-birth re-announce stopped at the first one
if node.metadata is not None and getattr(node.metadata.capabilities, "motion", False):
motion_payload = b"ON" if entity.motion else b"OFF"
await self.pub_entity_state(
node,
Expand Down Expand Up @@ -1157,7 +1170,9 @@ async def _publish_entity(
if device.supports_rgb:
registry_struct["supported_color_modes"].append("rgb")
registry_struct["effect"] = True
registry_struct["effect_list"] = list(FACTORY_EFFECTS_BYTES.keys())
registry_struct["effect_list"] = effect_names(
dynamic=bool(device.metadata and device.metadata.capabilities.dynamic)
)
if device.metadata.capabilities.dynamic:
pass
# add brightness : True only when supported_color_modes are present
Expand Down
24 changes: 24 additions & 0 deletions tests/test_light_show_ids.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Factory light-show IDs by deviceType.

On a deviceType 75 (24' Cafe Lights, fw 1.0.516) each ID was sent one at a time and checked three ways: the status code the device
reported, the Cync app's highlighted tile, and the string itself. Fireworks was the only one that differs from the table: 0x3A did
nothing there (also with the table's own last byte 0xDA), 0x03 ran Fireworks (status 0x82).
"""

import pytest

from cync_lan.const import FACTORY_EFFECTS_BYTES, light_show_id


def test_fireworks_keeps_0x3a_by_default():
assert light_show_id("fireworks", None) == 0x3A
assert light_show_id("fireworks", 137) == 0x3A


def test_fireworks_is_0x03_on_type_75():
assert light_show_id("fireworks", 75) == 0x03


@pytest.mark.parametrize("show", [s for s in FACTORY_EFFECTS_BYTES if s != "fireworks"])
def test_other_shows_are_the_same_on_type_75(show):
assert light_show_id(show, 75) == FACTORY_EFFECTS_BYTES[show][0]
12 changes: 12 additions & 0 deletions tests/test_model_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,15 @@ def test_model_string_full():

def test_model_string_plain():
assert mi.DeviceTypeInfo(model_name="Plain").model_string == "Plain"


def test_type_127_eave_lights():
"""GE Cync Dynamic Effects Outdoor Smart Eave Lights (CEV100CDOD), fw 1.0.510."""
info = mi.device_type_map[127]
assert info.capabilities.color and info.capabilities.tunable_white and info.capabilities.dynamic


def test_type_171_full_color_a19_matter():
"""GE CYNC Full Color A19, Matter-capable revision (CLEDA199CD1MS), fw 1.1.204."""
info = mi.device_type_map[171]
assert info.capabilities.color and info.capabilities.tunable_white and info.protocol.MATTER
Loading