From 8050b270fb709b190895aa1534e6781d95bd0f9a Mon Sep 17 00:00:00 2001 From: Charles Mynard Date: Tue, 28 Jul 2026 21:15:35 +0000 Subject: [PATCH 1/7] Add Pomodoro Timer plugin with MQTT + Home Assistant control A configurable focus/break timer rendered on the matrix and driven over MQTT, modelled on the on-air plugin's MQTT + HA auto-discovery approach. Timer: - Configurable work, short break, and long break lengths, plus sessions per set; auto-start breaks and/or the next work session independently. - Runs on its own thread so it keeps counting and publishing whether or not it is the screen currently on the panel. - Holds the display while a session is active (optional) and grabs the panel with a flash when a phase ends. Display: - Phase label, MM:SS countdown, session dots, and a phase progress bar, each individually toggleable and colourable. - Countdown font is fitted to the panel; layout is stacked on smaller panels and side-by-side on long ones (256x32). Labels that still do not fit are truncated rather than drawn past the panel edge. MQTT: - Command topic accepts START/PAUSE/RESUME/TOGGLE/SKIP/STOP/RESET and the phase commands, plus JSON for one-off durations and labels and for changing the configured durations. - Publishes state, status, phase, remaining, remaining_seconds, session count, a JSON attributes snapshot, transition events, and availability (with LWT). - HA auto-discovery announces 17 entities: switch, five control buttons, four duration number boxes, five sensors, a timer-event entity, and connectivity. Duration boxes write back so the timer can be set from Home Assistant. Verified with the core safety harness across all eight sampled panel sizes (goldens committed for 64x32, 128x32, 128x64, 256x32), a 21-case self-contained test suite, and an end-to-end run against mosquitto. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AtZDGDrzbWdK4JxUeH4hk6 --- plugins.json | 25 + plugins/pomodoro-timer/LICENSE | 17 + plugins/pomodoro-timer/README.md | 431 ++++++ plugins/pomodoro-timer/config_schema.json | 368 +++++ plugins/pomodoro-timer/manager.py | 1256 +++++++++++++++++ plugins/pomodoro-timer/manifest.json | 31 + plugins/pomodoro-timer/requirements.txt | 1 + .../test/golden/128x32/pomodoro.png | Bin 0 -> 824 bytes .../test/golden/128x64/pomodoro.png | Bin 0 -> 730 bytes .../test/golden/256x32/pomodoro.png | Bin 0 -> 1050 bytes .../test/golden/64x32/pomodoro.png | Bin 0 -> 593 bytes plugins/pomodoro-timer/test/harness.json | 21 + plugins/pomodoro-timer/test_pomodoro_timer.py | 420 ++++++ 13 files changed, 2570 insertions(+) create mode 100644 plugins/pomodoro-timer/LICENSE create mode 100644 plugins/pomodoro-timer/README.md create mode 100644 plugins/pomodoro-timer/config_schema.json create mode 100644 plugins/pomodoro-timer/manager.py create mode 100644 plugins/pomodoro-timer/manifest.json create mode 100644 plugins/pomodoro-timer/requirements.txt create mode 100644 plugins/pomodoro-timer/test/golden/128x32/pomodoro.png create mode 100644 plugins/pomodoro-timer/test/golden/128x64/pomodoro.png create mode 100644 plugins/pomodoro-timer/test/golden/256x32/pomodoro.png create mode 100644 plugins/pomodoro-timer/test/golden/64x32/pomodoro.png create mode 100644 plugins/pomodoro-timer/test/harness.json create mode 100644 plugins/pomodoro-timer/test_pomodoro_timer.py diff --git a/plugins.json b/plugins.json index 37aed422..0aeafc22 100644 --- a/plugins.json +++ b/plugins.json @@ -1120,6 +1120,31 @@ "screenshot": "https://raw.githubusercontent.com/ChuckBuilds/ledmatrix-plugins/main/plugins/incoming-packages/assets/screenshot.png", "latest_version": "1.0.0", "icon": "fas fa-box" + }, + { + "id": "pomodoro-timer", + "name": "Pomodoro Timer", + "description": "A configurable Pomodoro focus/break timer for your matrix. Set the work and break lengths, then start, pause, skip, or reset it over MQTT — with Home Assistant auto-discovery so the whole timer shows up as a device with no YAML.", + "author": "ChuckBuilds", + "category": "productivity", + "tags": [ + "pomodoro", + "timer", + "focus", + "productivity", + "mqtt", + "home-assistant" + ], + "repo": "https://github.com/ChuckBuilds/ledmatrix-plugins", + "branch": "main", + "plugin_path": "plugins/pomodoro-timer", + "stars": 0, + "downloads": 0, + "last_updated": "2026-07-28", + "verified": true, + "screenshot": "", + "latest_version": "1.0.0", + "icon": "fa-hourglass-half" } ] } diff --git a/plugins/pomodoro-timer/LICENSE b/plugins/pomodoro-timer/LICENSE new file mode 100644 index 00000000..e653a0c1 --- /dev/null +++ b/plugins/pomodoro-timer/LICENSE @@ -0,0 +1,17 @@ +GNU GENERAL PUBLIC LICENSE +Version 3, 29 June 2007 + +Copyright (C) 2025 LEDMatrix Team + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . diff --git a/plugins/pomodoro-timer/README.md b/plugins/pomodoro-timer/README.md new file mode 100644 index 00000000..c1a033ff --- /dev/null +++ b/plugins/pomodoro-timer/README.md @@ -0,0 +1,431 @@ +# Pomodoro Timer + +A focus/break timer for your LED matrix, driven over MQTT. Set the work and +break lengths however you like, then start, pause, skip, or reset the timer from +Home Assistant, a dashboard button, a voice assistant, or a one-line +`mosquitto_pub`. The matrix shows the phase you're in, the countdown, how far +through the phase you are, and how many sessions you've banked toward the long +break. + +Home Assistant MQTT auto-discovery is on by default, so the whole timer — +switch, buttons, duration boxes, and sensors — appears as a device with no YAML. + +--- + +## Table of Contents + +1. [How It Works](#how-it-works) +2. [What's On Screen](#whats-on-screen) +3. [Quick Start](#quick-start) +4. [Plugin Configuration](#plugin-configuration) +5. [MQTT Reference](#mqtt-reference) + - [Commands](#commands) + - [JSON Payloads](#json-payloads) + - [Published Topics](#published-topics) +6. [Home Assistant Setup](#home-assistant-setup) + - [Auto-Discovery](#auto-discovery-recommended) + - [What You Get](#what-you-get) + - [Automation Examples](#automation-examples) + - [Manual YAML](#manual-yaml-if-you-turn-discovery-off) +7. [Testing Without Home Assistant](#testing-without-home-assistant) +8. [Troubleshooting](#troubleshooting) + +--- + +## How It Works + +```text +Home Assistant ──MQTT──► ledmatrix/pomodoro/set ──► timer starts / pauses / skips +LED matrix ──MQTT──► ledmatrix/pomodoro/state ──► HA switch stays in sync +LED matrix ──MQTT──► ledmatrix/pomodoro/event ──► "work_complete" fires your automations +``` + +The timer is a classic Pomodoro cycle: + +```text +work ─► short break ─► work ─► short break ─► work ─► short break ─► work ─► LONG break ─► … + (4 sessions per set, configurable) +``` + +It runs in its own thread, so it keeps counting and keeps publishing state +whether or not it's the screen currently on the panel. By default it **holds the +display** for as long as a session is active, then hands the panel back to your +normal rotation when the timer goes idle. + +--- + +## What's On Screen + +```text +┌──────────────────────────────┐ +│ FOCUS │ ← phase label (or PAUSED) +│ 17:42 │ ← countdown, colored by phase +│ ●●○○ │ ← one dot per session in the set +│ ▓▓▓▓▓▓▓▓▓░░░░░░░░░░░░░░░░░░░ │ ← progress through the current phase +└──────────────────────────────┘ +``` + +Each element can be turned off, and each color can be changed. The layout adapts +to the panel: stacked on 64×32, 128×32, and 128×64; label and dots beside a large +countdown on long panels like 256×32. + +| Phase | Default color | +|---|---| +| Work | red | +| Short break | green | +| Long break | blue | +| Paused | amber | +| Idle | grey | + +When a phase ends the panel flashes for a few seconds so you notice even if you +weren't looking — that alert length is configurable, and can be turned off. + +--- + +## Quick Start + +1. **Install the plugin** from the LEDMatrix plugin store and open its + configuration tab. +2. **Set your MQTT broker host** (typically your Home Assistant IP or + `homeassistant.local`). +3. **Set your durations** — work, short break, long break, and sessions per set. +4. **Save.** The plugin connects, subscribes, and announces itself to Home + Assistant. +5. **Test it:** + +```bash +mosquitto_pub -h -t ledmatrix/pomodoro/set -m START +mosquitto_pub -h -t ledmatrix/pomodoro/set -m PAUSE +mosquitto_pub -h -t ledmatrix/pomodoro/set -m RESET +``` + +Not using MQTT at all? Turn **Enable MQTT Control** off and turn **Start Timer +When Plugin Is Enabled** on — the timer then runs the cycle on its own from the +durations you configured. + +--- + +## Plugin Configuration + +### Timer + +| Field | Default | Description | +|---|---|---| +| **Work Session (minutes)** | `25` | Length of a focus session. Also settable live from Home Assistant. | +| **Short Break (minutes)** | `5` | Break after each work session. | +| **Long Break (minutes)** | `15` | Break after a full set of work sessions. | +| **Sessions Before Long Break** | `4` | How many work sessions make a set — this is the number of dots on screen. | +| **Auto-Start Breaks** | `true` | Roll straight into the break when work ends. Off makes the break wait for a Start/Resume. | +| **Auto-Start Next Work Session** | `false` | Roll straight back into work when a break ends. Off by default so you decide when to go again. | +| **Start Timer When Plugin Is Enabled** | `false` | Begin a work session as soon as the plugin loads. Handy without MQTT. | + +### MQTT + +| Field | Default | Description | +|---|---|---| +| **Enable MQTT Control** | `true` | Connect to a broker so the timer can be driven remotely. | +| **Broker Address** | `localhost` | IP or hostname of your MQTT broker. | +| **Broker Port** | `1883` | Use 8883 for TLS. | +| **Username / Password** | *(blank)* | Leave blank for an anonymous broker. | +| **Command Topic** | `ledmatrix/pomodoro/set` | Everything the plugin publishes is derived from this topic's base. | +| **State Topic** | `ledmatrix/pomodoro/state` | `ON` while a session is active, `OFF` when idle. | +| **Enable Home Assistant Auto-Discovery** | `true` | Announce the device to HA over MQTT. | +| **HA Discovery Prefix** | `homeassistant` | Only change this if you changed it in HA. | +| **Device Name in Home Assistant** | `LED Matrix — Pomodoro` | How the device is labelled in HA. | +| **State Publish Interval (seconds)** | `1` | How often the countdown is published while running. Raise it to cut broker traffic — state *changes* are always published immediately. | + +### Appearance + +| Field | Default | Description | +|---|---|---| +| **Countdown Color** | `phase` | `phase` colors the countdown by what's running; `fixed` always uses the Countdown Text Color. | +| **Work / Short Break / Long Break Color** | red / green / blue | Phase colors. | +| **Idle / Paused Color** | grey / amber | Used when nothing is running and when paused. | +| **Countdown Text Color** | white | Only used when Countdown Color is `fixed`. | +| **Background Color** | black | Panel background. | +| **Show Phase Label** | `true` | The phase name above the countdown. | +| **Show Session Dots** | `true` | One dot per session in the set, filled as you complete them. | +| **Show Progress Bar** | `true` | Bar along the bottom that fills as the phase elapses. | +| **Work / Short Break / Long Break / Idle / Paused Label** | `FOCUS` / `BREAK` / `LONG BREAK` / `POMODORO` / `PAUSED` | The on-screen text for each state. Blank the Paused Label to keep showing the phase name while paused. | +| **Font** | *(blank)* | Path to a TTF relative to the LEDMatrix root, e.g. `assets/fonts/PressStart2P-Regular.ttf`. Blank uses the display's default font. | +| **Font Size (px)** | `0` | Fix the countdown height in pixels. `0` sizes it automatically to the panel. | + +### Behavior + +| Field | Default | Description | +|---|---|---| +| **Hold the Display While Running** | `true` | Take over the matrix while a session is active instead of rotating. Off lets the timer take a normal turn in the rotation. | +| **Phase-Change Alert (seconds)** | `8` | How long the timer grabs the display when a phase ends. `0` disables it. | +| **Flash on Phase Change** | `true` | Flash the panel during that alert. | +| **Display Duration (seconds)** | `10` | Time on screen per rotation cycle when it isn't holding the display. | + +--- + +## MQTT Reference + +All topics below are derived from the **Command Topic**. With the default +`ledmatrix/pomodoro/set`, the base is `ledmatrix/pomodoro`. + +### Commands + +Publish any of these as a plain string to the command topic (case-insensitive): + +| Payload | Effect | +|---|---| +| `START` / `ON` | Start a work session — or resume a paused one. | +| `PAUSE` | Freeze the countdown where it is. | +| `RESUME` | Continue a paused countdown. | +| `TOGGLE` | Start, pause, or resume depending on the current state. Perfect for a single physical button. | +| `SKIP` | Abandon the current phase and move to the next one. A skipped work session doesn't count toward the set. | +| `STOP` / `OFF` | Stop and go idle, keeping the session count. | +| `RESET` | Stop, go idle, and zero the session count. | +| `WORK` | Start a work session immediately, whatever was running. | +| `SHORT_BREAK` / `BREAK` | Start a short break immediately. | +| `LONG_BREAK` | Start a long break immediately. | + +Anything unrecognized is ignored, so a stray message can never be mistaken for a +stop. + +### JSON Payloads + +Send an object to the same topic for one-off durations, labels, and setting +changes: + +```jsonc +// A 50-minute deep-work block with its own on-screen label. +// The configured Work Session length is left alone. +{"command": "start", "phase": "work", "duration_minutes": 50, "label": "DEEP WORK"} + +// Change the configured durations without starting anything. +{"work_minutes": 45, "short_break_minutes": 10, "sessions_before_long_break": 3} + +// Change a duration and start in one message. +{"command": "start", "work_minutes": 30} +``` + +| Key | Meaning | +|---|---| +| `command` (or `action`, `state`) | Any command from the table above. | +| `phase` | `work`, `short_break`, or `long_break` — which phase `start` should begin. | +| `duration_minutes` / `duration_seconds` | Length of *this* phase only. | +| `label` | On-screen text for this phase only. | +| `work_minutes`, `short_break_minutes`, `long_break_minutes`, `sessions_before_long_break` | Update the configured values. | + +> Duration and label overrides last for the phase they start. Changes to +> `work_minutes` and friends apply from that moment on but are **not** written +> back to the plugin's saved configuration — set them in the web UI to make them +> permanent. + +### Published Topics + +| Topic | Payload | +|---|---| +| `/state` | `ON` when a session is active, `OFF` when idle | +| `/status` | `running`, `paused`, or `idle` | +| `/phase` | `idle`, `work`, `short_break`, or `long_break` | +| `/remaining` | `MM:SS` | +| `/remaining_seconds` | Integer seconds | +| `/session` | Completed work sessions | +| `/attributes` | JSON snapshot of everything above plus `cycle_position`, `elapsed_fraction`, and the configured durations | +| `/event` | JSON `{"event_type": …}` on every transition — `started`, `paused`, `resumed`, `stopped`, `reset`, `skipped`, `phase_started`, `work_complete`, `break_complete` | +| `/available` | `online` / `offline` (also the last-will message) | +| `/work_minutes` etc. | Current value of each duration setting | + +Each duration also has a matching command topic — `/work_minutes/set`, +`/short_break_minutes/set`, `/long_break_minutes/set`, +`/sessions_before_long_break/set` — which is what the Home Assistant +number boxes write to. + +--- + +## Home Assistant Setup + +### Auto-Discovery (recommended) + +1. Make sure the **MQTT integration** is set up in Home Assistant + (*Settings → Devices & Services → Add Integration → MQTT*) and pointed at the + same broker. +2. Leave **Enable Home Assistant Auto-Discovery** on in the plugin config. +3. Save. The device appears under *Settings → Devices & Services → MQTT* within + a few seconds. + +### What You Get + +| Entity | Type | What it does | +|---|---|---| +| **Timer** | Switch | On starts a session, off stops it. Carries the full state as attributes. | +| **Start / Pause / Resume / Skip Phase / Reset** | Buttons | One press per command. | +| **Work Minutes / Short Break Minutes / Long Break Minutes / Sessions Before Long Break** | Numbers | Set the durations from HA — the matrix picks them up immediately. | +| **Phase** | Sensor | `idle`, `work`, `short_break`, `long_break` | +| **Status** | Sensor | `running`, `paused`, `idle` | +| **Time Remaining** | Sensor | `MM:SS` | +| **Seconds Remaining** | Sensor | Numeric, for gauges and templates | +| **Completed Sessions** | Sensor | Running count, resets on `RESET` | +| **Timer Event** | Event | Fires on every transition — the clean hook for automations | +| **MQTT Connected** | Binary sensor | Connectivity | + +### Automation Examples + +**Announce the end of a work session on a speaker:** + +```yaml +automation: + - alias: "Pomodoro — time for a break" + triggers: + - trigger: state + entity_id: event.led_matrix_pomodoro_timer_event + attribute: event_type + to: work_complete + actions: + - action: tts.speak + target: + entity_id: tts.piper + data: + media_player_entity_id: media_player.office + message: "Nice work. Take a break." +``` + +**Start a session when you sit down at your desk:** + +```yaml +automation: + - alias: "Pomodoro — start on desk occupancy" + triggers: + - trigger: state + entity_id: binary_sensor.desk_occupancy + to: "on" + for: "00:02:00" + conditions: + - condition: state + entity_id: sensor.led_matrix_pomodoro_phase + state: idle + actions: + - action: button.press + target: + entity_id: button.led_matrix_pomodoro_start +``` + +**Turn on do-not-disturb for the length of a work session:** + +```yaml +automation: + - alias: "Pomodoro — focus mode" + triggers: + - trigger: state + entity_id: sensor.led_matrix_pomodoro_phase + actions: + - action: "switch.turn_{{ 'on' if trigger.to_state.state == 'work' else 'off' }}" + target: + entity_id: switch.office_do_not_disturb +``` + +**Dashboard card:** + +```yaml +type: entities +title: Pomodoro +entities: + - entity: sensor.led_matrix_pomodoro_time_remaining + - entity: sensor.led_matrix_pomodoro_phase + - entity: sensor.led_matrix_pomodoro_completed_sessions + - type: buttons + entities: + - entity: button.led_matrix_pomodoro_start + - entity: button.led_matrix_pomodoro_pause + - entity: button.led_matrix_pomodoro_skip_phase + - entity: button.led_matrix_pomodoro_reset + - entity: number.led_matrix_pomodoro_work_minutes + - entity: number.led_matrix_pomodoro_short_break_minutes +``` + +> Entity IDs are generated from the device name — check +> *Settings → Devices & Services → MQTT → LED Matrix — Pomodoro* for the exact +> ones on your system. + +### Manual YAML (if you turn discovery off) + +```yaml +mqtt: + switch: + - name: "Pomodoro" + command_topic: "ledmatrix/pomodoro/set" + state_topic: "ledmatrix/pomodoro/state" + payload_on: "START" + payload_off: "STOP" + state_on: "ON" + state_off: "OFF" + json_attributes_topic: "ledmatrix/pomodoro/attributes" + availability_topic: "ledmatrix/pomodoro/available" + icon: mdi:timer-play-outline + + sensor: + - name: "Pomodoro Time Remaining" + state_topic: "ledmatrix/pomodoro/remaining" + availability_topic: "ledmatrix/pomodoro/available" + - name: "Pomodoro Phase" + state_topic: "ledmatrix/pomodoro/phase" + availability_topic: "ledmatrix/pomodoro/available" + + button: + - name: "Pomodoro Skip" + command_topic: "ledmatrix/pomodoro/set" + payload_press: "SKIP" + + number: + - name: "Pomodoro Work Minutes" + command_topic: "ledmatrix/pomodoro/work_minutes/set" + state_topic: "ledmatrix/pomodoro/work_minutes" + min: 1 + max: 180 + unit_of_measurement: "min" +``` + +--- + +## Testing Without Home Assistant + +```bash +# Watch everything the plugin publishes +mosquitto_sub -h -t 'ledmatrix/pomodoro/#' -v + +# Drive it +mosquitto_pub -h -t ledmatrix/pomodoro/set -m START +mosquitto_pub -h -t ledmatrix/pomodoro/set -m PAUSE +mosquitto_pub -h -t ledmatrix/pomodoro/set -m SKIP + +# A one-off 50-minute block +mosquitto_pub -h -t ledmatrix/pomodoro/set \ + -m '{"command":"start","duration_minutes":50,"label":"DEEP WORK"}' + +# Change the configured work length +mosquitto_pub -h -t ledmatrix/pomodoro/work_minutes/set -m 45 +``` + +The plugin's own test suite runs without a broker or a LEDMatrix checkout: + +```bash +python plugins/pomodoro-timer/test_pomodoro_timer.py +``` + +--- + +## Troubleshooting + +| Symptom | Cause / fix | +|---|---| +| Nothing appears in Home Assistant | The MQTT integration isn't set up, or it points at a different broker. Check *Settings → Devices & Services → MQTT*, and confirm the discovery prefix matches (`homeassistant` unless you changed it). | +| Log says `paho-mqtt is not installed` | Install it on the Pi: `pip install paho-mqtt`. The plugin still renders; only remote control is unavailable. | +| Log says `MQTT connect failed rc=5` | Bad username/password, or the broker requires authentication. | +| Log says `MQTT connect error` and retries | Wrong host or port, or the broker isn't reachable from the Pi. The plugin retries with backoff, so fixing the config is enough. | +| The timer never appears on the matrix | Check the plugin is enabled and, if **Hold the Display While Running** is off, that it has a turn in your display rotation. | +| The timer takes over and won't give the panel back | That's **Hold the Display While Running**. Turn it off to keep the timer in the normal rotation, or send `STOP`. | +| The countdown looks tiny on a big panel | Set **Font Size (px)** to `0` so it auto-sizes, or raise it for a fixed size. | +| Text is cut off | Shorten the phase labels — long labels are truncated to fit rather than drawn past the panel edge. | +| Durations changed in HA revert after a restart | Number-box changes are runtime-only by design. Set them in the plugin's web UI config to make them permanent. | +| The session count reset itself | `RESET` zeroes it (that's the difference between `STOP` and `RESET`), and so does a long break for the on-screen dots. | + +--- + +## License + +GPL-3.0 — see [LICENSE](LICENSE). diff --git a/plugins/pomodoro-timer/config_schema.json b/plugins/pomodoro-timer/config_schema.json new file mode 100644 index 00000000..9a5171f0 --- /dev/null +++ b/plugins/pomodoro-timer/config_schema.json @@ -0,0 +1,368 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "Pomodoro Timer", + "description": "A focus/break timer on the matrix, controlled over MQTT or Home Assistant.", + "x-propertyOrder": [ + "enabled", + "work_minutes", + "short_break_minutes", + "long_break_minutes", + "sessions_before_long_break", + "auto_start_breaks", + "auto_start_work", + "auto_start_on_enable", + "mqtt_enabled", + "mqtt_host", + "mqtt_port", + "mqtt_username", + "mqtt_password", + "command_topic", + "state_topic", + "ha_discovery", + "discovery_prefix", + "device_name", + "publish_interval_seconds", + "color_mode", + "work_color", + "short_break_color", + "long_break_color", + "idle_color", + "paused_color", + "time_color", + "background_color", + "show_phase_label", + "show_session_dots", + "show_progress_bar", + "work_label", + "short_break_label", + "long_break_label", + "idle_label", + "paused_label", + "font_path", + "font_size", + "pin_while_running", + "alert_seconds", + "alert_flash", + "display_duration" + ], + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "title": "Enabled" + }, + + "work_minutes": { + "type": "integer", + "default": 25, + "minimum": 1, + "maximum": 180, + "title": "Work Session (minutes)", + "description": "Length of a focus session. Also settable at runtime from Home Assistant." + }, + "short_break_minutes": { + "type": "integer", + "default": 5, + "minimum": 1, + "maximum": 60, + "title": "Short Break (minutes)", + "description": "Break taken after each work session." + }, + "long_break_minutes": { + "type": "integer", + "default": 15, + "minimum": 1, + "maximum": 120, + "title": "Long Break (minutes)", + "description": "Longer break taken after a full set of work sessions." + }, + "sessions_before_long_break": { + "type": "integer", + "default": 4, + "minimum": 1, + "maximum": 12, + "title": "Sessions Before Long Break", + "description": "How many work sessions make up one set. Shown on screen as the row of session dots." + }, + "auto_start_breaks": { + "type": "boolean", + "default": true, + "title": "Auto-Start Breaks", + "description": "Start the break automatically when a work session ends. Turn this off to make the break wait for a Start/Resume command." + }, + "auto_start_work": { + "type": "boolean", + "default": false, + "title": "Auto-Start Next Work Session", + "description": "Start the next work session automatically when a break ends. Off by default so you decide when to get back to it." + }, + "auto_start_on_enable": { + "type": "boolean", + "default": false, + "title": "Start Timer When Plugin Is Enabled", + "description": "Begin a work session as soon as the plugin loads. Useful if you don't use MQTT.", + "x-advanced": true + }, + + "mqtt_enabled": { + "type": "boolean", + "default": true, + "title": "Enable MQTT Control", + "description": "Connect to an MQTT broker so the timer can be driven from Home Assistant or any MQTT client. Turn off to run the timer purely from this configuration." + }, + "mqtt_host": { + "type": "string", + "default": "localhost", + "title": "Broker Address", + "description": "Hostname or IP of your MQTT broker — usually the same address as your Home Assistant instance." + }, + "mqtt_port": { + "type": "integer", + "default": 1883, + "minimum": 1, + "maximum": 65535, + "title": "Broker Port", + "description": "Default is 1883. Use 8883 for TLS." + }, + "mqtt_username": { + "type": "string", + "default": "", + "title": "Username", + "description": "MQTT broker username. Leave blank if your broker does not require authentication." + }, + "mqtt_password": { + "type": "string", + "default": "", + "title": "Password", + "description": "MQTT broker password. Leave blank if your broker does not require authentication.", + "x-sensitive": true + }, + "command_topic": { + "type": "string", + "default": "ledmatrix/pomodoro/set", + "title": "Command Topic", + "description": "The plugin subscribes here. Publish START, PAUSE, RESUME, TOGGLE, SKIP, STOP or RESET — or JSON for per-message durations and labels. Every other topic is derived from this one." + }, + "state_topic": { + "type": "string", + "default": "ledmatrix/pomodoro/state", + "title": "State Topic", + "description": "The plugin publishes ON (a session is active) or OFF (idle) here so the Home Assistant switch stays in sync.", + "x-advanced": true + }, + "ha_discovery": { + "type": "boolean", + "default": true, + "title": "Enable Home Assistant Auto-Discovery", + "description": "Announce the timer to Home Assistant over MQTT. HA creates a device with the switch, start/pause/skip/reset buttons, duration number boxes, and phase/remaining sensors — no configuration.yaml entry needed." + }, + "discovery_prefix": { + "type": "string", + "default": "homeassistant", + "title": "HA Discovery Prefix", + "description": "The MQTT topic prefix your HA MQTT integration listens on. Leave as 'homeassistant' unless you changed it in HA.", + "x-advanced": true + }, + "device_name": { + "type": "string", + "default": "LED Matrix — Pomodoro", + "title": "Device Name in Home Assistant", + "description": "How this device appears under Settings → Devices & Services → MQTT in Home Assistant.", + "x-advanced": true + }, + "publish_interval_seconds": { + "type": "integer", + "default": 1, + "minimum": 1, + "maximum": 60, + "title": "State Publish Interval (seconds)", + "description": "How often the countdown is published while running. Raise this to cut broker traffic; state changes (start, pause, phase change) are always published immediately.", + "x-advanced": true + }, + + "color_mode": { + "type": "string", + "enum": ["phase", "fixed"], + "default": "phase", + "title": "Countdown Color", + "description": "'phase' colors the countdown by what's running (work / break / long break / paused). 'fixed' always uses the Countdown Text Color below.", + "x-widget": "radio" + }, + "work_color": { + "type": "array", + "items": { "type": "integer", "minimum": 0, "maximum": 255 }, + "minItems": 3, + "maxItems": 3, + "default": [255, 70, 50], + "title": "Work Color", + "x-widget": "color-picker" + }, + "short_break_color": { + "type": "array", + "items": { "type": "integer", "minimum": 0, "maximum": 255 }, + "minItems": 3, + "maxItems": 3, + "default": [60, 200, 110], + "title": "Short Break Color", + "x-widget": "color-picker" + }, + "long_break_color": { + "type": "array", + "items": { "type": "integer", "minimum": 0, "maximum": 255 }, + "minItems": 3, + "maxItems": 3, + "default": [70, 150, 255], + "title": "Long Break Color", + "x-widget": "color-picker" + }, + "idle_color": { + "type": "array", + "items": { "type": "integer", "minimum": 0, "maximum": 255 }, + "minItems": 3, + "maxItems": 3, + "default": [110, 110, 110], + "title": "Idle Color", + "x-widget": "color-picker", + "x-advanced": true + }, + "paused_color": { + "type": "array", + "items": { "type": "integer", "minimum": 0, "maximum": 255 }, + "minItems": 3, + "maxItems": 3, + "default": [255, 176, 0], + "title": "Paused Color", + "x-widget": "color-picker", + "x-advanced": true + }, + "time_color": { + "type": "array", + "items": { "type": "integer", "minimum": 0, "maximum": 255 }, + "minItems": 3, + "maxItems": 3, + "default": [255, 255, 255], + "title": "Countdown Text Color", + "description": "Used only when Countdown Color is set to 'fixed'.", + "x-widget": "color-picker", + "x-advanced": true + }, + "background_color": { + "type": "array", + "items": { "type": "integer", "minimum": 0, "maximum": 255 }, + "minItems": 3, + "maxItems": 3, + "default": [0, 0, 0], + "title": "Background Color", + "x-widget": "color-picker", + "x-advanced": true + }, + + "show_phase_label": { + "type": "boolean", + "default": true, + "title": "Show Phase Label", + "description": "Show the current phase name above the countdown." + }, + "show_session_dots": { + "type": "boolean", + "default": true, + "title": "Show Session Dots", + "description": "Show one dot per work session in the set, filled in as you complete them." + }, + "show_progress_bar": { + "type": "boolean", + "default": true, + "title": "Show Progress Bar", + "description": "Show a bar along the bottom edge that fills as the current phase elapses." + }, + + "work_label": { + "type": "string", + "default": "FOCUS", + "maxLength": 24, + "title": "Work Label", + "x-advanced": true + }, + "short_break_label": { + "type": "string", + "default": "BREAK", + "maxLength": 24, + "title": "Short Break Label", + "x-advanced": true + }, + "long_break_label": { + "type": "string", + "default": "LONG BREAK", + "maxLength": 24, + "title": "Long Break Label", + "x-advanced": true + }, + "idle_label": { + "type": "string", + "default": "POMODORO", + "maxLength": 24, + "title": "Idle Label", + "description": "Shown when no timer is running, above the next work session's length.", + "x-advanced": true + }, + "paused_label": { + "type": "string", + "default": "PAUSED", + "maxLength": 24, + "title": "Paused Label", + "description": "Shown in place of the phase name while the timer is paused. Leave blank to keep showing the phase name.", + "x-advanced": true + }, + + "font_path": { + "type": "string", + "default": "", + "title": "Font", + "description": "Path to a TTF font file relative to the LEDMatrix root, e.g. assets/fonts/PressStart2P-Regular.ttf. Leave blank to use the display's default font.", + "x-advanced": true + }, + "font_size": { + "type": "integer", + "default": 0, + "minimum": 0, + "maximum": 64, + "title": "Font Size (px)", + "description": "Fix the countdown font height in pixels. Leave at 0 to size the countdown automatically to the panel.", + "x-advanced": true + }, + + "pin_while_running": { + "type": "boolean", + "default": true, + "title": "Hold the Display While Running", + "description": "Take over the matrix for as long as a session is active, instead of rotating through your other plugins. Turn off to let the timer take its normal turn in the rotation." + }, + "alert_seconds": { + "type": "integer", + "default": 8, + "minimum": 0, + "maximum": 60, + "title": "Phase-Change Alert (seconds)", + "description": "How long the timer grabs the display when a phase ends. Set to 0 to disable the alert." + }, + "alert_flash": { + "type": "boolean", + "default": true, + "title": "Flash on Phase Change", + "description": "Flash the panel during the phase-change alert.", + "x-advanced": true + }, + "display_duration": { + "type": "number", + "default": 10, + "minimum": 1, + "maximum": 300, + "title": "Display Duration (seconds)", + "description": "How long the timer stays on screen each rotation cycle when it isn't holding the display.", + "x-advanced": true + } + }, + "required": ["enabled", "work_minutes", "short_break_minutes", "long_break_minutes", "sessions_before_long_break"], + "additionalProperties": false +} diff --git a/plugins/pomodoro-timer/manager.py b/plugins/pomodoro-timer/manager.py new file mode 100644 index 00000000..4985a91f --- /dev/null +++ b/plugins/pomodoro-timer/manager.py @@ -0,0 +1,1256 @@ +""" +Pomodoro Timer Plugin for LEDMatrix + +A configurable focus/break (Pomodoro) timer rendered on the matrix and driven +over MQTT, with Home Assistant auto-discovery so the whole timer — start, +pause, resume, skip, reset, plus the work and break durations — appears as a +device in Home Assistant with no YAML. + +The timer runs in its own thread, so it keeps counting (and keeps publishing +state) whether or not the plugin is the screen currently on the panel. + +MQTT topics (all derived from the command topic's base): + /set — subscribe: START | PAUSE | RESUME | TOGGLE | STOP | + RESET | SKIP | WORK | SHORT_BREAK | LONG_BREAK, + or a JSON object (see _parse_command) + /state — publish: ON / OFF (HA switch) + /status — publish: running / paused / idle + /phase — publish: idle | work | short_break | long_break + /remaining — publish: MM:SS + /remaining_seconds — publish: integer seconds + /session — publish: completed work sessions + /attributes — publish: JSON snapshot of the full state + /event — publish: JSON {"event_type": ...} on transitions + /available — publish: online / offline (also the LWT) + //set — subscribe: work_minutes, short_break_minutes, + / long_break_minutes, sessions_before_long_break + +HA MQTT Auto-Discovery (enabled by default) publishes device + entity configs +to homeassistant//... on connect, so Home Assistant builds the +device automatically. +""" + +import json +import math +import os +import threading +import time +import uuid +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from PIL import Image, ImageDraw, ImageFont + +try: + import paho.mqtt.client as mqtt +except ImportError: + mqtt = None # type: ignore + +from src.plugin_system.base_plugin import BasePlugin + +_PLUGIN_VERSION = "1.0.0" + +PHASE_IDLE = "idle" +PHASE_WORK = "work" +PHASE_SHORT_BREAK = "short_break" +PHASE_LONG_BREAK = "long_break" + +# attr name, HA entity name, min, max, step, icon +_NUMBER_FIELDS: Tuple[Tuple[str, str, int, int, int, str], ...] = ( + ("work_minutes", "Work Minutes", 1, 180, 1, "mdi:briefcase-clock"), + ("short_break_minutes", "Short Break Minutes", 1, 60, 1, "mdi:coffee-outline"), + ("long_break_minutes", "Long Break Minutes", 1, 120, 1, "mdi:sofa-outline"), + ("sessions_before_long_break", "Sessions Before Long Break", 1, 12, 1, "mdi:counter"), +) + +# key suffix, HA entity name, payload published to the command topic, icon +_BUTTONS: Tuple[Tuple[str, str, str, str], ...] = ( + ("start", "Start", "START", "mdi:play"), + ("pause", "Pause", "PAUSE", "mdi:pause"), + ("resume", "Resume", "RESUME", "mdi:play"), + ("skip", "Skip Phase", "SKIP", "mdi:skip-next"), + ("reset", "Reset", "RESET", "mdi:restart"), +) + +_EVENT_TYPES = [ + "started", "paused", "resumed", "stopped", "reset", "skipped", + "phase_started", "work_complete", "break_complete", +] + +_PHASE_ORDER = (PHASE_WORK, PHASE_SHORT_BREAK, PHASE_LONG_BREAK) + + +def _rgb(value, default: Tuple[int, int, int]) -> Tuple[int, int, int]: + """Coerce a config/JSON colour into a clamped RGB tuple.""" + try: + parts = [max(0, min(255, int(c))) for c in value] + except (TypeError, ValueError): + return default + if len(parts) != 3: + return default + return (parts[0], parts[1], parts[2]) + + +def _dim(color: Tuple[int, int, int], factor: float) -> Tuple[int, int, int]: + return tuple(max(0, min(255, int(c * factor))) for c in color) # type: ignore[return-value] + + +def _fmt_clock(seconds: float) -> str: + """Format remaining seconds as MM:SS (minutes are not wrapped at 60).""" + total = int(math.ceil(max(0.0, seconds) - 1e-6)) + return f"{total // 60:02d}:{total % 60:02d}" + + +class PomodoroTimerPlugin(BasePlugin): + """Pomodoro focus/break timer with MQTT control and HA auto-discovery.""" + + def __init__(self, plugin_id: str, config: Dict[str, Any], + display_manager, cache_manager, plugin_manager): + super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager) + + # Runtime state (guarded by state_lock). Created before settings are + # loaded so _load_settings can seed the idle countdown. + self.state_lock = threading.RLock() + self.phase = PHASE_IDLE + self.running = False + self.phase_total = 25 * 60.0 + self.remaining = 25 * 60.0 + self.deadline: Optional[float] = None + self.completed_sessions = 0 + self.cycle_position = 0 + self.custom_label: Optional[str] = None + self.alert_until = 0.0 + self.alert_color: Optional[Tuple[int, int, int]] = None + + self._load_settings(config) + self.remaining = self.phase_total = float(self.work_minutes * 60) + + # Fonts + self._font_cache: Dict[int, Any] = {} + self._ttf_path_cache: Optional[str] = None + self._ttf_path_resolved = False + + # Display pinning + self._pinned = False + + # Timer thread + self.timer_thread: Optional[threading.Thread] = None + self.timer_stop_event = threading.Event() + + # MQTT internals + self.mqtt_client_id = f"ledmatrix-pomodoro-{plugin_id}-{uuid.uuid4().hex[:8]}" + self.mqtt_client: Optional["mqtt.Client"] = None + self.mqtt_thread: Optional[threading.Thread] = None + self.mqtt_connected = False + self.mqtt_connecting = False + self.mqtt_reconnect_delay = 1.0 + self.mqtt_max_reconnect_delay = 60.0 + self.mqtt_stop_event = threading.Event() + self._last_publish = 0.0 + self._last_published_key: Optional[Tuple] = None + + if self.mqtt_enabled and mqtt is None: + self.logger.error( + "paho-mqtt is not installed — MQTT control is disabled. " + "Install it with: pip install paho-mqtt") + + self.logger.info("Pomodoro timer ready — %d/%d/%d min, command topic: %s", + self.work_minutes, self.short_break_minutes, + self.long_break_minutes, self.command_topic) + + # ── Configuration ─────────────────────────────────────────────────────────── + + def _load_settings(self, config: Dict[str, Any]) -> None: + """Read every configurable value out of `config` onto self.""" + def _int(key: str, default: int, low: int, high: int) -> int: + try: + return max(low, min(high, int(config.get(key, default)))) + except (TypeError, ValueError): + return default + + # Timer + self.work_minutes = _int("work_minutes", 25, 1, 180) + self.short_break_minutes = _int("short_break_minutes", 5, 1, 60) + self.long_break_minutes = _int("long_break_minutes", 15, 1, 120) + self.sessions_before_long_break = _int("sessions_before_long_break", 4, 1, 12) + self.auto_start_breaks = bool(config.get("auto_start_breaks", True)) + self.auto_start_work = bool(config.get("auto_start_work", False)) + self.auto_start_on_enable = bool(config.get("auto_start_on_enable", False)) + + # Labels + self.work_label = str(config.get("work_label", "FOCUS")) + self.short_break_label = str(config.get("short_break_label", "BREAK")) + self.long_break_label = str(config.get("long_break_label", "LONG BREAK")) + self.idle_label = str(config.get("idle_label", "POMODORO")) + self.paused_label = str(config.get("paused_label", "PAUSED")) + + # Appearance + self.color_mode = str(config.get("color_mode", "phase")) + self.time_color = _rgb(config.get("time_color", [255, 255, 255]), (255, 255, 255)) + self.work_color = _rgb(config.get("work_color", [255, 70, 50]), (255, 70, 50)) + self.short_break_color = _rgb(config.get("short_break_color", [60, 200, 110]), (60, 200, 110)) + self.long_break_color = _rgb(config.get("long_break_color", [70, 150, 255]), (70, 150, 255)) + self.idle_color = _rgb(config.get("idle_color", [110, 110, 110]), (110, 110, 110)) + self.paused_color = _rgb(config.get("paused_color", [255, 176, 0]), (255, 176, 0)) + self.background_color = _rgb(config.get("background_color", [0, 0, 0]), (0, 0, 0)) + self.show_phase_label = bool(config.get("show_phase_label", True)) + self.show_session_dots = bool(config.get("show_session_dots", True)) + self.show_progress_bar = bool(config.get("show_progress_bar", True)) + self.font_path = str(config.get("font_path", "") or "") + try: + self.font_size = max(0, min(64, int(config.get("font_size", 0)))) + except (TypeError, ValueError): + self.font_size = 0 + + # Behaviour + self.pin_while_running = bool(config.get("pin_while_running", True)) + try: + self.alert_seconds = max(0, min(60, int(config.get("alert_seconds", 8)))) + except (TypeError, ValueError): + self.alert_seconds = 8 + self.alert_flash = bool(config.get("alert_flash", True)) + + # MQTT + self.mqtt_enabled = bool(config.get("mqtt_enabled", True)) + self.mqtt_host = str(config.get("mqtt_host", "localhost")) + try: + self.mqtt_port = int(config.get("mqtt_port", 1883)) + except (TypeError, ValueError): + self.mqtt_port = 1883 + self.mqtt_username = str(config.get("mqtt_username", "") or "") + self.mqtt_password = str(config.get("mqtt_password", "") or "") + self.command_topic = str(config.get("command_topic", "ledmatrix/pomodoro/set")) + self.state_topic = str(config.get("state_topic", "ledmatrix/pomodoro/state")) + self.ha_discovery = bool(config.get("ha_discovery", True)) + self.discovery_prefix = str(config.get("discovery_prefix", "homeassistant")) + self.device_name = str(config.get("device_name", "LED Matrix — Pomodoro")) + try: + self.publish_interval = max(1, min(60, int(config.get("publish_interval_seconds", 1)))) + except (TypeError, ValueError): + self.publish_interval = 1 + + self._derive_topics() + + def _derive_topics(self) -> None: + base = self.command_topic + if base.endswith("/set"): + base = base[:-4] + self.topic_base = base + self.availability_topic = f"{base}/available" + self.status_topic = f"{base}/status" + self.phase_topic = f"{base}/phase" + self.remaining_topic = f"{base}/remaining" + self.remaining_secs_topic = f"{base}/remaining_seconds" + self.session_topic = f"{base}/session" + self.attributes_topic = f"{base}/attributes" + self.event_topic = f"{base}/event" + self.number_state_topics = {f: f"{base}/{f}" for f, *_ in _NUMBER_FIELDS} + self.number_set_topics = {f"{base}/{f}/set": f for f, *_ in _NUMBER_FIELDS} + + def validate_config(self) -> bool: + if not super().validate_config(): + return False + if self.color_mode not in ("phase", "fixed"): + self.logger.error("Invalid color_mode: %s (expected 'phase' or 'fixed')", + self.color_mode) + return False + if not self.command_topic.strip(): + self.logger.error("command_topic must not be empty") + return False + if not 1 <= self.mqtt_port <= 65535: + self.logger.error("Invalid mqtt_port: %s", self.mqtt_port) + return False + return True + + # ── Timer state ───────────────────────────────────────────────────────────── + + def _phase_seconds(self, phase: str) -> float: + return { + PHASE_WORK: self.work_minutes * 60.0, + PHASE_SHORT_BREAK: self.short_break_minutes * 60.0, + PHASE_LONG_BREAK: self.long_break_minutes * 60.0, + }.get(phase, self.work_minutes * 60.0) + + def _remaining_locked(self) -> float: + if self.running and self.deadline is not None: + return max(0.0, self.deadline - time.monotonic()) + return max(0.0, self.remaining) + + def _begin_phase_locked(self, phase: str, seconds: Optional[float] = None, + run: bool = True, label: Optional[str] = None) -> None: + total = float(seconds) if seconds else self._phase_seconds(phase) + self.phase = phase + self.phase_total = max(1.0, total) + self.remaining = self.phase_total + self.running = bool(run) and phase != PHASE_IDLE + self.deadline = time.monotonic() + self.phase_total if self.running else None + self.custom_label = label or None + + def _go_idle_locked(self, reset_counters: bool = False) -> None: + self.phase = PHASE_IDLE + self.running = False + self.deadline = None + self.phase_total = self._phase_seconds(PHASE_WORK) + self.remaining = self.phase_total + self.custom_label = None + if reset_counters: + self.completed_sessions = 0 + self.cycle_position = 0 + + def _next_phase_locked(self, completed: bool = True) -> str: + """Which phase follows the current one, without mutating counters. + + A skipped work session doesn't count toward the set, so it never earns + the long break. + """ + if self.phase == PHASE_WORK: + position = self.cycle_position + (1 if completed else 0) + return (PHASE_LONG_BREAK if position >= self.sessions_before_long_break + else PHASE_SHORT_BREAK) + return PHASE_WORK + + def _advance_locked(self, completed: bool) -> List[Dict[str, Any]]: + """Move to the next phase. `completed` distinguishes a finished phase + from a skipped one (only finished work counts toward the cycle).""" + events: List[Dict[str, Any]] = [] + previous = self.phase + nxt = self._next_phase_locked(completed) + + if previous == PHASE_WORK and completed: + self.completed_sessions += 1 + self.cycle_position = min(self.sessions_before_long_break, + self.cycle_position + 1) + elif previous == PHASE_LONG_BREAK: + # A long break closes the set, so the session dots start over. + self.cycle_position = 0 + auto = self.auto_start_work if nxt == PHASE_WORK else self.auto_start_breaks + + if completed: + events.append({ + "event_type": "work_complete" if previous == PHASE_WORK else "break_complete", + "phase": previous, + "next_phase": nxt, + "completed_sessions": self.completed_sessions, + }) + if self.alert_seconds: + self.alert_until = time.monotonic() + self.alert_seconds + self.alert_color = self._phase_color(previous) + else: + events.append({"event_type": "skipped", "phase": previous, "next_phase": nxt}) + + self._begin_phase_locked(nxt, run=auto) + events.append({ + "event_type": "phase_started", + "phase": nxt, + "auto_started": bool(auto), + "duration_seconds": int(self.phase_total), + }) + return events + + def _tick(self) -> None: + """Advance the state machine and publish. Safe to call from any thread.""" + events: List[Dict[str, Any]] = [] + with self.state_lock: + # A phase can only roll over once per tick in practice, but loop + # defensively in case the process was suspended for a long time. + for _ in range(8): + if not self.running or self.deadline is None: + break + if time.monotonic() < self.deadline: + break + events.extend(self._advance_locked(completed=True)) + for event in events: + self._publish_event(event) + if events: + self._sync_pin() + self._publish_state(force=True) + else: + self._publish_state() + + # ── Commands ──────────────────────────────────────────────────────────────── + + def _apply_command(self, command: str, payload: Dict[str, Any]) -> None: + """Apply a normalised command plus any JSON options that came with it.""" + command = (command or "").strip().upper().replace("-", "_").replace(" ", "_") + events: List[Dict[str, Any]] = [] + + with self.state_lock: + # Duration overrides that arrive alongside the command. + for field, *_ in _NUMBER_FIELDS: + if field in payload: + self._set_number_locked(field, payload[field]) + label = payload.get("label") + explicit = payload.get("duration_minutes") or payload.get("minutes") + duration = None + if explicit is not None: + try: + duration = max(1.0, float(explicit)) * 60.0 + except (TypeError, ValueError): + duration = None + if duration is None and payload.get("duration_seconds") is not None: + try: + duration = max(1.0, float(payload["duration_seconds"])) + except (TypeError, ValueError): + duration = None + + if command in ("START", "ON", "PLAY"): + phase = str(payload.get("phase", "") or "").strip().lower() + if phase in _PHASE_ORDER: + self._begin_phase_locked(phase, duration, run=True, label=label) + elif self.phase == PHASE_IDLE or self.remaining <= 0: + self._begin_phase_locked(PHASE_WORK, duration, run=True, label=label) + elif not self.running: + self._resume_locked() + elif duration is not None: + self._begin_phase_locked(self.phase, duration, run=True, label=label) + events.append({"event_type": "started", "phase": self.phase, + "duration_seconds": int(self.phase_total)}) + + elif command in ("WORK", "FOCUS"): + self._begin_phase_locked(PHASE_WORK, duration, run=True, label=label) + events.append({"event_type": "started", "phase": PHASE_WORK, + "duration_seconds": int(self.phase_total)}) + + elif command in ("SHORT_BREAK", "BREAK"): + self._begin_phase_locked(PHASE_SHORT_BREAK, duration, run=True, label=label) + events.append({"event_type": "started", "phase": PHASE_SHORT_BREAK, + "duration_seconds": int(self.phase_total)}) + + elif command == "LONG_BREAK": + self._begin_phase_locked(PHASE_LONG_BREAK, duration, run=True, label=label) + events.append({"event_type": "started", "phase": PHASE_LONG_BREAK, + "duration_seconds": int(self.phase_total)}) + + elif command == "PAUSE": + if self.running: + self.remaining = self._remaining_locked() + self.running = False + self.deadline = None + events.append({"event_type": "paused", "phase": self.phase, + "remaining_seconds": int(self.remaining)}) + + elif command == "RESUME": + if not self.running and self.phase != PHASE_IDLE: + self._resume_locked() + events.append({"event_type": "resumed", "phase": self.phase, + "remaining_seconds": int(self.remaining)}) + + elif command in ("TOGGLE", "PAUSE_RESUME"): + if self.phase == PHASE_IDLE: + self._begin_phase_locked(PHASE_WORK, duration, run=True, label=label) + events.append({"event_type": "started", "phase": PHASE_WORK, + "duration_seconds": int(self.phase_total)}) + elif self.running: + self.remaining = self._remaining_locked() + self.running = False + self.deadline = None + events.append({"event_type": "paused", "phase": self.phase, + "remaining_seconds": int(self.remaining)}) + else: + self._resume_locked() + events.append({"event_type": "resumed", "phase": self.phase, + "remaining_seconds": int(self.remaining)}) + + elif command in ("STOP", "OFF"): + self._go_idle_locked() + self.alert_until = 0.0 + events.append({"event_type": "stopped"}) + + elif command in ("RESET", "CLEAR"): + self._go_idle_locked(reset_counters=True) + self.alert_until = 0.0 + events.append({"event_type": "reset"}) + + elif command in ("SKIP", "NEXT"): + if self.phase != PHASE_IDLE: + events.extend(self._advance_locked(completed=False)) + + elif command in ("SET", "CONFIGURE", ""): + # Duration-only payload — already applied above. + pass + + else: + self.logger.debug("Ignoring unrecognised command: %r", command) + return + + for event in events: + self._publish_event(event) + self._sync_pin() + self._publish_state(force=True) + with self.state_lock: + self.logger.info("Pomodoro %s → phase=%s running=%s remaining=%s", + command, self.phase, self.running, + _fmt_clock(self._remaining_locked())) + + def _resume_locked(self) -> None: + self.remaining = max(1.0, self.remaining) + self.running = True + self.deadline = time.monotonic() + self.remaining + + def _set_number_locked(self, field: str, raw: Any) -> bool: + """Apply one of the numeric settings; returns True when it changed.""" + spec = next((s for s in _NUMBER_FIELDS if s[0] == field), None) + if spec is None: + return False + _, _, low, high, _, _ = spec + try: + value = int(round(float(raw))) + except (TypeError, ValueError): + self.logger.warning("Invalid value for %s: %r", field, raw) + return False + value = max(low, min(high, value)) + if getattr(self, field) == value: + return False + setattr(self, field, value) + # Keep the idle screen (and a not-yet-started phase) in step with the + # new duration so HA edits are visible immediately. + if self.phase == PHASE_IDLE and field == "work_minutes": + self.phase_total = self.remaining = float(value * 60) + elif (not self.running and self.phase != PHASE_IDLE + and abs(self.remaining - self.phase_total) < 0.5): + phase_field = { + PHASE_WORK: "work_minutes", + PHASE_SHORT_BREAK: "short_break_minutes", + PHASE_LONG_BREAK: "long_break_minutes", + }.get(self.phase) + if phase_field == field: + self.phase_total = self.remaining = float(value * 60) + if field == "sessions_before_long_break": + self.cycle_position = min(self.cycle_position, value) + return True + + def _parse_command(self, raw: bytes) -> Tuple[Optional[str], Dict[str, Any]]: + """Return (command, options) for a command-topic payload. + + Accepts a bare string (``START``) or a JSON object such as + ``{"command": "start", "phase": "work", "duration_minutes": 50}``. + Returns (None, {}) for anything unrecognised so a stray message can + never be mistaken for a stop. + """ + try: + text = raw.decode("utf-8").strip() + except (UnicodeDecodeError, AttributeError): + return None, {} + if not text: + return None, {} + + try: + data = json.loads(text) + except (json.JSONDecodeError, ValueError): + return text, {} + + if isinstance(data, dict): + command = data.get("command") or data.get("action") or data.get("state") + if command is None: + # A payload with only settings is a configuration update. + if any(f in data for f in (f for f, *_ in _NUMBER_FIELDS)): + return "SET", data + return None, {} + return str(command), data + if isinstance(data, (int, float)): + return ("START" if data else "STOP"), {} + return str(data), {} + + # ── BasePlugin interface ──────────────────────────────────────────────────── + + def update(self) -> None: + # No network data to fetch; just keep the state machine honest even if + # the timer thread is not running (e.g. under the safety harness). + self._tick() + + def display(self, force_clear: bool = False) -> bool: + try: + self._tick() + snapshot = self._snapshot() + width, height = self._panel_size() + canvas, draw = self._frame(width, height) + self._render(draw, width, height, snapshot) + self.display_manager.image = canvas + self.display_manager.draw = draw + self.display_manager.update_display() + return True + except Exception as e: + self.logger.error("Error displaying pomodoro timer: %s", e, exc_info=True) + try: + self.display_manager.draw_text("Pomodoro Error", x=2, y=2, color=(255, 0, 0)) + self.display_manager.update_display() + except Exception: + self.logger.debug("Fallback display failed", exc_info=True) + return False + + def get_display_duration(self) -> float: + try: + return float(self.config.get("display_duration", 10)) + except (TypeError, ValueError): + return 10.0 + + def on_enable(self) -> None: + super().on_enable() + if self.timer_thread is None or not self.timer_thread.is_alive(): + self.timer_stop_event.clear() + self.timer_thread = threading.Thread( + target=self._timer_loop, name=f"{self.plugin_id}-timer", daemon=True) + self.timer_thread.start() + if self.auto_start_on_enable: + with self.state_lock: + idle = self.phase == PHASE_IDLE + if idle: + self._apply_command("START", {}) + if self.mqtt_enabled and mqtt is not None: + if self.mqtt_thread is None or not self.mqtt_thread.is_alive(): + self.mqtt_stop_event.clear() + self.mqtt_thread = threading.Thread( + target=self._mqtt_loop, name=f"{self.plugin_id}-mqtt", daemon=True) + self.mqtt_thread.start() + self.logger.info("MQTT thread started") + + def on_disable(self) -> None: + super().on_disable() + self._graceful_shutdown() + + def cleanup(self) -> None: + self._graceful_shutdown() + self.logger.info("Pomodoro timer cleaned up") + + def on_config_change(self, new_config: Dict[str, Any]) -> None: + super().on_config_change(new_config) + previous = (self.mqtt_enabled, self.mqtt_host, self.mqtt_port, self.mqtt_username, + self.mqtt_password, self.command_topic, self.state_topic, + self.ha_discovery, self.discovery_prefix) + was_idle = self.phase == PHASE_IDLE + + self._load_settings(new_config) + self._font_cache = {} + self._ttf_path_resolved = False + if was_idle: + with self.state_lock: + self._go_idle_locked() + + current = (self.mqtt_enabled, self.mqtt_host, self.mqtt_port, self.mqtt_username, + self.mqtt_password, self.command_topic, self.state_topic, + self.ha_discovery, self.discovery_prefix) + if previous != current: + self._graceful_shutdown() + self.on_enable() + else: + self._publish_state(force=True) + + def get_info(self) -> Dict[str, Any]: + info = super().get_info() + info.update(self._snapshot()) + info.update({ + "mqtt_enabled": self.mqtt_enabled, + "mqtt_connected": self.mqtt_connected, + "mqtt_host": self.mqtt_host, + "command_topic": self.command_topic, + "ha_discovery": self.ha_discovery, + }) + return info + + # ── State snapshot ────────────────────────────────────────────────────────── + + def _snapshot(self) -> Dict[str, Any]: + with self.state_lock: + remaining = self._remaining_locked() + return { + "phase": self.phase, + "status": ("idle" if self.phase == PHASE_IDLE + else "running" if self.running else "paused"), + "running": self.running, + "remaining_seconds": int(round(remaining)), + "remaining": _fmt_clock(remaining), + "phase_total_seconds": int(self.phase_total), + "elapsed_fraction": (0.0 if self.phase_total <= 0 + else max(0.0, min(1.0, 1.0 - remaining / self.phase_total))), + "completed_sessions": self.completed_sessions, + "cycle_position": self.cycle_position, + "sessions_before_long_break": self.sessions_before_long_break, + "work_minutes": self.work_minutes, + "short_break_minutes": self.short_break_minutes, + "long_break_minutes": self.long_break_minutes, + "label": self.custom_label or self._phase_label(self.phase, self.running), + "alerting": time.monotonic() < self.alert_until, + "alert_color": self.alert_color, + } + + def _phase_label(self, phase: str, running: bool) -> str: + if phase == PHASE_IDLE: + return self.idle_label + if not running and self.paused_label: + return self.paused_label + return { + PHASE_WORK: self.work_label, + PHASE_SHORT_BREAK: self.short_break_label, + PHASE_LONG_BREAK: self.long_break_label, + }.get(phase, self.work_label) + + def _phase_color(self, phase: str) -> Tuple[int, int, int]: + return { + PHASE_WORK: self.work_color, + PHASE_SHORT_BREAK: self.short_break_color, + PHASE_LONG_BREAK: self.long_break_color, + }.get(phase, self.idle_color) + + # ── Rendering ─────────────────────────────────────────────────────────────── + + def _panel_size(self) -> Tuple[int, int]: + dm = self.display_manager + width = getattr(dm, "width", None) or getattr(getattr(dm, "matrix", None), "width", 128) + height = getattr(dm, "height", None) or getattr(getattr(dm, "matrix", None), "height", 32) + return int(width), int(height) + + def _frame(self, width: int, height: int): + """Return (image, draw) to render into. + + Reuses the display manager's own buffer when it is at least panel-sized + so anything drawn past the edge stays visible to the safety harness's + bounds check, instead of being silently clipped by a fresh canvas. + """ + canvas = getattr(self.display_manager, "image", None) + if (not isinstance(canvas, Image.Image) or canvas.mode != "RGB" + or canvas.size[0] < width or canvas.size[1] < height): + canvas = Image.new("RGB", (width, height), (0, 0, 0)) + return canvas, ImageDraw.Draw(canvas) + draw = getattr(self.display_manager, "draw", None) + if draw is None: + draw = ImageDraw.Draw(canvas) + return canvas, draw + + def _ttf_path(self) -> Optional[str]: + """Best available TrueType font path, or None to use the built-ins.""" + if self._ttf_path_resolved: + return self._ttf_path_cache + self._ttf_path_resolved = True + self._ttf_path_cache = None + + configured = self.font_path.strip() + if configured: + candidates = [configured] + if not os.path.isabs(configured): + candidates.append(os.path.join(os.getcwd(), configured)) + root = Path(__file__).resolve().parent.parent.parent + candidates.append(str(root / configured)) + for candidate in candidates: + if os.path.exists(candidate): + self._ttf_path_cache = candidate + return self._ttf_path_cache + self.logger.warning("Font not found: %s — falling back to the default font", + configured) + + # Borrow whatever TTF the core already loaded (usually PressStart2P). + # PIL's built-in bitmap font also carries a `.path`, but it is a file + # object rather than a filename — only take real paths. + for attr in ("small_font", "regular_font", "extra_small_font"): + path = getattr(getattr(self.display_manager, attr, None), "path", None) + if isinstance(path, (str, bytes, os.PathLike)) and os.path.exists(path): + self._ttf_path_cache = os.fspath(path) + return self._ttf_path_cache + return None + + def _font(self, size: int): + """A TTF at `size` px, cached; None when no TTF is available.""" + size = max(5, int(size)) + if size in self._font_cache: + return self._font_cache[size] + path = self._ttf_path() + font = None + if path: + try: + font = ImageFont.truetype(path, size) + except Exception as e: + self.logger.debug("Could not load %s @ %dpx: %s", path, size, e) + self._font_cache[size] = font + return font + + def _fallback_font(self, max_height: int): + dm = self.display_manager + if max_height >= 8: + return getattr(dm, "small_font", None) or getattr(dm, "regular_font", None) + return getattr(dm, "extra_small_font", None) or getattr(dm, "small_font", None) + + @staticmethod + def _measure(draw, text: str, font) -> Tuple[int, int, int, int]: + """(width, height, x_offset, y_offset) of `text` in `font`.""" + try: + x0, y0, x1, y1 = draw.textbbox((0, 0), text, font=font) + return x1 - x0, y1 - y0, x0, y0 + except Exception: + approx = 6 * len(text) + return approx, 8, 0, 0 + + def _fit_font(self, draw, text: str, max_w: int, max_h: int): + """Largest available font that renders `text` inside (max_w, max_h).""" + if self.font_size: + return self._font(self.font_size) or self._fallback_font(max_h) + if self._ttf_path() is None: + font = self._fallback_font(max_h) + if font is not None and max_h < 8: + return getattr(self.display_manager, "extra_small_font", font) + return font + best = None + low, high = 5, max(5, min(max_h, 96)) + while low <= high: + mid = (low + high) // 2 + font = self._font(mid) + if font is None: + break + tw, th, _, _ = self._measure(draw, text, font) + if tw <= max_w and th <= max_h: + best = font + low = mid + 1 + else: + high = mid - 1 + return best or self._font(5) or self._fallback_font(max_h) + + def _draw_in_box(self, draw, text: str, font, box: Tuple[int, int, int, int], + color: Tuple[int, int, int], align: str = "center") -> None: + """Draw `text` centred (or left-aligned) inside box=(x, y, w, h). + + Text that still doesn't fit at the smallest font is truncated rather + than allowed to spill past the panel edge. + """ + if not text or font is None: + return + bx, by, bw, bh = box + tw, th, ox, oy = self._measure(draw, text, font) + while tw > bw and len(text) > 1: + text = text[:-1] + tw, th, ox, oy = self._measure(draw, text, font) + if tw > bw: + return + x = bx if align == "left" else bx + max(0, (bw - tw) // 2) + y = by + max(0, (bh - th) // 2) + draw.text((x - ox, y - oy), text, font=font, fill=color) + + def _render(self, draw, width: int, height: int, snap: Dict[str, Any]) -> None: + phase = snap["phase"] + accent = self._phase_color(phase) + if snap["status"] == "paused": + accent = self.paused_color + background = self.background_color + text_color = accent if self.color_mode == "phase" else self.time_color + + # Completion flash: swap foreground and background twice a second. + if snap["alerting"] and self.alert_flash: + if int(time.monotonic() * 2) % 2 == 0: + flash = snap["alert_color"] or accent + background = flash + text_color = self.background_color + accent = self.background_color + + draw.rectangle([0, 0, width - 1, height - 1], fill=background) + + label = snap["label"] + bar_h = self._bar_height(height) if self.show_progress_bar else 0 + dot_h = self._dot_size(height) if self.show_session_dots else 0 + + if width >= 192 and height <= 48: + self._render_wide(draw, width, height, snap, label, text_color, accent, + bar_h, dot_h) + else: + self._render_stacked(draw, width, height, snap, label, text_color, accent, + bar_h, dot_h) + + if bar_h: + self._draw_progress_bar(draw, 0, height - bar_h, width, bar_h, + snap["elapsed_fraction"], accent) + + @staticmethod + def _bar_height(height: int) -> int: + return 3 if height >= 64 else 2 + + @staticmethod + def _dot_size(height: int) -> int: + return 4 if height >= 64 else 3 + + def _render_stacked(self, draw, width, height, snap, label, text_color, accent, + bar_h, dot_h) -> None: + label_h = (10 if height >= 64 else 7) if (self.show_phase_label and label) else 0 + top = label_h + (1 if label_h else 0) + bottom = height - bar_h - (dot_h + 1 if dot_h else 0) + box_h = max(6, bottom - top) + + if label_h: + label_font = self._fit_font(draw, label, width - 2, label_h) + self._draw_in_box(draw, label, label_font, (1, 0, width - 2, label_h), accent) + + time_text = snap["remaining"] + time_font = self._fit_font(draw, time_text, width - 4, box_h) + self._draw_in_box(draw, time_text, time_font, (2, top, width - 4, box_h), text_color) + + if dot_h: + self._draw_session_dots(draw, 0, bottom + 1, width, dot_h, snap, accent, + align="center") + + def _render_wide(self, draw, width, height, snap, label, text_color, accent, + bar_h, dot_h) -> None: + """Side-by-side layout for very wide panels (e.g. 256x32).""" + left_w = max(40, int(width * 0.30)) + usable_h = height - bar_h + label_h = (min(12, usable_h // 2) if (self.show_phase_label and label) else 0) + + if label_h: + label_font = self._fit_font(draw, label, left_w - 4, label_h) + self._draw_in_box(draw, label, label_font, (2, 1, left_w - 4, label_h), + accent, align="left") + if dot_h: + dots_y = (label_h + 3) if label_h else max(1, (usable_h - dot_h) // 2) + dots_y = min(dots_y, max(0, usable_h - dot_h - 1)) + self._draw_session_dots(draw, 2, dots_y, left_w - 4, dot_h, snap, accent, + align="left") + + time_text = snap["remaining"] + time_box = (left_w + 2, 0, width - left_w - 4, max(6, usable_h - 1)) + time_font = self._fit_font(draw, time_text, time_box[2], time_box[3]) + self._draw_in_box(draw, time_text, time_font, time_box, text_color) + + def _draw_session_dots(self, draw, x: int, y: int, avail_w: int, size: int, + snap: Dict[str, Any], accent: Tuple[int, int, int], + align: str = "center") -> None: + total = max(1, int(snap["sessions_before_long_break"])) + filled = max(0, min(total, int(snap["cycle_position"]))) + gap = 1 + while total * (size + gap) - gap > avail_w and size > 1: + size -= 1 + span = total * (size + gap) - gap + if span > avail_w or size < 1: + return + start = x if align == "left" else x + max(0, (avail_w - span) // 2) + dim = _dim(accent, 0.28) + for index in range(total): + left = start + index * (size + gap) + draw.rectangle([left, y, left + size - 1, y + size - 1], + fill=accent if index < filled else dim) + + def _draw_progress_bar(self, draw, x: int, y: int, width: int, height: int, + fraction: float, accent: Tuple[int, int, int]) -> None: + draw.rectangle([x, y, x + width - 1, y + height - 1], fill=_dim(accent, 0.22)) + filled = int(round(max(0.0, min(1.0, fraction)) * width)) + if filled > 0: + draw.rectangle([x, y, x + filled - 1, y + height - 1], fill=accent) + + # ── Display pinning ───────────────────────────────────────────────────────── + + def _sync_pin(self) -> None: + """Take over (or release) the panel to match the timer's state.""" + with self.state_lock: + active = self.phase != PHASE_IDLE + alerting = time.monotonic() < self.alert_until + want = (self.pin_while_running and active) or alerting + if want == self._pinned: + return + self._pinned = want + request: Dict[str, Any] = { + "request_id": str(uuid.uuid4()), + "plugin_id": self.plugin_id, + "action": "start" if want else "stop", + "timestamp": time.time(), + } + if want: + request.update({"mode": "pomodoro", "duration": None, "pinned": True}) + try: + self.cache_manager.set("display_on_demand_request", request) + except Exception as e: + self.logger.debug("On-demand display request failed: %s", e) + + def _timer_loop(self) -> None: + while not self.timer_stop_event.wait(0.25): + try: + self._tick() + # Idempotent: releases the panel once the alert window closes + # or the pin setting is turned off. + self._sync_pin() + except Exception as e: + self.logger.error("Timer loop error: %s", e, exc_info=True) + self.logger.info("Timer loop stopped") + + # ── MQTT publishing ───────────────────────────────────────────────────────── + + def _publish(self, topic: str, payload: str, retain: bool = True) -> None: + if not self.mqtt_client or not self.mqtt_connected: + return + try: + self.mqtt_client.publish(topic, payload, retain=retain) + except Exception as e: + self.logger.debug("Publish to %s failed: %s", topic, e) + + def _publish_availability(self, online: bool) -> None: + if not self.mqtt_client: + return + try: + self.mqtt_client.publish(self.availability_topic, + "online" if online else "offline", retain=True) + except Exception as e: + self.logger.debug("Availability publish failed: %s", e) + + def _publish_event(self, event: Dict[str, Any]) -> None: + self.logger.info("Pomodoro event: %s", event.get("event_type")) + self._publish(self.event_topic, json.dumps(event), retain=False) + + def _publish_state(self, force: bool = False) -> None: + """Publish the full state, throttled to publish_interval while ticking.""" + if not self.mqtt_client or not self.mqtt_connected: + return + now = time.monotonic() + snap = self._snapshot() + key = (snap["phase"], snap["status"], snap["remaining"], + snap["completed_sessions"], snap["cycle_position"]) + if not force: + if key == self._last_published_key: + return + if now - self._last_publish < self.publish_interval: + return + self._last_publish = now + self._last_published_key = key + + self._publish(self.state_topic, "ON" if snap["phase"] != PHASE_IDLE else "OFF") + self._publish(self.status_topic, snap["status"]) + self._publish(self.phase_topic, snap["phase"]) + self._publish(self.remaining_topic, snap["remaining"]) + self._publish(self.remaining_secs_topic, str(snap["remaining_seconds"])) + self._publish(self.session_topic, str(snap["completed_sessions"])) + self._publish(self.attributes_topic, json.dumps({ + k: v for k, v in snap.items() if k != "alert_color" + })) + for field, *_ in _NUMBER_FIELDS: + self._publish(self.number_state_topics[field], str(getattr(self, field))) + + # ── HA MQTT auto-discovery ────────────────────────────────────────────────── + + def _discovery_uid(self) -> str: + return f"ledmatrix_pomodoro_{self.plugin_id}" + + def _discovery_device(self) -> Dict[str, Any]: + return { + "identifiers": [self._discovery_uid()], + "name": self.device_name, + "model": "Pomodoro Timer", + "manufacturer": "LEDMatrix", + "sw_version": _PLUGIN_VERSION, + } + + def _discovery_entities(self) -> List[Tuple[str, Dict[str, Any]]]: + """(discovery topic, config payload) for every entity we announce.""" + uid = self._discovery_uid() + device = self._discovery_device() + avail = [{"topic": self.availability_topic, + "payload_available": "online", + "payload_not_available": "offline"}] + common = {"availability": avail, "device": device} + entities: List[Tuple[str, Dict[str, Any]]] = [] + + entities.append((f"switch/{uid}/config", { + "name": "Timer", "unique_id": f"{uid}_switch", + "command_topic": self.command_topic, "state_topic": self.state_topic, + "payload_on": "START", "payload_off": "STOP", + "state_on": "ON", "state_off": "OFF", + "json_attributes_topic": self.attributes_topic, + "icon": "mdi:timer-play-outline", **common, + })) + + for key, name, payload, icon in _BUTTONS: + entities.append((f"button/{uid}_{key}/config", { + "name": name, "unique_id": f"{uid}_{key}", + "command_topic": self.command_topic, "payload_press": payload, + "icon": icon, **common, + })) + + for field, name, low, high, step, icon in _NUMBER_FIELDS: + entities.append((f"number/{uid}_{field}/config", { + "name": name, "unique_id": f"{uid}_{field}", + "command_topic": f"{self.topic_base}/{field}/set", + "state_topic": self.number_state_topics[field], + "min": low, "max": high, "step": step, "mode": "box", + "unit_of_measurement": None if field == "sessions_before_long_break" else "min", + "icon": icon, "entity_category": "config", **common, + })) + + entities.append((f"sensor/{uid}_phase/config", { + "name": "Phase", "unique_id": f"{uid}_phase", + "state_topic": self.phase_topic, "icon": "mdi:progress-clock", **common, + })) + entities.append((f"sensor/{uid}_status/config", { + "name": "Status", "unique_id": f"{uid}_status", + "state_topic": self.status_topic, "icon": "mdi:play-pause", **common, + })) + entities.append((f"sensor/{uid}_remaining/config", { + "name": "Time Remaining", "unique_id": f"{uid}_remaining", + "state_topic": self.remaining_topic, "icon": "mdi:timer-sand", **common, + })) + entities.append((f"sensor/{uid}_remaining_seconds/config", { + "name": "Seconds Remaining", "unique_id": f"{uid}_remaining_seconds", + "state_topic": self.remaining_secs_topic, + "unit_of_measurement": "s", "device_class": "duration", + "state_class": "measurement", "icon": "mdi:timer-outline", **common, + })) + entities.append((f"sensor/{uid}_sessions/config", { + "name": "Completed Sessions", "unique_id": f"{uid}_sessions", + "state_topic": self.session_topic, "state_class": "total_increasing", + "icon": "mdi:check-circle-outline", **common, + })) + entities.append((f"event/{uid}_event/config", { + "name": "Timer Event", "unique_id": f"{uid}_event", + "state_topic": self.event_topic, "event_types": _EVENT_TYPES, + "icon": "mdi:bell-ring-outline", **common, + })) + entities.append((f"binary_sensor/{uid}_connected/config", { + "name": "MQTT Connected", "unique_id": f"{uid}_connected", + "state_topic": self.availability_topic, + "payload_on": "online", "payload_off": "offline", + "device_class": "connectivity", "icon": "mdi:wifi", + "device": device, + })) + return entities + + def _publish_discovery(self) -> None: + if not self.ha_discovery or not self.mqtt_client: + return + for suffix, payload in self._discovery_entities(): + self._publish(f"{self.discovery_prefix}/{suffix}", json.dumps(payload)) + self.logger.info("Published HA MQTT discovery — device: %s", self.device_name) + + def _remove_discovery(self) -> None: + if not self.ha_discovery or not self.mqtt_client: + return + for suffix, _ in self._discovery_entities(): + self._publish(f"{self.discovery_prefix}/{suffix}", "") + + # ── MQTT callbacks ────────────────────────────────────────────────────────── + + def _on_mqtt_connect(self, client, userdata, flags, rc): # pylint: disable=unused-argument + if rc != 0: + self.mqtt_connecting = False + self.mqtt_connected = False + self.logger.error("MQTT connect failed rc=%s", rc) + return + self.mqtt_connected = True + self.mqtt_connecting = False + self.mqtt_reconnect_delay = 1.0 + client.subscribe(self.command_topic, qos=1) + for topic in self.number_set_topics: + client.subscribe(topic, qos=1) + self._publish_availability(True) + self._publish_discovery() + self._publish_state(force=True) + self.logger.info("MQTT connected — subscribed to %s", self.command_topic) + + def _on_mqtt_disconnect(self, client, userdata, rc): # pylint: disable=unused-argument + self.mqtt_connected = False + self.mqtt_connecting = False + if rc != 0: + self.logger.warning("MQTT disconnected unexpectedly rc=%s", rc) + + def _on_mqtt_message(self, client, userdata, msg): # pylint: disable=unused-argument + try: + field = self.number_set_topics.get(msg.topic) + if field: + try: + raw = msg.payload.decode("utf-8").strip() + except (UnicodeDecodeError, AttributeError): + return + with self.state_lock: + changed = self._set_number_locked(field, raw) + if changed: + self.logger.info("%s set to %s via MQTT", field, getattr(self, field)) + self._publish_state(force=True) + return + + command, payload = self._parse_command(msg.payload) + if command is None: + self.logger.debug("Ignoring unrecognised payload on %s", msg.topic) + return + self._apply_command(command, payload if isinstance(payload, dict) else {}) + except Exception as e: + self.logger.error("Error handling MQTT message: %s", e, exc_info=True) + + # ── MQTT connect / loop ───────────────────────────────────────────────────── + + def _connect_mqtt(self) -> bool: + try: + try: + self.mqtt_client = mqtt.Client( + callback_api_version=mqtt.CallbackAPIVersion.VERSION1, + client_id=self.mqtt_client_id, clean_session=True) + except (TypeError, AttributeError): + self.mqtt_client = mqtt.Client( + client_id=self.mqtt_client_id, clean_session=True) + self.mqtt_client.on_connect = self._on_mqtt_connect + self.mqtt_client.on_disconnect = self._on_mqtt_disconnect + self.mqtt_client.on_message = self._on_mqtt_message + if self.mqtt_username: + self.mqtt_client.username_pw_set(self.mqtt_username, self.mqtt_password) + self.mqtt_client.will_set(self.availability_topic, payload="offline", + qos=1, retain=True) + self.mqtt_client.connect(self.mqtt_host, self.mqtt_port, keepalive=60) + return True + except Exception as e: + self.logger.error("MQTT connect error: %s", e) + return False + + def _mqtt_loop(self) -> None: + while not self.mqtt_stop_event.is_set(): + try: + if not self.mqtt_connected and not self.mqtt_connecting: + self.mqtt_connecting = True + if self._connect_mqtt(): + self.mqtt_client.loop_start() + # Give the broker a moment to answer CONNACK before the + # next pass, so we never open a second connection. + self.mqtt_stop_event.wait(5.0) + else: + self.mqtt_connecting = False + wait = min(self.mqtt_reconnect_delay, self.mqtt_max_reconnect_delay) + self.logger.info("Retrying MQTT in %.0fs", wait) + if self.mqtt_stop_event.wait(wait): + break + self.mqtt_reconnect_delay = min( + self.mqtt_reconnect_delay * 2, self.mqtt_max_reconnect_delay) + else: + if self.mqtt_stop_event.wait(1.0): + break + self.mqtt_reconnect_delay = 1.0 + except Exception as e: + self.logger.error("MQTT loop error: %s", e, exc_info=True) + self.mqtt_connected = False + self.mqtt_connecting = False + if self.mqtt_client: + try: + self.mqtt_client.loop_stop() + self.mqtt_client.disconnect() + except Exception: + pass + self.mqtt_client = None + wait = min(self.mqtt_reconnect_delay, self.mqtt_max_reconnect_delay) + if self.mqtt_stop_event.wait(wait): + break + self.mqtt_reconnect_delay = min( + self.mqtt_reconnect_delay * 2, self.mqtt_max_reconnect_delay) + + self._publish_availability(False) + if self.mqtt_client: + try: + self.mqtt_client.loop_stop() + self.mqtt_client.disconnect() + except Exception: + pass + self.mqtt_client = None + self.logger.info("MQTT loop stopped") + + def _graceful_shutdown(self) -> None: + self.timer_stop_event.set() + if self.timer_thread and self.timer_thread.is_alive(): + self.timer_thread.join(timeout=2.0) + self.timer_thread = None + + if self.mqtt_thread and self.mqtt_thread.is_alive(): + self.mqtt_stop_event.set() + if self.mqtt_client: + try: + self.mqtt_client.loop_stop() + self.mqtt_client.disconnect() + except Exception: + pass + self.mqtt_thread.join(timeout=5.0) + self.mqtt_thread = None + self.mqtt_connected = False + self.mqtt_connecting = False diff --git a/plugins/pomodoro-timer/manifest.json b/plugins/pomodoro-timer/manifest.json new file mode 100644 index 00000000..f019fa5b --- /dev/null +++ b/plugins/pomodoro-timer/manifest.json @@ -0,0 +1,31 @@ +{ + "id": "pomodoro-timer", + "name": "Pomodoro Timer", + "version": "1.0.0", + "author": "ChuckBuilds", + "description": "A configurable Pomodoro focus/break timer for your matrix. Set the work and break lengths, then start, pause, skip, or reset it over MQTT — with Home Assistant auto-discovery so the whole timer shows up as a device with no YAML.", + "entry_point": "manager.py", + "class_name": "PomodoroTimerPlugin", + "category": "productivity", + "tags": ["pomodoro", "timer", "focus", "productivity", "mqtt", "home-assistant"], + "display_modes": ["pomodoro"], + "compatible_versions": [">=2.0.0"], + "versions": [ + { + "released": "2026-07-28", + "version": "1.0.0", + "notes": "Initial release. Configurable work/short-break/long-break durations with session dots and a phase progress bar, MQTT command topic (START/PAUSE/RESUME/TOGGLE/SKIP/STOP/RESET plus JSON), and Home Assistant MQTT auto-discovery covering a switch, five control buttons, four duration number boxes, phase/status/remaining/session sensors, a timer-event entity, and connectivity.", + "ledmatrix_min_version": "2.0.0" + } + ], + "default_duration": 10, + "icon": "fa-hourglass-half", + "license": "GPL-3.0", + "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/pomodoro-timer", + "config_schema": "config_schema.json", + "last_updated": "2026-07-28", + "stars": 0, + "downloads": 0, + "verified": true, + "screenshot": "" +} diff --git a/plugins/pomodoro-timer/requirements.txt b/plugins/pomodoro-timer/requirements.txt new file mode 100644 index 00000000..7fa2a0a8 --- /dev/null +++ b/plugins/pomodoro-timer/requirements.txt @@ -0,0 +1 @@ +paho-mqtt>=1.6.0 diff --git a/plugins/pomodoro-timer/test/golden/128x32/pomodoro.png b/plugins/pomodoro-timer/test/golden/128x32/pomodoro.png new file mode 100644 index 0000000000000000000000000000000000000000..d654da8cf2b5a255d39cb6a38945892320fe8af4 GIT binary patch literal 824 zcmV-81IPS{P)cje(&{hj#A32OuAYu^kvXHy?nEEO>+^3lnN8 z7F1NLZTe4ogGHa!q-k2axR2-GWI1=flYM8OofJsIw(Y@S(C_zkUH3c>V{BPgu~=k# zY}>{d(@*7c*>M~I2+PoMf*@!%n*b0w&Qe>l=0THX7yw>hUuTo+`+m7xW~bZteE>iR z(N9z*QihIGsZe37+bAYOUfjqrC3c)5Co-Ci4d|>5@U=IvMeh`=JN7VoNHo` znE{1DA)n9h?Cd;0KeyZMNST+H7t69}TyxqwJ3H%kyY+f~I2`f}B^LQx&{DA^;ST_Y zVFW=i9*-B<+TGnfIXTJYa&K>Mt2w$!WG=uQj^h-I#e6=m>pI4Gwkbb8KF&6lNEw<* zK@ikxwU}|9o}Ml)F0QYy2_cJQ6bi-9k`gk??d`2$7>mC8zE3N3if!9V2b@#kq*AHm zayhog^E}se#j32wwwIy59^MRK!`XG+kB<-b>+$jN)zuYk6G9#y9+s+5CX>mc;)jh* zdYOuGkESMT<^24-P$do&lPsWwTk+G-W%oaYSxG;mUzB z7tW1FBW4hKY#baMRIAma|cLCU`Pe`>|owdW&o+$3`@8NrI zr2i7akug^ttf9h>_$_E}Z*PBpKiof^PQSjs;QJ6ZO*5TN8-~Fq1OQ|*8Th^l^B>5a zPG>Y4nWm{}8ta&@>z|*W-ELQi3J)TmBI22=DOY;G<3v5iDHIBYLZJw;spn|n9336e z#{2twlJ?JgxE`tsl8jma$)pYs4`uuCU!yWWGJgOm7cvQUMzyj40000EaktG3V_a+f}a|MA$z} zK7QCuNYHW1PFEMj-f2x+BOAO=u*LL9Z`feAReN|Pq)c3#J z4=3)w|M}nL#rFlhCOxs}%e_7QbnAD?_k}(Uxwq#%XMOtkqsEg@i*9aPy9x+4>4bI|P2J~bMLW-=7;Vx?-yEsrwfyq(%b9!Q;^l>Pl1+S*^OtIP=}iZ^ zf2x;i49C%Jw{pxDUwlzxH~sX}5G_*)9-vFSTk^lWOm|p+-I(X`FT>MD)Ri5g$Z9f~i%y(LL{YLpi90Git0&iZ%AKPg2 z{pZ@INxqIw%fgN|8#1vtGqH64jO03mB!m$WW*4F7i{&;y@o!X)B1ZOt`6$cPn8KBY8_i#VNkQ@3cf}eN0J{F$~GQiW- K&t;ucLK6VfNKH5Z literal 0 HcmV?d00001 diff --git a/plugins/pomodoro-timer/test/golden/256x32/pomodoro.png b/plugins/pomodoro-timer/test/golden/256x32/pomodoro.png new file mode 100644 index 0000000000000000000000000000000000000000..dc09b4403768e465b47c7f7a8e239362c5bb2460 GIT binary patch literal 1050 zcmV+#1m*jQP)&twXuDd&vA@1zX&TcXY2qmA-mrA9tN|~m4a&j_Ue{gUR z$Cx0s-^u>|z9@Hj1Kf95>vArK;6xv)M%d zCQIA4E0qdWieMOqs;cBqMkz)M0U(?V{=vgbRn=Cjg%Da>TPqfecqmhwa4XOAvMeKn z7^QI2*49>`Q1FcMXlD2}MSd^7u}ZVqe13iok&+}So6UMERaG?%BaT`^r_<4Oou%h! zamRta)(V0k2!f}QIt?YH_Dc->w`|+)cDo3nUa#lr7t(|f3W}1c3Qtc@+wFF#R8ka$ zjMr#pgj+ZgbX~`5T@apyXjE#W;Tu)g>-BoQ9!IUOwX!S^kJNEcOT{}~ML<|+kcuqJc(nn5m<%{Txm+e?(=@BqDvJu+wqIXg9mh#->Lp+cGntHG7@DSi ze0(JIpzG`F;Y!9p1^7h_IDn>U?RFcN-`?Jm8(xvoG)5@TV7r^O*5ef)oL}y zp7EEXy}dogp5MH=XHVL?BP$XIilXS=C5CT=;;W6ufCE^Tb$@^F>Cx?WXJ%$Nj+>pG zMF>q#Pxt%%$Hzx^ADqU?=kx4RJkJY)P$(2~xg1FZQKc-)(slj){e5F&V{&q`R;xWc zJg`^@0KU-o0LRD2ilVr;xh&BURcdEv2N&FFP?l%i-Q9IM9fZ*0;$pd6u2d?~ECc{V z9N_TqFr-sZKG7p(7>0X2Vw8&Ttl?BfoQx+s)YD2rP^{v+wV*<>t8;U6ySux%^6Kg; zxd<>lj35gHu*1H!a6`i1si~=4E{7{`Z*RjL0FDA#fU6Kds(}@N!otGB`uaL9^E@Bk z`QqZ@<>dt-WZQP)mI0#x9Du_7{QUOzHe=_@%gZEgECR{#=>V&%tGLi;G+4^tYa|eb zgswvTXTTl+NHq`w`4fIx8S|8EV~fFA?Wpg^km3$JkK U-kx+aNB{r;07*qoM6N<$f-Yj}=>Px# literal 0 HcmV?d00001 diff --git a/plugins/pomodoro-timer/test/golden/64x32/pomodoro.png b/plugins/pomodoro-timer/test/golden/64x32/pomodoro.png new file mode 100644 index 0000000000000000000000000000000000000000..78fbee6bf2330c8e8c8b6959f4aa958be1dfb65a GIT binary patch literal 593 zcmV-X0I$LA1S-R= zhZwoME)G{JWqYqs)yK| zEuI`hm)dCb38>(nR9 literal 0 HcmV?d00001 diff --git a/plugins/pomodoro-timer/test/harness.json b/plugins/pomodoro-timer/test/harness.json new file mode 100644 index 00000000..c6d9e455 --- /dev/null +++ b/plugins/pomodoro-timer/test/harness.json @@ -0,0 +1,21 @@ +{ + "_comment": "Deterministic settings the plugin safety harness renders pomodoro-timer with for golden-image comparison. The timer is idle on a fresh instance, so the golden shows the resting screen: the idle label above the configured work length, with an empty progress bar and an empty set of session dots. Everything that affects the frame is pinned here so a future change to a schema default can't silently invalidate the goldens.", + "config": { + "work_minutes": 25, + "short_break_minutes": 5, + "long_break_minutes": 15, + "sessions_before_long_break": 4, + "mqtt_enabled": false, + "auto_start_on_enable": false, + "color_mode": "phase", + "idle_color": [110, 110, 110], + "background_color": [0, 0, 0], + "idle_label": "POMODORO", + "show_phase_label": true, + "show_session_dots": true, + "show_progress_bar": true, + "font_path": "", + "font_size": 0 + }, + "mock_data": {} +} diff --git a/plugins/pomodoro-timer/test_pomodoro_timer.py b/plugins/pomodoro-timer/test_pomodoro_timer.py new file mode 100644 index 00000000..53c88390 --- /dev/null +++ b/plugins/pomodoro-timer/test_pomodoro_timer.py @@ -0,0 +1,420 @@ +#!/usr/bin/env python3 +""" +Pomodoro Timer plugin tests. + +Self-contained: the LEDMatrix core is stubbed out, so this runs from a plain +checkout of the plugins repo with only Pillow installed. + + python -m pytest plugins/pomodoro-timer/test_pomodoro_timer.py + python plugins/pomodoro-timer/test_pomodoro_timer.py # no pytest needed +""" + +import importlib.util +import json +import logging +import sys +import types +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + +PLUGIN_DIR = Path(__file__).resolve().parent + + +# ── Core stubs ────────────────────────────────────────────────────────────── + +class _StubBasePlugin: + def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager): + self.plugin_id = plugin_id + self.config = config + self.display_manager = display_manager + self.cache_manager = cache_manager + self.plugin_manager = plugin_manager + self.enabled = True + self.global_config = {} + self.logger = logging.getLogger(plugin_id) + + def validate_config(self): + return True + + def get_info(self): + return {"plugin_id": self.plugin_id} + + def on_enable(self): + self.enabled = True + + def on_disable(self): + self.enabled = False + + def on_config_change(self, new_config): + self.config = new_config + + +def _install_core_stub(): + for name in ("src", "src.plugin_system", "src.plugin_system.base_plugin"): + sys.modules.pop(name, None) + src = types.ModuleType("src") + plugin_system = types.ModuleType("src.plugin_system") + base_plugin = types.ModuleType("src.plugin_system.base_plugin") + base_plugin.BasePlugin = _StubBasePlugin + plugin_system.base_plugin = base_plugin + src.plugin_system = plugin_system + sys.modules["src"] = src + sys.modules["src.plugin_system"] = plugin_system + sys.modules["src.plugin_system.base_plugin"] = base_plugin + + +class _Matrix: + def __init__(self, width, height): + self.width = width + self.height = height + + +class _DisplayManager: + def __init__(self, width=128, height=32): + self.width = width + self.height = height + self.matrix = _Matrix(width, height) + self.image = Image.new("RGB", (width, height), (0, 0, 0)) + self.draw = ImageDraw.Draw(self.image) + self.small_font = ImageFont.load_default() + self.regular_font = self.small_font + self.extra_small_font = self.small_font + self.updates = 0 + + def update_display(self): + self.updates += 1 + + def draw_text(self, text, x=None, y=None, color=(255, 255, 255), **kwargs): + self.draw.text((x or 0, y or 0), text, fill=color, font=self.small_font) + + +class _CacheManager: + def __init__(self): + self.store = {} + + def get(self, key, max_age=None): + return self.store.get(key) + + def set(self, key, value, ttl=None): + self.store[key] = value + + def delete(self, key): + self.store.pop(key, None) + + +class _PluginManager: + font_manager = None + config_manager = None + + +def _load_module(): + _install_core_stub() + spec = importlib.util.spec_from_file_location( + "pomodoro_timer_manager", PLUGIN_DIR / "manager.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +MODULE = _load_module() +DEFAULTS = { + k: v.get("default") + for k, v in json.loads((PLUGIN_DIR / "config_schema.json").read_text())["properties"].items() + if "default" in v +} + + +def make_plugin(width=128, height=32, **overrides): + config = {**DEFAULTS, "mqtt_enabled": False, **overrides} + return MODULE.PomodoroTimerPlugin( + "pomodoro-timer", config, _DisplayManager(width, height), + _CacheManager(), _PluginManager()) + + +# ── Tests ─────────────────────────────────────────────────────────────────── + +def test_defaults_start_idle(): + p = make_plugin() + snap = p._snapshot() + assert snap["phase"] == "idle" + assert snap["status"] == "idle" + assert snap["remaining"] == "25:00" + assert snap["elapsed_fraction"] == 0.0 + + +def test_clock_formatting(): + assert MODULE._fmt_clock(0) == "00:00" + assert MODULE._fmt_clock(59.2) == "01:00" + assert MODULE._fmt_clock(1500) == "25:00" + assert MODULE._fmt_clock(-5) == "00:00" + + +def test_start_pause_resume(): + p = make_plugin() + p._apply_command("START", {}) + assert p._snapshot()["status"] == "running" + assert p._snapshot()["phase"] == "work" + + p._apply_command("PAUSE", {}) + snap = p._snapshot() + assert snap["status"] == "paused" + assert snap["label"] == "PAUSED" + + p._apply_command("RESUME", {}) + assert p._snapshot()["status"] == "running" + + p._apply_command("TOGGLE", {}) + assert p._snapshot()["status"] == "paused" + + +def test_stop_keeps_counters_reset_clears_them(): + p = make_plugin() + p._apply_command("START", {}) + with p.state_lock: + p.deadline = 0.0 # force the phase to be due + p._tick() # work completes -> break + assert p._snapshot()["completed_sessions"] == 1 + + p._apply_command("STOP", {}) + assert p._snapshot()["phase"] == "idle" + assert p._snapshot()["completed_sessions"] == 1 + + p._apply_command("RESET", {}) + assert p._snapshot()["completed_sessions"] == 0 + assert p._snapshot()["cycle_position"] == 0 + + +def test_full_cycle_reaches_long_break_and_wraps(): + p = make_plugin(auto_start_breaks=True, auto_start_work=True) + p._apply_command("START", {}) + seen = [] + for _ in range(8): + with p.state_lock: + p.deadline = 0.0 + p._tick() + seen.append(p._snapshot()["phase"]) + assert seen == ["short_break", "work", "short_break", "work", + "short_break", "work", "long_break", "work"] + assert p._snapshot()["completed_sessions"] == 4 + # The long break closed the set, so the dots start over. + assert p._snapshot()["cycle_position"] == 0 + + +def test_break_waits_when_auto_start_is_off(): + p = make_plugin(auto_start_breaks=False) + p._apply_command("START", {}) + with p.state_lock: + p.deadline = 0.0 + p._tick() + snap = p._snapshot() + assert snap["phase"] == "short_break" + assert snap["status"] == "paused" + assert snap["remaining"] == "05:00" + + +def test_skipped_work_does_not_earn_a_long_break(): + p = make_plugin() + with p.state_lock: + p.cycle_position = 3 # one work session away from the long break + p._apply_command("WORK", {}) + p._apply_command("SKIP", {}) + snap = p._snapshot() + assert snap["phase"] == "short_break" + assert snap["completed_sessions"] == 0 + assert snap["cycle_position"] == 3 + + +def test_json_command_with_custom_duration_and_label(): + p = make_plugin() + command, payload = p._parse_command( + b'{"command": "start", "phase": "work", "duration_minutes": 50, "label": "DEEP"}') + p._apply_command(command, payload) + snap = p._snapshot() + assert snap["phase"] == "work" + assert snap["remaining"] == "50:00" + assert snap["label"] == "DEEP" + # The custom duration is per-session; the configured length is untouched. + assert p.work_minutes == 25 + + +def test_settings_only_payload_updates_durations(): + p = make_plugin() + command, payload = p._parse_command(b'{"work_minutes": 45, "short_break_minutes": 10}') + assert command == "SET" + p._apply_command(command, payload) + assert p.work_minutes == 45 + assert p.short_break_minutes == 10 + # Idle screen follows the new work length. + assert p._snapshot()["remaining"] == "45:00" + + +def test_number_values_are_clamped(): + p = make_plugin() + with p.state_lock: + p._set_number_locked("work_minutes", "9999") + p._set_number_locked("sessions_before_long_break", 0) + assert p.work_minutes == 180 + assert p.sessions_before_long_break == 1 + + +def test_unrecognised_payloads_are_ignored(): + p = make_plugin() + for raw in (b"", b"gibberish-command", b'{"nothing": 1}', b"\xff\xfe"): + command, payload = p._parse_command(raw) + if command is not None: + p._apply_command(command, payload) + assert p._snapshot()["phase"] == "idle" + + +def test_pin_request_follows_the_timer(): + p = make_plugin(pin_while_running=True) + p._apply_command("START", {}) + request = p.cache_manager.get("display_on_demand_request") + assert request["action"] == "start" and request["pinned"] is True + + p._apply_command("STOP", {}) + assert p.cache_manager.get("display_on_demand_request")["action"] == "stop" + + +def test_pin_is_not_requested_when_disabled(): + p = make_plugin(pin_while_running=False, alert_seconds=0) + p._apply_command("START", {}) + assert p.cache_manager.get("display_on_demand_request") is None + + +def test_display_renders_every_panel_size(): + for width, height in ((64, 32), (128, 32), (128, 64), (256, 32)): + p = make_plugin(width, height) + p._apply_command("START", {}) + assert p.display() is True + assert p.display_manager.updates >= 1 + assert p.display_manager.image.size == (width, height) + assert p.display_manager.image.getbbox() is not None, "rendered a blank frame" + + +def test_discovery_payloads_are_valid_json_and_unique(): + p = make_plugin() + entities = p._discovery_entities() + assert len(entities) == 17 + uids = set() + for topic, payload in entities: + assert topic.count("/") == 2 and topic.endswith("/config") + json.dumps(payload) + assert payload["unique_id"] not in uids + uids.add(payload["unique_id"]) + topics = [t for t, _ in entities] + assert any(t.startswith("switch/") for t in topics) + assert sum(t.startswith("button/") for t in topics) == 5 + assert sum(t.startswith("number/") for t in topics) == 4 + + +class _RecordingClient: + """Stands in for paho's client so the MQTT plumbing can be exercised + without a broker (an end-to-end run against mosquitto is covered + separately).""" + + def __init__(self): + self.subscriptions = [] + self.published = [] + + def subscribe(self, topic, qos=0): + self.subscriptions.append(topic) + + def publish(self, topic, payload, retain=False, qos=0): + self.published.append((topic, payload)) + + +class _Message: + def __init__(self, topic, payload): + self.topic = topic + self.payload = payload if isinstance(payload, bytes) else payload.encode() + + +def _wire_client(plugin): + client = _RecordingClient() + plugin.mqtt_client = client + plugin.mqtt_connected = True + return client + + +def test_connect_subscribes_to_command_and_number_topics(): + p = make_plugin() + client = _RecordingClient() + p.mqtt_client = client + p._on_mqtt_connect(client, None, {}, 0) + assert p.mqtt_connected is True + assert p.command_topic in client.subscriptions + for topic in p.number_set_topics: + assert topic in client.subscriptions + published = {t for t, _ in client.published} + assert any(t.startswith("homeassistant/") for t in published) + assert p.state_topic in published + + +def test_incoming_messages_drive_the_timer(): + p = make_plugin() + client = _wire_client(p) + + p._on_mqtt_message(client, None, _Message(p.command_topic, "START")) + assert p._snapshot()["status"] == "running" + + p._on_mqtt_message(client, None, _Message(p.command_topic, "PAUSE")) + assert p._snapshot()["status"] == "paused" + + p._on_mqtt_message(client, None, _Message(f"{p.topic_base}/work_minutes/set", "45")) + assert p.work_minutes == 45 + + published = dict(client.published) + assert published[p.state_topic] == "ON" + assert published[p.status_topic] == "paused" + assert published[p.number_state_topics["work_minutes"]] == "45" + + +def test_a_failed_connection_does_not_look_connected(): + p = make_plugin() + client = _RecordingClient() + p.mqtt_client = client + p._on_mqtt_connect(client, None, {}, 5) # 5 = not authorised + assert p.mqtt_connected is False + assert client.subscriptions == [] + + +def test_derived_topics_track_the_command_topic(): + p = make_plugin(command_topic="home/focus/set") + assert p.topic_base == "home/focus" + assert p.remaining_topic == "home/focus/remaining" + assert "home/focus/work_minutes/set" in p.number_set_topics + assert p.number_state_topics["work_minutes"] == "home/focus/work_minutes" + + +def test_validate_config_rejects_bad_values(): + assert make_plugin().validate_config() is True + assert make_plugin(color_mode="rainbow").validate_config() is False + assert make_plugin(mqtt_port=0).validate_config() is False + + +def test_config_change_keeps_a_running_session(): + p = make_plugin() + p._apply_command("START", {}) + before = p._snapshot()["remaining_seconds"] + p.on_config_change({**DEFAULTS, "mqtt_enabled": False, "work_color": [1, 2, 3]}) + snap = p._snapshot() + assert snap["phase"] == "work" + assert abs(snap["remaining_seconds"] - before) <= 1 + assert p.work_color == (1, 2, 3) + + +if __name__ == "__main__": + failures = 0 + for name, fn in sorted(globals().items()): + if not name.startswith("test_") or not callable(fn): + continue + try: + fn() + print(f"[ok] {name}") + except Exception as exc: # noqa: BLE001 - report and keep going + failures += 1 + print(f"[FAIL] {name}: {exc!r}") + print(f"\n{'FAILED' if failures else 'PASSED'} — {failures} failure(s)") + sys.exit(1 if failures else 0) From 42cb7b030d43b62cfa60c1aa50efacf9f13a2291 Mon Sep 17 00:00:00 2001 From: Charles Mynard Date: Tue, 28 Jul 2026 21:28:25 +0000 Subject: [PATCH 2/7] pomodoro-timer: harden MQTT teardown, pin sync, and START semantics Review fixes, all in the plugin's threading/MQTT plumbing: - Centralise paho client teardown in _teardown_client(). The reconnect path, the loop's error handler, and shutdown now all funnel through it, so a refused CONNACK or a half-open attempt can no longer leave a live client and its network thread behind for the next attempt to overwrite. - Retry when no CONNACK arrives within the 5s window. Previously mqtt_connecting stayed set, which parked the supervisor loop in its 1s wait forever and never reconnected. - _graceful_shutdown now sets the stop event and tears the client down unconditionally, so a loop that already died (or never started) with a live client still releases it. - Publish the offline availability state, and wait for it, before disconnecting. It was racing the loop thread's own offline publish against our disconnect, so HA could sit on a stale 'online' until the keepalive lapsed. - Serialise the whole _sync_pin compare-and-set under state_lock. It is called from the timer, MQTT, and display threads; interleaving could publish a stop after a start and, because the method early-returns once _pinned matches, strand the panel until the next transition. A failed request now restores _pinned so the next tick retries. - START on an already-running timer no longer emits a 'started' event, so pressing the HA Start button mid-session can't re-trigger automations. Settings sent in the same payload still apply. - Clear retained discovery topics when ha_discovery is switched off, otherwise the entities linger in HA forever. A plain restart still leaves them retained -- that is what lets HA keep the device across reboots, with the LWT marking it unavailable. - Restore sys.modules in the test loader so its stub 'src' package can't shadow the real core for other plugins' tests. Removes the three try/except/pass blocks flagged as a security issue by Codacy and Ruff (S110); bandit is now clean on manager.py. Adds six regression tests (26 total) and re-verified the harness across all sampled panel sizes and end-to-end against mosquitto. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AtZDGDrzbWdK4JxUeH4hk6 --- plugins.json | 2 +- plugins/pomodoro-timer/manager.py | 207 ++++++++++++------ plugins/pomodoro-timer/manifest.json | 25 ++- plugins/pomodoro-timer/test_pomodoro_timer.py | 127 ++++++++++- 4 files changed, 283 insertions(+), 78 deletions(-) diff --git a/plugins.json b/plugins.json index 0aeafc22..937ee8fc 100644 --- a/plugins.json +++ b/plugins.json @@ -1143,7 +1143,7 @@ "last_updated": "2026-07-28", "verified": true, "screenshot": "", - "latest_version": "1.0.0", + "latest_version": "1.0.1", "icon": "fa-hourglass-half" } ] diff --git a/plugins/pomodoro-timer/manager.py b/plugins/pomodoro-timer/manager.py index 4985a91f..a7fae3f7 100644 --- a/plugins/pomodoro-timer/manager.py +++ b/plugins/pomodoro-timer/manager.py @@ -394,6 +394,7 @@ def _apply_command(self, command: str, payload: Dict[str, Any]) -> None: duration = None if command in ("START", "ON", "PLAY"): + started = True phase = str(payload.get("phase", "") or "").strip().lower() if phase in _PHASE_ORDER: self._begin_phase_locked(phase, duration, run=True, label=label) @@ -403,8 +404,16 @@ def _apply_command(self, command: str, payload: Dict[str, Any]) -> None: self._resume_locked() elif duration is not None: self._begin_phase_locked(self.phase, duration, run=True, label=label) - events.append({"event_type": "started", "phase": self.phase, - "duration_seconds": int(self.phase_total)}) + else: + # Already running with nothing to change. Pressing Start + # mid-session must not re-fire "started" automations, so + # emit no event — any settings in the same payload were + # still applied above and are published below. + self.logger.debug("START ignored: timer already running") + started = False + if started: + events.append({"event_type": "started", "phase": self.phase, + "duration_seconds": int(self.phase_total)}) elif command in ("WORK", "FOCUS"): self._begin_phase_locked(PHASE_WORK, duration, run=True, label=label) @@ -619,6 +628,14 @@ def on_config_change(self, new_config: Dict[str, Any]) -> None: self.ha_discovery, self.discovery_prefix) was_idle = self.phase == PHASE_IDLE + # Turning discovery off has to clear the retained config topics while we + # still know where they were published, or the entities linger in Home + # Assistant forever. A plain restart deliberately leaves them retained — + # that's what lets HA keep the device across reboots, with the LWT + # marking it unavailable in the meantime. + if self.ha_discovery and not bool(new_config.get("ha_discovery", True)): + self._remove_discovery() + self._load_settings(new_config) self._font_cache = {} self._ttf_path_resolved = False @@ -932,26 +949,35 @@ def _draw_progress_bar(self, draw, x: int, y: int, width: int, height: int, # ── Display pinning ───────────────────────────────────────────────────────── def _sync_pin(self) -> None: - """Take over (or release) the panel to match the timer's state.""" + """Take over (or release) the panel to match the timer's state. + + Called from the timer thread, the MQTT thread, and the display thread, + so the whole compare-and-set — including writing the request — runs + under state_lock. Otherwise two callers can interleave and publish the + requests out of order (a stop landing after a start), and because the + method early-returns once _pinned matches, the panel would stay stuck + that way until the next transition. + """ with self.state_lock: active = self.phase != PHASE_IDLE alerting = time.monotonic() < self.alert_until - want = (self.pin_while_running and active) or alerting - if want == self._pinned: - return - self._pinned = want - request: Dict[str, Any] = { - "request_id": str(uuid.uuid4()), - "plugin_id": self.plugin_id, - "action": "start" if want else "stop", - "timestamp": time.time(), - } - if want: - request.update({"mode": "pomodoro", "duration": None, "pinned": True}) - try: - self.cache_manager.set("display_on_demand_request", request) - except Exception as e: - self.logger.debug("On-demand display request failed: %s", e) + want = (self.pin_while_running and active) or alerting + if want == self._pinned: + return + self._pinned = want + request: Dict[str, Any] = { + "request_id": str(uuid.uuid4()), + "plugin_id": self.plugin_id, + "action": "start" if want else "stop", + "timestamp": time.time(), + } + if want: + request.update({"mode": "pomodoro", "duration": None, "pinned": True}) + try: + self.cache_manager.set("display_on_demand_request", request) + except Exception as e: + self.logger.debug("On-demand display request failed: %s", e) + self._pinned = not want # let the next tick retry def _timer_loop(self) -> None: while not self.timer_stop_event.wait(0.25): @@ -974,12 +1000,23 @@ def _publish(self, topic: str, payload: str, retain: bool = True) -> None: except Exception as e: self.logger.debug("Publish to %s failed: %s", topic, e) - def _publish_availability(self, online: bool) -> None: - if not self.mqtt_client: + def _publish_availability(self, online: bool, wait: bool = False) -> None: + """Publish the availability state. + + Pass wait=True when shutting down: the publish has to reach the broker + before we disconnect, otherwise it sits in paho's outbound queue and + dies with the client, leaving the device stuck 'online' in HA until the + keepalive lapses. + """ + client = self.mqtt_client + if client is None: return try: - self.mqtt_client.publish(self.availability_topic, - "online" if online else "offline", retain=True) + info = client.publish(self.availability_topic, + "online" if online else "offline", + qos=1, retain=True) + if wait and info is not None and hasattr(info, "wait_for_publish"): + info.wait_for_publish(timeout=2.0) except Exception as e: self.logger.debug("Availability publish failed: %s", e) @@ -1187,53 +1224,82 @@ def _connect_mqtt(self) -> bool: self.logger.error("MQTT connect error: %s", e) return False + def _teardown_client(self) -> None: + """Stop the network loop, disconnect, and release the paho client. + + The single place a client is torn down: the reconnect path, the loop's + error handler, and shutdown all funnel through here so a refused or + half-open connection can never leave a live client (and its network + thread) behind. Idempotent, and never raises. + """ + client, self.mqtt_client = self.mqtt_client, None + self.mqtt_connected = False + self.mqtt_connecting = False + if client is None: + return + try: + client.loop_stop() + except Exception as e: + self.logger.debug("MQTT loop_stop during teardown failed: %s", e) + try: + client.disconnect() + except Exception as e: + self.logger.debug("MQTT disconnect during teardown failed: %s", e) + + def _backoff(self) -> bool: + """Sleep out the current reconnect delay, then double it. + + Returns True when the plugin is shutting down and the loop should stop. + """ + wait = min(self.mqtt_reconnect_delay, self.mqtt_max_reconnect_delay) + self.logger.info("Retrying MQTT in %.0fs", wait) + if self.mqtt_stop_event.wait(wait): + return True + self.mqtt_reconnect_delay = min( + self.mqtt_reconnect_delay * 2, self.mqtt_max_reconnect_delay) + return False + def _mqtt_loop(self) -> None: while not self.mqtt_stop_event.is_set(): try: - if not self.mqtt_connected and not self.mqtt_connecting: - self.mqtt_connecting = True - if self._connect_mqtt(): - self.mqtt_client.loop_start() - # Give the broker a moment to answer CONNACK before the - # next pass, so we never open a second connection. - self.mqtt_stop_event.wait(5.0) - else: - self.mqtt_connecting = False - wait = min(self.mqtt_reconnect_delay, self.mqtt_max_reconnect_delay) - self.logger.info("Retrying MQTT in %.0fs", wait) - if self.mqtt_stop_event.wait(wait): - break - self.mqtt_reconnect_delay = min( - self.mqtt_reconnect_delay * 2, self.mqtt_max_reconnect_delay) - else: + if self.mqtt_connected: if self.mqtt_stop_event.wait(1.0): break self.mqtt_reconnect_delay = 1.0 + continue + + # Release whatever is left from a refused or half-open attempt + # before dialing again, so its network thread can't outlive it. + self._teardown_client() + self.mqtt_connecting = True + if not self._connect_mqtt(): + self._teardown_client() + if self._backoff(): + break + continue + + client = self.mqtt_client + if client is None: # torn down from another thread + continue + client.loop_start() + # Wait for CONNACK before looping so we never open a second + # connection. If it never arrives, drop this client and retry + # rather than sitting here connecting forever. + self.mqtt_stop_event.wait(5.0) + if not self.mqtt_connected and not self.mqtt_stop_event.is_set(): + self.logger.warning("No CONNACK from %s:%s within 5s — retrying", + self.mqtt_host, self.mqtt_port) + self._teardown_client() + if self._backoff(): + break except Exception as e: self.logger.error("MQTT loop error: %s", e, exc_info=True) - self.mqtt_connected = False - self.mqtt_connecting = False - if self.mqtt_client: - try: - self.mqtt_client.loop_stop() - self.mqtt_client.disconnect() - except Exception: - pass - self.mqtt_client = None - wait = min(self.mqtt_reconnect_delay, self.mqtt_max_reconnect_delay) - if self.mqtt_stop_event.wait(wait): + self._teardown_client() + if self._backoff(): break - self.mqtt_reconnect_delay = min( - self.mqtt_reconnect_delay * 2, self.mqtt_max_reconnect_delay) self._publish_availability(False) - if self.mqtt_client: - try: - self.mqtt_client.loop_stop() - self.mqtt_client.disconnect() - except Exception: - pass - self.mqtt_client = None + self._teardown_client() self.logger.info("MQTT loop stopped") def _graceful_shutdown(self) -> None: @@ -1242,15 +1308,22 @@ def _graceful_shutdown(self) -> None: self.timer_thread.join(timeout=2.0) self.timer_thread = None + # Say goodbye while the socket is still up — once we disconnect below, + # the loop thread's own offline publish has nothing to send on. + self._publish_availability(False, wait=True) + + self.mqtt_stop_event.set() if self.mqtt_thread and self.mqtt_thread.is_alive(): - self.mqtt_stop_event.set() - if self.mqtt_client: + # Drop the connection so the loop wakes out of its wait instead of + # sitting there for the full timeout. + client = self.mqtt_client + if client is not None: try: - self.mqtt_client.loop_stop() - self.mqtt_client.disconnect() - except Exception: - pass + client.disconnect() + except Exception as e: + self.logger.debug("MQTT disconnect during shutdown failed: %s", e) self.mqtt_thread.join(timeout=5.0) self.mqtt_thread = None - self.mqtt_connected = False - self.mqtt_connecting = False + # Unconditional: the loop may have died (or never started) with a live + # client still attached. + self._teardown_client() diff --git a/plugins/pomodoro-timer/manifest.json b/plugins/pomodoro-timer/manifest.json index f019fa5b..465a3354 100644 --- a/plugins/pomodoro-timer/manifest.json +++ b/plugins/pomodoro-timer/manifest.json @@ -1,16 +1,33 @@ { "id": "pomodoro-timer", "name": "Pomodoro Timer", - "version": "1.0.0", + "version": "1.0.1", "author": "ChuckBuilds", "description": "A configurable Pomodoro focus/break timer for your matrix. Set the work and break lengths, then start, pause, skip, or reset it over MQTT — with Home Assistant auto-discovery so the whole timer shows up as a device with no YAML.", "entry_point": "manager.py", "class_name": "PomodoroTimerPlugin", "category": "productivity", - "tags": ["pomodoro", "timer", "focus", "productivity", "mqtt", "home-assistant"], - "display_modes": ["pomodoro"], - "compatible_versions": [">=2.0.0"], + "tags": [ + "pomodoro", + "timer", + "focus", + "productivity", + "mqtt", + "home-assistant" + ], + "display_modes": [ + "pomodoro" + ], + "compatible_versions": [ + ">=2.0.0" + ], "versions": [ + { + "released": "2026-07-28", + "version": "1.0.1", + "notes": "Reliability fixes from review. Centralise MQTT client teardown so a refused or half-open connection can no longer leave a live paho client and its network thread behind, and retry instead of stalling forever when no CONNACK arrives. Shut down cleanly even when the MQTT thread already died, and publish the offline availability state before the socket closes so Home Assistant doesn't sit on a stale 'online'. Serialise the display-pin compare-and-set under the state lock so concurrent callers can't strand the panel pinned or unpinned. START on an already-running timer no longer re-fires the 'started' event. Clear retained discovery topics when Home Assistant discovery is turned off.", + "ledmatrix_min_version": "2.0.0" + }, { "released": "2026-07-28", "version": "1.0.0", diff --git a/plugins/pomodoro-timer/test_pomodoro_timer.py b/plugins/pomodoro-timer/test_pomodoro_timer.py index 53c88390..f9003e0e 100644 --- a/plugins/pomodoro-timer/test_pomodoro_timer.py +++ b/plugins/pomodoro-timer/test_pomodoro_timer.py @@ -108,13 +108,31 @@ class _PluginManager: config_manager = None +_MISSING = object() +_STUBBED_MODULES = ("src", "src.plugin_system", "src.plugin_system.base_plugin") + + def _load_module(): - _install_core_stub() - spec = importlib.util.spec_from_file_location( - "pomodoro_timer_manager", PLUGIN_DIR / "manager.py") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module + """Import manager.py against the stubbed core, then put sys.modules back. + + `manager.py` binds BasePlugin at class-creation time, so the stub only has + to be in place for the exec. Restoring afterwards keeps our fake `src` from + shadowing the real core for any other plugin's tests in the same session. + """ + originals = {name: sys.modules.get(name, _MISSING) for name in _STUBBED_MODULES} + try: + _install_core_stub() + spec = importlib.util.spec_from_file_location( + "pomodoro_timer_manager", PLUGIN_DIR / "manager.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + finally: + for name, original in originals.items(): + if original is _MISSING: + sys.modules.pop(name, None) + else: + sys.modules[name] = original MODULE = _load_module() @@ -380,6 +398,103 @@ def test_a_failed_connection_does_not_look_connected(): assert client.subscriptions == [] +def test_start_on_a_running_timer_emits_no_event(): + p = make_plugin() + client = _wire_client(p) + p._apply_command("START", {}) + before = [t for t, _ in client.published if t == p.event_topic] + + p._apply_command("START", {}) # e.g. the HA Start button pressed again + after = [t for t, _ in client.published if t == p.event_topic] + assert after == before, "a redundant START re-fired the started event" + + # Settings sent alongside a redundant START still apply and get published. + p._apply_command("START", {"work_minutes": 30}) + assert p.work_minutes == 30 + assert dict(client.published)[p.number_state_topics["work_minutes"]] == "30" + + # A resume, by contrast, is a real transition and does emit. + p._apply_command("PAUSE", {}) + p._apply_command("START", {}) + assert p._snapshot()["status"] == "running" + assert len([t for t, _ in client.published if t == p.event_topic]) > len(after) + + +class _StubPahoClient: + """Records the teardown calls made against a live client.""" + + def __init__(self): + self.loop_stopped = 0 + self.disconnected = 0 + self.published = [] + + def loop_stop(self): + self.loop_stopped += 1 + + def disconnect(self): + self.disconnected += 1 + + def publish(self, topic, payload, retain=False, qos=0): + self.published.append((topic, payload)) + + +def test_teardown_releases_the_client_and_is_idempotent(): + p = make_plugin() + client = _StubPahoClient() + p.mqtt_client = client + p.mqtt_connected = True + p.mqtt_connecting = True + + p._teardown_client() + assert p.mqtt_client is None + assert p.mqtt_connected is False and p.mqtt_connecting is False + assert client.loop_stopped == 1 and client.disconnected == 1 + + p._teardown_client() # no client left — must not raise or re-call + assert client.loop_stopped == 1 + + +def test_teardown_survives_a_client_that_raises(): + p = make_plugin() + + class _Angry(_StubPahoClient): + def loop_stop(self): + raise RuntimeError("loop already stopped") + + def disconnect(self): + raise RuntimeError("socket gone") + + p.mqtt_client = _Angry() + p._teardown_client() # must swallow both failures + assert p.mqtt_client is None + + +def test_shutdown_releases_a_client_left_by_a_dead_thread(): + p = make_plugin() + client = _StubPahoClient() + p.mqtt_client = client # loop exited (or never ran) with a live client + p.mqtt_thread = None + + p._graceful_shutdown() + assert p.mqtt_stop_event.is_set() + assert p.mqtt_client is None + assert client.disconnected >= 1 + # HA must be told we're gone before the socket closes, or the device sits + # "online" until the keepalive lapses. + assert (p.availability_topic, "offline") in client.published + + +def test_pin_state_is_restored_when_the_request_fails(): + p = make_plugin() + + def _boom(*args, **kwargs): + raise RuntimeError("cache unavailable") + + p.cache_manager.set = _boom + p._apply_command("START", {}) + assert p._pinned is False, "a failed request must stay retryable" + + def test_derived_topics_track_the_command_topic(): p = make_plugin(command_topic="home/focus/set") assert p.topic_base == "home/focus" From bb26b7fe2d9840bcb86fc28fee7dccbf4a209e5e Mon Sep 17 00:00:00 2001 From: Charles Mynard Date: Tue, 28 Jul 2026 22:45:18 +0000 Subject: [PATCH 3/7] pomodoro-timer: seven-segment countdown and a burndown ring Redraws the panel. The countdown was set in the display's pixel font, which is a good label face and a poor timer face; the burndown was a bar that filled up along the bottom. - The countdown is now drawn as geometric seven-segment digits sized to the panel rather than picked from a font, so it scales to whatever space is left instead of snapping to font sizes. Unlit segments stay faintly lit the way a real LED clock shows its dark segments, which also stops the digits appearing to shift as the numbers change. digit_style: pixel keeps the old look. - The digits refuse to go below a 0.64 width-to-height ratio and step down a size instead. Without that guard a tall box drove them to a sliver and a 0 stopped reading as a digit at all -- it became two stacked boxes on 128x64. - New burndown indicator. perimeter (default) lights the whole border at the start of a phase and drains it clockwise from the top-left behind a brighter head pixel. It costs no interior space, which is what pays for the larger digits. bar and segments sit along the bottom edge and both now empty rather than fill; none hides it. - On panels under 40px tall the session dots tuck in beside the phase label when the label leaves room, instead of taking a row of their own. That is a quarter of the height back for the digits on a 32px panel. Config: show_progress_bar is replaced by progress_style; adds digit_style and show_ghost_segments. Goldens regenerated for all four canonical sizes. Five new tests (31 total) cover the ring draining in proportion, digit legibility across panel shapes, the fallback when there is no room for segments, and every style combination rendering. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AtZDGDrzbWdK4JxUeH4hk6 --- plugins.json | 2 +- plugins/pomodoro-timer/README.md | 34 ++- plugins/pomodoro-timer/config_schema.json | 145 +++++++-- plugins/pomodoro-timer/manager.py | 287 +++++++++++++++--- plugins/pomodoro-timer/manifest.json | 8 +- .../test/golden/128x32/pomodoro.png | Bin 824 -> 602 bytes .../test/golden/128x64/pomodoro.png | Bin 730 -> 833 bytes .../test/golden/256x32/pomodoro.png | Bin 1050 -> 497 bytes .../test/golden/64x32/pomodoro.png | Bin 593 -> 564 bytes plugins/pomodoro-timer/test/harness.json | 6 +- plugins/pomodoro-timer/test_pomodoro_timer.py | 66 ++++ 11 files changed, 462 insertions(+), 86 deletions(-) diff --git a/plugins.json b/plugins.json index 937ee8fc..aa08705a 100644 --- a/plugins.json +++ b/plugins.json @@ -1143,7 +1143,7 @@ "last_updated": "2026-07-28", "verified": true, "screenshot": "", - "latest_version": "1.0.1", + "latest_version": "1.1.0", "icon": "fa-hourglass-half" } ] diff --git a/plugins/pomodoro-timer/README.md b/plugins/pomodoro-timer/README.md index c1a033ff..a9888a69 100644 --- a/plugins/pomodoro-timer/README.md +++ b/plugins/pomodoro-timer/README.md @@ -57,17 +57,30 @@ normal rotation when the timer goes idle. ## What's On Screen ```text -┌──────────────────────────────┐ -│ FOCUS │ ← phase label (or PAUSED) -│ 17:42 │ ← countdown, colored by phase -│ ●●○○ │ ← one dot per session in the set -│ ▓▓▓▓▓▓▓▓▓░░░░░░░░░░░░░░░░░░░ │ ← progress through the current phase -└──────────────────────────────┘ +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▔▔▔▔▔▔▔▔ ← burndown ring: lit = time left in the phase +▏ FOCUS ●●○○ ▕ ← phase label, and one dot per session in the set +▏ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ▕ +▏ │17│:│42│ ▕ ← countdown in seven-segment digits +▏ └─┘ └─┘ ▕ +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ ``` +The countdown is drawn as **seven-segment digits**, sized to whatever space the +panel has left rather than picked from a font. The unlit segments stay faintly +visible, the way the dark segments of a real LED clock do, which keeps the digits +from appearing to jump around as the numbers change. + +The **burndown ring** is the default indicator: the whole border is lit when a +phase starts and drains clockwise from the top-left, with a brighter head pixel +leading the way. It costs no interior space, which is what lets the digits be as +large as they are. If you'd rather have the border back, `bar` and `segments` put +the indicator along the bottom edge instead, and `none` hides it. + Each element can be turned off, and each color can be changed. The layout adapts to the panel: stacked on 64×32, 128×32, and 128×64; label and dots beside a large -countdown on long panels like 256×32. +countdown on long panels like 256×32. On short panels the session dots tuck in +beside the label rather than taking a row of their own, so the digits get the +height instead. | Phase | Default color | |---|---| @@ -143,9 +156,11 @@ durations you configured. | **Idle / Paused Color** | grey / amber | Used when nothing is running and when paused. | | **Countdown Text Color** | white | Only used when Countdown Color is `fixed`. | | **Background Color** | black | Panel background. | +| **Countdown Digits** | `seven_segment` | `seven_segment` draws clock-radio style segments sized to the panel. `pixel` uses the display's pixel font. | +| **Show Unlit Segments** | `true` | Keep the unlit segments faintly visible, like a real LED clock. Turn off for digits on pure black. Ignored when the stroke is only one pixel wide, where the effect would just be noise. | +| **Burndown Indicator** | `perimeter` | `perimeter` drains a ring around the edge of the panel; `bar` empties a bar along the bottom; `segments` puts out a row of blocks one at a time; `none` hides it. | | **Show Phase Label** | `true` | The phase name above the countdown. | | **Show Session Dots** | `true` | One dot per session in the set, filled as you complete them. | -| **Show Progress Bar** | `true` | Bar along the bottom that fills as the phase elapses. | | **Work / Short Break / Long Break / Idle / Paused Label** | `FOCUS` / `BREAK` / `LONG BREAK` / `POMODORO` / `PAUSED` | The on-screen text for each state. Blank the Paused Label to keep showing the phase name while paused. | | **Font** | *(blank)* | Path to a TTF relative to the LEDMatrix root, e.g. `assets/fonts/PressStart2P-Regular.ttf`. Blank uses the display's default font. | | **Font Size (px)** | `0` | Fix the countdown height in pixels. `0` sizes it automatically to the panel. | @@ -419,7 +434,8 @@ python plugins/pomodoro-timer/test_pomodoro_timer.py | Log says `MQTT connect error` and retries | Wrong host or port, or the broker isn't reachable from the Pi. The plugin retries with backoff, so fixing the config is enough. | | The timer never appears on the matrix | Check the plugin is enabled and, if **Hold the Display While Running** is off, that it has a turn in your display rotation. | | The timer takes over and won't give the panel back | That's **Hold the Display While Running**. Turn it off to keep the timer in the normal rotation, or send `STOP`. | -| The countdown looks tiny on a big panel | Set **Font Size (px)** to `0` so it auto-sizes, or raise it for a fixed size. | +| The countdown looks tiny on a big panel | The seven-segment digits are sized to the space left after the label and dots. Turning off **Show Phase Label**, or switching **Burndown Indicator** to `perimeter`, frees the most room. **Font Size (px)** applies to the `pixel` digit style only. | +| The digits look squashed or the zeros read as two stacked boxes | The digits refuse to go below a readable width-to-height ratio and step down in size instead, so this should not happen — if it does on your panel shape, please open an issue with the dimensions. | | Text is cut off | Shorten the phase labels — long labels are truncated to fit rather than drawn past the panel edge. | | Durations changed in HA revert after a restart | Number-box changes are runtime-only by design. Set them in the plugin's web UI config to make them permanent. | | The session count reset itself | `RESET` zeroes it (that's the difference between `STOP` and `RESET`), and so does a long break for the on-screen dots. | diff --git a/plugins/pomodoro-timer/config_schema.json b/plugins/pomodoro-timer/config_schema.json index 9a5171f0..049fc78d 100644 --- a/plugins/pomodoro-timer/config_schema.json +++ b/plugins/pomodoro-timer/config_schema.json @@ -31,9 +31,11 @@ "paused_color", "time_color", "background_color", + "digit_style", + "show_ghost_segments", "show_phase_label", "show_session_dots", - "show_progress_bar", + "progress_style", "work_label", "short_break_label", "long_break_label", @@ -52,7 +54,6 @@ "default": true, "title": "Enabled" }, - "work_minutes": { "type": "integer", "default": 25, @@ -104,7 +105,6 @@ "description": "Begin a work session as soon as the plugin loads. Useful if you don't use MQTT.", "x-advanced": true }, - "mqtt_enabled": { "type": "boolean", "default": true, @@ -180,10 +180,12 @@ "description": "How often the countdown is published while running. Raise this to cut broker traffic; state changes (start, pause, phase change) are always published immediately.", "x-advanced": true }, - "color_mode": { "type": "string", - "enum": ["phase", "fixed"], + "enum": [ + "phase", + "fixed" + ], "default": "phase", "title": "Countdown Color", "description": "'phase' colors the countdown by what's running (work / break / long break / paused). 'fixed' always uses the Countdown Text Color below.", @@ -191,57 +193,105 @@ }, "work_color": { "type": "array", - "items": { "type": "integer", "minimum": 0, "maximum": 255 }, + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, "minItems": 3, "maxItems": 3, - "default": [255, 70, 50], + "default": [ + 255, + 70, + 50 + ], "title": "Work Color", "x-widget": "color-picker" }, "short_break_color": { "type": "array", - "items": { "type": "integer", "minimum": 0, "maximum": 255 }, + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, "minItems": 3, "maxItems": 3, - "default": [60, 200, 110], + "default": [ + 60, + 200, + 110 + ], "title": "Short Break Color", "x-widget": "color-picker" }, "long_break_color": { "type": "array", - "items": { "type": "integer", "minimum": 0, "maximum": 255 }, + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, "minItems": 3, "maxItems": 3, - "default": [70, 150, 255], + "default": [ + 70, + 150, + 255 + ], "title": "Long Break Color", "x-widget": "color-picker" }, "idle_color": { "type": "array", - "items": { "type": "integer", "minimum": 0, "maximum": 255 }, + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, "minItems": 3, "maxItems": 3, - "default": [110, 110, 110], + "default": [ + 110, + 110, + 110 + ], "title": "Idle Color", "x-widget": "color-picker", "x-advanced": true }, "paused_color": { "type": "array", - "items": { "type": "integer", "minimum": 0, "maximum": 255 }, + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, "minItems": 3, "maxItems": 3, - "default": [255, 176, 0], + "default": [ + 255, + 176, + 0 + ], "title": "Paused Color", "x-widget": "color-picker", "x-advanced": true }, "time_color": { "type": "array", - "items": { "type": "integer", "minimum": 0, "maximum": 255 }, + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, "minItems": 3, "maxItems": 3, - "default": [255, 255, 255], + "default": [ + 255, + 255, + 255 + ], "title": "Countdown Text Color", "description": "Used only when Countdown Color is set to 'fixed'.", "x-widget": "color-picker", @@ -249,15 +299,22 @@ }, "background_color": { "type": "array", - "items": { "type": "integer", "minimum": 0, "maximum": 255 }, + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, "minItems": 3, "maxItems": 3, - "default": [0, 0, 0], + "default": [ + 0, + 0, + 0 + ], "title": "Background Color", "x-widget": "color-picker", "x-advanced": true }, - "show_phase_label": { "type": "boolean", "default": true, @@ -270,13 +327,6 @@ "title": "Show Session Dots", "description": "Show one dot per work session in the set, filled in as you complete them." }, - "show_progress_bar": { - "type": "boolean", - "default": true, - "title": "Show Progress Bar", - "description": "Show a bar along the bottom edge that fills as the current phase elapses." - }, - "work_label": { "type": "string", "default": "FOCUS", @@ -314,7 +364,6 @@ "description": "Shown in place of the phase name while the timer is paused. Leave blank to keep showing the phase name.", "x-advanced": true }, - "font_path": { "type": "string", "default": "", @@ -331,7 +380,6 @@ "description": "Fix the countdown font height in pixels. Leave at 0 to size the countdown automatically to the panel.", "x-advanced": true }, - "pin_while_running": { "type": "boolean", "default": true, @@ -361,8 +409,45 @@ "title": "Display Duration (seconds)", "description": "How long the timer stays on screen each rotation cycle when it isn't holding the display.", "x-advanced": true + }, + "digit_style": { + "type": "string", + "enum": [ + "seven_segment", + "pixel" + ], + "default": "seven_segment", + "title": "Countdown Digits", + "description": "'seven_segment' draws the countdown as clock-radio style segments sized to the panel. 'pixel' uses the display's pixel font.", + "x-widget": "radio" + }, + "show_ghost_segments": { + "type": "boolean", + "default": true, + "title": "Show Unlit Segments", + "description": "Keep the unlit segments faintly visible, the way a real LED clock shows its dark segments. Turn off for digits on pure black.", + "x-advanced": true + }, + "progress_style": { + "type": "string", + "enum": [ + "perimeter", + "bar", + "segments", + "none" + ], + "default": "perimeter", + "title": "Burndown Indicator", + "description": "How the time left in the phase is shown. 'perimeter' drains a ring around the edge of the panel and leaves the most room for the digits; 'bar' empties a bar along the bottom; 'segments' puts out a row of blocks one at a time; 'none' hides it.", + "x-widget": "radio" } }, - "required": ["enabled", "work_minutes", "short_break_minutes", "long_break_minutes", "sessions_before_long_break"], + "required": [ + "enabled", + "work_minutes", + "short_break_minutes", + "long_break_minutes", + "sessions_before_long_break" + ], "additionalProperties": false } diff --git a/plugins/pomodoro-timer/manager.py b/plugins/pomodoro-timer/manager.py index a7fae3f7..738aa806 100644 --- a/plugins/pomodoro-timer/manager.py +++ b/plugins/pomodoro-timer/manager.py @@ -95,6 +95,12 @@ def _dim(color: Tuple[int, int, int], factor: float) -> Tuple[int, int, int]: return tuple(max(0, min(255, int(c * factor))) for c in color) # type: ignore[return-value] +def _lighten(color: Tuple[int, int, int], factor: float) -> Tuple[int, int, int]: + """Blend `factor` of white into `color` — used for the burndown's head.""" + return tuple( # type: ignore[return-value] + max(0, min(255, int(c + (255 - c) * factor))) for c in color) + + def _fmt_clock(seconds: float) -> str: """Format remaining seconds as MM:SS (minutes are not wrapped at 60).""" total = int(math.ceil(max(0.0, seconds) - 1e-6)) @@ -195,7 +201,13 @@ def _int(key: str, default: int, low: int, high: int) -> int: self.background_color = _rgb(config.get("background_color", [0, 0, 0]), (0, 0, 0)) self.show_phase_label = bool(config.get("show_phase_label", True)) self.show_session_dots = bool(config.get("show_session_dots", True)) - self.show_progress_bar = bool(config.get("show_progress_bar", True)) + self.digit_style = str(config.get("digit_style", "seven_segment")) + if self.digit_style not in ("seven_segment", "pixel"): + self.digit_style = "seven_segment" + self.show_ghost_segments = bool(config.get("show_ghost_segments", True)) + self.progress_style = str(config.get("progress_style", "perimeter")) + if self.progress_style not in ("perimeter", "bar", "segments", "none"): + self.progress_style = "perimeter" self.font_path = str(config.get("font_path", "") or "") try: self.font_size = max(0, min(64, int(config.get("font_size", 0)))) @@ -858,68 +870,88 @@ def _render(self, draw, width: int, height: int, snap: Dict[str, Any]) -> None: draw.rectangle([0, 0, width - 1, height - 1], fill=background) label = snap["label"] - bar_h = self._bar_height(height) if self.show_progress_bar else 0 + remaining = max(0.0, 1.0 - snap["elapsed_fraction"]) + + # The perimeter ring lives on the outermost pixels, so the content + # box steps in to clear it. Every other style sits below the content. + ring = self.progress_style == "perimeter" + inset = 2 if ring else 0 + strip_h = 0 if ring else self._strip_height(height) dot_h = self._dot_size(height) if self.show_session_dots else 0 + box = (inset, inset, width - 2 * inset, height - 2 * inset - strip_h) if width >= 192 and height <= 48: - self._render_wide(draw, width, height, snap, label, text_color, accent, - bar_h, dot_h) + self._render_wide(draw, box, snap, label, text_color, accent, dot_h) else: - self._render_stacked(draw, width, height, snap, label, text_color, accent, - bar_h, dot_h) + self._render_stacked(draw, box, snap, label, text_color, accent, dot_h) - if bar_h: - self._draw_progress_bar(draw, 0, height - bar_h, width, bar_h, - snap["elapsed_fraction"], accent) + if ring: + self._draw_perimeter(draw, width, height, remaining, accent) + elif strip_h: + self._draw_strip(draw, 0, height - strip_h, width, strip_h, remaining, accent) - @staticmethod - def _bar_height(height: int) -> int: + def _strip_height(self, height: int) -> int: + if self.progress_style == "none": + return 0 return 3 if height >= 64 else 2 @staticmethod def _dot_size(height: int) -> int: return 4 if height >= 64 else 3 - def _render_stacked(self, draw, width, height, snap, label, text_color, accent, - bar_h, dot_h) -> None: - label_h = (10 if height >= 64 else 7) if (self.show_phase_label and label) else 0 - top = label_h + (1 if label_h else 0) - bottom = height - bar_h - (dot_h + 1 if dot_h else 0) + def _render_stacked(self, draw, box, snap, label, text_color, accent, dot_h) -> None: + bx, by, bw, bh = box + label_h = (10 if bh >= 54 else 7) if (self.show_phase_label and label) else 0 + label_font = self._fit_font(draw, label, bw - 2, label_h) if label_h else None + + # On a 32px panel a dedicated dots row costs a quarter of the height the + # digits could have. When the label leaves enough width, the dots tuck in + # beside it instead and the countdown gets those rows back. + dots_inline = False + if dot_h and label_font is not None and bh < 40: + label_w = self._measure(draw, label, label_font)[0] + if label_w + self._dots_span(snap, dot_h) + 6 <= bw: + dots_inline = True + + top = by + label_h + (1 if label_h else 0) + bottom = by + bh - (0 if (dots_inline or not dot_h) else dot_h + 1) box_h = max(6, bottom - top) if label_h: - label_font = self._fit_font(draw, label, width - 2, label_h) - self._draw_in_box(draw, label, label_font, (1, 0, width - 2, label_h), accent) + self._draw_in_box(draw, label, label_font, (bx + 1, by, bw - 2, label_h), accent) - time_text = snap["remaining"] - time_font = self._fit_font(draw, time_text, width - 4, box_h) - self._draw_in_box(draw, time_text, time_font, (2, top, width - 4, box_h), text_color) + self._draw_time(draw, snap["remaining"], (bx + 2, top, bw - 4, box_h), text_color) - if dot_h: - self._draw_session_dots(draw, 0, bottom + 1, width, dot_h, snap, accent, + if dot_h and dots_inline: + self._draw_session_dots(draw, bx, by + max(0, (label_h - dot_h) // 2), + bw - 1, dot_h, snap, accent, align="right") + elif dot_h: + self._draw_session_dots(draw, bx, bottom + 1, bw, dot_h, snap, accent, align="center") - def _render_wide(self, draw, width, height, snap, label, text_color, accent, - bar_h, dot_h) -> None: + @staticmethod + def _dots_span(snap: Dict[str, Any], size: int) -> int: + total = max(1, int(snap["sessions_before_long_break"])) + return total * (size + 1) - 1 + + def _render_wide(self, draw, box, snap, label, text_color, accent, dot_h) -> None: """Side-by-side layout for very wide panels (e.g. 256x32).""" - left_w = max(40, int(width * 0.30)) - usable_h = height - bar_h - label_h = (min(12, usable_h // 2) if (self.show_phase_label and label) else 0) + bx, by, bw, bh = box + left_w = max(40, int(bw * 0.30)) + label_h = (min(12, bh // 2) if (self.show_phase_label and label) else 0) if label_h: label_font = self._fit_font(draw, label, left_w - 4, label_h) - self._draw_in_box(draw, label, label_font, (2, 1, left_w - 4, label_h), + self._draw_in_box(draw, label, label_font, (bx + 2, by + 1, left_w - 4, label_h), accent, align="left") if dot_h: - dots_y = (label_h + 3) if label_h else max(1, (usable_h - dot_h) // 2) - dots_y = min(dots_y, max(0, usable_h - dot_h - 1)) - self._draw_session_dots(draw, 2, dots_y, left_w - 4, dot_h, snap, accent, + dots_y = by + ((label_h + 3) if label_h else max(1, (bh - dot_h) // 2)) + dots_y = min(dots_y, by + max(0, bh - dot_h - 1)) + self._draw_session_dots(draw, bx + 2, dots_y, left_w - 4, dot_h, snap, accent, align="left") - time_text = snap["remaining"] - time_box = (left_w + 2, 0, width - left_w - 4, max(6, usable_h - 1)) - time_font = self._fit_font(draw, time_text, time_box[2], time_box[3]) - self._draw_in_box(draw, time_text, time_font, time_box, text_color) + self._draw_time(draw, snap["remaining"], + (bx + left_w + 2, by, bw - left_w - 4, max(6, bh - 1)), text_color) def _draw_session_dots(self, draw, x: int, y: int, avail_w: int, size: int, snap: Dict[str, Any], accent: Tuple[int, int, int], @@ -932,19 +964,188 @@ def _draw_session_dots(self, draw, x: int, y: int, avail_w: int, size: int, span = total * (size + gap) - gap if span > avail_w or size < 1: return - start = x if align == "left" else x + max(0, (avail_w - span) // 2) + if align == "left": + start = x + elif align == "right": + start = x + max(0, avail_w - span) + else: + start = x + max(0, (avail_w - span) // 2) dim = _dim(accent, 0.28) for index in range(total): left = start + index * (size + gap) draw.rectangle([left, y, left + size - 1, y + size - 1], fill=accent if index < filled else dim) - def _draw_progress_bar(self, draw, x: int, y: int, width: int, height: int, - fraction: float, accent: Tuple[int, int, int]) -> None: - draw.rectangle([x, y, x + width - 1, y + height - 1], fill=_dim(accent, 0.22)) - filled = int(round(max(0.0, min(1.0, fraction)) * width)) - if filled > 0: - draw.rectangle([x, y, x + filled - 1, y + height - 1], fill=accent) + # ── Countdown digits ──────────────────────────────────────────────────────── + + def _draw_time(self, draw, text: str, box: Tuple[int, int, int, int], + color: Tuple[int, int, int]) -> None: + if self.digit_style == "seven_segment": + if self._draw_seven_segment(draw, text, box, color): + return + # Too little room for legible segments — fall back to the pixel font. + bx, by, bw, bh = box + font = self._fit_font(draw, text, bw, bh) + self._draw_in_box(draw, text, font, box, color) + + def _seven_segment_metrics(self, text: str, bw: int, bh: int): + """Largest segment geometry for `text` that fits (bw, bh), or None. + + Returns (digit_h, digit_w, stroke, gap, colon_w, total_w). + """ + digits = sum(c.isdigit() for c in text) + colons = sum(c == ":" for c in text) + if not digits: + return None + for h in range(bh, 4, -1): + stroke = max(1, round(h * 0.15)) + gap = max(1, round(h * 0.10)) + colon_w = stroke + spare = bw - colons * colon_w - (digits + colons - 1) * gap + if spare <= 0: + continue + width_cap = spare // digits + # A digit narrower than two strokes plus a gap has no interior left, + # so the vertical segments would fuse into a solid block. + digit_w = min(round(h * 0.72), width_cap) + if digit_w < 2 * stroke + 1 or h < 3 * stroke + 2: + continue + # Keep the classic clock-radio proportion. Without this a tall, + # narrow box drives the digits to a sliver and a 0 stops reading as + # a digit at all — it becomes two stacked boxes. Falling through to + # a shorter h trades height for a shape that still reads. + if digit_w < round(h * 0.64): + continue + total = digits * digit_w + colons * colon_w + (digits + colons - 1) * gap + return h, digit_w, stroke, gap, colon_w, total + return None + + def _draw_seven_segment(self, draw, text: str, box: Tuple[int, int, int, int], + color: Tuple[int, int, int]) -> bool: + """Draw `text` as seven-segment digits. False if it cannot be done legibly.""" + bx, by, bw, bh = box + metrics = self._seven_segment_metrics(text, bw, bh) + if metrics is None: + return False + h, digit_w, stroke, gap, colon_w, total = metrics + + # Unlit segments stay faintly visible, the way the dark segments of a + # real LED clock are: it anchors the digits so they don't appear to + # jump around as the numbers change. At a one-pixel stroke there is no + # room for the effect to read as anything but noise, so it drops out. + ghost = (_dim(color, 0.17) + if self.show_ghost_segments and stroke >= 2 else None) + x = bx + max(0, (bw - total) // 2) + y = by + max(0, (bh - h) // 2) + + for index, char in enumerate(text): + if index: + x += gap + if char == ":": + dot = max(1, colon_w) + for cy in (y + h // 3 - dot // 2, y + 2 * h // 3 - dot // 2): + draw.rectangle([x, cy, x + dot - 1, cy + dot - 1], fill=color) + x += colon_w + continue + if char.isdigit(): + self._draw_digit(draw, char, x, y, digit_w, h, stroke, color, ghost) + x += digit_w + return True + + # Lit segments per digit, labelled clockwise from the top bar plus the middle. + _SEGMENTS = { + "0": "abcdef", "1": "bc", "2": "abdeg", "3": "abcdg", "4": "bcfg", + "5": "acdfg", "6": "acdefg", "7": "abc", "8": "abcdefg", "9": "abcdfg", + } + + @staticmethod + def _segment_boxes(x: int, y: int, w: int, h: int, t: int) -> Dict[str, Tuple]: + mid = y + (h - t) // 2 + # Verticals stop at the middle bar so corners meet cleanly. + upper_h = mid - y + t + lower_h = (y + h) - mid + return { + "a": (x, y, w, t), + "b": (x + w - t, y, t, upper_h), + "c": (x + w - t, mid, t, lower_h), + "d": (x, y + h - t, w, t), + "e": (x, mid, t, lower_h), + "f": (x, y, t, upper_h), + "g": (x, mid, w, t), + } + + def _draw_digit(self, draw, char: str, x: int, y: int, w: int, h: int, t: int, + color: Tuple[int, int, int], + ghost: Optional[Tuple[int, int, int]]) -> None: + lit = self._SEGMENTS.get(char, "") + boxes = self._segment_boxes(x, y, w, h, t) + for name, (sx, sy, sw, sh) in boxes.items(): + on = name in lit + fill = color if on else ghost + if fill is None: + continue + draw.rectangle([sx, sy, sx + sw - 1, sy + sh - 1], fill=fill) + + # ── Burndown indicator ────────────────────────────────────────────────────── + + def _draw_perimeter(self, draw, width: int, height: int, remaining: float, + accent: Tuple[int, int, int]) -> None: + """Trace the remaining time around the edge of the panel. + + The whole border is lit at the start of a phase and drains clockwise + from the top-left, so the ring itself is the countdown. It costs no + interior space, which is what lets the digits be as large as they are. + """ + path = [] + path += [(x, 0) for x in range(width)] + path += [(width - 1, y) for y in range(1, height)] + path += [(x, height - 1) for x in range(width - 2, -1, -1)] + path += [(0, y) for y in range(height - 2, 0, -1)] + if not path: + return + + track = _dim(accent, 0.14) + for point in path: + draw.point(point, fill=track) + + lit = int(round(max(0.0, min(1.0, remaining)) * len(path))) + for point in path[:lit]: + draw.point(point, fill=accent) + if 0 < lit <= len(path): + # A brighter head makes the direction of travel readable, and gives + # the last minute of a phase something to watch. + draw.point(path[lit - 1], fill=_lighten(accent, 0.55)) + + def _draw_strip(self, draw, x: int, y: int, width: int, height: int, + remaining: float, accent: Tuple[int, int, int]) -> None: + if self.progress_style == "segments": + self._draw_segments(draw, x, y, width, height, remaining, accent) + return + draw.rectangle([x, y, x + width - 1, y + height - 1], fill=_dim(accent, 0.18)) + lit = int(round(max(0.0, min(1.0, remaining)) * width)) + if lit <= 0: + return + draw.rectangle([x, y, x + lit - 1, y + height - 1], fill=accent) + draw.rectangle([x + lit - 1, y, x + lit - 1, y + height - 1], + fill=_lighten(accent, 0.55)) + + def _draw_segments(self, draw, x: int, y: int, width: int, height: int, + remaining: float, accent: Tuple[int, int, int]) -> None: + """A row of discrete blocks that go out one by one, like a fuel gauge.""" + count = max(4, min(32, width // 8)) + gap = 1 + block = (width - (count - 1) * gap) // count + if block < 1: + self.progress_style = "bar" + return + span = count * block + (count - 1) * gap + start = x + max(0, (width - span) // 2) + lit = int(round(max(0.0, min(1.0, remaining)) * count)) + dark = _dim(accent, 0.18) + for index in range(count): + left = start + index * (block + gap) + draw.rectangle([left, y, left + block - 1, y + height - 1], + fill=accent if index < lit else dark) # ── Display pinning ───────────────────────────────────────────────────────── diff --git a/plugins/pomodoro-timer/manifest.json b/plugins/pomodoro-timer/manifest.json index 465a3354..a5d3094c 100644 --- a/plugins/pomodoro-timer/manifest.json +++ b/plugins/pomodoro-timer/manifest.json @@ -1,7 +1,7 @@ { "id": "pomodoro-timer", "name": "Pomodoro Timer", - "version": "1.0.1", + "version": "1.1.0", "author": "ChuckBuilds", "description": "A configurable Pomodoro focus/break timer for your matrix. Set the work and break lengths, then start, pause, skip, or reset it over MQTT — with Home Assistant auto-discovery so the whole timer shows up as a device with no YAML.", "entry_point": "manager.py", @@ -22,6 +22,12 @@ ">=2.0.0" ], "versions": [ + { + "released": "2026-07-28", + "version": "1.1.0", + "notes": "Redrawn display. The countdown is now rendered as geometric seven-segment digits sized to the panel rather than set in a pixel font, with faintly-lit unlit segments the way a real LED clock shows them; digit_style: pixel keeps the old look. New burndown indicator styles replace the fill-up progress bar: perimeter (default) drains a ring around the edge of the panel with a brighter leading pixel and costs no interior space, bar and segments sit along the bottom edge, none hides it. On panels under 40px tall the session dots tuck in beside the phase label instead of taking their own row, giving the digits that height. Config: show_progress_bar is replaced by progress_style; adds digit_style and show_ghost_segments.", + "ledmatrix_min_version": "2.0.0" + }, { "released": "2026-07-28", "version": "1.0.1", diff --git a/plugins/pomodoro-timer/test/golden/128x32/pomodoro.png b/plugins/pomodoro-timer/test/golden/128x32/pomodoro.png index d654da8cf2b5a255d39cb6a38945892320fe8af4..3136b8251ebb19bea444c3d89f86b6b5f0766e15 100644 GIT binary patch delta 577 zcmV-H0>1sY2HFIWB!3}EL_t(|ob8$+kHRn%$KM?{+zgfmHGv=`1Tl@AK@7`h=!Zdq z#j*rTg2GS<27@9DiJ%FTU{3VX+;MCxH?{}4{!~N%^fmAC{%aTBD5YRPWICNCO2iQT7Ru}I2;Cp!TEenv(#h3x1dBB=Uits+qQ)em&+w8R(>CAw<95k6C9nn>8AZR;$H1kJ6+;xr*la z3v&-Zgi`vBy@&r!h89#RmE@q??Zzv6y&h=v;&3>WgMa;g|9e)yl{Ld%=`+G~05YZk z$mDSfifZ8~6Nu6)Ps=AXu{FErDKWY(AtK*kgRnG7u`@%SG_;?qxh z&Xx11TSC;cS_%H9&j`~2$e02kgLx0Q>izgP4Ul9_%JCA<^-^%JJ|oOA6d7Yd$e02k z^THPN(mKwgFdcwQ9vz^0vC`M443|>>SWE{XV+w$c`BwxYl+rg-09-x+(DZE=)~8n_ P00000NkvXXu0mjf$*~Px delta 801 zcmV++1K#}F1h@u}B!B-&L_t(|ob8!EPwOxcz^^4?DN$50fYgYE4j`c-#9)byfuT!> zcJ9CjARmUY9T++{AApQ3c!VVj6KX0JR8*^N`cHa;MW5BAXuf*@!%n*b0w&Qe>l z=0THX7yw>hUuTo+`+m7xW~bZteE>iR(N9z*QihIGsZe37+bAYOUfjqrC3c) z5Co-Ci4d|>5@U=IvMeh`=JN7VoNHo`nE{1DA)n9h?Cd;0KeyZMNST+H7t69}Tyxqw zJ3H%kyY+f~IDZ`S3?&x%ThLOmB;gMLhG7IjFdmN=+1lORJvlka<#KOtZ>u@FNn|d- z9FF4@i^Y6Cuj@L-c(y4&K0eMimPi?zNkI_QYPFbgo}QjAE-tRGuL&WGWE2X;&yo@{ z%I)o~VHk_P`o2#qbc$`;O9z}&;iOWjSqj9v&W+s!%4A$)e(ijZJ!)igAyoCTr#V{Jc;o&~{{=t)aq$$SQ~s z(&=>Ao?frFs>&u{tqcZ(4XLb$m@^;@LI@$m_C#jDB4Pq9VFs{P9LJd}G*3@YZ*Fd4 z980Le&wrMl0h*>|vsu$LWjnHQL~cRh%7HQ$&W%PRW)OO892^`}tJTOr6T1aVN5V}w ze$!uiZ2U6BEWo0Bz=mq)-^3QZ1tnyqGIaalU4Xm0I{-+f{#H}2>w2CSqms>LXKg~r zWHRCO;h%wZqk(DDLm?;d^hS|9=v~kug^ttf9h>_$_E}Z*PBpKiof^ zPQSjs;QJ6ZO*5TN8-~Fq1OQ|*8Th^l^B>5aPG>Y4nWm{}8ta&@>z|*W-ELQi3J)Tm zBI22=DOY;G<3v5iDHIBYLZJw;spn|n9336e#{2twlJ?JgxE`tsl8jma$)pYs4`uuC fUmBw_Kr(*-DHk#cc1E?b00000NkvXXu0mjf%4vCC diff --git a/plugins/pomodoro-timer/test/golden/128x64/pomodoro.png b/plugins/pomodoro-timer/test/golden/128x64/pomodoro.png index 121d38b63521f329839036ad3cffea46ab45572e..8969cc7c0daad1755a0adc7be508371fa5d31b10 100644 GIT binary patch delta 811 zcmcb`dXQ~`ay>JLr;B4q#hkZu_GkSH5INp&-{tLjM65-RPmonymi75F#y4*!iJv(9 zh1KZF)~4A4Oy~AB>^2Et{qLr^Wm*}>et}6z>n^_De(CefygVEA=g(fBwB30z#pkjL zTgPLI7XttEmSx6$t+JJ@Q?_0A+RonockW>W5!cT5pVscW`|kAVdTznc)uFN1ZO?Dk z(Y~maU?O#Mn`{nO6sMa;v$khl=ZS4^uU`*W(wgdZHtn(j&*@EnCjQ@XdV5tx#fIGI z(5r5X1$TID`BZ-O_1CDi%T{Im&{2<)m3o*tQKR8`W%tphj%~Ns{?JppRBAi-@WBLw znLZ`4{PWkBB);kY(BdJYAiJ$TH+$B$yLslbeK+5pM>t=ELT#pN5)Y`BMWwG0D zzwL^socI0TzklDpsr_E<{$Ke_iqYbWCkp4RU$1}Dy+=8a;dr6ilq;UeB{<>ttGU{q@Zpv6nGAVmo8}0;km2`MX@detq}dysc5K_K)kQ z8(z%{z2g;n_4e)CKucGKG&yNp%-S2r-+uV~`SV3P=bTOZ{r6wdPLa+JEpw{*DypiC zc$96-X^c$B%{?^~<%lSk3iYa(unGt3_yL`5%pMzg5mL zSm-cFh%xYhbxz(RAwF|!{gVq^jz`k>$XT;p(GaRFVgniXXIJ{6rX#`8Pnte(KjeYh zv4J5Wg27;V6l-k8Sz&c8hh10yJ9}>5rdN0A@p&PacdH+VZK@TDZ+ZY$S8o8e=}hK4 z_BZR7~)Tr@x(S!3M+bu;hw>)H2qVv hS-rjFKEcDEcwargvO}KLP@Mq?JYD@<);T3K0RXsWeWd^Z delta 707 zcmV;!0zCb}2HFLXBYy&+Nkl_+Hh=X>!ee;g% zy8V7XC>w?$%kq6y(8@H;$z(!Anx^Tx?)!eX+l_4%avaC9tbgz&V=Kl8d60N?8eE=b zS<~s1h)ShWqtOU%Wo%VYt0+pnUMC{PRzX#*R;yO4H+MN%7!Rv>+^e5FIt}=~zg#Yf zXt&$3R}qn}>&ZGC4u_uS>AG$hMtHxHg%KU$8*xlPGL~hjs=C|lw%cu^(fGg_>-Bmt z+JxYkfL5y&mVaH>HBFPHmGAq9!(qSQ^ZwJL-w{fZi$&LUKOY0f1Z*~&`Fwu!=5o2rWHPcWXR}!%N~hDO(`mh4 zUmp`JV>C@;Z>11RN% z*E0;``Z3EgJH@tYHk(1=dKt=cuGwt1SS*MrpU<~it!}rQEJ6SP000000002^(>>l3 zzTdNs(CKtuX&%uJKM@f=6~^oR{9r#B@PGse_H7EfA5anFDB|F!`gzKWJ%`O%rcIV> zf3Y9J2!9X`2@ve*U&gUK_xY`Yl1)KErdGy-F#-fb0tAcsSqjT!azC>+35lkH_PuUVbLRS71X%FeE@Q pBtS5!P$-Cw;lG9vAQ%!L*e@gz^Sedk7o7kA002ovPDHLkV1hatOn?9Y diff --git a/plugins/pomodoro-timer/test/golden/256x32/pomodoro.png b/plugins/pomodoro-timer/test/golden/256x32/pomodoro.png index dc09b4403768e465b47c7f7a8e239362c5bb2460..0f05b156d03d0cd58a7bcf6a857ac82fa69f64fb 100644 GIT binary patch delta 472 zcmV;}0Vn>N2=N1uBYy$7Nkl78Bba@kU&L(4-W)67ac`y63lM9XrqU?@WhkYZaU4sh@%!4gHSN#m6aT@!?{y<- zq8^69w3lV^L}t(bd5P`zP}X1R1b~t#0EpJuFWl+uXr|NG^?$`pjQ!SbyxVzXd@vs% zRpVZ=2bof;?|XNk>+9yFi7{6^@n&$Fi!t%>`BW~LXK+;=?7`l0#JeG}3LiOMvjocc z!H(|nz@08A06mnyeFLUc@C`dlAbY6Qu&yxq)R4?`)%I(xgl`WJFXJPdb-cfG9sHn$ zKAvKy09<+q#(&>J{PFG`J=p)I22%3@j;sp6GmLiMa))VBd{Y0O?*g(#%>Xzl>G(rX z((?fKX|l~eG!cAwY6oA~Ls|SK7@|=C5RC$WXsbOy*LAuv&vQ)st|Wg--a&@=6V9)c zO@h5SNjk`2g3tF*7MoxxDja+@UPf^DV0MXb3NWr>)7c&4vqW~Zp zOQ5^uo#e^xA>R3JpZHHp%m5IL0)S`~0AzdOx(&F5Qi>S>qP>{`AlW}x(+8O9{0W}` O0000&twXuDd&vA@1zX z&TcXY2qmA-mrA9tN|~m4a&j_Ue{gUR$Cx0s-^u>|z9@6SY+Sj9eoC z{35dWXLx3EPd198a2z+>gr%z0YO~oy|0YY@wkwqiRf=F3hN`OMPev(53jrXU4F18x zOI6iYtA!9+TU#p@i+Ctgn{X@7^Rg@>gczl8)7I8jp-}LQ@@QuGHbs6fzOhQP*?fL} z4v~^1DVxoDDt}c~H4Gz;T0*DO(RH1r=V)=qfxgxXf*=Tjr;<7iC8YLC4E(oj+wOL| z2%%oD=jj*Hgb)gflBo($Pfy$JcBxcS6orh}Xl8_4I1+SS$7@{>o`q;sYNO#BRoCnF zdc7V;t*^DREDw*=aZpReJ6%OURaHrndcEG$(^IinjDN3?iY&``wE=*b3^+i!Tqb4H zG^^DriwfJeUteDx$4PGLC149PnT%l=nx=hxd?fUs>+9>`O2$A1_(cpjfTn5fb{m)9 z-rkZMUXjr>O_C%@k~ofAUS2j$Goc67YBk25@t32$y*Cx?WXJ%$Nj+>pGMF>q#Pxt%%$Hzx^ADqU?=kx4RJkJY) zP$(2~xg1FZQKc-)(slj){e5F&V{&q`R;xWcJg`^@0KU-o0LRD2ilVr;xh&BURcdEv z2N&FFP?l%i-Q9IM9fZ*0;$pd6u2d?~ECc{V9Dm^O@GzuPP(INkWf+EgJz|uK@T}of zMx2Z%JJi!kLQt&YyS1P~va54*bGy5{xbo`iD!B+SJ&Yg=1hB)twQxhi->IpoTrP(z zZ*OnI9RQ93S%9k$K&pWifWpGU!ut9;F7rGe-udF<;^pN9A!OTj;+6rU033kA{QUg( z_J1~G=gZ5>ByKDM$?@p`tE;QH&}cMR%HL}w5QT)ULi}gI9so!+5CZuVep(sxswpr& zd^&(Ts$nSu(eU*v@_m;`U4eBfqbQqG5+Gs!s1f&{p03g+5e%gRBC43Li z@Aq$RZWvX$?<|mWTu~G^H#c#a{X;YN7k*)_Rx3Q^not&qs114vXJ==Qa$9|O{$K&tr*uW;zzo^&!u00000NkvXXu0mjfJ~Qk9 diff --git a/plugins/pomodoro-timer/test/golden/64x32/pomodoro.png b/plugins/pomodoro-timer/test/golden/64x32/pomodoro.png index 78fbee6bf2330c8e8c8b6959f4aa958be1dfb65a..fedb3656ce41c6ed1887de6ca9da3adc42ccc527 100644 GIT binary patch delta 540 zcmV+%0^|MB1hfQ@BYy$=Nkl|^rn)USd-k#IjtiTupqp?^lKApnzV2qV! zvsu#I^?JQrE|z6&w_DCRA;fW<=oiKq=NzY8*Y!LP03JCQM}G)0olXHj8YgwHq#8=Hc1knVLBYy%INkl~=d0E=|)g4D;cp zX$k<8QaL4;Xf@K#Y&N5ma?TAJgRdk>v_XCt60kuCF`Z7cEUQ#<&MBpY5QAns9zPm@ zX$OYGq3`>xR%^4_oK7dLX1m=ILS$VZ^-A_fnoK5HmX&E58XEo`4vr|Llu}g@MUfmZ#>%Xq<{|P1<-J=&;}mpx1tG-qJY1>O zls!SQHaA%;T!n?pi)}Q3_WxxJTn$Yz0e{5MFzOWN-0;0sxjc#@S#SKj zwbA@Yz-%3(RHKn?Cigm@K)q8@gYN{ev9%Q{EfX1~UF0 200) + return lit / len(path) + + +def test_burndown_ring_drains_with_the_phase(): + for elapsed in (0.0, 0.25, 0.5, 0.75): + p = make_plugin(128, 32, progress_style="perimeter") + p._apply_command("START", {}) + with p.state_lock: + p.deadline -= p.phase_total * elapsed + p.display() + got = _lit_ring_fraction(p) + assert abs(got - (1 - elapsed)) < 0.02, \ + f"{elapsed:.0%} elapsed should leave {1 - elapsed:.0%} of the ring lit, got {got:.0%}" + + +def test_seven_segment_digits_keep_a_readable_shape(): + """A digit that gets too narrow for its height stops reading as a digit.""" + for width, height in ((64, 32), (128, 32), (128, 64), (256, 32)): + p = make_plugin(width, height) + for text in ("25:00", "05:00", "180:00"): + metrics = p._seven_segment_metrics(text, width - 6, height - 10) + if metrics is None: + continue + h, digit_w, stroke, gap, colon_w, total = metrics + assert digit_w >= round(h * 0.64), f"{width}x{height} {text}: digit too narrow" + assert digit_w >= 2 * stroke + 1, f"{width}x{height} {text}: segments would fuse" + assert total <= width - 6, f"{width}x{height} {text}: digits overrun the box" + + +def test_seven_segment_gives_up_when_there_is_no_room(): + p = make_plugin() + assert p._seven_segment_metrics("25:00", 10, 4) is None + # ...and display() still renders, having fallen back to the pixel font. + tiny = make_plugin(64, 16) + tiny._apply_command("START", {}) + assert tiny.display() is True + assert tiny.display_manager.image.getbbox() is not None + + +def test_every_indicator_and_digit_style_renders(): + for style in ("perimeter", "bar", "segments", "none"): + for digits in ("seven_segment", "pixel"): + p = make_plugin(128, 32, progress_style=style, digit_style=digits) + p._apply_command("START", {}) + assert p.display() is True, f"{style}/{digits} failed to render" + assert p.display_manager.image.getbbox() is not None + + +def test_unknown_styles_fall_back_to_the_defaults(): + p = make_plugin(progress_style="disco", digit_style="gothic") + assert p.progress_style == "perimeter" + assert p.digit_style == "seven_segment" + + def test_discovery_payloads_are_valid_json_and_unique(): p = make_plugin() entities = p._discovery_entities() From 9237514d02f9853c279ebe23062e5d67a20c6ea3 Mon Sep 17 00:00:00 2001 From: Charles Mynard Date: Tue, 28 Jul 2026 22:52:41 +0000 Subject: [PATCH 4/7] pomodoro-timer: stop the render path rewriting config; clear orphaned topics Review fixes. - _draw_segments assigned self.progress_style = "bar" when a panel was too narrow for discrete blocks, permanently overwriting the user's setting from the render thread on a transient width computation. The bar is now its own method that both the strip dispatcher and the segments fallback call, so the fallback lasts one frame. - Changing the HA discovery prefix republishes the configs under the new prefix and never touches the old ones, leaving permanently-unavailable entities in Home Assistant. Those retained configs are now cleared before the settings are reloaded, while the old prefix is still known. - Renaming the command topic is a different case: the discovery path is keyed on the prefix and plugin id, so republishing overwrites it in place and HA follows. What it does strand is the retained payloads on the old state topics, so those are cleared instead. - Dropped two unused names from a test's tuple unpack (RUF059). - Added docstrings across the state machine, render path, and MQTT plumbing, taking manager.py from 38% to 76% coverage. 34 tests (three new: the render path leaving progress_style alone, and each of the two topic-orphaning cases). Harness green on all sampled sizes with the goldens unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AtZDGDrzbWdK4JxUeH4hk6 --- plugins.json | 2 +- plugins/pomodoro-timer/manager.py | 91 +++++++++++++++++-- plugins/pomodoro-timer/manifest.json | 8 +- plugins/pomodoro-timer/test_pomodoro_timer.py | 33 ++++++- 4 files changed, 124 insertions(+), 10 deletions(-) diff --git a/plugins.json b/plugins.json index aa08705a..1347a82c 100644 --- a/plugins.json +++ b/plugins.json @@ -1143,7 +1143,7 @@ "last_updated": "2026-07-28", "verified": true, "screenshot": "", - "latest_version": "1.1.0", + "latest_version": "1.1.1", "icon": "fa-hourglass-half" } ] diff --git a/plugins/pomodoro-timer/manager.py b/plugins/pomodoro-timer/manager.py index 738aa806..00411a5f 100644 --- a/plugins/pomodoro-timer/manager.py +++ b/plugins/pomodoro-timer/manager.py @@ -244,6 +244,7 @@ def _int(key: str, default: int, low: int, high: int) -> int: self._derive_topics() def _derive_topics(self) -> None: + """Rebuild every published topic from the command topic's base.""" base = self.command_topic if base.endswith("/set"): base = base[:-4] @@ -260,6 +261,7 @@ def _derive_topics(self) -> None: self.number_set_topics = {f"{base}/{f}/set": f for f, *_ in _NUMBER_FIELDS} def validate_config(self) -> bool: + """Check the plugin-specific config on top of the base validation.""" if not super().validate_config(): return False if self.color_mode not in ("phase", "fixed"): @@ -277,6 +279,7 @@ def validate_config(self) -> bool: # ── Timer state ───────────────────────────────────────────────────────────── def _phase_seconds(self, phase: str) -> float: + """Configured length of `phase`, in seconds.""" return { PHASE_WORK: self.work_minutes * 60.0, PHASE_SHORT_BREAK: self.short_break_minutes * 60.0, @@ -284,12 +287,14 @@ def _phase_seconds(self, phase: str) -> float: }.get(phase, self.work_minutes * 60.0) def _remaining_locked(self) -> float: + """Seconds left in the current phase. Caller must hold state_lock.""" if self.running and self.deadline is not None: return max(0.0, self.deadline - time.monotonic()) return max(0.0, self.remaining) def _begin_phase_locked(self, phase: str, seconds: Optional[float] = None, run: bool = True, label: Optional[str] = None) -> None: + """Start `phase`, optionally paused. Caller must hold state_lock.""" total = float(seconds) if seconds else self._phase_seconds(phase) self.phase = phase self.phase_total = max(1.0, total) @@ -299,6 +304,7 @@ def _begin_phase_locked(self, phase: str, seconds: Optional[float] = None, self.custom_label = label or None def _go_idle_locked(self, reset_counters: bool = False) -> None: + """Stop the timer. Caller must hold state_lock.""" self.phase = PHASE_IDLE self.running = False self.deadline = None @@ -504,6 +510,7 @@ def _apply_command(self, command: str, payload: Dict[str, Any]) -> None: _fmt_clock(self._remaining_locked())) def _resume_locked(self) -> None: + """Continue a paused phase from where it stopped.""" self.remaining = max(1.0, self.remaining) self.running = True self.deadline = time.monotonic() + self.remaining @@ -577,9 +584,16 @@ def _parse_command(self, raw: bytes) -> Tuple[Optional[str], Dict[str, Any]]: def update(self) -> None: # No network data to fetch; just keep the state machine honest even if # the timer thread is not running (e.g. under the safety harness). + """Keep the state machine honest even when the timer thread is not + running (the safety harness constructs the plugin without enabling it). + There is no network data to fetch. + """ self._tick() def display(self, force_clear: bool = False) -> bool: + """Render the current frame. Ticks first so a phase that expired between + renders rolls over before it is drawn. + """ try: self._tick() snapshot = self._snapshot() @@ -634,19 +648,37 @@ def cleanup(self) -> None: self.logger.info("Pomodoro timer cleaned up") def on_config_change(self, new_config: Dict[str, Any]) -> None: + """Reload every setting, clearing anything retained under topics we are + about to abandon, and reconnect only if the broker wiring changed. + """ super().on_config_change(new_config) previous = (self.mqtt_enabled, self.mqtt_host, self.mqtt_port, self.mqtt_username, self.mqtt_password, self.command_topic, self.state_topic, self.ha_discovery, self.discovery_prefix) was_idle = self.phase == PHASE_IDLE - # Turning discovery off has to clear the retained config topics while we - # still know where they were published, or the entities linger in Home - # Assistant forever. A plain restart deliberately leaves them retained — - # that's what lets HA keep the device across reboots, with the LWT - # marking it unavailable in the meantime. - if self.ha_discovery and not bool(new_config.get("ha_discovery", True)): + # Anything retained under the OLD topics has to be cleared before + # _load_settings overwrites them, while we still know where it went. + # A plain restart deliberately leaves discovery retained — that is what + # lets HA keep the device across reboots, with the LWT marking it + # unavailable in the meantime — but these two cases orphan it: + # + # discovery off the config topics would linger and HA would keep + # entities for a device that no longer announces itself + # prefix change the configs are republished under the new prefix, so + # the ones at the old prefix are never touched again + # + # A command_topic change is different: the discovery path is keyed on the + # prefix and plugin id, so republishing overwrites it in place and HA + # follows. What it does strand is the retained payloads on the old state + # topics, so those get cleared instead. + new_prefix = str(new_config.get("discovery_prefix", "homeassistant")) + new_command = str(new_config.get("command_topic", "ledmatrix/pomodoro/set")) + discovery_off = not bool(new_config.get("ha_discovery", True)) + if self.ha_discovery and (discovery_off or new_prefix != self.discovery_prefix): self._remove_discovery() + if new_command != self.command_topic: + self._clear_retained_state() self._load_settings(new_config) self._font_cache = {} @@ -665,6 +697,7 @@ def on_config_change(self, new_config: Dict[str, Any]) -> None: self._publish_state(force=True) def get_info(self) -> Dict[str, Any]: + """State plus connection details for the web UI.""" info = super().get_info() info.update(self._snapshot()) info.update({ @@ -679,6 +712,11 @@ def get_info(self) -> Dict[str, Any]: # ── State snapshot ────────────────────────────────────────────────────────── def _snapshot(self) -> Dict[str, Any]: + """A consistent view of the timer for rendering and publishing. + + Taken under the lock so a frame can never mix a phase from before a + rollover with a countdown from after it. + """ with self.state_lock: remaining = self._remaining_locked() return { @@ -703,6 +741,7 @@ def _snapshot(self) -> Dict[str, Any]: } def _phase_label(self, phase: str, running: bool) -> str: + """The on-screen name for a phase, or the paused label when held.""" if phase == PHASE_IDLE: return self.idle_label if not running and self.paused_label: @@ -714,6 +753,7 @@ def _phase_label(self, phase: str, running: bool) -> str: }.get(phase, self.work_label) def _phase_color(self, phase: str) -> Tuple[int, int, int]: + """The configured colour for a phase.""" return { PHASE_WORK: self.work_color, PHASE_SHORT_BREAK: self.short_break_color, @@ -723,6 +763,7 @@ def _phase_color(self, phase: str) -> Tuple[int, int, int]: # ── Rendering ─────────────────────────────────────────────────────────────── def _panel_size(self) -> Tuple[int, int]: + """The panel's logical size, however the core exposes it.""" dm = self.display_manager width = getattr(dm, "width", None) or getattr(getattr(dm, "matrix", None), "width", 128) height = getattr(dm, "height", None) or getattr(getattr(dm, "matrix", None), "height", 32) @@ -792,6 +833,7 @@ def _font(self, size: int): return font def _fallback_font(self, max_height: int): + """A built-in font for when no TrueType face is available.""" dm = self.display_manager if max_height >= 8: return getattr(dm, "small_font", None) or getattr(dm, "regular_font", None) @@ -852,6 +894,7 @@ def _draw_in_box(self, draw, text: str, font, box: Tuple[int, int, int, int], draw.text((x - ox, y - oy), text, font=font, fill=color) def _render(self, draw, width: int, height: int, snap: Dict[str, Any]) -> None: + """Compose one frame: background, phase content, then the indicator.""" phase = snap["phase"] accent = self._phase_color(phase) if snap["status"] == "paused": @@ -900,6 +943,7 @@ def _dot_size(height: int) -> int: return 4 if height >= 64 else 3 def _render_stacked(self, draw, box, snap, label, text_color, accent, dot_h) -> None: + """Label above the countdown, for panels that are not extremely wide.""" bx, by, bw, bh = box label_h = (10 if bh >= 54 else 7) if (self.show_phase_label and label) else 0 label_font = self._fit_font(draw, label, bw - 2, label_h) if label_h else None @@ -1060,6 +1104,7 @@ def _draw_seven_segment(self, draw, text: str, box: Tuple[int, int, int, int], @staticmethod def _segment_boxes(x: int, y: int, w: int, h: int, t: int) -> Dict[str, Tuple]: + """Pixel rectangles for each of the seven segments of a digit cell.""" mid = y + (h - t) // 2 # Verticals stop at the middle bar so corners meet cleanly. upper_h = mid - y + t @@ -1077,6 +1122,7 @@ def _segment_boxes(x: int, y: int, w: int, h: int, t: int) -> Dict[str, Tuple]: def _draw_digit(self, draw, char: str, x: int, y: int, w: int, h: int, t: int, color: Tuple[int, int, int], ghost: Optional[Tuple[int, int, int]]) -> None: + """Draw one seven-segment digit, lit segments over the ghost layer.""" lit = self._SEGMENTS.get(char, "") boxes = self._segment_boxes(x, y, w, h, t) for name, (sx, sy, sw, sh) in boxes.items(): @@ -1121,6 +1167,10 @@ def _draw_strip(self, draw, x: int, y: int, width: int, height: int, if self.progress_style == "segments": self._draw_segments(draw, x, y, width, height, remaining, accent) return + self._draw_bar(draw, x, y, width, height, remaining, accent) + + def _draw_bar(self, draw, x: int, y: int, width: int, height: int, + remaining: float, accent: Tuple[int, int, int]) -> None: draw.rectangle([x, y, x + width - 1, y + height - 1], fill=_dim(accent, 0.18)) lit = int(round(max(0.0, min(1.0, remaining)) * width)) if lit <= 0: @@ -1136,7 +1186,10 @@ def _draw_segments(self, draw, x: int, y: int, width: int, height: int, gap = 1 block = (width - (count - 1) * gap) // count if block < 1: - self.progress_style = "bar" + # Too narrow for discrete blocks. Fall back for this frame only — + # rewriting self.progress_style here would silently overwrite the + # user's setting from the render thread. + self._draw_bar(draw, x, y, width, height, remaining, accent) return span = count * block + (count - 1) * gap start = x + max(0, (width - span) // 2) @@ -1181,6 +1234,9 @@ def _sync_pin(self) -> None: self._pinned = not want # let the next tick retry def _timer_loop(self) -> None: + """Drive the state machine independently of the render loop, so the + timer keeps counting while another plugin owns the panel. + """ while not self.timer_stop_event.wait(0.25): try: self._tick() @@ -1341,6 +1397,7 @@ def _discovery_entities(self) -> List[Tuple[str, Dict[str, Any]]]: return entities def _publish_discovery(self) -> None: + """Announce every entity to Home Assistant.""" if not self.ha_discovery or not self.mqtt_client: return for suffix, payload in self._discovery_entities(): @@ -1348,14 +1405,27 @@ def _publish_discovery(self) -> None: self.logger.info("Published HA MQTT discovery — device: %s", self.device_name) def _remove_discovery(self) -> None: + """Clear the retained discovery configs so HA drops the entities.""" if not self.ha_discovery or not self.mqtt_client: return for suffix, _ in self._discovery_entities(): self._publish(f"{self.discovery_prefix}/{suffix}", "") + def _clear_retained_state(self) -> None: + """Empty the retained payloads on the topics we are about to abandon.""" + if not self.mqtt_client: + return + topics = [self.state_topic, self.status_topic, self.phase_topic, + self.remaining_topic, self.remaining_secs_topic, + self.session_topic, self.attributes_topic, self.availability_topic] + topics += list(self.number_state_topics.values()) + for topic in topics: + self._publish(topic, "") + # ── MQTT callbacks ────────────────────────────────────────────────────────── def _on_mqtt_connect(self, client, userdata, flags, rc): # pylint: disable=unused-argument + """Subscribe and announce ourselves once the broker accepts us.""" if rc != 0: self.mqtt_connecting = False self.mqtt_connected = False @@ -1373,12 +1443,14 @@ def _on_mqtt_connect(self, client, userdata, flags, rc): # pylint: disable=unus self.logger.info("MQTT connected — subscribed to %s", self.command_topic) def _on_mqtt_disconnect(self, client, userdata, rc): # pylint: disable=unused-argument + """Mark the connection down so the supervisor loop redials.""" self.mqtt_connected = False self.mqtt_connecting = False if rc != 0: self.logger.warning("MQTT disconnected unexpectedly rc=%s", rc) def _on_mqtt_message(self, client, userdata, msg): # pylint: disable=unused-argument + """Route an incoming message to a duration setter or a command.""" try: field = self.number_set_topics.get(msg.topic) if field: @@ -1404,6 +1476,7 @@ def _on_mqtt_message(self, client, userdata, msg): # pylint: disable=unused-arg # ── MQTT connect / loop ───────────────────────────────────────────────────── def _connect_mqtt(self) -> bool: + """Build and dial a client. False if it could not be reached.""" try: try: self.mqtt_client = mqtt.Client( @@ -1461,6 +1534,9 @@ def _backoff(self) -> bool: return False def _mqtt_loop(self) -> None: + """Supervise the connection: dial, wait for CONNACK, redial on failure + with backoff, and tear the client down on the way out. + """ while not self.mqtt_stop_event.is_set(): try: if self.mqtt_connected: @@ -1504,6 +1580,7 @@ def _mqtt_loop(self) -> None: self.logger.info("MQTT loop stopped") def _graceful_shutdown(self) -> None: + """Stop both threads and release the MQTT client. Safe to call twice.""" self.timer_stop_event.set() if self.timer_thread and self.timer_thread.is_alive(): self.timer_thread.join(timeout=2.0) diff --git a/plugins/pomodoro-timer/manifest.json b/plugins/pomodoro-timer/manifest.json index a5d3094c..a85cbfe3 100644 --- a/plugins/pomodoro-timer/manifest.json +++ b/plugins/pomodoro-timer/manifest.json @@ -1,7 +1,7 @@ { "id": "pomodoro-timer", "name": "Pomodoro Timer", - "version": "1.1.0", + "version": "1.1.1", "author": "ChuckBuilds", "description": "A configurable Pomodoro focus/break timer for your matrix. Set the work and break lengths, then start, pause, skip, or reset it over MQTT — with Home Assistant auto-discovery so the whole timer shows up as a device with no YAML.", "entry_point": "manager.py", @@ -22,6 +22,12 @@ ">=2.0.0" ], "versions": [ + { + "released": "2026-07-28", + "version": "1.1.1", + "notes": "Review fixes. The segments indicator no longer rewrites the configured progress_style from the render thread when a panel is too narrow for discrete blocks; it falls back to a bar for that frame only. Changing the Home Assistant discovery prefix now clears the retained configs at the old prefix, which would otherwise leave permanently-unavailable entities in HA, and renaming the command topic clears the retained payloads on the state topics it abandons. Docstrings added across the state machine, render path, and MQTT plumbing.", + "ledmatrix_min_version": "2.0.0" + }, { "released": "2026-07-28", "version": "1.1.0", diff --git a/plugins/pomodoro-timer/test_pomodoro_timer.py b/plugins/pomodoro-timer/test_pomodoro_timer.py index c531c1fa..7eb15857 100644 --- a/plugins/pomodoro-timer/test_pomodoro_timer.py +++ b/plugins/pomodoro-timer/test_pomodoro_timer.py @@ -346,7 +346,7 @@ def test_seven_segment_digits_keep_a_readable_shape(): metrics = p._seven_segment_metrics(text, width - 6, height - 10) if metrics is None: continue - h, digit_w, stroke, gap, colon_w, total = metrics + h, digit_w, stroke, _gap, _colon_w, total = metrics assert digit_w >= round(h * 0.64), f"{width}x{height} {text}: digit too narrow" assert digit_w >= 2 * stroke + 1, f"{width}x{height} {text}: segments would fuse" assert total <= width - 6, f"{width}x{height} {text}: digits overrun the box" @@ -377,6 +377,37 @@ def test_unknown_styles_fall_back_to_the_defaults(): assert p.digit_style == "seven_segment" +def test_rendering_never_rewrites_the_configured_style(): + """A panel too narrow for blocks falls back per-frame, not permanently.""" + p = make_plugin(24, 16, progress_style="segments") + p._apply_command("START", {}) + p.display() + assert p.progress_style == "segments", "the render path overwrote the user's setting" + + +def test_changing_the_discovery_prefix_clears_the_old_configs(): + p = make_plugin(command_topic="ledmatrix/pomodoro/set") + client = _wire_client(p) + p.on_config_change({**DEFAULTS, "mqtt_enabled": False, "discovery_prefix": "ha2"}) + cleared = [t for t, payload in client.published + if t.startswith("homeassistant/") and payload == ""] + assert len(cleared) == 17, "old prefix's retained configs would orphan HA entities" + + +def test_renaming_the_command_topic_clears_the_old_state_topics(): + p = make_plugin(command_topic="ledmatrix/pomodoro/set") + client = _wire_client(p) + old_state, old_avail = p.state_topic, p.availability_topic + p.on_config_change({**DEFAULTS, "mqtt_enabled": False, + "command_topic": "desk/focus/set"}) + cleared = {t for t, payload in client.published if payload == ""} + assert old_state in cleared and old_avail in cleared + # The discovery configs are keyed on the prefix, so they are republished in + # place rather than orphaned — nothing under homeassistant/ gets cleared. + assert not any(t.startswith("homeassistant/") for t in cleared) + assert p.topic_base == "desk/focus" + + def test_discovery_payloads_are_valid_json_and_unique(): p = make_plugin() entities = p._discovery_entities() From c335b340555055b59bda94bbb3181d7aba2a2c46 Mon Sep 17 00:00:00 2001 From: Charles Mynard Date: Tue, 28 Jul 2026 22:59:35 +0000 Subject: [PATCH 5/7] pomodoro-timer: make the countdown readable at a glance The ghost segments were the problem. They are authentic -- real LED clocks show their dark segments -- but they put a faint 8 behind every digit, so 15:00 read as 85:00 from across a room. My own comparison sheet showed the no-ghost row was the clearest of the lot and I shipped them on anyway. They are now off by default, in the schema as well as the code, and still available for anyone who wants the clock-radio look. Also, to push the digits further: - Heavier segment stroke (0.15 -> 0.17 of digit height). More lit pixels is what legibility comes down to at a distance. - Digits may spend spare panel width (cap 0.72 -> 0.78 of height). Height is usually the binding constraint, so there was width going unused; longer horizontal segments read further away. - The phase label drops to 70% brightness. It was competing with the countdown at equal weight; it is still plainly the phase colour. - A 1 is centred in its cell instead of hanging off the right, so "11:11" no longer reads as four scattered bars. Cell widths are unchanged, so the digits never jitter as the time changes. With ghosts on it stays right-hung, matching real hardware. Verified across 11:11, 01:01, 10:00, 179:59, 09:08 and 00:08 on 64x32 and 128x32, and on every phase colour. Goldens regenerated. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AtZDGDrzbWdK4JxUeH4hk6 --- plugins.json | 2 +- plugins/pomodoro-timer/README.md | 13 ++-- plugins/pomodoro-timer/config_schema.json | 4 +- plugins/pomodoro-timer/manager.py | 56 +++++++++++++----- plugins/pomodoro-timer/manifest.json | 8 ++- .../test/golden/128x32/pomodoro.png | Bin 602 -> 544 bytes .../test/golden/128x64/pomodoro.png | Bin 833 -> 768 bytes .../test/golden/256x32/pomodoro.png | Bin 497 -> 452 bytes .../test/golden/64x32/pomodoro.png | Bin 564 -> 508 bytes plugins/pomodoro-timer/test/harness.json | 14 ++++- 10 files changed, 70 insertions(+), 27 deletions(-) diff --git a/plugins.json b/plugins.json index 1347a82c..eed255d7 100644 --- a/plugins.json +++ b/plugins.json @@ -1143,7 +1143,7 @@ "last_updated": "2026-07-28", "verified": true, "screenshot": "", - "latest_version": "1.1.1", + "latest_version": "1.2.0", "icon": "fa-hourglass-half" } ] diff --git a/plugins/pomodoro-timer/README.md b/plugins/pomodoro-timer/README.md index a9888a69..1b640bcb 100644 --- a/plugins/pomodoro-timer/README.md +++ b/plugins/pomodoro-timer/README.md @@ -66,9 +66,14 @@ normal rotation when the timer goes idle. ``` The countdown is drawn as **seven-segment digits**, sized to whatever space the -panel has left rather than picked from a font. The unlit segments stay faintly -visible, the way the dark segments of a real LED clock do, which keeps the digits -from appearing to jump around as the numbers change. +panel has left rather than picked from a font, with a heavy stroke so it stays +readable from across a room. The phase label is held back a little so the eye +lands on the time first. + +If you want the full clock-radio look, **Show Unlit Segments** faintly lights the +dark segments the way real hardware does. It's off by default because it costs +real legibility — every digit gains a faint `8` behind it, and a `15` starts to +read as an `85`. The **burndown ring** is the default indicator: the whole border is lit when a phase starts and drains clockwise from the top-left, with a brighter head pixel @@ -157,7 +162,7 @@ durations you configured. | **Countdown Text Color** | white | Only used when Countdown Color is `fixed`. | | **Background Color** | black | Panel background. | | **Countdown Digits** | `seven_segment` | `seven_segment` draws clock-radio style segments sized to the panel. `pixel` uses the display's pixel font. | -| **Show Unlit Segments** | `true` | Keep the unlit segments faintly visible, like a real LED clock. Turn off for digits on pure black. Ignored when the stroke is only one pixel wide, where the effect would just be noise. | +| **Show Unlit Segments** | `false` | Faintly light the unlit segments, like a real LED clock. Authentic, but it costs legibility — every digit gains a faint `8` behind it. Ignored when the stroke is only one pixel wide, where the effect would just be noise. | | **Burndown Indicator** | `perimeter` | `perimeter` drains a ring around the edge of the panel; `bar` empties a bar along the bottom; `segments` puts out a row of blocks one at a time; `none` hides it. | | **Show Phase Label** | `true` | The phase name above the countdown. | | **Show Session Dots** | `true` | One dot per session in the set, filled as you complete them. | diff --git a/plugins/pomodoro-timer/config_schema.json b/plugins/pomodoro-timer/config_schema.json index 049fc78d..d36dd47e 100644 --- a/plugins/pomodoro-timer/config_schema.json +++ b/plugins/pomodoro-timer/config_schema.json @@ -423,9 +423,9 @@ }, "show_ghost_segments": { "type": "boolean", - "default": true, + "default": false, "title": "Show Unlit Segments", - "description": "Keep the unlit segments faintly visible, the way a real LED clock shows its dark segments. Turn off for digits on pure black.", + "description": "Show the unlit segments faintly, the way a real LED clock shows its dark segments. It looks authentic but costs legibility — every digit gains a faint 8 behind it — so it is off by default. Ignored when the stroke is only one pixel wide.", "x-advanced": true }, "progress_style": { diff --git a/plugins/pomodoro-timer/manager.py b/plugins/pomodoro-timer/manager.py index 00411a5f..824fd0b5 100644 --- a/plugins/pomodoro-timer/manager.py +++ b/plugins/pomodoro-timer/manager.py @@ -204,7 +204,7 @@ def _int(key: str, default: int, low: int, high: int) -> int: self.digit_style = str(config.get("digit_style", "seven_segment")) if self.digit_style not in ("seven_segment", "pixel"): self.digit_style = "seven_segment" - self.show_ghost_segments = bool(config.get("show_ghost_segments", True)) + self.show_ghost_segments = bool(config.get("show_ghost_segments", False)) self.progress_style = str(config.get("progress_style", "perimeter")) if self.progress_style not in ("perimeter", "bar", "segments", "none"): self.progress_style = "perimeter" @@ -914,6 +914,10 @@ def _render(self, draw, width: int, height: int, snap: Dict[str, Any]) -> None: label = snap["label"] remaining = max(0.0, 1.0 - snap["elapsed_fraction"]) + # Hold the label back so the eye lands on the countdown first. It is + # still plainly the phase colour, just not competing at full brightness. + # During the alert flash everything is already inverted, so leave it. + label_color = accent if snap["alerting"] else _dim(accent, 0.7) # The perimeter ring lives on the outermost pixels, so the content # box steps in to clear it. Every other style sits below the content. @@ -924,9 +928,11 @@ def _render(self, draw, width: int, height: int, snap: Dict[str, Any]) -> None: box = (inset, inset, width - 2 * inset, height - 2 * inset - strip_h) if width >= 192 and height <= 48: - self._render_wide(draw, box, snap, label, text_color, accent, dot_h) + self._render_wide(draw, box, snap, label, text_color, accent, + label_color, dot_h) else: - self._render_stacked(draw, box, snap, label, text_color, accent, dot_h) + self._render_stacked(draw, box, snap, label, text_color, accent, + label_color, dot_h) if ring: self._draw_perimeter(draw, width, height, remaining, accent) @@ -942,7 +948,8 @@ def _strip_height(self, height: int) -> int: def _dot_size(height: int) -> int: return 4 if height >= 64 else 3 - def _render_stacked(self, draw, box, snap, label, text_color, accent, dot_h) -> None: + def _render_stacked(self, draw, box, snap, label, text_color, accent, + label_color, dot_h) -> None: """Label above the countdown, for panels that are not extremely wide.""" bx, by, bw, bh = box label_h = (10 if bh >= 54 else 7) if (self.show_phase_label and label) else 0 @@ -962,7 +969,8 @@ def _render_stacked(self, draw, box, snap, label, text_color, accent, dot_h) -> box_h = max(6, bottom - top) if label_h: - self._draw_in_box(draw, label, label_font, (bx + 1, by, bw - 2, label_h), accent) + self._draw_in_box(draw, label, label_font, (bx + 1, by, bw - 2, label_h), + label_color) self._draw_time(draw, snap["remaining"], (bx + 2, top, bw - 4, box_h), text_color) @@ -978,7 +986,8 @@ def _dots_span(snap: Dict[str, Any], size: int) -> int: total = max(1, int(snap["sessions_before_long_break"])) return total * (size + 1) - 1 - def _render_wide(self, draw, box, snap, label, text_color, accent, dot_h) -> None: + def _render_wide(self, draw, box, snap, label, text_color, accent, + label_color, dot_h) -> None: """Side-by-side layout for very wide panels (e.g. 256x32).""" bx, by, bw, bh = box left_w = max(40, int(bw * 0.30)) @@ -987,7 +996,7 @@ def _render_wide(self, draw, box, snap, label, text_color, accent, dot_h) -> Non if label_h: label_font = self._fit_font(draw, label, left_w - 4, label_h) self._draw_in_box(draw, label, label_font, (bx + 2, by + 1, left_w - 4, label_h), - accent, align="left") + label_color, align="left") if dot_h: dots_y = by + ((label_h + 3) if label_h else max(1, (bh - dot_h) // 2)) dots_y = min(dots_y, by + max(0, bh - dot_h - 1)) @@ -1042,7 +1051,9 @@ def _seven_segment_metrics(self, text: str, bw: int, bh: int): if not digits: return None for h in range(bh, 4, -1): - stroke = max(1, round(h * 0.15)) + # A heavier stroke puts more lit pixels on the panel, which is what + # legibility comes down to at a distance. + stroke = max(1, round(h * 0.17)) gap = max(1, round(h * 0.10)) colon_w = stroke spare = bw - colons * colon_w - (digits + colons - 1) * gap @@ -1050,8 +1061,10 @@ def _seven_segment_metrics(self, text: str, bw: int, bh: int): continue width_cap = spare // digits # A digit narrower than two strokes plus a gap has no interior left, - # so the vertical segments would fuse into a solid block. - digit_w = min(round(h * 0.72), width_cap) + # so the vertical segments would fuse into a solid block. The height + # is usually the binding constraint, so spend spare width on wider + # digits — longer horizontal segments read further away. + digit_w = min(round(h * 0.78), width_cap) if digit_w < 2 * stroke + 1 or h < 3 * stroke + 2: continue # Keep the classic clock-radio proportion. Without this a tall, @@ -1125,12 +1138,23 @@ def _draw_digit(self, draw, char: str, x: int, y: int, w: int, h: int, t: int, """Draw one seven-segment digit, lit segments over the ghost layer.""" lit = self._SEGMENTS.get(char, "") boxes = self._segment_boxes(x, y, w, h, t) - for name, (sx, sy, sw, sh) in boxes.items(): - on = name in lit - fill = color if on else ghost - if fill is None: - continue - draw.rectangle([sx, sy, sx + sw - 1, sy + sh - 1], fill=fill) + + if ghost is not None: + for name, (sx, sy, sw, sh) in boxes.items(): + if name not in lit: + draw.rectangle([sx, sy, sx + sw - 1, sy + sh - 1], fill=ghost) + elif char == "1": + # A 1 lights only the right-hand verticals, so it hangs off the edge + # of its cell and opens a ragged gap before the next digit — "11:11" + # reads as four scattered bars. Centring it in the cell evens the + # spacing without changing cell widths, so digits never jitter as the + # time changes. With the ghost layer on, the unlit 8 already anchors + # the cell and a right-hung 1 is what real hardware looks like. + boxes = self._segment_boxes(x + (w - t) // 2, y, t, h, t) + + for name in lit: + sx, sy, sw, sh = boxes[name] + draw.rectangle([sx, sy, sx + sw - 1, sy + sh - 1], fill=color) # ── Burndown indicator ────────────────────────────────────────────────────── diff --git a/plugins/pomodoro-timer/manifest.json b/plugins/pomodoro-timer/manifest.json index a85cbfe3..c02a3cf9 100644 --- a/plugins/pomodoro-timer/manifest.json +++ b/plugins/pomodoro-timer/manifest.json @@ -1,7 +1,7 @@ { "id": "pomodoro-timer", "name": "Pomodoro Timer", - "version": "1.1.1", + "version": "1.2.0", "author": "ChuckBuilds", "description": "A configurable Pomodoro focus/break timer for your matrix. Set the work and break lengths, then start, pause, skip, or reset it over MQTT — with Home Assistant auto-discovery so the whole timer shows up as a device with no YAML.", "entry_point": "manager.py", @@ -22,6 +22,12 @@ ">=2.0.0" ], "versions": [ + { + "released": "2026-07-28", + "version": "1.2.0", + "notes": "Legibility pass on the countdown. The unlit 'ghost' segments are now off by default: they look authentic but put a faint 8 behind every digit, so a 15 read as an 85 at a glance. The segment stroke is heavier and the digits are allowed to spend spare panel width, since height is usually the binding constraint and longer horizontal segments read further away. The phase label is drawn at 70% brightness so the eye lands on the time first. A 1 is centred in its cell rather than hanging off the right, which evens out the spacing of times like 11:11 without changing cell widths, so the digits never jitter as the time changes; with the ghost layer enabled it stays right-hung, which is what real hardware looks like.", + "ledmatrix_min_version": "2.0.0" + }, { "released": "2026-07-28", "version": "1.1.1", diff --git a/plugins/pomodoro-timer/test/golden/128x32/pomodoro.png b/plugins/pomodoro-timer/test/golden/128x32/pomodoro.png index 3136b8251ebb19bea444c3d89f86b6b5f0766e15..24b9b2d28537472e34b2d01175b2a14b53a84747 100644 GIT binary patch delta 520 zcmV+j0{8ve1fT?vBYy$sNkl@5XRReP3o-z5=2nDQ~_b%A+NFzK$p}O?n+^6BJ{KvawO+4L zY6n380N3l)G|j`|5XUj+-1q&*UxFY=l0-ieLPSx-82cTg^M3$<4tSd9Wi6B@Fvcvb z!i=#rP4nhvStf*dY@Fp2p}rLWsp;p%+ClCAChc6G{^>2T%n7ST2`J zDciQoBX#R_yNj63W}WWt2d?Yt#eTo9vepaC0Zb+n$8ik9h~xNtK0l2~)0A_ro7=LM z>$;cAB@Dyc?SIxLRkz?<(91BTRF!PDZ3DplelMzJS=M+w=A0jo$9fy>lQ*)UD2jaF zcO1tw%_K>3Q{Ha3ob!?xtt0>l!>}aJZnv9Gr=I62rHW$Okh@C7^*3}6KtxJuVDI76 z>DYqm>S?uFHGb=TO1FU&fD8=)nT{=}qC$&=--*D>lYcZT-QQ7#6oAYd-2>{9f0dp8 zG5#f_0Ay$Y$ROVXn!T<)XVA`*Lkd8K27pXor=VK?(DL>$Xy_P<3|SB|Gyr4GIjz5pNlQ!xOWU2y;a002ov KPDHLkU;%t?Wx&Bl`|MWHQ@&0QU-YBJDKx8_dVsGL9 zLMiTwA*dYxoz9=N|}0@?kJkg=0kV)gG!|$2b;}CXDkPp1*q5Sj^h*xh53BG+wE>@ z7K;TTL?$<7tbbaqb~qdcgTeWHPP5cw!MC798RuMQHQTm@5SPm(Dps*rtX8Y#a(TU8 z-?z~$c_Ir6g23}U$8jvnVvH$Mo=hf$kQj~ZBq2n<-;Y^mHk&mXjaIA0Igiq$LAi?N z_zQCnK!j5Ij=hKfPKFj#DwX7*+wI0Hd%YfL^x|+hlz)T$e*b$`zm+w^UFkEzbO17@ z0LbKV3W{psC=-a%D^JTOTt3GW(*ejl(LLZ^)GsvChh)~64nW2f0GSLeDDn6oMdH&> zdd`*es9QqRvswxMrq2k|0mzsFAcJ`ixa$4*Hw}cnA3Z)fU{2?iary?VR+ zzW)9jxBmF1oKJeo-E3zkZVV84P&qI6c8kFM@0;fdsSztf`i>_TJ=GBIV&Xa6u`JNZL!#~a>#1?^KMi=AA6xW2mT1c~n|<+7ypag& zj&;>mbMyFaZHrxfRibU>_17&EvbIXKEqrWoa(|~?&s;zCYLU~M90k5q?Je6aD_-xk zGH~7NcV)XzKfRQ-)ot-bpsnLVda03e{l`B})Y%-I6|yH!{qzN$+Q@05 zy4!l)oF}P-hJH?+A^%5>r(=;u{et2jWdRHZ8VnL53_M&6&$jXCuF{xwHZA0K%^CJs z);@*=6j`oQahWQW+nnl8TtBUy@^a#_&?VbCl|h=%+}w;Y zV0+<#2OJC#j%Pe`Tz%PpKKmm{OC~xi=01W+fX&Icx6tKh)Q5W40}zvI4qU#Fw_Q5_ z{rBI?%TAyCAX>lzG7+e@=73az@l^Y*zyF1N=$6o_XROTHz3TUcy=e?U;OXk;vd$@? F2>^a#QlkI> delta 811 zcmZo*JIFRcxt^KB)5S5QV$Rz+`?G!ph#c>?@ACFMBG#hEC&(%;%liBodx93xR)n%QEA>R@q9{Dci1lZD())JNK}Gh->HjPiuGGeRukFJ-1-!>d@Hhw&yqN zXkXMyFp;{sO*V%siqlP_S=+O&^Tf8d*RO{wX-)Myn|9fN=k%sO6aVixy}hcUVnc3p z=vBAHf;+snd@8^C`fJqMWvjA&=%`1@NtHklj|Fn>}mW-8}QzzMF5_92S)Bb+fpAuE&KjYHiqsve@mn z-*!b*&inrF-@k9))PApa|F3)|#c1)x6NPiuuh+ln-lH7IaJ*1$a_9YjKYldu z&X~0cR{$0O-`Hvn%~j(~;olCrux?AM!x$ z*uanw!C){wiZwRltgyP4!>+6Uojtd2)2qAm_`HzIyVZ}wHq{EnH$4EWt2Y4KbS85i z`@^wQ|v)89_EV1}rJDC5yT)(E#psB%o?DMU{#bDKvr7ff97d2Ebr@!-aL-^?n*OQT gtlnO7pWxw7ysw^L*&)wrsLlWcp00i_>zopr0G?xgOaK4? diff --git a/plugins/pomodoro-timer/test/golden/256x32/pomodoro.png b/plugins/pomodoro-timer/test/golden/256x32/pomodoro.png index 0f05b156d03d0cd58a7bcf6a857ac82fa69f64fb..e17dcc91ac8507ccb47bf645e16d96ff2475808d 100644 GIT binary patch delta 426 zcmV;b0agC-1H=Q6B!7!ZL_t(|obB7ev4bEGfZ^ngb=VQDP>h{8oNdNRa$w>hq5=<4 zv;TK8NN^9Vu*ST#)-OP;X_~GM0l0?Nx*Nyw>M(xOKbO{F7|M>b=P7Yk@^{Hqftw%! zAX?{sP)d2HjWNMfw%s;*ecV&Q{@cML`=7=GqzbG6=IiyHXMfAg_S!ACkK3}>f7=+- zKRzBHUU%LlLdN_v#wewfQonlhPOa)9i^$r=VyU9JcJWG)m_ zq{aj6StY>1G=B=7+D_A?{z>B>Tnorn<|z=iDm~>$9hSZRF%m!uG9idpAs`wF0MS-k zpuX?z!933~$E|_{YM};bBzp^9>Xiz92n%8h1kqM2fV-=SIR>7N6#$}<01%BLSPH7% zAQGUF>{l2X|8b<$&0`lK=n! delta 471 zcmV;|0Vw{&1MvfpB!9R`L_t(|obB7~k%J%*fZ^oE3M|0NQUk?UgAG`K%~*gn+z)0L z6y-}Jn0=pL#B9jk94p{)Z>7`=5Nn#I(kTFCD5aWl980J1``WfO?a${E|G~cRbt7q_ z9)`iRmu2xpX3zh5iS71K)?euafRZQxh}PII-0AFSrqkE;#eYqV{nl>0+j(SsFdraQ z<6g1{nNq6ndv~Dg>*l42F;_hCW^kK}G4b*FR4$lja8(`b!QOJjyCJa(A30vL1j_iq zj_&cmoh~Q_(M?A z^8oj0vdum;5qx-R2VdAjS^Om!qEP@4jRJsZt35#1b-FRnb4>fLB!5cYL5BDf&aaeB zg1tFOI>=yx&-YLkn_wv_9DFrk2?WtrGl0FSiD?5*$50Fa(bxk>!McAJGXO-R03aGm zpu6Rrg144MFf{hH;WgAHw23IC26YlN#va=G$;4olY5f3BgPn5jm>8B=@Om?W6WEx*V4Hp zNf7aJxwx)-I2<_VgpeqT?w=${lIOXYqLgM?1^~Y~A`T)7f`2qlXD*cnPou9Wil(@- zEGeb;<(6d$0AU!4DX~P#5pgJ`i^W0=R2)sN)9EBF(lP0j28bBPab4G)OwM^2hJ+9e zXFi|zEkM_S>2&J*zT-HY^Qx+(oT4ZQA!1+q=5FN*g5Z2Ur)hex<$rnJWHKRy9FIqnfC4%JEo501MUn6OuIuJ`-k5T`-8ROMazrN)F-=oV zoZW6Wo6VNXWnI@Qjs#Y}2N+|o9^#MV!9tzgSgls(v=-hJ-1-h!FW|vK($<5ztt!4A ztyPA>RkR-0XkRobpVMiG@3K(ecfzpNdJb>2(BH`TE?WyV&~KjMW%YlEjbOb1>kqM9 zfc}1b@eAB=xgpJZ3t2C~`aiP44sj3!VzA%uO~ymzwcbM33mD8o_pUG*4~3D{hZq2U Y05M@uQK1UHCjbBd07*qoM6N<$f@DMJPyhe` delta 539 zcmV+$0_6St1GEH?B!Bx!L_t(|oZVNUuEH=By*wBWgQY=DAP5OTR3m2)!}1ON7$jIM zORyv;43%IoD8i5kmOu&SiJL5Yqw6{tkCdaD_4M}Mp3~c`z!(Fgu~;lVox<~AjFo1y zS<>9~dc9mOmSt_XTh2Kl#BrSH7seRp9H(5@^*j#%9yu6C2!Ao1P60p~Cv~r+8cK@# zHk(bDobUUt>qgV{eIEcQr8tF4q#TUX@AoOCoO1<7vDe{nkS6(~qm%+dh+eN31VJj3 zb51EGgeW+jPA6*uDg|1tmTlX`VsW)v9gjyTXT4q%LhxL(?j75!*=(NA=ka)ayWR8` zrdudAgwF8;K!2rD5klPW_oThb<#MCZsMTt_-A*SULRCNvp65A^W81c6S)B9Gl=JyK zG=`LeN(v!Hqmd%ca=C1`+ud$A2!bSz1V+CDjImcQ@y8LjP_O81 zWC8%E(<$~nRqE8pQCf(vQveaG67hI8QW|QQgjXU<#eXqgf%g``7<+e@GhTrV57`rt zS~*|D3s5xBM=0#^m^!vs=@rMROtcVRYN6ErnDkn~Us-UB(NJ4Of6PP+@udpng@3rj z#w%ca0dfmz_Qxk)S7Tx=_7d$H9KT*BR%vukjW#zJS~;6m^BpI4=~8 dzQh3V3lx Date: Tue, 28 Jul 2026 23:10:49 +0000 Subject: [PATCH 6/7] pomodoro-timer: pip states, task label, and a calmer palette Picks up the parts of the requested UI spec that a display-only panel can actually carry. - Session pips gain a third state. Completed are solid, upcoming are hollow outlines, and the session you are in is picked out and slowly pulses, so the row reads as "two done, on the third, one to go" instead of just a count. Hollow needs 3px, so smaller pips fall back to a dim fill. - Task label. Set it from Home Assistant via a new MQTT text entity, or publish to /task/set (32 chars, empty clears). It takes the label row rather than fighting for a second one on a 32px panel -- the phase is already carried by the colour. Survives phase changes, since it is what you are working on rather than which phase you are in; a per-session label from a start command still wins, and RESET clears it. Discovery goes from 17 entities to 18. - Calm colour theme (warm terracotta, sage, soft indigo) as a preset alongside the punchy defaults, and paused_style: desaturate, which drains the phase's own hue rather than switching to amber, so a held timer reads as halted without changing which phase you are in. - A label sent with START to an already-running timer is now applied rather than silently dropped. It still emits no "started" event. Not built, and why: a circular progress ring around the readout is not viable on this hardware -- enclosing the digits in a circle takes them from 16px wide to 1px on 128x32, and from 27 to 8 on 128x64. The existing rectangular perimeter is that same gauge fitted to the panel's aspect ratio. Hero buttons, tabs, task drawers and settings icons need an input device the panel does not have; those controls already exist as Home Assistant entities. 37 tests. Goldens regenerated for the hollow pips. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AtZDGDrzbWdK4JxUeH4hk6 --- plugins.json | 2 +- plugins/pomodoro-timer/README.md | 17 +++- plugins/pomodoro-timer/config_schema.json | 33 +++++++ plugins/pomodoro-timer/manager.py | 89 ++++++++++++++++-- plugins/pomodoro-timer/manifest.json | 8 +- .../test/golden/128x32/pomodoro.png | Bin 544 -> 558 bytes .../test/golden/128x64/pomodoro.png | Bin 768 -> 786 bytes .../test/golden/256x32/pomodoro.png | Bin 452 -> 464 bytes .../test/golden/64x32/pomodoro.png | Bin 508 -> 516 bytes plugins/pomodoro-timer/test_pomodoro_timer.py | 50 +++++++++- 10 files changed, 184 insertions(+), 15 deletions(-) diff --git a/plugins.json b/plugins.json index eed255d7..50a00780 100644 --- a/plugins.json +++ b/plugins.json @@ -1143,7 +1143,7 @@ "last_updated": "2026-07-28", "verified": true, "screenshot": "", - "latest_version": "1.2.0", + "latest_version": "1.3.0", "icon": "fa-hourglass-half" } ] diff --git a/plugins/pomodoro-timer/README.md b/plugins/pomodoro-timer/README.md index 1b640bcb..4303bcd1 100644 --- a/plugins/pomodoro-timer/README.md +++ b/plugins/pomodoro-timer/README.md @@ -58,7 +58,7 @@ normal rotation when the timer goes idle. ```text ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▔▔▔▔▔▔▔▔ ← burndown ring: lit = time left in the phase -▏ FOCUS ●●○○ ▕ ← phase label, and one dot per session in the set +▏ Editing specs ●●◉○ ▕ ← task (or phase label), and one pip per session ▏ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ▕ ▏ │17│:│42│ ▕ ← countdown in seven-segment digits ▏ └─┘ └─┘ ▕ @@ -75,6 +75,11 @@ dark segments the way real hardware does. It's off by default because it costs real legibility — every digit gains a faint `8` behind it, and a `15` starts to read as an `85`. +The **task label** is whatever you're working on. Set it from Home Assistant (or +any MQTT client) and it takes the label row — the phase is already carried by the +colour, so it doesn't need the words too. Leave it empty and the phase name shows +instead. + The **burndown ring** is the default indicator: the whole border is lit when a phase starts and drains clockwise from the top-left, with a brighter head pixel leading the way. It costs no interior space, which is what lets the digits be as @@ -157,15 +162,18 @@ durations you configured. | Field | Default | Description | |---|---|---| | **Countdown Color** | `phase` | `phase` colors the countdown by what's running; `fixed` always uses the Countdown Text Color. | +| **Colour Theme** | `classic` | `classic` uses the individual phase colours below. `calm` overrides them with a softer palette — warm terracotta, sage, soft indigo. | | **Work / Short Break / Long Break Color** | red / green / blue | Phase colors. | | **Idle / Paused Color** | grey / amber | Used when nothing is running and when paused. | +| **How Paused Looks** | `amber` | `amber` switches the countdown to the Paused Colour. `desaturate` keeps the phase's own hue but drains it, so a held timer reads as halted without changing which phase you're in. | | **Countdown Text Color** | white | Only used when Countdown Color is `fixed`. | | **Background Color** | black | Panel background. | | **Countdown Digits** | `seven_segment` | `seven_segment` draws clock-radio style segments sized to the panel. `pixel` uses the display's pixel font. | | **Show Unlit Segments** | `false` | Faintly light the unlit segments, like a real LED clock. Authentic, but it costs legibility — every digit gains a faint `8` behind it. Ignored when the stroke is only one pixel wide, where the effect would just be noise. | | **Burndown Indicator** | `perimeter` | `perimeter` drains a ring around the edge of the panel; `bar` empties a bar along the bottom; `segments` puts out a row of blocks one at a time; `none` hides it. | | **Show Phase Label** | `true` | The phase name above the countdown. | -| **Show Session Dots** | `true` | One dot per session in the set, filled as you complete them. | +| **Show Session Dots** | `true` | One pip per session in the set. Completed are solid, upcoming are hollow, and the one you're in is picked out. | +| **Pulse the Current Session Dot** | `true` | Slowly blink the pip for the session you're in, so the row reads as "two done, on the third" rather than just a count. | | **Work / Short Break / Long Break / Idle / Paused Label** | `FOCUS` / `BREAK` / `LONG BREAK` / `POMODORO` / `PAUSED` | The on-screen text for each state. Blank the Paused Label to keep showing the phase name while paused. | | **Font** | *(blank)* | Path to a TTF relative to the LEDMatrix root, e.g. `assets/fonts/PressStart2P-Regular.ttf`. Blank uses the display's default font. | | **Font Size (px)** | `0` | Fix the countdown height in pixels. `0` sizes it automatically to the panel. | @@ -248,9 +256,13 @@ changes: | `/session` | Completed work sessions | | `/attributes` | JSON snapshot of everything above plus `cycle_position`, `elapsed_fraction`, and the configured durations | | `/event` | JSON `{"event_type": …}` on every transition — `started`, `paused`, `resumed`, `stopped`, `reset`, `skipped`, `phase_started`, `work_complete`, `break_complete` | +| `/task` | The current task label | | `/available` | `online` / `offline` (also the last-will message) | | `/work_minutes` etc. | Current value of each duration setting | +The task label is set by publishing to `/task/set` (max 32 characters; +publish an empty payload to clear it). `RESET` clears it too. + Each duration also has a matching command topic — `/work_minutes/set`, `/short_break_minutes/set`, `/long_break_minutes/set`, `/sessions_before_long_break/set` — which is what the Home Assistant @@ -281,6 +293,7 @@ number boxes write to. | **Time Remaining** | Sensor | `MM:SS` | | **Seconds Remaining** | Sensor | Numeric, for gauges and templates | | **Completed Sessions** | Sensor | Running count, resets on `RESET` | +| **Task** | Text | What you're working on. Type it in HA and it appears on the panel. | | **Timer Event** | Event | Fires on every transition — the clean hook for automations | | **MQTT Connected** | Binary sensor | Connectivity | diff --git a/plugins/pomodoro-timer/config_schema.json b/plugins/pomodoro-timer/config_schema.json index d36dd47e..1e29d497 100644 --- a/plugins/pomodoro-timer/config_schema.json +++ b/plugins/pomodoro-timer/config_schema.json @@ -23,18 +23,21 @@ "discovery_prefix", "device_name", "publish_interval_seconds", + "color_theme", "color_mode", "work_color", "short_break_color", "long_break_color", "idle_color", "paused_color", + "paused_style", "time_color", "background_color", "digit_style", "show_ghost_segments", "show_phase_label", "show_session_dots", + "pulse_active_pip", "progress_style", "work_label", "short_break_label", @@ -440,6 +443,36 @@ "title": "Burndown Indicator", "description": "How the time left in the phase is shown. 'perimeter' drains a ring around the edge of the panel and leaves the most room for the digits; 'bar' empties a bar along the bottom; 'segments' puts out a row of blocks one at a time; 'none' hides it.", "x-widget": "radio" + }, + "color_theme": { + "type": "string", + "enum": [ + "classic", + "calm" + ], + "default": "classic", + "title": "Colour Theme", + "description": "'classic' uses the individual phase colours below. 'calm' overrides them with a softer palette — warm terracotta, sage, and a soft indigo — for a panel that doesn't shout at you.", + "x-widget": "radio" + }, + "paused_style": { + "type": "string", + "enum": [ + "amber", + "desaturate" + ], + "default": "amber", + "title": "How Paused Looks", + "description": "'amber' switches the countdown to the Paused Colour. 'desaturate' keeps the phase's own hue but drains it, so a held timer reads as halted without changing what phase you're in.", + "x-widget": "radio", + "x-advanced": true + }, + "pulse_active_pip": { + "type": "boolean", + "default": true, + "title": "Pulse the Current Session Dot", + "description": "Slowly blink the dot for the session you're in, so the row reads as 'two done, on the third' rather than just a count. Completed sessions are solid and upcoming ones are hollow either way.", + "x-advanced": true } }, "required": [ diff --git a/plugins/pomodoro-timer/manager.py b/plugins/pomodoro-timer/manager.py index 824fd0b5..05938e21 100644 --- a/plugins/pomodoro-timer/manager.py +++ b/plugins/pomodoro-timer/manager.py @@ -79,6 +79,16 @@ _PHASE_ORDER = (PHASE_WORK, PHASE_SHORT_BREAK, PHASE_LONG_BREAK) +# Calmer hues for people who don't want the panel shouting at them. Chosen to +# still separate cleanly on an LED matrix, where heavily muted colours turn to +# mud: warm terracotta, sage, and a soft indigo. +_CALM_THEME = { + "work_color": (230, 108, 76), + "short_break_color": (122, 176, 150), + "long_break_color": (118, 132, 210), + "idle_color": (120, 124, 140), +} + def _rgb(value, default: Tuple[int, int, int]) -> Tuple[int, int, int]: """Coerce a config/JSON colour into a clamped RGB tuple.""" @@ -95,6 +105,13 @@ def _dim(color: Tuple[int, int, int], factor: float) -> Tuple[int, int, int]: return tuple(max(0, min(255, int(c * factor))) for c in color) # type: ignore[return-value] +def _desaturate(color: Tuple[int, int, int], amount: float) -> Tuple[int, int, int]: + """Pull `color` toward its own luminance — keeps the hue, drops the punch.""" + grey = int(0.299 * color[0] + 0.587 * color[1] + 0.114 * color[2]) + return tuple( # type: ignore[return-value] + max(0, min(255, int(c + (grey - c) * amount))) for c in color) + + def _lighten(color: Tuple[int, int, int], factor: float) -> Tuple[int, int, int]: """Blend `factor` of white into `color` — used for the burndown's head.""" return tuple( # type: ignore[return-value] @@ -125,6 +142,7 @@ def __init__(self, plugin_id: str, config: Dict[str, Any], self.completed_sessions = 0 self.cycle_position = 0 self.custom_label: Optional[str] = None + self.task = "" self.alert_until = 0.0 self.alert_color: Optional[Tuple[int, int, int]] = None @@ -199,6 +217,14 @@ def _int(key: str, default: int, low: int, high: int) -> int: self.idle_color = _rgb(config.get("idle_color", [110, 110, 110]), (110, 110, 110)) self.paused_color = _rgb(config.get("paused_color", [255, 176, 0]), (255, 176, 0)) self.background_color = _rgb(config.get("background_color", [0, 0, 0]), (0, 0, 0)) + self.color_theme = str(config.get("color_theme", "classic")) + if self.color_theme == "calm": + for key, value in _CALM_THEME.items(): + setattr(self, key.replace("_color", "") + "_color", value) + self.paused_style = str(config.get("paused_style", "amber")) + if self.paused_style not in ("amber", "desaturate"): + self.paused_style = "amber" + self.pulse_active_pip = bool(config.get("pulse_active_pip", True)) self.show_phase_label = bool(config.get("show_phase_label", True)) self.show_session_dots = bool(config.get("show_session_dots", True)) self.digit_style = str(config.get("digit_style", "seven_segment")) @@ -257,6 +283,8 @@ def _derive_topics(self) -> None: self.session_topic = f"{base}/session" self.attributes_topic = f"{base}/attributes" self.event_topic = f"{base}/event" + self.task_topic = f"{base}/task" + self.task_set_topic = f"{base}/task/set" self.number_state_topics = {f: f"{base}/{f}" for f, *_ in _NUMBER_FIELDS} self.number_set_topics = {f"{base}/{f}/set": f for f, *_ in _NUMBER_FIELDS} @@ -423,10 +451,13 @@ def _apply_command(self, command: str, payload: Dict[str, Any]) -> None: elif duration is not None: self._begin_phase_locked(self.phase, duration, run=True, label=label) else: - # Already running with nothing to change. Pressing Start + # Already running with nothing to restart. Pressing Start # mid-session must not re-fire "started" automations, so - # emit no event — any settings in the same payload were - # still applied above and are published below. + # emit no event — but an explicit label is a real change + # the caller asked for, so apply it rather than drop it. + # Settings in the same payload were applied above. + if label: + self.custom_label = label self.logger.debug("START ignored: timer already running") started = False if started: @@ -485,6 +516,7 @@ def _apply_command(self, command: str, payload: Dict[str, Any]) -> None: elif command in ("RESET", "CLEAR"): self._go_idle_locked(reset_counters=True) + self.task = "" self.alert_until = 0.0 events.append({"event_type": "reset"}) @@ -735,7 +767,12 @@ def _snapshot(self) -> Dict[str, Any]: "work_minutes": self.work_minutes, "short_break_minutes": self.short_break_minutes, "long_break_minutes": self.long_break_minutes, - "label": self.custom_label or self._phase_label(self.phase, self.running), + "task": self.task, + # A task name is what you actually want to see; the phase is + # already carried by the colour, so the task takes the label + # row rather than fighting for a second one on a 32px panel. + "label": (self.custom_label or self.task + or self._phase_label(self.phase, self.running)), "alerting": time.monotonic() < self.alert_until, "alert_color": self.alert_color, } @@ -898,7 +935,8 @@ def _render(self, draw, width: int, height: int, snap: Dict[str, Any]) -> None: phase = snap["phase"] accent = self._phase_color(phase) if snap["status"] == "paused": - accent = self.paused_color + accent = (_dim(_desaturate(accent, 0.6), 0.75) + if self.paused_style == "desaturate" else self.paused_color) background = self.background_color text_color = accent if self.color_mode == "phase" else self.time_color @@ -1023,11 +1061,25 @@ def _draw_session_dots(self, draw, x: int, y: int, avail_w: int, size: int, start = x + max(0, avail_w - span) else: start = x + max(0, (avail_w - span) // 2) + dim = _dim(accent, 0.28) + # The session you are actually in gets its own state, so the row reads + # as "two done, on the third, one to go" rather than just a count. + active = filled if snap["phase"] == PHASE_WORK else -1 + pulse_on = int(time.monotonic()) % 2 == 0 for index in range(total): left = start + index * (size + gap) - draw.rectangle([left, y, left + size - 1, y + size - 1], - fill=accent if index < filled else dim) + box = [left, y, left + size - 1, y + size - 1] + if index < filled: + draw.rectangle(box, fill=accent) + elif index == active: + lit = accent if (pulse_on or not self.pulse_active_pip) else dim + draw.rectangle(box, fill=lit) + elif size >= 3: + # Hollow: clearly "not yet" without reading as a dim filled dot. + draw.rectangle(box, outline=dim) + else: + draw.rectangle(box, fill=dim) # ── Countdown digits ──────────────────────────────────────────────────────── @@ -1312,7 +1364,7 @@ def _publish_state(self, force: bool = False) -> None: now = time.monotonic() snap = self._snapshot() key = (snap["phase"], snap["status"], snap["remaining"], - snap["completed_sessions"], snap["cycle_position"]) + snap["completed_sessions"], snap["cycle_position"], snap["task"]) if not force: if key == self._last_published_key: return @@ -1327,6 +1379,7 @@ def _publish_state(self, force: bool = False) -> None: self._publish(self.remaining_topic, snap["remaining"]) self._publish(self.remaining_secs_topic, str(snap["remaining_seconds"])) self._publish(self.session_topic, str(snap["completed_sessions"])) + self._publish(self.task_topic, snap["task"]) self._publish(self.attributes_topic, json.dumps({ k: v for k, v in snap.items() if k != "alert_color" })) @@ -1406,6 +1459,11 @@ def _discovery_entities(self) -> List[Tuple[str, Dict[str, Any]]]: "state_topic": self.session_topic, "state_class": "total_increasing", "icon": "mdi:check-circle-outline", **common, })) + entities.append((f"text/{uid}_task/config", { + "name": "Task", "unique_id": f"{uid}_task", + "command_topic": self.task_set_topic, "state_topic": self.task_topic, + "max": 32, "icon": "mdi:format-text", **common, + })) entities.append((f"event/{uid}_event/config", { "name": "Timer Event", "unique_id": f"{uid}_event", "state_topic": self.event_topic, "event_types": _EVENT_TYPES, @@ -1441,7 +1499,8 @@ def _clear_retained_state(self) -> None: return topics = [self.state_topic, self.status_topic, self.phase_topic, self.remaining_topic, self.remaining_secs_topic, - self.session_topic, self.attributes_topic, self.availability_topic] + self.session_topic, self.attributes_topic, self.availability_topic, + self.task_topic] topics += list(self.number_state_topics.values()) for topic in topics: self._publish(topic, "") @@ -1461,6 +1520,7 @@ def _on_mqtt_connect(self, client, userdata, flags, rc): # pylint: disable=unus client.subscribe(self.command_topic, qos=1) for topic in self.number_set_topics: client.subscribe(topic, qos=1) + client.subscribe(self.task_set_topic, qos=1) self._publish_availability(True) self._publish_discovery() self._publish_state(force=True) @@ -1489,6 +1549,17 @@ def _on_mqtt_message(self, client, userdata, msg): # pylint: disable=unused-arg self._publish_state(force=True) return + if msg.topic == self.task_set_topic: + try: + task = msg.payload.decode("utf-8").strip()[:32] + except (UnicodeDecodeError, AttributeError): + return + with self.state_lock: + self.task = task + self.logger.info("Task set to %r", task) + self._publish_state(force=True) + return + command, payload = self._parse_command(msg.payload) if command is None: self.logger.debug("Ignoring unrecognised payload on %s", msg.topic) diff --git a/plugins/pomodoro-timer/manifest.json b/plugins/pomodoro-timer/manifest.json index c02a3cf9..04e07777 100644 --- a/plugins/pomodoro-timer/manifest.json +++ b/plugins/pomodoro-timer/manifest.json @@ -1,7 +1,7 @@ { "id": "pomodoro-timer", "name": "Pomodoro Timer", - "version": "1.2.0", + "version": "1.3.0", "author": "ChuckBuilds", "description": "A configurable Pomodoro focus/break timer for your matrix. Set the work and break lengths, then start, pause, skip, or reset it over MQTT — with Home Assistant auto-discovery so the whole timer shows up as a device with no YAML.", "entry_point": "manager.py", @@ -22,6 +22,12 @@ ">=2.0.0" ], "versions": [ + { + "released": "2026-07-28", + "version": "1.3.0", + "notes": "Session pips now have three states rather than two: completed are solid, upcoming are hollow outlines, and the session you are actually in is picked out and slowly pulses (pulse_active_pip), so the row reads as 'two done, on the third' instead of just a count. New task label, settable from Home Assistant via an MQTT text entity or by publishing to /task/set, which takes the label row since the phase is already carried by the colour; cleared by RESET. New 'calm' colour theme preset (warm terracotta, sage, soft indigo) as an alternative to the punchy defaults, and paused_style: desaturate, which drains the phase's own hue instead of switching to amber. A label sent with START to an already-running timer is now applied instead of silently dropped.", + "ledmatrix_min_version": "2.0.0" + }, { "released": "2026-07-28", "version": "1.2.0", diff --git a/plugins/pomodoro-timer/test/golden/128x32/pomodoro.png b/plugins/pomodoro-timer/test/golden/128x32/pomodoro.png index 24b9b2d28537472e34b2d01175b2a14b53a84747..46348a573b5a2f65c222b912bf4503ca95cece8a 100644 GIT binary patch delta 533 zcmV+w0_y#s1g->-B!BfuL_t(|ob8vrio!q;fXCZ3skaJ<1VQbz2?$%+SmsssVSE$I z6t-zoStL~u!6u^J;(~C+Lb&5h%%3;rZoX>B&dw||JCmJ(loAL;cDo(+5&kbyN^85_ zqWJcGUu%84-56uX<1vn7&bjA#U%&XipCpM{5<-Mw2mn8IjDH=iwNZFz=czB0AOL`c z7+42#3R=ifn|B*B$67uIsK=tHojw$MNNId8m`7>GgVz zqNu&E^?J=13x9&3$X9#vuvV1e9a_*+HKkM)Yqo7`t?&1HkuS@#hQlG}{B%0iJ7{I{ z3tmx%w`xIQ7z|;N?b|xqjVK zg(?72V9{b?3>U3P9#n6;NxW>h^il%o^4ciz)yaN&q&0a=PgYQpy1(0GCh7 Xj$dmZi4Nn@00000NkvXXu0mjfP$&ak delta 518 zcmV+h0{Q)}1fT?vB!A~gL_t(|ob8vtio!q;#@8fG>a7A2L{Pg_0b$=Eud)y0n^>l> zO>1S5R6zurh@Fdr!WDZ2k25iUZ_GKrYB7_UWhUR}?np|>2t+oU4fYm37b&H+UawJV z2SES;*Xz|Z&BNgk$1&&J_x;CTf*?qeL_ZQjL{Y>T`yHe60DpiEc$(*BEtDoO#w@JD zjIlIL^X6t*CWLrwca~+0vH5(iAL&<~#^^jkh{a-|7ez58wN9rKN)s>#Pz3;3E|*Fv z+qTOib?bGzie!s7>)(gx5OePb@aSX$Vm29?c1Hk=$FREo()_6SToF9+JdK>MNH?p88ihSR9 z9LF@xBuR2p-fp*?^O6{?BmfA*uq4lJx0_CeJP;Um8#q0O|xuRM=VkRGBf~e{^jjvC`c(sGyq(_03Z8PF#wxgaR2}S07*qo IM6N<$g4RCtV*mgE diff --git a/plugins/pomodoro-timer/test/golden/128x64/pomodoro.png b/plugins/pomodoro-timer/test/golden/128x64/pomodoro.png index 0483e47ccbf61267f169e227c45dcdb5c95c6d8c..02cc1a7733a777a929e370a49587af6df5e23588 100644 GIT binary patch delta 762 zcmZo*o5VIjrT(U;i(^Q|oVRoK_N{UdY3MhP=7^HeE^WT()+;(K>hdowo1Xln{*9OY z45pQ3csnU^TwJkTVfuzDWqTu;$n)VJH>W>4@cfL7VxHr)R0V;VXVY$I*63~0srOxe z`O0nC{^RBDJclE6#O`fV@sv4kJMYsXvF_GH2?hm@CXKGX#~SrrLQ+pR-+pW2`?*}% z&GpO#*7%-VtPG){zL!}Vs`lP{{Z+*?NaX15Pyc~h-4;(=mT7ao+)8%;$_pCrLw4WI z+Zttap8b7MM#vqW%dHC;ju+~jW@5Nrx_CYlL!iiH(;YW+d@hT)8dmI|wDhCRVUa`) z1<6&buDve3UK)G-?bj;F`Y$)$mNhTdO_t!2;A{W4Em7k1QzNe_k1d{m{#jzR@!yMs z`+2xq57q7GUndiJDBwWV-fznS8Fs|zZNJ?c8-Frl{q?O;Yr|F-%7m{C+k4a=XpDti zfBWHw?-ry+E)D8@yrXC*55v)R>)*fnZ`=zn zta$u!$KAa3*RvO2eDVGF>Z_-=z15Ih=5a0iK#kpWl}rPPAMT6J_$>_lw@X&XdGVLj zSkJvj2R=_D51CI&0-#LUCQry@f76BVN?IK7g3~lr^534b7VMn%=S-uyU(8Wmr~p5_!#y} UF43#%)-wQsr>mdKI;Vst0H7pi=l}o! delta 744 zcmbQl*1$GFrT)05i(^Q|oVRoKu3P0G&~SZrFh`Vx_Jxcsfk~~Z*S75A;(t8*M)I2t z!5vHI-kLPwM7+TZ-4m)V{wxzyzgWc|JwNkqaSN{$a01*C*ro z=ehICt>)g7G4u_-yd@)gT54P3JAc=}S<7ZF^W=D0Vzo%4YToiy^;uaVXC4+@D7C$J zR${85v(%q}tmy)qZszEmR%-cRqtDT#b9&V(xA1?jS88M#&3v)z+@?1P1|74#db|9- z{{9=c{`jVxPkPJUY-cBK3=nxxIWPBii@^Qwo98RFbS#>&{@~lP?8s>vT_=A})$ctn zDaPT-`13}Nna%m%``-WFr@*0F|HVRvZ}Q@`DH>c8R5b6I`4(@DO6~k)^S;K8-O=Iq zV)uWk5i3LbjwcsA)e!Dt;yK*0EYQhAqV4+Ysd4c?4S1R#Tl77aXv;L4eeqGekqGOK zb=6jL^Z0IUi(P$HqHX2%*DVvWwo0`vd~9)Yf2Uo~TtD?{k<*(T1-?}6E!!cnAatHt z9_A)Hd!pT2&&f9H#e1uJ7SQ-dQ+-OJP$ueY-Imh=2D;L~ISJB5vv0jT0& zd*Oix91IYSXFPLUec69L`y)#dCps(UK7vVr&B?g8(B)^;hkDlo5R+>TT)vRET{{2$ z_utFQPM`cBTEGG_5vaE2fK-9;RQs*J|Al<$me8qZtjyZI>i31cX$(N%>FVdQ&MBb@ E0Pq)5asU7T diff --git a/plugins/pomodoro-timer/test/golden/256x32/pomodoro.png b/plugins/pomodoro-timer/test/golden/256x32/pomodoro.png index e17dcc91ac8507ccb47bf645e16d96ff2475808d..4207c3f197dd65e1f90595c257a3e4bc7d1416da 100644 GIT binary patch delta 438 zcmV;n0ZIPE1JDDIB!8DlL_t(|obB7q(SslmfZ^mD>#!qQp%^=HINOYs$+2xQDZ=bSOd81t()@6@VJSVYz?7E2Y)wTtgk3e*7846Z$oxyPf1$O;f_ zKTZrLZjR>;!8})R$Gz&YWOn;;3sr58X>Yp_J&b5GSWu5|ItlqDY9s)3drGka6lbkv g1%PUARsd-B0VYfY{eHNV`Tzg`07*qoM6N<$f<5ooeEh{8oNdNRa$w>hq5=<4 zv;TK8NN^9Vu*ST#)-OP;X_~GM0l0?Nx*Nyw>M(xOKbO{F7|M>b=P7Yk@^{Hqftw%! zAX?{sP)d2HjWNMfw%s;*ecV&Q{@cML`=7=GqzbG6=IiyHXMfAg_S!ACkK3}>f7=+- zKRzBHUU%LlLdN_v#wewfQonlhPOa)9i^$r=VyU9JcJWG)m_ zq{aj6StY>1G=B=7+D_A?{z>B>Tnorn<|z=iDm~>$9hSZRF%m!uG9idpAs`wF0MS-k zpuX?z!933~$E|_{YM};bBzp^9>Xiz92n%8h1kqM2fV-=SIR>7N6#$}<01%BLSPH7% zAQGUF>{l2X|8b<$S>>MP5#Nv^6Y zO6h&NRaF5%9LHiwY>{$=A4=(LHWP-5qltAq9;HcoCX>5q<3&yA>;nqVxHjW!d$5HQ(4Rp-vYj?FWG2 zaEOSv+f5ZK2!DdnXhaA(91bQ91#|_pkmq@lBw-l(zF!nYBg)NY(+ES#5tBs3EXy>0 zw%hGwGMUfkbzQ4C5;*-HV2r(bh(C@833X0mxm=plT6mLl=O^HN0S^+AjvmxwRq^#` z?J{((qV>2&`=SZ?T%CsaE(!I0C)kzNb9keK{-%uYl4DQ5c)A?FKl{*TPsA&#O*7`xrh#I_aJriAX(GO?||W&uru4Q(s1CZUhC g9{>hUA7TLb0pGh^Q9paZiU0rr07*qoM6N<$f&-H1MF0Q* delta 482 zcmV<80UiE?1pEV#B!9z6L_t(|ob6Y!s=_c7y|!JPGZmzWVBMSwh5SQ*B_HFTICgOD z>g144MFf{hH;WgAHw23IC26YlN#va=G$;4olY5f3BgPn5jm>8B=@Om?W6WEx*V4Hp zNf7aJxwx)-I2<_VgpeqT?w=${lIOXYqLgM?1^~Y~A`T)7f`2qlXD*cnPou9Wil(@- zEGeb;<(6d$0AU!4DX~P#5pgJ`i^W0=R2)sN)9EBF(lP0j28bBPab4G)OwM^2hJ+9e zXFi|zEkM_S>2&J*zT-HY^Qx+(oT4ZQA!1+q=5FN*g5Z2Ur)hex<$rnJWHKRy9FIqnfC4%JEo501MUn6OuIuJ`-k5T`-8ROMazrN)F-=oV zoZW6Wo6VNXWnI@Qjs#Y}2N+|o9^#MV!9tzgSgls(v=-hJ-1-h!FW|vK($<5ztt!4A ztyPA>RkR-0XkRobpVMiG@3K(ecfzpNdJb>2(BH`TE?WyV&~KjMW%YlEjbOb1>kqM9 zfc}1b@eAB=xgpJZ3t2C~`aiP44sj3!VzA%uO~ymzwcbM33mD8o_pUG*4~3D{hZq2U Y05M@uQK1UHCjbBd07*qoM6N<$f(hg3AOHXW diff --git a/plugins/pomodoro-timer/test_pomodoro_timer.py b/plugins/pomodoro-timer/test_pomodoro_timer.py index 7eb15857..29c8ffc4 100644 --- a/plugins/pomodoro-timer/test_pomodoro_timer.py +++ b/plugins/pomodoro-timer/test_pomodoro_timer.py @@ -371,6 +371,51 @@ def test_every_indicator_and_digit_style_renders(): assert p.display_manager.image.getbbox() is not None +def test_a_task_replaces_the_phase_label_and_survives_phases(): + p = make_plugin() + client = _wire_client(p) + p._apply_command("START", {}) + assert p._snapshot()["label"] == "FOCUS" + + p._on_mqtt_message(client, None, _Message(p.task_set_topic, "Editing specs")) + assert p._snapshot()["task"] == "Editing specs" + assert p._snapshot()["label"] == "Editing specs" + assert dict(client.published)[p.task_topic] == "Editing specs" + + # It is what you are working on, not what phase you are in, so it carries + # across a phase change... + p._apply_command("SKIP", {}) + assert p._snapshot()["label"] == "Editing specs" + # ...but a per-session label sent with a start command still wins. + p._apply_command("START", {"label": "DEEP WORK"}) + assert p._snapshot()["label"] == "DEEP WORK" + # ...and RESET clears it. + p._apply_command("RESET", {}) + assert p._snapshot()["task"] == "" + assert p._snapshot()["label"] == "POMODORO" + + +def test_task_text_is_bounded(): + p = make_plugin() + client = _wire_client(p) + p._on_mqtt_message(client, None, _Message(p.task_set_topic, "x" * 200)) + assert len(p._snapshot()["task"]) == 32 + + +def test_calm_theme_and_desaturated_pause(): + classic = make_plugin() + calm = make_plugin(color_theme="calm") + assert calm.work_color != classic.work_color + for name in ("work_color", "short_break_color", "long_break_color"): + assert getattr(calm, name) != getattr(classic, name) + + p = make_plugin(paused_style="desaturate") + p._apply_command("START", {}) + p._apply_command("PAUSE", {}) + assert p.display() is True + assert p.display_manager.image.getbbox() is not None + + def test_unknown_styles_fall_back_to_the_defaults(): p = make_plugin(progress_style="disco", digit_style="gothic") assert p.progress_style == "perimeter" @@ -391,7 +436,7 @@ def test_changing_the_discovery_prefix_clears_the_old_configs(): p.on_config_change({**DEFAULTS, "mqtt_enabled": False, "discovery_prefix": "ha2"}) cleared = [t for t, payload in client.published if t.startswith("homeassistant/") and payload == ""] - assert len(cleared) == 17, "old prefix's retained configs would orphan HA entities" + assert len(cleared) == 18, "old prefix's retained configs would orphan HA entities" def test_renaming_the_command_topic_clears_the_old_state_topics(): @@ -411,7 +456,7 @@ def test_renaming_the_command_topic_clears_the_old_state_topics(): def test_discovery_payloads_are_valid_json_and_unique(): p = make_plugin() entities = p._discovery_entities() - assert len(entities) == 17 + assert len(entities) == 18 uids = set() for topic, payload in entities: assert topic.count("/") == 2 and topic.endswith("/config") @@ -422,6 +467,7 @@ def test_discovery_payloads_are_valid_json_and_unique(): assert any(t.startswith("switch/") for t in topics) assert sum(t.startswith("button/") for t in topics) == 5 assert sum(t.startswith("number/") for t in topics) == 4 + assert any(t.startswith("text/") for t in topics) class _RecordingClient: From f70f8bff661c2fb065f929c302f881780cca8808 Mon Sep 17 00:00:00 2001 From: Charles Mynard Date: Wed, 29 Jul 2026 00:14:11 +0000 Subject: [PATCH 7/7] pomodoro-timer: make it actually pixel-exact, and fix short-panel overflow Two things turned up when measuring rather than eyeballing. Antialiasing. PIL antialiases TrueType text, so the phase label carried a grey halo of partial-brightness pixels -- 26 to 36 distinct colours in a frame that should have six. The label face is a pixel font rendered at integer sizes, so every one of those intermediate values was artefact, not design. The label now goes through a thresholded mask and is painted one flat colour. A frame is back to exactly its palette: background, accent, the 70% label, the 14% ring track, the 28% upcoming pip, and the lightened ring head. Overflow below ~16px tall. _render_stacked floored the countdown box at 6px, so once the label had taken its row on a short panel the digits were laid out past the bottom edge -- 18 of 168 sampled shapes drew off-panel, all at heights 8 to 14. The countdown is the point, so the label is now dropped when both cannot fit, and the box is clamped to the space that actually exists rather than floored. The harness only samples down to 32px tall, so nothing in CI would have caught this. Swept 256 shapes from 32x8 to 384x128: no overflow, no crashes, no blank frames. Two regression tests pin both properties, and the goldens are regenerated for the crisper label. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AtZDGDrzbWdK4JxUeH4hk6 --- plugins.json | 4 +- plugins/pomodoro-timer/manager.py | 29 +++++++++-- plugins/pomodoro-timer/manifest.json | 8 ++- .../test/golden/128x32/pomodoro.png | Bin 558 -> 384 bytes .../test/golden/128x64/pomodoro.png | Bin 786 -> 528 bytes .../test/golden/64x32/pomodoro.png | Bin 516 -> 348 bytes plugins/pomodoro-timer/test_pomodoro_timer.py | 46 ++++++++++++++++++ 7 files changed, 80 insertions(+), 7 deletions(-) diff --git a/plugins.json b/plugins.json index 50a00780..1b9c0848 100644 --- a/plugins.json +++ b/plugins.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "last_updated": "2026-07-28", + "last_updated": "2026-07-29", "plugins": [ { "id": "cricket-scoreboard", @@ -1143,7 +1143,7 @@ "last_updated": "2026-07-28", "verified": true, "screenshot": "", - "latest_version": "1.3.0", + "latest_version": "1.3.1", "icon": "fa-hourglass-half" } ] diff --git a/plugins/pomodoro-timer/manager.py b/plugins/pomodoro-timer/manager.py index 05938e21..afa98ca0 100644 --- a/plugins/pomodoro-timer/manager.py +++ b/plugins/pomodoro-timer/manager.py @@ -924,11 +924,24 @@ def _draw_in_box(self, draw, text: str, font, box: Tuple[int, int, int, int], while tw > bw and len(text) > 1: text = text[:-1] tw, th, ox, oy = self._measure(draw, text, font) - if tw > bw: + if tw > bw or th > bh: return x = bx if align == "left" else bx + max(0, (bw - tw) // 2) y = by + max(0, (bh - th) // 2) - draw.text((x - ox, y - oy), text, font=font, fill=color) + + # PIL antialiases TrueType text, which on an LED panel shows up as + # fringe pixels at partial brightness around every glyph. The label face + # is a pixel font rendered at integer sizes, so that grey is pure + # artefact — render to a mask, threshold it, and paint one flat colour + # so every lit pixel is fully lit. + try: + mask = Image.new("L", (tw, th), 0) + ImageDraw.Draw(mask).text((-ox, -oy), text, font=font, fill=255) + draw.bitmap((x, y), mask.point(lambda v: 255 if v >= 128 else 0), + fill=color) + except Exception as e: + self.logger.debug("Crisp text failed, falling back: %s", e) + draw.text((x - ox, y - oy), text, font=font, fill=color) def _render(self, draw, width: int, height: int, snap: Dict[str, Any]) -> None: """Compose one frame: background, phase content, then the indicator.""" @@ -1004,7 +1017,15 @@ def _render_stacked(self, draw, box, snap, label, text_color, accent, top = by + label_h + (1 if label_h else 0) bottom = by + bh - (0 if (dots_inline or not dot_h) else dot_h + 1) - box_h = max(6, bottom - top) + # On a very short panel the label and the countdown cannot both fit. + # The countdown is the point, so the label goes. Flooring box_h instead + # would push the digits straight off the bottom edge. + if label_h and bottom - top < 7: + label_h = 0 + label_font = None + top = by + bottom = by + bh - (0 if (dots_inline or not dot_h) else dot_h + 1) + box_h = max(1, bottom - top) if label_h: self._draw_in_box(draw, label, label_font, (bx + 1, by, bw - 2, label_h), @@ -1042,7 +1063,7 @@ def _render_wide(self, draw, box, snap, label, text_color, accent, align="left") self._draw_time(draw, snap["remaining"], - (bx + left_w + 2, by, bw - left_w - 4, max(6, bh - 1)), text_color) + (bx + left_w + 2, by, bw - left_w - 4, max(1, bh - 1)), text_color) def _draw_session_dots(self, draw, x: int, y: int, avail_w: int, size: int, snap: Dict[str, Any], accent: Tuple[int, int, int], diff --git a/plugins/pomodoro-timer/manifest.json b/plugins/pomodoro-timer/manifest.json index 04e07777..70e22ea9 100644 --- a/plugins/pomodoro-timer/manifest.json +++ b/plugins/pomodoro-timer/manifest.json @@ -1,7 +1,7 @@ { "id": "pomodoro-timer", "name": "Pomodoro Timer", - "version": "1.3.0", + "version": "1.3.1", "author": "ChuckBuilds", "description": "A configurable Pomodoro focus/break timer for your matrix. Set the work and break lengths, then start, pause, skip, or reset it over MQTT — with Home Assistant auto-discovery so the whole timer shows up as a device with no YAML.", "entry_point": "manager.py", @@ -22,6 +22,12 @@ ">=2.0.0" ], "versions": [ + { + "released": "2026-07-28", + "version": "1.3.1", + "notes": "Crispness and bounds. The phase label is now rendered through a thresholded mask instead of PIL's antialiased text, so a frame contains only the palette colours rather than a grey halo around every glyph — 26-36 distinct colours per frame drops to 6, all intentional. Fixes content drawn past the bottom edge on panels under about 16px tall: the countdown box had a hard 6px floor that pushed the digits off-panel once the label had taken its row, so the label is now dropped instead when both cannot fit. Swept 256 panel shapes from 32x8 to 384x128 with no overflow, no crashes, and no blank frames.", + "ledmatrix_min_version": "2.0.0" + }, { "released": "2026-07-28", "version": "1.3.0", diff --git a/plugins/pomodoro-timer/test/golden/128x32/pomodoro.png b/plugins/pomodoro-timer/test/golden/128x32/pomodoro.png index 46348a573b5a2f65c222b912bf4503ca95cece8a..b88b1814517031da4c7e8cbca96495c31772d5fb 100644 GIT binary patch delta 357 zcmV-r0h<1<1b_pOB!5RqL_t(|ob8uU4ul{KL~-Leyb)LFVZ4dyx0mrG`$5tW4FnM# z*`4==E>f3vMmmUyBS6-5MZd!TA|m6mERfjV`}k5-cQ;ivT-r~WIq@R-IjLCJ|4|(u zRN#QB?shG{W7uY;=EQrNnNt$$VMc8D5_a{Q;#ld`OVu$=Q-2t2+g36T1F!;r&^ z2>2U78e7m=J{V<;F}5Ev!^Ove_~weuKM z0GPWJaH^$hd%USz4Ryq#0>Cf-uPm;fwa?1jdJIqDnk00000NkvXXu0mjf D)Ul!3 delta 533 zcmV+w0_y#M1Fi&+B!BfuL_t(|ob8vrio!q;fXCZ3skaJ<1VQbz2?$%+SmsssVSE$I z6t-zoStL~u!6u^J;(~C+Lb&5h%%3;rZoX>B&dw||JCmJ(loAL;cDo(+5&kbyN^85_ zqWJcGUu%84-56uX<1vn7&bjA#U%&XipCpM{5<-Mw2mn8IjDH=iwNZFz=czB0AOL`c z7+42#3R=ifn|B*B$67uIsK=tHojw$MNNId8m`7>GgVz zqNu&E^?J=13x9&3$X9#vuvV1e9a_*+HKkM)Yqo7`t?&1HkuS@#hQlG}{B%0iJ7{I{ z3tmx%w`xIQ7z|;N?b|xqjVK zg(?72V9{b?3>U3P9#n6;NxW>h^il%o^4ciz)yaN&q&0a=PgYQpy1(0GCh7 Xj$dmZi4Nn@00000NkvXXu0mjf3v>fK diff --git a/plugins/pomodoro-timer/test/golden/128x64/pomodoro.png b/plugins/pomodoro-timer/test/golden/128x64/pomodoro.png index 02cc1a7733a777a929e370a49587af6df5e23588..3920f317f429f165a0169916c4152c6fef03c000 100644 GIT binary patch delta 503 zcmbQlHi2b=ay{d9PZ!6KiaBrR9GuvqAmBQ8ZRg=0kJf}UM;_%Exol!zr4bgvvh=6H z=lhusjnn+M>rVT8DN3K^M4?QE@ILXiU#stbfA&{v=_9Y?GxhFw6~o{ATVH(=t;)VC zD>P{#Oa8*T=^08>f{x92qO;q1-wS{4^C!1+Ex&qQeA}w68+7f3>gU?2?6mPVIF=&z zV+N19Ls4bdBe9D48`EQ^P`r1dVzI2`+|bb6BUQODS%ZJhV4(UVzb}BcUcl%bg3(OFcc)*69nZ@cm>+=zhz5FMN^QZLB{36h%0JH`o3AFKg=8MmFoFBzopr051I4KL7v# delta 763 zcmbQhGKp=1ay`>cPZ!6KiaBrR?Co3SAkxrp9?cOYpTGZuVS~flTN&Ooy z`x#6t%kXwm;<&hCyTbGhRm%28GLh%QKWjZod82#P@T# zvYYFf39Ruww^$iMLwzr^G*s=q_xh`fXOPIz-JkvgwYn{yxGdA=e7TkE{*@Or-iPeI zo3}N}<~;lRqKuF`JeOM+G8`||InBgyy>#(>CWb(f$)-DQ=J;F|aW$;iKWXVlo5LcB z8VZuDR$Y5tdc8FE`rEHn^^#w1ye(^9teY&sCBfJJZ(E|o>8D0sQyyD9|NOJWYU95b z2lw-Ew;rn7&%aJ4@=(Bms=eQq1v2c2(c6BzH#Yuc#QN)7qt=G4E|dvh8@BhTJ#t`ozWCz%@6}gNZF{RBxy<8Q_JJC^=_;895$zR4dpO4d_jYYwkX@Vl-i96F5W{SE_KG~8v z`~1m^p%*w1QWF?b-mPBnVyavHgdL(zo8saWz4~?5DQ)qUVmtwqyZ3Hxfs0&w*|*D4 zT4>rnMX^5J@~pDu>hFfv zIcZ^g;R(2Dobk+Y^=1D{`0S6QOmx=Dy@cYroO=shenz~gcYOdc`N@OKjLkQ{Y%h)N z-#Xj3ZT*CP!3D3YKI9#{Ra^W?@qzf{W=)Q|b54cj_vgrP?3nGJ^md;;>o29Y)A2Fv Vmt3M()vad$0#8>zmvv4FO#sjK4FV}8(uM*{a!qhS+JDjO(=M4^iX9Rn9ano! zx8ar4{S*g7TU4R%0Yr3Y;(M{_fo3(vakQHixX5|<3WO)Hkx;etAeU9L*HNr8I+x5E z_rMuY$YVT>c$S3r-U+_aI))1+^dB0}k`O`PJmX>YHn9oe350LrIsyHBJoyD~Ty8WA zFQM=R!vB%EH9GMy4C`Q77R%TxuBU`}T9&a3JPFVS8^S>>MP5#Nv^6Y zO6h&NRaF5%9LHiwY>{$=A4=(LHWP-5qltAq9;HcoCX>5q<3&yA>;nqVxHjW!d$5HQ(4Rp-vYj?FWG2 zaEOSv+f5ZK2!DdnXhaA(91bQ91#|_pkmq@lBw-l(zF!nYBg)NY(+ES#5tBs3EXy>0 zw%hGwGMUfkbzQ4C5;*-HV2r(bh(C@833X0mxm=plT6mLl=O^HN0S^+AjvmxwRq^#` z?J{((qV>2&`=SZ?T%CsaE(!I0C)kzNb9keK{-%uYl4DQ5c)A?FKl{*TPsA&#O*7`xrh#I_aJriAX(GO?||W&uru4Q(s1CZUhC g9{>hUA7TLb0pGh^Q9paZiU0rr07*qoM6N<$g2Y