diff --git a/AGENTS.md b/AGENTS.md index a15a7de..0657c70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,3 +10,4 @@ - sayso's tests: `PYTHONPATH=lib python -m unittest discover -s tests/sayso` from the root. The offline tests need no credentials; `test_live.py` skips without a `sayso.local.json`. Never commit `sayso.local.json`, `tokens.json` or `lib/sayso/examples/write_examples.json`. - To test sayso without a network, replace `sayso.transport.requests` with a mock that has `get`/`post` (and `put`/`delete` for writes) before you build `SpotifyClient`. - Spotify's Dev Mode removed `GET /users/{id}` and the legacy playlist track endpoints ([PORTABILITY.md](lib/sayso/PORTABILITY.md)). Don't add `fetch_method` on `User`. +- tests/ has an `appkit` folder, which hides the real `appkit` when `tests/` is first on the path: put `lib/` before it. diff --git a/README.md b/README.md index 7bfe6d9..f50cfc1 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ pip install ./audiodsp numpy pip install --no-deps ./audioinstruments ./audioeffects/lib/audioeffects ``` -The libraries the apps share (`appkit`, `controlkit`, `sayso`, +The libraries the apps share (`appkit`, `controlkit`, `deskkit`, `sayso`, `songmodel`, `soundmodule`, `spectrum` and `touchkit`) also install as a package, under their own names. Each is imported directly, as in `import controlkit`: diff --git a/ROADMAP.md b/ROADMAP.md index 7c0ce3e..a181d8c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -35,6 +35,11 @@ It grows one part at a time, on the desktop interpreters first. - Stompbox (`apps/stompbox`) plays a guitar through one airwindows effect. A meter and clip light, a tuner, several slots and presets come next. +## Clock and Weather + +- More faces on the clock, and a way to pick a face that's saved. +- Weather searches by place name, for anywhere outside the US. + ## Libraries - sayso's own list is in [lib/sayso/ROADMAP.md](lib/sayso/ROADMAP.md). diff --git a/apps/clock/__init__.py b/apps/clock/__init__.py new file mode 100644 index 0000000..73495fc --- /dev/null +++ b/apps/clock/__init__.py @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""Clock: an analog clock that fills the screen, on the time at your chosen place.""" + +import lvgl as lv +from deskkit import forecast, localtime + +from .face import AnalogClock + +FACE = "Grand Classic" +_TICK_MS = 200 # the second hand is at most this late + + +def main(scope): + root = scope.root + root.remove_flag(lv.obj.FLAG.SCROLLABLE) + clock = AnalogClock(root, scope.size, localtime.clock_time, FACE) + + # A clock showing a wrong time looks right, so say so until it's been set. + waiting = lv.label(root) + waiting.set_text("Setting the time") + waiting.set_style_text_color(lv.color_hex(clock.skin["second"]), 0) + waiting.set_style_text_font(lv.font_montserrat_16, 0) + waiting.align(lv.ALIGN.CENTER, 0, 70) + + def tick(_timer=None): + clock.update_time() + if localtime.is_set(): + waiting.add_flag(lv.obj.FLAG.HIDDEN) + else: + waiting.remove_flag(lv.obj.FLAG.HIDDEN) + + scope.every(_TICK_MS, tick) + tick() + # Here all day: the forecast's reply is what keeps the offset and the clock right. + forecast.keep_fresh(scope) diff --git a/apps/clock/app.json b/apps/clock/app.json new file mode 100644 index 0000000..54eec55 --- /dev/null +++ b/apps/clock/app.json @@ -0,0 +1 @@ +{"name": "clock", "title": "Clock", "category": "Desk", "symbol": "BELL", "color": "#8A651E"} diff --git a/apps/clock/face.py b/apps/clock/face.py new file mode 100644 index 0000000..aa4bfa7 --- /dev/null +++ b/apps/clock/face.py @@ -0,0 +1,616 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""The clock's face: a responsive, skinnable analog clock in LVGL. + +Add a face with ``AnalogClock.register_skin(name, mapping)``, or a new entry in +`BUILTIN_SKINS`, then name it in the app's ``FACE``. +""" + +import math + +import lvgl as lv + +_CIRCLE = getattr(lv, "RADIUS_CIRCLE", 0x7FFF) + + +BUILTIN_SKINS = { + "Grand Classic": { + "bezel_shape": "round", + "face_shape": "round", + "bezel": 0xC8AA68, + "bezel_hi": 0xFFF0B0, + "bezel_lo": 0x5B421E, + "face": 0xF4E8C8, + "face_grad": 0xCDBB91, + "marks": 0x241A10, + "hands": 0x17100B, + "second": 0x9E1D18, + "accent": 0x8A651E, + "hand_style": "dauphine", + "numerals": "roman", + "brand": "CHRONOMETER", + }, + "Midnight Diver": { + "bezel_shape": "round", + "face_shape": "round", + "bezel": 0x17202A, + "bezel_hi": 0x6B7784, + "bezel_lo": 0x05080B, + "face": 0x07121A, + "face_grad": 0x102C3A, + "marks": 0xD9F5EF, + "hands": 0xF4FFF8, + "second": 0xFF6B24, + "accent": 0x42D6B5, + "hand_style": "diver", + "numerals": "quarters", + "brand": "200 m AUTOMATIC", + }, + "Swiss Rail": { + "bezel_shape": "round", + "face_shape": "round", + "bezel": 0xD7D9D8, + "bezel_hi": 0xFFFFFF, + "bezel_lo": 0x5C6162, + "face": 0xF2F2EC, + "face_grad": 0xD6D8D3, + "marks": 0x111111, + "hands": 0x151515, + "second": 0xE32322, + "accent": 0xE32322, + "hand_style": "railway", + "numerals": "none", + "brand": "HELVETICA", + }, + "Aviator": { + "bezel_shape": "round", + "face_shape": "round", + "bezel": 0x4A4740, + "bezel_hi": 0xA49B87, + "bezel_lo": 0x0C0C0B, + "face": 0x171914, + "face_grad": 0x303427, + "marks": 0xE7E1C2, + "hands": 0xF5EED0, + "second": 0xD47C2B, + "accent": 0xC99A50, + "hand_style": "sword", + "numerals": "arabic", + "brand": "FLIEGER", + }, + "Deco Salon": { + "bezel_shape": "rounded", + "face_shape": "round", + "bezel_radius": 0.13, + "bezel_aspect": 0.86, + "bezel": 0xC7A65A, + "bezel_hi": 0xF8E4A5, + "bezel_lo": 0x443315, + "face": 0x182723, + "face_grad": 0x31554B, + "marks": 0xE9D49B, + "hands": 0xF8E9B8, + "second": 0xD36B42, + "accent": 0xE7C568, + "hand_style": "skeleton", + "numerals": "roman", + "brand": "ART DECO", + }, + "Tank": { + "bezel_shape": "rect", + "face_shape": "rect", + "bezel_aspect": 0.78, + "bezel": 0xBDA76E, + "bezel_hi": 0xF1E3B5, + "bezel_lo": 0x4C4027, + "face": 0xE8DFC5, + "face_grad": 0xBEB397, + "marks": 0x252118, + "hands": 0x245478, + "second": 0x245478, + "accent": 0x725D29, + "hand_style": "breguet", + "numerals": "roman", + "brand": "PARIS", + }, + "Metro Square": { + "bezel_shape": "rounded", + "face_shape": "rounded", + "bezel_radius": 0.18, + "face_radius": 0.14, + "bezel_aspect": 1.0, + "bezel": 0x31343B, + "bezel_hi": 0x8F96A3, + "bezel_lo": 0x08090B, + "face": 0xDDE3E6, + "face_grad": 0xA9B5BB, + "marks": 0x172129, + "hands": 0x15212A, + "second": 0x0077D9, + "accent": 0x0077D9, + "hand_style": "baton", + "numerals": "quarters", + "brand": "METRO", + }, + "Rose Nocturne": { + "bezel_shape": "round", + "face_shape": "round", + "bezel": 0x8C5D55, + "bezel_hi": 0xE3B4A4, + "bezel_lo": 0x321C1A, + "face": 0x160F19, + "face_grad": 0x38233C, + "marks": 0xE8C9C1, + "hands": 0xF0D8CF, + "second": 0xD889A8, + "accent": 0xCF8F82, + "hand_style": "leaf", + "numerals": "none", + "brand": "NOCTURNE", + }, +} + +BUILTIN_SKIN_ORDER = ( + "Grand Classic", + "Midnight Diver", + "Swiss Rail", + "Aviator", + "Deco Salon", + "Tank", + "Metro Square", + "Rose Nocturne", +) + + +def _color(value): + return lv.color_hex(int(value)) + + +def _shape_radius(shape, radius, short_side): + if shape == "round": + return _CIRCLE + if shape == "rect": + return 0 + return max(2, int(short_side * float(radius))) + + +def _font_for(size): + for points in (48, 40, 36, 32, 28, 24, 22, 20, 18, 16, 14): + if points <= size: + font = getattr(lv, "font_montserrat_%d" % points, None) + if font is not None: + return font, points + font = getattr(lv, "font_montserrat_14", None) + return (font, 14) if font is not None else (None, 0) + + +def _set_scaled_font(label, target_size): + """Use the nearest compiled font and scale it to the requested visual size.""" + font, points = _font_for(max(1, int(target_size))) + if font is None: + return + label.set_style_text_font(font, 0) + scale = max(128, min(640, round(256 * target_size / points))) + if scale != 256: + try: + label.set_style_transform_scale(scale, 0) + except AttributeError: + label.set_style_transform_scale_x(scale, 0) + label.set_style_transform_scale_y(scale, 0) + + +def _plain(obj): + obj.set_style_pad_all(0, 0) + obj.set_style_border_width(0, 0) + obj.set_style_outline_width(0, 0) + obj.set_style_bg_opa(lv.OPA.TRANSP, 0) + try: + obj.remove_flag(lv.obj.FLAG.CLICKABLE) + obj.remove_flag(lv.obj.FLAG.SCROLLABLE) + except AttributeError: + pass + + +class GlassShimmer: + """Subtle specular layers drawn above the dial to suggest curved glass.""" + + def __init__(self, parent, width, height, radius, light, dark): + self.wash = lv.obj(parent) + self.wash.set_size(max(1, width - 6), max(1, height * 43 // 100)) + self.wash.align(lv.ALIGN.TOP_MID, 0, 3) + self.wash.set_style_radius(radius, 0) + self.wash.set_style_border_width(0, 0) + self.wash.set_style_bg_color(_color(light), 0) + self.wash.set_style_bg_grad_color(_color(dark), 0) + self.wash.set_style_bg_grad_dir(lv.GRAD_DIR.VER, 0) + self.wash.set_style_bg_opa(32, 0) + self.wash.set_style_pad_all(0, 0) + + self.glint = lv.arc(parent) + glint_size = max(24, min(width, height) * 88 // 100) + self.glint.set_size(glint_size, glint_size) + self.glint.center() + self.glint.set_bg_angles(205, 302) + self.glint.set_angles(205, 302) + self.glint.set_style_arc_width(max(2, glint_size // 70), lv.PART.MAIN) + self.glint.set_style_arc_color(_color(light), lv.PART.MAIN) + self.glint.set_style_arc_opa(72, lv.PART.MAIN) + self.glint.set_style_arc_width(max(2, glint_size // 70), lv.PART.INDICATOR) + self.glint.set_style_arc_color(_color(light), lv.PART.INDICATOR) + self.glint.set_style_arc_opa(72, lv.PART.INDICATOR) + self.glint.set_style_bg_opa(lv.OPA.TRANSP, lv.PART.KNOB) + self.glint.set_style_pad_all(0, 0) + try: + self.glint.remove_flag(lv.obj.FLAG.CLICKABLE) + self.glint.remove_flag(lv.obj.FLAG.SCROLLABLE) + self.wash.remove_flag(lv.obj.FLAG.CLICKABLE) + self.wash.remove_flag(lv.obj.FLAG.SCROLLABLE) + except AttributeError: + pass + + +class AnalogClock: + """Responsive analog clock widget with a validated, extensible skin registry.""" + + skins = {} + _skin_order = [] + + @classmethod + def register_skin(cls, name, mapping): + skin = dict(mapping) + bezel_shape = skin.get("bezel_shape", "round") + face_shape = skin.get("face_shape", bezel_shape) + shapes = ("round", "rect", "rounded") + if bezel_shape not in shapes or face_shape not in shapes: + raise ValueError("shapes must be round, rect, or rounded") + if bezel_shape == "round" and face_shape != "round": + raise ValueError("a round bezel can contain only a round face") + required = ( + "bezel", + "bezel_hi", + "bezel_lo", + "face", + "face_grad", + "marks", + "hands", + "second", + "accent", + ) + missing = [key for key in required if key not in skin] + if missing: + raise ValueError("skin is missing: " + ", ".join(missing)) + skin["bezel_shape"] = bezel_shape + skin["face_shape"] = face_shape + name = str(name) + if name not in cls.skins: + cls._skin_order.append(name) + cls.skins[name] = skin + + @classmethod + def register_skins(cls, mappings): + for name, skin in mappings.items(): + cls.register_skin(name, skin) + + @classmethod + def skin_names(cls): + return tuple(cls._skin_order) + + def __init__(self, parent, size, read_time, skin="Grand Classic"): + self.parent = parent + self._size = size + self._read_time = read_time + self._shown_second = None + self._skin_name = skin if isinstance(skin, str) else "External" + self.skin = self._resolve_skin(skin) + self._build() + + def _resolve_skin(self, skin): + if isinstance(skin, str): + if skin not in self.skins: + raise KeyError("unknown clock skin: " + skin) + return dict(self.skins[skin]) + temporary = "__external__" + self.register_skin(temporary, skin) + result = dict(self.skins.pop(temporary)) + self._skin_order.remove(temporary) + return result + + def _available_size(self): + width, height = self._size + margin = max(8, min(width, height) // 30) + return width, height, margin + + def _build(self): + width, height, margin = self._available_size() + self.parent.set_style_bg_color(_color(0x000000), 0) + self.parent.set_style_bg_opa(lv.OPA.COVER, 0) + self.parent.set_style_pad_all(0, 0) + try: + self.parent.remove_flag(lv.obj.FLAG.SCROLLABLE) + except AttributeError: + pass + + aspect = float(self.skin.get("bezel_aspect", 1.0)) + available_w = width - 2 * margin + available_h = height - 2 * margin + bezel_w = min(available_w, int(available_h * aspect)) + bezel_h = min(available_h, int(available_w / aspect)) + short = min(bezel_w, bezel_h) + + self.shadow = lv.obj(self.parent) + self.shadow.set_size(bezel_w, bezel_h) + self.shadow.center() + self.shadow.set_style_translate_y(max(3, short // 45), 0) + self.shadow.set_style_radius( + _shape_radius( + self.skin["bezel_shape"], + self.skin.get("bezel_radius", 0.12), + short, + ), + 0, + ) + self.shadow.set_style_bg_color(_color(0x000000), 0) + self.shadow.set_style_bg_opa(210, 0) + self.shadow.set_style_shadow_color(_color(self.skin["bezel_hi"]), 0) + self.shadow.set_style_shadow_width(max(8, short // 16), 0) + self.shadow.set_style_shadow_opa(70, 0) + self.shadow.set_style_border_width(0, 0) + + self.bezel = lv.obj(self.parent) + self.bezel.set_size(bezel_w, bezel_h) + self.bezel.center() + bezel_radius = _shape_radius( + self.skin["bezel_shape"], + self.skin.get("bezel_radius", 0.12), + short, + ) + self.bezel.set_style_radius(bezel_radius, 0) + self.bezel.set_style_bg_color(_color(self.skin["bezel_hi"]), 0) + self.bezel.set_style_bg_grad_color(_color(self.skin["bezel_lo"]), 0) + self.bezel.set_style_bg_grad_dir(lv.GRAD_DIR.VER, 0) + self.bezel.set_style_bg_opa(lv.OPA.COVER, 0) + self.bezel.set_style_border_color(_color(self.skin["bezel"]), 0) + self.bezel.set_style_border_width(max(2, short // 45), 0) + self.bezel.set_style_pad_all(max(7, short // 19), 0) + try: + self.bezel.remove_flag(lv.obj.FLAG.SCROLLABLE) + except AttributeError: + pass + + face_w = bezel_w - 2 * max(8, short // 12) + face_h = bezel_h - 2 * max(8, short // 12) + self.face = lv.obj(self.bezel) + self.face.set_size(face_w, face_h) + self.face.center() + face_radius = _shape_radius( + self.skin["face_shape"], + self.skin.get("face_radius", 0.10), + min(face_w, face_h), + ) + self.face.set_style_radius(face_radius, 0) + self.face.set_style_bg_color(_color(self.skin["face"]), 0) + self.face.set_style_bg_grad_color(_color(self.skin["face_grad"]), 0) + self.face.set_style_bg_grad_dir(lv.GRAD_DIR.VER, 0) + self.face.set_style_bg_opa(lv.OPA.COVER, 0) + self.face.set_style_border_color(_color(self.skin["accent"]), 0) + self.face.set_style_border_width(max(1, short // 95), 0) + self.face.set_style_pad_all(0, 0) + try: + self.face.remove_flag(lv.obj.FLAG.SCROLLABLE) + except AttributeError: + pass + + dial = min(face_w, face_h) + self.scale = lv.scale(self.face) + self.scale.set_size(dial, dial) + self.scale.center() + _plain(self.scale) + self.scale.set_mode(lv.scale.MODE.ROUND_INNER) + self.scale.set_range(0, 720) + self.scale.set_angle_range(360) + self.scale.set_rotation(270) + self.scale.set_total_tick_count(61) + self.scale.set_major_tick_every(5) + self.scale.set_label_show(False) + + minor = lv.style_t() + minor.init() + minor.set_line_color(_color(self.skin["marks"])) + minor.set_line_width(max(1, dial // 180)) + minor.set_length(max(3, dial // 34)) + self.scale.add_style(minor, lv.PART.ITEMS) + + major = lv.style_t() + major.init() + major.set_line_color(_color(self.skin["marks"])) + major.set_line_width(max(2, dial // 100)) + major.set_length(max(7, dial // 17)) + major.set_line_rounded(True) + self.scale.add_style(major, lv.PART.INDICATOR) + self._styles = [minor, major] + + self._add_numerals(dial) + self._add_brand(dial) + self._make_hands(dial) + self._make_hub(dial) + self.glass = GlassShimmer( + self.face, + face_w, + face_h, + face_radius, + 0xFFFFFF, + self.skin["face_grad"], + ) + self.update_time() + + def _add_numerals(self, dial): + mode = self.skin.get("numerals", "none") + if mode == "none": + return + roman = ("XII", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X", "XI") + arabic = ("12", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11") + values = roman if mode == "roman" else arabic + radius = dial * (0.365 if mode != "quarters" else 0.36) + for hour in range(12): + if mode == "quarters" and hour not in (0, 3, 6, 9): + continue + label = lv.label(self.scale) + label.set_text(values[hour]) + label.set_style_text_color(_color(self.skin["marks"]), 0) + _set_scaled_font(label, max(14, dial // 13)) + label.set_style_pad_all(0, 0) + angle = math.radians(hour * 30 - 90) + x = int(math.cos(angle) * radius) + y = int(math.sin(angle) * radius) + label.align(lv.ALIGN.CENTER, x, y) + + def _add_brand(self, dial): + text = self.skin.get("brand") + if not text: + return + label = lv.label(self.face) + label.set_text(text) + label.set_style_text_color(_color(self.skin["accent"]), 0) + _set_scaled_font(label, max(12, dial // 20)) + label.align(lv.ALIGN.CENTER, 0, -dial // 7) + # Printed on the dial: behind the scale and its hands (children of the + # scale, which this label would otherwise follow and cover). + label.move_background() + + def _line(self, color, width): + line = lv.line(self.scale) + style = lv.style_t() + style.init() + style.set_line_color(_color(color)) + style.set_line_width(width) + style.set_line_rounded(True) + line.add_style(style, 0) + self._styles.append(style) + return line + + def _make_hands(self, dial): + style = self.skin.get("hand_style", "baton") + hour_length = dial * 27 // 100 + minute_length = dial * 39 // 100 + second_length = dial * 43 // 100 + hour_w = max(4, dial // 34) + minute_w = max(3, dial // 42) + second_w = max(1, dial // 115) + hands = self.skin["hands"] + accent = self.skin["accent"] + face = self.skin["face"] + second = self.skin["second"] + self.hand_layers = {"hour": [], "minute": [], "second": []} + + def add(which, color, width, length): + self.hand_layers[which].append((self._line(color, max(1, width)), max(2, length))) + + if style == "dauphine": + # Wide lower facets and fine full-length tips suggest tapered hands. + for which, length, width in ( + ("hour", hour_length, hour_w), + ("minute", minute_length, minute_w), + ): + add(which, 0x090705, width + 4, length + 2) + add(which, hands, width + 1, length * 82 // 100) + add(which, accent, max(1, width // 3), length) + elif style == "diver": + # Heavy black outlines around luminous centers stay legible underwater. + for which, length, width in ( + ("hour", hour_length, hour_w + 3), + ("minute", minute_length, minute_w + 2), + ): + add(which, 0x010304, width + 5, length + 2) + add(which, hands, width, length) + add(which, accent, max(1, width // 4), length * 84 // 100) + elif style == "railway": + # Restrained black batons and the iconic high-contrast red seconds hand. + add("hour", hands, hour_w + 1, hour_length) + add("minute", hands, minute_w, minute_length) + elif style == "sword": + # Dark edging, a broad pale blade, and a fine warm center ridge. + for which, length, width in ( + ("hour", hour_length, hour_w + 1), + ("minute", minute_length, minute_w + 1), + ): + add(which, 0x030403, width + 5, length + 2) + add(which, hands, width + 1, length) + add(which, accent, max(1, width // 4), length * 90 // 100) + elif style == "skeleton": + # A face-colored channel cuts through a bright outlined framework. + for which, length, width in ( + ("hour", hour_length, hour_w + 1), + ("minute", minute_length, minute_w + 1), + ): + add(which, accent, width + 5, length) + add(which, face, width, length * 91 // 100) + add(which, hands, max(1, width // 3), length) + elif style == "breguet": + # Fine heat-blued tips over a shorter, broader traditional body. + for which, length, width in ( + ("hour", hour_length, hour_w), + ("minute", minute_length, minute_w), + ): + add(which, 0x101820, width + 3, length + 1) + add(which, hands, width, length) + add(which, self.skin["face_grad"], max(1, width // 3), length * 68 // 100) + elif style == "leaf": + # Layered short and long strokes produce a soft leaf-like silhouette. + for which, length, width in ( + ("hour", hour_length, hour_w), + ("minute", minute_length, minute_w), + ): + add(which, 0x080509, width + 4, length + 1) + add(which, hands, width + 2, length * 76 // 100) + add(which, hands, max(1, width // 2), length) + else: # geometric baton + for which, length, width in ( + ("hour", hour_length, hour_w + 2), + ("minute", minute_length, minute_w + 1), + ): + add(which, 0x05080A, width + 4, length + 2) + add(which, hands, width, length) + + if style in ("diver", "sword", "skeleton", "baton"): + add("second", 0x050505, second_w + 2, second_length + 1) + add("second", second, second_w, second_length) + + def _make_hub(self, dial): + self.hub = lv.obj(self.face) + hub_size = max(10, dial // 17) + self.hub.set_size(hub_size, hub_size) + self.hub.center() + self.hub.set_style_radius(_CIRCLE, 0) + self.hub.set_style_bg_color(_color(self.skin["bezel_hi"]), 0) + self.hub.set_style_bg_grad_color(_color(self.skin["bezel_lo"]), 0) + self.hub.set_style_bg_grad_dir(lv.GRAD_DIR.VER, 0) + self.hub.set_style_bg_opa(lv.OPA.COVER, 0) + self.hub.set_style_border_color(_color(self.skin["accent"]), 0) + self.hub.set_style_border_width(max(1, hub_size // 8), 0) + self.hub.set_style_pad_all(0, 0) + try: + self.hub.remove_flag(lv.obj.FLAG.CLICKABLE) + self.hub.remove_flag(lv.obj.FLAG.SCROLLABLE) + except AttributeError: + pass + + def update_time(self): + hour, minute, second = self._read_time() + if second == self._shown_second: + return + self._shown_second = second + second_value = second * 12 + minute_value = minute * 12 + second // 5 + hour_value = (hour % 12) * 60 + minute + for which, value in ( + ("hour", hour_value), + ("minute", minute_value), + ("second", second_value), + ): + for hand, length in self.hand_layers[which]: + self.scale.set_line_needle_value(hand, length, value) + + +for _skin_name in BUILTIN_SKIN_ORDER: + AnalogClock.register_skin(_skin_name, BUILTIN_SKINS[_skin_name]) diff --git a/apps/weather/__init__.py b/apps/weather/__init__.py new file mode 100644 index 0000000..8563039 --- /dev/null +++ b/apps/weather/__init__.py @@ -0,0 +1,195 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""Weather: conditions and five days ahead, laid out to fit a round screen. + +Swipe up for the five days and down to come back. Hold your finger on the +screen to change the ZIP code or the units. +""" + +import lvgl as lv +from deskkit import forecast + +from . import icons +from .zippad import ZipPad + +_BG_DAY = 0x123A5E +_BG_NIGHT = 0x0A1020 +_WHITE = 0xFFFFFF +_SOFT = 0xB9C6D4 +_RAIN = 0x57B6FF +_ICON = 130 +_SMALL_ICON = 44 +_BIG_TEMP_SCALE = 512 # the largest compiled font, doubled + + +def _clean(obj): + obj.remove_flag(lv.obj.FLAG.SCROLLABLE) + obj.set_style_pad_all(0, 0) + obj.set_style_border_width(0, 0) + + +def _label(parent, font, color=_WHITE): + label = lv.label(parent) + label.set_style_text_font(font, 0) + label.set_style_text_color(lv.color_hex(color), 0) + label.remove_flag(lv.obj.FLAG.CLICKABLE) + return label + + +class _Screen: + def __init__(self, scope): + self.scope = scope + self.root = scope.root + self.w, self.h = scope.size + self.pad = None + self.showing_days = False + _clean(self.root) + self.root.add_event_cb(self._gesture, lv.EVENT.GESTURE, None) + self.root.add_event_cb(self._held, lv.EVENT.LONG_PRESSED, None) + self.now = None + self.days = None + self.notice = _label(self.root, lv.font_montserrat_24, _SOFT) + self.notice.set_style_text_align(lv.TEXT_ALIGN.CENTER, 0) + self.notice.set_width(self.w * 7 // 10) + self.notice.set_long_mode(lv.label.LONG_MODE.WRAP) + self.notice.center() + + def _panel(self, background): + panel = lv.obj(self.root) + _clean(panel) + panel.set_size(self.w, self.h) + panel.set_style_radius(0, 0) + panel.set_style_bg_color(lv.color_hex(background), 0) + panel.remove_flag(lv.obj.FLAG.CLICKABLE) + return panel + + def _drop(self, attr): + panel = getattr(self, attr) + if panel is not None: + panel.delete() + setattr(self, attr, None) + + def show(self): + """Draw what's known. Rebuilt whole: it changes twice an hour at most.""" + self._drop("now") + self._drop("days") + place = forecast.place() + data = forecast.latest + if self.pad is not None: + return + if place is None: + self.ask_for_zip() + return + if data is None: + self.notice.remove_flag(lv.obj.FLAG.HIDDEN) + if forecast.last_error is None: + self.notice.set_text("Getting the weather") + else: + self.notice.set_text("Can't reach the weather service. Trying again soon.") + return + self.notice.add_flag(lv.obj.FLAG.HIDDEN) + background = _BG_DAY if data["is_day"] else _BG_NIGHT + self.root.set_style_bg_color(lv.color_hex(background), 0) + self._build_now(place, data, background) + self._build_days(data, background) + self._apply_page() + + def _build_now(self, place, data, background): + panel = self.now = self._panel(background) + name = _label(panel, lv.font_montserrat_16, _SOFT) + name.set_text(place["name"]) + name.align(lv.ALIGN.TOP_MID, 0, self.h * 8 // 100) + + icon = icons.make(panel, data["code"], data["is_day"], _ICON, background) + icon.align(lv.ALIGN.TOP_MID, 0, self.h * 13 // 100) + + temp = _label(panel, lv.font_montserrat_40) + temp.set_text("%d°" % forecast.degrees(data["temp"])) + temp.set_style_transform_scale(_BIG_TEMP_SCALE, 0) + temp.update_layout() + temp.set_style_transform_pivot_x(temp.get_width() // 2, 0) + temp.set_style_transform_pivot_y(temp.get_height() // 2, 0) + temp.align(lv.ALIGN.CENTER, 0, self.h * 8 // 100) + + words = _label(panel, lv.font_montserrat_24) + words.set_text(icons.name(data["code"], data["is_day"])) + words.align(lv.ALIGN.CENTER, 0, self.h * 22 // 100) + + range_ = _label(panel, lv.font_montserrat_16, _SOFT) + range_.set_text( + "H %d° L %d° Feels %d°" + % ( + forecast.degrees(data["hi"]), + forecast.degrees(data["lo"]), + forecast.degrees(data["feels"]), + ) + ) + range_.align(lv.ALIGN.CENTER, 0, self.h * 30 // 100) + + if forecast.last_error is not None: + old = _label(panel, lv.font_montserrat_14, _SOFT) + old.set_text("as of %s" % data["as_of"]) + old.align(lv.ALIGN.CENTER, 0, self.h * 37 // 100) + + def _build_days(self, data, background): + panel = self.days = self._panel(background) + row_h = self.h * 15 // 100 + top = (self.h - row_h * len(data["days"])) // 2 + for i, (day, code, hi, lo, rain) in enumerate(data["days"]): + y = top + i * row_h + cy = y + row_h // 2 - self.h // 2 + name = _label(panel, lv.font_montserrat_24) + name.set_text("Today" if i == 0 else day) + name.align(lv.ALIGN.LEFT_MID, self.w * 13 // 100, cy) + + icon = icons.make(panel, code, True, _SMALL_ICON, background) + icon.align(lv.ALIGN.CENTER, -self.w * 8 // 100, cy) + + temps = _label(panel, lv.font_montserrat_24) + temps.set_text("%d° / %d°" % (forecast.degrees(hi), forecast.degrees(lo))) + temps.align(lv.ALIGN.RIGHT_MID, -self.w * 16 // 100, cy - 9) + + chance = _label(panel, lv.font_montserrat_14, _RAIN) + chance.set_text("%d%% rain" % rain) + chance.align(lv.ALIGN.RIGHT_MID, -self.w * 16 // 100, cy + 14) + + def _apply_page(self): + shown, hidden = (self.days, self.now) if self.showing_days else (self.now, self.days) + if shown is not None: + shown.remove_flag(lv.obj.FLAG.HIDDEN) + if hidden is not None: + hidden.add_flag(lv.obj.FLAG.HIDDEN) + + def _gesture(self, _event): + direction = lv.indev_active().get_gesture_dir() + if direction == lv.DIR.TOP: + self.showing_days = True + elif direction == lv.DIR.BOTTOM: + self.showing_days = False + else: + return + lv.indev_active().wait_release() # or a slow swipe would also count as a hold + self._apply_page() + + def _held(self, _event): + if self.pad is None: + self.ask_for_zip() + + def ask_for_zip(self): + self.notice.add_flag(lv.obj.FLAG.HIDDEN) + self._drop("now") + self._drop("days") + self.pad = ZipPad(self.scope, self.w, self.h, self._zip_done) + + def _zip_done(self): + self.pad.delete() + self.pad = None + self.show() + + +def main(scope): + screen = _Screen(scope) + forecast.place() # loads it, and with it the timezone + screen.show() + forecast.keep_fresh(scope, screen.show) diff --git a/apps/weather/app.json b/apps/weather/app.json new file mode 100644 index 0000000..48f245a --- /dev/null +++ b/apps/weather/app.json @@ -0,0 +1 @@ +{"name": "weather", "title": "Weather", "category": "Desk", "symbol": "EYE_OPEN", "color": "#2F6EA5"} diff --git a/apps/weather/icons.py b/apps/weather/icons.py new file mode 100644 index 0000000..77712b4 --- /dev/null +++ b/apps/weather/icons.py @@ -0,0 +1,165 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""Weather icons built from LVGL objects, so they scale to any size with no image files.""" + +import lvgl as lv + +_SUN = 0xFFC533 +_MOON = 0xDCE6F2 +_CLOUD = 0xD5DEE8 +_DARK_CLOUD = 0x8896A6 +_RAIN = 0x57B6FF +_SNOW = 0xFFFFFF +_BOLT = 0xFFE14D + +CLEAR, PARTLY, CLOUDY, FOG, DRIZZLE, RAIN, SNOW, STORM = range(8) + +NAMES = { + CLEAR: "Clear", + PARTLY: "Partly cloudy", + CLOUDY: "Cloudy", + FOG: "Fog", + DRIZZLE: "Drizzle", + RAIN: "Rain", + SNOW: "Snow", + STORM: "Thunderstorm", +} + + +def kind(code): + """Return the icon kind for a WMO weather code, the codes Open-Meteo sends.""" + if code <= 1: + return CLEAR + if code == 2: + return PARTLY + if code == 3: + return CLOUDY + if code in (45, 48): + return FOG + if 51 <= code <= 57: + return DRIZZLE + if 61 <= code <= 67 or 80 <= code <= 82: + return RAIN + if 71 <= code <= 77 or code in (85, 86): + return SNOW + if code >= 95: + return STORM + return CLOUDY + + +def name(code, is_day): + """Return the words for a WMO weather code.""" + which = kind(code) + if which == CLEAR and is_day: + return "Sunny" + return NAMES[which] + + +def _disc(parent, x, y, d, color): + o = lv.obj(parent) + o.remove_flag(lv.obj.FLAG.CLICKABLE) + o.remove_flag(lv.obj.FLAG.SCROLLABLE) + o.set_style_pad_all(0, 0) + o.set_style_border_width(0, 0) + o.set_style_bg_color(lv.color_hex(color), 0) + o.set_style_radius(lv.RADIUS_CIRCLE, 0) + o.set_pos(x, y) + o.set_size(d, d) + return o + + +def _bar(parent, x, y, w, h, color): + o = _disc(parent, x, y, w, color) + o.set_size(w, h) + return o + + +def _sun(parent, x, y, d): + _disc(parent, x, y, d, _SUN) + + +def _moon(parent, x, y, d, background): + _disc(parent, x, y, d, _MOON) + _disc(parent, x + d * 3 // 10, y - d // 10, d * 8 // 10, background) + + +def _cloud(parent, x, y, w, color=_CLOUD): + u = w // 10 + _bar(parent, x, y + 3 * u, w, 3 * u, color) + _disc(parent, x + u, y + u, 4 * u, color) + _disc(parent, x + 4 * u, y, 5 * u, color) + + +def _streaks(parent, x, y, w, n, color, long): + step = w // n + for i in range(n): + _bar(parent, x + step // 4 + i * step, y, max(2, w // 16), long, color) + + +def _bolt(parent, x, y, u): + bolt = lv.line(parent) + bolt.set_points( + [ + {"x": x + u, "y": y}, + {"x": x - u // 2, "y": y + u * 2}, + {"x": x + u // 2, "y": y + u * 2}, + {"x": x - u, "y": y + u * 4}, + ], + 4, + ) + bolt.set_style_line_width(max(2, u // 2), 0) + bolt.set_style_line_color(lv.color_hex(_BOLT), 0) + bolt.set_style_line_rounded(True, 0) + + +def make(parent, code, is_day, size, background): + """Draw the icon for *code* in a new transparent box; return the box. + + Args: + parent (lv.obj): What to draw it on. + code (int): The WMO weather code. + is_day (bool): False swaps the sun for the moon. + size (int): The box's width and height in pixels. + background (int): The colour behind it, which cuts the moon's crescent. + """ + box = lv.obj(parent) + box.remove_flag(lv.obj.FLAG.CLICKABLE) + box.remove_flag(lv.obj.FLAG.SCROLLABLE) + box.set_style_pad_all(0, 0) + box.set_style_border_width(0, 0) + box.set_style_bg_opa(lv.OPA.TRANSP, 0) + box.set_size(size, size) + + which = kind(code) + u = size // 10 + if which == CLEAR: + if is_day: + _sun(box, u * 2, u * 2, u * 6) + else: + _moon(box, u * 2, u * 2, u * 6, background) + elif which == PARTLY: + if is_day: + _sun(box, u * 4, u, u * 5) + else: + _moon(box, u * 4, u, u * 5, background) + _cloud(box, 0, u * 3, u * 8) + elif which == CLOUDY: + _cloud(box, u, u * 2, u * 8) + elif which == FOG: + for i in range(4): + _bar(box, u * (1 + i % 2), u * (2 + i * 2), u * 7, u, _CLOUD) + elif which == DRIZZLE: + _cloud(box, u, u, u * 8) + _streaks(box, u * 2, u * 7, u * 6, 3, _RAIN, u) + elif which == RAIN: + _cloud(box, u, u, u * 8, _DARK_CLOUD) + _streaks(box, u * 2, u * 6, u * 6, 3, _RAIN, u * 3) + elif which == SNOW: + _cloud(box, u, u, u * 8) + for x, y in ((2, 7), (4, 8), (6, 7)): + _disc(box, u * x, u * y, u, _SNOW) + else: + _cloud(box, u, u, u * 8, _DARK_CLOUD) + _bolt(box, u * 5, u * 6, u) + return box diff --git a/apps/weather/zippad.py b/apps/weather/zippad.py new file mode 100644 index 0000000..c524089 --- /dev/null +++ b/apps/weather/zippad.py @@ -0,0 +1,138 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""A keypad for a ZIP code, sized so every key sits inside a round screen.""" + +import lvgl as lv +from deskkit import forecast + +_LENGTH = 5 +_BG = 0x0A1020 +_KEY = 0x24344A +_GO = 0x2F7D4F +_WHITE = 0xFFFFFF +_SOFT = 0xB9C6D4 +_BAD = 0xFF8A7A +_GRID = (("1", "2", "3"), ("4", "5", "6"), ("7", "8", "9"), ("del", "0", "ok")) + + +class ZipPad: + """Ask for a ZIP code, look it up, and call *done* with the place saved. + + Args: + scope (AppScope): The app it belongs to. + width (int): The screen's width. + height (int): The screen's height. + done (callable): Called with no arguments once there's a place, or when + you back out of changing one you already have. + """ + + def __init__(self, scope, width, height, done): + self.scope = scope + self.done = done + self.digits = "" + self.busy = False + + self.panel = panel = lv.obj(scope.root) + panel.remove_flag(lv.obj.FLAG.SCROLLABLE) + panel.set_size(width, height) + panel.set_style_pad_all(0, 0) + panel.set_style_border_width(0, 0) + panel.set_style_radius(0, 0) + panel.set_style_bg_color(lv.color_hex(_BG), 0) + + key_w = width * 20 // 100 + key_h = height * 13 // 100 + gap = height * 2 // 100 + grid_top = height * 29 // 100 + + self.title = lv.label(panel) + self.title.set_style_text_font(lv.font_montserrat_16, 0) + self.title.set_style_text_color(lv.color_hex(_SOFT), 0) + self.title.align(lv.ALIGN.TOP_MID, 0, height * 8 // 100) + + self.shown = lv.label(panel) + self.shown.set_style_text_font(lv.font_montserrat_40, 0) + self.shown.set_style_text_color(lv.color_hex(_WHITE), 0) + self.shown.align(lv.ALIGN.TOP_MID, 0, height * 13 // 100) + + place = forecast.place() + if place is not None: + self.units = lv.button(panel) + self.units.set_size(width * 16 // 100, height * 8 // 100) + self.units.align(lv.ALIGN.TOP_RIGHT, -width * 14 // 100, height * 14 // 100) + self.units.set_style_bg_color(lv.color_hex(_KEY), 0) + self.units.add_event_cb(self._toggle_units, lv.EVENT.CLICKED, None) + self.units_label = lv.label(self.units) + self.units_label.set_style_text_font(lv.font_montserrat_24, 0) + self.units_label.set_text("°" + place.get("units", "F")) + self.units_label.center() + + for row, keys in enumerate(_GRID): + for col, key in enumerate(keys): + b = lv.button(panel) + b.set_size(key_w, key_h) + b.set_pos( + (width - 3 * key_w - 2 * gap) // 2 + col * (key_w + gap), + grid_top + row * (key_h + gap), + ) + color = _GO if key == "ok" else _KEY + b.set_style_bg_color(lv.color_hex(color), 0) + label = lv.label(b) + label.set_style_text_font(lv.font_montserrat_24, 0) + if key == "del": + label.set_text(lv.SYMBOL.BACKSPACE) + elif key == "ok": + label.set_text(lv.SYMBOL.OK) + else: + label.set_text(key) + label.center() + b.add_event_cb(self._maker(key), lv.EVENT.CLICKED, None) + self._refresh("Enter your ZIP code") + + def delete(self): + self.panel.delete() + + def _maker(self, key): + return lambda _event: self._press(key) + + def _refresh(self, title=None, color=_SOFT): + if title is not None: + self.title.set_text(title) + self.title.set_style_text_color(lv.color_hex(color), 0) + self.shown.set_text(self.digits + "-" * (_LENGTH - len(self.digits))) + + def _press(self, key): + if self.busy: + return + if key == "del": + if self.digits: + self.digits = self.digits[:-1] + self._refresh("Enter your ZIP code") + elif forecast.place() is not None: + self.done() # nothing typed and a place to go back to + elif key == "ok": + if len(self.digits) == _LENGTH: + self.busy = True + self._refresh("Looking it up") + self.scope.worker.submit(lambda: forecast.choose(self.digits), self._found, key="zip") + elif len(self.digits) < _LENGTH: + self.digits += key + self._refresh() + + def _found(self, _place, error): + self.busy = False + if error is None: + self.done() + return + if isinstance(error, ValueError): + self._refresh("No place has that ZIP code", _BAD) + else: + self._refresh("Can't reach the service. Try again.", _BAD) + self.digits = "" + self._refresh() + + def _toggle_units(self, _event): + units = "C" if forecast.place().get("units", "F") == "F" else "F" + forecast.set_units(units) + self.units_label.set_text("°" + units) diff --git a/docs/guides/put-a-clock-on-your-desk.md b/docs/guides/put-a-clock-on-your-desk.md new file mode 100644 index 0000000..aad8e70 --- /dev/null +++ b/docs/guides/put-a-clock-on-your-desk.md @@ -0,0 +1,73 @@ +# Put a clock and the weather on your desk + +You can turn a round touch board into a desk clock that also shows the weather. +It needs no account or key, only Wi-Fi. This was built on a LILYGO T-RGB 2.1" +round board with MicroPython and LVGL. Any LVGL touch board should work, but +the layouts were only checked at 480x480. + +## Put it on the board + +From a clone of this repository, with the board on USB as `PORT`: + +```bash +for d in /lib/appkit /lib/deskkit /apps /apps/clock /apps/weather; do mpftp mkdir -d PORT $d; done +for f in lib/appkit/*.py; do mpftp put -d PORT $f /lib/appkit/$(basename $f); done +for f in lib/deskkit/*.py; do mpftp put -d PORT $f /lib/deskkit/$(basename $f); done +for f in apps/clock/* apps/weather/*; do mpftp put -d PORT $f /$f; done +touch empty.py && mpftp put -d PORT empty.py /apps/__init__.py +``` + +Then make this the board's `main.py`: + +```python +import deskkit + +deskkit.run() +``` + +The firmware needs `lvgl`, `requests` and the `pydevices` package, which has +`wifi_manager`. + +## The first time it starts + +1. **Wi-Fi.** The screen shows a QR code. Join the board's network with your + phone, then pick your own network and type its password. The board remembers + it. The [Wi-Fi guide](https://github.com/PyDevices/pydevices/blob/main/docs/guides/wifi.md) + has the details. It needs a network with a plain password: one with a + sign-in page or a company login won't work. +2. **ZIP code.** Type your US ZIP code on the keypad and press the check mark. + This also sets the clock's timezone. + +## Using it + +Swipe left or right to move between the clock and the weather. After a minute +without a touch, the weather goes back to the clock. + +On the weather, swipe up for the next five days and down to come back. Hold your +finger on the screen to change the ZIP code or switch between °F and °C. To back +out without changing the ZIP code, press the delete key with nothing typed. + +The forecast updates every 30 minutes. When the board can't reach the service, +it keeps the last forecast and shows "as of" with its time. + +## Choose when the screen is on + +By default the screen is lit from 7:00 until 18:00, Monday to Friday. A touch +wakes it for a minute at other times. To change that, pass the hours in +`main.py`: + +```python +deskkit.run(awake=(8, 22), weekends=True) # 8:00 until 22:00, every day +deskkit.run(awake=None) # always on +``` + +## Change the clock's face + +Set `FACE` in [`apps/clock/__init__.py`](../../apps/clock/__init__.py) to any +name in `BUILTIN_SKINS` in [`apps/clock/face.py`](../../apps/clock/face.py), or +add a new entry there. + +## Weather data + +The weather comes from [Open-Meteo](https://open-meteo.com), which is free for +non-commercial use. A commercial product needs one of its paid plans. diff --git a/docs/index.md b/docs/index.md index 53f010c..4cc2208 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,8 +5,9 @@ libraries they use, then [render a MIDI file](get-started/first-program.md) through the sound module. **Guides.** [Play the sound module from a DAW](guides/play-the-sound-module.md), -[run Ostinato](guides/run-ostinato.md), and -[put Vamp on a board](guides/put-vamp-on-a-board.md). +[run Ostinato](guides/run-ostinato.md), +[put Vamp on a board](guides/put-vamp-on-a-board.md), and +[put a clock and the weather on your desk](guides/put-a-clock-on-your-desk.md). **Reference.** [The apps, and what each does](reference/apps.md). [What an appkit app must provide](reference/app-requirements.md). [The sound module's MIDI](reference/sound-module-midi.md): banks, controllers, System Exclusive, and the `soundmodule.run` options. Vamp's diff --git a/docs/reference/apps.md b/docs/reference/apps.md index 5f9ab70..602bf31 100644 --- a/docs/reference/apps.md +++ b/docs/reference/apps.md @@ -7,6 +7,7 @@ What each app here does. Apps that run on a board are also listed in the |---|---| | [Synth Station](../../apps/synthstation) | Pick an audioinstruments instrument and play it from a MIDI keyboard, with a front panel for its knobs and patches. | | [Spotify](../../apps/spotify) | The board is a Spotify Connect speaker, and the screen is its remote. | +| [Clock](../../apps/clock), [Weather](../../apps/weather) | An analog clock and a five-day forecast for a round screen on a desk. [Set one up](../guides/put-a-clock-on-your-desk.md). | | [Roku remote](../../apps/roku) | A TV remote over the LAN: d-pad, OK, Back, playback, volume and power. | | [midiEngine](../../apps/midiengine) | A General MIDI sound module: 16 parts from a SoundFont, played from a USB MIDI keyboard. | | [MP3 player](../../apps/mp3) | The songs on an SD card, with play, previous, next, volume and a spectrum. | diff --git a/lib/deskkit/__init__.py b/lib/deskkit/__init__.py new file mode 100644 index 0000000..947775d --- /dev/null +++ b/lib/deskkit/__init__.py @@ -0,0 +1,72 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""deskkit: put a clock and the weather on a screen that sits on a desk. + +``main.py`` is two lines:: + + import deskkit + deskkit.run() + +It joins Wi-Fi with `wifi_manager`, sets the clock and starts the first app. +A swipe left or right moves between the apps, and anything but the first goes +back to it after a minute without a touch. The screen goes dark outside the +hours you pass. +""" + +APPS = ("clock", "weather") + +launcher = None # the running one, to reach from the REPL + + +def _say(lv, text): + # Joining can take a minute on a weak link, and a dark screen looks broken. + scr = lv.screen_active() + scr.set_style_bg_color(lv.color_hex(0), 0) + label = lv.label(scr) + label.set_style_text_color(lv.color_hex(0xFFFFFF), 0) + label.set_style_text_font(lv.font_montserrat_24, 0) + label.set_text(text) + label.center() + lv.refr_now(None) + return label + + +def run(*, hostname="deskclock", apps=APPS, awake=(7, 18), weekends=False, idle_ms=60_000): + """Join Wi-Fi, set the clock and run *apps* until the program ends. + + Args: + hostname (str): The name the board answers to on the network. + apps (tuple): The apps to swipe between, first one the home screen. + Each is a folder under ``apps/``. + awake (tuple): The hours the screen stays lit, as ``(from, until)``, + or None to leave it on. + weekends (bool): True to keep those hours on Saturday and Sunday too. + idle_ms (int): How long without a touch before going back to the first + app, and before a screen woken at night goes dark again. + """ + # Here rather than at the top, so an app can import deskkit.forecast without + # opening the display. + import lvgl as lv + import wifi_manager + from appkit import Launcher, serve + from display_driver import app + + from . import forecast, localtime + from .shell import DeskShell + + global launcher + label = _say(lv, "Joining Wi-Fi") + wifi_manager.connect(hostname=hostname) + label.set_text("Setting the time") + lv.refr_now(None) + localtime.sync() + label.delete() + + launcher = Launcher(app) + for name in apps: + launcher.add(name, "apps." + name) + launcher.shell = DeskShell(launcher, apps, awake=awake, weekends=weekends, idle_ms=idle_ms) + # With no place yet there's no timezone either, so the place comes first. + launcher.boot(apps[0] if forecast.place() else "weather") + serve(app) diff --git a/lib/deskkit/forecast.py b/lib/deskkit/forecast.py new file mode 100644 index 0000000..7e9a253 --- /dev/null +++ b/lib/deskkit/forecast.py @@ -0,0 +1,226 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""deskkit.forecast: the place you've chosen and its weather, from Open-Meteo. + +Open-Meteo needs no account or key, and is free for non-commercial use. Its +reply also carries the place's UTC offset, which is how the clock follows the +timezone and daylight saving. + +The forecast lives here rather than in an app, so it stays while apps open and +close. It's kept in RAM: writing flash every half hour would wear it for +something a boot gets back in seconds. Only the place is saved. +""" + +import json +import time + +import requests +import wifi_manager +from appkit import data + +from . import localtime + +try: + import network +except ImportError: # a desktop + network = None + +MAX_AGE_S = 30 * 60 +RETRY_S = 5 * 60 + +_GEO_URL = ( + "https://geocoding-api.open-meteo.com/v1/search" + "?name=%s&count=1&language=en&format=json&countryCode=US" +) +_URL = ( + "https://api.open-meteo.com/v1/forecast?latitude=%s&longitude=%s" + "¤t=temperature_2m,apparent_temperature,weather_code,is_day" + "&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max" + "&temperature_unit=fahrenheit&timezone=auto&forecast_days=5" +) +_DAYS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") + +latest = None # what parse() made from the last good reply +last_error = None +_tried_at = None +_got_at = None +_place = None + + +def _place_file(): + return data.join(data.app_dir("deskkit"), "place.json") + + +def place(): + """Return the saved place as a dict, or None before one is chosen.""" + global _place + if _place is None: + try: + with open(_place_file()) as f: + _place = json.load(f) + except (OSError, ValueError): + return None + localtime.offset_s = _place.get("utc_offset") + return _place + + +def _save(): + with open(_place_file(), "w") as f: + json.dump(_place, f) + + +def choose(zip_code): + """Look up *zip_code* and make it the place. It blocks: call it from a worker. + + Args: + zip_code (str): A US ZIP code. + + Returns: + dict: The place. + + Raises: + ValueError: Open-Meteo knows no place with that code. + """ + global _place, latest, _got_at + resp = requests.get(_GEO_URL % zip_code, timeout=15) + try: + results = resp.json().get("results") + finally: + resp.close() + if not results: + raise ValueError("no place for " + zip_code) + found = results[0] + units = _place.get("units", "F") if _place else "F" + _place = { + "zip": zip_code, + "name": found["name"], + "lat": found["latitude"], + "lon": found["longitude"], + "units": units, + } + _save() + latest = None + _got_at = None + fetch() + return _place + + +def set_units(units): + """Show temperatures in *units*, ``"F"`` or ``"C"``, from now on.""" + if _place is not None: + _place["units"] = units + _save() + + +def degrees(fahrenheit): + """Return a Fahrenheit reading as whole degrees in the chosen units.""" + if _place is not None and _place.get("units") == "C": + return round((fahrenheit - 32) * 5 / 9) + return round(fahrenheit) + + +def _weekday(date): + y, m, d = date.split("-") + return _DAYS[time.gmtime(time.mktime((int(y), int(m), int(d), 12, 0, 0, 0, 0, 0)))[6]] + + +def parse(reply): + """Turn an Open-Meteo reply into the plain dict the apps show. + + Args: + reply (dict): The decoded JSON. + + Returns: + dict: ``temp``, ``feels``, ``code``, ``is_day``, ``hi``, ``lo``, ``as_of`` + (``"HH:MM"`` at the place), ``utc_offset`` and ``days``, a list of + ``(weekday, code, hi, lo, chance_of_rain)``. + """ + now = reply["current"] + daily = reply["daily"] + days = [] + for i, date in enumerate(daily["time"]): + days.append( + ( + _weekday(date), + daily["weather_code"][i], + daily["temperature_2m_max"][i], + daily["temperature_2m_min"][i], + daily["precipitation_probability_max"][i], + ) + ) + return { + "temp": now["temperature_2m"], + "feels": now["apparent_temperature"], + "code": now["weather_code"], + "is_day": bool(now["is_day"]), + "hi": daily["temperature_2m_max"][0], + "lo": daily["temperature_2m_min"][0], + "as_of": now["time"].split("T")[1], + "utc_offset": reply["utc_offset_seconds"], + "days": days, + } + + +def _rejoin(): + if network is not None and not network.WLAN(network.STA_IF).isconnected(): + wifi_manager.connect(setup=False) + + +def fetch(): + """Get the forecast now, and keep the clock right. It blocks: call it from a worker. + + A failure keeps the last forecast, so the app can say how old it is. + + Returns: + bool: True when the forecast is new. + """ + global latest, last_error, _tried_at, _got_at + _tried_at = time.time() + try: + _rejoin() + localtime.resync_if_due() + resp = requests.get(_URL % (_place["lat"], _place["lon"]), timeout=15) + try: + latest = parse(resp.json()) + finally: + resp.close() + except Exception as exc: + last_error = exc + print("deskkit: no forecast: %r" % (exc,)) + return False + last_error = None + _got_at = time.time() + localtime.offset_s = latest["utc_offset"] + if _place.get("utc_offset") != latest["utc_offset"]: + _place["utc_offset"] = latest["utc_offset"] + _save() # rare: the first time, and at each daylight saving change + return True + + +def is_stale(): + """Return True when a fetch is due: the data is old and no try was recent.""" + if _got_at is not None and time.time() - _got_at < MAX_AGE_S: + return False + return _tried_at is None or time.time() - _tried_at >= RETRY_S + + +def keep_fresh(scope, on_update=None): + """Fetch whenever the forecast goes stale, for as long as *scope* is open. + + Args: + scope (AppScope): The app that wants it. + on_update (callable): Called with no arguments on the main thread after + each fetch, good or bad, while the app is open. + """ + + def done(_value, _error): + if on_update is not None: + on_update() + + def check(_timer=None): + if place() is not None and is_stale(): + scope.worker.submit(fetch, done, key="forecast") + + scope.every(60_000, check) + check() diff --git a/lib/deskkit/localtime.py b/lib/deskkit/localtime.py new file mode 100644 index 0000000..24e6705 --- /dev/null +++ b/lib/deskkit/localtime.py @@ -0,0 +1,116 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""deskkit.localtime: keep the board's clock right, and read it in local time. + +A board with no battery-backed clock forgets the time when it loses power, so +it asks the network. It tries NTP first. Guest and office networks often block +NTP, so a web server's ``Date`` header is the fallback. +""" + +import time + +try: + import machine + import ntptime +except ImportError: # a desktop: the host keeps the time + machine = None + ntptime = None + +try: + import requests +except ImportError: + requests = None + +_FIRST_VALID_YEAR = 2025 +_MONTHS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec") +_DATE_URL = "https://api.open-meteo.com/" +_RESYNC_S = 24 * 3600 + +offset_s = None # seconds east of UTC at the chosen place; forecast sets it +_synced_at = None + + +def is_set(): + """Return True once the clock holds a real date.""" + return time.gmtime()[0] >= _FIRST_VALID_YEAR + + +def parse_http_date(text): + """Return ``(year, month, day, hour, minute, second)`` from a ``Date`` header. + + Args: + text (str): A date such as ``Wed, 04 Nov 2026 02:36:50 GMT``. + + Raises: + ValueError: The text isn't in that format. + """ + parts = text.split() + if len(parts) != 6 or parts[2] not in _MONTHS: + raise ValueError("bad date: %s" % text) + hour, minute, second = parts[4].split(":") + return ( + int(parts[3]), + _MONTHS.index(parts[2]) + 1, + int(parts[1]), + int(hour), + int(minute), + int(second), + ) + + +def _set_from_web(): + resp = requests.get(_DATE_URL, timeout=10) + try: + for name, value in resp.headers.items(): + if name.lower() == "date": + break + else: + raise ValueError("no Date header") + finally: + resp.close() + y, mo, d, h, mi, s = parse_http_date(value) + weekday = time.gmtime(time.mktime((y, mo, d, 12, 0, 0, 0, 0, 0)))[6] + machine.RTC().datetime((y, mo, d, weekday, h, mi, s, 0)) + + +def sync(): + """Set the clock from the network. It blocks: call it from a worker. + + Returns: + bool: True when the clock was set, or the host keeps its own time. + """ + global _synced_at + if machine is None: + return True + try: + ntptime.timeout = 3 + ntptime.settime() + except Exception as exc: + print("deskkit: NTP failed (%r), asking a web server" % (exc,)) + try: + _set_from_web() + except Exception as exc2: + print("deskkit: could not set the clock: %r" % (exc2,)) + return False + _synced_at = time.time() + return True + + +def resync_if_due(): + """Set the clock when it's unset or a day has passed. Call it from a worker.""" + if not is_set() or _synced_at is None or time.time() - _synced_at >= _RESYNC_S: + sync() + + +def now(): + """Return the local time as a ``time.gmtime``-style tuple.""" + if offset_s is None: + return time.localtime() + return time.gmtime(time.time() + offset_s) + + +def clock_time(): + """Return the local ``(hour, minute, second)``.""" + t = now() + return t[3], t[4], t[5] diff --git a/lib/deskkit/shell.py b/lib/deskkit/shell.py new file mode 100644 index 0000000..48baa11 --- /dev/null +++ b/lib/deskkit/shell.py @@ -0,0 +1,113 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""deskkit.shell: swipe between apps, drift back to the first, and sleep at night.""" + +import board_config +import lvgl as lv +from appkit import Shell + +from . import localtime + +_CHECK_MS = 1000 +_SWIPE_PX = 60 +_HOLD_MS = 1000 # long enough that resting a finger isn't a hold + + +def is_awake(hour, weekday, awake, weekends): + """Return True when the screen belongs on at this time. + + Args: + hour (int): The local hour. + weekday (int): 0 for Monday through 6 for Sunday. + awake (tuple): ``(from, until)`` hours, or None for always. + weekends (bool): True to keep those hours on Saturday and Sunday. + """ + if awake is None: + return True + if weekday >= 5 and not weekends: + return False + return awake[0] <= hour < awake[1] + + +def tune_touch(indev): + """Make a one-finger swipe easy on *indev*. + + `display_driver` asks for 255 px, so that a two-finger swipe can't steal a + pinch, and these apps have no pinch. A touch chip also repeats a position + between reads, which LVGL takes for a stopped finger and restarts its count, + so the minimum speed goes too. Otherwise a swipe has to cross over half of a + 480 px screen with no repeat at all. + """ + indev.set_gesture_min_distance(_SWIPE_PX) + indev.set_gesture_min_velocity(0) + indev.set_long_press_time(_HOLD_MS) + + +class DeskShell(Shell): + """Give each app the whole screen, with no buttons around it. + + Args: + launcher (Launcher): The launcher it serves. + apps (tuple): The apps to swipe between; the first is the home screen. + awake (tuple): The hours the screen is lit, or None for always. + weekends (bool): True to keep those hours on Saturday and Sunday. + idle_ms (int): How long a touch keeps the screen awake, and how long + an app other than the first stays up without one. + """ + + def __init__(self, launcher, apps, *, awake, weekends, idle_ms): + super().__init__(launcher) + self.apps = apps + self.awake = awake + self.weekends = weekends + self.idle_ms = idle_ms + self._lit = True + device = getattr(getattr(board_config, "display_drv", None), "touch_device", None) + if device is not None: + tune_touch(device.user_data) + self._timer = lv.timer_create(self._check, _CHECK_MS, None) + + def make_root(self, scope): + root = super().make_root(scope) + root.add_event_cb(self._gesture, lv.EVENT.GESTURE, None) + return root + + def _gesture(self, _event): + direction = lv.indev_active().get_gesture_dir() + if direction == lv.DIR.LEFT: + step = 1 + elif direction == lv.DIR.RIGHT: + step = -1 + else: + return # up and down are the app's + lv.indev_active().wait_release() + if localtime.offset_s is None: + return # still asking where it is: the clock has no timezone yet + apps = self.apps + current = self.launcher.current + if current in apps: + self.launcher.request(apps[(apps.index(current) + step) % len(apps)]) + + def _set_lit(self, lit): + if lit == self._lit: + return + self._lit = lit + backlight = getattr(board_config, "backlight", None) + if backlight is not None: + backlight.value(1 if lit else 0) + else: + board_config.display_drv.brightness = 1.0 if lit else 0.0 + + def _check(self, _timer): + idle = lv.display_get_default().get_inactive_time() + current = self.launcher.current + if idle >= self.idle_ms and localtime.offset_s is not None: + if current != self.apps[0] and current in self.apps: + self.launcher.request(self.apps[0]) + # Without the real time and place, a dark screen could be dark for no reason. + if not localtime.is_set() or localtime.offset_s is None: + self._set_lit(True) + return + t = localtime.now() + self._set_lit(is_awake(t[3], t[6], self.awake, self.weekends) or idle < self.idle_ms) diff --git a/pyproject.toml b/pyproject.toml index 1a35937..a46c5a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "omnipython-applibs" dynamic = ["version"] -description = "The libraries apps share: appkit, controlkit, sayso, songmodel, soundmodule, spectrum and touchkit" +description = "The libraries apps share: appkit, controlkit, deskkit, sayso, songmodel, soundmodule, spectrum and touchkit" readme = "README.md" authors = [{ name = "Brad Barnett" }] license = { text = "MIT" } diff --git a/tests/desk_contract.py b/tests/desk_contract.py new file mode 100644 index 0000000..cf705f6 --- /dev/null +++ b/tests/desk_contract.py @@ -0,0 +1,196 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""DeskShell on real LVGL with no window: a finger swipes, and the hours pass. + +``DESK_PLANT`` swaps in a fault that a check below must catch; test_deskkit.py +runs each one. +""" + +import os +import sys + +_HERE = __file__.rsplit("/", 1)[0] +# lib first: tests/appkit would hide the real appkit. +sys.path[:0] = [_HERE + "/../lib", _HERE] +_PYDEVICES = os.getenv("PYDEVICES_LIB") or _HERE + "/../../pydevices/lib" +sys.path.append(_PYDEVICES) + + +class _Pin: + state = 1 + + def value(self, v): + _Pin.state = v + + +class _Board: + backlight = _Pin() + + +class _Stub: + pass + + +sys.modules["board_config"] = _Board +for _name in ("requests", "wifi_manager"): + sys.modules[_name] = _Stub() + +import lvgl as lv + +import _headless + +SIZE = 480 +_headless.display(SIZE, SIZE) +_headless.pointer() + +from deskkit import localtime +from deskkit.shell import DeskShell, tune_touch + +PLANT = os.getenv("DESK_PLANT") + + +class _Launcher: + home = None + current = "clock" + + def __init__(self): + self.asked = [] + + def request(self, name): + self.asked.append(name) + self.current = name + + +class _Scope: + def screen(self): + scr = lv.obj(None) + lv.screen_load(scr) + return scr + + +# The real panel's indev: display_driver asks for 255 px. +_headless.pointer().set_gesture_min_distance(255) +if PLANT != "untuned_touch": + tune_touch(_headless.pointer()) + + +def check(ok, what): + if not ok: + print("FAILED:", what) + sys.exit(1) + + +localtime.offset_s = 0 +launcher = _Launcher() +shell = DeskShell(launcher, ("clock", "weather"), awake=(7, 18), weekends=False, idle_ms=60_000) +root = shell.make_root(_Scope()) +lv.refr_now(None) + +if PLANT == "swapped_swipes": + _real = launcher.request + launcher.request = lambda name: _real("clock" if name == "weather" else "weather") +if PLANT == "no_idle_return": + shell.idle_ms = 10**9 + + +def swipe(x0, y0, x1, y1): + _headless.touch(x0, y0, False) + _headless.touch(x0, y0, True) + steps = 6 + for i in range(1, steps + 1): + _headless.touch(x0 + (x1 - x0) * i // steps, y0 + (y1 - y0) * i // steps, True) + _headless.touch(x1, y1, False) + _headless.advance(50) + + +# Slow swipes recorded from a CST820 on a real panel, repeated positions and all. +# Neither reached 255 px between repeats. +_RECORDED = ( + (479, 479, 479, 478, 476, 473, 468, 459, 447, 433, 417, 401, 386, 371, 371, 355, 336, 317, 299, 282, 265, 251, 237, 222), + (447, 444, 439, 433, 425, 414, 403, 389, 374, 358, 358, 341, 322, 304, 285, 268, 254, 242, 232, 222, 212, 203, 193, 183, 172, 172, 161, 150, 140), +) + + +def replay(xs): + _headless.touch(xs[0], 250, True, 40) + for x in xs: + _headless.touch(x, 250, True, 40) + _headless.touch(xs[-1], 250, False, 40) + _headless.advance(50) + + +for _xs in _RECORDED: + launcher.current = "clock" + del launcher.asked[:] + replay(_xs) + check(launcher.asked == ["weather"], "a recorded slow swipe: %r" % (launcher.asked,)) +launcher.current = "clock" +del launcher.asked[:] + +swipe(380, 240, 80, 240) +check(launcher.asked == ["weather"], "a swipe left from the clock asks for weather: %r" % (launcher.asked,)) +del launcher.asked[:] +swipe(80, 240, 380, 240) +check(launcher.asked == ["clock"], "a swipe right from weather asks for the clock: %r" % (launcher.asked,)) +del launcher.asked[:] +swipe(240, 380, 240, 80) +check(not launcher.asked, "a swipe up belongs to the app: %r" % (launcher.asked,)) + +launcher.current = "weather" +# A minute with no touch goes home. +_headless.advance(61_000) +_headless.advance(1_100) +check(launcher.asked == ["clock"], "idle returns to the first app: %r" % (launcher.asked,)) + +# Noon on a Wednesday, then midnight, then a Saturday noon, all a minute idle. +_noon_wed = (2026, 11, 4, 12, 0, 0, 2, 308) +_midnight = (2026, 11, 4, 0, 0, 0, 2, 308) +_noon_sat = (2026, 11, 7, 12, 0, 0, 5, 311) +localtime.is_set = lambda: True +localtime.offset_s = 0 +for moment, lit in ((_noon_wed, 1), (_midnight, 0), (_noon_sat, 0), (_noon_wed, 1)): + localtime.now = lambda: moment + _headless.advance(1_100) + if PLANT == "never_dark": + shell._set_lit(True) + check(_Pin.state == lit, "the backlight at %r should be %d" % (moment[:6], lit)) + +# A touch lights a dark screen. +localtime.now = lambda: _midnight +_headless.advance(1_100) +check(_Pin.state == 0, "dark at midnight") +_headless.touch(240, 240, True, 100) +_headless.touch(240, 240, False, 100) +_headless.advance(1_100) +check(_Pin.state == 1, "a tap wakes it") +_headless.advance(61_000) +_headless.advance(1_100) +check(_Pin.state == 0, "and it goes dark again") + +# Without the real time, never dark. +localtime.is_set = lambda: False +_headless.advance(1_100) +check(_Pin.state == 1, "an unset clock keeps the screen on") + +# Asking for the ZIP code, swipes and idle both leave it alone. +localtime.is_set = lambda: True +localtime.offset_s = None +launcher.current = "weather" +del launcher.asked[:] +swipe(80, 240, 380, 240) +_headless.advance(61_000) +_headless.advance(1_100) +check(not launcher.asked, "no leaving the ZIP code screen: %r" % (launcher.asked,)) +localtime.offset_s = 0 + +# Nor before the place, and so the timezone, is known. +localtime.is_set = lambda: True +localtime.now = lambda: _midnight +_Pin.state = 0 +shell._lit = False +localtime.offset_s = None +_headless.advance(1_100) +check(_Pin.state == 1, "no timezone yet keeps the screen on") + +print("desk contract: ok") diff --git a/tests/test_deskkit.py b/tests/test_deskkit.py new file mode 100644 index 0000000..85e78d7 --- /dev/null +++ b/tests/test_deskkit.py @@ -0,0 +1,166 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""deskkit and the weather app's parts that need no screen and no network.""" + +import importlib.util +import os +from pathlib import Path +import shutil +import subprocess +import sys +import unittest +from unittest import mock + +TESTS = Path(__file__).resolve().parent +ROOT = TESTS.parent +sys.path.insert(0, str(ROOT / "lib")) + +REPLY = { + "utc_offset_seconds": -18000, + "current": { + "time": "2026-11-03T21:15", + "temperature_2m": 59.2, + "apparent_temperature": 58.6, + "weather_code": 63, + "is_day": 0, + }, + "daily": { + "time": ["2026-11-03", "2026-11-04"], + "weather_code": [63, 0], + "temperature_2m_max": [64.0, 58.4], + "temperature_2m_min": [51.3, 47.0], + "precipitation_probability_max": [80, 0], + }, +} + + +def _forecast(): + fakes = {name: mock.MagicMock() for name in ("lvgl", "requests", "wifi_manager", "appkit", "appkit.data")} + with mock.patch.dict(sys.modules, fakes): + for name in ("deskkit", "deskkit.forecast", "deskkit.localtime"): + sys.modules.pop(name, None) + from deskkit import forecast + + return forecast, fakes + + +class ForecastTest(unittest.TestCase): + def test_a_reply_becomes_what_the_screen_shows(self): + forecast, _ = _forecast() + got = forecast.parse(REPLY) + self.assertEqual(got["temp"], 59.2) + self.assertFalse(got["is_day"]) + self.assertEqual(got["as_of"], "21:15") + self.assertEqual(got["utc_offset"], -18000) + self.assertEqual([d[0] for d in got["days"]], ["Tue", "Wed"]) + self.assertEqual(got["days"][0][4], 80) + + def test_celsius_is_worked_out_on_the_board(self): + forecast, _ = _forecast() + forecast._place = {"units": "C"} + self.assertEqual(forecast.degrees(212), 100) + forecast._place = {"units": "F"} + self.assertEqual(forecast.degrees(59.2), 59) + + def test_a_failed_fetch_keeps_the_last_forecast_and_retries_later(self): + forecast, fakes = _forecast() + forecast.requests.get.side_effect = OSError(110) + forecast._place = {"lat": 1, "lon": 2} + forecast.latest = "old" + forecast.localtime.resync_if_due = lambda: None + self.assertFalse(forecast.fetch()) + self.assertEqual(forecast.latest, "old") + self.assertIsNotNone(forecast.last_error) + self.assertFalse(forecast.is_stale()) # tried just now + forecast._tried_at -= forecast.RETRY_S + self.assertTrue(forecast.is_stale()) + + +class LocalTimeTest(unittest.TestCase): + def test_a_date_header_reads_as_utc_fields(self): + forecast, _ = _forecast() + got = forecast.localtime.parse_http_date("Wed, 04 Nov 2026 02:36:50 GMT") + self.assertEqual(got, (2026, 11, 4, 2, 36, 50)) + + def test_a_bad_date_header_is_refused(self): + forecast, _ = _forecast() + with self.assertRaises(ValueError): + forecast.localtime.parse_http_date("yesterday") + + def test_local_time_follows_the_offset(self): + forecast, _ = _forecast() + lt = forecast.localtime + lt.offset_s = -18000 + with mock.patch.object(lt.time, "time", return_value=1793673000): # 02:30 UTC + self.assertEqual(lt.clock_time()[:2], (21, 30)) + + +class IconsTest(unittest.TestCase): + def test_every_weather_code_has_words(self): + fakes = {"lvgl": mock.MagicMock()} + path = ROOT / "apps" / "weather" / "icons.py" + with mock.patch.dict(sys.modules, fakes): + spec = importlib.util.spec_from_file_location("weather_icons", path) + icons = importlib.util.module_from_spec(spec) + spec.loader.exec_module(icons) + wmo = (0, 1, 2, 3, 45, 48, 51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 71, 73, 75, 77, 80, 81, 82, 85, 86, 95, 96, 99) + for code in wmo: + self.assertTrue(icons.name(code, True)) + self.assertEqual(icons.name(0, True), "Sunny") + self.assertEqual(icons.name(0, False), "Clear") + self.assertEqual(icons.name(65, True), "Rain") + self.assertEqual(icons.name(73, True), "Snow") + self.assertEqual(icons.name(96, True), "Thunderstorm") + + +def _micropython(): + named = os.environ.get("PYDEVICES_MICROPYTHON") or shutil.which("micropython") + if named: + return named + for parent in TESTS.parents: + candidate = parent / "bin" / "micropython" + if candidate.is_file(): + return str(candidate) + return None + + +def _has_lvgl(interpreter): + try: + return subprocess.run([interpreter, "-c", "import lvgl"], capture_output=True, timeout=60, check=False).returncode == 0 + except (OSError, subprocess.TimeoutExpired): + return False + + +class ContractTest(unittest.TestCase): + def _run(self, plant=None): + interpreter = _micropython() + if interpreter is None or not _has_lvgl(interpreter): + message = "no MicroPython with LVGL: the swipe checks did NOT run" + if os.environ.get("PYDEVICES_REQUIRE_MICROPYTHON") == "1": + self.fail(message) + self.skipTest(message) + env = dict(os.environ) + if plant: + env["DESK_PLANT"] = plant + return subprocess.run( + [interpreter, "-X", "heapsize=32M", str(TESTS / "desk_contract.py")], + capture_output=True, + text=True, + timeout=120, + env=env, + ) + + def test_swipes_idle_and_night_on_real_lvgl(self): + done = self._run() + self.assertEqual(done.returncode, 0, done.stdout + done.stderr) + self.assertIn("desk contract: ok", done.stdout) + + def test_the_checks_can_fail(self): + for plant in ("swapped_swipes", "no_idle_return", "never_dark", "untuned_touch"): + done = self._run(plant) + self.assertIn("FAILED:", done.stdout, plant + " was not caught\n" + done.stdout + done.stderr) + + +if __name__ == "__main__": + unittest.main()