From 6a81e5481fb4c9b0f9422e3690858eeee8af59f6 Mon Sep 17 00:00:00 2001 From: Pavel Machek Date: Tue, 17 Feb 2026 13:36:06 +0100 Subject: [PATCH] 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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