I was looking for a way to control my Philips Hue light strip without their terrible app1. All my searches led to this conclusion: you need to buy a Hue Bridge to control the lamp from a PC. But I don’t want to have another device just to do what my PC is capable of doing right now.

I want my light to turn on and off automatically every day without paying for another device. I also want to control it from my desk without grabbing my phone.

I published the end result of this project in huec, a CLI app that lets you control Philips Hue lights. Here’s a quick demo:

Here I discuss the journey of discovering the protocol, explaining how power, brightness, color, and alarms are controlled.

Use Cases #

Turn lights on and off every day

I have two alarms for my light to turn on at 07:00 and turn off at 08:00. To have this repeat every day I run the following command:

huec alarms enable --all

Timer using the lamps

I have a 5-minute timer on the light. Using this script I can start this timer.

result = run("uv run huec alarms list --json")
alarms = json.loads(result.stdout)
matches = [a for a in alarms if a["name"] == "Timer"]
if not matches:
    print("No alarm named 'Timer' found", file=sys.stderr)
    sys.exit(1)

alarm_id = matches[0]["id"]
print(f"Enabling alarm ID: {alarm_id}")
run(f"uv run huec alarms enable --id {alarm_id}")

Turn on after unlocking my Mac

Using Hammerspoon I set the lights to turn on when I unlock my Mac:

function ToggleLights(eventType)
	if eventType == hs.caffeinate.watcher.screensDidUnlock or eventType == hs.caffeinate.watcher.systemDidWake then
		fishRunCommand("huec power on")
	end
end
local ToggleLights = hs.caffeinate.watcher.new(toggleLights)
ToggleLights:start()

Without further ado, let’s see what I figured out about controlling the light.

It all started when I found Blendr. Blendr connects to Bluetooth Low Energy (BLE) devices and lets you browse their services and characteristics.

Characteristics are a place where the light stores some data. You can get data from a characteristic or write to it. A service is simply a group of characteristics. For example a service could be for changing color and brightness.

Here’s how the output looked for my lamp:

Service Device Information (0x1800)
  Manufacturer Name String (0x2A29) [Read]
  Model Number String (0x2A24) [Read]
  Software Revision String (0x2A28) [Read]

Service 932c32bd-0001-47a2-835a-a8d455b859dd
  932c32bd-0001-47a2-835a-a8d455b859dd [Read]
  932c32bd-0002-47a2-835a-a8d455b859dd [Read, Write, Notify]
  932c32bd-0003-47a2-835a-a8d455b859dd [Read, Write, Notify]
  932c32bd-0004-47a2-835a-a8d455b859dd [Read, Write, Notify]
  932c32bd-0005-47a2-835a-a8d455b859dd [Read, Write, Notify]
  932c32bd-0006-47a2-835a-a8d455b859dd [Write]
  932c32bd-0007-47a2-835a-a8d455b859dd [Read, Write, Notify]
  932c32bd-1005-47a2-835a-a8d455b859dd [Read, Write]

Service 97fe6561-0001-4f62-86e9-b71ee2da3d22
  97fe6561-0001-4f62-86e9-b71ee2da3d22 [Read]
  97fe6561-0003-4f62-86e9-b71ee2da3d22 [Read, Write]
  97fe6561-0004-4f62-86e9-b71ee2da3d22 [Write]
  97fe6561-0005-4f62-86e9-b71ee2da3d22 [Write]
  97fe6561-0006-4f62-86e9-b71ee2da3d22 [Read, Write]
  97fe6561-0008-4f62-86e9-b71ee2da3d22 [Write, Notify]
  97fe6561-1001-4f62-86e9-b71ee2da3d22 [Read, Write, Notify]
  97fe6561-2001-4f62-86e9-b71ee2da3d22 [Read, Write]
  97fe6561-2002-4f62-86e9-b71ee2da3d22 [Write]
  97fe6561-2004-4f62-86e9-b71ee2da3d22 [Write]
  97fe6561-a001-4f62-86e9-b71ee2da3d22 [Write]
  97fe6561-a002-4f62-86e9-b71ee2da3d22 [Read]
  97fe6561-a003-4f62-86e9-b71ee2da3d22 [Read, Write]

Service 9da2ddf1-0001-44d0-909c-3f3d3cb34a7b
  9da2ddf1-0001-44d0-909c-3f3d3cb34a7b [Write, Notify]

Service b8843add-0001-4aa1-8794-c3f462030bda
  b8843add-0001-4aa1-8794-c3f462030bda [Read]
  b8843add-0002-4aa1-8794-c3f462030bda [Write, Notify]
  b8843add-0003-4aa1-8794-c3f462030bda [Write]
  b8843add-0004-4aa1-8794-c3f462030bda [Read]

Some characteristics have “Read” in front of them. This means you can read their values using Blendr.

Now the question is, what does each characteristic do? There are two ways to find this out:

  1. Randomly write data into different characteristics to see if the lamp reacts. For example we can write 0x00 into all characteristics and see when the lamp turns off. This requires guessing what value turns the light on and off and what value changes the color.
  2. Use the app to change properties of the lamp and then read values using Blendr.

I turned the lamp off using Philips Hue app and checked what characteristic has 0x00 in it. It was the 932c32bd-0002-47a2-835a-a8d455b859dd, and that was the characteristic that controls power.

To send and receive data from the lamp there is Bleak.

By knowing the name of the lamp(you can get it from Blendr) you can connect to the lamp using:

POWER_UUID = '932c32bd-0002-47a2-835a-a8d455b859dd'

async def connect_to_light(name: str, timeout: float = 10.0) -> BleakClient:
    device = await BleakScanner.find_device_by_name(name, timeout=timeout)
    if not device:
        raise SystemExit(f"Device '{name}' not found.")

    client = BleakClient(device, timeout=timeout)
    await client.connect(timeout=timeout)
    return client

await client.write_gatt_char(POWER_UUID, b"\x01")  # turn on
await client.write_gatt_char(POWER_UUID, b"\x00")  # turn off

The client can then be used to read and write values.

Color #

There are multiple characteristics that update when you change the color the light color:

  • 932c32bd-0003-47a2-835a-a8d455b859dd changes with brightness
  • 932c32bd-0005-47a2-835a-a8d455b859dd changes with color (if I do warm white then cool white stays the same)
  • 932c32bd-0007-47a2-835a-a8d455b859dd changes with everything.

You can find out the pattern by changing the light color and observing the characteristic values.

Cool white:

000102030405060708090A0B0C0D0E0F
00000101010201FE03029C00

Warm white:

000102030405060708090A0B0C0D0E0F
00000101010201FE03025A01

The temperature values are in mireds. Warm white and cool white only differ in bytes 8-9.

When I set it to another color the bytes change to this format:

For example, here’s the packet for red

000102030405060708090A0B0C0D0E0F
00000101010201FE0404C5AF514E

The color is encoded in CIE xy format.

Philips Hue developer docs require login! So I asked Claude to figure out what this format is and how to convert from RGB.

  1. Convert the 8-bit number from R/G/B into a number between 0 and 1
  2. Linearize the numbers based on this formula if g > 0.04045 then g / 12.92 else ((g + 0.055) / 1.055) ^ 2.4
  3. Apply D65 matrix transformation, one full matrix example is here.

You can play around with it in the box below:

X Y

When you run the app in interactive mode with huec interactive, it will open up a browser page and run a server. The browser displays a color picker and calculates the payload for the color based on the explanations above. The server accepts the payload and sends it to the light using Bleak.

The set_color function below sends the packet to the lamp:

async def set_color(self, data: bytes) -> None:
    COLOR_UUID = "932c32bd-0007-47a2-835a-a8d455b859dd"
    await self.client.write_gatt_char(COLOR_UUID, data, response=True)

Alarms #

Alarms in the Philips app are a functionality to turn on/off the light at a specific time or create a countdown to flash the lights. Once an alarm fires, it deactivates and must be manually re-enabled to go off again the next day.

Similar to how I discovered how colors work I tried to look into what characteristics change when I create an alarm. But I didn’t see anything changing.

I needed to see what my phone was doing to create alarms.

For capturing Bluetooth packets there are tools like Wireshark. These tools allow you to see what data software running on the system is sending and where it’s going. I was using macOS + iOS. For this combination there is:

Install Packet Logger on your computer and the profile on your iPhone. Then, connect the phone to the computer. Start using the Philips Hue app, and you will see the packets being sent or received.

After setting up the tools I checked what was happening when the app connects to the light. The logs looked like this:

0x005A  Hue lightstrip pl  Write Request - Handle:0x0068 - Value: 0311 00  
	Write Request - Handle:0x0068 - Value: 0311 00
	Opcode: 0x12
	Attribute Handle: 0x0068 (104)
	Value: 0311 00
0x005A  Hue lightstrip pl  Channel ID: 0x0004  Length: 0x0006 (06) [ 12 68 00 03 11 00 ]  
	Channel ID: 0x0004  Length: 0x0006 (06) [ 12 68 00 03 11 00 ]
	L2CAP Payload:
	00000000: 1268 0003 1100                           .h....
0x005A  Hue lightstrip pl  Data [Handle: 0x005A, Packet Boundary Flags: 0x0, Length: 0x000A (10)]  
0x005A  Hue lightstrip pl  Write Response  
	Write Response
	Opcode: 0x13
0x005A  Hue lightstrip pl  Channel ID: 0x0004  Length: 0x0001 (01) [ 13 ]  
	Channel ID: 0x0004  Length: 0x0001 (01) [ 13 ]
	L2CAP Payload:
	00000000: 13                                       .
0x005A  Hue lightstrip pl  Data [Handle: 0x005A, Packet Boundary Flags: 0x2, Length: 0x0005 (5)]  
0x005A  Hue lightstrip pl  Handle Value Notification - Handle:0x0068 - Value: 0300 1100  
	Handle Value Notification - Handle:0x0068 - Value: 0300 1100
	Opcode: 0x1B
	Attribute Handle: 0x0068 (104)
0x005A  Hue lightstrip pl  Channel ID: 0x0004  Length: 0x0007 (07) [ 1B 68 00 03 00 11 00 ]  
	Channel ID: 0x0004  Length: 0x0007 (07) [ 1B 68 00 03 00 11 00 ]
	L2CAP Payload:
	00000000: 1B68 0003 0011 00                        .h.....
0x005A  Hue lightstrip pl  Data [Handle: 0x005A, Packet Boundary Flags: 0x2, Length: 0x000B (11)]  
0x005A  Hue lightstrip pl  Handle Value Notification - Handle:0x0068 - Value: 0411 00FF FF  
	Handle Value Notification - Handle:0x0068 - Value: 0411 00FF FF
	Opcode: 0x1B
	Attribute Handle: 0x0068 (104)
0x005A  Hue lightstrip pl  Channel ID: 0x0004  Length: 0x0008 (08) [ 1B 68 00 04 11 00 FF FF ]  
	Channel ID: 0x0004  Length: 0x0008 (08) [ 1B 68 00 04 11 00 FF FF ]
	L2CAP Payload:
	00000000: 1B68 0004 1100 FFFF                      .h......
0x005A  Hue lightstrip pl  Data [Handle: 0x005A, Packet Boundary Flags: 0x2, Length: 0x000C (12)]  
	Packet Boundary Flags: [10] 0x02 - First Flushable Packet Of Higher Layer Message (Start Of An L2CAP Packet)
	Broadcast Flags: [00] 0x00 - Point-to-point
	Data (0x000C Bytes)
0x0000  00:00:00:00:00:00  00000000: 5A20 0C00 0800 0400 1B68 0004 1100 FFFF  Z .......h......  
0x005A  Hue lightstrip pl  Number Of Completed Packets - Handle: 0x005A - Packets: 0x0001    
	Parameter Length: 5 (0x05)
	Number Of Handles: 0x01
	Connection Handle: 0x005A
	Number Of Packets: 0x0001

I asked Claude to figure out what the light was doing and gave it the context about what I was looking for. It figured out that the app performs this process:

  1. Write 00 to a characteristic.
  2. The characteristic replies with current alarm IDs.
  3. The app writes each alarm ID to the characteristic again and receives more information about that alarm.

So I learned that characteristics can also reply. This happens through subscriptions. From the first list of characteristics you can see some have read and write properties. Some characteristics have write and notify properties. You can write to these characteristics and receive a response.

Here’s the code to do this:

ALARM_ID = "9da2ddf1-0001-44d0-909c-3f3d3cb34a7b"

notifications = asyncio.Queue()

def on_alarm_notification(sender, data: bytearray):
    notifications.put_nowait(data)

await client.start_notify(ALARM_ID, on_alarm_notification)

await client.write_gatt_char(ALARM_ID, bytes([0x00]))

response = await asyncio.wait_for(notifications.get(), timeout=5.0)

You can see that in the Packet Logger logs there is only a handle. There is no characteristic ID. I tried a simple approach: I subscribed to all characteristics and then wrote 00 payload to all and checked which one replied. That’s how I got the characteristic.

The alarm characteristic (9da2ddf1-0001-44d0-909c-3f3d3cb34a7b) is like a server. You can write different messages to it and subscribe to it to get back responses as notifications.

When the app connects it writes this to the characteristic:

000102030405060708090A0B0C0D0E0F
000000

Then the characteristic responds back with the list of alarm IDs:

000102030405060708090A0B0C0D0E0F
0000000007022C002D00

To read the alarm details using its ID, we construct this message:

000102030405060708090A0B0C0D0E0F
0000022C000000

This gives us the full alarm details:

000102030405060708090A0B0C0D0E0F
000002002C00350000000001006055956900
00100901010106010908017D2201D40C138D
002081B94A4CAA42B99ACEC62D8800FFFFFF
0030FF0A4D6F726E696E6720757001

That’s it. You can read and parse the alarm.

Can we create any alarm we like now? When I took this same message and just tried to create an alarm by substituting my timestamp and name the lamp was not creating the alarm.

So I checked what the app sends to the lamp to create an alarm:

000102030405060708090A0B0C0D0E0F
000001FFFF000100D8B68E69000901010106
0010010908015B190194D18484B75143DAA8
002067A92F02110C8D00FFFFFFFF014101

After the alarm write succeeds there will be these two notifications which reply with the ID of the alarm:

000102030405060708090A0B0C0D0E0F
00000100FFFF1E00
000102030405060708090A0B0C0D0E0F
000004FFFF1E00

Then I tried sending the same payload to the lamp to create an alarm. But the alarm never actually got created. So then I checked what happens when I create the same alarm twice via the app.

First alarm creation payload:

000102030405060708090A0B0C0D0E0F
000001FFFF000100305C9169000901010106
001001090801651F01FBD061C25B6340F6AA
002071BB49E186F0C900FFFFFFFF0757616B
00306520757001

Second alarm creation, same configuration:

000102030405060708090A0B0C0D0E0F
000001FFFF000100305C9169000901010106
001001090801651F018597FE881CC14647A1
00209D9F6A8C2C297B00FFFFFFFF0757616B
00306520757001

As you see the mystery bytes change. There’s no change in the alarm configuration. This suggests that the app generates these bytes as a checksum. The lamp checks the checksum to verify if the alarm is valid or not. This means if I just repeat this with any configuration I want, it’s not going to work.

After spending some time on it3 I decided to come up with another solution to control alarms. My goal was to have an alarm that repeats every day. What if I could just change the active byte of the alarm and it would be enabled every day?

Then I created an alarm and turned it off and on again in the app. I already knew how to read alarm information. I was able to read the alarm info and see what had changed.

Create a new alarm called “Test”:

000102030405060708090A0B0C0D0E0F
000001FFFF00010040899069000901010106
001001090801651C01EF5572FEF8174B67AC
0020ED26721FCFAA2400FFFFFFFF04546573
00307401

Response confirming the alarm was created with ID 1:

000102030405060708090A0B0C0D0E0F
00000100FFFF0100
000102030405060708090A0B0C0D0E0F
000004FFFF0100

I turned off the alarm via the app, then I read the alarm details. Alarm active byte is 0:

000102030405060708090A0B0C0D0E0F
0000020006002F000000000000C0DA916900
0010090101010601090801651C01EF5572FE
0020F8174B67ACED26721FCFAA2400FFFFFF
0030FF045465737401

Then I turned it on again for tomorrow via the app. This is the edit request that sets the active byte to 1:

000102030405060708090A0B0C0D0E0F
0000010200000100C0DA9169000901010106
001001090801651C01EF5572FEF8174B67AC
0020ED26721FCFAA2400FFFFFFFF04546573
00307401

Responses confirming the edit:

000102030405060708090A0B0C0D0E0F
0000010002000300
000102030405060708090A0B0C0D0E0F
00000402000300

Reading the alarm again after re-enabling byte 9 is now 01 (active):

000102030405060708090A0B0C0D0E0F
0000020007002F000000000100C0DA916900
0010090101010601090801651C01EF5572FE
0020F8174B67ACED26721FCFAA2400FFFFFF
0030FF045465737401

So in order to turn on the alarm for the next day I have to do two things:

  • Change the active byte to 01.
  • Update the timestamp to be the next day. The alarm timestamp contains date and time. Time stamp is in UTC.

Timers #

The Philips app also has a feature called timer. You can start a timer and after it reaches 0 the light starts flashing.

000102030405060708090A0B0C0D0E0F
00000200380028000000000001B9BA946901
001001021D0150C15349696040B1B338466B
0020C3BB4258032C0100000554696D657201

Timers can be turned on and off in the same way. So the code that turns alarms on and off works on timers too.

Deleting Alarms #

Deleting alarms happen using the same characteristic.

By setting first byte to 03 we can make a delete alarm request. For example:

Delete alarm with ID 30:

000102030405060708090A0B0C0D0E0F
0000031E00

Response confirming the deletion:

000102030405060708090A0B0C0D0E0F
000003001E00
000102030405060708090A0B0C0D0E0F
0000041E00FFFF


  1. They have one app per device. Each app is slow and unresponsive. They don’t have good features. When I open the light app I need to wait a few seconds before it loads. If you want the lamp to turn on on a routine you need to pay extra. It’s a mess. I don’t want another Philips device in my home. ↩︎

  2. You need an Apple account to download it. I hate this because when I was in Iran many of these tools were blocked because you can’t easily create an account. ↩︎

  3. I created more alarms with different configurations trying to figure out the mystery bytes but I did not find a pattern. Let me know if you do!

    alarm creation packets
    Wake up 07:00 sunrise fade in 30 min
    
    01FF FF00 0100 D8B6 8E69 0009 0101 0106 0109 0801 5B19 0194 D184 84B7 5143 DAA8 67A9 2F02 110C 8D00 FFFF FFFF 0141 01
    
    --
    Wake up 06:50 sunrise fade in 20 min
    
    01FF FF00 0100 D8B6 8E69 0009 0101 0106 0109 0801 6519 01CA 492E A08E 6A48 6883 4FC0 1C5B 8E8F 4700 FFFF FFFF 0141 01
    
    --
    Wake up 06:50 sunrise fade in 10 min
    
    01FF FF00 0100 30B9 8E69 0009 0101 0106 0109 0801 7D19 01FA 3FD8 C1E2 304D 1E81 948B AE5E C246 3000 FFFF FFFF 0141 01
    
    --
    Wake up 07:00 full brightness fade in 30 min
    
    010C 0000 0100 D8B6 8E69 000E 0101 0102 01FE 0302 BF01 0502 5046 1901 2114 F58F E794 40F1 86C4 BF6A 8529 73C4 00FF FFFF FF01 4101
    
    --
    Wake up 06:50 full brightness fade in 20 min
    
    01FF FF00 0100 D8B6 8E69 000E 0101 0102 01FE 0302 BF01 0502 E02E 1901 BE74 4F8A 71FA 464D 8C10 910D 7983 676C 00FF FFFF FF01 4101
    
    --
    Wake up 06:50 full brightness fade in 10 min
    
    01FF FF00 0100 30B9 8E69 000E 0101 0102 01FE 0302 BF01 0502 7017 1901 AA85 9C87 96F4 4A08 A30D 26A7 E9E0 629B 00FF FFFF FF01 4101
    
    --
    Wake up 06:50 sunrise fade in 30 min
    01FF FF00 0100 80B4 8E69 0009 0101 0106 0109 0801 5B19 012D A26C 130F A94B F882 E9C3 215C 27A4 8700 FFFF FFFF 0141 01
    
    --
    Wake up 06:50 full brightness fade in 30 min
    01FF FF00 0100 80B4 8E69 000E 0101 0102 01FE 0302 BF01 0502 5046 1901 1478 FFD5 B1F1 431E AB18 E212 A720 34D6 00FF FFFF FF01 4101
     ↩︎