|
| 1 | +# SPDX-FileCopyrightText: 2026 PyDevices / Brad Barnett |
| 2 | +# |
| 3 | +# SPDX-License-Identifier: MIT |
| 4 | +""" |
| 5 | +PyDevices Hybrid Smartwatch — 240x240 LVGL v9 watch face for hero canvas. |
| 6 | +
|
| 7 | +Features: |
| 8 | +- Self-rendered circular metallic bezel and casing |
| 9 | +- Dark sunray/carbon watch dial with Roman hour numerals and 60-minute tick ring |
| 10 | +- High-precision dauphine analog hour & minute hands |
| 11 | +- Continuous sweeping brand amber/orange seconds hand (~30 FPS) |
| 12 | +- Central/lower digital time readout (HH:MM:SS) and date badge (Day, Mon DD) |
| 13 | +- Zero board_config dependency (uses PSDisplay + display_driver directly) |
| 14 | +""" |
| 15 | + |
| 16 | +import math |
| 17 | +import sys |
| 18 | +import time |
| 19 | +import types |
| 20 | + |
| 21 | +try: |
| 22 | + from js import Date as _JSDate |
| 23 | +except ImportError: |
| 24 | + _JSDate = None |
| 25 | + |
| 26 | +from displaydev.psdisplay import PSDisplay |
| 27 | + |
| 28 | +# Provide synthetic board_config for display_driver in browser / standalone canvas |
| 29 | +if "board_config" not in sys.modules: |
| 30 | + bc = types.ModuleType("board_config") |
| 31 | + bc.display_drv = PSDisplay("hero_canvas", width=240, height=240) |
| 32 | + sys.modules["board_config"] = bc |
| 33 | + |
| 34 | +import display_driver |
| 35 | +import lvgl as lv |
| 36 | + |
| 37 | + |
| 38 | +def _color(rgb): |
| 39 | + return lv.color_hex(rgb) |
| 40 | + |
| 41 | + |
| 42 | +def _font_for(size): |
| 43 | + for points in (48, 40, 36, 32, 28, 24, 22, 20, 18, 16, 14, 12, 10): |
| 44 | + if points <= size: |
| 45 | + font = getattr(lv, "font_montserrat_%d" % points, None) |
| 46 | + if font is not None: |
| 47 | + return font, points |
| 48 | + font = getattr(lv, "font_montserrat_14", None) |
| 49 | + return (font, 14) if font is not None else (None, 0) |
| 50 | + |
| 51 | + |
| 52 | +def _set_scaled_font(label, target_size): |
| 53 | + font, points = _font_for(max(1, int(target_size))) |
| 54 | + if font is not None: |
| 55 | + label.set_style_text_font(font, 0) |
| 56 | + scale = max(128, min(640, round(256 * target_size / max(1, points)))) |
| 57 | + if scale != 256 and points > 0: |
| 58 | + try: |
| 59 | + label.set_style_transform_scale(scale, 0) |
| 60 | + except AttributeError: |
| 61 | + pass |
| 62 | + |
| 63 | + |
| 64 | +def _plain(obj): |
| 65 | + obj.set_style_border_width(0, 0) |
| 66 | + obj.set_style_pad_all(0, 0) |
| 67 | + obj.set_style_bg_opa(lv.OPA.TRANSP, 0) |
| 68 | + try: |
| 69 | + obj.remove_flag(lv.obj.FLAG.SCROLLABLE) |
| 70 | + except AttributeError: |
| 71 | + pass |
| 72 | + |
| 73 | + |
| 74 | +def _local_time(): |
| 75 | + """Return (hour, minute, second, millis, day_name, month_name, day_num).""" |
| 76 | + if _JSDate is not None: |
| 77 | + now = _JSDate.new() |
| 78 | + hour = int(now.getHours()) |
| 79 | + minute = int(now.getMinutes()) |
| 80 | + second = int(now.getSeconds()) |
| 81 | + millis = int(now.getMilliseconds()) |
| 82 | + days = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"] |
| 83 | + months = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"] |
| 84 | + day_name = days[int(now.getDay())] |
| 85 | + month_name = months[int(now.getMonth())] |
| 86 | + day_num = int(now.getDate()) |
| 87 | + return hour, minute, second, millis, day_name, month_name, day_num |
| 88 | + else: |
| 89 | + t = time.localtime() |
| 90 | + millis = int((time.time() % 1.0) * 1000) |
| 91 | + days = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"] |
| 92 | + months = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"] |
| 93 | + day_name = days[t[6]] |
| 94 | + month_name = months[t[1] - 1] |
| 95 | + return t[3], t[4], t[5], millis, day_name, month_name, t[2] |
| 96 | + |
| 97 | + |
| 98 | +class PyDevicesWatch: |
| 99 | + def __init__(self, parent, size=240): |
| 100 | + self.parent = parent |
| 101 | + self.size = size |
| 102 | + self._styles = [] |
| 103 | + self._build_watch() |
| 104 | + |
| 105 | + def _line(self, parent, color, width): |
| 106 | + line = lv.line(parent) |
| 107 | + style = lv.style_t() |
| 108 | + style.init() |
| 109 | + style.set_line_color(_color(color)) |
| 110 | + style.set_line_width(width) |
| 111 | + style.set_line_rounded(True) |
| 112 | + line.add_style(style, 0) |
| 113 | + self._styles.append(style) |
| 114 | + return line |
| 115 | + |
| 116 | + def _build_watch(self): |
| 117 | + size = self.size |
| 118 | + dial_size = int(size * 0.916) |
| 119 | + r = dial_size // 2 |
| 120 | + |
| 121 | + # 1. Base Screen Layer |
| 122 | + self.parent.set_style_bg_color(_color(0x0A0D10), 0) |
| 123 | + self.parent.set_style_bg_opa(lv.OPA.COVER, 0) |
| 124 | + self.parent.set_style_pad_all(0, 0) |
| 125 | + try: |
| 126 | + self.parent.remove_flag(lv.obj.FLAG.SCROLLABLE) |
| 127 | + except AttributeError: |
| 128 | + pass |
| 129 | + |
| 130 | + # 2. Outer Watch Bezel / Casing |
| 131 | + self.bezel = lv.obj(self.parent) |
| 132 | + self.bezel.set_size(size, size) |
| 133 | + self.bezel.center() |
| 134 | + self.bezel.set_style_radius(lv.RADIUS_CIRCLE, 0) |
| 135 | + self.bezel.set_style_bg_color(_color(0x1E242B), 0) |
| 136 | + self.bezel.set_style_bg_grad_color(_color(0x101418), 0) |
| 137 | + self.bezel.set_style_bg_grad_dir(lv.GRAD_DIR.VER, 0) |
| 138 | + self.bezel.set_style_border_color(_color(0x3B444E), 0) |
| 139 | + self.bezel.set_style_border_width(max(2, int(size * 0.017)), 0) |
| 140 | + self.bezel.set_style_pad_all(0, 0) |
| 141 | + _plain(self.bezel) |
| 142 | + |
| 143 | + # 3. Inner Metallic Bezel Ring with Subtle Amber Accent |
| 144 | + inner_bezel_size = int(size * 0.95) |
| 145 | + self.bezel_ring = lv.obj(self.bezel) |
| 146 | + self.bezel_ring.set_size(inner_bezel_size, inner_bezel_size) |
| 147 | + self.bezel_ring.center() |
| 148 | + self.bezel_ring.set_style_radius(lv.RADIUS_CIRCLE, 0) |
| 149 | + self.bezel_ring.set_style_bg_color(_color(0x151A20), 0) |
| 150 | + self.bezel_ring.set_style_border_color(_color(0xF54E00), 0) |
| 151 | + self.bezel_ring.set_style_border_width(1, 0) |
| 152 | + self.bezel_ring.set_style_border_opa(lv.OPA._60, 0) |
| 153 | + _plain(self.bezel_ring) |
| 154 | + |
| 155 | + # 4. Dial Face (Underlay for background elements) |
| 156 | + self.face = lv.obj(self.bezel_ring) |
| 157 | + self.face.set_size(dial_size, dial_size) |
| 158 | + self.face.center() |
| 159 | + self.face.set_style_radius(lv.RADIUS_CIRCLE, 0) |
| 160 | + self.face.set_style_bg_color(_color(0x0E1115), 0) |
| 161 | + self.face.set_style_bg_grad_color(_color(0x181F26), 0) |
| 162 | + self.face.set_style_bg_grad_dir(lv.GRAD_DIR.VER, 0) |
| 163 | + self.face.set_style_border_color(_color(0x28323D), 0) |
| 164 | + self.face.set_style_border_width(1, 0) |
| 165 | + _plain(self.face) |
| 166 | + |
| 167 | + # 5. Roman Hour Numerals (XII, III, VI, IX) - Created on dial face |
| 168 | + numerals = {0: "XII", 3: "III", 6: "VI", 9: "IX"} |
| 169 | + num_radius = int(r * 0.76) |
| 170 | + num_font_size = int(dial_size * 0.065) |
| 171 | + for hour, label_text in numerals.items(): |
| 172 | + lbl = lv.label(self.face) |
| 173 | + lbl.set_text(label_text) |
| 174 | + lbl.set_style_text_color(_color(0xE2E8F0), 0) |
| 175 | + _set_scaled_font(lbl, num_font_size) |
| 176 | + angle = math.radians(hour * 30 - 90) |
| 177 | + nx = int(math.cos(angle) * num_radius) |
| 178 | + ny = int(math.sin(angle) * num_radius) |
| 179 | + lbl.align(lv.ALIGN.CENTER, nx, ny) |
| 180 | + |
| 181 | + # 6. Brand Label (Top Half) - Calculated anchor point |
| 182 | + brand_y = -int(r * 0.38) |
| 183 | + self.brand_lbl = lv.label(self.face) |
| 184 | + self.brand_lbl.set_text("PYDEVICES") |
| 185 | + self.brand_lbl.set_style_text_color(_color(0xF54E00), 0) |
| 186 | + _set_scaled_font(self.brand_lbl, int(dial_size * 0.05)) |
| 187 | + self.brand_lbl.align(lv.ALIGN.CENTER, 0, brand_y) |
| 188 | + |
| 189 | + # 7. Digital Readout Sub-Dial (Lower Half) - Created on dial face behind hands |
| 190 | + pill_w = int(dial_size * 0.46) |
| 191 | + pill_h = int(dial_size * 0.16) |
| 192 | + pill_y = int(r * 0.42) |
| 193 | + |
| 194 | + self.pill = lv.obj(self.face) |
| 195 | + self.pill.set_size(pill_w, pill_h) |
| 196 | + self.pill.align(lv.ALIGN.CENTER, 0, pill_y) |
| 197 | + self.pill.set_style_radius(max(4, int(pill_h * 0.18)), 0) |
| 198 | + self.pill.set_style_bg_color(_color(0x080B0E), 0) |
| 199 | + self.pill.set_style_bg_opa(lv.OPA._90, 0) |
| 200 | + self.pill.set_style_border_color(_color(0x28333E), 0) |
| 201 | + self.pill.set_style_border_width(1, 0) |
| 202 | + self.pill.set_style_pad_all(0, 0) |
| 203 | + try: |
| 204 | + self.pill.remove_flag(lv.obj.FLAG.SCROLLABLE) |
| 205 | + except AttributeError: |
| 206 | + pass |
| 207 | + |
| 208 | + time_y = -int(pill_h * 0.18) |
| 209 | + self.digital_time = lv.label(self.pill) |
| 210 | + self.digital_time.set_text("00:00:00") |
| 211 | + self.digital_time.set_style_text_color(_color(0xF8FAFC), 0) |
| 212 | + _set_scaled_font(self.digital_time, int(pill_h * 0.38)) |
| 213 | + self.digital_time.align(lv.ALIGN.CENTER, 0, time_y) |
| 214 | + |
| 215 | + date_y = int(pill_h * 0.24) |
| 216 | + self.digital_date = lv.label(self.pill) |
| 217 | + self.digital_date.set_text("FRI OCT 24") |
| 218 | + self.digital_date.set_style_text_color(_color(0x94A3B8), 0) |
| 219 | + _set_scaled_font(self.digital_date, int(pill_h * 0.28)) |
| 220 | + self.digital_date.align(lv.ALIGN.CENTER, 0, date_y) |
| 221 | + |
| 222 | + # 8. Scale for Ticks & Analog Needles - Created AFTER dial & digital sub-dial |
| 223 | + # This guarantees that the tick ring and all rotating hands draw in FRONT of the digital display! |
| 224 | + self.scale = lv.scale(self.face) |
| 225 | + self.scale.set_size(dial_size, dial_size) |
| 226 | + self.scale.center() |
| 227 | + _plain(self.scale) |
| 228 | + self.scale.set_mode(lv.scale.MODE.ROUND_INNER) |
| 229 | + self.scale.set_range(0, 3600) |
| 230 | + self.scale.set_angle_range(360) |
| 231 | + self.scale.set_rotation(270) |
| 232 | + self.scale.set_total_tick_count(60) |
| 233 | + self.scale.set_major_tick_every(5) |
| 234 | + self.scale.set_label_show(False) |
| 235 | + |
| 236 | + # Minor ticks (minutes) |
| 237 | + minor_style = lv.style_t() |
| 238 | + minor_style.init() |
| 239 | + minor_style.set_line_color(_color(0x64748B)) |
| 240 | + minor_style.set_line_width(1) |
| 241 | + minor_style.set_length(max(3, int(r * 0.05))) |
| 242 | + self.scale.add_style(minor_style, lv.PART.ITEMS) |
| 243 | + |
| 244 | + # Major ticks (hours) |
| 245 | + major_style = lv.style_t() |
| 246 | + major_style.init() |
| 247 | + major_style.set_line_color(_color(0xF54E00)) |
| 248 | + major_style.set_line_width(max(2, int(dial_size * 0.009))) |
| 249 | + major_style.set_length(max(6, int(r * 0.09))) |
| 250 | + major_style.set_line_rounded(True) |
| 251 | + self.scale.add_style(major_style, lv.PART.INDICATOR) |
| 252 | + self._styles.extend([minor_style, major_style]) |
| 253 | + |
| 254 | + # 9. Analog Hands (attached to self.scale) |
| 255 | + self.hands = {"hour": [], "minute": [], "second": []} |
| 256 | + |
| 257 | + # Hour hand layers (dark border + bright silver core + gold ridge) |
| 258 | + hour_len = int(r * 0.58) |
| 259 | + self.hands["hour"].append((self._line(self.scale, 0x050709, max(4, int(dial_size * 0.025))), hour_len + 1)) |
| 260 | + self.hands["hour"].append((self._line(self.scale, 0xE2E8F0, max(3, int(dial_size * 0.018))), int(hour_len * 0.85))) |
| 261 | + self.hands["hour"].append((self._line(self.scale, 0xF54E00, 1), hour_len)) |
| 262 | + |
| 263 | + # Minute hand layers |
| 264 | + min_len = int(r * 0.82) |
| 265 | + self.hands["minute"].append((self._line(self.scale, 0x050709, max(3, int(dial_size * 0.018))), min_len + 1)) |
| 266 | + self.hands["minute"].append((self._line(self.scale, 0xF8FAFC, max(2, int(dial_size * 0.010))), int(min_len * 0.88))) |
| 267 | + self.hands["minute"].append((self._line(self.scale, 0xF54E00, 1), min_len)) |
| 268 | + |
| 269 | + # Second hand (sweeping brand orange/amber needle + dark outline) |
| 270 | + sec_len = int(r * 0.90) |
| 271 | + self.hands["second"].append((self._line(self.scale, 0x1A0802, max(2, int(dial_size * 0.012))), sec_len + 1)) |
| 272 | + self.hands["second"].append((self._line(self.scale, 0xF54E00, 1), sec_len)) |
| 273 | + |
| 274 | + # 10. Center Hub Cap - Created on top of the hands |
| 275 | + hub_size = max(10, int(dial_size * 0.065)) |
| 276 | + self.hub = lv.obj(self.face) |
| 277 | + self.hub.set_size(hub_size, hub_size) |
| 278 | + self.hub.center() |
| 279 | + self.hub.set_style_radius(lv.RADIUS_CIRCLE, 0) |
| 280 | + self.hub.set_style_bg_color(_color(0xF54E00), 0) |
| 281 | + self.hub.set_style_bg_grad_color(_color(0x9A3412), 0) |
| 282 | + self.hub.set_style_bg_grad_dir(lv.GRAD_DIR.VER, 0) |
| 283 | + self.hub.set_style_border_color(_color(0xFFF7ED), 0) |
| 284 | + self.hub.set_style_border_width(1, 0) |
| 285 | + _plain(self.hub) |
| 286 | + |
| 287 | + # First update and timer (~30 FPS: 33ms) |
| 288 | + self.update_time() |
| 289 | + self._timer = lv.timer_create(lambda _t: self.update_time(), 33, None) |
| 290 | + |
| 291 | + def update_time(self): |
| 292 | + hour, minute, second, millis, day_name, month_name, day_num = _local_time() |
| 293 | + |
| 294 | + # Update analog needles (range: 0..3600) |
| 295 | + sec_val = int((second + millis / 1000.0) * 60) % 3600 |
| 296 | + min_val = int((minute + second / 60.0) * 60) % 3600 |
| 297 | + hour_val = int(((hour % 12) + minute / 60.0 + second / 3600.0) * 300) % 3600 |
| 298 | + |
| 299 | + for hand, length in self.hands["hour"]: |
| 300 | + self.scale.set_line_needle_value(hand, length, hour_val) |
| 301 | + for hand, length in self.hands["minute"]: |
| 302 | + self.scale.set_line_needle_value(hand, length, min_val) |
| 303 | + for hand, length in self.hands["second"]: |
| 304 | + self.scale.set_line_needle_value(hand, length, sec_val) |
| 305 | + |
| 306 | + # Update digital readout |
| 307 | + time_str = f"{hour:02d}:{minute:02d}:{second:02d}" |
| 308 | + date_str = f"{day_name} {month_name} {day_num:02d}" |
| 309 | + self.digital_time.set_text(time_str) |
| 310 | + self.digital_date.set_text(date_str) |
| 311 | + |
| 312 | + |
| 313 | +_watch_app = None |
| 314 | + |
| 315 | + |
| 316 | +def main(canvas_id="hero_canvas"): |
| 317 | + global _watch_app |
| 318 | + print(f"Initializing PyDevices LVGL Smartwatch on canvas '{canvas_id}'...") |
| 319 | + |
| 320 | + scr = lv.screen_active() if hasattr(lv, "screen_active") else lv.scr_act() |
| 321 | + _watch_app = PyDevicesWatch(scr, size=240) |
| 322 | + print("PyDevices LVGL Smartwatch running successfully!") |
| 323 | + |
| 324 | + |
| 325 | +if __name__ == "__main__": |
| 326 | + cid = sys.argv[1] if len(sys.argv) > 1 else "hero_canvas" |
| 327 | + main(cid) |
0 commit comments