Reverse Engineering the Gallery Spotlights
The spotlights in our gallery halls are motorised: dimmer, pan, tilt, zoom, colour. Around a hundred and sixty of them. They are lovely, and you can only drive them from the vendor’s tablet app, one fixture at a time, by hand.
What I wanted was a script. Specifically I wanted the thing you always want at an opening: the speaker says look at this, and every relevant spot swings onto that point at once. You cannot do that by hand on a tablet.
So: work out the protocol.
What the fixtures actually speak
The lamps are Bluetooth-mesh nodes — CSRmesh, an older mesh stack that predates the official Bluetooth Mesh spec. Control messages are AES-encrypted with a key derived from a passphrase, and the frame is seq | src | ciphertext | mac(8) | ttl.
Two things made this tractable. First, the control layer is byte-for-byte compatible with the existing python-csrmesh work, so the transport was not a blank page. Second, the vendor did something pragmatic and slightly cheeky: rather than define their own message set, they overload the standard CSRmesh “set light RGB” opcode as a generic command carrier. Pan, tilt, zoom, scene selection — all of it rides inside a message that nominally sets a colour.
The useful consequence: scenes, programs and schedules live on the devices themselves. Once you can talk to a lamp, you do not need anything else to run the fleet.
I verified the crypto against the decompiled app rather than trusting my reading of it — key derivation, the nonce layout, the framing. All matched my implementation exactly.
Which made the next part more annoying.
Reads worked. Writes did nothing.
I could ask a lamp for its state and get a correct, decryptable answer back. I could send it a command — power, brightness, identify, unicast or broadcast — and nothing happened. No error. The lamp would echo the write and carry on ignoring me.
That asymmetry is diagnostic: if GET works, the key is right, the crypto is right, and the packet is reaching the device. Something at the delivery layer differed between my writes and the app’s.
The problem is that there is no success signal to iterate against. The lamp does not answer a failed SET with anything.
So I built a rig to watch the app do it properly.
A phone, a camera, and no root
The app runs on a tablet, but the tablet is a closed box. Instead I put the vendor app on a spare Android phone, repackaged with a Frida gadget so I could hook it — no root needed, which mattered because that phone has no path to root.
The instrumentation logs every layer at once: the key in memory, the plaintext the app builds, the fully encrypted on-air packet, and — crucially — the GATT delivery underneath: which characteristic, which write type, what handshake came first.
Then the question of ground truth. How does a script know whether a lamp actually turned on?
The phone’s front camera. Point it at the fixture, take a frame before and after, compare clipped highlights and the 99th percentile brightness. It is crude and it is completely reliable, which is the correct trade for a test oracle. Auto-exposure tries to fight you; comparing highlight clipping rather than mean brightness wins that fight.
With that, the loop closes: trigger the app’s own send path, capture what it emitted, decrypt it with the captured key, compare against what my code would have sent, replay it, and let the camera say whether the lamp moved.
The bug
The fixture I was testing against was older silicon than most of the fleet, and it exposed only the legacy CSRmesh control-point characteristics — not the modern ones my code wrote to. The app checks for the modern characteristic and falls back; my implementation did not, so it had been writing into a characteristic that fixture does not have.
Then, having found that, the second half: a command longer than one packet gets split into two chunks across two control points. I had them the wrong way round. Which explains the symptom exactly — short GETs fit in a single chunk and worked fine, longer SETs were split and landed swapped.
Two lines. Roughly a week.
One further detail worth writing down: the legacy control point needs write-without-response, and the MTU has to be negotiated first, or the Bluetooth stack refuses the write outright.
Where it ended up
Full local control: power, dimming, colour temperature, pan, tilt and zoom, in real units — percent, Kelvin, degrees — rather than raw protocol values. Each fixture is asked over Bluetooth what its own travel limits are, so the driver calibrates itself per lamp instead of carrying a hardcoded table.
On top of that a web console with the hall floorplan, where you tap a point on the floor and the selected spots converge on it.
The floorplan came out of the building’s CAD drawings as a metric SVG, using the six-metre column grid as the coordinate reference. Lamp positions are seeded off the columns, then refined by aiming each lamp at a known three-by-three floor grid and logging pan and tilt — a space-resection solve recovers where each lamp really is, to well under half a metre.
Which is a lot of machinery for “the spots should point at the thing the speaker is talking about”. But that is the job, and now it is a script.