From 6a81e5481fb4c9b0f9422e3690858eeee8af59f6 Mon Sep 17 00:00:00 2001 From: Pavel Machek Date: Tue, 17 Feb 2026 13:36:06 +0100 Subject: [PATCH 1/3] compass: Add an application for magnetometer / accelerometer debugging This is more of a debugging tool for now, but should point north when calibrated properly. For best results, place on flat surface and rotate device few times. --- .../META-INF/MANIFEST.JSON | 24 + .../apps/cz.ucw.pavel.compass/assets/main.py | 687 ++++++++++++++++++ .../res/mipmap-mdpi/icon_64x64.png | Bin 0 -> 9440 bytes 3 files changed, 711 insertions(+) create mode 100644 internal_filesystem/apps/cz.ucw.pavel.compass/META-INF/MANIFEST.JSON create mode 100644 internal_filesystem/apps/cz.ucw.pavel.compass/assets/main.py create mode 100644 internal_filesystem/apps/cz.ucw.pavel.compass/res/mipmap-mdpi/icon_64x64.png diff --git a/internal_filesystem/apps/cz.ucw.pavel.compass/META-INF/MANIFEST.JSON b/internal_filesystem/apps/cz.ucw.pavel.compass/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..dd52c192 --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.compass/META-INF/MANIFEST.JSON @@ -0,0 +1,24 @@ +{ +"name": "Compass", +"publisher": "Pavel Machek", +"short_description": "Application for testing accelerometer and magnetometer", +"long_description": "Simple compass application, allowing tests of accelerometer and magnetometer.", +"icon_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.compass/icons/cz.ucw.pavel.compass_0.0.1_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.compass/mpks/cz.ucw.pavel.compass_0.0.1.mpk", +"fullname": "cz.ucw.pavel.compass", +"version": "0.0.1", +"category": "utilities", +"activities": [ + { + "entrypoint": "assets/main.py", + "classname": "Main", + "intent_filters": [ + { + "action": "main", + "category": "launcher" + } + ] + } + ] +} + diff --git a/internal_filesystem/apps/cz.ucw.pavel.compass/assets/main.py b/internal_filesystem/apps/cz.ucw.pavel.compass/assets/main.py new file mode 100644 index 00000000..383da538 --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.compass/assets/main.py @@ -0,0 +1,687 @@ +""" +Robot translated that from bwatch/magcali.js + +""" + +import time +import os +import math + +try: + import lvgl as lv +except ImportError: + pass + +from mpos import Activity, MposKeyboard, SensorManager + +# ----------------------------- +# Utilities +# ----------------------------- + +def clamp(v, lo, hi): + if v < lo: + return lo + if v > hi: + return hi + return v + +def to_rad(deg): + return deg * math.pi / 180.0 + +def to_deg(rad): + return rad * 180.0 / math.pi + +# ----------------------------- +# Calibration + heading +# ----------------------------- + +class Compass: + def __init__(self): + self.reset() + + def reset(self): + self.vmin = [10000.0, 10000.0, 10000.0] + self.vmax = [-10000.0, -10000.0, -10000.0] + + def step(self, v): + """ + Update min/max. Returns True if calibration box changed ("bad" in JS). + """ + bad = False + for i in range(3): + if v[i] < self.vmin[i]: + self.vmin[i] = v[i] + bad = True + if v[i] > self.vmax[i]: + self.vmax[i] = v[i] + bad = True + return bad + + def compensated(self, v): + """ + Returns: + vh = v - center + sc = scaled to [-1..+1] + """ + vh = [0.0, 0.0, 0.0] + sc = [0.0, 0.0, 0.0] + + for i in range(3): + center = (self.vmin[i] + self.vmax[i]) / 2.0 + vh[i] = v[i] - center + + denom = (self.vmax[i] - self.vmin[i]) + if denom == 0: + sc[i] = 0.0 + else: + sc[i] = (v[i] - self.vmin[i]) / denom * 2.0 - 1.0 + + return vh, sc + + def heading_flat(self): + """ + Equivalent of: + heading = atan2(sc[1], sc[0]) * 180/pi - 90 + + Compute heading based on last update(). This will only work well + on flat surface. + """ + vh, sc = self.compensated(self.val) + + h = to_deg(math.atan2(sc[1], sc[0])) - 90.0 + while h < 0: + h += 360.0 + while h >= 360.0: + h -= 360.0 + return h + + +class TiltCompass(Compass): + def __init__(self): + super().__init__() + + def tilt_calibrate(self): + """ + JS tiltCalibrate(min,max) + vmin/vmax are dicts with x,y,z + """ + vmin = self.vmin + vmax = self.vmax + + offset = ( (vmax[0] + vmin[0]) / 2.0, + (vmax[1] + vmin[1]) / 2.0, + (vmax[2] + vmin[2]) / 2.0 ) + delta = ( (vmax[0] - vmin[0]) / 2.0, + (vmax[1] - vmin[1]) / 2.0, + (vmax[2] - vmin[2]) / 2.0 ) + + avg = (delta[0] + delta[1] + delta[2]) / 3.0 + + # Avoid division by zero + scale = ( + avg / delta[0] if delta[0] else 1.0, + avg / delta[1] if delta[1] else 1.0, + avg / delta[2] if delta[2] else 1.0, + ) + + self.offset = offset + self.scale = scale + + def heading_tilted(self): + """ + Returns heading 0..360 + """ + mag_xyz = self.val + acc_xyz = self.acc + + if mag_xyz is None or acc_xyz is None: + return None + + self.tilt_calibrate() + + mx, my, mz = mag_xyz + ax, ay, az = acc_xyz + + dx = (mx - self.offset[0]) * self.scale[0] + dy = (my - self.offset[1]) * self.scale[1] + dz = (mz - self.offset[2]) * self.scale[2] + + # JS: + # phi = atan(-g.x/-g.z) + # theta = atan(-g.y/(-g.x*sinphi-g.z*cosphi)) + # ... + # psi = atan2(yh,xh) + # + # Keep the same structure. + + # Avoid pathological az=0 + if az == 0: + az = 1e-9 + + phi = math.atan((-ax) / (-az)) + cosphi = math.cos(phi) + sinphi = math.sin(phi) + + denom = (-ax * sinphi - az * cosphi) + if denom == 0: + denom = 1e-9 + + theta = math.atan((-ay) / denom) + costheta = math.cos(theta) + sintheta = math.sin(theta) + + xh = dy * costheta + dx * sinphi * sintheta + dz * cosphi * sintheta + yh = dz * sinphi - dx * cosphi + + psi = to_deg(math.atan2(yh, xh)) + if psi < 0: + psi += 360.0 + return psi + +# ----------------------------- +# Canvas (LVGL) +# ----------------------------- + +class Canvas: + """ + LVGL canvas + layer drawing Canvas. + + This matches ports where: + - lv.canvas has init_layer() / finish_layer() + - primitives are drawn via lv.draw_* into lv.layer_t + """ + + def __init__(self, scr, canvas): + self.scr = scr + + # Screen size + self.W = scr.get_width() + self.H = scr.get_height() + + # Bottom button bar + self.margin = 2 + self.bar_h = 39 + + # Canvas drawing area (everything above button bar) + self.draw_w = self.W + self.draw_h = self.H - (self.bar_h + self.margin * 2) + + self.canvas = canvas + + # Background: white (change if you want dark theme) + self.canvas.set_style_bg_color(lv.color_white(), lv.PART.MAIN) + + # Buffer: your working example uses 4 bytes/pixel + # Reality filter: this depends on LV_COLOR_DEPTH; but your example proves it works. + self.buf = bytearray(self.draw_w * self.draw_h * 4) + self.canvas.set_buffer(self.buf, self.draw_w, self.draw_h, lv.COLOR_FORMAT.NATIVE) + + # Layer used for draw engine + self.layer = lv.layer_t() + self.canvas.init_layer(self.layer) + + # Persistent draw descriptors (avoid allocations) + self._line_dsc = lv.draw_line_dsc_t() + lv.draw_line_dsc_t.init(self._line_dsc) + self._line_dsc.width = 1 + self._line_dsc.color = lv.color_black() + self._line_dsc.round_end = 1 + self._line_dsc.round_start = 1 + + self._label_dsc = lv.draw_label_dsc_t() + lv.draw_label_dsc_t.init(self._label_dsc) + self._label_dsc.color = lv.color_black() + self._label_dsc.font = lv.font_montserrat_24 + + self._rect_dsc = lv.draw_rect_dsc_t() + lv.draw_rect_dsc_t.init(self._rect_dsc) + self._rect_dsc.bg_opa = lv.OPA.TRANSP + self._rect_dsc.border_opa = lv.OPA.COVER + self._rect_dsc.border_width = 1 + self._rect_dsc.border_color = lv.color_black() + + self._fill_dsc = lv.draw_rect_dsc_t() + lv.draw_rect_dsc_t.init(self._fill_dsc) + self._fill_dsc.bg_opa = lv.OPA.COVER + self._fill_dsc.bg_color = lv.color_black() + self._fill_dsc.border_width = 1 + + # Clear once + self.clear() + + # ---------------------------- + # Layer lifecycle + # ---------------------------- + + def _begin(self): + # Start drawing into the layer + self.canvas.init_layer(self.layer) + + def _end(self): + # Commit drawing + self.canvas.finish_layer(self.layer) + + # ---------------------------- + # Public API: drawing + # ---------------------------- + + def clear(self): + # Clear the canvas background + self.canvas.fill_bg(lv.color_white(), lv.OPA.COVER) + + def text(self, x, y, s, fg = lv.color_black()): + self._begin() + + dsc = lv.draw_label_dsc_t() + lv.draw_label_dsc_t.init(dsc) + dsc.text = str(s) + dsc.font = lv.font_montserrat_24 + dsc.color = lv.color_black() + + area = lv.area_t() + area.x1 = x + area.y1 = y + area.x2 = x + self.W + area.y2 = y + self.H + + lv.draw_label(self.layer, dsc, area) + + self._end() + + def line(self, x1, y1, x2, y2, fg = lv.color_black()): + self._begin() + + dsc = self._line_dsc + dsc.p1 = lv.point_precise_t() + dsc.p2 = lv.point_precise_t() + dsc.p1.x = int(x1) + dsc.p1.y = int(y1) + dsc.p2.x = int(x2) + dsc.p2.y = int(y2) + + lv.draw_line(self.layer, dsc) + + self._end() + + def circle(self, x, y, r, fg = lv.color_black()): + # Rounded rectangle trick (works everywhere) + self._begin() + + a = lv.area_t() + a.x1 = int(x - r) + a.y1 = int(y - r) + a.x2 = int(x + r) + a.y2 = int(y + r) + + dsc = self._rect_dsc + dsc.radius = lv.RADIUS_CIRCLE + dsc.border_color = fg + + lv.draw_rect(self.layer, dsc, a) + + self._end() + + def fill_circle(self, x, y, r, fg = lv.color_black(), bg = lv.color_white()): + self._begin() + + a = lv.area_t() + a.x1 = int(x - r) + a.y1 = int(y - r) + a.x2 = int(x + r) + a.y2 = int(y + r) + + dsc = self._rect_dsc + dsc.radius = lv.RADIUS_CIRCLE + dsc.border_color = fg + dsc.bg_color = bg + + lv.draw_rect(self.layer, dsc, a) + + self._end() + + def fill_rect(self, x, y, sx, sy, fg = lv.color_black(), bg = lv.color_white()): + self._begin() + + a = lv.area_t() + a.x1 = x + a.y1 = y + a.x2 = x+sx + a.y2 = y+sy + + dsc = self._fill_dsc + dsc.border_color = fg + dsc.bg_color = bg + + lv.draw_rect(self.layer, dsc, a) + + self._end() + + def update(self): + # Nothing needed; drawing is committed per primitive. + # If you want, you can change the implementation so that: + # - draw ops happen between clear() and update() + # But then you must ensure the app calls update() once per frame. + pass + +# ---------------------------- +# App logic +# ---------------------------- + +class PagedCanvas(Activity): + def __init__(self): + super().__init__() + self.page = 0 + self.pages = 3 + + def onCreate(self): + self.scr = lv.obj() + scr = self.scr + + # Screen size + self.W = scr.get_width() + self.H = scr.get_height() + + # Bottom button bar + self.margin = 2 + self.bar_h = 39 + + # Canvas drawing area (everything above button bar) + self.draw_w = self.W + self.draw_h = self.H - (self.bar_h + self.margin * 2) + + # Canvas + self.canvas = lv.canvas(self.scr) + self.canvas.set_size(self.draw_w, self.draw_h) + self.canvas.align(lv.ALIGN.TOP_LEFT, 0, 0) + self.canvas.set_style_border_width(0, 0) + + self.c = Canvas(self.scr, self.canvas) + + # Build buttons + self.build_buttons() + self.setContentView(self.c.scr) + + # ---------------------------- + # Button bar + # ---------------------------- + + def _make_btn(self, parent, x, y, w, h, label): + b = lv.button(parent) + b.set_pos(x, y) + b.set_size(w, h) + + l = lv.label(b) + l.set_text(label) + l.center() + + return b + + def _btn_cb(self, evt, tag): + self.page = tag + + def template_buttons(self, names): + margin = self.margin + y = self.H - self.bar_h - margin + + num = len(names) + if num == 0: + self.buttons = [] + return + + w = (self.W - margin * (num + 1)) // num + h = self.bar_h + x0 = margin + + self.buttons = [] + + for i, label in enumerate(names): + x = x0 + (w + margin) * i + btn = self._make_btn(self.scr, x, y, w, h, label) + + # capture index correctly + btn.add_event_cb( + lambda evt, idx=i: self._btn_cb(evt, idx), + lv.EVENT.CLICKED, + None + ) + + self.buttons.append(btn) + + def build_buttons(self): + self.template_buttons(["Pg0", "Pg1", "Pg2", "Pg3", "..."]) + + def onResume(self, screen): + self.timer = lv.timer_create(self.tick, 1000, None) + + def onPause(self, screen): + if self.timer: + self.timer.delete() + self.timer = None + + def tick(self, t): + self.update() + self.draw() + + def update(self): + pass + + def draw_page_example(self): + ui = self.c + ui.clear() + + st = 28 + y = 2*st + ui.text(0, y, "Hello world, page is %d" % self.page) + y += st + + def draw(self): + self.draw_page_example() + + def handle_buttons(self): + ui = self.c + +# ---------------------------- +# App logic +# ---------------------------- + +class UCompass(TiltCompass): + # val (+vfirst, vmin, vmax) -- vector from magnetometer + # acc -- vector from accelerometer + + # FIXME: we need to scale acc to similar values we used on watch; + # 90 degrees should correspond to outer circle + + def __init__(self): + super().__init__() + + self.accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + self.magn = SensorManager.get_default_sensor(SensorManager.TYPE_MAGNETIC_FIELD) + + self.val = None + self.vfirst = None + + def update(self): + v = SensorManager.read_sensor_once(self.magn) + sc = 1000 + v = [float(v[1]) * sc, -float(v[0]) * sc, float(v[2]) * sc] + self.val = v + + if self.vfirst is None: + self.vfirst = self.val[:] + + acc = SensorManager.read_sensor_once(self.accel) + acc = ( -acc[1], -acc[0], acc[2] ) + self.acc = acc + +class Main(PagedCanvas): + def __init__(self): + super().__init__() + + self.cal = UCompass() + + self.bad = False + + self.heading = 0.0 + self.heading2 = None + + self.Ypos = 40 + self.brg = None # bearing target, degrees or None + + def draw(self): + pass + + def onResume(self, screen): + self.timer = lv.timer_create(self.tick, 50, None) + + def update(self): + self.c.clear() + st = 14 + y = 2*st + + self.cal.update() + if self.cal.val is None: + self.c.text(0, y, f"No compass data") + y += st + return + + self.bad = self.cal.step(self.cal.val) + self.heading = self.cal.heading_flat() + + acc = self.cal.acc + + #self.c.text(0, y, f"Compass, raw is {self.cal.val}, bad is {self.bad}, acc is {acc}") + y += st + + self.heading2 = self.cal.heading_tilted() + + if self.page == 0: + self.draw_top(acc) + elif self.page == 1: + self.draw_values() + elif self.page == 2: + self.c.text(0, y, f"Resetting calibration") + self.page = 0 + self.cal.reset() + + def build_buttons(self): + self.template_buttons(["Graph", "Values", "Reset"]) + + def draw_values(self): + self.c.text(0, 28, f""" +Acccelerometer +X {self.cal.acc[0]:.2f} Y {self.cal.acc[1]:.2f} Z {self.cal.acc[2]:.2f} +Magnetometer +X {self.cal.val[0]:.2f} Y {self.cal.val[1]:.2f} Z {self.cal.val[2]:.2f} +""") + + def _px_per_deg(self): + # JS used deg->px: (deg/90)*(width/2.1) + s = min(self.c.W, self.c.H) + return (s / 2.1) / 90.0 + + def _degrees_to_pixels(self, deg): + return deg * self._px_per_deg() + + # ---- TOP VIEW ---- + + def draw_top(self, acc): + heading=self.heading + heading2=self.heading2 + vmin=self.cal.vmin + vmax=self.cal.vmax + vfirst=self.cal.vfirst + v=self.cal.val + bad=self.bad + + cx = self.c.W // 2 + cy = self.c.H // 2 + + # Crosshair + self.c.line(0, cy, self.c.W, cy) + self.c.line(cx, 0, cx, self.c.H) + + # Circles (30/60/90 deg) + for rdeg in (30, 60, 90): + r = int(self._degrees_to_pixels(rdeg)) + self.c.circle(cx, cy, r) + + # Calibration box + current point + self._draw_calib_box(vmin, vmax, vfirst, v, bad) + + # Accel circle + if acc is not None: + self._draw_accel(acc) + + # Heading arrow(s) + self._draw_heading_arrow(heading, color=lv.color_make(255, 0, 0)) + self.c.text(265, 22, "%d°" % int(heading)) + if heading2 is not None: + self._draw_heading_arrow(heading2, color=lv.color_make(255, 255, 255), size = 100) + self.c.text(10, 22, "%d°" % int(heading2)) + + def _draw_heading_arrow(self, heading, color, size = 80): + cx = self.c.W / 2.0 + cy = self.c.H / 2.0 + + rad = -to_rad(heading) + x2 = cx + math.sin(rad - 0.1) * size + y2 = cy - math.cos(rad - 0.1) * size + x3 = cx + math.sin(rad + 0.1) * size + y3 = cy - math.cos(rad + 0.1) * size + + poly = [ + int(cx), int(cy), + int(x2), int(y2), + int(x3), int(y3), + ] + + self.c.line(poly[0], poly[1], poly[2], poly[3]) + self.c.line(poly[2], poly[3], poly[4], poly[5]) + self.c.line(poly[4], poly[5], poly[0], poly[1]) + + def _draw_accel(self, acc): + ax, ay, az = acc + cx = self.c.W / 2.0 + cy = self.c.H / 2.0 + + x2 = cx + ax * self.c.W + y2 = cy + ay * self.c.W + + self.c.circle(int(x2), int(y2), int(self.c.W / 8)) + + def _draw_calib_box(self, vmin, vmax, vfirst, v, bad): + if v is None or vfirst is None: + return + + scale = 0.15 + + boxW = (vmax[0] - vmin[0]) * scale + boxH = -(vmax[1] - vmin[1]) * scale + boxX = (vmin[0] - vfirst[0]) * scale + self.c.W / 2.0 + boxY = -(vmin[1] - vfirst[1]) * scale + self.c.H / 2.0 + + x = (v[0] - vfirst[0]) * scale + self.c.W / 2.0 + y = -(v[1] - vfirst[1]) * scale + self.c.H / 2.0 + + # box rect + if bad: + bg = lv.color_make(255, 0, 0) + else: + bg = lv.color_make(0, 150, 0) + + x1 = int(boxX) + y1 = int(boxY) + x2 = int(boxX + boxW) + y2 = int(boxY + boxH) + + # normalize coords + xa = min(x1, x2) + xb = max(x1, x2) + ya = min(y1, y2) + yb = max(y1, y2) + + self.c.fill_rect(xa, ya, xb - xa, yb - ya, bg = bg) + + # point + self.c.fill_circle(int(x), int(y), 3, bg = lv.color_make(255, 255, 0)) + diff --git a/internal_filesystem/apps/cz.ucw.pavel.compass/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/cz.ucw.pavel.compass/res/mipmap-mdpi/icon_64x64.png new file mode 100644 index 0000000000000000000000000000000000000000..2b1f919fbaed011489c8794dc854a97b36ecb4a0 GIT binary patch literal 9440 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 Date: Sun, 22 Feb 2026 10:42:21 +0100 Subject: [PATCH 2/3] weather: start simple weather application Simple weather application, using open-meteo.com data. Once an hour weather is fetched and temperature/wind/sky condition is displayed. --- .../META-INF/MANIFEST.JSON | 24 ++ .../apps/cz.ucw.pavel.weather/assets/main.py | 225 ++++++++++++++++++ .../res/mipmap-mdpi/icon_64x64.png | Bin 0 -> 12342 bytes 3 files changed, 249 insertions(+) create mode 100644 internal_filesystem/apps/cz.ucw.pavel.weather/META-INF/MANIFEST.JSON create mode 100644 internal_filesystem/apps/cz.ucw.pavel.weather/assets/main.py create mode 100644 internal_filesystem/apps/cz.ucw.pavel.weather/res/mipmap-mdpi/icon_64x64.png diff --git a/internal_filesystem/apps/cz.ucw.pavel.weather/META-INF/MANIFEST.JSON b/internal_filesystem/apps/cz.ucw.pavel.weather/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..53b80338 --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.weather/META-INF/MANIFEST.JSON @@ -0,0 +1,24 @@ +{ +"name": "Weather", +"publisher": "Pavel Machek", +"short_description": "Display weather information.", +"long_description": "This displays weather information from open-meteo.com.", +"icon_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.weather/icons/cz.ucw.pavel.weather_0.0.1_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.weather/mpks/cz.ucw.pavel.weather_0.0.1.mpk", +"fullname": "cz.ucw.pavel.weather", +"version": "0.0.1", +"category": "utilities", +"activities": [ + { + "entrypoint": "assets/main.py", + "classname": "Main", + "intent_filters": [ + { + "action": "main", + "category": "launcher" + } + ] + } + ] +} + diff --git a/internal_filesystem/apps/cz.ucw.pavel.weather/assets/main.py b/internal_filesystem/apps/cz.ucw.pavel.weather/assets/main.py new file mode 100644 index 00000000..9258e47d --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.weather/assets/main.py @@ -0,0 +1,225 @@ +from mpos import Activity + +""" +Look at https://open-meteo.com/en/docs , then design an application that would display current time and weather, and summary of forecast ("no change expected for 2 days" or maybe "rain in 5 hours"), with a way to access detailed forecast. +""" + +import time +import os + +try: + import lvgl as lv +except ImportError: + pass + +from mpos import Activity, MposKeyboard + +import ujson +import utime +import usocket as socket +import ujson + +# ----------------------------- +# WEATHER DATA MODEL +# ----------------------------- + +class WData: + WMO_CODES = { + 0: "Clear sky", + 1: "Mainly clear", + 2: "Partly cloudy", + 3: "Overcast", + 45: "Fog", + 48: "Rime fog", + 51: "Light drizzle", + 53: "Drizzle", + 55: "Heavy drizzle", + 56: "Freezing drizzle", + 57: "Freezing drizzle", + 61: "Light rain", + 63: "Rain", + 65: "Heavy rain", + 66: "Freezing rain", + 67: "Freezing rain", + 71: "Light snow", + 73: "Snow", + 75: "Heavy snow", + 77: "Snow grains", + 80: "Rain showers", + 81: "Rain showers", + 82: "Heavy rain showers", + 85: "Snow showers", + 86: "Heavy snow showers", + 95: "Thunderstorm", + 96: "Thunderstorm + hail", + 99: "Thunderstorm + hail", + } + + def code_to_text(self, code): + return self.WMO_CODES.get(int(code), "Unknown") + +class Hourly(WData): + def __init__(self, cw): + self.temp = cw["temperature_2m"] + self.wind = cw["windspeed"] + self.code = self.code_to_text(cw["weather_code"]) + + def summarize(self): + return f"{self.code}\nTemp {self.temp}\nWind {self.wind}" + +class Weather: + name = "Prague" + lat = 50.08 + lon = 14.44 + + def __init__(self): + self.now = None + self.hourly = [] + self.daily = [] + self.summary = "(no weather)" + + def fetch(self): + self.summary = "...fetching..." + + # See https://open-meteo.com/en/docs?forecast_days=1¤t=relative_humidity_2m + + host = "api.open-meteo.com" + port = 80 # HTTP only + path = ( + "/v1/forecast?" + "latitude={}&longitude={}" + "¤t=temperature_2m,dewpoint_2m,pressure_msl,precipitation,weather_code,windspeed" + "&timezone=auto" + ).format(self.lat, self.lon) + + print("Weather fetch: ", path) + + # Resolve DNS + addr = socket.getaddrinfo(host, port, socket.AF_INET)[0][-1] + print("DNS", addr) + + s = socket.socket() + s.connect(addr) + + # Send HTTP request + request = ( + "GET {} HTTP/1.1\r\n" + "Host: {}\r\n" + "Connection: close\r\n\r\n" + ).format(path, host) + + s.send(request.encode()) + + # ---- Read response ---- + # Skip HTTP headers + buffer = b"" + while True: + chunk = s.recv(256) + if not chunk: + raise Exception("No response") + buffer += chunk + header_end = buffer.find(b"\r\n\r\n") + if header_end != -1: + body = buffer[header_end + 4:] + break + + + # Read remaining body + while True: + chunk = s.recv(512) + if not chunk: + break + body += chunk + + s.close() + + # Strip non-json parts + body = body[5:] + body = body[:-7] + + print("Have result:", body.decode()) + + # Parse JSON + data = ujson.loads(body) + + # ---- Extract data ---- + cw = data["current"] + self.now = Hourly(cw) + self.summary = self.now.summarize() + +weather = Weather() + +# ------------------------------------------------------------ +# Main activity +# ------------------------------------------------------------ + +class Main(Activity): + def __init__(self): + self.last_hour = 0 + super().__init__() + + # -------------------- + + def onCreate(self): + self.screen = lv.obj() + #self.screen.remove_flag(lv.obj.FLAG.SCROLLABLE) + scr_main = self.screen + + # ---- MAIN SCREEN ---- + + label_time = lv.label(scr_main) + label_time.set_text("(time)") + label_time.align(lv.ALIGN.TOP_LEFT, 10, 40) + label_time.set_style_text_font(lv.font_montserrat_24, 0) + self.label_time = label_time + + label_weather = lv.label(scr_main) + label_weather.set_text(f"Weather for {weather.name} ({weather.lat}, {weather.lon})") + label_weather.align_to(label_time, lv.ALIGN.OUT_BOTTOM_LEFT, 0, 10) + label_weather.set_style_text_font(lv.font_montserrat_14, 0) + self.label_weather = label_weather + + label_summary = lv.label(scr_main) + label_summary.set_text("(weather)") + #label_summary.set_long_mode(lv.label.LONG.WRAP) + label_summary.set_width(300) + label_summary.align_to(label_weather, lv.ALIGN.OUT_BOTTOM_LEFT, 0, 5) + label_summary.set_style_text_font(lv.font_montserrat_24, 0) + self.label_summary = label_summary + + btn_hourly = lv.button(scr_main) + btn_hourly.set_size(100, 40) + btn_hourly.align(lv.ALIGN.BOTTOM_LEFT, 10, -10) + lv.label(btn_hourly).set_text("Reload") + + btn_hourly.add_event_cb(lambda x: self.do_load(), lv.EVENT.CLICKED, None) + + self.setContentView(self.screen) + + def onResume(self, screen): + self.timer = lv.timer_create(self.tick, 15000, None) + self.tick(0) + + def onPause(self, screen): + if self.timer: + self.timer.delete() + self.timer = None + + # -------------------- + + def tick(self, t): + now = time.localtime() + y, m, d = now[0], now[1], now[2] + hh, mm, ss = now[3], now[4], now[5] + + if hh != self.last_hour: + self.last_hour = hh + self.do_load() + + self.label_time.set_text("%02d:%02d" % (hh, mm)) + self.label_summary.set_text(weather.summary) + + def do_load(self): + self.label_summary.set_text("Requesting...") + weather.fetch() + diff --git a/internal_filesystem/apps/cz.ucw.pavel.weather/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/cz.ucw.pavel.weather/res/mipmap-mdpi/icon_64x64.png new file mode 100644 index 0000000000000000000000000000000000000000..87df6b6275aeea67edcfb9f9b101568b579dcf41 GIT binary patch literal 12342 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 Date: Tue, 3 Feb 2026 00:53:46 +0100 Subject: [PATCH 3/3] columns: simple falling-blocks game This is a start of falling-blocks game. More improvements are possible (and some are described in the sources), but this is already playable. You can try to beat score of 120 :-). --- .../META-INF/MANIFEST.JSON | 25 ++ .../apps/cz.ucw.pavel.columns/assets/main.py | 337 ++++++++++++++++++ .../res/mipmap-mdpi/icon_64x64.png | Bin 0 -> 8820 bytes 3 files changed, 362 insertions(+) create mode 100644 internal_filesystem/apps/cz.ucw.pavel.columns/META-INF/MANIFEST.JSON create mode 100644 internal_filesystem/apps/cz.ucw.pavel.columns/assets/main.py create mode 100644 internal_filesystem/apps/cz.ucw.pavel.columns/res/mipmap-mdpi/icon_64x64.png diff --git a/internal_filesystem/apps/cz.ucw.pavel.columns/META-INF/MANIFEST.JSON b/internal_filesystem/apps/cz.ucw.pavel.columns/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..51d15601 --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.columns/META-INF/MANIFEST.JSON @@ -0,0 +1,25 @@ +{ +"name": "Columns", +"publisher": "Pavel Machek", +"short_description": "Falling columns game", +"long_description": "Blocks of 3 colors are falling. Align the colors to make blocks di\ +sappear.", +"icon_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.columns/icons/cz.ucw.pavel.columns_0.0.1_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.columns/mpks/cz.ucw.pavel.columns_0.0.1.mpk", +"fullname": "cz.ucw.pavel.columns", +"version": "0.0.1", +"category": "utilities", +"activities": [ + { + "entrypoint": "assets/main.py", + "classname": "Main", + "intent_filters": [ + { + "action": "main", + "category": "launcher" + } + ] + } + ] +} + diff --git a/internal_filesystem/apps/cz.ucw.pavel.columns/assets/main.py b/internal_filesystem/apps/cz.ucw.pavel.columns/assets/main.py new file mode 100644 index 00000000..4bbc58b5 --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.columns/assets/main.py @@ -0,0 +1,337 @@ +import time +import random + +""" +Columns -- falling columns game + +Possible TODOs: + +should blink explosions +explodes while moving? +/ in bottom left part may not explode + +smooth moving? +music? +some kind of game over? + +more contrast colors? +different shapes? + +""" + +from mpos import Activity + +try: + import lvgl as lv +except ImportError: + pass + +class Main(Activity): + + COLS = 6 + ROWS = 12 + + COLORS = [ + 0xE74C3C, # red + 0xF1C40F, # yellow + 0x2ECC71, # green + 0x3498DB, # blue + 0x9B59B6, # purple + ] + + EMPTY = -1 + + FALL_INTERVAL = 1000 # ms + # I can do 120 in this config :-). + + def __init__(self): + super().__init__() + self.board = [[self.EMPTY for _ in range(self.COLS)] for _ in range(self.ROWS)] + self.cells = [] + + self.active_col = self.COLS // 2 + self.active_row = -3 + self.active_colors = [] + + self.timer = None + self.animating = False + + # --------------------------------------------------------------------- + + def onCreate(self): + self.screen = lv.obj() + self.screen.remove_flag(lv.obj.FLAG.SCROLLABLE) + + vert = 60 + horiz = 60 + font = lv.font_montserrat_20 + + score = lv.label(self.screen) + score.align(lv.ALIGN.TOP_LEFT, 5, 25) + score.set_text("Score") + score.set_style_text_font(font, 0) + self.lb_score = score + + btn_left = lv.button(self.screen) + btn_left.set_size(horiz, vert) + btn_left.align(lv.ALIGN.BOTTOM_LEFT, 5, -10-vert) + btn_left.add_event_cb(lambda e: self.move(-1), lv.EVENT.CLICKED, None) + lc = lv.label(btn_left) + lc.set_style_text_font(font, 0) + lc.set_text("<") + lc.center() + + btn_right = lv.button(self.screen) + btn_right.set_size(horiz, vert) + btn_right.align(lv.ALIGN.BOTTOM_RIGHT, -5, -10-vert) + btn_right.add_event_cb(lambda e: self.move(1), lv.EVENT.CLICKED, None) + lc = lv.label(btn_right) + lc.set_style_text_font(font, 0) + lc.set_text(">") + lc.center() + + btn_rotate = lv.button(self.screen) + btn_rotate.set_size(horiz, vert) + btn_rotate.align(lv.ALIGN.BOTTOM_RIGHT, -5, -15-vert-vert) + btn_rotate.add_event_cb(lambda e: self.rotate(), lv.EVENT.CLICKED, None) + lc = lv.label(btn_rotate) + lc.set_style_text_font(font, 0) + lc.set_text("R") + lc.center() + + btn_down = lv.button(self.screen) + btn_down.set_size(horiz, vert) + btn_down.align(lv.ALIGN.BOTTOM_LEFT, 5, -5) + btn_down.add_event_cb(lambda e: self.tick(0), lv.EVENT.CLICKED, None) + lc = lv.label(btn_down) + lc.set_style_text_font(font, 0) + lc.set_text("v") + lc.center() + + d = lv.display_get_default() + self.SCREEN_WIDTH = d.get_horizontal_resolution() + self.SCREEN_HEIGHT = d.get_vertical_resolution() + + self.CELL = min( + self.SCREEN_WIDTH // (self.COLS + 1), + self.SCREEN_HEIGHT // (self.ROWS + 1) + ) + + board_x = (self.SCREEN_WIDTH - self.CELL * self.COLS) // 2 + board_y = (self.SCREEN_HEIGHT - self.CELL * self.ROWS) // 2 + + for r in range(self.ROWS): + row = [] + for c in range(self.COLS): + o = lv.obj(self.screen) + o.set_size(self.CELL - 2, self.CELL - 2) + o.set_pos( + board_x + c * self.CELL + 1, + board_y + r * self.CELL + 1 + ) + o.set_style_radius(4, 0) + o.set_style_bg_color(lv.color_hex(0x1C2833), 0) + o.set_style_border_width(1, 0) + row.append(o) + self.cells.append(row) + + # Make screen focusable for keyboard input + focusgroup = lv.group_get_default() + if focusgroup: + focusgroup.add_obj(self.screen) + + #self.screen.add_event_cb(self.on_touch, lv.EVENT.CLICKED, None) + self.screen.add_event_cb(self.on_key, lv.EVENT.KEY, None) + + self.setContentView(self.screen) + + self.new_game() + self.spawn_piece() + + + def new_game(self): + self.score = 0 + # --------------------------------------------------------------------- + + def onResume(self, screen): + self.timer = lv.timer_create(self.tick, self.FALL_INTERVAL, None) + + def onPause(self, screen): + if self.timer: + self.timer.delete() + self.timer = None + + # --------------------------------------------------------------------- + + def spawn_piece(self): + self.active_col = self.COLS // 2 + self.active_row = -3 + self.active_colors = [random.randrange(len(self.COLORS)) for _ in range(3)] + + def tick(self, t): + if self.can_fall(): + self.active_row += 1 + else: + self.lock_piece() + self.clear_matches() + self.spawn_piece() + + self.redraw() + + # --------------------------------------------------------------------- + + def can_fall(self): + for i in range(3): + r = self.active_row + i + 1 + c = self.active_col + if r >= self.ROWS: + return False + if r >= 0 and self.board[r][c] != self.EMPTY: + return False + return True + + def lock_piece(self): + for i in range(3): + r = self.active_row + i + if r >= 0: + self.board[r][self.active_col] = self.active_colors[i] + + # --------------------------------------------------------------------- + + def clear_matches(self): + to_clear = set() + score = 0 + + for r in range(self.ROWS): + for c in range(self.COLS): + color = self.board[r][c] + if color == self.EMPTY: + continue + + # horizontal + if c <= self.COLS - 3: + if all(self.board[r][c + i] == color for i in range(3)): + for i in range(3): + to_clear.add((r, c + i)) + score += 1 + + # vertical + if r <= self.ROWS - 3: + if all(self.board[r + i][c] == color for i in range(3)): + for i in range(3): + to_clear.add((r + i, c)) + score += 1 + + # diagonal \ + if r <= self.ROWS - 3 and c <= self.COLS - 3: + if all(self.board[r + i][c + i] == color for i in range(3)): + for i in range(3): + to_clear.add((r + i, c + i)) + score += 1 + + # diagonal / + if r <= self.ROWS - 3 and c > 2: + if all(self.board[r + i][c - i] == color for i in range(3)): + for i in range(3): + to_clear.add((r + i, c - i)) + score += 1 + + if not to_clear: + return + + print("Score: ", score) + self.score += score + self.lb_score.set_text("Score\n%d" % self.score) + for r, c in to_clear: + self.board[r][c] = self.EMPTY + + self.redraw() + time.sleep(.5) + self.apply_gravity() + self.redraw() + time.sleep(.5) + self.clear_matches() + self.redraw() + + def apply_gravity(self): + for c in range(self.COLS): + stack = [self.board[r][c] for r in range(self.ROWS) if self.board[r][c] != self.EMPTY] + for r in range(self.ROWS): + self.board[r][c] = self.EMPTY + for i, v in enumerate(reversed(stack)): + self.board[self.ROWS - 1 - i][c] = v + + # --------------------------------------------------------------------- + + def redraw(self): + # draw board + for r in range(self.ROWS): + for c in range(self.COLS): + v = self.board[r][c] + if v == self.EMPTY: + self.cells[r][c].set_style_bg_color(lv.color_hex(0x1C2833), 0) + else: + self.cells[r][c].set_style_bg_color( + lv.color_hex(self.COLORS[v]), 0 + ) + + # draw active piece + for i in range(3): + r = self.active_row + i + if r >= 0 and r < self.ROWS: + self.cells[r][self.active_col].set_style_bg_color( + lv.color_hex(self.COLORS[self.active_colors[i]]), 0 + ) + + # --------------------------------------------------------------------- + + def on_touch(self, e): + return + print("Touch event") + p = lv.indev_get_act().get_point() + x = p.x + + if x < self.SCREEN_WIDTH // 3: + self.move(-1) + elif x > self.SCREEN_WIDTH * 2 // 3: + self.move(1) + else: + self.rotate() + + def on_key(self, event): + """Handle keyboard input""" + print("Keyboard event") + key = event.get_key() + if key == ord("a"): + self.move(-1) + return + if key == ord("w"): + self.rotate() + return + if key == ord("d"): + self.move(1) + return + if key == ord("s"): + self.tick(0) + return + + #if key == lv.KEY.ENTER or key == lv.KEY.UP or key == ord("A") or key == ord("a"): + print(f"on_key: unhandled key {key}") + + def move(self, dx): + nc = self.active_col + dx + if not(0 <= nc < self.COLS): + return + + for i in range(3): + r = self.active_row + i + if self.board[r][nc] != self.EMPTY: + return + + self.active_col = nc + self.redraw() + + def rotate(self): + self.active_colors = self.active_colors[-1:] + self.active_colors[:-1] + self.redraw() + diff --git a/internal_filesystem/apps/cz.ucw.pavel.columns/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/cz.ucw.pavel.columns/res/mipmap-mdpi/icon_64x64.png new file mode 100644 index 0000000000000000000000000000000000000000..49812a67e41c1cc2a86d6e37a8d318be137fb5a2 GIT binary patch literal 8820 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 literal 0 HcmV?d00001