← Notes

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:

ValueMeaningRange
cINTCloud intensity0–3
cMOTCloud motion0–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.