This document describes the overall architecture, rendering data flow, and hardware pipeline of the zeClock project.
zeClock is an asynchronous Python client application that communicates with DMD hardware through a pluggable backend system. The default backend (ZeDMDBackend) drives ZeDMD displays directly via libzedmd (C shared library called through Python ctypes). An alternative backend (DMDServerBackend) communicates over TCP with a separate dmdserver process (from libdmdutil), which can route frames to multiple device types simultaneously -- ZeDMD, Pixelcade, PIN2DMD, and an SDL2 simulator. The dmdserver backend is also used for development with virtual-dmd.py.
graph TD
subgraph "Application Layer #40;Python#41;"
A["zeclock #40;clock.py#41;"] -->|Initializes| B["BitmapFont #40;fnt_reader.py#41;"]
A -->|Initializes| C["Scene #40;scn_reader.py#41;"]
A -->|Pre-computes frames| D["Overlay #40;overlay.py#41;"]
A -->|Sends frames via| E["DMDBackend #40;backends/#41;"]
A -->|Auto bootstrap| F2["Installer #40;installer.py#41;"]
A -->|Brightness control| BS["BrightnessScheduler #40;brightness_scheduler.py#41;"]
A -->|Remote control| RC["Remote Control #40;remote/#41;"]
end
subgraph "Remote Control Layer"
RC -->|MQTT pub/sub| MQTT["MqttRemote #40;aiomqtt#41;"]
RC -->|HTTP server| REST["RestRemote #40;aiohttp#41;"]
RC -->|Shared logic| CMD["CommandHandler"]
MQTT -->|HA Discovery| HA["Home Assistant"]
end
subgraph "Backend Layer"
E -->|"#45;#45;backend zedmd #40;default#41;"| E1["ZeDMDBackend #40;ctypes/libzedmd#41;"]
E -->|"#45;#45;backend dmdserver"| E2["DMDServerBackend #40;TCP socket#41;"]
end
subgraph "Hardware and Rendering Layer"
G["ZeDMD #40;ESP32 / Teensy Hardware#41;"]
H["RGB LED Panels #40;128x32 / 256x64#41;"]
I["dmd-simulator #40;SDL2 Graphical Simulation#41;"]
F["dmdserver #40;TCP Daemon#41;"]
J["Pixelcade"]
K["PIN2DMD"]
end
E1 -->|USB Serial / WiFi via libzedmd| G
E2 -->|TCP Socket: Port 6789| F
F -->|USB Serial / WiFi| G
F -->|USB Serial| J
F -->|USB| K
G -->|Matrix Bus| H
F -->|Local Simulation| I
Manages global state and the time-based execution loop.
- Render Loop (Tick Event): Runs as an async background task. Precisely calculates the remaining delay before the next frame to call
asyncio.sleepand stabilize the framerate (25 FPS by default, or per-scene timing). - Animation State Machine: Coordinates transitions between clock pages and plugin activations. The clock shows one page per activation (time β time+date β time+day), returning
Noneafterpage_duration_secondsto hand control to the main loop for plugin selection. - Async Pre-computer (
_precompute_animation): To avoid any slowdown during real-time rendering, an async task loads a randomly chosen.scnanimation, merges each frame with the current time (in two versions: with and without the colon:for blinking) and stores everything in RAM. The PinballPlugin and GifPlugin further optimize this by running pre-computation in a background thread, appending frames progressively so rendering can start before all frames are ready. - Dual Color: Supports independent colors for the clock and animations, with an
automode that rotates colors every 60 seconds. - CLI Entry Point: The
main()function exposes--color,--animation-color, and--bootstraparguments.
Protocol-agnostic remote control with MQTT and REST API interfaces running as concurrent asyncio tasks. See Section 8 for full details.
Command priority in the render loop:
- Text overlay (highest β temporary, expires after configured duration)
- Screen off override (all-black frame)
- Forced plugin (stays on the specified plugin until cleared)
- Normal state machine (clock β plugin rotation)
Text overlay rendering uses a PIL TrueType font with auto-scaling:
- Loads
default.ttffrom the bundled fonts directory (sized proportionally to display height). - Auto-shrinks the font size until the text fits within the display width (with 2px margin).
- Falls back to PIL's built-in default font if the TrueType font is unavailable. Text is rendered centered on screen using the clock color.
Pluggable backend system for DMD communication. All backends implement the DMDBackend abstract base class, ensuring the clock and plugins remain backend-agnostic.
For user-facing backend documentation (selection guide, configuration), see backends.md.
DMDBackendABC (backends/base.py): Defines the common interface:connect() -> bool: Establish connection to the display.send_frame(image, buffered, color) -> bool: Send a frame to the display.disconnect() -> None: Close the connection.connectedproperty: Whether the backend is currently connected.- Context manager protocol (
__enter__/__exit__): Callsconnect()/disconnect()automatically.
ZeDMDBackend(backends/zedmd.py): Direct hardware communication vialibzedmd(C shared library loaded through ctypes). Sends frames as RGB888 directly viaZeDMD_RenderRgb888, avoiding any Python-level pixel conversion. Supports WiFi and USB connections. On connect, auto-detects the physical panel dimensions viaZeDMD_GetPanelWidth/ZeDMD_GetPanelHeightand enables upscaling (ZeDMD_EnableUpscaling) so libzedmd handles centering/scaling when the frame size differs from the panel size. The upscale algorithm is controlled byBackendConfig.upscale_mode(epxby default,hq2x, ornearest). Uses a simple error detection strategy: a log callback registered with libzedmd (ZeDMD_SetLogCallback) sets a thread-safe error flag on stream errors (StreamBytes failures, serial/TCP/UDP errors). On the nextsend_frame()call, the flag is checked β if set, the instance is closed immediately andFalseis returned. The caller (main loop inclock.py) handles the wait and reconnection with exponential backoff (initial 2s, max 30s, Γ1.5 multiplier). Athreading.Lockprotects the error flag between the libzedmd internal thread and the Python asyncio thread.DMDServerBackend(backends/dmdserver.py): TCP client using the DMDStream protocol (refactored fromdmdserver_client.py). Used for development withvirtual-dmd.py.BackendFactory(backends/factory.py): Instantiates the correct backend based on--backendCLI argument (auto,zedmd,dmdserver). Inautomode, tries ZeDMD first, falls back to dmdserver.
- Header (big-endian):
- Magic word:
DMDStream\x00(10 bytes) - Version: 1 (uint8)
- Mode: 3 = RGB565 (uint32 big-endian)
- Dimensions: width, height (uint16 big-endian)
- Flags: buffered, disconnectOthers (uint8 each)
- Data size: payload length (uint32 big-endian)
- Magic word:
- Payload: RGB565 big-endian data (2 bytes per pixel).
- Persistent connection: Socket stays open between frames for continuous streaming.
Optimized parsers for decoding native DotClk hardware formats:
BitmapFont(fnt_reader.py): Decodes the binary.fntformat. Reads font headers (version, name, character count), maps ASCII characters with their advance width and kerning info, decompresses the global bitmap encoded at 4 bits per pixel (2 pixels per byte, dotmap format with header), and extracts individual grayscale glyphs. When rendering on HD displays (256Γ64) with SD fonts,render_text()renders at native resolution then delegates 2Γ upscaling tooverlay.upscale_2x()(EPX, hq2x, or nearest-neighbor), controlled by theupscale_modeparameter. HD fonts (name ending with_HD) render directly at target size without upscaling.Scene(scn_reader.py): Decodes the binary.scnanimation format. Extracts the storyboard containing display metadata:first_frame_delay/last_frame_delay: Hold delays on first/last frame (ms).first_blank/last_blank: Whether display should be blank during those delays.clock_style: Clock style (0 = Standard centered, 1 = Custom coordinates).custom_x/custom_y: Custom clock position coordinates.frame_layer: Layer order (0 = Clock behind animation, 1 = Clock above).frame_delay_ms: Delay between frames (default 40ms = 25 FPS).
This module handles image merging (clock on one side, animation on the other) and pixel-art upscaling. It faithfully implements the original DotClk hardware DotBlt algorithm and serves as the single entry point for all pixel-art upscaling in zeClock.
Mask metadata (mask_data, mask_width_bytes) is attached dynamically to PIL Image objects by the binary readers and accessed via typed Any casts in the overlay compositor.
- Each animation frame or bitmap text carries a binary mask (
mask_data). - DotBlt Algorithm:
- If the upper layer's mask bit is
0, the upper image pixel is applied (overwriting the background). - If the mask bit is
1, the background pixel is preserved.
- If the upper layer's mask bit is
- Dual Colorization via
overlay_or_rgb: Monochrome grayscale images are dynamically projected into RGB space. The clock receives thecolor(e.g., Orange) and the animation receives theanimation_color(e.g., Blue), ensuring perfect readability of clock digits over retro pinball animations. - Pixel-art upscaling β public API used by
BitmapFont.render_text()and any other caller that needs to scale SD content to HD displays:upscale_nx(img, scale, mode): General dispatcher for arbitrary integer scale factors. Routes to the best algorithm for the given scale:upscale_2xfor scale=2,scale3xfor scale=3 withmode="epx", and nearest-neighbor for all other scales. This is the preferred entry point when the scale factor is not known at compile time.upscale_2x(img, mode): Dispatcher for 2Γ upscaling.mode="epx"(default),mode="hq2x", ormode="nearest".epx_upscale_2x(img): EPX/Scale2x algorithm. Smooths diagonal edges and corners without introducing new colors or blurring. A hole-prevention rule ensures filled pixels are never replaced by empty ones (avoids gaps at inner corners of shapes such as "E"). Works on modeL(grayscale) images.hq2x(img): hq2x (High Quality 2Γ) algorithm by Maxim Stepin. Produces smoother curves and anti-aliased diagonals via neighbor interpolation. May introduce intermediate gray values. Best for pre-computed content (fonts, animations) where upscaling happens once at initialization. Works on modeL(grayscale) images.nearest_upscale_2x(img): Simple pixel doubling viaImage.Resampling.NEAREST. Fastest method, works on any PIL image mode.scale3x(img): Scale3x/AdvMAME3x algorithm. Generalizes EPX to the 3Γ case β each source pixel expands to a 3Γ3 output block using neighbor comparisons on the full 3Γ3 neighborhood. Never introduces new colors; preserves pixel-art style. Useful for 3Γ scale factors (e.g., 128Γ32 β 384Γ96). Works on modeL(grayscale) images._upscale_mask_2x(...): Internal helper that expands a DotBlt bit-mask 2Γ (each set bit β 2Γ2 block) so DotBlt compositing remains correct after scaling. Called automatically by both 2Γ upscalers whenmask_datais present on the source image._upscale_mask_nx(...): Internal helper that expands a DotBlt bit-mask by an arbitrary integer scale factor N (each set bit β NΓN block). Used byscale3xand the nearest-neighbor fallback inupscale_nxto keep DotBlt compositing correct at any scale.
Extensible plugin architecture that allows contributors to author display plugins alternating with the default clock display during attract mode.
PluginManager(plugin_manager.py): Top-level orchestrator for the plugin lifecycle. Responsibilities:- Discovery: Scans built-in plugins (
zeclock/plugins/) then user plugins (~/.zeclock/plugins/), importing Python files and findingClockPluginsubclasses. - Validation: Checks plugin name format, description, and frame_delay_ms before registration.
- Loading: Instantiates plugin classes, handles import errors gracefully (logs WARNING, skips file).
- Override logic: User plugins with the same name as a built-in plugin replace the built-in.
- Configuration injection: Merges plugin-specific YAML config with a
_helperskey containing thePluginHelpersinstance. - Scheduling: Selects the next plugin via weighted random selection from normalized frequencies.
- Activation: Calls
initialize(config)with a 10-second timeout; marks plugin as failed on error. - Frame rendering: Calls
render_frame()with a 2-second timeout; holds last good frame on errors; deactivates after 5 consecutive failures. - Duration enforcement: Stops a plugin after 30 seconds or when it returns
None.
- Discovery: Scans built-in plugins (
PluginRegistry(plugin_registry.py): Internal data structure holding all loaded plugins with their state (active, failed), frequency, error count, and source (builtin/user). Provides frequency normalization so active plugins always sum to 100%.PluginConfig(plugin_config.py): Loads~/.zeclock/config/plugins.yaml, creates defaults if missing, clamps values (frequency 0β100, clock_display_seconds 1β300), and handles invalid YAML gracefully.ClockPluginABC (base.py): Defines the plugin interface contract. Plugins must implement:nameproperty: unique identifier (1β64 lowercase alphanumeric, hyphens, underscores).descriptionproperty: human-readable description (1β256 characters).frame_delay_msproperty: desired delay between frames (20β5000 ms).async initialize(config): prepare the plugin for rendering.async render_frame(width, height): produce a single RGB PIL Image frame, orNoneto signal completion.async cleanup(): release resources on deactivation.
PagedPlugin(base.py): Abstract subclass ofClockPluginfor plugins that cycle through multiple display pages. Handles frame counting, page advancement, and automatic completion signaling. Subclasses implementrender_page(page, width, height)instead ofrender_frame(). Acceptspage_duration_seconds(2β30s, default 4) and manages internal paging state via_init_paging().- Validation Utilities:
validate_plugin_name()andvalidate_plugin_description()enforce naming and description constraints at registration time. - Built-in Plugins:
- PinballPlugin (
pinball_plugin.py): Wraps the existing.scnanimation playback logic with DotBlt clock overlay composition. Randomly selects a scene file, pre-computes frames progressively in a background thread sorender_frame()can start serving frames before all are ready β the clock keeps rendering while computation continues. Respects storyboard metadata (clock_style, custom_x, custom_y, frame_layer), and supports independent clock/animation colors. Thread-safe access to the frame list is protected by athreading.Lock. - PongPlugin (
pong_plugin.py): Animated Pong game with real scoring and human-like AI. Two paddles play with randomized imperfections (reaction delay, limited speed, prediction error). The ball speeds up after each rally. A game resets after one side reaches 5 points or the plugin duration expires. Features score flash animations, serve delay blinking, and renders scores using the MENU font. - GifPlugin (
gif_plugin.py): Picks a random animated GIF from a configurable directory (~/.zeclock/plugins/gif/by default), extracts frames progressively in a background thread sorender_frame()can start serving frames before all are ready β the clock keeps rendering during loading. Scales frames to fit the display dimensions using a two-path strategy (pixel-perfect GIFs whose dimensions are exact integer multiples of the display size use the configured upscale algorithm βepx,hq2x, ornearestβ while GIFs requiring arbitrary rescaling use LANCZOS). Thread-safe access to the frame list is protected by athreading.Lock. Plays the animation once, then signals completion. - WeatherPlugin (
weather_plugin.py): Fetches weather data from the Open-Meteo API and cycles through display pages (current conditions, tomorrow's forecast, 3-day outlook) with configurable page duration. Supports 15-minute caching, staleness indicators, and Celsius/Fahrenheit units. - SpeakerTimerPlugin (
speaker_timer_plugin.py): Conference countdown timer designed for stage visibility. Displays large countdown digits with automatic color transitions based on remaining time (green β yellow at 5 min β red at 1 min). Supports start/pause/reset via class-level methods called by the web API, configurable presets (Lightning 5 min, Short 20 min, Standard 45 min), and overtime counting (switches to count-up with negative display after reaching zero). Uses class-level state to persist across activations and never signals completion on its own β stays active until force-deactivated. Blinks the display when paused. - StockPlugin (
stock_plugin.py): Fetches stock quotes from Yahoo Finance (no API key required) and displays current price, daily change, and change percentage for configured ticker symbols. Shows one symbol per page with configurable page duration. Detects market state (OPEN, PRE, POST, CLOSED) and displays extended hours price and variation when in pre/post market. Supports 10-minute caching with a staleness indicator.
- PinballPlugin (
PluginHelpers(helpers.py): Shared rendering utilities injected into plugins at initialization. Provides:create_frame(): Creates blank RGB PIL Images at the correct display dimensions.render_text(): Renders text using the BitmapFont system with positioning and colorization.draw_icon(): Draws 16Γ16 pixel-art icons from raw bitmap data onto frames.composite_frames(): Merges foreground onto background using DotBlt-style OR blending (non-black pixels overwrite).get_font_names()/get_text_width(): Font discovery and text measurement for layout calculations.draw_staleness_indicator(): Draws a blinking red dot in the top-right corner when data is stale (used by WeatherPlugin and StockPlugin).ConfettiAnimation: Reusable particle animation class. Particles shoot upward from configurable cannon positions and fall with gravity. Supports three intensity levels (small/medium/big) and multiple color palettes (CONFETTI_COLORS_PARTY,CONFETTI_COLORS_WARM,CONFETTI_COLORS_COOL).
Manages automatic brightness adjustment based on time-of-day schedules, day-of-week rules, and sunrise/sunset data.
- Day-of-week scheduling: Configurable time ranges with brightness percentages per day (e.g., Monday 08:00β16:00 β 50%). Falls back to a
defaultschedule when no day-specific rule matches. - Sunrise/sunset integration: Fetches sun times from the sunrise-sunset.org API (no API key required), caches for 30 minutes, and applies day/night brightness levels automatically based on geographic location.
- Dual brightness control: Maps user-facing 0β100% brightness to a combination of hardware brightness (0β15) and software dimming (0β100%). Ultra-low brightness (below HW minimum) is achieved via progressive SW dimming at HW level 1.
- "Time only" mode: Disables plugins during configured hours (e.g., nighttime) β only the clock is displayed at minimum brightness.
- Animation suppression: Optionally disables animations during configured hours (e.g., 22:00β08:00).
- Efficient caching: Checks once per minute and caches the result to avoid repeated computation.
apply_sw_dimming(image, dim_percent): Fast software dimming using Pillow'spoint()with a pre-built LUT (C-native processing, no NumPy dependency).
Module providing external control of the running zeClock instance via network protocols. Both MQTT and REST interfaces share a common CommandHandler that translates incoming commands into actions on the ZeClock instance.
CommandHandler(remote/command_handler.py): Central command execution logic. DefinesRemoteCommanddata structures and dispatches commands (screen on/off, force plugin, display free text, adjust brightness) to the appropriate ZeClock methods.MqttRemote(remote/mqtt_remote.py): MQTT-based remote control. Primary protocol for bidirectional communication:- Publishes state (active plugin, brightness, on/off status).
- Subscribes to command topics (on/off, force plugin, display text).
- Supports Home Assistant MQTT Discovery for automatic entity creation.
RestRemote(remote/rest_remote.py): REST API (HTTP) for simple integrations, Recalbox Web Manager compatibility, and the built-in Web UI. Provides endpoints for the same command set as MQTT, plus a plugin listing endpoint and a dedicated Speaker Timer sub-API. Serves a static Web UI fromremote/web/at/ui/(with root/redirecting to it).- Web UI: Static HTML/JS/CSS served from
zeclock/remote/web/. Provides a browser-based control panel for the clock (accessible athttp://<host>:<port>/). - Plugin list endpoint (
GET /api/plugins): Returns all registered plugins with their state, frequency, source, active/forced status, and optionalweb_controlsmetadata. - Brightness sub-API (
/api/brightness):GETreturns current brightness state (override value, SW dimming level, time-only mode, auto/manual mode).POSTsets manual brightness (0β15).POST /api/brightness/autoresumes automatic brightness scheduling. - Speaker Timer sub-API (
/api/speaker-timer/*): Dedicated endpoints for controlling the SpeakerTimerPlugin remotely β status, start (also forces the plugin active), pause, reset (also resumes normal rotation), and set duration. - Alert endpoint (
POST /api/alert): Displays a rich alert with blinking border, auto-wrapped text, and optional icon on the DMD. Accepts{"text": "...", "duration": 15, "icon": "person", "color": [255,0,0]}. Duration clamped to 1β300s.
- Web UI: Static HTML/JS/CSS served from
graph LR
subgraph "Remote Control Module"
CH["CommandHandler"]
MQTT["MqttRemote"]
REST["RestRemote"]
WEBUI["Web UI #40;static#41;"]
end
MQTT -->|commands| CH
REST -->|commands| CH
CH -->|actions| ZC["ZeClock Instance"]
HA["Home Assistant"] <-->|MQTT Discovery| MQTT
EXT["External Systems"] -->|HTTP| REST
EXT2["Recalbox"] -->|HTTP| REST
BROWSER["Browser"] -->|HTTP| WEBUI
BROWSER -->|API calls| REST
Runtime initialization module that detects and automatically installs system dependencies:
- Platform Detection: Linux x64/aarch64, macOS arm64/x64, Windows x64.
- libzedmd Installation: Downloads the latest release from
PPUC/libzedmdon GitHub to~/.zeclock/lib/. - DotClk Animations Installation: Downloads 2300+ animations from
sigmafx/DotClk-Resources. Fonts are bundled in the package (zeclock/resources/fonts/) and do not require downloading. - Interactive or Automatic Mode: User prompt by default, or
--bootstrapfor silent installation. - dmdserver Installation (optional): Available via
--with-dmdserverfor development setups.
Logical steps executed each frame to build the final image sent to the display:
sequenceDiagram
participant C as clock.py #40;ZeClock#41;
participant F as fnt_reader.py #40;BitmapFont#41;
participant O as overlay.py #40;Overlay#41;
participant B as DMDBackend #40;backends/#41;
C->>C: Event loop tick #40;dynamic timing#41;
C->>F: Render current time text #40;"12:34" or "12 34"#41;
F->>F: Assemble .fnt font glyphs #40;centered or custom#41;
F->>F: Pack text mask #40;bytearray bitwise ops#41;
F-->>C: Return PIL Image #40;L-mode + mask_data attribute#41;
alt Retro pinball animation is active
C->>O: Merge time and animation frame #40;overlay_or_rgb#41;
O->>O: Extract and apply DotBlt binary mask
O->>O: Apply RGB tints #40;Clock Color vs Animation Color#41;
O-->>C: Return merged frame #40;PIL RGB-mode#41;
else No animation
C->>C: Colorize time image to RGB #40;per-pixel bytearray#41;
end
C->>B: Send final RGB image via send_frame#40;#41;
B->>B: ZeDMDBackend: send RGB888 directly #40;no conversion needed#41;
B->>B: DMDServerBackend: convert RGB888 to RGB565 big-endian for TCP
B->>B: Transmit to hardware #40;libzedmd ctypes#41; or TCP socket
stateDiagram-v2
[*] --> ClockPage: Startup
ClockPage --> PluginSelect: Clock returns None #40;page done#41;
PluginSelect --> PluginActive: Plugin selected #40;weighted random#41;
PluginActive --> ClockPage: Plugin signals completion or 30s max
PluginActive --> ClockPage: 5 consecutive errors #40;deactivate plugin#41;
ClockPage --> ClockPage: Refresh every 500ms #40;colon blink#41;
note right of PluginSelect: PluginManager selects via\nnormalized frequency weights
note left of ClockPage: Pages cycle: time β time+date β time+day
- ClockPage: Displays one clock page (time, time+date, or time+day) with colon blinking every 500ms. After
page_duration_secondselapses, the clock returnsNoneto signal completion to the main loop. On the next clock activation, the next page in the cycle is shown. - PluginSelect: PluginManager selects the next plugin using weighted random selection from normalized frequencies.
- PluginActive: The selected plugin renders frames at its configured
frame_delay_ms. Ends when the plugin returnsNone, 30 seconds elapse, or 5 consecutive errors occur. - Fallback: If all plugins are deactivated due to errors, the system falls back to the built-in pinball animation behavior.