From 2386923e4a88b682215bd059eeee7d1b76fee5dc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?F=C3=A6rkeren?=
<164513459+Faerkeren@users.noreply.github.com>
Date: Mon, 25 May 2026 12:26:43 -0100
Subject: [PATCH 1/3] examples: add ClockController for ClockKey.dui
Adds an analog clock key controller that drives the hour_hand and
minute_hand transform bindings from the system clock via a 1Hz tick
task. Angles are written through set_range over a 0--360 degree
domain matching the manifest. The controller is not yet attached to
any screen.
---
examples/ClockKey.dui/layout.svg | 52 ++++++++++
examples/ClockKey.dui/manifest.yaml | 44 ++++++++
examples/streamdeck.py | 155 ++++++++++++++++++++++++++++
3 files changed, 251 insertions(+)
create mode 100644 examples/ClockKey.dui/layout.svg
create mode 100644 examples/ClockKey.dui/manifest.yaml
diff --git a/examples/ClockKey.dui/layout.svg b/examples/ClockKey.dui/layout.svg
new file mode 100644
index 0000000..ec3ae9a
--- /dev/null
+++ b/examples/ClockKey.dui/layout.svg
@@ -0,0 +1,52 @@
+
\ No newline at end of file
diff --git a/examples/ClockKey.dui/manifest.yaml b/examples/ClockKey.dui/manifest.yaml
new file mode 100644
index 0000000..8c400e5
--- /dev/null
+++ b/examples/ClockKey.dui/manifest.yaml
@@ -0,0 +1,44 @@
+name: ClockKey
+type: Key
+version: 1
+description: Analog Clock
+author: Graphras.com
+license: Apache-2.0
+category: utilities
+layout: layout.svg
+
+bindings:
+ minute_hand:
+ type: transform
+ node: minute_hand
+ default: 0
+ transforms:
+ - kind: rotate
+ from: 00
+ to: 360
+ origin: 0 0
+
+ hour_hand:
+ type: transform
+ node: hour_hand
+ default: 0
+ transforms:
+ - kind: rotate
+ from: 0
+ to: 360
+ origin: 0 0
+
+events:
+ - name: click
+ source: key_press_release
+ max_duration_ms: 300
+
+ - name: hold
+ source: key_hold
+ hold_ms: 350
+
+ - name: press
+ source: key_press
+
+ - name: release
+ source: key_release
diff --git a/examples/streamdeck.py b/examples/streamdeck.py
index d0a8f11..1e35e19 100644
--- a/examples/streamdeck.py
+++ b/examples/streamdeck.py
@@ -96,6 +96,7 @@
DeviceInfo,
DuiCard,
DuiKey,
+ KeyController,
Theme,
add_dui_path,
)
@@ -861,6 +862,160 @@ async def on_detach(self) -> None:
"""Stop the background drift simulator."""
await self._svc.stop()
+class ClockController(KeyController):
+ """Analog clock key -- ticking hour and minute hands driven by system time.
+
+ Loads ``ClockKey.dui`` and updates two transform bindings,
+ ``hour_hand`` and ``minute_hand``, with rotation angles in degrees
+ derived from the local system clock. Both bindings are declared in
+ the manifest as ``rotate`` transforms whose ``from``/``to`` span the
+ full ``0 -- 360`` degree range, so the controller writes confirmed
+ domain values (degrees) through :meth:`~deux.DuiKey.set_range` with
+ ``min_val=0`` and ``max_val=360``.
+
+ Angle calculation
+ ~~~~~~~~~~~~~~~~~
+ * **Minute hand** -- ``6 degrees per minute`` (``360 / 60``), with
+ sub-minute precision contributed by the seconds component
+ (``0.1 deg/s``). At 12 o'clock the angle is ``0``.
+ * **Hour hand** -- ``0.5 degrees per minute`` (``30 / 60``), i.e.
+ ``30 degrees per hour`` plus a smooth drift across the hour driven
+ by the minutes (and seconds). The hour value is taken modulo 12
+ so that 12:00 and 00:00 both render at ``0``.
+
+ Because this is a pure display (no user input changes the time), the
+ controller does not own a backend service. It runs a single
+ ``asyncio`` task that ticks once per second, recomputes the hand
+ angles, writes them to the key, and requests a refresh. The task is
+ started in :meth:`on_attach` and cancelled in :meth:`on_detach`, so
+ it is safe across reconnect cycles.
+
+ Notes
+ -----
+ The tick task only requests a refresh when at least one hand angle
+ actually changes since the last tick, avoiding redundant renders
+ while the second hand is between visible positions.
+ """
+
+ TICK_INTERVAL_S = 1.0
+ ANGLE_MIN = 0
+ ANGLE_MAX = 360
+ DEGREES_PER_MINUTE_MINUTE_HAND = 6.0
+ DEGREES_PER_MINUTE_HOUR_HAND = 0.5
+
+ def __init__(self) -> None:
+ self.key = DuiKey("ClockKey")
+ self._tick_task: asyncio.Task[None] | None = None
+ self._last_hour_angle: float | None = None
+ self._last_minute_angle: float | None = None
+
+ @classmethod
+ def compute_angles(cls, now: datetime.datetime) -> tuple[float, float]:
+ """Compute the (hour, minute) hand angles in degrees for *now*.
+
+ Both angles are normalised so that ``0`` corresponds to the
+ 12 o'clock position and values increase clockwise.
+
+ Parameters
+ ----------
+ now : datetime.datetime
+ The point in time to render. Only the ``hour``, ``minute``,
+ and ``second`` fields are used.
+
+ Returns
+ -------
+ tuple[float, float]
+ ``(hour_angle, minute_angle)`` both in the range
+ ``[0, 360)`` degrees.
+ """
+ total_minutes = now.minute + now.second / 60.0
+ minute_angle = (
+ total_minutes * cls.DEGREES_PER_MINUTE_MINUTE_HAND
+ ) % cls.ANGLE_MAX
+ hour_angle = (
+ (now.hour % 12) * 30.0
+ + total_minutes * cls.DEGREES_PER_MINUTE_HOUR_HAND
+ ) % cls.ANGLE_MAX
+ return hour_angle, minute_angle
+
+ def _apply_now(self, now: datetime.datetime) -> bool:
+ """Update the key bindings for time *now*.
+
+ Parameters
+ ----------
+ now : datetime.datetime
+ The time to render.
+
+ Returns
+ -------
+ bool
+ ``True`` if either hand angle changed since the previous
+ call, ``False`` otherwise.
+ """
+ hour_angle, minute_angle = self.compute_angles(now)
+ if (
+ hour_angle == self._last_hour_angle
+ and minute_angle == self._last_minute_angle
+ ):
+ return False
+ self.key.set_range(
+ "hour_hand",
+ hour_angle,
+ min_val=self.ANGLE_MIN,
+ max_val=self.ANGLE_MAX,
+ )
+ self.key.set_range(
+ "minute_hand",
+ minute_angle,
+ min_val=self.ANGLE_MIN,
+ max_val=self.ANGLE_MAX,
+ )
+ self._last_hour_angle = hour_angle
+ self._last_minute_angle = minute_angle
+ return True
+
+ async def on_attach(self, deck: Deck) -> None:
+ """Render the current time and start the per-second tick task.
+
+ Idempotent so reconnects do not stack background tasks.
+
+ Parameters
+ ----------
+ deck
+ The :class:`~deux.Deck` instance (unused -- the clock is
+ independent of deck state).
+ """
+ del deck
+ self._apply_now(datetime.datetime.now())
+ if self._tick_task is None or self._tick_task.done():
+ self._tick_task = asyncio.create_task(
+ self._tick_loop(), name="clock-tick"
+ )
+
+ async def on_detach(self) -> None:
+ """Cancel the tick task and unsubscribe key events."""
+ task = self._tick_task
+ self._tick_task = None
+ if task is not None and not task.done():
+ task.cancel()
+ with contextlib.suppress(asyncio.CancelledError):
+ await task
+ await super().on_detach()
+
+ async def _tick_loop(self) -> None:
+ """Drive hand updates once per second.
+
+ Only requests a refresh when a visible angle actually changes,
+ so the renderer is not woken up unnecessarily.
+ """
+ try:
+ while True:
+ await asyncio.sleep(self.TICK_INTERVAL_S)
+ if self._apply_now(datetime.datetime.now()):
+ await self.key.request_refresh()
+ except asyncio.CancelledError:
+ pass
+
class SceneController:
"""Scene-activation keys -- one :class:`DuiKey` per scene definition.
From d25857727b39e5d3b647d5f9c75abab79f694c65 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?F=C3=A6rkeren?=
<164513459+Faerkeren@users.noreply.github.com>
Date: Mon, 25 May 2026 14:10:31 -0100
Subject: [PATCH 2/3] examples: add second hand to ClockController and mount on
Main key 0
Updates ClockController to drive the new second_hand transform
binding at 6 deg/sec alongside the existing hour and minute hands,
sharing the same 0--360 degree set_range mapping.
Wires the clock onto key 0 of the Main screen, shifting favourites
and scenes onto the remaining keys. Adds a separate KeyController
lifecycle list on StreamDeckApp so the clock participates in the
uniform on_attach/on_detach flow across reconnects.
---
examples/ClockKey.dui/layout.svg | 10 ++-
examples/ClockKey.dui/manifest.yaml | 10 +++
examples/streamdeck.py | 102 +++++++++++++++++++++-------
3 files changed, 95 insertions(+), 27 deletions(-)
diff --git a/examples/ClockKey.dui/layout.svg b/examples/ClockKey.dui/layout.svg
index ec3ae9a..59609a2 100644
--- a/examples/ClockKey.dui/layout.svg
+++ b/examples/ClockKey.dui/layout.svg
@@ -38,13 +38,19 @@
-
+
-
+
+
+
+
+
+
+
diff --git a/examples/ClockKey.dui/manifest.yaml b/examples/ClockKey.dui/manifest.yaml
index 8c400e5..e1a5266 100644
--- a/examples/ClockKey.dui/manifest.yaml
+++ b/examples/ClockKey.dui/manifest.yaml
@@ -8,6 +8,16 @@ category: utilities
layout: layout.svg
bindings:
+ second_hand:
+ type: transform
+ node: second_hand
+ default: 0
+ transforms:
+ - kind: rotate
+ from: 00
+ to: 360
+ origin: 0 0
+
minute_hand:
type: transform
node: minute_hand
diff --git a/examples/streamdeck.py b/examples/streamdeck.py
index 1e35e19..d8f3354 100644
--- a/examples/streamdeck.py
+++ b/examples/streamdeck.py
@@ -863,18 +863,20 @@ async def on_detach(self) -> None:
await self._svc.stop()
class ClockController(KeyController):
- """Analog clock key -- ticking hour and minute hands driven by system time.
+ """Analog clock key -- ticking hour, minute, and second hands driven by system time.
- Loads ``ClockKey.dui`` and updates two transform bindings,
- ``hour_hand`` and ``minute_hand``, with rotation angles in degrees
- derived from the local system clock. Both bindings are declared in
- the manifest as ``rotate`` transforms whose ``from``/``to`` span the
- full ``0 -- 360`` degree range, so the controller writes confirmed
- domain values (degrees) through :meth:`~deux.DuiKey.set_range` with
- ``min_val=0`` and ``max_val=360``.
+ Loads ``ClockKey.dui`` and updates three transform bindings,
+ ``hour_hand``, ``minute_hand``, and ``second_hand``, with rotation
+ angles in degrees derived from the local system clock. All three
+ bindings are declared in the manifest as ``rotate`` transforms whose
+ ``from``/``to`` span the full ``0 -- 360`` degree range, so the
+ controller writes confirmed domain values (degrees) through
+ :meth:`~deux.DuiKey.set_range` with ``min_val=0`` and ``max_val=360``.
Angle calculation
~~~~~~~~~~~~~~~~~
+ * **Second hand** -- ``6 degrees per second`` (``360 / 60``). At
+ ``:00`` the angle is ``0`` (12 o'clock position).
* **Minute hand** -- ``6 degrees per minute`` (``360 / 60``), with
sub-minute precision contributed by the seconds component
(``0.1 deg/s``). At 12 o'clock the angle is ``0``.
@@ -893,13 +895,16 @@ class ClockController(KeyController):
Notes
-----
The tick task only requests a refresh when at least one hand angle
- actually changes since the last tick, avoiding redundant renders
- while the second hand is between visible positions.
+ actually changes since the last tick, avoiding redundant renders.
+ With a one-second tick the second hand changes every iteration, so
+ in practice a refresh is issued each tick while the controller is
+ attached.
"""
TICK_INTERVAL_S = 1.0
ANGLE_MIN = 0
ANGLE_MAX = 360
+ DEGREES_PER_SECOND_SECOND_HAND = 6.0
DEGREES_PER_MINUTE_MINUTE_HAND = 6.0
DEGREES_PER_MINUTE_HOUR_HAND = 0.5
@@ -908,12 +913,15 @@ def __init__(self) -> None:
self._tick_task: asyncio.Task[None] | None = None
self._last_hour_angle: float | None = None
self._last_minute_angle: float | None = None
+ self._last_second_angle: float | None = None
@classmethod
- def compute_angles(cls, now: datetime.datetime) -> tuple[float, float]:
- """Compute the (hour, minute) hand angles in degrees for *now*.
+ def compute_angles(
+ cls, now: datetime.datetime
+ ) -> tuple[float, float, float]:
+ """Compute the (hour, minute, second) hand angles in degrees for *now*.
- Both angles are normalised so that ``0`` corresponds to the
+ All angles are normalised so that ``0`` corresponds to the
12 o'clock position and values increase clockwise.
Parameters
@@ -924,11 +932,14 @@ def compute_angles(cls, now: datetime.datetime) -> tuple[float, float]:
Returns
-------
- tuple[float, float]
- ``(hour_angle, minute_angle)`` both in the range
- ``[0, 360)`` degrees.
+ tuple[float, float, float]
+ ``(hour_angle, minute_angle, second_angle)`` all in the
+ range ``[0, 360)`` degrees.
"""
total_minutes = now.minute + now.second / 60.0
+ second_angle = (
+ now.second * cls.DEGREES_PER_SECOND_SECOND_HAND
+ ) % cls.ANGLE_MAX
minute_angle = (
total_minutes * cls.DEGREES_PER_MINUTE_MINUTE_HAND
) % cls.ANGLE_MAX
@@ -936,7 +947,7 @@ def compute_angles(cls, now: datetime.datetime) -> tuple[float, float]:
(now.hour % 12) * 30.0
+ total_minutes * cls.DEGREES_PER_MINUTE_HOUR_HAND
) % cls.ANGLE_MAX
- return hour_angle, minute_angle
+ return hour_angle, minute_angle, second_angle
def _apply_now(self, now: datetime.datetime) -> bool:
"""Update the key bindings for time *now*.
@@ -949,13 +960,14 @@ def _apply_now(self, now: datetime.datetime) -> bool:
Returns
-------
bool
- ``True`` if either hand angle changed since the previous
- call, ``False`` otherwise.
+ ``True`` if any hand angle changed since the previous call,
+ ``False`` otherwise.
"""
- hour_angle, minute_angle = self.compute_angles(now)
+ hour_angle, minute_angle, second_angle = self.compute_angles(now)
if (
hour_angle == self._last_hour_angle
and minute_angle == self._last_minute_angle
+ and second_angle == self._last_second_angle
):
return False
self.key.set_range(
@@ -970,8 +982,15 @@ def _apply_now(self, now: datetime.datetime) -> bool:
min_val=self.ANGLE_MIN,
max_val=self.ANGLE_MAX,
)
+ self.key.set_range(
+ "second_hand",
+ second_angle,
+ min_val=self.ANGLE_MIN,
+ max_val=self.ANGLE_MAX,
+ )
self._last_hour_angle = hour_angle
self._last_minute_angle = minute_angle
+ self._last_second_angle = second_angle
return True
async def on_attach(self, deck: Deck) -> None:
@@ -1218,6 +1237,7 @@ def __init__(
self.lights = LightsController()
self.timer = TimerController()
self.gauge = GaugeController(simulate=False)
+ self.clock = ClockController()
self.dashboard = DashboardController()
self.favorites = FavoritesController(catalog, self.audio)
self.scenes = SceneController(scene_defs)
@@ -1235,6 +1255,12 @@ def __init__(
self.gauge,
self.dashboard,
]
+ # KeyController-derived objects follow the same lifecycle but
+ # are tracked separately because they expose ``key`` instead of
+ # ``card`` and the typed list above is constrained to cards.
+ self._key_controllers: list[KeyController] = [
+ self.clock,
+ ]
async def on_connect(self, deck: Deck) -> None:
"""Configure screens for *deck* and start (or resume) the demo.
@@ -1279,6 +1305,8 @@ async def _log_screen(name: str, screens: dict) -> None:
# Drive the uniform lifecycle on every controller.
for controller in self._controllers:
await controller.on_attach(deck)
+ for key_controller in self._key_controllers:
+ await key_controller.on_attach(deck)
self.nav.on_attach(deck)
self.scenes.set_deck(deck)
@@ -1302,11 +1330,17 @@ async def on_disconnect(self, info: DeviceInfo) -> None:
)
for controller in self._controllers:
await controller.on_detach()
+ for key_controller in self._key_controllers:
+ await key_controller.on_detach()
# -- screen construction -------------------------------------------
def _build_main_screen(self, deck: Deck) -> None:
- """Layout: favourites + scenes on keys, all four cards on the strip."""
+ """Layout: clock on key 0, favourites + scenes on the remaining keys.
+
+ Cards (audio, lights, gauge, dashboard) fill the touch strip
+ when the deck has one.
+ """
caps = deck.capabilities
screen = deck.screen("Main")
@@ -1316,13 +1350,31 @@ def _build_main_screen(self, deck: Deck) -> None:
screen.set_card(2, self.gauge.card)
screen.set_card(3, self.dashboard.card)
- num_favs = min(len(self.favorites.keys), caps.key_count)
- remaining = max(0, caps.key_count - num_favs)
+ # Reserve key 0 for the analog clock; lay favourites and scenes
+ # on the remaining keys in order.
+ clock_slot = 0
+ if caps.key_count > clock_slot:
+ screen.set_key(clock_slot, self.clock.key)
+ next_slot = clock_slot + 1
+ else:
+ next_slot = 0
+
+ remaining = max(0, caps.key_count - next_slot)
+ num_favs = min(len(self.favorites.keys), remaining)
+ remaining -= num_favs
num_scenes = min(len(self.scenes.keys), remaining)
- self.favorites.install(screen, list(range(num_favs)))
+ self.favorites.install(
+ screen, list(range(next_slot, next_slot + num_favs))
+ )
self.scenes.install(
- screen, list(range(num_favs, num_favs + num_scenes))
+ screen,
+ list(
+ range(
+ next_slot + num_favs,
+ next_slot + num_favs + num_scenes,
+ )
+ ),
)
def _build_settings_screen(self, deck: Deck) -> None:
From 6b624490f3ed284b2ae630b85c0a58ae07299f6e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?F=C3=A6rkeren?=
<164513459+Faerkeren@users.noreply.github.com>
Date: Mon, 25 May 2026 14:49:20 -0100
Subject: [PATCH 3/3] examples: log press/release/click/hold on ClockKey at
INFO
Adds passive INFO-level loggers for the four manifest input events on
the ClockKey so the wiring can be verified on a live device. The
handlers are observers only and do not mutate any binding.
---
examples/streamdeck.py | 28 ++++++++++++++++++++++++++++
1 file changed, 28 insertions(+)
diff --git a/examples/streamdeck.py b/examples/streamdeck.py
index d8f3354..8957b20 100644
--- a/examples/streamdeck.py
+++ b/examples/streamdeck.py
@@ -74,6 +74,7 @@
import contextlib
import datetime
import logging
+from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Any
@@ -915,6 +916,33 @@ def __init__(self) -> None:
self._last_minute_angle: float | None = None
self._last_second_angle: float | None = None
+ # Log every manifest input event at INFO so the clock key is
+ # easy to verify on a live device. These are pure observers --
+ # they do not mutate any binding.
+ for event_name in ("press", "release", "click", "hold"):
+ self.key.on(event_name)(self._log_event(event_name))
+
+ @staticmethod
+ def _log_event(name: str) -> Callable[[], Awaitable[None]]:
+ """Build an async handler that logs *name* at INFO when invoked.
+
+ Parameters
+ ----------
+ name : str
+ The DUI event name to embed in the log message.
+
+ Returns
+ -------
+ Callable[[], Awaitable[None]]
+ An async, zero-argument handler suitable for
+ :meth:`~deux.DuiKey.on`.
+ """
+
+ async def _handler() -> None:
+ log.info("ClockKey event: %s", name)
+
+ return _handler
+
@classmethod
def compute_angles(
cls, now: datetime.datetime