Kessil AP700 Reverse Engineered.

Tikuf

New Member
View Badges
Joined
Apr 8, 2026
Messages
4
Reaction score
2
Location
Alberta
Rating - 0%
0   0   0
Edit: I may not have put this in the right section, sorry.

Join me on my anger fueled journey.

Context: I am new to the hobby, and purchased some used AP700 without realizing how old they were. No idea if this information will be useful, but I struggled to find any when I was researching. I was hoping to utilize the smart features included with it's built in wifi.

Problems I encountered.
- App is for the most part required to fully use the lights, Android app is no longer compatible with any modern device and can't be installed. Apple iOS app still does work, though very flakey.
- Fixtures don't seem to keep time well or ignore NTP. (Two lights would drift apart slowly turning on at slightly different times)
- Likely due to age, but one of two of the lights will turn off completely when set to moon mode.


Solutions I believe helped.
- iOS device is required to adopt lights to the "router", android may be possible, but didn't invest time in emulating an old enough android version.
- Static IP must be set, if changed from initial adoption, will likely require app reset, and readoption. Without a static IP, the light does not seem discoverable by the app anymore once it inventible changes.
- WiFi must be limited to 2.4Ghz only. (I setup a second SSID)
- lights benefit from being isolated/locked to a single AP, they do not seem to handle roaming or handoff well if they change APs.


So I got these stupid things on the network after about 2 days..., and they are controllable via the iOS app, but really for how much longer.
It's time to look under the hood and see how these things really work sense there's zero support anymore.

After an embarrassing amount of time, I have some information on how they operate and can be controlled by manually sending packets to it. (It has zero authentication)
This is also where I rant.

So yes, you can send packets of information just like the app, from any device. What makes it a pain in the butt, is there are 6 channel of information you need to feed it, and it's not RGB with Intensity. They expect a byte value the corresponds to power level on a selected "channel" of LEDs. I was unable to figure out on my own any kind of pattern to how it was formulating color with intensity. So it's time to crack open the app and see how it's working, and it's gross. There is a massive table of information that the app calls "calibration table" this gross array contains the formulas to produce the colors listed in the app (Deep Blue, Ocean Blue 3, etc) What this means, without this calibration data, it's not possible for me to guess the power output of each LED channel to reach a desired color or intensity. To be ***** they seemed to have obfuscated the latest versions of android apps calibration data, though luckily some of the first versions of the app are not obfuscated. The app also uses a intensity mapping presets, so adjusting the intensity manually is not linear, fun...

Technical Details
- Lights operate on port 8899
- They take 12 channels of information, 6 per light socket.
- No Authentication (Lights do expect checksum)
- Format: (start byte: 0xA9) + (length byte) + (body) + (checksum) + (stop byte: 0x5C)


I have been testing with simple Python script I can control from Home Assistant in my case.
CALIBRATION = [
[720,3028,720,4095,4095,0,670,3632,670,3913,3913,0],
[0,2318,0,2060,2060,0,0,2250,0,2000,2000,0],
[0,807,0,0,1421,0,0,800,0,0,1400,0],
[1100,2813,1100,4095,4095,0,1084,2746,1084,4034,4034,0],
[821,2144,821,1870,2082,0,847,2118,847,1857,2056,0],
[0,1255,731,794,786,0,0,1236,721,772,773,0],
[1660,1704,1660,4095,4095,0,1626,1743,1626,4095,4095,0],
[1071,1953,1164,1829,1878,0,1071,1953,1164,1829,1878,0],
[724,1212,724,759,805,0,0,1178,875,751,746,0],
[2223,1000,2223,3568,3508,0,2200,1635,2200,4095,2734,0],
[1405,1684,1405,1720,1684,0,1405,1684,1405,1720,1684,0],
[788,986,788,0,986,0,786,961,786,0,961,0],
[2684,703,2684,3486,2955,0,2695,985,2695,3181,2734,0],
[1676,1676,1676,1390,1502,0,1654,1698,1654,1401,1524,0],
[832,913,832,0,913,0,826,903,826,0,898,0],
[3161,770,3161,4054,1833,0,3333,1915,3333,2069,1967,0],
[1918,1429,1918,1259,1314,0,1922,1451,1922,1257,1323,0],
[885,857,885,0,857,0,884,852,884,0,842,0],
[4015,1125,4015,2236,1466,0,4095,1466,4095,1677,1466,0],
[2140,1240,2140,1080,1125,0,2144,1262,2144,1078,1134,0],
[916,800,916,0,789,0,938,798,938,0,777,0],
[3975,1129,3975,1200,1309,0,4095,1000,4095,1034,1599,0],
[2200,1025,2200,853,1013,0,2320,1080,2345,828,1030,0],
[972,911,972,0,0,0,974,909,974,0,0,0],
[3975,880,3975,1020,1160,0,4095,900,4095,1000,1100,0],
[2226,906,2226,779,881,0,2352,876,2352,787,850,0],
[992,836,992,0,0,0,1004,824,1004,0,0,0],
[0,0,0,0,0,0,0,0,0,0,0,1785],
[0,0,0,0,0,0,0,0,0,0,0,1298],
[0,0,0,0,0,0,0,0,0,0,0,743],
[0,860,0,880,860,0,0,850,0,950,850,0],
[0,750,0,750,750,0,0,739,0,739,739,0],
[0,0,0,0,754,0,0,0,0,0,700,0],
[0,0,0,0,0,0,0,0,0,0,0,4095],
[0,0,0,0,0,0,0,0,0,0,0,2169],
[0,0,0,0,0,0,0,0,0,0,0,1021],
[0,0,0,0,0,1520,0,0,0,0,0,3975],
[0,0,0,0,0,970,0,0,568,0,0,2196],
[0,0,0,0,0,726,0,0,0,0,0,1265],
[0,0,0,0,0,4095,0,0,0,0,0,2760],
[0,0,0,0,0,1797,0,0,0,0,0,1773],
[0,0,0,0,0,795,0,0,0,0,0,837],
[0,0,0,0,0,3200,0,0,0,0,0,0],
[0,0,0,0,0,1772,0,0,0,0,0,0],
[0,0,0,0,0,771,0,0,0,0,0,0],
[1960,0,1960,2560,0,4095,2100,0,2100,2800,0,0],
[1140,0,1010,2537,0,2594,1122,0,1122,1966,0,0],
[0,0,690,1339,0,1315,0,0,694,1442,0,0],
[1527,3817,1527,2005,3536,0,1476,3947,1476,2069,3654,858],
[1018,2655,1018,1367,2331,0,1018,2628,1018,1346,2315,0],
[0,924,0,1144,1261,0,0,873,0,1090,1202,987],
[700,1460,740,690,710,0,710,1490,750,705,740,4075],
[0,0,0,809,1110,0,0,0,0,894,1143,2315],
[0,0,0,0,803,0,0,0,0,0,783,1043],
]

INTENSITY_MAPPING_PERCENT = [0, 0, 1, 1, 2, 2, 2, 3, 3, 4, 4, 4, 5, 5, 5, 6, 6, 7, 7, 7, 8, 8, 9, 9, 9, 10, 10, 11, 11, 11, 12, 12, 13, 13, 13, 14, 14, 15, 15, 15, 16, 16, 16, 17, 17, 18, 18, 18, 19, 19, 20, 20, 20, 21, 21, 22, 22, 22, 23, 23, 24, 24, 24, 25, 25, 25, 26, 26, 27, 27, 27, 28, 28, 29, 29, 29, 30, 30, 31, 31, 31, 32, 32, 33, 33, 33, 34, 34, 35, 35, 35, 36, 36, 36, 37, 37, 38, 38, 38, 39, 39, 40, 40, 40, 41, 41, 42, 42, 42, 43, 43, 44, 44, 44, 45, 45, 45, 46, 46, 47, 47, 47, 48, 48, 49, 49, 49, 50, 50, 51, 51, 51, 52, 52, 53, 53, 53, 54, 54, 55, 55, 55, 56, 56, 56, 57, 57, 58, 58, 58, 59, 59, 60, 60, 60, 61, 61, 62, 62, 62, 63, 63, 64, 64, 64, 65, 65, 65, 66, 66, 67, 67, 67, 68, 68, 69, 69, 69, 70, 70, 71, 71, 71, 72, 72, 73, 73, 73, 74, 74, 75, 75, 75, 76, 76, 76, 77, 77, 78, 78, 78, 79, 79, 80, 80, 80, 81, 81, 82, 82, 82, 83, 83, 84, 84, 84, 85, 85, 85, 86, 86, 87, 87, 87, 88, 88, 89, 89, 89, 90, 90, 91, 91, 91, 92, 92, 93, 93, 93, 94, 94, 95, 95, 95, 96, 96, 96, 97, 97, 98, 98, 98, 99, 99, 100, 100]

Python Script attached.
(Must edit script with your lights IP address at the top)

Example Usage from within Home Assistant PyScript.
Turn on Light -
service: pyscript.ap700_on

Turn Off Light -
service: pyscript.ap700_off

Set Color -
service: pyscript.ap700_set_color_name
data:
color_name: Ocean Blue 3
intensity: 60

List of Colors recognized
sky blue
ocean blue 7
ocean blue 6
ocean blue 5
ocean blue 4
ocean blue 3
ocean blue 2
ocean blue 1
deep ocean blue
indigo
purple
red
orange
yellow
green
blue
moon red
moon blue
 

Attachments

  • AP700-HomeAssistant-001.py.txt
    16.4 KB · Views: 313
Last edited:

oysterguy

New Member
View Badges
Joined
Feb 3, 2020
Messages
1
Reaction score
0
Rating - 0%
0   0   0
Edit: I may not have put this in the right section, sorry.

Join me on my anger fueled journey.

Context: I am new to the hobby, and purchased some used AP700 without realizing how old they were. No idea if this information will be useful, but I struggled to find any when I was researching. I was hoping to utilize the smart features included with it's built in wifi.

Problems I encountered.
- App is for the most part required to fully use the lights, Android app is no longer compatible with any modern device and can't be installed. Apple iOS app still does work, though very flakey.
- Fixtures don't seem to keep time well or ignore NTP. (Two lights would drift apart slowly turning on at slightly different times)
- Likely due to age, but one of two of the lights will turn off completely when set to moon mode.


Solutions I believe helped.
- iOS device is required to adopt lights to the "router", android may be possible, but didn't invest time in emulating an old enough android version.
- Static IP must be set, if changed from initial adoption, will likely require app reset, and readoption. Without a static IP, the light does not seem discoverable by the app anymore once it inventible changes.
- WiFi must be limited to 2.4Ghz only. (I setup a second SSID)
- lights benefit from being isolated/locked to a single AP, they do not seem to handle roaming or handoff well if they change APs.


So I got these stupid things on the network after about 2 days..., and they are controllable via the iOS app, but really for how much longer.
It's time to look under the hood and see how these things really work sense there's zero support anymore.

After an embarrassing amount of time, I have some information on how they operate and can be controlled by manually sending packets to it. (It has zero authentication)
This is also where I rant.

So yes, you can send packets of information just like the app, from any device. What makes it a pain in the butt, is there are 6 channel of information you need to feed it, and it's not RGB with Intensity. They expect a byte value the corresponds to power level on a selected "channel" of LEDs. I was unable to figure out on my own any kind of pattern to how it was formulating color with intensity. So it's time to crack open the app and see how it's working, and it's gross. There is a massive table of information that the app calls "calibration table" this gross array contains the formulas to produce the colors listed in the app (Deep Blue, Ocean Blue 3, etc) What this means, without this calibration data, it's not possible for me to guess the power output of each LED channel to reach a desired color or intensity. To be ***** they seemed to have obfuscated the latest versions of android apps calibration data, though luckily some of the first versions of the app are not obfuscated. The app also uses a intensity mapping presets, so adjusting the intensity manually is not linear, fun...

Technical Details
- Lights operate on port 8899
- They take 12 channels of information, 6 per light socket.
- No Authentication (Lights do expect checksum)
- Format: (start byte: 0xA9) + (length byte) + (body) + (checksum) + (stop byte: 0x5C)


I have been testing with simple Python script I can control from Home Assistant in my case.


Python Script attached.
(Must edit script with your lights IP address at the top)

Example Usage from within Home Assistant PyScript.
Turn on Light -


Turn Off Light -


Set Color -


List of Colors recognized
Hi,

I just read your AP700 reverse engineering thread from start to finish and I have to say — thank you. Genuinely.

I'm in almost exactly your situation. Bought a used AP700 off Craigslist out of pure nostalgia, hit the v1.0.5 firmware wall immediately, went through the same Kessil support dead end you did. Mine has been sitting on my Red Sea Reefer 350 running off an Apex outlet timer at fixed intensity while I tried to figure out a path forward.

Your thread is that path.

I already have Home Assistant running (HAOS on Proxmox), so your Python script drops right into my existing setup. The calibration table you extracted from the old unobfuscated Android app is the piece I never would have found on my own — that's the real gem.

A few questions if you don't mind:

1. Is the AP700-HomeAssistant-001.py.txt attachment still available? I'd love to get the latest version directly from you rather than trying to reconstruct it.
2. Did you get the iOS app working for initial adoption, or are you running purely via the Python script now?
3. Any tips on the static IP / 2.4GHz setup for someone using Unifi WiFi and Omada for the router?

I'm running an Apex A2 on the same tank, so I'm thinking about integrating your script through the existing HA → Apex flow I'm building out.

Thank you again for doing the hard work and sharing it openly. The AP700 community owes you.

John
Monterey, CA
[email protected]
 
OP
OP
T

Tikuf

New Member
View Badges
Joined
Apr 8, 2026
Messages
4
Reaction score
2
Location
Alberta
Rating - 0%
0   0   0
A few questions if you don't mind:

1. Is the AP700-HomeAssistant-001.py.txt attachment still available? I'd love to get the latest version directly from you rather than trying to reconstruct it.
2. Did you get the iOS app working for initial adoption, or are you running purely via the Python script now?
3. Any tips on the static IP / 2.4GHz setup for someone using Unifi WiFi and Omada for the router?
Thrilled someone might benefit from this.

1. Should have been attached to the post for download, but if that doesn't work, this is a pastbin of the same file.
I am using Home Assistant's HACS PyScript to execute the script / service calls.

2. Yes at this stage, the iOS app (KessilAP700Controller) is still required to get them onto the network or "router" as the app describes it, and not broadcasting it's own wifi for direct connection.
The app is flakey and slow, while researching I can see that the app sends a bunch of a discovery calls that fail/timeout, and connections to the internet that also timeout not helping the app.
I am no longer using the app, or any built in scheduling and running fully off Home Assistant calling the python script.

3. I can confirm unifi APs will work, as this is the same I am using. In your Router you will need to static/assign an IP address.
3.1 - This process is also ugly, as the light doesn't seem to have it's MAC address printed on the device.
3.2 - Download iOS app, go through "Router" setup. (Also terrible)
3.3 - Your wifi may not display in the very limited selection window of selecting your WiFi/SSID as you are unable to type the name of your network, this is solved by refreshing many times until luck is on your side and your SSID appears. I personally had to make my phone broadcast the same SSID as my home network, and place it on top of the light so it was by far the strongest signal, then tell it to scan again. I immediately turn off the phones hot spot, as the goal is only to get the name to be selectable field.
WPA2 or Open are the only security it understands, ensure your network is not running the newer WPA3
3.4 - Your Light should now be on the network and seen by your router, at this stage you need to make it's current IP static. If the router doesn't like making the same IP it just handed out as a static, you can assign a new IP*. (*If you assign a new IP, you MUST reset the iOS app, and re-run the adoption process above again)

The device is only capable of seeing 2.4Ghz, admitly I already had a separate SSID for some other IoT stuff and utilized it. However Hybrid systems of 5Ghz & 2.4 Ghz SHOULD still work as the light is only capable of seeing the 2.4Ghz, the catch is some Routers and APs will try and push clients onto the 5Ghz radio, and this will mess with it.

Give a shout if you need a hand.
 
Last edited:

A_Blind_Reefer

2500 Club Member
View Badges
Joined
Aug 13, 2019
Messages
2,872
Reaction score
3,616
Rating - 0%
0   0   0
The wired spectral controller will work with these fixtures (just as an option to manual).

The internal clocks will drift but do correct themselves each time you connect via the app. I was opening the app and connecting monthly just to keep multiple lights synced. Obviously this isn’t a problem if you’re using home assistant for control.

Direct WiFi on these lights worked fairly well (as noted above 2.4 only and limited range) but be aware that the router based connections may not be reliable especially after a power cycle. This isn’t the case with newer lights using the newer app and was only an issue with the ap700.

I do have the Kessil updater tool (windows) and the latest firmware saved but don’t know how I’d share it here. I actually spent a lot of time looking for it after @oysterguy post awhile back but couldn’t find the thread to post back. There is a caveat to the firmware in that really old fw required a step update (ex 1.01 needed to be upgraded to 1.03 before it could be upgraded to 1.07) I made up those version numbers, it’s been so long that I forgot the actual versions.

I also have a couple ap700 lights with mounting arms and extensions out in the garage if someone’s interested. I posted them for sale here a couple years back but didn’t want to ship them.
 
OP
OP
T

Tikuf

New Member
View Badges
Joined
Apr 8, 2026
Messages
4
Reaction score
2
Location
Alberta
Rating - 0%
0   0   0
The wired spectral controller will work with these fixtures (just as an option to manual).

Do you have any more information, I was unable to find any wired support for the AP700. This would interest me, as I could add a wifi capable chip to control it.
 

A_Blind_Reefer

2500 Club Member
View Badges
Joined
Aug 13, 2019
Messages
2,872
Reaction score
3,616
Rating - 0%
0   0   0
Do you have any more information, I was unable to find any wired support for the AP700. This would interest me, as I could add a wifi capable chip to control it.
I’ll try and see if I can find the thread where someone was mentioning how they did it. It was years ago, these lights are pretty old. I know the usb port is supposed to only be for upgrading firmware, and if I remember (big if there) they had to open up the fixture and solder but again that was probably like 2017/2018.
 
OP
OP
T

Tikuf

New Member
View Badges
Joined
Apr 8, 2026
Messages
4
Reaction score
2
Location
Alberta
Rating - 0%
0   0   0
AI Slop Warning.

I encountered a bug in my first script unable to set lights to "moon blue" Below is a revised version produced from AI examining the decompiles files.

"""
AP700 PyScript for Home Assistant — v2, rebuilt from APK source.

Changes from v1 (AP700-HomeAssistant-001.py):
- Calibration table extended to 66 rows (rows 54-65 added from APK Default_Calibration.java)
- getTouchChanelindex lower-range slope now uses integer division (//) to match the
APK's Java `(mid - low) / 50` (int division), not the float /50.0 in v1.
Upper-range keeps float division, matching the APK's `(double)(high - mid) / 50.0`.
- Intensity conversion uses the APK's linear formula:
device_byte = clamp(round(intensity_pct * 255 / 100), 0, 255)
which is the exact inverse of APK getIntensityPercent(): (byte/255*100 + 0.5)
- Color region table, angle→color mapping, framing & checksum are unchanged
(they already matched the APK exactly).
- Only on/off, intensity, and color functions are included (no scheduling).

Connection details come from the working v1 script.

Install:
Save as /config/pyscript/ap700.py
Reload PyScript

Services exposed:
pyscript.ap700_on host=<optional IP>
pyscript.ap700_off host=<optional IP>
pyscript.ap700_power state=0|1, host=<optional IP>
pyscript.ap700_set_intensity intensity=0..100, host=<optional IP>
pyscript.ap700_set_angle angle=0..359, intensity=0..100, host=<optional IP>
pyscript.ap700_set_color color_name="Ocean Blue 3", intensity=0..100, host=<optional IP>
pyscript.ap700_set_channels channels=[v0..v11], host=<optional IP>
pyscript.ap700_send_hex hex_payload="A901002700...", host=<optional IP>
"""

import socket

# ── Connection ────────────────────────────────────────────────────────────────
LIGHT_HOSTS = ["10.50.52.2", "10.50.52.3"]
PORT = 8899
TIMEOUT = 0.7

# ── Protocol constants ────────────────────────────────────────────────────────
START_BYTE = 0xA9 # -87 signed → 0xA9 unsigned
STOP_BYTE = 0x5C # 92

CMD_POWER = 0x27 # 39
CMD_SET_ALL_CHANNEL = 0x01 # 1
CMD_FIND_ME = 0x76 # 118

# ── Calibration table (66 rows × 12 channels) — sourced from APK ─────────────
# Each row is [ch0, ch1, ch2, ch3, ch4, ch5, ch6, ch7, ch8, ch9, ch10, ch11]
# Channels are 12-bit DAC values (0-4095).
# Rows 0-53 matched v1 exactly; rows 54-65 are new from the APK.
CALIBRATION = [
# ── Deep Ocean Blue (rows 0-2: high/mid/low) ──
[720,3028,720,4095,4095,0,670,3632,670,3913,3913,0], # 0 high
[0,2318,0,2060,2060,0,0,2250,0,2000,2000,0], # 1 mid
[0,807,0,0,1421,0,0,800,0,0,1400,0], # 2 low
# ── Ocean Blue 1 (rows 3-5) ──
[1100,2813,1100,4095,4095,0,1084,2746,1084,4034,4034,0], # 3 high
[821,2144,821,1870,2082,0,847,2118,847,1857,2056,0], # 4 mid
[0,1255,731,794,786,0,0,1236,721,772,773,0], # 5 low
# ── Ocean Blue 2 (rows 6-8) ──
[1660,1704,1660,4095,4095,0,1626,1743,1626,4095,4095,0], # 6 high
[1071,1953,1164,1829,1878,0,1071,1953,1164,1829,1878,0], # 7 mid
[724,1212,724,759,805,0,0,1178,875,751,746,0], # 8 low
# ── Ocean Blue 3 (rows 9-11) ──
[2223,1000,2223,3568,3508,0,2200,1635,2200,4095,2734,0], # 9 high
[1405,1684,1405,1720,1684,0,1405,1684,1405,1720,1684,0], # 10 mid
[788,986,788,0,986,0,786,961,786,0,961,0], # 11 low
# ── Ocean Blue 4 (rows 12-14) ──
[2684,703,2684,3486,2955,0,2695,985,2695,3181,2734,0], # 12 high
[1676,1676,1676,1390,1502,0,1654,1698,1654,1401,1524,0], # 13 mid
[832,913,832,0,913,0,826,903,826,0,898,0], # 14 low
# ── Ocean Blue 5 (rows 15-17) ──
[3161,770,3161,4054,1833,0,3333,1915,3333,2069,1967,0], # 15 high
[1918,1429,1918,1259,1314,0,1922,1451,1922,1257,1323,0], # 16 mid
[885,857,885,0,857,0,884,852,884,0,842,0], # 17 low
# ── Ocean Blue 6 (rows 18-20) ──
[4015,1125,4015,2236,1466,0,4095,1466,4095,1677,1466,0], # 18 high
[2140,1240,2140,1080,1125,0,2144,1262,2144,1078,1134,0], # 19 mid
[916,800,916,0,789,0,938,798,938,0,777,0], # 20 low
# ── Ocean Blue 7 (rows 21-23) ──
[3975,1129,3975,1200,1309,0,4095,1000,4095,1034,1599,0], # 21 high
[2200,1025,2200,853,1013,0,2320,1080,2345,828,1030,0], # 22 mid
[972,911,972,0,0,0,974,909,974,0,0,0], # 23 low
# ── Sky Blue (rows 24-26) ──
[3975,880,3975,1020,1160,0,4095,900,4095,1000,1100,0], # 24 high
[2226,906,2226,779,881,0,2352,876,2352,787,850,0], # 25 mid
[992,836,992,0,0,0,1004,824,1004,0,0,0], # 26 low
# ── Moon Red (rows 27-29) ──
[0,0,0,0,0,0,0,0,0,0,0,1785], # 27 high
[0,0,0,0,0,0,0,0,0,0,0,1298], # 28 mid
[0,0,0,0,0,0,0,0,0,0,0,743], # 29 low
# ── Mood Blue (rows 30-32) ──
[0,860,0,880,860,0,0,850,0,950,850,0], # 30 high
[0,750,0,750,750,0,0,739,0,739,739,0], # 31 mid
[0,0,0,0,754,0,0,0,0,0,700,0], # 32 low
# ── Red (rows 33-35) ──
[0,0,0,0,0,0,0,0,0,0,0,4095], # 33 high
[0,0,0,0,0,0,0,0,0,0,0,2169], # 34 mid
[0,0,0,0,0,0,0,0,0,0,0,1021], # 35 low
# ── Orange (rows 36-38) ──
[0,0,0,0,0,1520,0,0,0,0,0,3975], # 36 high
[0,0,0,0,0,970,0,0,568,0,0,2196], # 37 mid
[0,0,0,0,0,726,0,0,0,0,0,1265], # 38 low
# ── Yellow (rows 39-41) ──
[0,0,0,0,0,4095,0,0,0,0,0,2760], # 39 high
[0,0,0,0,0,1797,0,0,0,0,0,1773], # 40 mid
[0,0,0,0,0,795,0,0,0,0,0,837], # 41 low
# ── Green (rows 42-44) ──
[0,0,0,0,0,3200,0,0,0,0,0,0], # 42 high
[0,0,0,0,0,1772,0,0,0,0,0,0], # 43 mid
[0,0,0,0,0,771,0,0,0,0,0,0], # 44 low
# ── Blue (rows 45-47) ──
[1960,0,1960,2560,0,4095,2100,0,2100,2800,0,0], # 45 high
[1140,0,1010,2537,0,2594,1122,0,1122,1966,0,0], # 46 mid
[0,0,690,1339,0,1315,0,0,694,1442,0,0], # 47 low
# ── Indigo (rows 48-50) ──
[1527,3817,1527,2005,3536,0,1476,3947,1476,2069,3654,858],# 48 high
[1018,2655,1018,1367,2331,0,1018,2628,1018,1346,2315,0], # 49 mid
[0,924,0,1144,1261,0,0,873,0,1090,1202,987], # 50 low
# ── Purple (rows 51-53) ──
[700,1460,740,690,710,0,710,1490,750,705,740,4075], # 51 high
[0,0,0,809,1110,0,0,0,0,894,1143,2315], # 52 mid
[0,0,0,0,803,0,0,0,0,0,783,1043], # 53 low
# ── Rows 54-65: additional APK rows (not yet assigned to a named color) ──
[850,0,850,650,0,4095,860,0,860,680,0,3670], # 54
[0,0,0,819,0,2398,0,0,0,879,0,2473], # 55
[0,0,0,718,0,1149,635,0,635,0,600,1346], # 56
[800,0,800,830,0,4095,920,0,920,670,600,3160], # 57
[0,0,0,0,762,2652,0,0,0,701,701,2201], # 58
[0,0,0,735,0,1113,685,0,675,0,590,1246], # 59
[860,0,860,860,0,4095,1180,0,1180,0,0,3050], # 60
[0,0,0,0,825,2832,615,0,615,0,821,2216], # 61
[0,0,0,769,0,1171,700,0,693,0,613,1265], # 62
[1090,0,1090,750,0,4095,1385,0,1345,0,0,2980], # 63
[0,0,0,0,885,3674,0,0,0,1122,0,2483], # 64
[0,0,0,844,0,1360,668,0,668,657,647,1401], # 65
]

# ── Color regions ─────────────────────────────────────────────────────────────
# high_angle / low_angle match APK ColorRegion_value.java exactly.
COLOR_REGIONS = {
"Sky Blue": {"high": 24, "mid": 25, "low": 26, "high_angle": 91.0, "low_angle": 109.0},
"Ocean Blue 7": {"high": 21, "mid": 22, "low": 23, "high_angle": 110.0, "low_angle": 129.0},
"Ocean Blue 6": {"high": 18, "mid": 19, "low": 20, "high_angle": 130.0, "low_angle": 149.0},
"Ocean Blue 5": {"high": 15, "mid": 16, "low": 17, "high_angle": 150.0, "low_angle": 169.0},
"Ocean Blue 4": {"high": 12, "mid": 13, "low": 14, "high_angle": 170.0, "low_angle": 189.0},
"Ocean Blue 3": {"high": 9, "mid": 10, "low": 11, "high_angle": 190.0, "low_angle": 209.0},
"Ocean Blue 2": {"high": 6, "mid": 7, "low": 8, "high_angle": 210.0, "low_angle": 229.0},
"Ocean Blue 1": {"high": 3, "mid": 4, "low": 5, "high_angle": 230.0, "low_angle": 249.0},
"Deep Ocean Blue": {"high": 0, "mid": 1, "low": 2, "high_angle": 250.0, "low_angle": 267.0},
"Indigo": {"high": 48, "mid": 49, "low": 50, "high_angle": 270.0, "low_angle": 271.0},
"Purple": {"high": 51, "mid": 52, "low": 53, "high_angle": 285.0, "low_angle": 325.0},
"Red": {"high": 33, "mid": 34, "low": 35, "high_angle": 325.0, "low_angle": 339.0},
"Orange": {"high": 36, "mid": 37, "low": 38, "high_angle": 340.0, "low_angle": 359.0},
"Yellow": {"high": 39, "mid": 40, "low": 41, "high_angle": 15.0, "low_angle": 64.0},
"Green": {"high": 42, "mid": 43, "low": 44, "high_angle": 65.0, "low_angle": 110.0},
"Blue": {"high": 45, "mid": 46, "low": 47, "high_angle": 90.0, "low_angle": 90.0},
"Moon Red": {"high": 27, "mid": 28, "low": 29, "high_angle": 400.0, "low_angle": 400.0},
"Mood Blue": {"high": 30, "mid": 31, "low": 32, "high_angle": 401.0, "low_angle": 401.0},
}

# Case-insensitive alias lookup (normalise → canonical COLOR_REGIONS key)
_ALIASES = {
"sky blue": "Sky Blue",
"ocean blue 7": "Ocean Blue 7",
"ocean blue 6": "Ocean Blue 6",
"ocean blue 5": "Ocean Blue 5",
"ocean blue 4": "Ocean Blue 4",
"ocean blue 3": "Ocean Blue 3",
"ocean blue 2": "Ocean Blue 2",
"ocean blue 1": "Ocean Blue 1",
"deep ocean blue": "Deep Ocean Blue",
"deep blue": "Deep Ocean Blue",
"indigo": "Indigo",
"purple": "Purple",
"red": "Red",
"orange": "Orange",
"yellow": "Yellow",
"green": "Green",
"blue": "Blue",
"moon red": "Moon Red",
"mood blue": "Mood Blue",
"moon blue": "Mood Blue",
"cyan": "Sky Blue",
"ocean1": "Ocean Blue 1",
"ocean2": "Ocean Blue 2",
"ocean3": "Ocean Blue 3",
"ocean4": "Ocean Blue 4",
"ocean5": "Ocean Blue 5",
"ocean6": "Ocean Blue 6",
"ocean7": "Ocean Blue 7",
}


# ── Low-level helpers ─────────────────────────────────────────────────────────

def _log_error(msg):
try:
log.error(msg)
except Exception:
print(msg)


def _clamp(v, lo=0, hi=255):
v = int(v)
return lo if v < lo else (hi if v > hi else v)


def _clamp_u16(v):
return _clamp(v, 0, 4095)


def _u16le(v):
v = _clamp_u16(v)
return [v & 0xFF, (v >> 8) & 0xFF]


def _pct_to_device_byte(pct):
"""
Convert a user-facing intensity percentage (0-100) to a device byte (0-255).

Matches the inverse of APK getIntensityPercent():
percent = int(byte / 255.0 * 100.0 + 0.5) → byte = round(pct * 255 / 100)
"""
pct = _clamp(pct, 0, 100)
return _clamp(int(round(pct * 255 / 100)), 0, 255)


def _normalize_color(name):
key = str(name).strip().lower().replace("_", " ").replace("-", " ")
key = " ".join(key.split())
return _ALIASES.get(key)


def _checksum(data):
"""APK productCheckSum — one's complement of byte sum, mod 256."""
total = 0
for b in data:
total = (total + int(b)) & 0xFF
return (256 - total) & 0xFF


def _frame(body):
"""
APK getSentByte / mergerLenght / merge_checksum:
[START] [len=body+1] [body bytes…] [checksum] [STOP]
"""
msg = [len(body) + 1] + [int(b) & 0xFF for b in body]
chk = _checksum(msg)
return bytes([START_BYTE] + msg + [chk, STOP_BYTE])


# ── Command builders ──────────────────────────────────────────────────────────

def _cmd_power(state):
"""APK PowerCMD — [0x01, 0x00, 0x27, state]."""
return _frame([0x01, 0x00, CMD_POWER, _clamp(state, 0, 1)])


def _cmd_find_me():
"""APK Find_ME — [0x01, 0x00, 0x76]."""
return _frame([0x01, 0x00, CMD_FIND_ME])


def _cmd_set_all_channel(channels12, intensity_byte):
"""
APK SET_ALL_CHANNEL_INDEX:
[0x01, 0x00, 0x01, ch0_lo, ch0_hi, …, ch11_lo, ch11_hi, 0,0,0,0,0, intensity_byte]
channels12 — list of 12 u16 channel values (0-4095)
intensity_byte — raw device byte (0-255), used as the apply-flag
"""
vals = list(channels12[:12])
while len(vals) < 12:
vals.append(1)
body = [0x01, 0x00, CMD_SET_ALL_CHANNEL]
for v in vals:
body.extend(_u16le(v))
body.extend([0, 0, 0, 0, 0, _clamp(intensity_byte, 0, 255)])
return _frame(body)


# ── Calibration / channel computation ────────────────────────────────────────

def _angle_to_color_pair(angle):
"""
APK angleToColor() — maps angle (0-359) to the two adjacent colour names.
The returned pair is [colorA, colorB] where the actual colour sits between them.
"""
d = float(angle)
if 270.0 <= d < 285.0:
return ("Indigo", "Purple")
if 285.0 <= d < 325.0:
return ("Purple", "Red")
if 325.0 <= d < 340.0:
return ("Red", "Orange")
if (340.0 <= d < 360.0) or (0.0 <= d < 15.0):
return ("Orange", "Yellow")
if 15.0 <= d < 65.0:
return ("Yellow", "Green")
if 65.0 <= d < 90.0:
return ("Green", "Blue")
if d == 90.0:
return ("Blue", "Sky Blue")
if 90.0 < d < 110.0:
return ("Sky Blue", "Ocean Blue 7")
if 110.0 <= d < 130.0:
return ("Ocean Blue 7", "Ocean Blue 6")
if 130.0 <= d < 150.0:
return ("Ocean Blue 6", "Ocean Blue 5")
if 150.0 <= d < 170.0:
return ("Ocean Blue 5", "Ocean Blue 4")
if 170.0 <= d < 190.0:
return ("Ocean Blue 4", "Ocean Blue 3")
if 190.0 <= d < 210.0:
return ("Ocean Blue 3", "Ocean Blue 2")
if 210.0 <= d < 230.0:
return ("Ocean Blue 2", "Ocean Blue 1")
if 230.0 <= d < 250.0:
return ("Ocean Blue 1", "Deep Ocean Blue")
if 250.0 <= d < 270.0:
return ("Deep Ocean Blue", "Indigo")
raise ValueError("angle out of range: %s" % angle)


def _get_channel_index(color_name, intensity_byte):
"""
APK getTouchChanelindex().

Interpolates calibration values for a single named colour at the given
intensity device byte (0-255).

Replicates the APK's exact arithmetic:
• upper range (d3 > 0.5): float division (high-mid) / 50.0
• lower range (d3 < 0.5): integer division (mid-low) // 50
(The asymmetry is present in the APK's decompiled Java source.)
"""
region = COLOR_REGIONS.get(color_name)
if region is None:
return [1] * 12

d3 = float(intensity_byte) / 255.0

if d3 == 1.0:
return list(CALIBRATION[region["high"]])

if d3 > 0.5 and d3 < 1.0:
high = CALIBRATION[region["high"]]
mid = CALIBRATION[region["mid"]]
out = []
for i in range(12):
slope = (high - mid) / 50.0 # float division (matches APK)
out.append(int(round(slope * (d3 - 0.5) * 100.0)) + mid)
return out

if d3 < 0.5 and d3 > 0.0:
mid = CALIBRATION[region["mid"]]
low = CALIBRATION[region["low"]]
out = []
for i in range(12):
slope = (mid - low) // 50 # integer division (matches APK)
out.append(int(round(slope * d3 * 100.0)) + low)
return out

if d3 == 0.0:
return [1] * 12

# fallback: mid row
return list(CALIBRATION[region["mid"]])


def _get_channel_index_blend(angle, intensity_byte, color_a, color_b):
"""
APK getTouchChanelindex_271_90().

Used for angles <= 90° or > 269° where the colour wheel wraps through
warm colours. Blends between two adjacent colour regions.
"""
region_a = COLOR_REGIONS[color_a]
ch_a = _get_channel_index(color_a, intensity_byte)
ch_b = _get_channel_index(color_b, intensity_byte)

d = float(angle)
d3 = d + 360.0 if d < 15.0 else d # wrap angle for orange→yellow span

span = abs(region_a["high_angle"] - region_a["low_angle"])
ratio = abs(region_a["high_angle"] - d3) / span if span > 0.0 else 0.0
ratio = max(0.0, min(1.0, ratio))

out = []
for i in range(12):
va = int((1.0 - ratio) * ch_a)
vb = int(ratio * ch_b)
v = va + vb
out.append(v if v >= 1 else 1)
return out


def _check_calibration_650(channels12):
"""
APK checkCalibrationData650().

Pairs: (0,6), (1,7), (2,8), (3,9).
If either channel in a pair is < 650, both are set to 1 (minimum signal).
Channels 4/5/10/11 are NOT paired here (they carry warm/UV/red LEDs).
"""
ch = list(channels12)
for a, b in ((0, 6), (1, 7), (2, 8), (3, 9)):
if ch[a] < 650 or ch < 650:
ch[a] = 1
ch = 1
return ch


def _channels_for_angle(angle, intensity_byte):
"""
Compute the 12-channel calibration array for a given angle and intensity.
Mirrors the APK touchEvent() logic.
"""
angle = int(angle) % 360

if intensity_byte <= 0:
return [1] * 12

color_a, color_b = _angle_to_color_pair(float(angle))

# Angles in the warm / wrap region use the blending path
if angle <= 90 or angle > 269:
channels = _get_channel_index_blend(angle, intensity_byte, color_a, color_b)
else:
channels = _get_channel_index(color_a, intensity_byte)

return _check_calibration_650(channels)


# ── Networking ────────────────────────────────────────────────────────────────

@pyscript_executor
def _send(host, payload, timeout=TIMEOUT):
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
sock.settimeout(timeout)
sock.connect((host, PORT))
sock.sendall(payload)
return True
finally:
try:
sock.close()
except Exception:
pass


def _target_hosts(host):
if host is None or host == "":
return list(LIGHT_HOSTS)
if isinstance(host, str):
return [host]
return list(host)


def _send_all(payload, host=None):
results = {}
for h in _target_hosts(host):
try:
_send(h, payload)
results[h] = True
except Exception as e:
results[h] = False
_log_error("AP700 %s error: %s" % (h, e))
return results


# ── Public services ───────────────────────────────────────────────────────────

@service
def ap700_on(host=None, **kwargs):
"""Turn the AP700 on."""
return _send_all(_cmd_power(1), host=host)


@service
def ap700_off(host=None, **kwargs):
"""Turn the AP700 off."""
return _send_all(_cmd_power(0), host=host)


@service
def ap700_power(state=1, host=None, **kwargs):
"""Set power state: state=1 (on) or state=0 (off)."""
return _send_all(_cmd_power(state), host=host)


@service
def ap700_set_intensity(intensity=100, host=None, **kwargs):
"""
Set brightness only, keeping the current colour wheel angle at Ocean Blue 3
(the default mid-spectrum colour). intensity=0..100.
"""
db = _pct_to_device_byte(intensity)
channels = _channels_for_angle(200, db) # 200° → Ocean Blue 3
return _send_all(_cmd_set_all_channel(channels, db), host=host)


@service
def ap700_set_angle(angle=200, intensity=100, host=None, **kwargs):
"""
Set colour by wheel angle (0-359) and intensity (0-100).

Angle guide (APK colour wheel):
90° = Blue 200° = Ocean Blue 3 250° = Deep Ocean Blue
110° = Ocean Blue 7 270° = Indigo 285° = Purple
130° = Ocean Blue 6 325° = Red 340° = Orange
... 15° = Yellow 65° = Green
"""
angle = int(angle) % 360
db = _pct_to_device_byte(intensity)
channels = _channels_for_angle(angle, db)
return _send_all(_cmd_set_all_channel(channels, db), host=host)


@service
def ap700_set_color(color_name="Ocean Blue 3", intensity=100, host=None, **kwargs):
"""
Set colour by name. intensity=0..100.

Supported names (case-insensitive):
Sky Blue, Ocean Blue 1-7, Deep Ocean Blue, Indigo, Purple,
Red, Orange, Yellow, Green, Blue, Moon Red, Mood Blue
Aliases: cyan, deep blue, ocean1-7, moon blue
"""
canonical = _normalize_color(color_name)
if canonical is None:
_log_error("AP700: unknown color_name: %s" % color_name)
raise ValueError("Unknown color_name: %s" % color_name)

db = _pct_to_device_byte(intensity)

# Moon Red and Mood Blue are single-row specials — use their mid row directly
if canonical == "Moon Red":
ch = list(CALIBRATION[28]) # row 28 = Moon Red mid
factor = db / 255.0
ch = [_clamp_u16(int(round(v * factor))) if v > 0 else 1 for v in ch]
return _send_all(_cmd_set_all_channel(ch, db), host=host)

if canonical == "Mood Blue":
ch = list(CALIBRATION[31]) # row 31 = Mood Blue mid
factor = db / 255.0
ch = [_clamp_u16(int(round(v * factor))) if v > 0 else 1 for v in ch]
return _send_all(_cmd_set_all_channel(ch, db), host=host)

# All other colours: use the centre of the region's angle span
region = COLOR_REGIONS[canonical]
angle = int(round((region["high_angle"] + region["low_angle"]) / 2.0)) % 360
if canonical == "Blue":
angle = 90
if canonical == "Indigo":
angle = 270

channels = _channels_for_angle(angle, db)
return _send_all(_cmd_set_all_channel(channels, db), host=host)


@service
def ap700_set_channels(channels=None, intensity=100, host=None, **kwargs):
"""
Send raw 12-channel values directly.
channels — list of 12 integers (0-4095) or comma-separated string.
intensity — 0-100, controls the apply-flag byte; defaults to 100.
"""
if channels is None:
raise ValueError("channels is required")
if isinstance(channels, str):
vals = [int(x.strip(), 0) for x in channels.split(",") if x.strip()]
else:
vals = [int(v) for v in channels]
if len(vals) != 12:
raise ValueError("channels must contain exactly 12 values")
db = _pct_to_device_byte(intensity)
return _send_all(_cmd_set_all_channel(vals, db), host=host)


@service
def ap700_find_me(host=None, **kwargs):
"""Flash the light so you can identify it."""
return _send_all(_cmd_find_me(), host=host)


@service
def ap700_send_hex(hex_payload="", host=None, **kwargs):
"""Send a raw hex packet directly (for debugging)."""
s = str(hex_payload).strip().replace(" ", "").replace("0x", "")
if not s:
raise ValueError("hex_payload is required")
if len(s) % 2 != 0:
raise ValueError("hex_payload must have an even number of hex digits")
return _send_all(bytes.fromhex(s), host=host)

 

TOP 10 Trending Threads

HOW DO YOU ADJUST YOUR CUC AS ALGAE DISAPPEARS?

  • Capture and re-home CUC

    Votes: 9 7.6%
  • Increase white light/hours in tank to spur algae growth to feed CUC

    Votes: 8 6.8%
  • Feed nori to support CUC

    Votes: 39 33.1%
  • Feed herbivore pellets to support CUC

    Votes: 41 34.7%
  • Allow attrition to balance CUC and algae

    Votes: 51 43.2%
  • Provide macro algae to feed CUC

    Votes: 8 6.8%
  • Introduce CUC predators

    Votes: 1 0.8%
  • Other (please explain)

    Votes: 12 10.2%
Back
Top