You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Adds the Tuya Beacon Mesh (Beacon v2 / "P2") protocol core as UniversalProxy.TuyaBeaconMesh.*, plus the reverse-engineered protocol docs. With an already-paired bulb's key dump (JSON), it controls Beacon v2 bulbs over BLE adverts with no cloud. Builds on the bluez 0.4.0 raw radio layer from #232.
Nothing is wired into the app yet. No supervisor starts these processes, and there is no UI, Settings storage or HA entities. That is a separate host-wiring PR, which also has to make the mesh's Bluetooth adapter dedicated: not used by the ESPHome scanner, audio, or Improv.
P2 is hardware-validated on one product only: Gardenreet RGBCW G4 LED bulbs, 12 V low-voltage bi-pin (ten bulbs). P1 and Thing Beacon Mesh are documented from the app but untested.
What's in it
Pure codec:Crypto (XXTEA + XOR mask, CRCs), Dp, P2 (frame build/parse, normalize), Carrier (AD 0x03 frames), and Control (friendly DPs → frames, source 0x8001, at least 3 s on air).
Inventory: decodes and validates the JSON key dump. It rejects bad or non-hex keys, duplicate MACs, node ids, labels and groups, and reserved addresses. Keys are left out of inspect.
Seq: the 16-bit mesh sequence number, compared RFC 1982-style. It never goes backwards or reuses a number that went on air.
Radio: owns one dedicated adapter: the Bluez.Radio lease, Bluez.Mgmt advertising, DuplicateData discovery and the raw AdvertReceiver.
The TX queue is bounded and coalescing; RX is deduped, with echoes of our own frames dropped.
It stays up and retries setup with backoff instead of crash-looping.
It doesn't start or link the shared Bluez singletons.
Lights: controller backed by the inventory: set, ping, reset, group set, and report tracking.
The inventory can come from a loader, so a restart reads the current saved seq.
It only hands out a reserved block of sequence numbers once on_persist returns :ok.
It re-subscribes after a Radio restart.
Its crash dumps don't show keys.
Docs:docs/protocols/tuya_beacon_mesh.md is the main spec. Its new §4.9 lists the hardware-validated behaviour; where the app and the bulbs disagree, the bulbs win. Also added: per-family notes, a Python reference implementation cross-checked against the Elixir vectors, and an offline localKey KDF search tool.
Notes for review
The protocol is only as strong as Tuya's: frames carry an unkeyed CRC over cleartext src/sn/subCmd, so they can be forged or replayed. This is documented in §4.9.
Test fixtures: reference vectors (P2 only) and a live capture from the bulbs on 2026-10-25, which includes their MACs.
Reviewed with /phx:review (Elixir, OTP, testing, security); all blockers and warnings were fixed before opening.
Test plan
mise run test: 1809 passed. The 121 Tuya tests also passed 45 runs in a row.
mix format --check-formatted, mix credo --strict, dialyzer
python3 docs/protocols/tuya_beacon_reference.py and tuya_kdf_search.py --self-test
Hardware run through the app once host wiring lands. The protocol itself is proven on the Gardenreet bulbs.
Reverse-engineered from Smart Life 7.12.0 with JADX and REA. Covers the
Beacon v1 (P1), Beacon v2 (P2) and Thing Beacon Mesh (type 500) protocols:
air carrier, frame layouts, XXTEA/AES-CFB crypto, CRCs, DP encoding,
pairing flows, cloud APIs, and what BlueZ and this project need to
support them.
Includes a Python reference implementation whose self-test vectors were
generated by the app's own decompiled Java code, plus the detailed
per-family analysis notes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLsD6bqAUWcRLZW8Misu22
Documents binding a Tuya beacon light as a fake physical remote, which
needs no Tuya keys: fixed bind key, per-remote key derivation, bind and
DP frames, procedure, radio parameters and how to recognise lights.
Based on the beacon-cooked project; the reference builders reuse the P1
code and are checked against beacon-cooked's own codec.
Also fixes a wrong cross-reference for extended advertising.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLsD6bqAUWcRLZW8Misu22
Device type 500 (TYPE_THING_BEACON_MESH) is assigned to unpaired P1
devices and 501 to P2; the SIG-provisioned beacon mesh family has
neither. Relabel that family and explain the confusing app naming.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLsD6bqAUWcRLZW8Misu22
Explain the two candidate schemes for where a v2 bulb's localKey comes
from (static authkey vs. a per-pairing KDF of authkey and srand) and how
to tell them apart with one bulb.
tuya_kdf_search.py takes a bulb's authkey and several (srand, localKey)
samples from that bulb and reports which MD5/SHA-256/HMAC/AES/XXTEA
construction reproduces them all. Offline only; includes a self-test
that recovers planted constructions.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLsD6bqAUWcRLZW8Misu22
Extract Crypto/Dp/P2/Carrier plus a Control helper from the Vagus garden
prototype so Universal Proxy can talk to already-paired Beacon v2 bulbs
without cloud pairing. Includes hardware lessons and ExUnit coverage
against reference vectors and a live capture.
Co-authored-by: Cursor <cursoragent@cursor.com>
Enough to drop a key dump into Universal Proxy and drive Beacon Mesh
bulbs: decode/validate inventory (reject duplicate node ids), own the
mesh SN counter with a persist callback, run the injectable Bluez radio
queue, and control/report via Lights. Pairing/cloud still omitted.
Co-authored-by: Cursor <cursoragent@cursor.com>
Run mix format over the Tuya beacon mesh sources, remove the accidentally committed Python bytecode and ignore __pycache__.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Split friendly_dps/fetch_bulbs/fetch_groups into smaller helpers and replace single-clause with/cond with case, without behaviour changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Start Radio/Lights with start_supervised! so teardown is synchronous, start the FakeBluez agent unlinked, and sync on handle_continue before injecting reports.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reloading a dump of the same mesh now advances the in-memory cursor to the serial-number max (Seq.advance_to/2) instead of resetting it, and persists the result; a different mesh takes its dump's seq as-is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lights now monitors the Radio and, when it is down or not yet running, retries the subscription with a capped backoff instead of logging once and going deaf.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Radio no longer starts or links Bluez.Radio/Bluez.Mgmt, and a failed or lost setup (lease, D-Bus, receiver) now unwinds and retries with a capped backoff while staying up as not-ready. FakeBluez follows bluez 0.4.0's real shapes and can inject failures.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Radio's moduledoc cited a plan and a TX proof that aren't in this repo; it now points at tuya_beacon_mesh.md §4.7 and Bluez.Mgmt's software-rotation notes, and the extraction notes describe the host-supervision contract.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
P2 is now proven on Gardenreet RGBCW G4 bulbs only, so the spec records what
those bulbs do (0x8001 source, beaconKey group keying, mesh-wide SN, hop TTL,
3 s on air, MGMT radio) in a new §4.9 and marks the app-only claims it overrides.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The P1/P2 analysis notes now flag where the bulbs override app behaviour, and
the extraction notes trim their override table to pointers into spec §4.9.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Python reference gains 0x8001 unicast and FFgg group vectors generated by
the Elixir Control builders, and comments now say relays decrement the hop TTL.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keys must be 32 hex or 16 printable ASCII chars so nothing that decodes can raise at send time; node ids, group ids, labels and dump size are checked too. The inventory is now structs whose Inspect omits key material, and decode/1 re-validates an already-decoded one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
P2.build takes 4-bit frame types only, Dp.encode refuses :value outside int32, Control.group_set refuses a non-byte group and Seq.reserve names a bad block size. Tests now decrypt control frames with the expected key pair and cover the payload-CRC and malformed-frame paths.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The queue holds 8 frames, coalesces by key and reports a synchronously refused advertise; tx_done is matched by a per-advert ref since Mgmt reuses instance numbers, a failed stop_advertising re-runs setup, and a receiver that won't stop is killed so its name frees. Every call now takes server:, and FakeBluez follows bluez 0.4's lease rules with an arity contract test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lights reads its inventory through an optional loader in init so a crash-restart resumes from the persisted seq, only an :ok from on_persist releases a block, and numbers the Radio refused before air are handed out again. It also ignores a bad stored inventory instead of failing boot, redacts keys in format_status, re-anchors a bulb whose report counter restarts, honours server:, and maps timeouts apart.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The spec and notes now say frame integrity is an unkeyed CRC (forgeable, replayable), how freshness and re-anchoring work, and how the host should wire the loader and on_persist. vectors.json keeps only the P2 section this code uses, dropping the app's cloud-signing material.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The kill only runs after GenServer.stop times out, which leaves the receiver alive holding its name; demonitor now runs on every path.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
Inventory error handling can expose localKey and beaconKey in startup logs, public error results, and exception messages, creating critical credential-leak risks.
Adds a standalone Tuya Beacon Mesh P2 protocol core for cloud-free control of already-paired bulbs, building on bluez 0.4.0 raw radio access. Application supervision, settings, UI, and Home Assistant integration remain separate work.
…l fields
Errors now identify a bad entry by a validated MAC, id or position instead of echoing input, and MACs must be MAC-shaped. Field lookup no longer skips a present false or null, so "seq": false is rejected rather than defaulting to 0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sts and loader logging
Unicast set/ping/reset now use the bulb's validated beaconKey, a group-list report reads exactly its 8 slots skipping empty ones, and loader errors are reduced to a category before they are logged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An adapter removal or bluetoothd restart can end discovery while every process survives, so every 15 s the radio checks the receiver is listening and Adapter1.Discovering is true, and re-runs setup when not.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mgmt crashing mid-request or expiring the lease after the instance went on air doesn't prove the frame stayed off air, so Radio now answers {:uncertain, reason} and Lights keeps that sequence number used instead of handing it out again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
A label matching another bulb’s MAC can cause set or reset commands to target the wrong device, requiring unambiguous lookup handling and a regression test.
Lights looks up a MAC before a label, so such a label addressed the wrong bulb; Lights now also only treats a MAC-shaped string as a MAC.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This accepts group ID 255 and turns it into destination FF FF, which is the mesh broadcast address, not a group. With beaconKey used for both encryption and masking, the result is a valid broadcast DP-set frame rather than a group-scoped command. Reject 255 here, consistently with Inventory's 0..254 group-ID validation, and cover that boundary in the tests.
Missing identifiers resolve to the first unlabeled bulb
When a bulb has no label, Inventory stores its label as nil. This fallback therefore makes Lights.set(nil, ...) or Lights.reset(nil) resolve to the first unlabeled bulb instead of returning :unknown_bulb; a missing identifier can reset an unrelated device. Require a binary identifier before matching labels, or explicitly exclude nil labels.
Rejects colon-containing localKeys during parsing
docs/protocols/tuya_kdf_search.py:226
Splitting on every : rejects valid 16-character localKeys containing a colon, such as abcd:efghijklmno; quoting the CLI argument does not help. Split off the srand first, then consume exactly 16 characters for the localKey before parsing the optional challenge.
FF FF is broadcast, so a group command to 255 went out as a mesh-wide one; Control and P2 now take 0..254 like Inventory.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Unlabelled bulbs hold a nil label, so Lights.reset(nil) matched the first of them; only a binary is matched as a label now.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot round 3 listed three issues in code that hadn't changed. They have no inline threads, so I'm answering them here:
Group id 255 (control.ex:137): fixed in 4d751fa. Control.group_set/5 and P2's group-id check now take 0..254, matching Inventory, so a group command can no longer become an FF FF mesh broadcast. Tested at both layers (254 accepted, 255 rejected).
nil identifier (lights.ex:660): fixed in 4c96ff3. Only a binary is matched against labels, so Lights.set(nil, …) and reset(nil) return :unknown_bulb instead of hitting the first unlabelled bulb. The new test removes one bulb's label to cover it.
Colon in localKey (tuya_kdf_search.py:226): fixed in 9343332. Samples are now parsed fixed-width (8 hex : 16 chars [: 8 hex]), so a localKey containing : survives. The self-test covers that and the malformed cases.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds the Tuya Beacon Mesh (Beacon v2 / "P2") protocol core as
UniversalProxy.TuyaBeaconMesh.*, plus the reverse-engineered protocol docs. With an already-paired bulb's key dump (JSON), it controls Beacon v2 bulbs over BLE adverts with no cloud. Builds on the bluez 0.4.0 raw radio layer from #232.Nothing is wired into the app yet. No supervisor starts these processes, and there is no UI, Settings storage or HA entities. That is a separate host-wiring PR, which also has to make the mesh's Bluetooth adapter dedicated: not used by the ESPHome scanner, audio, or Improv.
P2 is hardware-validated on one product only: Gardenreet RGBCW G4 LED bulbs, 12 V low-voltage bi-pin (ten bulbs). P1 and Thing Beacon Mesh are documented from the app but untested.
What's in it
Crypto(XXTEA + XOR mask, CRCs),Dp,P2(frame build/parse, normalize),Carrier(AD 0x03 frames), andControl(friendly DPs → frames, source0x8001, at least 3 s on air).Inventory: decodes and validates the JSON key dump. It rejects bad or non-hex keys, duplicate MACs, node ids, labels and groups, and reserved addresses. Keys are left out ofinspect.Seq: the 16-bit mesh sequence number, compared RFC 1982-style. It never goes backwards or reuses a number that went on air.Radio: owns one dedicated adapter: theBluez.Radiolease,Bluez.Mgmtadvertising,DuplicateDatadiscovery and the rawAdvertReceiver.Lights: controller backed by the inventory: set, ping, reset, group set, and report tracking.on_persistreturns:ok.docs/protocols/tuya_beacon_mesh.mdis the main spec. Its new §4.9 lists the hardware-validated behaviour; where the app and the bulbs disagree, the bulbs win. Also added: per-family notes, a Python reference implementation cross-checked against the Elixir vectors, and an offline localKey KDF search tool.Notes for review
/phx:review(Elixir, OTP, testing, security); all blockers and warnings were fixed before opening.Test plan
mise run test: 1809 passed. The 121 Tuya tests also passed 45 runs in a row.mix format --check-formatted,mix credo --strict, dialyzerpython3 docs/protocols/tuya_beacon_reference.pyandtuya_kdf_search.py --self-test🤖 Generated with Claude Code