Notes from a Juwel lighting controller
Mapping an undocumented aquarium-lighting interface from browser requests to binary profiles.
One of my favourite kinds of hardware is the appliance that quietly contains a complete little computer. A Juwel aquarium-lighting controller turned out to be exactly that: a web interface, HTTP endpoints, JavaScript state, and downloadable binary profiles hiding behind a straightforward consumer product.
I wanted to understand how it represented a day of lighting and whether I could control it without depending on its browser interface. These are field notes from that exploration rather than a promise of a stable public API. Firmware changes may alter any of this.
Start with the browser
The controller exposes much of its current configuration through JavaScript. A request to:
GET /statusvars.js
returns assignments describing the active profile, simulated time, channel brightness, and scheduled points. A shortened example looks like:
profile = 'Profile 2';
brightness = [0, 5, 0, 0];
times = [0, 750, 780, 870, 1350, 1380, 1410, 1439];
CH1 = [0, 0, 30, 100, 100, 30, 0, 0];
CH2 = [5, 5, 15, 50, 50, 20, 5, 5];
The four channels correspond to white, blue, green, and red light. Values are percentages from 0 to 100. Schedule times are minutes after midnight, which keeps the representation pleasantly simple: 750 is 12:30 and 1439 is 23:59.
Two additional values describe the simulated cloud effect:
| Value | Meaning | Range |
|---|---|---|
cINT | Cloud intensity | 0–3 |
cMOT | Cloud motion | 0–3 |
This response alone is enough to reconstruct the brightness curves for a profile.
Watching changes
The next step was to make one change in the browser and inspect the resulting request. Colour updates are submitted as form data:
POST /color
Content-Type: application/x-www-form-urlencoded
action=5&ch1=0&ch2=28&ch3=0&ch4=0&cMOT=0&cINT=0
The controller replies with compact JSON containing the action and current channel state. Other action numbers appear to represent changes to cloud settings, time points, and live mode. I treat these values as observations, not a specification; several still need controlled tests before I would automate them.
The selected profile can be downloaded directly:
GET /profile.bin?p=2
Profile names and selection state are also exposed as JavaScript at /wpvars.js.
Looking inside a profile
The downloaded profile is a sequence of fixed-size records. My first useful interpretation was a 16-byte block containing:
- A little-endian 32-bit time in minutes after midnight
- One byte each for cloud motion and intensity
- Four little-endian 16-bit channel values
- Two bytes still to be accounted for
A minimal Python reader for that hypothesis is:
from dataclasses import dataclass
from pathlib import Path
import struct
@dataclass
class Point:
minute: int
cloud_motion: int
cloud_intensity: int
channels: tuple[int, int, int, int]
def read_profile(path: Path) -> list[Point]:
points = []
with path.open("rb") as profile:
while block := profile.read(16):
if len(block) != 16:
raise ValueError("truncated profile record")
minute, motion, intensity, *channels = struct.unpack(
"<IBB4H2x", block
)
points.append(Point(minute, motion, intensity, tuple(channels)))
return points
The 2x deliberately marks the final two bytes as unknown padding rather than pretending I understand them. That distinction matters when reverse engineering: a parser that produces plausible output is evidence, not proof.
What I would investigate next
Reading the state is the easy half. Writing complete profiles safely needs more work:
- Capture profiles that differ by exactly one setting and compare their bytes.
- Determine whether the unexplained bytes are flags, padding, or a checksum.
- Test every observed action value independently.
- Record how the controller behaves on malformed or incomplete input.
- Put automation behind strict bounds so an experiment cannot drive a channel outside its normal range.
This is why I enjoy poking at embedded web interfaces. A status page becomes a protocol, a protocol becomes a data format, and an ordinary aquarium controller turns into a small exercise in experimental method.