Skip to content

Commit 6dc9594

Browse files
committed
feat: add hero canvas smartwatch and update site chrome
1 parent 9d4f511 commit 6dc9594

5 files changed

Lines changed: 1084 additions & 6 deletions

File tree

assets/apps/watch.py

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
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)

index.html

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,23 +22,32 @@
2222
<div id="pydevices-site-header"></div>
2323

2424
<!-- Hero Banner -->
25-
<section class="hero wrap">
26-
<div class="hero-lead">
27-
<div class="hero-brand">
25+
<section class="hero wrap has-hero-canvas">
26+
<div class="hero-main">
27+
<div class="hero-lead">
2828
<div class="logo-badge product-mark" style="background: linear-gradient(135deg, var(--tier-1-amber), #d97706); color: #fff;"><img src="/img/logo.svg" alt="PyDevices" width="112" height="112"></div>
2929
<span class="eyebrow" style="color: var(--tier-1-amber);">PyDevices Portal</span>
3030
</div>
3131
<h1>One display & hardware contract. 5 Python interpreters.</h1>
32-
</div>
33-
<p><code>PyDevices.github.io</code> Cross-platform hardware driver engine, board contract specification, and display stack for MicroPython, CircuitPython, CPython, PyScript, and Android.</p>
34-
<div class="cta">
32+
<p><code>PyDevices.github.io</code> Cross-platform hardware driver engine, board contract specification, and display stack for MicroPython, CircuitPython, CPython, PyScript, and Android.</p>
33+
<div class="cta">
3534
<a class="btn primary" style="background: #24292e; color: #fff; border-color: #24292e;" href="https://github.com/PyDevices">
3635
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.9a3.4 3.4 0 0 0-1-2.6c3-.3 6-1.5 6-6.5a5 5 0 0 0-1.4-3.5 4.6 4.6 0 0 0-.1-3.5s-1.1-.3-3.5 1.3a12 12 0 0 0-6 0C6.6 1.7 5.5 2 5.5 2a4.6 4.6 0 0 0-.1 3.5A5 5 0 0 0 4 9c0 5 3 6.2 6 6.5a3.4 3.4 0 0 0-1 2.6V22"/></svg>
3736
GitHub Org
3837
</a>
3938
<a class="btn" style="border-color: var(--tier-1-amber); color: var(--tier-1-amber);" href="https://pydevices.github.io/pydevices/">Get Started</a>
4039
<a class="btn" style="border-color: var(--tier-1-amber); color: var(--tier-1-amber);" href="https://pydevices.github.io/pydevices-examples/pyscript/">PyScript Demos</a>
4140
<a class="btn" style="border-color: var(--tier-1-amber); color: var(--tier-1-amber);" href="https://pydevices.github.io/mip/">MIP Index</a>
41+
</div>
42+
</div>
43+
<div class="hero-canvas-wrap" data-hero-canvas="hero_canvas" data-hero-app="watch" data-hero-deps="pydevices,pydevices-lvgl" data-hero-app-url="https://PyDevices.github.io/assets/apps/watch.py">
44+
<div class="hero-canvas-circle">
45+
<div class="hero-canvas-loader">
46+
<div class="hero-spinner"></div>
47+
<span class="hero-canvas-status">Live Pure-Python Device</span>
48+
</div>
49+
<canvas id="hero_canvas" width="240" height="240" tabindex="0"></canvas>
50+
</div>
4251
</div>
4352
</section>
4453
<!-- PYDEVICES-ABOVE-THE-FOLD: END -->
@@ -312,5 +321,6 @@ <h2>Cross-Interpreter Compatibility Matrix</h2>
312321
<script src="./vendor/pydevices-chrome/site-chrome.js"></script>
313322
<script src="./vendor/pydevices-chrome/theme-toggle.js"></script>
314323
<script src="./vendor/pydevices-chrome/tree-nav.js"></script>
324+
<script src="./vendor/pydevices-chrome/hero-runtime.js"></script>
315325
</body>
316326
</html>

0 commit comments

Comments
 (0)