This document describes the complete hardware and software of the roof hatch controller in enough detail that work (testing, debugging, or extensions) can resume immediately in a new chat, together with the source code. Status: after the per-channel configuration redesign (servo tester, mounting angles, time-based S-curve ramp, internationalisation). All previous concept documents (konzept_grad_umstellung.md, konzept_config_redesign.md) have been incorporated into this document and removed – this document is the sole reference. This is the English translation of implementierungsdoku.md; the German version remains the primary/original document.
Control of up to 4 roof hatches via a web interface (phone, tablet, desktop). Each hatch has a powerful servo for movement (lever servo) and a second servo for a mechanical locking brake (latch servo). Runs on a Seeed XIAO ESP32-S3 with MicroPython. Additionally, a servo tester output pair for maintenance and calibration of servos outside the installed hatches.
| Component | Function |
|---|---|
| Seeed XIAO ESP32-S3 | Microcontroller, Wi-Fi |
| Seeed Expansion Board Base for XIAO | I²C hub, OLED (SSD1306 128×64), micro-SD slot, RTC (PCF8563), user button |
| Grove 4-Channel SPDT Relay | Switches servo power supply (per hatch) |
| Grove 16-Channel PWM Driver (PCA9685) | PWM signals for all servos (8 hatch channels + 2 servo tester channels) |
| MCP23017 (breakout) | I²C GPIO expander for local control panel – deferred |
| Device | Address | Notes |
|---|---|---|
| SSD1306 OLED | 0x3C | |
| PCA9685 PWM driver | 0x7F | All address pads pulled high; therefore never appears in i2c.scan() results (outside the 0x08–0x77 scan range), but is directly addressable. See section 15. |
| Grove 4-ch. relay | 0x11 | The board's I²C address can be reconfigured via software (see https://wiki.seeedstudio.com/Grove-4-Channel_SPDT_Relay/). |
| RTC PCF8563 | 0x51 | Present on the expansion board, currently unused (see 17.2) |
| MCP23017 (deferred) | 0x20 |
GPIO pins on the expansion board (XIAO ESP32-S3), as of hw_config.py:
| Signal | GPIO |
|---|---|
| I²C SDA | 5 |
| I²C SCL | 6 |
| User button | 2 (active low, pull-up) |
I2C_FREQ = 1_000_000 (1MHz) – raised from the originally conservative default of 400kHz, after a successful stability test of all four bus participants at this rate (including repeated relay switching, the most likely failure candidate, since the Grove relay board implements its I²C slave function in software on an STM32F030 rather than in silicon).
The I²C specification (NXP/UM10204) defines named modes with fixed upper limits (not mandatory intermediate steps, but the practically relevant thresholds for electrical requirements and component specification):
| Mode | Max. clock rate |
|---|---|
| Standard Mode | 100 kHz |
| Fast Mode | 400 kHz |
| Fast Mode Plus (Fm+) | 1 MHz |
| High Speed Mode (Hs-Mode) | 3.4 MHz |
| Ultra Fast Mode (UFm) | 5 MHz |
1_000_000 sits exactly at the upper Fm+ limit – no safety margin above it, but within the specified limits of all chips involved (ESP32-S3, PCA9685 and SSD1306 all support Fm+ or higher per their datasheets). High Speed Mode (3.4MHz) is not an option here, since it requires a special bus-master protocol element (master code + switchover) that neither the ESP32 nor the attached devices support.
No per-device clock switching: The clock rate is generated by the master (ESP32) for the entire shared clock line, not per device – unlike the PCA9685 PWM frequency (see 9.11), there is no known candidate here that would justify a runtime switch.
- One relay per hatch (NO contact): switches the shared +8V line for both servos of that hatch (lever and latch servo are always powered together).
- Servo tester outputs are permanently powered, with no relay of their own – intended for servos removed from an installed hatch and tested individually on the bench. Since a tester channel and a hatch channel are never under load at the same time (the tester is only ever used with servos removed from the hatches), this is uncritical.
- GND is common to all servos throughout.
- Each servo has its own PWM line from the PCA9685.
- Power supply: 3A / 8V – sufficient for one hatch at a time (guaranteed in software, see section 11).
| Channel | Function |
|---|---|
| 0 | Hatch 1 – lever servo |
| 1 | Hatch 1 – latch servo |
| 2 | Hatch 2 – lever servo |
| 3 | Hatch 2 – latch servo |
| 4 | Hatch 3 – lever servo |
| 5 | Hatch 3 – latch servo |
| 6 | Hatch 4 – lever servo |
| 7 | Hatch 4 – latch servo |
| 8 | Servo tester – lever servo output |
| 9 | Servo tester – latch servo output |
Configured in hw_config.py: PCA9685_CH = {0: (0,1), 1: (2,3), 2: (4,5), 3: (6,7)} (hatch channels) and TESTER_CH = (8, 9) (servo tester, its own constant pair rather than a dict entry, since it only exists once).
| Relay channel | Function |
|---|---|
| 0 | +8V for hatch 1's servos |
| 1 | +8V for hatch 2's servos |
| 2 | +8V for hatch 3's servos |
| 3 | +8V for hatch 4's servos |
Configured as RELAY_CH = {0: 0, 1: 1, 2: 2, 3: 3} in hw_config.py. The servo tester has no relay entry (see 2.3).
- A ratchet system of interlocking teeth takes over the holding torque when the servos are unpowered.
- The hatch gear train is not self-locking → without the brake and power, the hatch would close under its own weight.
- The brake has two regular operating positions (
open_deg/close_deg), individually configurable per hatch (the brake mechanism may sit slightly differently on each hatch), plus its own mounting angle for maintenance purposes (see 3.7). - The brake engages under spring load; the servo pulls it open against the spring.
- The latch servo cannot be damaged by over-travel (cam principle).
- The latch servo now ramps just like the lever servo (its own configurable
speed_deg_per_s) – it originally jumped directly between the two positions in a single step; this was changed because it let two fixed "hopefully long enough" wait times be replaced by actually waiting for the ramp to finish (see 9.11).
Hobby servos (here roughly 4.5 Nm / 2–3 A stall current) are designed for intermittent duty. Continuous load leads to overheating. Therefore: servos are only powered during the movement sequence (relay on), unpowered afterwards. The mechanical brake takes over the holding torque. The servo tester is the only exception (permanently powered, see 2.3) – but no loaded servos are ever connected there.
Every servo (lever and latch, per hatch, plus both servo tester outputs) has its own individually configurable range instead of fixed values:
range_deg: total range, toggle value 180°, 270°, or 360°direction: rotation direction,"pos"or"neg"(default"neg") – determines whether 0° corresponds to the minimum or maximum pulse width (see 9.2b)pulse_min_us/pulse_max_us: pulse-width limits in µs, configurable within 500–4000µs (covers standard as well as long-throw servos)
Absolute hardware limits for this in hw_config.py: SERVO_MIN_US = 500, SERVO_MAX_US = 4000 – every configured pulse_min_us/pulse_max_us value must lie within this window, regardless of the chosen range_deg.
High-torque digital servos run an internal initialisation routine on power-up and briefly move at maximum speed towards centre position – regardless of the PWM signal present. The movement sequence in task_control.py still contains a workaround for this: step_preset_servos outputs the actual position before relay-on (lever servo at 0µs = no signal, so the controller doesn't see a signal during its own boot process), step_preset_delay gives the controller time to boot before the first valid signal is applied. Details and background: xiao_esp32s3_erfahrungen.md section 12 (German only).
Historical note – wiggle removed: In addition to the preset delay, there used to be a "wiggle" (lever servo alternated briefly by ±10µs around the target position, 5 cycles) intended to additionally absorb the same power-on jerk. Since the movement ramp now follows an S-curve (see 3.5/9.11) and every move already starts at zero speed, the effect wiggle was meant to prevent no longer occurred – the mechanism was removed without replacement.
Both servos of a hatch (and both servo tester outputs) do not move linearly but follow a smoothstep profile (3f² − 2f³, f = motion progress 0…1): position and its first derivative (velocity) are exactly zero and continuous at the start and end of the motion – no jerk (discontinuity in acceleration) at either end. See section 9.11 for implementation details.
Important consequence for speed_deg_per_s: with a smoothstep profile, the peak velocity (reached at the midpoint of the motion) is 1.5 times the average velocity. The configured speed_deg_per_s is deliberately defined as the peak velocity (not the average) – the motion duration is therefore internally extended by a factor of 1.5 compared to a linear ramp of the same peak speed, so that the configured speed limit (see 3.6) is never exceeded at any point in the motion.
The same allowed range for speed_deg_per_s applies to every servo (lever and latch servo of all 4 hatches, both servo tester outputs): 30–180°/s (SPEED_MIN_DEG_PER_S/SPEED_MAX_DEG_PER_S in hw_config.py).
In addition to the regular detent positions (lever servo) or open/close positions (latch servo), each of the two servos per hatch has its own, separately configurable mounting angle (mount_angle_deg). Purpose: being able to move to an angle convenient for physical mounting, independent of the operationally meaningful detent positions.
Important warning (must be included in the end-user documentation): The lever servo's mounting angle may be a position that does not fit the brake's tooth pattern and therefore cannot be held under load. The mounting angle must therefore only be driven to after the load (the window/hatch) has been physically disconnected from the lever beforehand. In software, the mounting angle is treated like any regular target position (including the full brake-open/move/brake-close sequence) – the software cannot and does not enforce this warning itself.
MicroPython on the XIAO ESP32-S3.
Rationale: uasyncio for cooperative multitasking, microdot as an async web server, built-in ujson, stable I²C libraries.
External libraries (via mip, managed through requirements.txt):
ssd1306– OLED drivermicrodot(viagithub:miguelgrinberg/microdot) – web server
Additionally manually installed libraries (not available via mip, see 6.2/6.2b):
uQR– QR code generationwriter.py(Peter Hinch, MIT) +font_hatch_status.py– larger display font
Custom drivers in the project:
shared/pca9685.py– PCA9685 driver (hand-written, considerably smaller than the full PyPI package)shared/grove_relay_4chn_spdt.py– Grove relay driver (no MicroPython package available)shared/servo_units.py– degree↔µs conversion (see 9.2b)
roof_hatch_controller/
├── designkonzept.md # original design concept (German, superseded, historical)
├── docs/
│ ├── implementierungsdoku.md # this document (German, primary)
│ └── implementation.md # this document (English translation)
├── requirements.txt # external MicroPython packages
├── upload.sh # deploy script
├── pyrightconfig.json # VSCode/Pyright: extraPaths: ["source"]
├── lib/ # external libraries (via mip or manually installed)
│ ├── microdot.py
│ ├── ssd1306.py
│ ├── uQR.py # manually installed, see 6.2
│ ├── writer.py # manually installed, see 6.2b (Peter Hinch, MIT)
│ └── font_hatch_status.py # manually generated, see 6.2b (14px font for task_display.py)
└── source/ # copied 1:1 to the device
├── main.py # entry point, also contains the early button check for AP_MODE
├── config.json # device configuration (adjust default values!)
├── config.json.template # conservative template for config_migration.py
├── test_qr.py # historical, standalone QR test script (see 15)
├── app/
│ ├── __init__.py
│ ├── hw_config.py # hardware constants
│ ├── hw_init.py # hardware initialisation
│ ├── state.py # config.json / state.json management
│ ├── config_migration.py # config.json creation/migration at boot
│ ├── failsafe.py # fail-safe sequence
│ ├── fatal_error.py # central handler for fatal boot errors
│ ├── wifi.py # Wi-Fi management
│ ├── session.py # config session/QR state
│ ├── task_watchdog.py # hardware WDT feeder task
│ ├── task_control.py # control loop task
│ ├── task_button.py # button monitor task
│ ├── task_display.py # OLED update task
│ └── task_webserver.py # microdot web server task
├── shared/
│ ├── __init__.py
│ ├── pca9685.py # PCA9685 driver
│ ├── grove_relay_4chn_spdt.py # Grove relay driver
│ └── servo_units.py # degree <-> µs conversion (deg_to_us/us_to_deg)
└── webpages/
├── index.html # control web page
├── config.html # configuration web page
├── ap_setup.html # Wi-Fi configuration (AP mode)
└── static/
├── bootstrap.min.css
├── bootstrap.bundle.min.js
└── alpine.min.js
Note on boot.py: An earlier planned separate boot.py for early AP-mode detection was never implemented – the logic (_button_pressed(), setting AP_MODE) sits directly at the top of main.py, before all other imports (see 8/9.1).
Same structure, but without the source/ prefix. Root / corresponds to source/.
Additionally on flash (not in the repo, created at runtime):
/wifi.json– Wi-Fi credentials (via AP mode or manually)/state.json– last known servo state (created automatically on first boot)
ssd1306
github:miguelgrinberg/microdot
upload.sh checks each entry to see whether the file/directory already exists in lib/ and installs it if needed.
uQR is not in the micropython.org package index. Download it manually, once:
https://github.com/JASchilz/uQR→uQR.py→ Raw button → save aslib/uQR.pyupload.shthen copieslib/to the device automatically
Important: lib/ lives at the project root (roof_hatch_controller/lib/), not under source/lib/ – upload.sh uses LIB_DIR="./lib" relative to the script's execution directory (project root). Files accidentally placed under source/lib/ will not be found by upload.sh and therefore not uploaded.
For the larger font in task_display.py (see 9.13), two further files not installable via mip are needed, also under lib/ (project root):
writer.py: taken unchanged fromhttps://github.com/peterhinch/micropython-font-to-py(folderwriter/writer.py, MIT license) →lib/writer.pyfont_hatch_status.py: generated with the PC toolfont_to_py.pyfrom the same repository:Result: monospace, 14px character height, 8px character width, character set 32–126 (ASCII), roughly 8.3 KB. Place the file atpython3 font_to_py.py DejaVuSansMono.ttf 14 font_hatch_status.py -f -s 32 -l 126
lib/font_hatch_status.py.upload.shthen copieslib/to the device automatically (likeuQR.py)
If a different font/size is needed: re-run font_to_py.py with a different height value or a different .ttf file. Adjust the line grid in task_display.py (_LINE_HEIGHT_PX = 16) if the new character height no longer matches the current grid.
Configuration block at the top of the script:
UPLOAD_MODE:"usb"or"wifi"USB_PORT: e.g./dev/ttyACM0WIFI_IP: device IP for WebREPL
Sequence:
- Walk
requirements.txt→ fetch missing packages viamicropython -m mip install --target lib/ - Reset the device to get a clean REPL connection (
mpremote reset, with retry if needed) - Everything in a single mpremote session: delete target directories, upload
config.json.template,config.json(if present),app/,shared/,webpages/,lib/, thenmain.pylast - Final
mpremote reset→ the realmain.pystarts
mpremote reset is a genuine hardware reset (DTR/RTS line, like the reset button) – on the next boot it loads all Python modules fresh from flash. Important limitation: the PCA9685 itself has its own power supply and remains continuously powered across a plain ESP32 reset – its PRE_SCALE register (PWM frequency) and internal state survive an mpremote reset unchanged. After changes to PWM-/timing-related code, an additional full power cycle (USB unplug/replug) is therefore recommended, which also resets the PCA9685's supply.
config.json.template is always uploaded; config.json only if present in the source/ directory.
Wi-Fi upload (WebREPL): enable once, via USB:
import webrepl_setupAfter that, possible via UPLOAD_MODE="wifi".
{
"extraPaths": ["source"]
}Lets VSCode/Pyright resolve imports correctly (without a source. prefix).
cd source/webpages/static
curl -L https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css -o bootstrap.min.css
curl -L https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js -o bootstrap.bundle.min.js
curl -L https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js -o alpine.min.jsMust be copied to the device before first boot (done automatically by upload.sh from source/config.json). All positions, hard stops, speeds and mounting angles are given in degrees or degrees/s, not µs. The conversion to µs for PCA9685 output is handled by shared/servo_units.py (deg_to_us()), exclusively inside task_control.py, immediately before driving the hardware.
Central architectural decision: every servo parameter (range, direction, speed, pulse widths, hard stops) is configurable per channel – unlike an earlier intermediate version, there are no shared/global servo parameters for all 4 hatches anymore. The only exception is the servo tester (global.servo_tester), which has its own, internally global parameter set, since it is only a single additional channel pair.
{
"global": {
"servo_tester": {
"pwm_freq_hz": 50,
"hatch_servo": {
"range_deg": 270, "direction": "neg", "speed_deg_per_s": 30,
"pulse_min_us": 500, "pulse_max_us": 2500,
"hard_stop_min_deg": 0, "hard_stop_max_deg": 270,
"test_angle_a_deg": 0, "test_angle_b_deg": 90
},
"brake_servo": {
"range_deg": 180, "direction": "neg", "speed_deg_per_s": 30,
"pulse_min_us": 500, "pulse_max_us": 2500,
"hard_stop_min_deg": 0, "hard_stop_max_deg": 180,
"test_angle_a_deg": 10, "test_angle_b_deg": 170
}
}
},
"hatches": [
{
"name": "Hatch 1",
"short_name": "Hatch 1",
"pwm_freq_hz": 50,
"hatch_servo": {
"range_deg": 270, "direction": "neg", "speed_deg_per_s": 30,
"pulse_min_us": 500, "pulse_max_us": 2500,
"hard_stop_min_deg": 120, "hard_stop_max_deg": 150,
"mount_angle_deg": 120
},
"brake_servo": {
"range_deg": 180, "direction": "neg", "speed_deg_per_s": 30,
"pulse_min_us": 500, "pulse_max_us": 2500,
"hard_stop_min_deg": 0, "hard_stop_max_deg": 180,
"mount_angle_deg": 90, "open_deg": 85, "close_deg": 95
},
"positions": [
{"hatch_deg": 120}, {"hatch_deg": 135}, {"hatch_deg": 150}
]
}
]
}Note: the internal field names remain hatch_servo/brake_servo in the JSON schema (matching the code), even though the web UI and this document use the terms "lever servo"/"latch servo" for them (see 9.2b and 10.2 for the naming rationale).
Field reference per servo block (hatch_servo/brake_servo, both per hatch and in global.servo_tester):
| Field | Meaning | Allowed range |
|---|---|---|
range_deg |
Total servo travel | {180, 270, 360} |
direction |
Rotation direction: "neg" (0° → pulse_max_us) or "pos" (0° → pulse_min_us) |
{"pos", "neg"}, default "neg" |
speed_deg_per_s |
Peak speed (see 3.5) | 30–180 |
pulse_min_us/pulse_max_us |
Pulse-width limits in µs | 500–4000, min < max |
hard_stop_min_deg/hard_stop_max_deg |
Software end stops in degrees – never exceeded | 0…range_deg, min < max |
Additional fields only in hatch_servo (per hatch):
mount_angle_deg: mounting angle (see 3.7), must lie within[hard_stop_min_deg, hard_stop_max_deg]
Additional fields only in brake_servo (per hatch):
mount_angle_deg: latch servo mounting angleopen_deg/close_deg: regular operating positions of the brake- all three must lie within
[hard_stop_min_deg, hard_stop_max_deg]of this block
Additional fields only in global.servo_tester.hatch_servo/brake_servo:
test_angle_a_deg/test_angle_b_deg: the two freely chosen test angles between which the respective tester channel can be moved back and forth; must lie within its own hard stops
pwm_freq_hz (range roughly 50–400Hz, see PWM_FREQ_MIN_HZ/PWM_FREQ_MAX_HZ) lives per hatch (not per servo!), plus once for global.servo_tester – rationale in 9.11 (PCA9685 frequency architecture).
positions (lever servo only, per hatch): list [{"hatch_deg": N}, ...], at least 2 entries. positions[0] is always the closed end stop ("ZU"/closed), positions[-1] is always the open end stop ("AUF"/open) – these two always stay at the first/last position of the list when intermediate positions are inserted/removed (see 10.2).
config.json.template in the source/ directory serves as a conservative template. config_migration.py uses it at boot to fill in missing or renamed fields automatically (see 9.16).
Removed (from earlier config iterations, no longer part of the schema): global hatch_servo/brake_servo blocks (now per hatch), SERVO_DIRECTION_REVERSED as a fixed code constant (now a direction field per servo), HATCH_STEP_US/fixed step size (now a time-based S-curve ramp, see 9.11), delay_after_brake_change_cmd_ms/delay_after_move_ms (replaced by actually waiting for the ramp to finish).
{"ssid": "MyNetwork", "password": "MyPassword"}Written via AP mode. Lives on internal flash, not on SD. Not in the Git repo.
{
"hatches": [
{"hatch_deg": 0, "brake_deg": 170, "target_deg": 0}
],
"servo_tester": {"hatch_deg": 0, "brake_deg": 0}
}Consistently in degrees, like config.json. Conversion to µs happens only locally in task_control.py, immediately before driving the PCA9685.
hatches[i].hatch_deg: last confirmed actual angle of the lever servo in degrees, orFAILSAFE_DEG(-1) if the position is unknown (fail-safe state or power loss mid-move).hatches[i].brake_deg: last confirmed actual angle of the latch servo in degrees. Always a real angle, neverFAILSAFE_DEG.hatches[i].target_deg: last commanded target angle in degrees, orFAILSAFE_DEG(-1) in the passive fail-safe rest state. Persisting this makes a target/actual mismatch after a power loss detectable and lets it be automatically resumed on the next boot.servo_tester.hatch_deg/brake_deg: last known actual angles of the two tester outputs, always real angles, no target value (the tester never moves on its own) – kept only so a test move ramps from the correct starting angle after a reboot instead of a stale value.
Not present on the very first boot → state.py builds a default state from config.json: all hatches at hatch_servo.hard_stop_min_deg (lever), brake_servo.close_deg (brake, each hatch's own), target = actual. The servo tester starts at hard_stop_min_deg of both of its own servo blocks. A state.json missing the servo_tester key or any required field (including old, now-superseded schemas) is treated as invalid and also recreated.
Important: actual positions are output to the PCA9685 before every relay-on. At FAILSAFE_DEG, positions[0] is used as the lever-servo fallback, so no jerk occurs.
Stage 0: WDT reset detection
-> machine.reset_cause() == WDT_RESET?
-> yes: init_hw_failsafe() + fail_safe_close_all() before anything else
Stage 1: Initialise the display early (init_display_early)
-> status messages on the OLED from here on
Stage 1b: AP mode - if the button was held down at power-on (checked at
the very top of main.py, before all further imports, see 9.1)
-> start_ap_mode() + a minimal task_webserver() call with
empty but correctly shaped placeholders for config/state/
targets, then return (the device restarts after Wi-Fi
credentials are saved; this code path is never regularly left)
Stage 2: Initialise remaining hardware (init_hardware)
-> PCA9685, relay, button
-> error -> fatal_error()
Stage 2b: config_migration.migrate_config() -> ensures config.json
exists and is complete
Load config.json (load_config) -> error -> fatal_error()
Load state.json (load_state)
-> error -> create_default_state() + save_state()
On WDT reset: mark all hatches FAILSAFE_DEG (-1) in state
+ save_state() (the physical fail-safe move already ran in
Stage 0; the servo tester has no fail-safe concept and is
left untouched)
Stage 3: Wi-Fi
-> start_client_mode()
Stage 4: Build the targets dict from the persisted target (see 9.11
for the full structure with hatches/brake_only/tester)
uasyncio.gather() - start all tasks:
task_watchdog, task_display, task_control,
task_button, task_webserver
Runs right at the start of main.py, before all other imports (only gc, network, uasyncio, machine, Pin, time and app.hw_init are imported at this point):
def _button_pressed():
from app.hw_config import BTN_USER_PIN
btn = Pin(BTN_USER_PIN, Pin.IN, Pin.PULL_UP)
if btn.value() == 0:
time.sleep_ms(50)
return btn.value() == 0
return False
AP_MODE = _button_pressed()Reads the user button once with simple debouncing (50ms). AP_MODE then controls, in main() Stage 1b, whether the Wi-Fi setup mode is started instead of normal operation.
AP mode: hold the button down while powering on.
All hardware constants. No imports of hardware libraries – importable from anywhere.
Important constants:
I2C_SDA_PIN = 5
I2C_SCL_PIN = 6
I2C_FREQ = 1_000_000 # see 2.2b for the rationale
I2C_ADDR_OLED = 0x3C
I2C_ADDR_PCA9685 = 0x7F
I2C_ADDR_RELAY = 0x11
I2C_ADDR_RTC = 0x51
BTN_USER_PIN = 2
BTN_DEBOUNCE_MS = 50
BTN_CONFIG_UNLOCK_MS = 60_000 # 60s unlock window after button press
BTN_CONFIG_SESSION_MS = 3_600_000 # 1h session
BTN_LONG_PRESS_MS = 3_000 # short-vs-long press threshold
PCA9685_FREQ_HZ = 50 # boot default, see 9.11 for the runtime switch
SERVO_MIN_US = 500 # absolute hardware limit for pulse_min_us/pulse_max_us
SERVO_MAX_US = 4000 # of every servo, regardless of the configured range_deg
RANGE_DEG_OPTIONS = (180, 270, 360)
SERVO_RANGE_DEG_MAX = 360 # upper sanity bound for degree values in state.json,
# independent of whichever range_deg is configured
SPEED_MIN_DEG_PER_S = 30
SPEED_MAX_DEG_PER_S = 180
PWM_FREQ_MIN_HZ = 50
PWM_FREQ_MAX_HZ = 400
FAILSAFE_DEG = -1 # sentinel: position unknown (fail-safe state)
MIN_STEP_MS = 8 # practical minimum update rate of the movement ramp
# (see 9.11); actual interval used:
# max(MIN_STEP_MS, PWM period of the configured
# pwm_freq_hz) - the 8ms floor is only reached
# at pwm_freq_hz >= 125Hz
NUM_HATCHES = 4
PCA9685_CH = {0:(0,1), 1:(2,3), 2:(4,5), 3:(6,7)} # per hatch: (lever, latch)
TESTER_CH = (8, 9) # servo tester: (lever, latch)
RELAY_CH = {0:0, 1:1, 2:2, 3:3}
BRAKE_TRAVEL_MS = 500 # wait time for the brake servo in fail-safe
BRAKE_OPEN_US_FAILSAFE = 1200 # fallback value with no config.json (raw µs
# value, independent of the degree config -
# failsafe.py doesn't need a readable config.json)Pure conversion math, no hardware import, importable everywhere:
deg_to_us(deg, pulse_min_us, pulse_max_us, range_deg, direction) # -> int µs
us_to_deg(us, pulse_min_us, pulse_max_us, range_deg, direction) # -> float degreesdirection has been a parameter since the per-channel redesign, not a fixed code constant anymore (it used to be SERVO_DIRECTION_REVERSED in hw_config.py, uniform for all servos): "neg" means increasing pulse width corresponds to decreasing angle (0° → pulse_max_us); "pos" is the opposite (0° → pulse_min_us).
us_per_deg = (pulse_max_us - pulse_min_us) / range_deg
# direction == "neg":
us = pulse_max_us - deg * us_per_deg
# direction == "pos":
us = pulse_min_us + deg * us_per_degUsed exclusively in task_control.py, to convert the configuration/state held in degrees to µs immediately before driving the PCA9685. us_to_deg() exists as the symmetric counterpart, but is currently never called anywhere.
Hand-written driver, built directly from the datasheet. No external import needed.
API:
pwm = PCA9685(i2c, address=0x40)
pwm.freq(50) # set PWM frequency (chip-wide, all 16 channels)
pwm.set_pwm(ch, on, off) # raw 12-bit values
pwm.set_us(ch, us, freq_hz=50) # pulse width in µs
pwm.set_all_off() # all channels off (safe state)Critical implementation point – set_us()'s freq_hz parameter: the driver cannot read back the frequency actually programmed into the chip – set_us() converts microseconds to tick values based on the passed freq_hz argument (default 50). If freq_hz isn't passed explicitly, or doesn't match the value last set via freq(), set_us() silently computes a wrong pulse width – with no error of any kind. Every caller working with a frequency other than 50Hz must pass the same freq_hz on every single set_us() call that was previously set via freq(). task_control.py adheres to this strictly (see 9.11); failsafe.py forces 50Hz explicitly before every drive, because at the time the fail-safe sequence runs, it's unknown which frequency the chip was last running at (see 9.7).
Further implementation details:
- The ALLCALL bit is not set in
_reset()(setting it would make the PCA9685 also listen on the broadcast address 0x00 → interference with other I²C devices) - Prescaler formula correct per datasheet:
round(osc_clk / (4096 * hz)) - 1 - Sleep time after oscillator wake-up: 1 ms (datasheet: min. 500 µs)
Driver for the Grove 4-Channel SPDT Relay (STM32-based, I²C).
I²C protocol:
- Only relevant command:
CMD_CHANNEL_CTRL = 0x10+ state byte (bitmask: bit 0–3 = channel 1–4) - No hardware read-back possible → software state held in RAM
- Board designed for 5V logic; experience shows it also works at 3.3V; if not, use a level shifter
API (0-based channel indices):
relay = GroveRelay(i2c, address=0x11)
relay.on(channel) # energise relay (close NO contact -> servos powered)
relay.off(channel) # de-energise relay (open NO contact -> servos unpowered)
relay.all_off() # safe state on init
relay.all_on() # caution: exceeds PSU rating
relay.is_on(channel) # query software state
relay.get_firmware_version() # read FW byte from the boardThree initialisation functions:
init_hw_failsafe() # I2C + PCA9685 + relay only (no display/button)
# -> for fail-safe after a WDT reset
init_display_early() # I2C + display only
# -> returns (i2c, display)
init_hardware(i2c=None, display=None) # everything: PCA9685, relay, display, button
# reuses i2c/display if providedAll three raise HardwareInitError if an I²C device fails to respond (_check_i2c_device, which writes directly to addresses above 0x77 instead of using i2c.scan(), see 15).
HardwareBundle is the return type, with fields: i2c, pwm, relay, display, btn.
Manages config.json and state.json.
API:
load_config() # loads + validates config.json -> ConfigError if missing/invalid
validate_config(data) # validates a dict without a file (for POST /api/config)
load_state() # loads + validates state.json -> StateError if missing/invalid
save_state(state) # writes state.json -> StateError on write failure
create_default_state(cfg) # builds a safe initial state from configValidating config.json (see also 7.1 for the field reference):
- Structure:
global.servo_tester(withpwm_freq_hz,hatch_servo,brake_servo) plus exactlyNUM_HATCHESentries inhatches, each withname,short_name,pwm_freq_hz,hatch_servo,brake_servo,positions - Each servo block (
_validate_servo_block):range_deg∈ {180,270,360},direction∈ {"pos","neg"},speed_deg_per_s∈ [30,180],pulse_min_us < pulse_max_usboth within [SERVO_MIN_US,SERVO_MAX_US],hard_stop_min_deg < hard_stop_max_degboth within [0,range_deg] - Additional angles (
mount_angle_deg,open_deg/close_deg,test_angle_a/b_deg) depending on block type, always checked against that same block's own hard stops (_check_within_hard_stops) positions: at least 2 entries, eachhatch_degwithin the associatedhatch_servo's hard stopspwm_freq_hz(per hatch and for the tester) ∈ [PWM_FREQ_MIN_HZ,PWM_FREQ_MAX_HZ]
Important side effect of the instant-persistence model (see 10.2): since every single field, on its own, immediately validates the entire document (there is no batch-save that could validate several changes together anymore), shrinking a range_deg/hard stop creates an ordering dependency: a dependent value (a position, mounting angle, test angle, open_deg/close_deg) that still lies outside the new, smaller range must be adjusted first, before the range itself can be shrunk – otherwise validation fails with a message about the dependent field, not necessarily the field currently being edited. This is not a bug, but a deliberately accepted consequence of the instant-save design.
hatch_deg and target_deg in state.json may contain FAILSAFE_DEG (-1); in that case the range check for that field is skipped. brake_deg as well as both servo_tester angles are always real angles and are always checked (sanity bound [0, SERVO_RANGE_DEG_MAX], independent of config.json).
First startup / outdated format: load_state() raises StateError → create_default_state(config) + save_state().
Synchronous function (no asyncio) – safe to call before the event loop starts.
fail_safe_close_all(hw, brake_open_us=None)Sequence:
hw.pwm.freq(50)– forces a known frequency. Runs in Stage 0, beforeconfig.jsonis readable; the PCA9685 might still be at any previously configuredpwm_freq_hzat this point (the chip stays powered across an ESP32 reset and keeps itsPRE_SCALEregister). Without this forced reset,set_us()(see 9.3) would compute a wrong tick conversion and drive the brake to a wrong position – in precisely the most safety-critical code path.- For each hatch, sequentially: set the brake servo to
brake_open_us(withfreq_hz=50explicitly) - Relay on
- Wait
time.sleep_ms(BRAKE_TRAVEL_MS)(brake servo moves) - Relay off → the hatch closes under gravity; the brake servo is left unpowered in the "open" state (spring force isn't enough to move the servo past the cam → the hatch stays unlocked)
- 200ms pause before the next hatch (to protect the PSU)
If brake_open_us=None → falls back to BRAKE_OPEN_US_FAILSAFE from hw_config.py (works without config.json; no current call site passes a different value).
load_wifi_config() # reads /wifi.json -> WifiConfigError
save_wifi_config(ssid, password) # writes /wifi.json + machine.reset()
start_client_mode(hw) # async, connects to Wi-Fi, 15s timeout
start_ap_mode(hw) # async, starts AP "Dachluke-Setup" (open)If wifi.json is missing in start_client_mode: error message on the display, continues without Wi-Fi. Requires a reboot with the button held for AP mode.
AP SSID: "Dachluke-Setup", no password, IP: 192.168.4.1 (ESP32 default).
Status lines during connection setup are written directly via _display_line(hw, line, text) (line * 8 px) – independent of the 16px line grid task_display.py uses in normal operation (see 9.13). Since task_display only starts after boot completes and redraws the entire display on its first regular refresh, this causes no conflict; the boot messages are simply overwritten.
Shared state between task_button and task_webserver/task_display. RAM only, no persistence.
unlock_config() # starts the 60s unlock window (long button press)
activate_session() # starts the 1h session (config page accessed)
is_unlock_active() # True during the 60s window
is_session_active() # True during the 1h session
show_qr() # starts the 15s QR display (short button press)
is_qr_active() # True during those 15sTime base: time.time() (seconds since epoch).
async def task_watchdog():
wdt = WDT(timeout=4000) # 4-second hardware WDT
while True:
wdt.feed()
await uasyncio.sleep_ms(200) # feed every 200msWhy 200ms: cooperative multitasking with no priorities – frequent feeding ensures this task doesn't get starved by other ready tasks. WDT timeout (4s) >> feed interval (200ms) → large margin.
Coding rule: every while loop in the entire project must contain at least one await, so task_watchdog always remains reachable.
WDT reset detection in main.py:
if machine.reset_cause() == machine.WDT_RESET:
hw_fs = init_hw_failsafe()
fail_safe_close_all(hw_fs)Historical note – heartbeat diagnostic removed: while debugging a servo speed wobble, task_watchdog was temporarily extended with a heartbeat logged roughly every 5s (gc.mem_free()), to tell whether the event loop was truly hung or only the network/socket layer was affected. Reverted to the simple version after the debugging was complete.
The heart of the application. Runs as an endless loop, sequentially comparing on every pass: all 4 hatches (targets["hatches"][i]["hatch_deg"] vs. state["hatches"][i]["hatch_deg"]), then all 4 pending latch-servo-only moves (mounting angle), then the 2 pending servo tester moves. config, state and targets are consistently in degrees; conversion to µs (shared/servo_units.py, deg_to_us()) happens only locally at the start of each movement function. All I²C write access to the PCA9685 happens exclusively in this one task – including for the latch-servo mounting angle and the servo tester (no direct hardware access from task_webserver.py).
The PCA9685 has a single, chip-wide PRE_SCALE register – no per-channel frequency is possible in hardware. Therefore:
- One frequency per hatch (
hatches[i].pwm_freq_hz), shared by the lever and latch servo of that hatch, since both are powered simultaneously throughout the entire movement sequence and could never have different frequencies active at the same time anyway. - One further, shared frequency for both servo tester outputs (
global.servo_tester.pwm_freq_hz), independent of the 4 hatches. _ensure_pwm_freq(hw, freq_hz)reprograms the chip immediately before every move to the appropriate frequency – but only if it differs from the last one set (_current_pwm_freqmodule cache), to avoid unnecessary reprogramming.- Deliberately no safeguard against a running hatch move disturbing the servo tester's frequency (or vice versa): the tester is only meant for servos removed from the hatches, so incorrect behaviour during an accidental simultaneous hatch move is considered harmless.
Critical ordering: _ensure_pwm_freq() must run before every set_us() call of the respective move – both because set_us()'s tick computation depends on the currently valid frequency (see 9.3), and because pwm.freq() itself briefly resets the chip, which would disturb outputs already written if called after them.
def _ramp_duration_ms(distance_deg, speed_deg_per_s):
return 1.5 * abs(distance_deg) / speed_deg_per_s * 1000
def _tick_interval_ms(freq_hz):
return max(MIN_STEP_MS, round(1000 / freq_hz))
async def _ramp(hw, channel, start_us, target_us, duration_ms, freq_hz):
tick_ms = _tick_interval_ms(freq_hz)
start_tick = time.ticks_ms()
distance = target_us - start_us
while True:
elapsed = time.ticks_diff(time.ticks_ms(), start_tick)
if elapsed >= duration_ms:
hw.pwm.set_us(channel, target_us, freq_hz)
return target_us
frac = elapsed / duration_ms
eased = frac * frac * (3 - 2 * frac) # smoothstep: 3f^2 - 2f^3
current_us = round(start_us + distance * eased)
hw.pwm.set_us(channel, current_us, freq_hz)
await uasyncio.sleep_ms(tick_ms)Two important design decisions are embedded here, both arising from concrete debugging sessions with the real servo:
1. Time-based, not step-count-based. The commanded position at each tick is computed from the actually elapsed time, not from a running step counter. task_display (OLED refresh, see 9.13) and task_webserver share the same I²C bus and event loop with this task; a tick delayed by another task automatically catches up the missed distance on the next tick, instead of the delay becoming a permanent gap in the motion. Cause and symptom were both measured directly: a step-count-based ramp profile recorded via debug log showed a timestamp gap of 80–100ms roughly every 330–500ms, exactly in the rhythm of the (at the time unconditional, every-cycle) OLED redraw – see 9.13 for the corresponding fix on the display side. After both fixes (time-based ramp and conditional redraw), the timing grid was consistently even (19–31ms between ticks at pwm_freq_hz=50).
2. Smoothstep profile instead of linear interpolation. eased = 3f² − 2f³ gives continuous velocity that is exactly zero at f=0 and f=1 – no jerk at the start or end of a move. Since a smoothstep profile's peak velocity (at the motion's midpoint) is 1.5 times the average velocity, _ramp_duration_ms() extends the naive constant-velocity duration by the same factor of 1.5 – this keeps the configured speed_deg_per_s exactly the actual peak velocity, not a (higher) average velocity, and the configured speed limit (30–180°/s) is never exceeded anywhere in the motion.
_tick_interval_ms() additionally floors the update rate at both MIN_STEP_MS (8ms) and at least one full PWM period of the currently configured pwm_freq_hz – writing faster than the chip actually outputs a full PWM cycle would mean some intermediate steps are never output as a complete pulse at all, while others linger twice as long: an irregular pattern that feels like repeated acceleration/deceleration, even though the software intends a smooth ramp. At 50Hz (default) this gives a 20ms update interval; the 8ms floor is only reached at pwm_freq_hz ≥ 125Hz.
| Step | What happens |
|---|---|
step_mark_failsafe |
Set hatch_deg to FAILSAFE_DEG and persist → on a power loss, the sentinel remains in state.json; the next boot detects the interrupted move |
step_preset_servos |
Latch servo to its own current actual position (not open_deg - avoids a jerk on relay-on, since it only starts ramping once the lever servo has taken up the holding torque); lever servo to 0µs (no valid signal), so the servo controller doesn't see a signal during its own boot process |
step_set_pwm_freq |
_ensure_pwm_freq() to this hatch's pwm_freq_hz – before the preset writes above (see above) |
step_relay_on |
Relay on → both servos of this hatch powered |
(wait _RELAY_ON_TO_TORQUE_MS = 200ms) |
Time until the lever servo has taken up the holding torque, before the brake is allowed to release. No wiggle step needed anymore (see 3.4) - the S-curve ramp already starts at zero speed |
step_ramp_brake_open |
Latch servo ramps from its actual position to open_deg (its own speed_deg_per_s) - no pause afterwards |
step_ramp_hatch |
Lever servo ramps to the target position (its own speed_deg_per_s), clamped to [hard_stop_min_deg, hard_stop_max_deg] |
(wait _HATCH_SETTLE_TO_BRAKE_CLOSE_MS = 200ms) |
Time to settle at the target before the brake gets the close command |
step_ramp_brake_close |
Latch servo ramps from open_deg to close_deg |
step_relay_off |
Relay off, immediately once the brake-close ramp finishes - always in the finally block, regardless of outcome |
step_update_state |
Update state (hatch_deg, brake_deg, target_deg) + save_state() |
Since the latch servo now also ramps (instead of jumping in one step), it is precisely known when it has physically finished - this lets two previous, purely estimated fixed wait times (_BRAKE_OPEN_TO_HATCH_MOVE_MS, _BRAKE_CLOSE_TO_RELAY_OFF_MS) be removed without replacement.
Module constants (defined directly at the top of task_control.py, not in config.json, not web-configurable):
| Constant | Value | Meaning |
|---|---|---|
_PRESET_DELAY_MS |
250 | Wait after relay-on before the first valid PWM signal; 0 = disabled |
_RELAY_ON_TO_TORQUE_MS |
200 | Relay-on until the lever servo has taken up torque |
_HATCH_SETTLE_TO_BRAKE_CLOSE_MS |
200 | Target reached until the brake closes |
_MAX_MOVE_RETRIES |
3 | See error handling below |
Hard-stop enforcement: the target value is clamped to [hard_stop_min_deg, hard_stop_max_deg] of this hatch before the sequence and written back into targets.
A simplified variant without the full choreography – moves only the latch servo directly:
step_preset_servos: lever servo to its current actual position (hold, don't move),
latch servo to its current actual position
step_set_pwm_freq
step_relay_on
step_preset_delay (shared for both channels, since they're on the same relay)
step_ramp_brake: latch servo ramps to the target position (its own speed)
step_relay_off: immediately after the ramp finishes
step_update_state: only brake_deg updated, hatch_deg/target_deg unchanged
The lever servo therefore stays powered (shared relay), but is only used to hold its current position, not moved - no wiggle-like mechanism for it, since nothing is meant to move there anyway.
Even simpler - no relay (tester channels are permanently powered, see 2.3):
async def _move_tester_servo(hw, config, state, which, target_deg):
# which: "hatch" or "brake"
st = config["global"]["servo_tester"]
servo_cfg = st[f"{which}_servo"]
channel = TESTER_CH[0 if which == "hatch" else 1]
...
_ensure_pwm_freq(hw, st["pwm_freq_hz"])
await _ramp(hw, channel, current_us, target_us, duration_ms, st["pwm_freq_hz"])
state["servo_tester"][f"{which}_deg"] = target_deg
save_state(state)targets = {
"hatches": [{"hatch_deg": N}, ...], # 4 entries, triggers _move_hatch
"brake_only": [None, None, None, None], # per hatch; a set value triggers
# _move_brake_only and is reset to
# None afterwards (success or failure)
"tester": {"hatch_deg": None, "brake_deg": None}, # analogous for the servo tester
}Built from the persisted target on startup (main.py Stage 4):
targets = {
"hatches": [{"hatch_deg": h["target_deg"]} for h in state["hatches"]],
"brake_only": [None for _ in state["hatches"]],
"tester": {"hatch_deg": None, "brake_deg": None},
}task_webserver.py sets values in targets, task_control reads them and runs the appropriate movement function - never the other way around, no direct hardware access from the web server task.
Exceptions from the three movement functions are caught in the main loop and logged to the serial console; the loop continues with the next hatch/channel. The relay is always safely off due to the respective finally blocks.
Important: a failed _move_hatch call leaves state["hatch_deg"] == FAILSAFE_DEG while targets still holds the original target - the mismatch condition therefore stays permanently true, and without a countermeasure _move_hatch would be retried on every loop pass (including another save_state() flash write on every attempt - which, with sufficiently frequent failures, could affect network latency and wear the flash unnecessarily). _fail_count (per hatch, module list [0,0,0,0]) counts consecutive failures; after _MAX_MOVE_RETRIES (3), targets["hatches"][i]["hatch_deg"] is reset to the current (unknown) actual value - the mismatch condition disappears, the loop gives up. A new user command can retrigger the move at any time.
Unexpected, uncaught exceptions (outside the three movement functions) bring down the event loop → WDT reset → fail-safe.
Polls the user button every 50ms. Detects a falling edge (1→0) with BTN_DEBOUNCE_MS debouncing. Distinguishes a short from a long press by hold duration:
- Short press (<
BTN_LONG_PRESS_MS= 3s):session.show_qr()→ OLED shows the QR code for 15s - Long press (≥ 3s):
session.unlock_config()→ 60s unlock window for the config page
# Pseudocode of the logic:
if falling_edge detected and debounced:
press_start = time.ticks_ms()
long_press = False
while btn still pressed:
if elapsed >= BTN_LONG_PRESS_MS:
long_press = True
session.unlock_config()
wait for release
break
await sleep_ms(_POLL_MS)
if not long_press:
session.show_qr()Checks every _REFRESH_MS (500ms) whether the display content needs to change, and only touches the I²C bus when there is actually something new to draw.
Why this is more than just efficiency: task_display shares the I²C bus and the event loop with task_control (servo control). A full redraw (framebuffer fill + Writer glyph rendering + the final I²C transfer in show()) runs with no await in between and blocks the event loop for its entire duration. Measured impact before this mechanism was introduced: an unconditional redraw on every cycle - even when nothing on screen actually changed - periodically delayed the scheduled wake-up of the servo ramp in task_control, which manifested as a real, physical speed wobble at the servo (most visible with the servo tester, since the 4 hatch status lines never change while it's in use). The same unconditional redraw also caused a visible flicker on the QR screen (moiré banding when filming it with a phone camera at scanning distance), from repeatedly blanking and rebuilding the same QR code.
Mechanism: _last_drawn holds a fingerprint of whatever is currently actually shown on the display (including which of the two screens - status or QR - is active). Both _refresh() and _render_qr() skip their I²C work entirely when the fingerprint they would draw already matches _last_drawn. Because the fingerprint includes the screen mode, switching between the status and QR view always forces a redraw, even if the underlying data happened to be unchanged - the redraw is triggered purely because what's actually visible on the panel differs from what's currently drawn.
Partial redraw during an actual hatch move: the fingerprint comparison above doesn't help while a hatch is actually moving - then the rotating spinner (see below) changes on every cycle, so a full redraw would still be needed and could noticeably compete with the servo ramp for I²C bus time. For exactly this case there is a second, finer-grained mechanism: _last_lines caches the 4 line strings last drawn. If, for one row, only the 4-character status field changes (columns 96-127, the last 4 of 16 characters) - the most common case during a move - the entire display is not redrawn; instead only this small 32x16px rectangle is:
_show_region(display, x0, x1, page0, page1)sets the SSD1306 address window specifically to this rectangle (SET_COL_ADDR0x21 /SET_PAGE_ADDR0x22 - the standardssd1306driver never uses these commands, itsshow()always addresses the full 128x64 area) and transfers only the corresponding framebuffer bytes (64 instead of 1024 bytes)._redraw_status_region()first clears only this cell (fill_rect, to prevent ghosting between differently shaped spinner frames) and draws the new text into it.- If the short-name part of a row also changes (e.g. editing config while a different hatch happens to be moving), the code falls back to a full redraw - this case is rare, and simplicity matters more here than further optimisation.
This mechanism bypasses the ssd1306 driver and talks directly to its write_cmd()/write_data()/buffer/width attributes - standard behaviour of the common micropython-lib driver (page-major MONO_VLSB buffer layout), but not part of its officially stable API. With a different driver version, _show_region() would need re-checking.
Normal layout (128x64, 4 lines at 16px each, filling the entire height):
Line 0 (y=0): "<short_name> <status>" Hatch 1
Line 1 (y=16): "<short_name> <status>" Hatch 2
Line 2 (y=32): "<short_name> <status>" Hatch 3
Line 3 (y=48): "<short_name> <status>" Hatch 4
Line format: short_name left-aligned in 11 characters, 1 space, then exactly 4 characters of status (always fixed width) - 16 characters total at 8px/character = exactly 128px.
Larger font via the Writer class: instead of the built-in 8x8 font of framebuf/ssd1306.py (which supports no scaling - only integer pixel doubling would be possible, rejected as too coarse), lib/writer.py (Peter Hinch, micropython-font-to-py, MIT license) is used together with lib/font_hatch_status.py (14px, monospace, 8px character width, see 6.2b). The Writer instance is created once in task_display() (not on every refresh). wri.set_clip(row_clip=True, col_clip=True, wrap=False) prevents automatic line wrapping.
Status values, fixed 4-character width:
| Value | Meaning |
|---|---|
" | "/" / "/" - "/" \\ " |
target != actual (move in progress, also during fail-safe recovery) - rotating stroke, one frame per actual redraw (_spin_frame, only while the line is redrawn anyway) |
"?ZU?" |
actual == target == FAILSAFE_DEG: fell shut under gravity after a fail-safe sequence, unlocked |
"ZU " |
position 0 in positions (closed) |
"AUF " |
last position in positions (open) |
"P2 "..."P5 " |
intermediate positions (1-based) |
"??? " |
actual position not found in positions (should not occur in normal operation) |
Historical note - spinner instead of plain text: the movement status used to be shown as the plain-text word "BEWEGT" ("moving", 6 characters). Since that needed more than 4 characters for the status overall, it was changed to the current fixed 4-character rotation, once it was decided that all status values (not just the position labels) should uniformly occupy exactly 4 characters - the short name gets the remaining 11 of a line's 16 characters as a result.
Inverted display while the config session is active: if session.is_session_active() or session.is_unlock_active() is true, the entire display is inverted (display.invert(True)) - the only on-device indication that the configuration page is currently reachable (the actual config button only appears on the web page). config_reachable is part of the redraw fingerprint, so a pure session-state change (with the status text otherwise unchanged) still forces a redraw.
QR mode (session.is_qr_active() = True, triggered by a short button press):
- Top area (0-47px,
qr_area_height = 64 - _IP_LINE_PX): white background, black QR code centred - Bottom area (48-63px, 16px): IP address as plain text (without the
http://prefix), in the normal built-in 8px font, horizontally centred - since the IP/Wi-Fi line was removed from normal operation, this is the only place the IP is visible on the device - URL for the QR encoding:
http://<current-IP>(STA or AP); the separate text line shows only the bare IP (_current_ip()) - The QR matrix is generated once via
uQRand cached as long as the URL doesn't change (generation takes about 0.5s); the display is additionally only redrawn on an actual change, via the fingerprint mechanism described above - On entering QR mode,
display.invert(False)is forced, so a previously active session inversion doesn't render the QR code negatively - Scale 1px/module + 4px quiet zone, confined to the top 48px - still enough room for typical home IPs
- Directly scannable with the iOS camera app
- If
uQRisn't in/lib: fallback text"uQR not found"on the display
microdot web server on port 80. Module variables _hw, _config, _state, _targets are set once when the task starts (route handlers cannot receive extra parameters in microdot).
Routes:
| Method | Path | Function |
|---|---|---|
| GET | / |
index.html (redirects to /ap if config.json failed to load) |
| GET | /config |
config.html (activates the session if the unlock window is open) |
| GET | /ap |
ap_setup.html |
| GET | /static/<path> |
static files from /webpages/static/ |
| GET | /sse |
SSE stream, every 500ms |
| GET | /api/config |
current config.json as JSON |
| POST | /api/hatch/<i>/pos |
set target by position index {position_idx: N} |
| POST | /api/hatch/<i>/test |
drive the lever servo directly {hatch_deg: N} - full sequence; also used for the lever servo's mounting angle (session required) |
| POST | /api/hatch/<i>/brake_test |
drive only the latch servo directly {brake_deg: N} - mounting angle, sets targets["brake_only"][i] (session required) |
| POST | /api/tester/<which> |
drive a servo tester output, which in {"hatch","brake"}, {angle_deg: N} (session required) |
| POST | /api/config |
validate the new config, persist immediately + apply in RAM - no reset; called again with the complete current document on every single field acceptance and every position insert/remove (session required) |
| POST | /api/wifi |
save Wi-Fi credentials + reset |
| GET | /api/wifi/scan |
Wi-Fi scan → JSON {networks: [{ssid, rssi}]} |
POST /api/config - atomic write:
with open('/config.json.tmp', 'w') as f:
f.write(ujson.dumps(new_cfg))
f.write('\n')
try:
uos.remove('/config.json')
except OSError:
pass
uos.rename('/config.json.tmp', '/config.json')os.rename() is atomic on the ESP32 filesystem - an interrupted write (memory error, reset, power loss) can therefore never leave a truncated /config.json behind (which would prevent the device from booting on the next start). ujson.dumps() instead of the previously used, hand-written pretty_json() formatting (see 9.16b) - reduces the amount of data written (roughly 2.5 instead of 5.5 KB) and avoids the large intermediate string built from many small sub-strings, which tends to fragment the small MicroPython heap. del new_cfg + gc.collect() immediately afterwards actively frees the memory of the just-written copy, instead of waiting for automatic garbage collection.
On success, _config is updated in place (_config["global"]["servo_tester"].update(...), per hatch _config["hatches"][i].update(h)), so task_control and task_display see the change on their very next pass, without needing a reference swap.
SSE implementation: the /sse stream uses a class-based _SseStream class instead of an async generator, since MicroPython raises a TypeError in microdot's write path for async generators that combine yield and await in the same function body (the second iteration returns a non-bytes object). The class implements the __aiter__/__anext__ protocol explicitly.
Config session protection:
/config: reachable ifis_session_active()ORis_unlock_active()→ activates the session/api/hatch/<i>/test,/api/hatch/<i>/brake_test,/api/tester/<which>,POST /api/config: only ifis_session_active()
SSE payload (_build_status()):
{
"hatches": [
{"name": "Hatch 1", "short_name": "Hatch 1", "current_deg": 0,
"target_deg": 0, "moving": false, "failsafe": false,
"pos_idx": 0, "num_positions": 3}
],
"wifi": {"connected": true, "ip": "192.168.8.70"},
"session": {"active": false, "unlock": false}
}Contains no servo tester fields - the tester has no automatic "moving" concept, config.html reads its values directly from GET /api/config.
Central handler for fatal boot errors. Replaces the earlier pattern of returning early from main(), which led to an uncontrolled WDT reboot loop.
async def fatal_error(message, hw=None, display=None)Sequence:
- Attempt a fail-safe move if
hwis available (try/except - errors are logged but ignored, so the following steps always run) - Permanent error message on the OLED if
displayis available (header line + message wrapped across 7 lines of 16 characters) - Start the "Dachluke-Setup" Wi-Fi AP, so the error page is reachable
- Start a minimal web server on port 80 and never return
No WDT is started → a power cycle is the only way out. Called from main() in place of every return after a fatal error.
Called in main() Stage 2b before load_config(). Ensures config.json exists and contains all required fields.
def migrate_config() # raises ConfigMigrationError if the template is missingLogic:
- Template (
/config.json.template) missing →ConfigMigrationError(fatal) config.jsonmissing → copy the template verbatimconfig.jsonpresent and valid (validate_config()raises no error) → do nothingconfig.jsonpresent but invalid or with missing/renamed fields → merge: keep known values, fill in missing ones from the template, drop unknown fields (this is how fields from an earlier schema version, such as the once-flathatch_min_us/step_usstructure, get automatically removed)
The merge is recursive for nested dicts (already handles the current global.servo_tester, hatch_servo, brake_servo blocks generically, no special-casing needed). The hatches list is always processed as 4 entries. The positions list is taken from config.json if non-empty (preserves calibrated values), otherwise from the template.
In practice: since POST /api/config (see 9.14) writes a fully and immediately validated document on every single field acceptance, config.json is, in normal operation, practically always already valid - the merge path only kicks in right after a schema change in the code (new field in the template, old config.json still on the previous schema) or after data corruption.
A historical, standalone test script from the early development phase, used to try out the QR code rendering before it was actually implemented in task_display.py. Not imported or called by any other module. Known bug: the black/white assignment when drawing is inverted (writes pixel=1 for a QR module that should be black, even though on the SSD1306 a value of 1 means "white") - the production implementation in task_display._render_qr() does it correctly. Only runnable manually via the REPL (exec(open('test_qr.py').read())), not part of the regular boot/test flow.
- Alpine.js
app()component on<body> - SSE connection to
/ssewith auto-reconnect after 3s - One Bootstrap card per hatch with position buttons
- Button labels:
ZU/closed (index 0),AUF/open (last),P2...P5(intermediate positions) - Active position:
btn-primary, others:btn-outline-secondary - Moving: badge pulses (CSS animation)
- Config button: appears/disappears reactively, no page reload
- Unaffected by the per-channel redesign - only uses
moving/failsafe/pos_idx/num_positions/name/short_namefrom the SSE payload, no field names that changed
- Alpine.js
configApp()component, loads config viaGET /api/configon page load - No shared "all 4 hatches" area anymore - each hatch is its own independent accordion item, titled by
name - 5th accordion item "Servo Tester", with a warning note (for servos removed from the hatches only; PWM frequency independent of the 4 channels)
- Download button (navbar): builds a
Blobfrom the currently loaded/savedbuildConfig()document, triggers a download via<a download="config.json">- purely client-side, no dedicated server endpoint - Language switcher (navbar, DE/EN dropdown), defaults from
navigator.language
Channel card (per hatch), layout top to bottom:
- Name, short name (text fields, full width, no two-column layout)
- PWM frequency (one field, shared by the lever and latch servo of this channel, see 9.11)
- Two columns, separated by a thin vertical line: left "Lever servo, channel N", right "Latch servo, channel N"
- Row 1: range (selector 180°/270°/360°), direction (selector pos/neg), speed (°/s)
- Row 2: pulse width at 0°, pulse width at the current
range_deg° (the label adapts dynamically to the chosen range - only the label, the values themselves stay unchanged across a range switch) - Row 3: hard stop min/max (°)
- Latch servo column only, row 4: brake open/close (°) - the two regular operating positions
- Row (lever: 4, latch: 5): mounting angle + accept + visible gap + "go" button (prevents mis-clicks while editing the angle value). Lever servo "go" triggers the full sequence (
/api/hatch/<i>/test), latch servo "go" moves only that servo (/api/hatch/<i>/brake_test)
- Detent positions (lever servo column only; the latch servo column stays empty there, to keep the two-column grid)
Servo tester card: the same two-column layout, rows 1-3 identical to the channel card, one shared PWM frequency row across both columns, then per column two test-angle rows (A and B, each with accept + gap + "go") instead of the position list/mounting angle - letting each tester channel be moved back and forth between two freely chosen angles.
Field states (two instead of three, since acceptance persists immediately):
draft !== committed→ yellow (field-edited) - typed, not yet accepted- otherwise → white (accepted = already permanently saved)
Interaction per field: reset (undo to committed), input field, accept (validates locally → sends the complete current document via POST /api/config → on success committed = draft; on failure, rolls back committed, shows an error, draft stays for correction).
persistConfig() - error classification: buildConfig() deliberately runs outside the network try block (a JS error there is a client bug, not a network error). The response is always read as text first and then parsed as JSON, regardless of HTTP status - a 400 with a valid {"error": "..."} body (a normal validation rejection, an "operator error") is shown as a plain error message; only a response that can't be parsed as JSON at all is shown as "Server error NNN: ..." (a genuine crash/broken response).
"Go" button: only active when draft === committed (the value has already been accepted and persisted) → sends the appropriate test route directly, without a prior apply step.
Positions: positions[0] (closed) and positions[length-1] (open) are fixed endpoints and are never moved. A new intermediate position is always inserted right before the open endpoint, defaulting to the average of the open endpoint and the previously last intermediate position (or of the open and closed endpoints, if no intermediate position exists yet). Removing deletes the last intermediate position (index length-2), never the closed or open endpoint itself. Both are persisted immediately (no extra accept click needed).
Internationalisation (i18n): an I18N object with language codes de/en, each a flat dictionary of all text keys. t(key) reads from it. Only the web UI text (labels, buttons, legends, hint text) is translated; error messages from the device (validate_config()) as well as all boot/console output stay deliberately in English, regardless of the UI language (for diagnostic purposes, not meant for end users).
- Scan starts automatically on page load (
GET /api/wifi/scan) - Results shown as a clickable list with an RSSI bar indicator
- Click → fills in the SSID field
- Password field with a show/hide toggle
- Submit →
POST /api/wifi→machine.reset() - After the connection drops (the device has deactivated its AP): a note that the device is restarting and the IP will briefly appear on the display
- Unaffected by the per-channel redesign
- Bootstrap 5.3.3 (
bootstrap.min.css,bootstrap.bundle.min.js) - Alpine.js 3.14.1 (
alpine.min.js)
All three HTML pages load the libraries primarily from the CDN (jsdelivr.net). If the CDN is unreachable, an onerror handler falls back to the local copies in source/webpages/static/. The browser caches CDN files after the first load, after which no further network access occurs.
Alpine.js is loaded without defer at the end of <body>, so the respective function appName() is already defined by the time Alpine initialises.
| Task | Interval | Job |
|---|---|---|
task_watchdog |
200ms | Feed the hardware WDT |
task_display |
500ms (check; redraw only on change) | Update the OLED |
task_control |
10ms (after a move) / 50ms (idle); during a move: see _ramp()'s update interval |
Target/actual comparison, movement sequences |
task_button |
50ms | Poll the button, start session unlock/QR |
task_webserver |
event-driven | HTTP requests, SSE stream |
Every while loop in the project must contain at least one await.
Background: cooperative multitasking with no priorities. Blocking code without an await prevents task_watchdog from running → WDT reset.
task_display and task_control share the same physical I²C bus and the same cooperative event loop. A blocking I²C transfer in task_display (OLED redraw) delays task_control's next scheduled continuation of its movement ramp by exactly that amount of time. With a step-count-based ramp (the earlier implementation), this added up to a regular, physically noticeable speed wobble at the servo. Two independent countermeasures were put in place, each complementing the other (see 9.11 and 9.13 for the respective details):
task_displayonly redraws when the content has actually changed (drastically less I²C traffic in normal operation, especially with the servo tester)task_control's ramp is time-based rather than step-count-based (a delayed tick is caught up rather than lost)
- Timeout: 4000ms
- Feed interval: 200ms
- Initialised inside
task_watchdog(not inmain.py) - The WDT only becomes active once
task_watchdoghas started (i.e. after all startup stages have completed successfully)
Triggers:
- WDT reset (the program stopped responding)
- Unhandled error during startup (
fatal_error(), see 9.15)
Sequence (fail_safe_close_all, see 9.7):
- Explicitly force the PCA9685 frequency to 50Hz (regardless of the prior state)
- For each hatch: set the brake servo PWM to
brake_open_us - Relay on
time.sleep_ms(BRAKE_TRAVEL_MS)- the brake opens- Relay off - the hatch closes under gravity; the brake servo stays unpowered in the "open" position (spring force isn't enough to move the servo past the cam → the hatch stays unlocked)
- 200ms pause → next hatch
Why doesn't the lever servo jerk during fail-safe? fail_safe_close_all only sets the brake channel to a valid PWM value. The lever channel stays at 0µs - no valid pulse. The servo controller detects the missing signal and doesn't engage its motor. This behaviour is deliberately mirrored in the normal movement sequence (see 9.11, step_preset_servos).
After a fail-safe (WDT reset): the normal startup sequence continues. In Stage 2b, all hatches are set to FAILSAFE_DEG (-1) in state.json - target and actual now match physical reality. task_control then stays passive for these hatches (target == actual == FAILSAFE_DEG). Only a new user command (a real degree position) triggers the full movement sequence including locking again. The servo tester has no fail-safe concept of its own (it never moves automatically) and is unaffected by all of this.
Goal: the configuration page is only reachable after a physical, long button press on the device.
Sequence:
- Press the user button long during normal operation (>=
BTN_LONG_PRESS_MS= 3s) →session.unlock_config()→ 60s window - Within 60s: the config button appears on the control web page
- Clicking the config button → calls
/config→session.activate_session()→ 1h session - During the 1h: the config page is reachable via the button or a direct URL
- After 1h: the button disappears on the next page load, direct calls are blocked
Security level:
- Physical access to the device required (long button press)
- Time-limited window
- All movement-triggering and writing config routes (see 9.14) require an active session
Visibility on the device: inverted display while the unlock window or session is active (see 9.13) - the only indication directly on the device; otherwise the status is only visible on the web page itself.
Purpose: easy access to the web UI without having to type the IP address.
Trigger: a short button press (< BTN_LONG_PRESS_MS = 3s).
Sequence:
session.show_qr()sets_qr_until = time.time() + 15task_displaydetectsis_qr_active()= True and calls_render_qr()- The QR matrix is generated once via
uQR(URL =http://<IP>) and cached; the display is additionally only redrawn on an actual change (see 9.13) - The OLED shows a white background with a black QR code (centred, 4px quiet zone), confined to the top 48px
- The bare IP address (without
http://) additionally appears in the bottom 16px as text in the normal 8px font, horizontally centred - After 15s,
task_displayautomatically returns to normal mode
Scannability: directly scannable with the iOS camera app. Scale 1 (1px/module) remains sufficient for typical home IPs within the 48px-reduced QR area.
Library: uQR (JASchilz/uQR) - produces a 2D boolean matrix. True = black module, False = white module. Must be manually copied as /lib/uQR.py onto the device (not installable via mip).
i2c.scan() only covers 0x08-0x77 per the I²C specification. The Seeed Grove PCA9685 defaults to address 0x7F (all address pads pulled high). It therefore never appears in scan results, but is directly addressable. Diagnosis: i2c.writeto(0x7F, b'') + i2c.readfrom(0x7F, 1) - a MODE1 register value of 0x11 means the device is responding correctly. hw_init._check_i2c_device() already handles this automatically (addresses above 0x77 are written to directly instead of scanned).
If i2c.scan() returns every address from 8 to 119 → SDA or SCL is wired wrong (e.g. pulled to GND). Not a genuine scan result, but an artefact. Correct pins for the XIAO ESP32-S3 on the expansion board: SDA=5, SCL=6.
When copying JSON out of chat conversations, Markdown backticks (```) can end up at the end of the file. MicroPython's ujson then raises a JSON parse error. Check the file in an editor and remove the backticks.
mpremote toggles DTR/RTS on connect → hardware reset of the ESP32 → the port may change from /dev/ttyACM0 to /dev/ttyACM1. If scripts then continue using the old port, they fail. Fix: briefly unplug and replug the device from USB.
Typically occurs at the end of upload.sh, when the device can't cleanly switch to REPL mode after the upload - usually because an import error in one of the freshly uploaded files sends the device into a boot loop instead of quietly waiting at the REPL. The files uploaded before that point are usually unaffected (the error only occurs at the final start). Diagnosis: mpremote connect /dev/ttyACM0 (leave it open, don't type anything) shows the Python traceback of the import error.
The PCA9685 has its own power supply and stays continuously powered across an mpremote reset or a software reset of the ESP32 - its PRE_SCALE register (PWM frequency) and all other internal state survive unchanged. After changes to PWM-/timing-related code (particularly to pwm_freq_hz handling), this can show up as a seemingly random "doesn't work after upload, works after a power cycle". Workaround: after such changes, do a full power cycle (USB unplug/replug) instead of just mpremote reset. See also 6.3.
A KeyboardInterrupt via Ctrl+C in an open mpremote connection shows a traceback of wherever the event loop happened to be at the moment of the interrupt. If it repeatedly lands in asyncio/core.py's wait_io_event (the normal idle wait point between tasks), that means: the loop is running normally and is not hung - a randomly hit point in an actively waiting coroutine is not proof of a hang, even if the rest of the system (e.g. the web UI) seems unreachable at that moment. In that case the cause more likely lies outside the plain Python event loop (e.g. the Wi-Fi/socket layer) rather than in an infinite loop in the code itself. A permanently running, periodically logged state value (e.g. gc.mem_free(), see the historical note in 9.10) can distinguish this more reliably than a single timed Ctrl+C attempt: if the periodic log keeps running right up until a watchdog reset, the loop itself never hung.
See 9.6: if range_deg or a hard stop is shrunk while a dependent value (a position, mounting angle, open_deg/close_deg, test angle) still lies outside the new, smaller range, validation rejects the change - with an error message about the dependent field, not necessarily the one currently being edited. Always bring the dependent values into the new range first, then shrink the range itself.
main.py starts immediately and blocks the REPL via uasyncio.run(main()) - as long as the program is running, no >>> prompt appears, no matter how often Enter is pressed (this is normal behaviour, not a bug). A Ctrl+C in the open mpremote connection cleanly interrupts the running loop and opens the prompt. Alternatively, to boot permanently without starting main.py:
mpremote connect /dev/ttyACM0 exec "import os; os.rename('/main.py', '/main_bak.py')"Renaming it back:
mpremote connect /dev/ttyACM0 exec "import os; os.rename('/main_bak.py', '/main.py')"MicroPython Studio leaves lock files in /tmp/. After crashes or a hard disconnect, they remain:
rm /tmp/mps_lock__dev_ttyACM*.lockDirect control at the device, independent of the web interface, carried over from the original design concept. Not implemented so far - neither the hardware installed nor the software written.
Control concept:
- 4 hatch buttons (select hatch 1-4)
- 6 position buttons (select target position 1-6)
- 6 LEDs (one next to each position button)
Operation: press a hatch button → the LEDs show that hatch's current actual position. Press a position button → the hatch moves to the selected position (sets targets["hatches"][i]["hatch_deg"] the same way task_webserver does - no change to task_control.py needed).
Planned hardware - MCP23017 (I²C GPIO expander, address 0x20):
- 16 GPIO total, freely configurable as input/output
- 10 inputs (buttons, internal pull-ups usable)
- 6 outputs (LEDs with series resistors)
- Interrupt output available → the ESP32 can react to a button press without polling
- Planned as a breakout board with pin headers, buttons and LEDs wired directly at the board on a piece of perfboard
Deferred until: the need is reassessed based on experience with the web interface.
Implementation, if built: a new module app/task_panel.py, imported in main.py, added to gather().
- A weatherproof electrodynamic exciter (mounted on an angled metal plate)
- Operated in reverse as a sensor: raindrop impact → plate vibration → the exciter generates a voltage pulse (low impedance, no charge amplifier needed)
- TLV3691 comparator (75 nA) as an always-on wake-up stage
- The MCU sleeps deeply, woken by the comparator output
- After waking: ADC measurement for intensity classification, send a LoRa packet, back to deep sleep
- Active cycle ~200-500ms → battery operation for months to years is realistic
Radio:
- LoRa (e.g. an RFM95 module) for a point-to-point link to the ESP32
- Receiver: a second RFM95 directly on the ESP32 (SPI)
- No third-party infrastructure needed, no Wi-Fi required (a Wi-Fi outage of the main device doesn't matter)
- No cable to the roof → no lightning-strike risk
Interface to the main system: the rain sensor delivers an intensity level (e.g. 0 = no rain, 1 = drizzle, 2 = rain, 3 = heavy rain). The main system reacts via the event/trigger system (e.g. close all hatches from level 2, only crack them open at level 1).
Connection to the main system: an additional task or webhook endpoint → sets targets via targets["hatches"].
The PCF8563 is already present on the expansion board (I²C 0x51). Automatic open/close at configured times → a new task + an extension of the configuration web page.
A clean abstraction layer is planned for all extensions:
- Trigger sources: web UI, local buttons, rain sensor, RTC
- Action:
targets["hatches"][i]["hatch_deg"] = new_position_deg - New sources can be added without reworking the core logic -
task_control.pyalready reacts exclusively to changes intargets, regardless of who sets them
MIN_STEP_MS (currently 8ms) is the floor for the movement ramp's update interval, reached at pwm_freq_hz ≥ 125Hz (see 9.11). If even finer updates are ever needed (e.g. for particularly high-resolution or fast servos), this value could be lowered further - the trade-off is higher I²C bus load per second, which could re-aggravate the contention issue with task_display/task_webserver described in section 11.3. Only change this with a concrete need.
Currently, every single field acceptance immediately validates the entire document (see 9.6, "ordering trap"). An optional batch acceptance (collect several drafts, then send them together) would solve this UX issue, but was deliberately not implemented, to keep the simpler instant-persistence model. Revisit if the need recurs.
Done:
- Full hardware bring-up (I²C bus, PCA9685, relay, display, button)
- Degree-based configuration with per-channel servo calibration (range, direction, pulse widths, hard stops)
- Instant configuration persistence (no batch save, no reboot for config changes)
- Time-based S-curve movement ramp for both the lever and latch servo
- Servo tester functionality (2 additional, permanently powered PWM channels)
- Mounting angle per servo for maintenance purposes
- Per-channel PWM frequency (with documented hardware limits, see 9.11)
- Multilingual configuration UI (DE/EN)
- Config download button
- QR code display with IP text, inverted session indicator
- Fail-safe sequence including WDT reset detection
- Bugs fixed during hardware testing: PWM frequency ordering (
_ensure_pwm_freqbeforeset_us()), I²C bus contention between the display and servo (conditional redraw + time-based ramp), atomic config writes, memory fragmentation during config writes, various docstring/comment inconsistencies from the various redesign stages
Open:
- Further hardware testing under real load (long-term test, particularly the PWM frequency contention between the servo tester and hatch operation, see 9.11)
- On-site control panel: pending a needs assessment (see 16)
- Unit tests (on hold): worthwhile for
state.pyvalidation andpca9685.py's calculations - after the API stabilises further - Rain sensor: a standalone prototyping project (see 17.1)
- End-user documentation for end users/re-users - planned as a separate document, building on this one