-
Notifications
You must be signed in to change notification settings - Fork 0
Protocol Specification
A description of the Glowrium lamp control protocol, for building interoperable clients. Everything here was established by observing a real device: network probing, GATT enumeration, and controlled experiments against the hardware.
| Property | Value |
|---|---|
| Product Name | Glowrium H4 Smart LED Corner Floor Lamp |
| Model Designation | Glowrium-C045 |
| Light Output | 1440 Lumens |
| Power Rating | 36 Watts |
| Illumination Angle | 160° Wide-Angle Glow |
| Color Temp Range | 2000K (Warm Yellow) to 7500K (Daylight) |
| WiFi MAC | 88:56:A6:F2:36:4C |
| BLE MAC | 88:56:A6:F2:36:4E |
| MAC OUI |
88:56:A6 = Espressif Inc. |
| LAN address |
192.168.0.81 (DHCP) |
| BLE advertised name | Glowrium-H4_F2364E |
| SoC family | ESP32 (classic, dual-mode) |
| Protocol version | 3 |
The device reports its own identity as a semicolon-delimited string on
characteristic facebd80:
brand:GLOWRIUM;pkey:Glowrium-C045;subid:1;devid:ESP-8856A6F2364C;
mac:8856A6F2364C;version:3;;
devid begins with ESP-, confirming the Espressif inference outright. The
BLE MAC is the WiFi MAC + 2, the ESP32 signature, and is also carried verbatim
in the advertising service data (below). -H4 in the advertising name is a
hardware revision tag, distinct from the C045 model code.
Service data under FACEBD00:
00 65 | 88 56 a6 f2 36 4e | 03 00 2d 01
\_____ BLE MAC ____/
The leading 00 65 and trailing 03 00 2d 01 are not fully identified; the
03 plausibly matches the protocol version reported elsewhere.
A full TCP sweep of 192.168.0.81 (ports 1-1024 plus all common IoT/LED
controller ports: 5577 Magic Home, 6668 Tuya, 6053 ESPHome, 1883/8883 MQTT,
8266 ESP OTA, 9999 Kasa, 80/443/8080) returned zero open ports.
The lamp is not a server. It makes an outbound connection to a cloud MQTT broker. There is no local WiFi control path; local control must use BLE.
| Item | Value |
|---|---|
| REST API | http://iot.ledinpro.com:8080/api/ |
| REST API (alt/IP) | http://47.251.4.156:9008/api/ |
| MQTT credentials | fetched per-user via device/mqtt_param
|
The broker address and credentials are provisioned per account at runtime, not hardcoded, so cloud control requires registered device credentials and is unsuitable as the basis for an open client. Both API endpoints are plain HTTP, not HTTPS.
Service facebd00-7261-6262-6974-696f74626c65. The UUID suffix is ASCII:
72 61 62 62 69 74 69 6f 74 62 6c 65 decodes to rabbitiotble. This is a
device BLE stack identifier rather than a Glowrium-specific one, so the same
protocol is likely shared across other INLEDCO products (including C011, C017, C037, C041, C045, C063, C066, C086, C094, 469).
| Characteristic | Properties | Role |
|---|---|---|
facebd01 |
write | command channel |
facebd02 |
read, write, notify | device state map + status notifications |
facebd03 |
write, notify | believed OTA / firmware transfer |
facebd80 |
read, write, notify | identity string |
facebd81 |
read | protocol version byte (observed 03) |
No pairing, bonding, or authentication handshake was required: an unbonded central could connect, read state, and subscribe immediately. That is convenient for us and a meaningful security weakness in the product.
The lamp only advertises while nothing is connected to it. When another active connection exists, it is invisible to other scanners. This was the initial blocker and is worth knowing before debugging a "missing" device.
The device does not use a byte frame with an opcode and checksum, which is what most cheap LED controllers do. It exchanges CBOR maps keyed by small integers. This matches standard CBOR property maps and the property get/set/post model in its MQTT layer, which suggests one key space serves both the BLE and cloud transports.
Reading facebd02 returns the full state map. The header and first pairs:
b8 1c map, 28 pairs
18 54 1b 00 00 01 9f a6 84 d4 6a key 84 -> uint64 1785205150826
18 65 03 key 101 -> 3
18 53 39 01 df key 83 -> int -480
Key 84 decodes to a millisecond Unix epoch matching the moment of capture, key
83 to exactly UTC-8, and key 101 to 3, matching both the facebd81 version
byte and the version:3 identity field. Three independent cross-checks, so the
CBOR reading is not in doubt.
Status notifications on the same characteristic are small maps of the same shape, appearing to be change-pushes rather than full snapshots:
a2 18 54 1b ... 02 f5 map(2) {84: <timestamp>, 2: true}
Observed in a live read. Note the read was cut short by the transport: the
device declared 28 pairs and 22 arrived, so keys beyond preset_3 are not yet
seen.
| Key | Name | Type | Description |
|---|---|---|---|
| 1 | brightness | uint | 0-100 (writable) |
| 2 | power | bool | true/false (writable) |
| 3 | unknown | bool | false |
| 5 | durations | array | [0, 0, 1800, 1800] |
| 8 | colour | bytes(4) | RGBY (stores but never renders) |
| 19 | mode | bytes(3) | 5 values seen (mode selector) |
| 22 | segments | bytes(80) | 20 segments x RGBY (writable) |
| 23 | segment_brightness | bytes(20) | all 100 |
| 24 | dynamic_program | bytes(100) | header + 20 x RGBY |
| 41-43 | preset_1..3 | bytes(112) | header + 20 x RGBY |
| 66 | unknown | uint | 0 |
| 77 | schedule_times | array | [0, 21600, 64800] |
| 80 | unknown | array | [0, 0] |
| 83 | timezone_offset | int | -480 |
| 84 | timestamp | uint | ms epoch |
| 86-89 | unknown | bool/uint | 0 / false |
| 101 | version | uint | 3 |
schedule_times decodes as seconds into the day: 21600 = 06:00, 64800 = 18:00.
durations as seconds: 1800 = a 30 minute ramp.
This is the single most important fact about the device, and it invalidates the obvious first reading of the data.
Key 22 is 80 bytes. Read as a time series that is "20 animation steps of RGBY", which is what it looks like in isolation. It is actually spatial: 20 physical segments, one 4-byte RGBY colour each.
Painting R,G,B repeating from the top reads back as:
array: G R B G R B G R B G R B G R B G R B G R
physical: ^ bottom top ^
The one-step offset is only explicable if index 0 is the bottom segment:
array[i] == physical_from_top[19 - i]
Verified by writing a deliberately asymmetric pattern (5 red, 5 green, 5 blue, 5 yellow from the top) and confirming visually that it rendered in that order. Had the ordering been backwards, the bands would have appeared mirrored.
glowctl's public helpers therefore default to top-to-bottom ordering and
reverse internally, because bottom-first is the opposite of how anyone
describes a standing lamp and silently mirrors every pattern when got wrong.
This also re-explains the factory preset (key 41), which is R,G,B,Y repeated
five times: a static rainbow down the lamp, not the four-step animation it
appears to be under the time-series reading. Likewise key 22's earlier
cool-to-warm ramp is a gradient along the lamp's length, not a sunrise fade.
Colour entries are four bytes, R G B Y. Three-channel RGB is wrong. The
reference "bright white" is (255, 255, 255, 200), holding yellow back
rather than driving all four to full.
Writing a CBOR property map to facebd01 sets those properties. facebd01
advertises plain write, so it expects a write with response, and the
device acknowledges at the ATT layer.
| Action | Frame |
|---|---|
| power on | a1 02 f5 |
| power off | a1 02 f4 |
| brightness 75 | a1 01 18 4b |
| paint segments | a1 16 58 50 <80 bytes> |
All verified end to end: acknowledged, pushed a matching notification, read back, and physically observed.
A write naming one key changes only that key, so partial updates are safe. Notifications are change-pushes carrying the changed key plus a timestamp, not full snapshots.
Painting segments needs no mode change. A per-segment write rendered
immediately while key 19 held constant at 00 00 05.
Key 8 is 4 bytes and looks exactly like the display colour. It is not.
Writes to it round-trip byte-exact, which is why it looked correct for a whole debugging cycle. But the lamp never changed. Conclusively: when solid red was set, key 8 did not move and did not contain red. The rendered colour was in key 22, with all 20 segments set to red.
What key 8 is for remains unidentified. It is marked unknown and not writable.
Key 19 tracks the active mode faithfully. It changed when the physical mode switch was pressed and differs across every distinct lighting state observed:
| Value | State |
|---|---|
01 00 04 |
white |
01 00 00 |
colour cycling |
00 00 06 |
solid colour |
00 01 00 |
per-segment pattern |
00 00 05 |
all white |
02 00 07 |
music reactive, "energy core" |
01 00 87 |
seen once, unidentified |
But writing it does nothing. With the lamp music-reactive, writing
00 00 05 (all white) was acknowledged, change-pushed, and read back. The lamp
reported all-white afterwards and carried on pulsing to sound.
So key 19 is a status record of the mode, not a direct control surface. Writing it alone only desynchronises the report from reality.
All three bytes vary and no structural reading has survived contact with the
next capture, so values are recorded raw in const.MODE_VALUES with no theory
attached.
This is the central open problem. Nothing found so far starts or stops a mode. Real mode control must live in the six properties that never arrive in the truncated read, or in a command form we have not observed.
Two properties now store perfectly and drive nothing: key 8 and key 19. Both looked correct from the protocol trace alone.
Every property this project marks writable, key 1, key 2 and key 22, was confirmed visually on hardware. That standard exists because this device will happily accept, acknowledge, echo and persist a write that changes nothing at all.
Two state reads four seconds apart while the lamp was actively colour cycling differed only in the timestamp. Key 22 did not move.
So animation runs entirely on the device and is not reported over BLE. During any dynamic mode, key 22 holds the configured pattern rather than what is currently lit, and a UI must not present it as live output.
- Which mode value renders the manual colour? Key 19 byte 3 is the selector (3.5), but the two values mapped so far are white and cycling, and neither displays key 8. Set a solid colour and read state to capture the value that does.
-
The rest of the key 19 mode enum, including what
0x87was. - The remaining 6 properties past the truncated read. The device declares 28 pairs and 22 arrive. Re-read with a larger MTU, or read in blobs.
-
Header layouts for the program properties (keys 24, 41-43). Key 24's
header begins
03 14 64 64 0a 02 03 00 01, where14= 20 matches the step count and64= 100 a brightness. - Scene IDs. The protocol defines 15 scene names; none has been tied to a numeric value or a property key yet.
- Keys 3, 66, 80, 86-89 remain unidentified.
- Whether
facebd03is really OTA, and what guards it. Given the service has no authentication at all, this is worth knowing.
Any CLI process launched from a terminal or non-GUI script context is SIGABRT-killed on
its first CoreBluetooth call, because TCC attributes the request to the
responsible process (the parent app), not the binary. Embedding an
NSBluetoothAlwaysUsageDescription via a Mach-O __TEXT,__info_plist section
is not sufficient for this reason.
Running the Python CLI from Terminal.app or iTerm2 is unaffected: the terminal emulator is itself an app bundle and macOS prompts for Bluetooth access normally.
Verbatim captures are kept in tests/data/ and used directly as test fixtures,
so the decoder is pinned to hardware behaviour rather than to anyone's reading
of it.
A mode is not an index you send. It is two properties written together in one frame:
ModeCtr (key 19) 3 bytes, identifies the mode
DyData (key 24) 100 bytes, the animation program
Established by writing campfire's DyData at a lamp showing torch: it changed
but sat half-applied, with the middle of the column flickering and the top
stuck on its old colour. Adding campfire's ModeCtr completed it. ModeCtr
written alone does nothing at all.
The lamp has 62 built-in modes (see tests/data/c045_modes.txt), but their
programs are not published anywhere. Each must be captured once from the device
while the mode is active, after which it replays offline forever.
bytes 0-19 header
bytes 20-99 20 RGBY palette entries, BOTTOM-first like Section0
Palette ordering was confirmed behaviourally: with LED count 7 and red in entries 0-6, exactly seven LEDs animated at the bottom of the column.
Orthogonal, safe to set independently within a given template:
| Byte | Meaning | How it was established |
|---|---|---|
| 1 | LED count, segments the effect spans | set 7, exactly 7 animated |
| 2 | bits 0-6 101 - speed, bit 7 speed mode | 100 to 1, 23 to 78, 50 to 51; uniform set bit 7 |
| 3 | brightness | 100 in every capture |
| 4 | total segments (20) | constant |
| 5 | response method, 0 stacking / 1 water drops | isolated cleanly |
| 8 | colour mode, see COLOUR_MODES | isolated cleanly |
| 18 | background brightness 0-100 | set 5, read 5 |
The background colour is palette[19], the last slot, not a segment.
Speed and speed mode sharing one byte matters for any encoder: writing either
must preserve the other. build_dydata does, and a regression test covers it.
Owned by the dynamic type, rewritten when it changes:
| Byte | Meaning |
|---|---|
| 0 | dynamic type |
| 6 | direction/variant, relabelled per type |
| 7 | tail length 0-3, cleared by types that lack it |
| 9, 10 | 19 and 100 under descent |
| 11 | 250 under expandable, 0 otherwise, across all captures |
| 19 | moves with the type |
Still unmapped: byte 12 only. Campfire reads 5 and torch 12, both dynamic type 3, while every custom capture reads 0, so it is a per-mode parameter within Expandable. Bytes 13-17 are zero in all sixteen captures.
Speed being stored inverted, as a delay rather than a rate, explains why campfire (1) and torch (3) both sit at near-maximum speed, which suits a flicker and a flame.
| Value | Type |
|---|---|
| 0 | descent |
| 1 | unused or unknown |
| 2 | gradient |
| 3 | expandable |
| 4 | random |
| 5 | marquee |
All five observed directly. The gap at 1 is real: gradient, expandable, random and marquee came out consecutive at 2-5, from which this project predicted descent = 1 and was wrong. Descent is 0.
Campfire and torch are both type 3, so built-in presets are built on Expandable with no privileged path.
Changing dynamic type rewrites six other header bytes, apparently clearing parameters that do not apply to the new type. Byte 6's meaning also shifts per type: "upward" produced 1 under one type, "downward expansion" produced 1 under another.
So a header cannot be assembled from parameters alone without producing
invalid combinations. build_dydata therefore requires a captured
template and exposes only the orthogonal fields plus the palette.
Selecting a dynamic type on hardware updates its parameter table. Updating any value afterwards flushes it. A capture taken between those two actions shows the previous type while the parameter map shows the new one.
A read of facebd02 returns exactly 738 bytes, every time, while the CBOR
header declares 28 pairs and only 22 arrive.
The negotiated ATT_MTU is 247, so a read fetches 246 bytes per PDU and 738 = 3 x 246 exactly. The device serves three full PDUs and stops. Its attribute is capped at 738 bytes in firmware regardless of how much its encoder produced. Why 738 specifically is unknown.
Measure the MTU after service discovery. Querying
maximumWriteValueLength(.withoutResponse) inside didConnect returns 20,
implying MTU 23, because the exchange has not completed. This project once
recorded that and built a whole explanation on it; notifications of 128 bytes,
impossible at MTU 23, exposed the error.
This is not fixable from the central. A central cannot force a larger MTU, and single reads hit the same MTU limit.
Writing a property makes the device push a notification carrying it back,
including properties a read never returns. Writing
{79: [1, 23400, 72900]} produced:
{84: <timestamp>, 79: [1, 23400, 72900], 65: 06 01 11 28}
Key 79 echoed verbatim and key 65 arrived unprompted. This is the only verification channel for the unreadable properties, and it is how RiseSet was confirmed.
The echo is not universal. Six of the eight unreadable keys respond:
4, 40, 62, 63, 65, 79. Two do not: baseAd (7) and extra (125) accept writes
silently, so their content has never been observed by any means.
Note the echo reflects what you sent. It proves storage, not effect, and it cannot disclose a value you did not already know.
Five slots, GTimeDat0..4 at keys 4, 40, 41, 42, 43. Each is 12 bytes of
config followed by a 100-byte DyData program.
Timer slots are filled in order, so a single timer lands in slot 0, which is past the read truncation. Three timers are needed before one appears in a readable slot. That is why several earlier experiments found no trace of a timer anywhere.
| Byte | Meaning |
|---|---|
| 0 | bit 7 enable, bits 0-6 weekday bitmask |
| 1 | hour |
| 2 | minute |
| 3 | action, 1 = on, 0 = off |
| 5 | unexplained; moved 100 to 101 with an action change |
| 10 |
0x80 whenever the slot is in use |
| 4, 6-9, 11 | identical in every slot observed |
"Once" is 0x80 (enabled, no day bits) and "daily" is 0xff (enabled, all
seven), so byte 0 packs the enable bit together with a weekday bitmask.
Verified end to end by building a timer in Python (06:53, weekdays 0x1f,
action on), writing it, and reading it back byte for byte.
Three properties, none of them readable:
| Key | Name | Size | Content |
|---|---|---|---|
| 79 | RiseSet |
12 | [enabled, rise_seconds, set_seconds] |
| 63 | RCfg |
102 | sunrise scene: 2 B config + 100 B program |
| 62 | SCfg |
102 | sunset scene, same layout |
| 65 | RStime |
4 | device-computed; accompanies every write in this family |
RiseSet was confirmed by echo. RCfg/SCfg accept writes built as 2 + 100.
The device stores more than it declares. Writing 102 bytes to SCfg
echoed back 106, with 40 40 00 7e appended. Identical for both. So a
declared size is the writable portion, not necessarily what the firmware keeps.
RStime is the one unreadable property whose genuine content has been seen,
because the device volunteers it. It reads 06 01 11 28, plausibly the
hour/minute pairs 06:01 and 17:40, unexplained.