From 603132577e8f3e147f8a83ca876c079294f67b9e Mon Sep 17 00:00:00 2001 From: Pavel Machek Date: Sat, 14 Feb 2026 18:53:23 +0100 Subject: [PATCH 01/16] navstar: Simple navigation application It can display GPS information, show arrow to selected point and save track. It can use several location sources, including /dev/gnss0; by default, it is configured with fake nmea source. --- .../META-INF/MANIFEST.JSON | 24 + .../apps/cz.ucw.pavel.navstar/assets/main.py | 1621 +++++++++++++++++ .../cz.ucw.pavel.navstar/assets/pcanvas.py | 306 ++++ .../res/mipmap-mdpi/icon_64x64.png | Bin 0 -> 6751 bytes 4 files changed, 1951 insertions(+) create mode 100644 internal_filesystem/apps/cz.ucw.pavel.navstar/META-INF/MANIFEST.JSON create mode 100644 internal_filesystem/apps/cz.ucw.pavel.navstar/assets/main.py create mode 100644 internal_filesystem/apps/cz.ucw.pavel.navstar/assets/pcanvas.py create mode 100644 internal_filesystem/apps/cz.ucw.pavel.navstar/res/mipmap-mdpi/icon_64x64.png diff --git a/internal_filesystem/apps/cz.ucw.pavel.navstar/META-INF/MANIFEST.JSON b/internal_filesystem/apps/cz.ucw.pavel.navstar/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..58b4638a --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.navstar/META-INF/MANIFEST.JSON @@ -0,0 +1,24 @@ +{ +"name": "Navstar", +"publisher": "Pavel Machek", +"short_description": "Simple navigation app.", +"long_description": "Simple navigation app using data from NAVSTAR GPS and other GNSS systems.", +"icon_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.xxx/icons/cz.ucw.pavel.xxx_0.0.1_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.xxx/mpks/cz.ucw.pavel.xxx_0.0.1.mpk", +"fullname": "cz.ucw.pavel.xxx", +"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.navstar/assets/main.py b/internal_filesystem/apps/cz.ucw.pavel.navstar/assets/main.py new file mode 100644 index 00000000..62270fc1 --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.navstar/assets/main.py @@ -0,0 +1,1621 @@ +from mpos import Activity + +""" +micropythonos, give me code to parse nmea data from gps, display lat/lon/speed/... display sky view, allow recording of track to egt, display current track length in kilometers, and allow navigation to a point. + +""" + +import time +import os +import uselect +import json +import time +import math +import re + +from pcanvas import * + +try: + import lvgl as lv +except ImportError: + pass + +import mpos +from mpos import Activity, MposKeyboard + +# +# Features: +# - NMEA parsing: RMC, GGA, GSV +# - Live data: lat/lon/speed/alt/course/time/fix/sats/hdop +# - Sky view from GSV +# - Track recording to EGT +# - Track length (km) +# - Navigation to a point: bearing + distance +# +# Reality filter: +# - Sky view uses only azimuth/elevation from GSV, which many GPS modules output, +# but some modules omit/limit GSV. In that case the sky view will be empty. +# - EGT is a simple plaintext format defined here (not a standard). + + +# ---------------------------- +# Small utilities +# ---------------------------- + +def clamp(x, lo, hi): + if x < lo: + return lo + if x > hi: + return hi + return x + + +def nmea_checksum_ok(line): + # line includes leading '$' and optional \r\n + line = line.strip() + if not line.startswith("$"): + return False + star = line.find("*") + if star < 0: + return False + body = line[1:star] + given = line[star + 1:] + if len(given) < 2: + return False + try: + want = int(given[:2], 16) + except ValueError: + return False + + c = 0 + for ch in body: + c ^= ord(ch) + return c == want + + +def safe_float(s): + try: + return float(s) + except Exception: + return None + + +def safe_int(s): + try: + return int(s) + except Exception: + return None + + +def knots_to_kmh(knots): + return knots * 1.852 + + +def deg_to_rad(d): + return d * math.pi / 180.0 + + +def rad_to_deg(r): + return r * 180.0 / math.pi + + +def haversine_km(lat1, lon1, lat2, lon2): + # Great-circle distance + R = 6371.0088 + phi1 = deg_to_rad(lat1) + phi2 = deg_to_rad(lat2) + dphi = deg_to_rad(lat2 - lat1) + dl = deg_to_rad(lon2 - lon1) + + a = math.sin(dphi / 2.0) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dl / 2.0) ** 2 + c = 2.0 * math.atan2(math.sqrt(a), math.sqrt(1.0 - a)) + return R * c + + +def bearing_deg(lat1, lon1, lat2, lon2): + # Initial bearing from point1 -> point2 + phi1 = deg_to_rad(lat1) + phi2 = deg_to_rad(lat2) + dl = deg_to_rad(lon2 - lon1) + + y = math.sin(dl) * math.cos(phi2) + x = math.cos(phi1) * math.sin(phi2) - math.sin(phi1) * math.cos(phi2) * math.cos(dl) + br = math.atan2(y, x) + brd = (rad_to_deg(br) + 360.0) % 360.0 + return brd + + +def parse_latlon(ddmm, hemi): + # NMEA format: latitude ddmm.mmmm, longitude dddmm.mmmm + if not ddmm or not hemi: + return None + + v = safe_float(ddmm) + if v is None: + return None + + # Split degrees and minutes + # For lat: 2 deg digits; for lon: 3 deg digits + # We infer by length before decimal. + s = ddmm + dot = s.find(".") + if dot < 0: + dot = len(s) + + deg_digits = 2 + if dot > 4: + deg_digits = 3 + + try: + deg = int(s[:deg_digits]) + minutes = float(s[deg_digits:]) + except Exception: + return None + + dec = deg + (minutes / 60.0) + if hemi in ("S", "W"): + dec = -dec + return dec + + +def parse_hhmmss(hhmmss): + # Returns (h,m,s) or None + if not hhmmss or len(hhmmss) < 6: + return None + try: + h = int(hhmmss[0:2]) + m = int(hhmmss[2:4]) + s = int(hhmmss[4:6]) + return (h, m, s) + except Exception: + return None + + +def parse_ddmmyy(ddmmyy): + if not ddmmyy or len(ddmmyy) != 6: + return None + try: + d = int(ddmmyy[0:2]) + mo = int(ddmmyy[2:4]) + y = int(ddmmyy[4:6]) + 2000 + return (y, mo, d) + except Exception: + return None + +class Config: + pass + +config = Config() +config.lat = None +config.lon = None +config.name = "" +config.recording = False + +# ---------------------------- +# NMEA state model +# ---------------------------- + +class GPSState: + def __init__(self): + self.start = time.time() + self.start_good = self.start + + # Position / motion + self.lat = None + self.lon = None + self.alt_m = None + self.speed_kmh = None + self.course_deg = None + + # Fix / quality + self.fix_quality = 0 # from GGA + self.fix_valid = False # from RMC + self.sats_used = 0 + self.hdop = None + + # Time + self.time_hms = None + self.date_ymd = None + + # Satellites in view from GSV: + # dict prn -> {el, az, snr} + self.sats_in_view = {} + + # For display freshness + self.last_update_ms = 0 + + def has_fix(self): + # Require RMC valid + lat/lon present + return self.fix_valid and (self.lat is not None) and (self.lon is not None) + + def summary(self): + num = 0 + good = 0 + best_snr = 0 + snrlim = 25 + print(self.sats_in_view) + for prn in self.sats_in_view: + d = self.sats_in_view[prn] + snr = d.get("snr") + num += 1 + if snr: + if snr > snrlim: + good += 1 + if best_snr < snr: + best_snr = snr + + now = time.time() + if good < 4: + self.start_good = now + + if self.has_fix(): + if good >=4: + return f"Have FIX, good sky, hdop {self.hdop}" + + return f"FIX, bad sky {good}/{num}" + + if best_snr < snrlim: + if best_snr > 0: + return f"Need some sky {best_snr} dB" + return f"Need some sky {num} sats" + + if good < 4: + return f"Need clear sky {good}/{num}" + + delta = now - self.start_good + return f"Need a minute {delta:.0f}s" + + delta = now - self.start + return f"No fix for {delta:.0f}" + + +class NMEAParser: + def __init__(self, gps_state): + self.gps = gps_state + + # GSV is multi-part, but we do not need to store parts, + # we just update sats_in_view as they arrive. + # Some modules send multiple talker IDs: GP, GN, GL, GA... + # We'll accept any. + + def feed_line(self, line): + line = line.strip() + if not line.startswith("$"): + return + + if not nmea_checksum_ok(line): + return + + # Strip $ and checksum + star = line.find("*") + body = line[1:star] + fields = body.split(",") + if len(fields) < 1: + return + + msg = fields[0] + # msg like GPRMC, GNRMC, etc. + if len(msg) < 5: + return + + msg_type = msg[-3:] + + if msg_type == "RMC": + self._parse_rmc(fields) + elif msg_type == "GGA": + self._parse_gga(fields) + elif msg_type == "GSV": + self._parse_gsv(fields) + + self.gps.last_update_ms = time.ticks_ms() + + def _parse_rmc(self, f): + # $GPRMC,hhmmss.sss,A,llll.ll,a,yyyyy.yy,a,x.x,x.x,ddmmyy,x.x,a*hh + # 0 1 2 3 4 5 6 7 8 9 ... + if len(f) < 10: + return + + self.gps.time_hms = parse_hhmmss(f[1]) + status = f[2] + self.gps.fix_valid = (status == "A") + + lat = parse_latlon(f[3], f[4]) + lon = parse_latlon(f[5], f[6]) + + if lat is not None and lon is not None: + self.gps.lat = lat + self.gps.lon = lon + + sp_kn = safe_float(f[7]) + if sp_kn is not None: + self.gps.speed_kmh = knots_to_kmh(sp_kn) + + course = safe_float(f[8]) + if course is not None: + self.gps.course_deg = course + + self.gps.date_ymd = parse_ddmmyy(f[9]) + + def _parse_gga(self, f): + # $GPGGA,hhmmss.sss,lat,NS,lon,EW,quality,numSV,HDOP,alt,M,... + if len(f) < 10: + return + + self.gps.time_hms = parse_hhmmss(f[1]) + + lat = parse_latlon(f[2], f[3]) + lon = parse_latlon(f[4], f[5]) + if lat is not None and lon is not None: + self.gps.lat = lat + self.gps.lon = lon + + q = safe_int(f[6]) + if q is not None: + self.gps.fix_quality = q + + sats = safe_int(f[7]) + if sats is not None: + self.gps.sats_used = sats + + hdop = safe_float(f[8]) + if hdop is not None: + self.gps.hdop = hdop + + alt = safe_float(f[9]) + if alt is not None: + self.gps.alt_m = alt + + def _parse_gsv(self, f): + # $GPGSV,total_msgs,msg_num,total_sats, [sat blocks...] + # Each sat block: prn, elev, az, snr + if len(f) < 4: + return + + # total_msgs = safe_int(f[1]) + # msg_num = safe_int(f[2]) + total_sats = safe_int(f[3]) + if total_sats is not None: + # not exactly "used", but we store it in view count indirectly + pass + + # sat blocks start at index 4 + i = 4 + while i + 3 < len(f): + prn = safe_int(f[i + 0]) + el = safe_int(f[i + 1]) + az = safe_int(f[i + 2]) + snr = safe_int(f[i + 3]) + i += 4 + + if prn is None: + continue + + d = self.gps.sats_in_view.get(prn) + if d is None: + d = {} + self.gps.sats_in_view[prn] = d + + if el is not None: + d["el"] = el + if az is not None: + d["az"] = az + if snr is not None: + d["snr"] = snr + + +# ---------------------------- +# Track recording (EGT) +# ---------------------------- + +class EGTWriter: + """ + EGT (Editable GPS Track) - a minimal plaintext format. + + So... recording to gpx is not really suitable, as that format is hard to modify by tools such as head/tail/tac. It also has trailer, so if you reboot while recording, you would end up with invalid file. + + Format description and some tools to work with these are available at tui/gtracks. + """ + + def __init__(self): + self.fp = None + self.started = False + + def start(self, filename): + self.filename = filename + if self.fp: + return + self.fp = open(self.filename, "a") + if not self.started: + self.fp.write("# EGT 1\n") + self.fp.write("# fields: lat lon alt_m speed_kmh course_deg sats_used hdop time date\n") + self.started = True + self.fp.flush() + + def stop(self): + if self.fp: + self.fp.flush() + self.fp.close() + self.fp = None + + def write_point(self, gps): + if not self.fp: + return + if not gps.has_fix(): + return + + lat = gps.lat + lon = gps.lon + alt = gps.alt_m if gps.alt_m is not None else -9999.0 + spd = gps.speed_kmh if gps.speed_kmh is not None else 0.0 + crs = gps.course_deg if gps.course_deg is not None else 0.0 + sats = gps.sats_used + hdop = gps.hdop if gps.hdop is not None else -1.0 + + if gps.time_hms: + t = "%02d:%02d:%02d" % gps.time_hms + else: + t = "--:--:--" + + if gps.date_ymd: + y, mo, d = gps.date_ymd + da = "%04d-%02d-%02d" % (y, mo, d) + else: + da = "---- -- --" + + #self.fp.write("P %.7f %.7f %.1f %.2f %.1f %d %.2f %s %s\n" % (lat, lon, alt, spd, crs, sats, hdop, t, da)) + self.fp.write("%.7f %.7f\n" % (lat, lon)) + #self.fp.flush() + + +class Track: + def __init__(self): + self.points = [] # list of (lat, lon) + self.length_km = 0.0 + + def reset(self): + self.points = [] + self.length_km = 0.0 + + def add_point(self, lat, lon): + if lat is None or lon is None: + return + + if len(self.points) > 0: + lat0, lon0 = self.points[-1] + d = haversine_km(lat0, lon0, lat, lon) + # Basic noise suppression: ignore jumps < 2m + if d < 0.002: + return + self.length_km += d + + self.points.append((lat, lon)) + +# ---------------------------- +# Navigation target +# ---------------------------- + +class NavTarget: + def __init__(self): + self.enabled = False + self.lat = None + self.lon = None + self.name = "TARGET" + + def set(self, lat, lon, name=None): + self.lat = lat + self.lon = lon + self.enabled = True + if name: + self.name = name + + def clear(self): + self.enabled = False + self.lat = None + self.lon = None + + + +# ---------------------------- +# App logic +# ---------------------------- + +class Main(PagedCanvas): + def __init__(self): + super().__init__() + self.gps = GPSState() + self.parser = NMEAParser(self.gps) + + self.track = Track() + self.egt = EGTWriter() + self.recording = False + + self.nav = NavTarget() + + self.last_track_write_ms = 0 + self.last_track_add_ms = 0 + + self.uart = None + + # Default nav point (Prague center) - change as desired + # (Reality filter: this is just a reasonable example coordinate.) + self.nav.set(50.087465, 14.421254, "Prague") + + def onResume(self, screen): + if not config.lon is None: + self.nav.name = config.name + self.nav.lon = config.lon + self.nav.lat = config.lat + self.recording = config.recording + self.toggle_recording() + self.timer = lv.timer_create(self.tick, 1000, None) + + def tick(self, t): + lm.poll() + nmea = lm.get_nmea() + if nmea: + lines = nmea.split('\n') + for line in lines: + #print("line", line) + self.parser.feed_line(line) + self.update() + self.draw() + + def build_buttons(self): + self.template_buttons(["Basic", "Sky", "Goto", "Rec", "..."]) + + def _btn_cb(self, evt, tag): + self.page = tag + if tag == 4: + intent = mpos.Intent(activity_class=EnterTarget) + self.startActivity(intent) + + def toggle_recording(self): + if self.recording: + track_file=f"track-{time.time()}.egt" + self.egt.start(track_file) + else: + self.egt.stop() + + def set_nav_target_here(self): + if self.gps.has_fix(): + self.nav.set(self.gps.lat, self.gps.lon, "HERE") + + def clear_track(self): + self.track.reset() + + def read_uart(self): + if not self.uart: + return + + # We read line-by-line. Many GPS modules end lines with \r\n. + while True: + line = self.uart.readline() + if not line: + break + try: + s = line.decode("ascii", "ignore") + except Exception: + continue + self.parser.feed_line(s) + + def maybe_update_track(self): + if not self.gps.has_fix(): + return + + now = time.ticks_ms() + + # Add a track point at ~1 Hz + if time.ticks_diff(now, self.last_track_add_ms) > 1000: + self.last_track_add_ms = now + self.track.add_point(self.gps.lat, self.gps.lon) + + # Write to file at ~1 Hz if recording + if self.recording and time.ticks_diff(now, self.last_track_write_ms) > 1000: + self.last_track_write_ms = now + self.egt.write_point(self.gps) + + def draw_page_status(self): + gps = self.gps + ui = self.c + + ui.clear() + + st = 28 + y = 1*st + fix = "FIX" if gps.has_fix() else "NOFIX" + rec = "REC" if self.recording else "----" + ui.text(0, y, "%s %s sats:%d" % (fix, rec, gps.sats_used)) + y += st + ui.text(0, y, "%s" % gps.summary()) + y += 2*st + + if gps.lat is not None and gps.lon is not None: + ui.text(0, y, "Lat: %.6f" % gps.lat) + ui.text(0, y+st, "Lon: %.6f" % gps.lon) + else: + ui.text(0, y, "Lat: ---") + ui.text(0, y+st, "Lon: ---") + y += 2*st + + if gps.speed_kmh is not None: + ui.text(0, y, "Speed: %.1f km/h" % gps.speed_kmh) + else: + ui.text(0, y, "Speed: ---") + y += st + + if gps.alt_m is not None: + ui.text(0, y, "Alt: %.1f m" % gps.alt_m) + else: + ui.text(0, y, "Alt: ---") + y += st + + if gps.course_deg is not None: + ui.text(0, y, "Head: %.0f deg" % gps.course_deg) + else: + ui.text(0, y, "Head: ---") + y += st + + ui.text(0, y, "Track: %.3f km" % self.track.length_km) + y += st + + if gps.hdop is not None: + ui.text(0, y, "HDOP: %.1f" % gps.hdop) + y += st + + if gps.time_hms: + ui.text(0, y, "Time: %02d:%02d:%02d" % gps.time_hms) + y += st + #print("Final size: ", y) + + ui.update() + + def draw_page_sky(self): + gps = self.gps + ui = self.c + + ui.clear() + ui.text(0, 22, "Sky view") + + # Sky view circle + cx = 200 + cy = 110 + R = 90 + + ui.circle(cx, cy, R) + ui.circle(cx, cy, int(R * 0.66)) + ui.circle(cx, cy, int(R * 0.33)) + ui.line(cx - R, cy, cx + R, cy) + ui.line(cx, cy - R, cx, cy + R) + + # Plot satellites + # NMEA: elevation 0..90, azimuth 0..359 + # Map elevation: 90 at center, 0 at edge + count = 0 + for prn in gps.sats_in_view: + d = gps.sats_in_view[prn] + el = d.get("el") + az = d.get("az") + snr = d.get("snr") + + if el is None or az is None: + continue + + # radial distance + r = (90 - el) / 90.0 + r = clamp(r, 0.0, 1.0) * R + + a = deg_to_rad(az - 90) # rotate so 0 deg is up + x = int(cx + r * math.cos(a)) + y = int(cy + r * math.sin(a)) + + # Dot size from SNR + if snr is None: + rr = 1 + else: + rr = 1 + int(clamp(snr-10, 0, 15)) / 3 + + ui.fill_circle(x, y, rr) + count += 1 + + ui.text(0, cy + R - 35, "SV: %d" % count) + ui.update() + + def draw_page_nav(self): + gps = self.gps + ui = self.c + st = 28 + + ui.clear() + + draw_nav_screen(ui, self.gps, self.track.points, self.nav.lat, self.nav.lon) + + y = st + + if not self.nav.enabled: + ui.text(0, st, "No target.") + ui.update() + return + + if gps.has_fix(): + dist = haversine_km(gps.lat, gps.lon, self.nav.lat, self.nav.lon) + brg = bearing_deg(gps.lat, gps.lon, self.nav.lat, self.nav.lon) + + ui.text(0, y, "Dist: %.3f km" % dist) + ui.text(0, y+st, "Bear: %.0f deg" % brg) + + if gps.course_deg is not None: + rel = (brg - gps.course_deg + 360.0) % 360.0 + if rel > 180.0: + rel -= 360.0 + ui.text(0, y+2*st, "Turn: %+d deg" % int(rel)) + + else: + ui.text(0, y, "Waiting for fix...") + y += st*3 + + ui.text(0, y, "%s" % self.nav.name) + ui.text(0, y+st, "Lat: %.4f" % self.nav.lat) + ui.text(0, y+st*2, "Lon: %.4f" % self.nav.lon) + y += st*3 + + ui.update() + + def draw_page_record(self): + gps = self.gps + ui = self.c + + ui.clear() + + st = 28 + y = st + fix = "FIX" if gps.has_fix() else "NOFIX" + rec = "REC" if self.recording else "----" + if False: + ui.text(0, y, "%s %s sats:%d" % (fix, rec, gps.sats_used)) + y += st + ui.text(0, y, "%s" % gps.summary()) + y += 2*st + + if gps.speed_kmh is not None: + ui.text(0, y, "Speed: %.1f km/h" % gps.speed_kmh) + else: + ui.text(0, y, "Speed: ---") + y += st + + if gps.alt_m is not None: + ui.text(0, y, "Alt: %.1f m" % gps.alt_m) + else: + ui.text(0, y, "Alt: ---") + y += st + + ui.text(0, y, "Track: %.3f km" % self.track.length_km) + y += st + + #print("Final size: ", y) + + ui.update() + + def update(self): + self.maybe_update_track() + + def draw(self): + if self.page == 0: + self.draw_page_status() + elif self.page == 1: + self.draw_page_sky() + elif self.page == 2: + self.draw_page_nav() + elif self.page == 3: + self.draw_page_record() + else: + self.draw_page_example() + + def handle_buttons(self): + ui = self.c + + if ui.button_next_page(): + ui.page = (ui.page + 1) % ui.pages + + if ui.button_toggle_record(): + self.toggle_recording() + + if ui.button_set_nav_target(): + # Here we implement: "set target to current position" + # If you want manual entry, see note below. + self.set_nav_target_here() + + if ui.button_clear_track(): + self.clear_track() + +# ---------------------------- +# GPS hardware handling +# ---------------------------- + +TMP = "/tmp/cmd.json" + +def run_cmd_json(cmd): + rc = os.system(cmd + " > " + TMP) + if rc != 0: + raise RuntimeError("command failed") + + with open(TMP, "r") as f: + data = f.read().strip() + + return json.loads(data) + +def dbus_json(cmd): + return run_cmd_json("sudo /home/mobian/g/MicroPythonOS/phone.py " + cmd) + +class LocationManagerDBUS: + def poll(self): + v = dbus_json("loc") + print(v) + self.loc = v + + def get_cellid(self): + if "1" in self.loc: + return self.loc["1"] + return None + + def get_nmea(self): + if "4" in self.loc: + return self.loc["4"] + return None + + +class LocationManager: + def __init__(self): + path = "/dev/gnss0" + self.f = open(path, "rb") + self.sel = uselect.poll() + self.sel.register(self.f, uselect.POLLIN) + self.data = b"" + + def poll(self): + while True: + events = self.sel.poll(0) # non-blocking + if not events: + break + self.data += self.f.readline() + + def get_cellid(self): + return None + + def get_nmea(self): + d = self.data + print(d) + self.data = b"" + return d.decode("ascii", "ignore") + +# ---------------------------- +# Fake NMEA source +# ---------------------------- + +def nmea_checksum(sentence_body): + # sentence_body without leading '$' and without '*xx' + c = 0 + for ch in sentence_body: + c ^= ord(ch) + return "%02X" % c + + +def nmea_wrap(sentence_body): + return "$%s*%s" % (sentence_body, nmea_checksum(sentence_body)) + + +def deg_to_nmea_lat(lat_deg): + # ddmm.mmmm, N/S + sign = "N" + if lat_deg < 0: + sign = "S" + lat_deg = -lat_deg + + dd = int(lat_deg) + mm = (lat_deg - dd) * 60.0 + return "%02d%07.4f" % (dd, mm), sign + + +def deg_to_nmea_lon(lon_deg): + # dddmm.mmmm, E/W + sign = "E" + if lon_deg < 0: + sign = "W" + lon_deg = -lon_deg + + ddd = int(lon_deg) + mm = (lon_deg - ddd) * 60.0 + return "%03d%07.4f" % (ddd, mm), sign + + +class FakeNMEASpiral: + """ + Fake NMEA generator for testing. + + Simulates a spiral around a center coordinate: + center_lat=50.0, center_lon=14.0 + + Generates: + - GGA + - RMC + - GSV (fake sats) + + Usage: + sim = FakeNMEASpiral() + lines = sim.next_sentences() # list of NMEA lines (strings) + """ + + def __init__(self, + center_lat=50.0, + center_lon=14.0, + alt_m=260.0, + start_radius_m=0.0, + radius_growth_m_per_s=0.25, + angular_speed_deg_per_s=18.0, + speed_noise=0.05, + sat_count=10, + seed_time=None): + self.center_lat = float(center_lat) + self.center_lon = float(center_lon) + self.alt_m = float(alt_m) + + self.r0 = float(start_radius_m) + self.r_growth = float(radius_growth_m_per_s) + self.w_deg = float(angular_speed_deg_per_s) + + self.speed_noise = float(speed_noise) + + self.sat_count = int(sat_count) + self.sats = self._make_fake_sats(self.sat_count) + + if seed_time is None: + seed_time = time.time() + + self.t0 = float(seed_time) + self.last_t = self.t0 + + self.last_lat = self.center_lat + self.last_lon = self.center_lon + self.last_course = 0.0 + self.last_speed_mps = 0.0 + + # NMEA-ish fields + self.hdop = 0.9 + self.fix_quality = 1 # 1=GPS fix + self.num_sats = clamp(self.sat_count, 4, 12) + + def _make_fake_sats(self, n): + # PRN, elevation, azimuth, snr + sats = [] + for i in range(n): + prn = 1 + i + el = 15 + (i * 7) % 70 + az = (i * 360.0 / n) % 360.0 + snr = 20 + (i * 3) % 30 + sats.append((prn, el, az, snr)) + return sats + + def _spiral_position(self, t): + # t in seconds since t0 + dt = t - self.t0 + + r = self.r0 + self.r_growth * dt # meters + ang_deg = (self.w_deg * dt) % 360.0 + ang = math.radians(ang_deg) + + # local ENU offsets (east, north) in meters + east = r * math.cos(ang) + north = r * math.sin(ang) + + # convert meters -> degrees + lat = self.center_lat + (north / 111132.0) + lon = self.center_lon + (east / (111320.0 * math.cos(math.radians(self.center_lat)))) + + return lat, lon, r, ang_deg + + def _course_and_speed(self, lat, lon, dt): + # compute speed and course from last point (very simple) + if dt <= 0.0: + return self.last_course, self.last_speed_mps + + # local approx meters + phi = math.radians(self.center_lat) + m_per_deg_lat = 111132.0 + m_per_deg_lon = 111320.0 * math.cos(phi) + + dlat = (lat - self.last_lat) * m_per_deg_lat + dlon = (lon - self.last_lon) * m_per_deg_lon + + # north/east + north = dlat + east = dlon + + dist = math.sqrt(north * north + east * east) + speed = dist / dt + + # course: 0=north, 90=east + course = math.degrees(math.atan2(east, north)) % 360.0 + + # add tiny deterministic noise + speed *= (1.0 + self.speed_noise * math.sin((time.time() - self.t0) * 0.7)) + + return course, speed + + def _utc_hhmmss(self, t): + #dt = datetime.datetime.utcfromtimestamp(t) + #return dt.strftime("%H%M%S") + ".00" + return "123456.00" + + def _utc_ddmmyy(self, t): + #dt = datetime.datetime.utcfromtimestamp(t) + #return dt.strftime("%d%m%y") + return "311122" + + def next_sentences(self, t=None, include_gsv=True): + """ + Return list of NMEA sentences (strings). + """ + if t is None: + t = time.time() + + dt = t - self.last_t + lat, lon, r_m, ang_deg = self._spiral_position(t) + course, speed_mps = self._course_and_speed(lat, lon, dt) + + # update state + self.last_t = t + self.last_lat = lat + self.last_lon = lon + self.last_course = course + self.last_speed_mps = speed_mps + + # NMEA formatting + hhmmss = self._utc_hhmmss(t) + ddmmyy = self._utc_ddmmyy(t) + + lat_s, lat_hemi = deg_to_nmea_lat(lat) + lon_s, lon_hemi = deg_to_nmea_lon(lon) + + speed_knots = speed_mps * 1.94384449 + + # --- GGA + # $GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47 + gga_body = "GPGGA,%s,%s,%s,%s,%s,%d,%02d,%.1f,%.1f,M,0.0,M,," % ( + hhmmss, + lat_s, lat_hemi, + lon_s, lon_hemi, + self.fix_quality, + self.num_sats, + self.hdop, + self.alt_m, + ) + + # --- RMC + # $GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A + # We omit magnetic variation field -> empty. + rmc_body = "GPRMC,%s,A,%s,%s,%s,%s,%.2f,%.1f,%s,," % ( + hhmmss, + lat_s, lat_hemi, + lon_s, lon_hemi, + speed_knots, + course, + ddmmyy, + ) + + out = [ + nmea_wrap(gga_body), + nmea_wrap(rmc_body), + ] + + if include_gsv: + out.extend(self._gsv_sentences()) + + return out + + def poll(self): + self.data = '\n'.join(self.next_sentences()) + + def get_cellid(self): + return None + + def get_nmea(self): + return self.data + + def _gsv_sentences(self): + # GSV: 4 sats per message + sats = self.sats + total = len(sats) + per = 4 + msgs = (total + per - 1) // per + out = [] + + for mi in range(msgs): + chunk = sats[mi * per:(mi + 1) * per] + fields = ["GPGSV", str(msgs), str(mi + 1), str(total)] + for (prn, el, az, snr) in chunk: + fields.extend([ + "%02d" % prn, + "%02d" % int(el), + "%03d" % int(az), + "%02d" % int(snr), + ]) + body = ",".join(fields) + out.append(nmea_wrap(body)) + + return out + +# ----------------------------- +# Helpers +# ----------------------------- + +def norm_deg(d): + # normalize to 0..360 + d = d % 360.0 + if d < 0: + d += 360.0 + return d + + +def bearing_deg(lat1, lon1, lat2, lon2): + # initial bearing (true) in degrees, 0..360 + # Inputs in degrees. + phi1 = deg_to_rad(lat1) + phi2 = deg_to_rad(lat2) + dlon = deg_to_rad(lon2 - lon1) + + y = math.sin(dlon) * math.cos(phi2) + x = math.cos(phi1) * math.sin(phi2) - math.sin(phi1) * math.cos(phi2) * math.cos(dlon) + brng = rad_to_deg(math.atan2(y, x)) + return norm_deg(brng) + + +def haversine_m(lat1, lon1, lat2, lon2): + return haversine_km(lat1, lon1, lat2, lon2) * 1000 + + +def meters_to_human(m): + if m is None: + return "?" + if m < 1000.0: + return "%dm" % int(m + 0.5) + return "%.2fkm" % (m / 1000.0) + + +def ms_to_kmh(v): + if v is None: + return None + return v * 3.6 + + +def kmh_to_human(kmh): + if kmh is None: + return "?" + if kmh < 10: + return "%.1f km/h" % kmh + return "%.0f km/h" % kmh + + +def draw_arrow(ui, x0, y0, x1, y1, head_len=14, head_ang_deg=28): + # main shaft + ui.line(x0, y0, x1, y1) + + # arrow head + ang = math.atan2(y1 - y0, x1 - x0) + ha = deg_to_rad(head_ang_deg) + + xh1 = int(x1 - head_len * math.cos(ang - ha)) + yh1 = int(y1 - head_len * math.sin(ang - ha)) + + xh2 = int(x1 - head_len * math.cos(ang + ha)) + yh2 = int(y1 - head_len * math.sin(ang + ha)) + + ui.line(x1, y1, xh1, yh1) + ui.line(x1, y1, xh2, yh2) + + +def polar_to_xy(cx, cy, r, angle_deg): + # angle_deg: 0 is up, 90 is right (screen coords) + a = deg_to_rad(angle_deg - 90.0) + x = int(cx + r * math.cos(a)) + y = int(cy + r * math.sin(a)) + return x, y + + +# ----------------------------- +# Main draw routine +# ----------------------------- + +def draw_nav_screen(ui, gps, trail, + dest_lat, dest_lon, + mag_declination_deg=None): + """ + Expected gps fields (typical gpsd-ish): + gps.lat, gps.lon + gps.speed_ms (or gps.speed) + gps.track_deg (COG, or gps.track) + gps.fix_ok (bool) + + trail: list of dicts: {"lat":..., "lon":...} newest last + mag_declination_deg: + If known for your region (e.g. Prague ~ 4-5 deg E in 2025-ish), + pass it here. If unknown, pass None and M will not be drawn. + """ + + # --- Geometry + cx = 200 + cy = 110 + R = 90 + + # --- Draw compass rose + ui.circle(cx, cy, R) + ui.line(cx - R, cy, cx + R, cy) + ui.line(cx, cy - R, cx, cy + R) + + # --- Require a fix + if not getattr(gps, "fix_ok", True): + ui.text(0, 440, "No GPS fix") + return + + lat = getattr(gps, "lat", None) + lon = getattr(gps, "lon", None) + + if lat is None or lon is None: + ui.text(0, 44, "No position") + return + + # --- Course over ground: defines "UP" + cog = getattr(gps, "course_deg", None) + #print("Lat, lon", lat, lon, "Cog", cog, "trail", trail) + + # If no course, assume north-up + if cog is None: + cog = 0.0 + + cog = norm_deg(cog) + + # --- Destination bearing and distance + brng_true = bearing_deg(lat, lon, dest_lat, dest_lon) + dist_m = haversine_m(lat, lon, dest_lat, dest_lon) + + # Arrow angle relative to UP=COG: + # If destination is straight ahead, arrow points up. + rel = norm_deg(brng_true - cog) + # Convert to signed -180..180 for nicer behavior (optional) + if rel > 180.0: + rel -= 360.0 + + # --- Draw destination arrow + # Use a fixed length so it is always visible + arrow_len = int(R * 0.85) + x_tip, y_tip = polar_to_xy(cx, cy, arrow_len, rel) + draw_arrow(ui, cx, cy, x_tip, y_tip, head_len=16, head_ang_deg=30) + + # --- Mark TRUE NORTH on the ring + # True north is bearing 0°, relative to UP=COG => angle = 0 - COG + ang_true_n = norm_deg(0.0 - cog) + xn, yn = polar_to_xy(cx, cy, R, ang_true_n) + ui.text(xn - 6, yn - 8, "N") + + # --- Mark MAGNETIC NORTH on the ring (if declination known) + # Magnetic bearing = true - declination(E positive) + # Magnetic north direction in true coords = -declination + if mag_declination_deg is not None: + ang_mag_n = norm_deg((-mag_declination_deg) - cog) + xm, ym = polar_to_xy(cx, cy, R, ang_mag_n) + ui.text(xm - 6, ym - 8, "M") + + # --- Draw trail of last fixes + # Project lat/lon into local meters (simple equirectangular) + # and rotate so UP is COG. + if trail and len(trail) >= 2: + lat0 = lat + lon0 = lon + phi = deg_to_rad(lat0) + + # meters per degree + m_per_deg_lat = 111132.0 + m_per_deg_lon = 111320.0 * math.cos(phi) + + # max range shown in trail radius + # (you can tune this) + trail_range_m = 80.0 + + # rotate by -COG so direction of travel is up + rot = deg_to_rad(cog) + + prev_xy = None + for p in trail[-12:]: + plat, plon = p + if plat is None or plon is None: + continue + + dx = (plon - lon0) * m_per_deg_lon + dy = (plat - lat0) * m_per_deg_lat + + # rotate into screen coords + rx = dx * math.cos(rot) - dy * math.sin(rot) + ry = dx * math.sin(rot) + dy * math.cos(rot) + + # Map meters -> pixels + sx = int(cx + (rx / trail_range_m) * (R * 0.95)) + sy = int(cy - (ry / trail_range_m) * (R * 0.95)) + + # clamp to circle-ish bounds + sx = clamp(sx, cx - R + 2, cx + R - 2) + sy = clamp(sy, cy - R + 2, cy + R - 2) + + # draw point (small cross) + ui.line(sx - 1, sy, sx + 1, sy) + ui.line(sx, sy - 1, sx, sy + 1) + + if prev_xy is not None: + ui.line(prev_xy[0], prev_xy[1], sx, sy) + + prev_xy = (sx, sy) + + # --- Text info + speed_ms = getattr(gps, "speed_ms", None) + if speed_ms is None: + speed_ms = getattr(gps, "speed", None) + + speed_kmh = ms_to_kmh(speed_ms) + + ui.text(0, 290, "Dist: " + meters_to_human(dist_m)) + ui.text(0, 312, "Speed: " + kmh_to_human(speed_kmh)) + + # Optional: show bearing numbers + ui.text(0, 334, "COG: %d deg" % int(cog + 0.5)) + ui.text(0, 356, "BRG: %d deg" % int(brng_true + 0.5)) + + +# ------------------------------------------------- +# Position parsing +# ------------------------------------------------- + +def parse_position(text): + """ + Flexible coordinate parser. + + Supports: + N 50 30.123 E 14 13.231 + 50.1234N 14.2345E + -14.2345 50.1234 + 50°30'12"N 14°13'20"E + 14 13 20 E 50 30 12 N + """ + + def split_compass(s): + result = [] + token = "" + + for c in s: + if c in "NSEWnsew": + if token.strip(): + result.append(token.strip()) + result.append(c) + token = "" + else: + token += c + + if token.strip(): + result.append(token.strip()) + + return result + + def normalize(s): + s = s.strip() + s = s.replace("°", " ") + s = s.replace("'", " ") + s = s.replace('"', " ") + s = re.sub(r"\s+", " ", s) + return s + + def extract_numbers(s): + nums = [] + buf = "" + + for c in s: + if c in "+-.0123456789": + buf += c + else: + if buf: + nums.append(buf) + buf = "" + if buf: + nums.append(buf) + + return nums + + def parse_one(part): + # Extract direction if present + dir_match = re.search(r"[NSEWnsew]", part) + direction = None + if dir_match: + direction = dir_match.group(0).upper() + part = re.sub(r"[NSEWnsew]", "", part) + + nums = extract_numbers(part) + if not nums: + return 0, "-", "No numeric data" + + nums = [float(x) for x in nums] + + # dd.dddd + if len(nums) == 1: + value = nums[0] + + # dd mm.mmm + elif len(nums) == 2: + deg, minutes = nums + value = abs(deg) + minutes / 60.0 + if deg < 0: + value = -value + + # dd mm ss + else: + deg, minutes, seconds = nums[:3] + value = abs(deg) + minutes / 60.0 + seconds / 3600.0 + if deg < 0: + value = -value + + if direction: + if direction in ("S", "W"): + value = -abs(value) + else: + value = abs(value) + + return value, direction, None + + text = normalize(text) + + # Try splitting into two coordinate parts + # Strategy: split around direction letters if possible + parts = split_compass(text) + + coords = [] + + for part in parts: + part = part.strip() + if not part: + continue + + value, direction, comment = parse_one(part) + if not comment: + coords.append((value, direction, comment)) + + # If we didn’t get two parts, fallback: split in half + if len(coords) != 2: + tokens = text.split(" ") + mid = len(tokens) // 2 + left = " ".join(tokens[:mid]) + right = " ".join(tokens[mid:]) + coords = [ + parse_one(left), + parse_one(right), + ] + + if len(coords) != 2: + return 0, 0, "Could not parse two coordinates" + + print("coords = ", coords) + + lat = None + lon = None + + for value, direction, comment in coords: + if direction in ("N", "S"): + lat = value + elif direction in ("E", "W"): + lon = value + + # If directions missing, assume first = lat, second = lon + if lat is None or lon is None: + lat = coords[0][0] + lon = coords[1][0] + + if abs(lat) > 90 or abs(lon) > 180: + return 0, 0, "Coordinate out of range" + + return lat, lon, "User input" + + +# ------------------------------------------------- +# Enter Target dialog +# ------------------------------------------------- + +class EnterTarget(Activity): + def __init__(self): + super().__init__() + + def onCreate(self): + self.scr = lv.obj() + + # Position input + self.pos_ta = lv.textarea(self.scr) + self.pos_ta.set_size(300, 40) + self.pos_ta.align(lv.ALIGN.TOP_MID, 0, 18) + self.pos_ta.set_placeholder_text("N 50 30.123 E 14 13.231") + + title = lv.label(self.scr) + title.set_text("Goto position") + title.align_to(self.pos_ta, lv.ALIGN.OUT_BOTTOM_MID, 0, 10) + + if False: + # Filename input + self.file_ta = lv.textarea(self.scr) + self.file_ta.set_size(300, 40) + self.file_ta.align(lv.ALIGN.TOP_MID, 0, 10) + self.file_ta.set_placeholder_text("track.txt") + + # Record checkbox + self.record_cb = lv.checkbox(self.scr) + self.record_cb.set_text("Record track") + self.record_cb.align_to(title, lv.ALIGN.OUT_BOTTOM_MID, 0, 10) + + if False: + # Status label + self.status = lv.label(self.scr) + self.status.set_text("") + self.status.align(lv.ALIGN.TOP_MID, 0, 10) + + # Apply button + apply_btn = lv.button(self.scr) + apply_btn.set_size(120, 50) + apply_btn.align(lv.ALIGN.BOTTOM_RIGHT, -20, -5) + apply_btn.add_event_cb(self.on_apply, lv.EVENT.CLICKED, None) + + lbl_apply = lv.label(apply_btn) + lbl_apply.set_text("Apply") + lbl_apply.center() + + # Back button + back_btn = lv.button(self.scr) + back_btn.set_size(120, 50) + back_btn.align(lv.ALIGN.BOTTOM_LEFT, 20, -5) + back_btn.add_event_cb(self.on_back, lv.EVENT.CLICKED, None) + + lbl_back = lv.label(back_btn) + lbl_back.set_text("Back") + lbl_back.center() + + keyboard = MposKeyboard(self.scr) + keyboard.set_textarea(self.pos_ta) + + self.setContentView(self.scr) + + def onResume(self, screen): + pass + + def on_apply(self, e): + pos_text = self.pos_ta.get_text() + if False: + file_text = self.file_ta.get_text() + config.recording = self.record_cb.get_state() & lv.STATE.CHECKED + + config.lat, config.lon, config.name = parse_position(pos_text) + + self.finish() + + def on_back(self, e): + self.finish() + + def load(self): + lv.scr_load(self.scr) + +if False: + print(parse_position("50 N 10 E")) + print(parse_position("N 50 30.000 E 10 15.000")) + print(parse_position("50.123 N 12.345 E")) + # FIXME: S/W does not really work. + print(parse_position("50 S 10 W")) + print(parse_position("52.345 12.345")) + print() + print() + print() + os.exit(1) + +if False: + lm = LocationManager() +elif False: + lm = LocationManagerDBUS() +else: + lm = FakeNMEASpiral(center_lat=50.0, center_lon=14.0) diff --git a/internal_filesystem/apps/cz.ucw.pavel.navstar/assets/pcanvas.py b/internal_filesystem/apps/cz.ucw.pavel.navstar/assets/pcanvas.py new file mode 100644 index 00000000..41f758ad --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.navstar/assets/pcanvas.py @@ -0,0 +1,306 @@ +import lvgl as lv +import mpos +from mpos import Activity, MposKeyboard + + +# ----------------------------- +# 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 diff --git a/internal_filesystem/apps/cz.ucw.pavel.navstar/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/cz.ucw.pavel.navstar/res/mipmap-mdpi/icon_64x64.png new file mode 100644 index 0000000000000000000000000000000000000000..dba74da3c82c466a45307972eb5a36c15a554859 GIT binary patch literal 6751 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 l(!o$3B6ZM3C{b@equ=A(0k`%~6# Date: Fri, 6 Mar 2026 23:30:36 +0100 Subject: [PATCH 02/16] floodit: simple game Objective is to turn the board into uniform color in minimum number of steps. --- .../META-INF/MANIFEST.JSON | 24 ++ .../apps/cz.ucw.pavel.floodit/assets/main.py | 210 ++++++++++++++++++ .../res/mipmap-mdpi/icon_64x64.png | Bin 0 -> 10235 bytes 3 files changed, 234 insertions(+) create mode 100644 internal_filesystem/apps/cz.ucw.pavel.floodit/META-INF/MANIFEST.JSON create mode 100644 internal_filesystem/apps/cz.ucw.pavel.floodit/assets/main.py create mode 100644 internal_filesystem/apps/cz.ucw.pavel.floodit/res/mipmap-mdpi/icon_64x64.png diff --git a/internal_filesystem/apps/cz.ucw.pavel.floodit/META-INF/MANIFEST.JSON b/internal_filesystem/apps/cz.ucw.pavel.floodit/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..55f1cdee --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.floodit/META-INF/MANIFEST.JSON @@ -0,0 +1,24 @@ +{ +"name": "Floodit", +"publisher": "Pavel Machek", +"short_description": "Simple game with colors.", +"long_description": "Game with colors, where objective is to turn whole board into single color in minimum number of steps.", +"icon_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.floodit/icons/cz.ucw.pavel.floodit_0.0.1_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.floodit/mpks/cz.ucw.pavel.floodit_0.0.1.mpk", +"fullname": "cz.ucw.pavel.floodit", +"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.floodit/assets/main.py b/internal_filesystem/apps/cz.ucw.pavel.floodit/assets/main.py new file mode 100644 index 00000000..cf0c77ca --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.floodit/assets/main.py @@ -0,0 +1,210 @@ +import time +import random + +""" +Flood-It game + +Fill the entire board with a single color +using the smallest number of moves. + +Touch a color button to flood the region +starting from the top-left corner. +""" + +from mpos import Activity + +try: + import lvgl as lv +except ImportError: + pass + + +class Main(Activity): + + COLS = 10 + ROWS = 10 + + COLORS = [ + 0xE74C3C, # red + 0xF1C40F, # yellow + 0x2ECC71, # green + 0x3498DB, # blue + 0x9B59B6, # purple + 0xE67E22, # orange + ] + + def __init__(self): + super().__init__() + + self.board = [] + self.cells = [] + + self.moves = 0 + + # --------------------------------------------------------------------- + + def onCreate(self): + + self.screen = lv.obj() + self.screen.remove_flag(lv.obj.FLAG.SCROLLABLE) + + font = lv.font_montserrat_20 + + score = lv.label(self.screen) + score.align(lv.ALIGN.TOP_LEFT, 5, 25) + score.set_text("Moves") + score.set_style_text_font(font, 0) + self.lb_score = score + + d = lv.display_get_default() + self.SCREEN_WIDTH = d.get_horizontal_resolution() + self.SCREEN_HEIGHT = d.get_vertical_resolution() + + # color buttons + btn_size = 45 + spacing = 5 + + self.CELL = min( + self.SCREEN_WIDTH // (self.COLS + 2), + (self.SCREEN_HEIGHT - btn_size) // (self.ROWS + 3) + ) + + 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 - btn_size // 2 + ) + + o.set_style_radius(4, 0) + o.set_style_border_width(1, 0) + + row.append(o) + + self.cells.append(row) + + + for i, col in enumerate(self.COLORS): + + btn = lv.button(self.screen) + btn.set_size(btn_size, btn_size) + + btn.align( + lv.ALIGN.BOTTOM_LEFT, + 5 + i * (btn_size + spacing), + -5 + ) + + btn.set_style_bg_color(lv.color_hex(col), 0) + + btn.add_event_cb( + lambda e, c=i: self.pick_color(c), + lv.EVENT.CLICKED, + None + ) + + focusgroup = lv.group_get_default() + if focusgroup: + focusgroup.add_obj(self.screen) + + self.setContentView(self.screen) + + self.new_game() + + # --------------------------------------------------------------------- + + def new_game(self): + + self.moves = 0 + self.lb_score.set_text("Moves\n0") + + self.board = [ + [random.randrange(len(self.COLORS)) for _ in range(self.COLS)] + for _ in range(self.ROWS) + ] + + self.redraw() + + # --------------------------------------------------------------------- + + def pick_color(self, color): + + start_color = self.board[0][0] + + if start_color == color: + return + + self.flood_fill(start_color, color) + + self.moves += 1 + self.lb_score.set_text("Moves\n%d" % self.moves) + + self.redraw() + + if self.check_win(): + self.win() + + # --------------------------------------------------------------------- + + def flood_fill(self, old, new): + + stack = [(0, 0)] + + while stack: + + r, c = stack.pop() + + if not (0 <= r < self.ROWS and 0 <= c < self.COLS): + continue + + if self.board[r][c] != old: + continue + + self.board[r][c] = new + + stack.append((r + 1, c)) + stack.append((r - 1, c)) + stack.append((r, c + 1)) + stack.append((r, c - 1)) + + # --------------------------------------------------------------------- + + def check_win(self): + + color = self.board[0][0] + + for r in range(self.ROWS): + for c in range(self.COLS): + if self.board[r][c] != color: + return False + + return True + + # --------------------------------------------------------------------- + + def win(self): + + label = lv.label(self.screen) + label.set_text("Finished in %d moves!" % self.moves) + label.center() + + # --------------------------------------------------------------------- + + def redraw(self): + + for r in range(self.ROWS): + for c in range(self.COLS): + + v = self.board[r][c] + + self.cells[r][c].set_style_bg_color( + lv.color_hex(self.COLORS[v]), 0 + ) diff --git a/internal_filesystem/apps/cz.ucw.pavel.floodit/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/cz.ucw.pavel.floodit/res/mipmap-mdpi/icon_64x64.png new file mode 100644 index 0000000000000000000000000000000000000000..f0ca6f7574a9e9bcd7974ac8572cdb417550199c GIT binary patch literal 10235 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 literal 0 HcmV?d00001 From 89bb4723646f7bdad952fa4d7d4e954161492203 Mon Sep 17 00:00:00 2001 From: Pavel Machek Date: Sat, 7 Mar 2026 23:50:35 +0100 Subject: [PATCH 03/16] cellular: First version, shows operator name and registration state --- .../META-INF/MANIFEST.JSON | 24 ++++ .../apps/cz.ucw.pavel.cellular/assets/main.py | 103 ++++++++++++++++++ .../res/mipmap-mdpi/icon_64x64.png | Bin 0 -> 6765 bytes 3 files changed, 127 insertions(+) create mode 100644 internal_filesystem/apps/cz.ucw.pavel.cellular/META-INF/MANIFEST.JSON create mode 100644 internal_filesystem/apps/cz.ucw.pavel.cellular/assets/main.py create mode 100644 internal_filesystem/apps/cz.ucw.pavel.cellular/res/mipmap-mdpi/icon_64x64.png diff --git a/internal_filesystem/apps/cz.ucw.pavel.cellular/META-INF/MANIFEST.JSON b/internal_filesystem/apps/cz.ucw.pavel.cellular/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..9e6b0742 --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.cellular/META-INF/MANIFEST.JSON @@ -0,0 +1,24 @@ +{ +"name": "Xxx", +"publisher": "Pavel Machek", +"short_description": "Xxx", +"long_description": "Simple xxx app.", +"icon_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.xxx/icons/cz.ucw.pavel.xxx_0.0.1_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.xxx/mpks/cz.ucw.pavel.xxx_0.0.1.mpk", +"fullname": "cz.ucw.pavel.xxx", +"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.cellular/assets/main.py b/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/main.py new file mode 100644 index 00000000..dcd5dcdd --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/main.py @@ -0,0 +1,103 @@ +from mpos import Activity + +""" + +""" + +import time +import os +import json + +try: + import lvgl as lv +except ImportError: + pass + +from mpos import Activity, MposKeyboard + +TMP = "/tmp/cmd.json" + + +def run_cmd_json(cmd): + rc = os.system(cmd + " > " + TMP) + if rc != 0: + raise RuntimeError("command failed") + + with open(TMP, "r") as f: + data = f.read().strip() + + return json.loads(data) + +def dbus_json(cmd): + return run_cmd_json("sudo /home/mobian/g/MicroPythonOS/phone.py " + cmd) + +class CellularManager: + def init(self): + v = dbus_json("loc_on") + + def poll(self): + v = dbus_json("signal") + print(v) + self.signal = v + +cm = CellularManager() + +# ------------------------------------------------------------ +# +# ------------------------------------------------------------ + +class Main(Activity): + + def __init__(self): + super().__init__() + + # -------------------- + + def onCreate(self): + self.screen = lv.obj() + #self.screen.remove_flag(lv.obj.FLAG.SCROLLABLE) + + # Top labels + self.lbl_time = lv.label(self.screen) + self.lbl_time.set_style_text_font(lv.font_montserrat_24, 0) + self.lbl_time.set_text("Startup...") + self.lbl_time.align(lv.ALIGN.TOP_LEFT, 6, 22) + + self.lbl_date = lv.label(self.screen) + self.lbl_date.align(lv.ALIGN.TOP_LEFT, 6, 48) + + self.lbl_month = lv.label(self.screen) + self.lbl_month.align(lv.ALIGN.TOP_RIGHT, -6, 10) + + self.setContentView(self.screen) + cm.init() + + def onResume(self, screen): + self.timer = lv.timer_create(self.tick, 3000, 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] + + cm.poll() + s = "\n" + s += cm.signal["OperatorName"] + "\n" + s += "RegistrationState %d\n" % cm.signal["RegistrationState"] + s += "State %d\n" % cm.signal["State"] + + self.lbl_time.set_text("%02d:%02d" % (hh, mm)) + self.lbl_date.set_text("%04d-%02d-%02d %s" % (y, m, d, s)) + + + # -------------------- + + diff --git a/internal_filesystem/apps/cz.ucw.pavel.cellular/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/cz.ucw.pavel.cellular/res/mipmap-mdpi/icon_64x64.png new file mode 100644 index 0000000000000000000000000000000000000000..316c5f2366bdcab259ac9fb75ce7c3db1dfc4c65 GIT binary patch literal 6765 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 Date: Sat, 7 Mar 2026 23:55:14 +0100 Subject: [PATCH 04/16] cellular: bigger fonts to make it readable --- .../apps/cz.ucw.pavel.cellular/assets/main.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/main.py b/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/main.py index dcd5dcdd..66e4b382 100644 --- a/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/main.py +++ b/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/main.py @@ -59,14 +59,16 @@ class Main(Activity): # Top labels self.lbl_time = lv.label(self.screen) - self.lbl_time.set_style_text_font(lv.font_montserrat_24, 0) + self.lbl_time.set_style_text_font(lv.font_montserrat_34, 0) self.lbl_time.set_text("Startup...") self.lbl_time.align(lv.ALIGN.TOP_LEFT, 6, 22) self.lbl_date = lv.label(self.screen) - self.lbl_date.align(lv.ALIGN.TOP_LEFT, 6, 48) + self.lbl_date.set_style_text_font(lv.font_montserrat_20, 0) + self.lbl_date.align(lv.ALIGN.TOP_LEFT, 6, 58) self.lbl_month = lv.label(self.screen) + self.lbl_month.set_style_text_font(lv.font_montserrat_20, 0) self.lbl_month.align(lv.ALIGN.TOP_RIGHT, -6, 10) self.setContentView(self.screen) From 952396ce4dae67f411e1455f0bb3c1f62e73416f Mon Sep 17 00:00:00 2001 From: Pavel Machek Date: Sun, 8 Mar 2026 00:02:23 +0100 Subject: [PATCH 05/16] cellular: Move signal display away from date --- .../apps/cz.ucw.pavel.cellular/assets/main.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/main.py b/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/main.py index 66e4b382..9db2fdca 100644 --- a/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/main.py +++ b/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/main.py @@ -69,7 +69,7 @@ class Main(Activity): self.lbl_month = lv.label(self.screen) self.lbl_month.set_style_text_font(lv.font_montserrat_20, 0) - self.lbl_month.align(lv.ALIGN.TOP_RIGHT, -6, 10) + self.lbl_month.align(lv.ALIGN.TOP_RIGHT, -6, 22) self.setContentView(self.screen) cm.init() @@ -95,11 +95,17 @@ class Main(Activity): s += cm.signal["OperatorName"] + "\n" s += "RegistrationState %d\n" % cm.signal["RegistrationState"] s += "State %d\n" % cm.signal["State"] + sq, re = cm.signal["SignalQuality"] + s += "Signal %d\n" % sq + self.lbl_month.set_text(s) self.lbl_time.set_text("%02d:%02d" % (hh, mm)) + s = "" self.lbl_date.set_text("%04d-%02d-%02d %s" % (y, m, d, s)) + + # -------------------- From ed337ef48c5643b7c670c2dee21fc945fc5f1c0b Mon Sep 17 00:00:00 2001 From: Pavel Machek Date: Sun, 8 Mar 2026 21:22:51 +0100 Subject: [PATCH 06/16] cellular: Add input for phone number --- .../apps/cz.ucw.pavel.cellular/assets/main.py | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/main.py b/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/main.py index 9db2fdca..01480fac 100644 --- a/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/main.py +++ b/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/main.py @@ -65,17 +65,26 @@ class Main(Activity): self.lbl_date = lv.label(self.screen) self.lbl_date.set_style_text_font(lv.font_montserrat_20, 0) - self.lbl_date.align(lv.ALIGN.TOP_LEFT, 6, 58) + self.lbl_date.align_to(self.lbl_time, lv.ALIGN.OUT_BOTTOM_LEFT, 0, 5) + self.lbl_date.set_text("(details here?") self.lbl_month = lv.label(self.screen) self.lbl_month.set_style_text_font(lv.font_montserrat_20, 0) self.lbl_month.align(lv.ALIGN.TOP_RIGHT, -6, 22) + self.number = lv.textarea(self.screen) + self.number.set_accepted_chars("0123456789") + self.number.set_one_line(True) + self.number.align_to(self.lbl_date, lv.ALIGN.OUT_BOTTOM_LEFT, 0, 12) + + kb = lv.keyboard(self.screen) + kb.set_textarea(self.number) + self.setContentView(self.screen) cm.init() def onResume(self, screen): - self.timer = lv.timer_create(self.tick, 3000, None) + self.timer = lv.timer_create(self.tick, 60000, None) self.tick(0) def onPause(self, screen): @@ -90,11 +99,13 @@ class Main(Activity): y, m, d = now[0], now[1], now[2] hh, mm, ss = now[3], now[4], now[5] + self.lbl_month.set_text("busy") + cm.poll() - s = "\n" + s = "" s += cm.signal["OperatorName"] + "\n" s += "RegistrationState %d\n" % cm.signal["RegistrationState"] - s += "State %d\n" % cm.signal["State"] + s += "State %d " % cm.signal["State"] sq, re = cm.signal["SignalQuality"] s += "Signal %d\n" % sq @@ -102,8 +113,6 @@ class Main(Activity): self.lbl_time.set_text("%02d:%02d" % (hh, mm)) s = "" self.lbl_date.set_text("%04d-%02d-%02d %s" % (y, m, d, s)) - - # -------------------- From d8fe3e1271dbeb5d22dea4621a6a3e2b6d9cdf18 Mon Sep 17 00:00:00 2001 From: Pavel Machek Date: Sun, 8 Mar 2026 22:02:47 +0100 Subject: [PATCH 07/16] cellular: add number input / call button --- .../apps/cz.ucw.pavel.cellular/assets/main.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/main.py b/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/main.py index 01480fac..78d15054 100644 --- a/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/main.py +++ b/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/main.py @@ -73,12 +73,22 @@ class Main(Activity): self.lbl_month.align(lv.ALIGN.TOP_RIGHT, -6, 22) self.number = lv.textarea(self.screen) - self.number.set_accepted_chars("0123456789") + #self.number.set_accepted_chars("0123456789") self.number.set_one_line(True) + self.number.set_style_text_font(lv.font_montserrat_34, 0) self.number.align_to(self.lbl_date, lv.ALIGN.OUT_BOTTOM_LEFT, 0, 12) + self.call = lv.button(self.screen) + self.call.align_to(self.number, lv.ALIGN.OUT_RIGHT_MID, 10, 0) + + l = lv.label(self.call) + l.set_text("Call") + l.center() + + kb = lv.keyboard(self.screen) kb.set_textarea(self.number) + kb.set_size(lv.pct(100), lv.pct(33)) self.setContentView(self.screen) cm.init() From 3c11fd60ce096f30af567994ef8aee64332bdcc4 Mon Sep 17 00:00:00 2001 From: Pavel Machek Date: Sun, 8 Mar 2026 22:12:31 +0100 Subject: [PATCH 08/16] cellular: hook up calls --- .../apps/cz.ucw.pavel.cellular/assets/main.py | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/main.py b/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/main.py index 78d15054..588349f9 100644 --- a/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/main.py +++ b/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/main.py @@ -40,6 +40,12 @@ class CellularManager: print(v) self.signal = v + def call(self, num): + v = dbus_json("call '%s'" % num) + + def sms(self, num, text): + v = dbus_json("call '%s' '%s'" % (num, text)) + cm = CellularManager() # ------------------------------------------------------------ @@ -80,7 +86,12 @@ class Main(Activity): self.call = lv.button(self.screen) self.call.align_to(self.number, lv.ALIGN.OUT_RIGHT_MID, 10, 0) + self.call.add_event_cb(lambda e: self.on_call(), lv.EVENT.CLICKED, None) + self.sms = lv.textarea(self.screen) + self.sms.set_style_text_font(lv.font_montserrat_24, 0) + self.sms.align_to(self.number, lv.ALIGN.OUT_BOTTOM_LEFT, 0, 10) + l = lv.label(self.call) l.set_text("Call") l.center() @@ -92,7 +103,7 @@ class Main(Activity): self.setContentView(self.screen) cm.init() - + def onResume(self, screen): self.timer = lv.timer_create(self.tick, 60000, None) self.tick(0) @@ -104,6 +115,15 @@ class Main(Activity): # -------------------- + def on_call(self): + num = self.number.get_text() + cm.call(num) + + def on_sms(self): + num = self.number.get_text() + text = self.sms.get_text() + cm.sms(num, text) + def tick(self, t): now = time.localtime() y, m, d = now[0], now[1], now[2] From 51fba25e4b3789130f829655853cd26a50f13de9 Mon Sep 17 00:00:00 2001 From: Pavel Machek Date: Sun, 8 Mar 2026 22:29:33 +0100 Subject: [PATCH 09/16] cellular: two textareas don't work well --- .../apps/cz.ucw.pavel.cellular/assets/main.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/main.py b/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/main.py index 588349f9..8e756cb5 100644 --- a/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/main.py +++ b/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/main.py @@ -88,15 +88,16 @@ class Main(Activity): self.call.align_to(self.number, lv.ALIGN.OUT_RIGHT_MID, 10, 0) self.call.add_event_cb(lambda e: self.on_call(), lv.EVENT.CLICKED, None) - self.sms = lv.textarea(self.screen) - self.sms.set_style_text_font(lv.font_montserrat_24, 0) - self.sms.align_to(self.number, lv.ALIGN.OUT_BOTTOM_LEFT, 0, 10) + # Two text areas on single screen don't work well. + # Perhaps make it dialog? + #self.sms = lv.textarea(self.screen) + #self.sms.set_style_text_font(lv.font_montserrat_24, 0) + #self.sms.align_to(self.number, lv.ALIGN.OUT_BOTTOM_LEFT, 0, 10) l = lv.label(self.call) l.set_text("Call") l.center() - kb = lv.keyboard(self.screen) kb.set_textarea(self.number) kb.set_size(lv.pct(100), lv.pct(33)) From c02c75e8a856a6150210a5c53da1912a71d44fd7 Mon Sep 17 00:00:00 2001 From: Pavel Machek Date: Sun, 8 Mar 2026 23:20:47 +0100 Subject: [PATCH 10/16] cellular: move phone.py helper to cellular --- .../apps/cz.ucw.pavel.cellular/assets/main.py | 2 +- .../cz.ucw.pavel.cellular/assets/phone.py | 472 ++++++++++++++++++ 2 files changed, 473 insertions(+), 1 deletion(-) create mode 100755 internal_filesystem/apps/cz.ucw.pavel.cellular/assets/phone.py diff --git a/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/main.py b/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/main.py index 8e756cb5..ddd1ebfd 100644 --- a/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/main.py +++ b/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/main.py @@ -29,7 +29,7 @@ def run_cmd_json(cmd): return json.loads(data) def dbus_json(cmd): - return run_cmd_json("sudo /home/mobian/g/MicroPythonOS/phone.py " + cmd) + return run_cmd_json("sudo /home/mobian/g/MicroPythonOS/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/phone.py " + cmd) class CellularManager: def init(self): diff --git a/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/phone.py b/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/phone.py new file mode 100755 index 00000000..41dc7813 --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/phone.py @@ -0,0 +1,472 @@ +#!/usr/bin/env python3 +from pydbus import SystemBus, Variant +import pydbus +import time +import sys +import json + +""" +Librem 5, phosh, python. Give me code to read current battery level. + +(and more) + +Lets make it class Phone, one method would be reading battery information, one would be reading operator name / signal strength, one would be getting wifi enabled/disabled / AP name. + +Can you also get silent mode, pending notifications, and gps coordinates on request? + +run this with sudo to work around permission problems + +sudo apt install python3-pydbus + +sudo mmcli --list-modems +sudo mmcli -m 6 --location-enable-gps-nmea --location-enable-gps-raw +""" + + + +class Phone: + verbose = False + + def __init__(self): + self.bus = pydbus.SystemBus() + + def init_sess(self): + self.sess = pydbus.SessionBus() + + # --- Battery --- + def get_battery_info(self): + upower = self.bus.get("org.freedesktop.UPower") + for dev_path in upower.EnumerateDevices(): + if self.verbose: print("dev_path is", dev_path) + dev = self.bus.get(".UPower", dev_path) + if dev.Type == 2: # battery + return { + "percentage": dev.Percentage, + "state": dev.State, + "charging": dev.State == 1, + "time_to_empty": dev.TimeToEmpty, # seconds, 0 if unknown + "time_to_full": dev.TimeToFull, # seconds, 0 if unknown + } + return None + + # --- Vibration --- + # https://github.com/agx/feedbackd/blob/main/examples/example.py + def set_vibration(self, enable: bool): + # Connect to GSettings backend (org.gnome.SettingsDaemon, commonly) + dconf = self.sess.get("org.sigxcpu.Feedback", "/org/sigxcpu/Feedback") + + # Use the standard Properties interface + iface = dconf["org.sigxcpu.Feedback.Haptic"] + + # Example pattern: list of (duration, strength) + pattern = [ + (3.0, 1), + (1.0, 200), + (0.0, 50), + (0.5, 300), + ] + + iface.Vibrate("org.foo.app", pattern) + print(dir(iface)) + + # --- Feedback: silent/full/... --- + # broken + def set_feedback_theme(self, value): + # Connect to GSettings backend (org.gnome.SettingsDaemon, commonly) + dconf = self.bus.get("org.gnome.SettingsDaemon", "/org/gnome/SettingsDaemon/Dbus") + + # Use the standard Properties interface + iface = dconf["org.freedesktop.DBus.Properties"] + + # Set the key (schema, key, value) + # GVariant format: value must match the expected type, here 's' = string + value = Variant("s", "custom") + + iface.Set("org.sigxcpu.feedbackd", "theme", value) + + # --- Mobile network --- + # Works as root + def get_mobile_info(self): + loc = None + mm = self.bus.get("org.freedesktop.ModemManager1") + for modem_path in mm.GetManagedObjects(): + modem = self.bus.get(".ModemManager1", modem_path) + print("modem ", modem) + operator = getattr(modem, "OperatorName", None) + print("Operator code:", getattr(modem, "OperatorCode", None)) # 0..11 according to MMState + print("State:", getattr(modem, "State", None)) # 0..11 according to MMState + print("Access Technology:", getattr(modem, "AccessTechnologies", None)) + print("Model:", getattr(modem, "Model", None)) + print("Manufacturer:", getattr(modem, "Manufacturer", None)) + print("Revision:", getattr(modem, "Revision", None)) + print("Equipment Identifier (IMEI):", getattr(modem, "EquipmentIdentifier", None)) + + print("Signal (gsm):", getattr(modem, "Gsm", None)) + print("Signal (umts):", getattr(modem, "Umts", None)) + print("Signal (lte):", getattr(modem, "Lte", None)) + + print("Signal:", getattr(modem, "SignalQuality", None)) + print("RegistrationState:", getattr(modem, "RegistrationState", None)) + + # Hallucination? + lac = getattr(modem, "LocationAreaCode", None) + cid = getattr(modem, "CellId", None) + tac = getattr(modem, "TrackingAreaCode", None) + print("Lac...:", lac, cid, tac) + + loc = getattr(modem, "Location", None) + print("Location:", loc) + + v = modem.Setup(0x027, False) + print("Location setup? ", v) + v = modem.GetLocation() + # This has 1) network info and 4) nmea + print(v) + + # Fails with no signal; but has even timing-advance info (I guess only when transmitting) + # It also seems to have neighbouring cells! + """ + Field Meaning Example + operator-id MCC+MNC, identifies mobile operator 23003 + serving Whether device is currently connected to this cell True + physical-ci LTE Physical Cell ID (PCI) 12 + ci LTE Cell Identity XXXXXX + tac Tracking Area Code XXXX + earfcn LTE frequency channel XXXX + cell-type Cell type code (macro/micro/etc.) 5 + rsrp Signal strength (dBm) -122.7 + rsrq Signal quality (dB) -17.0 + """ + try: + v = modem.GetCellInfo() + except: + v = {} + print(v) + + if False: + simple = self.bus.get(".ModemManager1.Modem.Modem3gpp", modem_path) + print(simple) + + if False: + # --- Signal --- + try: + modem3gpp = modem.Modem3gpp + if modem3gpp: + print("3GPP Operator Code:", getattr(modem3gpp, "OperatorCode", None)) + print("Signal Quality:", getattr(modem3gpp, "SignalQuality", None)) # (percent, valid) + print("Registration State:", getattr(modem3gpp, "RegistrationState", None)) + except Exception: + print("No 3gpp?") + + # Pokud je LTE/other, ModemManager má ještě Modem4g nebo ModemSignal + try: + signal = modem.Signal + if signal: + # SignalQuality může být tuple (percent, valid) + print("SignalQuality (Signal interface):", getattr(signal, "SignalQuality", None)) + except Exception: + print("No signal?") + + signal = None + if False: + try: + signal = modem.Signal.Get()["rssi"] + except Exception as e: + return {"error": str(e)} + return {"operator": operator, "signal_strength": signal} + return loc + + def get_mobile_loc(self): + loc = None + mm = self.bus.get("org.freedesktop.ModemManager1") + for modem_path in mm.GetManagedObjects(): + modem = self.bus.get(".ModemManager1", modem_path) + loc = modem.GetLocation() + return loc + + def get_cell_signal(self): + loc = None + mm = self.bus.get("org.freedesktop.ModemManager1") + for modem_path in mm.GetManagedObjects(): + modem = self.bus.get(".ModemManager1", modem_path) + + loc = {} + + def attr(v): + loc[v] = getattr(modem, v, None) + + attr("OperatorName") + attr("OperatorCode") # 0..11 according to MMState + attr("State") # 0..11 according to MMState + attr("AccessTechnologies") + attr("Model") + attr("Manufacturer") + attr("Revision") + attr("EquipmentIdentifier") + + attr("Gsm") + attr("Umts") + attr("Lte") + + attr("SignalQuality") + attr("RegistrationState") + + return loc + + def start_call(self, num): + mm = self.bus.get("org.freedesktop.ModemManager1") + + for modem_path in mm.GetManagedObjects(): + modem = self.bus.get("org.freedesktop.ModemManager1", modem_path) + voice = modem["org.freedesktop.ModemManager1.Modem.Voice"] + + call_properties = { + "number": Variant('s', num) + } + + call_path = voice.CreateCall(call_properties) + #call = self.bus.get("org.freedesktop.ModemManager1", call_path) + #call_iface = call["org.freedesktop.ModemManager1.Call"] + #call_iface.Start() + + return { "call": call_path } + + def send_sms(self, num, text): + mm = self.bus.get("org.freedesktop.ModemManager1") + + for modem_path in mm.GetManagedObjects(): + modem = self.bus.get("org.freedesktop.ModemManager1", modem_path) + messaging = modem["org.freedesktop.ModemManager1.Modem.Messaging"] + + sms_properties = { + "number": Variant('s', num), + "text": Variant('s', text) + } + + sms_path = messaging.Create(sms_properties) + sms = self.bus.get("org.freedesktop.ModemManager1", sms_path) + sms_iface = sms["org.freedesktop.ModemManager1.Sms"] + sms_iface.Send() + + return { "sms": sms_path } + + # 0x01 = 3GPP LAC/CI + # 0x02 = GPS NMEA + # 0x04 = GPS RAW + # 0x08 = CDMA BS + # 0x10 = GPS Unmanaged + CELL_ID = 0x01 + GPS_NMEA = 0x02 + GPS_RAW = 0x04 + + def enable_mobile_loc(self, gps_on, cell_on): + """ + Enable GPS RAW + NMEA. + """ + mm = self.bus.get("org.freedesktop.ModemManager1") + for modem_path in mm.GetManagedObjects(): + modem = self.bus.get(".ModemManager1", modem_path) + + # Setup(uint32 sources, boolean signal_location) + # signal_location=True makes ModemManager emit LocationUpdated signals + if gps_on: + sources = self.GPS_NMEA | self.GPS_RAW + else: + sources = 0 + if cell_on: + sources |= self.CELL_ID; + modem.Setup(sources, True) + + continue + # Optional: explicitly enable (some modems require it) + try: + modem.SetEnable(True) + except Exception: + print("Cant setenable") + return { 'result' : 'setenable failed' } + return { 'result': 'ok' } + + # --- WiFi --- + def get_wifi_info(self): + nm = self.bus.get("org.freedesktop.NetworkManager") + wifi_enabled = nm.WirelessEnabled + active_ssid = None + for conn_path in nm.ActiveConnections: + ac = self.bus.get(".NetworkManager", conn_path) + if ac.Type == "802-11-wireless": + # Step 1: get the settings connection path + settings_path = ac.Connection + # Step 2: fetch the settings object + sc = self.bus.get(".NetworkManager", settings_path) + settings = sc.GetSettings() + ssid = settings["802-11-wireless"]["ssid"] + if isinstance(ssid, (bytes, bytearray)): + ssid = ssid.decode("utf-8", errors="ignore") + else: + ssid = ''.join(chr(c) for c in ssid) + return {"enabled": nm.WirelessEnabled, "ssid": ssid} + + return {"enabled": wifi_enabled, "ssid": active_ssid} + + # --- Silent mode / Do Not Disturb --- + # broken + def get_silent_mode(self): + try: + portal = self.bus.get("org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop") + return portal.Settings.Read("org.freedesktop.appearance", + "sound-theme-enabled") == 0 + except Exception as e: + return {"error": str(e)} + + # --- Pending notifications --- + # broken + def get_notifications(self): + try: + notif = self.bus.get("org.freedesktop.Notifications") + # org.freedesktop.Notifications has no standard "list" API, + # Phosh implements its own. + # In phosh, you can query /org/gnome/Notifications for backlog. + phosh_notif = self.bus.get("org.gnome.Shell", + "/org/gnome/Shell/Notifications") + return phosh_notif.ListNotifications() + except Exception as e: + return {"error": str(e)} + + # --- GPS coordinates --- + # Needs permissions from .desktop + def get_location(self): + try: + geoclue = self.bus.get("org.freedesktop.GeoClue2", + "/org/freedesktop/GeoClue2/Manager") + # Step 1: get a client object path + client_path = geoclue.GetClient() + client = self.bus.get("org.freedesktop.GeoClue2", client_path) + + # Step 2: set required properties + client.DesktopId = "phone.py" + client.RequestedAccuracyLevel = 3 # 3 = city-level accuracy + client.Start() # start location updates + + # Step 3: read location + loc_path = client.Location + location = self.bus.get("org.freedesktop.GeoClue2", loc_path) + + return { + "latitude": location.Latitude, + "longitude": location.Longitude, + "accuracy": location.Accuracy, + } + except Exception as e: + return {"error": str(e)} + + # --- Hardware sensors (accelerometer, gyroscope, light, proximity) --- + def get_hardware_sensors(self): + try: + obj = self.bus.get("net.hadess.SensorProxy", "/net/hadess/SensorProxy") + + # obj exposes multiple interfaces; access the one we need + sensor_proxy = obj["net.hadess.SensorProxy"] + + # Enable accelerometer + sensor_proxy.ClaimAccelerometer() + sensor_proxy.ClaimLight() + sensor_proxy.ClaimProximity() + + # Give it a small delay to start updating + time.sleep(0.5) + + sensors = {} + #print(dir(sensor_proxy)) + print('tilt -- tells you phone position -- ', sensor_proxy.AccelerometerTilt) + print('orient -- orientation for screen rotation -- ', sensor_proxy.AccelerometerOrientation) + + # Ambient light + if sensor_proxy.HasAmbientLight: + sensors['ambient_light'] = { + 'lux': sensor_proxy.LightLevel + } + + # Proximity + if sensor_proxy.HasProximity: + sensors['proximity'] = { + 'near': sensor_proxy.ProximityNear + } + + return sensors + except Exception as e: + return {"error": str(e)} + + # --- Screen lock --- + def get_screen_lock(self): + # This one complains + #screensaver = self.sess.get("org.freedesktop.ScreenSaver", "/org/freedesktop/ScreenSaver") + screensaver = self.sess.get("org.gnome.ScreenSaver", "/org/gnome/ScreenSaver") + print(dir(screensaver)) + #screensaver.SetActive(True) + return { "Locked": screensaver.GetActive() } + + +# bus = SystemBus() +# login1 = bus.get("org.freedesktop.login1", "/org/freedesktop/login1") +# login1.Suspend(False) # False = interactive, True = force +# login1.Hibernate(False) + +phone = Phone() + +def handle_cmd(v, a): + if v == "bat": + print(json.dumps(phone.get_battery_info())) + sys.exit(0) + if v == "loc": + print(json.dumps(phone.get_mobile_loc())) + sys.exit(0) + if v == "loc_on": + print(json.dumps(phone.enable_mobile_loc(True, True))) + sys.exit(0) + if v == "loc_off": + print(json.dumps(phone.enable_mobile_loc(False, False))) + sys.exit(0) + if v == "signal": + print(json.dumps(phone.get_cell_signal())) + sys.exit(0) + if v == "call": + print(json.dumps(phone.start_call(a[2]))) + sys.exit(0) + if v == "sms": + print(json.dumps(phone.send_sms(a[2], a[3]))) + sys.exit(0) + print("Unknown command "+v) + sys.exit(1) + +if len(sys.argv) > 1: + handle_cmd(sys.argv[1], sys.argv) + +def full(): + phone.init_sess() + print("Battery:", phone.get_battery_info()) + phone.set_vibration(True) +# print("Mobile:", phone.get_mobile_info()) + print("WiFi:", phone.get_wifi_info()) +# print("Silent mode:", phone.get_silent_mode()) +# print("Notifications:", phone.get_notifications()) + print("Location:", phone.get_location()) + print("Hardware sensors:", phone.get_hardware_sensors()) + print("Screen lock:", phone.get_screen_lock()) + phone.set_vibration(False) + # full, quiet, silent +# phone.set_feedback_theme("full") + +def as_root(): + print("Battery:", phone.get_battery_info()) + print("Mobile:", phone.get_mobile_info()) + print("WiFi:", phone.get_wifi_info()) +# print("Silent mode:", phone.get_silent_mode()) +# print("Notifications:", phone.get_notifications()) +# print("Location:", phone.get_location()) + print("Hardware sensors:", phone.get_hardware_sensors()) + # full, quiet, silent +# phone.set_feedback_theme("full") + +#full() +as_root() From 85823fc58c024c843638ab55bda0e09150ff7d1a Mon Sep 17 00:00:00 2001 From: Pavel Machek Date: Mon, 9 Mar 2026 10:10:24 +0100 Subject: [PATCH 11/16] cellular: Add metadata --- .../META-INF/MANIFEST.JSON | 12 ++++++------ .../res/mipmap-mdpi/icon_64x64.png | Bin 6765 -> 8683 bytes 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/internal_filesystem/apps/cz.ucw.pavel.cellular/META-INF/MANIFEST.JSON b/internal_filesystem/apps/cz.ucw.pavel.cellular/META-INF/MANIFEST.JSON index 9e6b0742..5456fb6d 100644 --- a/internal_filesystem/apps/cz.ucw.pavel.cellular/META-INF/MANIFEST.JSON +++ b/internal_filesystem/apps/cz.ucw.pavel.cellular/META-INF/MANIFEST.JSON @@ -1,11 +1,11 @@ { -"name": "Xxx", +"name": "Cellular", "publisher": "Pavel Machek", -"short_description": "Xxx", -"long_description": "Simple xxx app.", -"icon_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.xxx/icons/cz.ucw.pavel.xxx_0.0.1_64x64.png", -"download_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.xxx/mpks/cz.ucw.pavel.xxx_0.0.1.mpk", -"fullname": "cz.ucw.pavel.xxx", +"short_description": "Application for placing phone calls", +"long_description": "Simple application for monitoring network state and placing phone calls.", +"icon_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.cellular/icons/cz.ucw.pavel.cellular_0.0.1_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.cellular/mpks/cz.ucw.pavel.cellular_0.0.1.mpk", +"fullname": "cz.ucw.pavel.cellular", "version": "0.0.1", "category": "utilities", "activities": [ diff --git a/internal_filesystem/apps/cz.ucw.pavel.cellular/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/cz.ucw.pavel.cellular/res/mipmap-mdpi/icon_64x64.png index 316c5f2366bdcab259ac9fb75ce7c3db1dfc4c65..662b3c89ded8f291bb9494fba13dedfe2ce17ff4 100644 GIT binary patch delta 4356 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 From d6ffabdb4baba9fd64c0d2f7f31e304d6225fa6b Mon Sep 17 00:00:00 2001 From: Pavel Machek Date: Mon, 9 Mar 2026 11:38:44 +0100 Subject: [PATCH 12/16] cellular: remove unused glue --- .../cz.ucw.pavel.cellular/assets/phone.py | 305 ------------------ 1 file changed, 305 deletions(-) diff --git a/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/phone.py b/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/phone.py index 41dc7813..948c19ff 100755 --- a/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/phone.py +++ b/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/phone.py @@ -6,16 +6,8 @@ import sys import json """ -Librem 5, phosh, python. Give me code to read current battery level. - -(and more) - Lets make it class Phone, one method would be reading battery information, one would be reading operator name / signal strength, one would be getting wifi enabled/disabled / AP name. -Can you also get silent mode, pending notifications, and gps coordinates on request? - -run this with sudo to work around permission problems - sudo apt install python3-pydbus sudo mmcli --list-modems @@ -33,149 +25,6 @@ class Phone: def init_sess(self): self.sess = pydbus.SessionBus() - # --- Battery --- - def get_battery_info(self): - upower = self.bus.get("org.freedesktop.UPower") - for dev_path in upower.EnumerateDevices(): - if self.verbose: print("dev_path is", dev_path) - dev = self.bus.get(".UPower", dev_path) - if dev.Type == 2: # battery - return { - "percentage": dev.Percentage, - "state": dev.State, - "charging": dev.State == 1, - "time_to_empty": dev.TimeToEmpty, # seconds, 0 if unknown - "time_to_full": dev.TimeToFull, # seconds, 0 if unknown - } - return None - - # --- Vibration --- - # https://github.com/agx/feedbackd/blob/main/examples/example.py - def set_vibration(self, enable: bool): - # Connect to GSettings backend (org.gnome.SettingsDaemon, commonly) - dconf = self.sess.get("org.sigxcpu.Feedback", "/org/sigxcpu/Feedback") - - # Use the standard Properties interface - iface = dconf["org.sigxcpu.Feedback.Haptic"] - - # Example pattern: list of (duration, strength) - pattern = [ - (3.0, 1), - (1.0, 200), - (0.0, 50), - (0.5, 300), - ] - - iface.Vibrate("org.foo.app", pattern) - print(dir(iface)) - - # --- Feedback: silent/full/... --- - # broken - def set_feedback_theme(self, value): - # Connect to GSettings backend (org.gnome.SettingsDaemon, commonly) - dconf = self.bus.get("org.gnome.SettingsDaemon", "/org/gnome/SettingsDaemon/Dbus") - - # Use the standard Properties interface - iface = dconf["org.freedesktop.DBus.Properties"] - - # Set the key (schema, key, value) - # GVariant format: value must match the expected type, here 's' = string - value = Variant("s", "custom") - - iface.Set("org.sigxcpu.feedbackd", "theme", value) - - # --- Mobile network --- - # Works as root - def get_mobile_info(self): - loc = None - mm = self.bus.get("org.freedesktop.ModemManager1") - for modem_path in mm.GetManagedObjects(): - modem = self.bus.get(".ModemManager1", modem_path) - print("modem ", modem) - operator = getattr(modem, "OperatorName", None) - print("Operator code:", getattr(modem, "OperatorCode", None)) # 0..11 according to MMState - print("State:", getattr(modem, "State", None)) # 0..11 according to MMState - print("Access Technology:", getattr(modem, "AccessTechnologies", None)) - print("Model:", getattr(modem, "Model", None)) - print("Manufacturer:", getattr(modem, "Manufacturer", None)) - print("Revision:", getattr(modem, "Revision", None)) - print("Equipment Identifier (IMEI):", getattr(modem, "EquipmentIdentifier", None)) - - print("Signal (gsm):", getattr(modem, "Gsm", None)) - print("Signal (umts):", getattr(modem, "Umts", None)) - print("Signal (lte):", getattr(modem, "Lte", None)) - - print("Signal:", getattr(modem, "SignalQuality", None)) - print("RegistrationState:", getattr(modem, "RegistrationState", None)) - - # Hallucination? - lac = getattr(modem, "LocationAreaCode", None) - cid = getattr(modem, "CellId", None) - tac = getattr(modem, "TrackingAreaCode", None) - print("Lac...:", lac, cid, tac) - - loc = getattr(modem, "Location", None) - print("Location:", loc) - - v = modem.Setup(0x027, False) - print("Location setup? ", v) - v = modem.GetLocation() - # This has 1) network info and 4) nmea - print(v) - - # Fails with no signal; but has even timing-advance info (I guess only when transmitting) - # It also seems to have neighbouring cells! - """ - Field Meaning Example - operator-id MCC+MNC, identifies mobile operator 23003 - serving Whether device is currently connected to this cell True - physical-ci LTE Physical Cell ID (PCI) 12 - ci LTE Cell Identity XXXXXX - tac Tracking Area Code XXXX - earfcn LTE frequency channel XXXX - cell-type Cell type code (macro/micro/etc.) 5 - rsrp Signal strength (dBm) -122.7 - rsrq Signal quality (dB) -17.0 - """ - try: - v = modem.GetCellInfo() - except: - v = {} - print(v) - - if False: - simple = self.bus.get(".ModemManager1.Modem.Modem3gpp", modem_path) - print(simple) - - if False: - # --- Signal --- - try: - modem3gpp = modem.Modem3gpp - if modem3gpp: - print("3GPP Operator Code:", getattr(modem3gpp, "OperatorCode", None)) - print("Signal Quality:", getattr(modem3gpp, "SignalQuality", None)) # (percent, valid) - print("Registration State:", getattr(modem3gpp, "RegistrationState", None)) - except Exception: - print("No 3gpp?") - - # Pokud je LTE/other, ModemManager má ještě Modem4g nebo ModemSignal - try: - signal = modem.Signal - if signal: - # SignalQuality může být tuple (percent, valid) - print("SignalQuality (Signal interface):", getattr(signal, "SignalQuality", None)) - except Exception: - print("No signal?") - - signal = None - if False: - try: - signal = modem.Signal.Get()["rssi"] - except Exception as e: - return {"error": str(e)} - return {"operator": operator, "signal_strength": signal} - return loc - def get_mobile_loc(self): loc = None mm = self.bus.get("org.freedesktop.ModemManager1") @@ -286,132 +135,6 @@ class Phone: return { 'result' : 'setenable failed' } return { 'result': 'ok' } - # --- WiFi --- - def get_wifi_info(self): - nm = self.bus.get("org.freedesktop.NetworkManager") - wifi_enabled = nm.WirelessEnabled - active_ssid = None - for conn_path in nm.ActiveConnections: - ac = self.bus.get(".NetworkManager", conn_path) - if ac.Type == "802-11-wireless": - # Step 1: get the settings connection path - settings_path = ac.Connection - # Step 2: fetch the settings object - sc = self.bus.get(".NetworkManager", settings_path) - settings = sc.GetSettings() - ssid = settings["802-11-wireless"]["ssid"] - if isinstance(ssid, (bytes, bytearray)): - ssid = ssid.decode("utf-8", errors="ignore") - else: - ssid = ''.join(chr(c) for c in ssid) - return {"enabled": nm.WirelessEnabled, "ssid": ssid} - - return {"enabled": wifi_enabled, "ssid": active_ssid} - - # --- Silent mode / Do Not Disturb --- - # broken - def get_silent_mode(self): - try: - portal = self.bus.get("org.freedesktop.portal.Desktop", - "/org/freedesktop/portal/desktop") - return portal.Settings.Read("org.freedesktop.appearance", - "sound-theme-enabled") == 0 - except Exception as e: - return {"error": str(e)} - - # --- Pending notifications --- - # broken - def get_notifications(self): - try: - notif = self.bus.get("org.freedesktop.Notifications") - # org.freedesktop.Notifications has no standard "list" API, - # Phosh implements its own. - # In phosh, you can query /org/gnome/Notifications for backlog. - phosh_notif = self.bus.get("org.gnome.Shell", - "/org/gnome/Shell/Notifications") - return phosh_notif.ListNotifications() - except Exception as e: - return {"error": str(e)} - - # --- GPS coordinates --- - # Needs permissions from .desktop - def get_location(self): - try: - geoclue = self.bus.get("org.freedesktop.GeoClue2", - "/org/freedesktop/GeoClue2/Manager") - # Step 1: get a client object path - client_path = geoclue.GetClient() - client = self.bus.get("org.freedesktop.GeoClue2", client_path) - - # Step 2: set required properties - client.DesktopId = "phone.py" - client.RequestedAccuracyLevel = 3 # 3 = city-level accuracy - client.Start() # start location updates - - # Step 3: read location - loc_path = client.Location - location = self.bus.get("org.freedesktop.GeoClue2", loc_path) - - return { - "latitude": location.Latitude, - "longitude": location.Longitude, - "accuracy": location.Accuracy, - } - except Exception as e: - return {"error": str(e)} - - # --- Hardware sensors (accelerometer, gyroscope, light, proximity) --- - def get_hardware_sensors(self): - try: - obj = self.bus.get("net.hadess.SensorProxy", "/net/hadess/SensorProxy") - - # obj exposes multiple interfaces; access the one we need - sensor_proxy = obj["net.hadess.SensorProxy"] - - # Enable accelerometer - sensor_proxy.ClaimAccelerometer() - sensor_proxy.ClaimLight() - sensor_proxy.ClaimProximity() - - # Give it a small delay to start updating - time.sleep(0.5) - - sensors = {} - #print(dir(sensor_proxy)) - print('tilt -- tells you phone position -- ', sensor_proxy.AccelerometerTilt) - print('orient -- orientation for screen rotation -- ', sensor_proxy.AccelerometerOrientation) - - # Ambient light - if sensor_proxy.HasAmbientLight: - sensors['ambient_light'] = { - 'lux': sensor_proxy.LightLevel - } - - # Proximity - if sensor_proxy.HasProximity: - sensors['proximity'] = { - 'near': sensor_proxy.ProximityNear - } - - return sensors - except Exception as e: - return {"error": str(e)} - - # --- Screen lock --- - def get_screen_lock(self): - # This one complains - #screensaver = self.sess.get("org.freedesktop.ScreenSaver", "/org/freedesktop/ScreenSaver") - screensaver = self.sess.get("org.gnome.ScreenSaver", "/org/gnome/ScreenSaver") - print(dir(screensaver)) - #screensaver.SetActive(True) - return { "Locked": screensaver.GetActive() } - - -# bus = SystemBus() -# login1 = bus.get("org.freedesktop.login1", "/org/freedesktop/login1") -# login1.Suspend(False) # False = interactive, True = force -# login1.Hibernate(False) - phone = Phone() def handle_cmd(v, a): @@ -442,31 +165,3 @@ def handle_cmd(v, a): if len(sys.argv) > 1: handle_cmd(sys.argv[1], sys.argv) -def full(): - phone.init_sess() - print("Battery:", phone.get_battery_info()) - phone.set_vibration(True) -# print("Mobile:", phone.get_mobile_info()) - print("WiFi:", phone.get_wifi_info()) -# print("Silent mode:", phone.get_silent_mode()) -# print("Notifications:", phone.get_notifications()) - print("Location:", phone.get_location()) - print("Hardware sensors:", phone.get_hardware_sensors()) - print("Screen lock:", phone.get_screen_lock()) - phone.set_vibration(False) - # full, quiet, silent -# phone.set_feedback_theme("full") - -def as_root(): - print("Battery:", phone.get_battery_info()) - print("Mobile:", phone.get_mobile_info()) - print("WiFi:", phone.get_wifi_info()) -# print("Silent mode:", phone.get_silent_mode()) -# print("Notifications:", phone.get_notifications()) -# print("Location:", phone.get_location()) - print("Hardware sensors:", phone.get_hardware_sensors()) - # full, quiet, silent -# phone.set_feedback_theme("full") - -#full() -as_root() From 439dc083209462b92b363ffbe8fc27433fe1562a Mon Sep 17 00:00:00 2001 From: Pavel Machek Date: Mon, 9 Mar 2026 11:39:02 +0100 Subject: [PATCH 13/16] cellular: add some comments --- .../apps/cz.ucw.pavel.cellular/assets/main.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/main.py b/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/main.py index ddd1ebfd..a6877e50 100644 --- a/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/main.py +++ b/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/main.py @@ -1,7 +1,7 @@ from mpos import Activity """ - +Simple cellular-network example """ import time @@ -49,7 +49,7 @@ class CellularManager: cm = CellularManager() # ------------------------------------------------------------ -# +# User interface # ------------------------------------------------------------ class Main(Activity): @@ -85,7 +85,7 @@ class Main(Activity): self.number.align_to(self.lbl_date, lv.ALIGN.OUT_BOTTOM_LEFT, 0, 12) self.call = lv.button(self.screen) - self.call.align_to(self.number, lv.ALIGN.OUT_RIGHT_MID, 10, 0) + self.call.align_to(self.number, lv.ALIGN.OUT_RIGHT_MID, 2, 0) self.call.add_event_cb(lambda e: self.on_call(), lv.EVENT.CLICKED, None) # Two text areas on single screen don't work well. From 0ac9d7155e80bbff9a6a588444a5d60a1e2dd0b3 Mon Sep 17 00:00:00 2001 From: Pavel Machek <8401486+pavelmachek@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:36:00 +0100 Subject: [PATCH 14/16] gyro: add gyroscope test/display application (#76) * iio: switch to maximum sampling frequency, apply mount matrix * mag: Apply mount matrix for magnetometer, too * iio: allow separate path for gyro sensor * gyro: fork from compass, get drawing back to work * iio: add todo. * gyro: gyro seems to work way better on pinephone * iio: move scale conversion where it belongs * gyro: rely of iio driver providing right scale of values * iio: Turn down debugging, but I still get framerate drops * gyro: add reset and calibration support, introduce vectors, display help image --- .../cz.ucw.pavel.gyro/META-INF/MANIFEST.JSON | 24 + .../apps/cz.ucw.pavel.gyro/assets/main.py | 625 ++++++++++++++++++ .../apps/cz.ucw.pavel.gyro/res/gyro-help.png | Bin 0 -> 28684 bytes .../res/mipmap-mdpi/icon_64x64.png | Bin 0 -> 7808 bytes .../lib/mpos/imu/drivers/iio.py | 159 ++++- 5 files changed, 798 insertions(+), 10 deletions(-) create mode 100644 internal_filesystem/apps/cz.ucw.pavel.gyro/META-INF/MANIFEST.JSON create mode 100644 internal_filesystem/apps/cz.ucw.pavel.gyro/assets/main.py create mode 100644 internal_filesystem/apps/cz.ucw.pavel.gyro/res/gyro-help.png create mode 100644 internal_filesystem/apps/cz.ucw.pavel.gyro/res/mipmap-mdpi/icon_64x64.png diff --git a/internal_filesystem/apps/cz.ucw.pavel.gyro/META-INF/MANIFEST.JSON b/internal_filesystem/apps/cz.ucw.pavel.gyro/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..bd365e8e --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.gyro/META-INF/MANIFEST.JSON @@ -0,0 +1,24 @@ +{ +"name": "Gyro", +"publisher": "Pavel Machek", +"short_description": "Gyro", +"long_description": "Simple gyro app.", +"icon_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.gyro/icons/cz.ucw.pavel.gyro_0.0.1_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.gyro/mpks/cz.ucw.pavel.gyro_0.0.1.mpk", +"fullname": "cz.ucw.pavel.gyro", +"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.gyro/assets/main.py b/internal_filesystem/apps/cz.ucw.pavel.gyro/assets/main.py new file mode 100644 index 00000000..f6127684 --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.gyro/assets/main.py @@ -0,0 +1,625 @@ +""" +Test/visualization of gyroscope / accelerometer + +""" + +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 + +class Vec3: + def __init__(self): + pass + + def init3(self, x, y, z): + self.x = float(x) + self.y = float(y) + self.z = float(z) + return self + + def init_v(self, v): + self.x = v[0] + self.y = v[1] + self.z = v[2] + return self + + def __add__(self, other): + return vec3( + self.x + other.x, + self.y + other.y, + self.z + other.z + ) + + def __sub__(self, other): + return vec3( + self.x - other.x, + self.y - other.y, + self.z - other.z + ) + + def __mul__(self, scalar): + return vec3( + self.x * scalar, + self.y * scalar, + self.z * scalar + ) + + def __truediv__(self, scalar): + return vec3( + self.x / scalar, + self.y / scalar, + self.z / scalar + ) + + __rmul__ = __mul__ + + def __repr__(self): + return f"X {self.x:.2f} Y {self.y:.2f} Z {self.z:.2f}" + +def vec3(x, y, z): return Vec3().init3(x, y, z) +def vec0(): return Vec3().init3(0, 0, 0) + +# ----------------------------- +# Calibration + heading +# ----------------------------- + +class Gyro: + def __init__(self): + super().__init__() + self.rot = vec0() + self.last = time.time() + self.last_reset = self.last + self.smooth = vec0() + self.calibration = vec0() + + def reset(self): + now = time.time() + self.calibration = self.rot / (now - self.last_reset) + print("Reset... ", self.calibration) + self.last_reset = now + self.rot = vec0() + + def update(self): + """ + Returns heading 0..360 + + iio is in rads/second + """ + t = time.time() + # pp: gyr[1] seems to be rotation "away" and "towards" the user, like pitch in plane ... or maybe roll? + # gyr[2] sseems to be rotation -- as useful for compass on table + v = self.gyr + coef = 1 + self.smooth = self.smooth * (1-coef) + v * coef + self.rot -= self.smooth * (t - self.last) + self.last = t + + def angle(self): + now = time.time() + return self.rot - (now - self.last_reset) * self.calibration + + def angvel(self): + return vec0()-self.smooth + +class UGyro(Gyro): + 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.gyro = SensorManager.get_default_sensor(SensorManager.TYPE_GYROSCOPE) + + self.gyr = None + + def update(self): + acc = SensorManager.read_sensor_once(self.accel) + sc = 1/9.81 + acc = vec3( -acc[0] * sc, acc[1] * sc, acc[2] * sc ) + self.acc = acc + + self.gyr = Vec3().init_v(SensorManager.read_sensor_once(self.gyro)) + super().update() + +# ----------------------------- +# 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 Main(PagedCanvas): + ASSET_PATH = "M:apps/cz.ucw.pavel.gyro/res/gyro-help.png" + + def __init__(self): + super().__init__() + + self.cal = UGyro() + self.Ypos = 40 + + img = lv.image(lv.layer_top()) + img.set_src(f"{self.ASSET_PATH}") + self.help_img = img + self.hide_img() + + def hide_img(self): + self.help_img.add_flag(lv.obj.FLAG.HIDDEN) + + def draw_img(self): + img = self.help_img + img.remove_flag(lv.obj.FLAG.HIDDEN) + img.set_pos(60, 18) + #img.set_size(640, 640) + img.set_rotation(0) + + def draw(self): + pass + + def onResume(self, screen): + self.timer = lv.timer_create(self.tick, 50, None) + + def update(self): + self.c.clear() + + y = 20 + st = 20 + + self.cal.update() + if self.cal.gyr is None: + self.c.text(0, y, f"No compass data") + y += st + return + + if self.page == 2: + self.draw_img() + return + self.hide_img() + + if self.page == 0: + self.draw_top(self.cal.acc) + elif self.page == 1: + self.draw_values() + elif self.page == 3: + self.c.text(0, y, f"Resetting calibration") + self.page = 0 + self.cal.reset() + + def build_buttons(self): + self.template_buttons(["Graph", "Values", "Help", "Reset"]) + + def draw_values(self): + x, y, z = self.cal.acc.x, self.cal.acc.y, self.cal.acc.z + total = math.sqrt(x*x+y*y+z*z) + s = "" + if x > .6: + s += " left" + if x < -.6: + s += " right" + if y > .6: + s += " up" + if y < -.6: + s += " down" + if z > .6: + s += " below" + if z < -.6: + s += " above" + + t = "" + lim = 25 + angvel = self.cal.angvel() + if angvel.z > lim: + # top part moves to the right + t += " yaw+" + if angvel.z < -lim: + t += " yaw-" + if angvel.x > lim: + # top part goes up + t += " pitch+" + if angvel.x < -lim: + t += " pitch-" + if angvel.y > lim: + # right part goes down + t += " roll+" + if angvel.y < -lim: + t += " roll-" + + self.c.text(0, 7, f""" +^ Up -> Right +|| Acc +{self.cal.acc} +Earth is{s}, {total*100:.0f}% +{self.cal.gyr} +Rotation is{t} +""") + + 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.cal.angle().z + heading2=self.cal.angvel().z + vmin=0 + vmax=20 + v=self.cal.gyr + + 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) + + # 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.x, acc.y, acc.z + 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)) diff --git a/internal_filesystem/apps/cz.ucw.pavel.gyro/res/gyro-help.png b/internal_filesystem/apps/cz.ucw.pavel.gyro/res/gyro-help.png new file mode 100644 index 0000000000000000000000000000000000000000..4b54b993402a0f2db0ef3e740fdab3f132e02411 GIT binary patch literal 28684 zcmeAS@N?(olHy`uVBq!ia0y~yU^oH79Bd2>3~M9S&0}ED=co#aC<#g|S12gTPs_|n zRVb+}NL5I!$V_8ksJOLuPUJKh%SoyKH+ID^sU6%P)ARfbU)9fA`72{ zKIT9Ce*NxudzUz`8sGW*zy0|7>tX3W{`mU$@$dQV@BM%5WWSHI`Td*c_B|iJ{`&Lx z>(A1IH~#IpaGfLQ$DNz+*USId70?y0d}_aa)XTO1zGYO`-nFdXd!_E*{NUcBUMptW z7k}A0?c8_WUokV!uWh_1`g`|;TD32~TjlHjf4p{L#v@O~%6~nYb#61i)O$9sfBAQ2 z?4Lb0LC?NFU_Ugw;(U-1i`%XJc_x3pr~I=rw}1X3zwYnkz1Li)-DFuaCYazn=Z~>g8=XB#)#HBUS~{;RUp~v;ey8g8+wz+Ax2~`Hw=b)H-JSZq zSr4fMFLiod?D3!5OOzGRov z6T{zZ$A!Af+YOg8J;-@jyH%8rhywL?+xc)$fi^^a9IQx5E6IjQ(U zpmwuC<55A2GxyfLJ{D(o(M4hX*;`T@t2<3Dt(&&xS7xfgkqglqkNtY`RPyxAJ4^cC z#NJwa+~-MZcj?b*>isX(&v!@7T;3*k+IQ#NoB2w%zhgb4vV>=)>b-7WBU!jR_|zHk zp0`q_Easmx#nx`k*6v&<-EaAA&60BjSH?Z4Y?KYJ>q$AC-!PUUI81jo0B-?*jynDjn#=Y%}|GjrRjxqLP(%)O?x*}~5^{v-jvW+z7g6 zl*m#Z_$|ahu#n-W&Xp>U)jND^p2u=-@Tqt|hv~(oy=nXVgVWz_{>pjr{>;z!t_g@T zoRT#D7_qbW)Tj5wF^}}z4{l~`FYHcdyCC#&8gq`p#GN_!_VYiC*>uQ^NxM|9p(XWM ztu-6-jcYrer`*^$XWLBmfaTlGA~xNO>M8aSnxTFs$R;`B#!n^9*&U(|uUovL4lasb zQx8b3X0jvJDxL@Y3(UHrIo^Tc&@+= zR$~_y7psW@0yN84Vfg3zUWS zOpcv==k;rbii1D4&#tn4T{dmm?j7p|U)u0qu0C<*7t`+JRXxkUz3qGTeAVX7D^~u| zov^#U>C66F)#7lmDesP$Z|5+1#rr`@giHDG;hrv0@1S|&37)o-VtAF5+Y+|=xU&b^9v>HY z)PAL<+r4ntKbhq9mxEJ9Y(K`{{KLWEJfrO@N7Y2djSWk)ETmcWueW>V`NeoLzW?^} zAoIS%VtY)3SHC=PWSe!iw6W(MhLt)+j9!Noo~$|T@1Chy@mj_<>!{-`r38*gU+iFe#Y(%H>b2)yE{BKl*Lj<=xUKS}wHk`5~3%3$%aj+xj6l0WP|7a}x@z#U#Bj@Yp&HEzsc(K6; z14$!>kCEmE;-MFrJbIf{j$iV+)7%pqzOwR;36tewmGVq$p*fOP{<0j*OS000R7}++ z?i@_NqO!x{{;N&*beK1IZ;|f_?}yBB}enlJN6 zoAp((&9QK8!;C7W>@rK%cTUW%iJA}FFR?wVcDQut&2q>89IXpqM6*3zd{ObZZ-A`F zx~TgR98-#ve9PA9F3Z(3^G5m3epb;-I;rsA){~Uarz-9CC^8U@i(!{@z3`}bq3ypXdQ0SAwM*!(ShX$ii?poJE7y|`T^)91UAR9< zme01Cg-7e%e4!ur_nope=;zl@QmhXv`mWOBBlN#q&ifFvXiEmy)Co)xGppb5D3(9J z5WJX+L#FlZm%lb|UoK2nZ$99%B%^`7k|AHXN@LS9&Ig+wiHqFs@!{+jVADRY>#Og4!gHI&iB-;Qj5aGv9vukTx<}gmi1fr0CvN3<&2B!+v?Gl5KtNtl zBK!AgPn@JYbB{bqTqL4+K+?{2w|fLz z?#3)j5K~+i8FJiU&C975_#bI3;GQ2fvvfw7mh=RN@`(yIoAr1!@5*22oqLDBXMSd~ zkB#l2@tV({j_1`Z3^zal8hIdUXGFL9+obuECn%;f zN>Ab4&MAA`txq`CdcDrAs8WL)d6HfZ>!&*$VQ4(@<&9R>=7o#=#VkLG2A*hQ@|13^ zUQqN!D2}ay>FMfej!wzjW0Z^-ujv`dpDaDnI6b?`SyI?xW3yLu#rCx_Ng~UOgMBor zR0|F|Y6yof5;l5zR=V=J(U~mfBQk&2ig?}gpV5`-$Rl_ndtE`xjRWm1-bW5>y4NwU z;oV_ohv{qDqKld%f83qSn=o^?e3Gbgr0Tau#+rnk=TlkRm=hGG9TTPr+eaO_E`Fb% zX4K)69p9RQ#NQYt zOlVY9>RMPn?Sa8(P7nFz3^jYQg!#W`Iq>qf%kAMcN)SsfWMT1P-acvBj1wL~?)AfW-;HOXD#ox14d??E}@Z>OaJWq$) z?$S-29xV+(kjIN#2REK-s@g1onpKx?EtUv&if2fGrT9t2ToY= z)UP1T{l~Sw2QepScr0q=Jbp#>nfFnJM|@oh{ii2MKXcq@b+^LVbzW?RvRA23aS*Gp zAM0Kx=S%G#QPmSTw(}{SSe&@(^6i41KX%0#sHpylvZ;)}`+T+(WAAR?SA8>%O!mLQ z`>;AtpLOFV#hUY*KFKkCoM7N1YdAl_HSUF^VB>}!ZMPW;F%RnvpE1sv*SS((hfCRX z={JFB2Qw|6kQ&cgbj%B(4K(W~nR zj(wbSTGnZ;QHQ$z!O$I@I%iX&DkW}(8XCrJXvljQS;nQkp7Ds{j~O0Q>^H5j&|tDv zy}KytxT4`YrfZL`O9Q zIYVKI>iW$b!iu|&ehbVyw9K19^&9W$o(bs;Gjb(d7cu7?dXn2FEL?f;ykzA>Q4XHh zA)Q7!e3n~gRviD^_~Rt+2J3)Jp@Lb>*JsG2*w=78&=2hm{qaFm`q9#;8SixttP}pX zoq0p!My3R5jqXhwSXS+qoB5P;+MH%PrxgO;$yvK*rsuLJc9cJkeE6l+G3R=suJ}6t zuM>K!9;UBZ#J#oT*qvODwX!E~?XaB~Zg62myJO7iS0$$xUyP2sxjdgo-l-#kZ{LSM z+|mr^Sa$9>W4~(9crHW~z;kmZziS>4}k_s#gPORGx zU-6E7q|kA2w#;<%H@+`cNekCMc$z8^(Y)f*{T!aA8;6Sck19S~=c};BPRYOOTK3JE zVbl91`Ij?UG&`^hswVKLQ6zZ6hS825v2A3(WdORWMf#PM? z5W#HUYPASKjaOG^J-qelh-<~t<-y7xn*x|?OZWVK@c*3thZWwM3wk6aw;xh8m~brm z`0qj;@vd*-%1l>l8SC^^P2PHBd^J42W6sjtjl~a7#C&|TY-+O9#*hzNHZXTKb{sjP zA1rJ9+)bn*At8XzM<^86&8drRfWZv9=Nbo_STlgr>Z{j z7jj+QntOQb$1NIS_oi>T5RoFJD$bn9?bXHUts@}UFQazuNa&dzD}9>%yw0mvmmHLp z7G%tNm=Jk;ZKZ=`pS6c|!k; zj&C^CRer+$pz5aH+yn8#m)j0JuKdQpA{)~9wAXp+GqH{t0TVkr^pp5C=B}A|%Aoa2 zuWz)RLVdE|G9A;O_A|p~EVlNj@8|jPC_DPjt#y{GCMq-wX>OfSKgm_2$U`q%d11zh z_^Wp&sNY~>6OX7X$xb~h_c}C`)j>_}vecrIM#YzWo%_O0TE+-o@hgj&+Gg*jbd&o> z*I6OXv;F5SE&ZluYKwLL*_T}VxH9E{#`Z%hl4<)^Ijp$b(DwJt3C-#AvTb8^JRSTl za7>=XGV^7oOihVXE&GRbH03W zauK-^FU`nmx?amdiS6dZOTr(UG(VK5s$4yyvQbLmbEKA_vGs~i%t8wDKPa84SBv7| z+RXcFgA$AFuJ3PyIu8oCEN4}${9z-Ow_)*Iwatwjw=%5$++w`hw;~|U{de%j{WG*K z2>t25YyClLJD2Um*{#)iB42+zQGOWIX|w4@>a%hs!PYxBx+fOS&=L?dTfm(pCjC{X z*o>uBWoukD-{gZQ%QK5#x*VVM(5#}#Fmz^`-~+W5=4s1jb}b1lkiR%xgLOtwTi*)z z*J)`pU)zDFhE$~W$g1B*}W zj3~Qh*Znr>P^97oMq$q)ks5<-4>!eW3Q2OZGk<@4Pb_kJp041pnXy}EtIeC~7y9(T zm(Fc^=TC3RzV+ZIr>{vzEz_6kvdh~{by_ByEtL^j9VEHEA~o{a3cb~JU9*|5oII7r zAj)0Dlgcg+(|h=Ot1idBk|Ty*XP-}%nAGv2=y`y_ z`i@EOe1F>>Uga!u(sI6er$)#RkEEEVb0-*g25evQe|74_-W$uD53HQUBXCVTl#vc9vx{tdHhkStzu?U1^7G5aUtfg&ZkbW@Qt^bY;0>>< zTetWJCH6D2iL0J2lboZoN_6w77ZM`*-WA){e5s_=Jehf-IK2SzT>>xU(BxI9cJ#Cy-Mfd+)is36{U-&j9hhzGk@7=CkM#R z{}Qy-r}1D*vaUx5|ADUyW^H|_9et32Ic~n1p!xJq|NGxMCYZXLnCCnaUt_Xm%d4rTX0|3y z-enVAzD>CM=vzjzk)ZwVD*+$Mg0CNFHi%MGZ%9+InEo!S-1+*W`xgorR~%heRO%D& z#?0#Duq+Dre;hYzWq73W?pz^5W*(DW`kB z%)Z`g`RexB>fIgTcNEHIbv)usK9aRo`El8`eLQ#i^M2)M^K*zUHJtvaDZuDQeqsG0 z{#^&YwJPo8RZ)MbbB*7A{XDLT+YXmA>+GFeSI}1aBy?LhOJBp9rI9B$2t>%;ukxPC zAmFsK!8D_|@*c}^jZ^SJEW z7j80-Qn}=8-P(FTh1vdl@ItN9*(&F4%7dta+Vwx4+RyG`vCPQdzLRlXvbMmIVBTwI zj%>_JVcEq}Efso0(e2`|9(P~GC=Zo$A$k+;uD%{|p@gp_spHj7rX`iH6iW}x

Cx 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 WnpJ;CMSl@!f4rxwpUXO@geCx9gGwd< literal 0 HcmV?d00001 diff --git a/internal_filesystem/apps/cz.ucw.pavel.gyro/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/cz.ucw.pavel.gyro/res/mipmap-mdpi/icon_64x64.png new file mode 100644 index 0000000000000000000000000000000000000000..fcb60f0cbd4a9b480e6bb47157e0b21828632b99 GIT binary patch literal 7808 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 str: - print("Read: ", name) + if False: + print("Read: ", name) f = open(name, "r") try: return f.readline().strip() finally: f.close() + def _parse_available_freqs(self, text): + """ + IIO typically uses either: + "12.5 25 50 100" + or + "0.5 1 2 4 8 16" + + Returns list of floats. + """ + out = [] + for tok in text.replace(",", " ").split(): + out.append(float(tok)) + return out + + def _format_freq_for_sysfs(self, f): + """ + Kernel sysfs usually accepts either integer or decimal. + We'll keep it minimal: + - if f is whole number -> "100" + - else -> "12.5" + """ + if int(f) == f: + return str(int(f)) + # avoid scientific notation + s = ("%.6f" % f).rstrip("0").rstrip(".") + return s + + def _try_set_via_sudo_tee(self, path, value_str): + """ + Executes: + sh -c 'echo VALUE | sudo tee PATH' + Returns True if command returns 0. + """ + cmd = "sh -c 'echo %s | sudo tee %s >/dev/null'" % (value_str, path) + rc = os.system(cmd) + return rc == 0 + + def ensure_sampling_frequency_max(self, dev_path): + """ + dev_path: "/sys/bus/iio/devices/iio:deviceX" + + Returns: + (changed: bool, max_freq: float or None, current: float or None) + """ + sf = dev_path + "/sampling_frequency" + sfa = dev_path + "/sampling_frequency_available" + + # read current + cur_s = self._read_text(sf) + cur = float(cur_s) + + avail_s = self._read_text(sfa) + avail = self._parse_available_freqs(avail_s) + + maxf = max(avail) + + # already max (tolerate float fuzz) + if abs(cur - maxf) < 1e-6: + print("Already at max frequency") + return (False, maxf, cur) + + max_str = self._format_freq_for_sysfs(maxf) + + # Fallback: sudo tee + ok = self._try_set_via_sudo_tee(sf, max_str) + if not ok: + print("Can't switch to max frequency") + return (False, maxf, cur) + + new_cur = float(self._read_text(sf)) + + return (True, maxf, new_cur) + + def ensure_sampling_frequency_max_for_device_with_file(self, filename): + """ + Convenience wrapper: + - finds iio device containing filename + - sets sampling_frequency to maximum + """ + dev = self.find_iio_device_with_file(filename) + if dev is None: + return (None, False, None, None) + + changed, maxf, cur = self.ensure_sampling_frequency_max(dev) + return (dev, changed, maxf, cur) + def _read_float(self, name: str) -> float: return float(self._read_text(name)) @@ -93,6 +185,7 @@ class IIODriver(IMUDriverBase): - in_temp_input (already scaled, usually millidegree C) - in_temp_raw + in_temp_scale """ + return 12.34 if not self.accel_path: return None @@ -102,6 +195,51 @@ class IIODriver(IMUDriverBase): return None return self._read_raw_scaled(raw_path, scale_path) + def _read_mount_matrix(self, p): + """ + Reads IIO mount matrix from *mount_matrix + + Format example: + "0, 1, 0; -1, 0, 0; 0, 0, 1" + + Returns: + 3x3 matrix as tuple of tuples (float) + """ + path = p + "/" + "in_accel_mount_matrix" + if not self._exists(path): + # Strange, librem 5 has different filename + path = self.accel_path + "/" + "mount_matrix" + if not self._exists(path): + return None + + text = self._read_text(path).strip() + + rows = [] + for row in text.split(";"): + rows.append(tuple(float(x.strip()) for x in row.split(","))) + + if len(rows) != 3 or any(len(r) != 3 for r in rows): + raise ValueError("Invalid mount matrix format") + + return tuple(rows) + + + def _apply_mount_matrix(self, ax, ay, az, p): + """ + Applies IIO mount matrix to acceleration vector. + + Returns rotated (ax, ay, az). + """ + M = self._read_mount_matrix(p) + if M is None: + return (ax, ay, az) + + x = M[0][0]*ax + M[0][1]*ay + M[0][2]*az + y = M[1][0]*ax + M[1][1]*ay + M[1][2]*az + z = M[2][0]*ax + M[2][1]*ay + M[2][2]*az + + return (x, y, z) + def _raw_acceleration_mps2(self): if not self.accel_path: return (0.0, 0.0, 0.0) @@ -111,18 +249,19 @@ class IIODriver(IMUDriverBase): ay = self._read_raw_scaled(self.accel_path + "/" + "in_accel_y_raw", scale_name) az = self._read_raw_scaled(self.accel_path + "/" + "in_accel_z_raw", scale_name) - return (ax, ay, az) + return self._apply_mount_matrix(ax, ay, az, self.accel_path) def _raw_gyroscope_dps(self): - if not self.accel_path: + if not self.gyro_path: return (0.0, 0.0, 0.0) - scale_name = self.accel_path + "/" + "in_anglvel_scale" + scale_name = self.gyro_path + "/" + "in_anglvel_scale" + mul = 57.2957795 - gx = self._read_raw_scaled(self.accel_path + "/" + "in_anglvel_x_raw", scale_name) - gy = self._read_raw_scaled(self.accel_path + "/" + "in_anglvel_y_raw", scale_name) - gz = self._read_raw_scaled(self.accel_path + "/" + "in_anglvel_z_raw", scale_name) + gx = mul * self._read_raw_scaled(self.gyro_path + "/" + "in_anglvel_x_raw", scale_name) + gy = mul * self._read_raw_scaled(self.gyro_path + "/" + "in_anglvel_y_raw", scale_name) + gz = mul * self._read_raw_scaled(self.gyro_path + "/" + "in_anglvel_z_raw", scale_name) - return (gx, gy, gz) + return self._apply_mount_matrix(gx, gy, gz, self.gyro_path) def read_acceleration(self): ax, ay, az = self._raw_acceleration_mps2() @@ -145,4 +284,4 @@ class IIODriver(IMUDriverBase): gy = self._read_raw_scaled(self.mag_path + "/" + "in_magn_y_raw", self.mag_path + "/" + "in_magn_y_scale") gz = self._read_raw_scaled(self.mag_path + "/" + "in_magn_z_raw", self.mag_path + "/" + "in_magn_z_scale") - return (gx, gy, gz) + return self._apply_mount_matrix(gx, gy, gz, self.mag_path) From 5ded4b40f6b88af2ceee04cdaddc1d2a1092f75e Mon Sep 17 00:00:00 2001 From: Pavel Machek <8401486+pavelmachek@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:36:59 +0100 Subject: [PATCH 15/16] weather: add forecasts, switch to download manager (#75) * weather: add forecasts, and summarize them * weather: Switch to download manager We had open coded http protocol, this simplifies it and switches to https --- .../apps/cz.ucw.pavel.weather/assets/main.py | 335 ++++++++++++++---- 1 file changed, 262 insertions(+), 73 deletions(-) diff --git a/internal_filesystem/apps/cz.ucw.pavel.weather/assets/main.py b/internal_filesystem/apps/cz.ucw.pavel.weather/assets/main.py index 9258e47d..b8189327 100644 --- a/internal_filesystem/apps/cz.ucw.pavel.weather/assets/main.py +++ b/internal_filesystem/apps/cz.ucw.pavel.weather/assets/main.py @@ -12,7 +12,7 @@ try: except ImportError: pass -from mpos import Activity, MposKeyboard +from mpos import Activity, MposKeyboard, DownloadManager import ujson import utime @@ -55,22 +55,87 @@ class WData: 99: "Thunderstorm + hail", } + def init(self): + pass + 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 get(self, v, cw, ind): + if ind == None: + return cw[v] + else: + return cw[v][ind] + + def full(self): + return f"{self.code}\nTemp {self.temp:.1f} dew {self.dew:.1f} pres {self.pres:1f}\n" \ + f"Precip {self.precip}\nWind {self.wind} gust {self.gust}" + + def short(self): + r = f"{self.code} {self.temp:.1f}°C" + if self.dew + 3 > self.temp: + r += f" dew {self.dew:.1f}°C" + if self.gust > self.wind + 5: + r += f" {self.gust:.0f} g" + elif self.wind > 10: + r += f" {self.wind:.0f} w" + # FIXME: add precip + return r + + def similar(self, prev): + if self.code != prev.code: + return False + if abs(self.temp - prev.temp) > 3: + return False + if abs(self.wind - prev.wind) > 10: + return False + if abs(self.gust - prev.gust) > 10: + return False + return True def summarize(self): - return f"{self.code}\nTemp {self.temp}\nWind {self.wind}" + return self.ftime() + self.short() + +class Hourly(WData): + def init(self, cw, ind): + super().init() + self.time = None + self.temp = self.get("temperature_2m", cw, ind) + self.dew = self.get("dewpoint_2m", cw, ind) + self.pres = self.get("pressure_msl", cw, ind) + self.precip = self.get("precipitation", cw, ind) + self.wind = self.get("wind_speed_10m", cw, ind) + self.gust = self.get("wind_gusts_10m", cw, ind) + self.raw_code = self.get("weather_code", cw, ind) + self.code = self.code_to_text(self.raw_code) + + def ftime(self): + if self.time: + return self.time[11:13] + "h " + return "" + +class Daily(WData): + def init(self, cw, ind): + super().init() + self.temp = self.get("temperature_2m_max", cw, ind) + self.temp_min = self.get("temperature_2m_min", cw, ind) + self.dew = self.get("dewpoint_2m_max", cw, ind) + self.dew_min = self.get("dewpoint_2m_min", cw, ind) + self.pres = None + self.precip = self.get("precipitation_sum", cw, ind) + self.wind = self.get("wind_speed_10m_max", cw, ind) + self.gust = self.get("wind_gusts_10m_max", cw, ind) + self.raw_code = self.get("weather_code", cw, ind) + self.code = self.code_to_text(self.raw_code) + + def ftime(self): + return self.time[8:10] + ". " class Weather: name = "Prague" - lat = 50.08 - lon = 14.44 + # LKPR airport + lat = 50 + 6/60. + lon = 14 + 15/60. def __init__(self): self.now = None @@ -84,68 +149,102 @@ class Weather: # 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" + "¤t=temperature_2m,dewpoint_2m,pressure_msl,precipitation,weather_code,wind_speed_10m,wind_gusts_10m" + "&forecast_hours=8" + "&hourly=temperature_2m,dewpoint_2m,pressure_msl,precipitation,weather_code,wind_speed_10m,wind_gusts_10m" + "&forecast_days=10" + "&daily=temperature_2m_max,temperature_2m_min,dewpoint_2m_min,dewpoint_2m_max,pressure_msl_min,pressure_msl_max,precipitation_sum,weather_code,wind_speed_10m_max,wind_gusts_10m_max" "&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()) + data = DownloadManager.download_url("https://"+host+path) + if not data: + self.summary = "Download error" + return + + #print("Have result:", body.decode()) # Parse JSON - data = ujson.loads(body) + data = ujson.loads(data) # ---- Extract data ---- + print("\n\n") + + s = "" + + print("---- ") cw = data["current"] - self.now = Hourly(cw) - self.summary = self.now.summarize() + self.now = Hourly() + self.now.init(cw, None) + prev = self.now + t = self.now.summarize() + s += t + "\n" + print(t) + + self.hourly = [] + d = data["hourly"] + times = d["time"] + #print(d) + + print("---- ") + for i in range(len(times)): + h = Hourly() + h.init(d, i) + h.time = times[i] + self.hourly.append(h) + if not h.similar(prev): + t = h.summarize() + s += t + "\n" + print(t) + prev = h + + self.daily = [] + d = data["daily"] + times = d["time"] + #print(d) + + print("---- ") + for i in range(len(times)): + h = Daily() + h.init(d, i) + h.time = times[i] + self.daily.append(h) + if i == 0: + prev = h + elif not h.similar(prev): + t = h.summarize() + s += t + "\n" + print(t) + prev = h + + + self.summary = s + + def summarize_future(): + now = utime.time() + + # Rain detection in next 24h + for h in weather.hourly[:24]: + if h["precip"] >= 1.0: + return "Rain soon" + + # Temperature trend + if len(weather.hourly) > 24: + t0 = weather.hourly[0]["temp"] + t24 = weather.hourly[24]["temp"] + if abs(t24 - t0) < 2: + return "No change expected" + if t24 > t0: + return "Getting warmer" + else: + return "Getting cooler" + + return "Stable weather" + weather = Weather() @@ -167,32 +266,38 @@ class Main(Activity): # ---- 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_text(f"{weather.name} ({weather.lat}, {weather.lon})") + label_weather.align(lv.ALIGN.TOP_LEFT, 10, 24) label_weather.set_style_text_font(lv.font_montserrat_14, 0) self.label_weather = label_weather + btn_hourly = lv.button(scr_main) + btn_hourly.align(lv.ALIGN.TOP_RIGHT, -5, 24) + lv.label(btn_hourly).set_text("Reload") + btn_hourly.add_event_cb(lambda x: self.do_load(), lv.EVENT.CLICKED, None) + + label_time = lv.label(scr_main) + label_time.set_text("(time)") + label_time.align_to(btn_hourly, lv.ALIGN.TOP_LEFT, -85, -10) + label_time.set_style_text_font(lv.font_montserrat_24, 0) + self.label_time = label_time + 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.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) + if False: + btn_daily = lv.button(scr_main) + btn_daily.set_size(100, 40) + btn_daily.align(lv.ALIGN.BOTTOM_RIGHT, -10, -10) + lv.label(btn_daily).set_text("Daily") + self.setContentView(self.screen) @@ -223,3 +328,87 @@ class Main(Activity): self.label_summary.set_text("Requesting...") weather.fetch() + # -------------------- + + def code(): + # ----------------------------- + # LVGL UI + # ----------------------------- + + scr_main = lv.obj() + scr_hourly = lv.obj() + scr_daily = lv.obj() + + + # ---- HOURLY SCREEN ---- + + hourly_list = lv.list(scr_hourly) + hourly_list.set_size(320, 200) + hourly_list.align(lv.ALIGN.TOP_MID, 0, 10) + + btn_back1 = lv.button(scr_hourly) + btn_back1.set_size(80, 30) + btn_back1.align(lv.ALIGN.BOTTOM_MID, 0, -5) + lv.label(btn_back1).set_text("Back") + + # ---- DAILY SCREEN ---- + + daily_list = lv.list(scr_daily) + daily_list.set_size(320, 200) + daily_list.align(lv.ALIGN.TOP_MID, 0, 10) + + btn_back2 = lv.button(scr_daily) + btn_back2.set_size(80, 30) + btn_back2.align(lv.ALIGN.BOTTOM_MID, 0, -5) + lv.label(btn_back2).set_text("Back") + + def foo(): + btn_hourly.add_event_cb(go_hourly, lv.EVENT.CLICKED, None) + btn_daily.add_event_cb(go_daily, lv.EVENT.CLICKED, None) + btn_back1.add_event_cb(go_back, lv.EVENT.CLICKED, None) + btn_back2.add_event_cb(go_back, lv.EVENT.CLICKED, None) + + # ----------------------------- + # STARTUP + # ----------------------------- + + def go_hourly(e): + populate_hourly() + lv.scr_load(scr_hourly) + + def go_daily(e): + populate_daily() + lv.scr_load(scr_daily) + + def go_back(e): + lv.scr_load(scr_main) + + def update_ui(): + if weather.current_temp is not None: + text = "%s %.1f C" % ( + weather_code_to_text(weather.current_code), + weather.current_temp + ) + label_weather.set_text(text) + + label_summary.set_text(weather.summary) + + def populate_hourly(): + hourly_list.clean() + for h in weather.hourly[:24]: + line = "%s %.1fC %.1fmm" % ( + h["time"][11:16], + h["temp"], + h["precip"] + ) + hourly_list.add_text(line) + + def populate_daily(): + daily_list.clean() + for d in weather.daily: + line = "%s %.1f/%.1f" % ( + d["date"], + d["high"], + d["low"] + ) + daily_list.add_text(line) From 2f4adfcace0c92b8d6e29e9d640b01847d9bf284 Mon Sep 17 00:00:00 2001 From: Jens Diemer Date: Wed, 11 Mar 2026 12:38:52 +0100 Subject: [PATCH 16/16] Add support for unPhone 9 (#74) https://unphone.net/ What worked: - hx8357d Display and XPT2046 touch screen works - Turn display backlight on/off via TCA9555 chip - Buttons TODOs: - Use LEDs - LoRa - IR `.../lib/drivers/display/hx8357d/` is a not modified copy from https://github.com/lvgl-micropython/lvgl_micropython/tree/main/api_drivers/common_api_drivers/display/hx8357d `.../lib/drivers/indev/xpt2046.py` based on https://github.com/lvgl-micropython/lvgl_micropython/blob/main/api_drivers/common_api_drivers/indev/xpt2046.py but is modified: Because of the shared SPI bus for SPI for hx8357d display and xpt2046 touch controller. For this i add the management of `CS` pins for reading the touch controller. Let's discuss how to add this to upstream in https://github.com/lvgl-micropython/lvgl_micropython/issues/536 --- .../lib/drivers/display/hx8357d/__init__.py | 26 + .../drivers/display/hx8357d/_hx8357d_init.py | 93 +++ .../lib/drivers/display/hx8357d/hx8357d.py | 15 + .../lib/drivers/indev/xpt2046.py | 127 +++++ internal_filesystem/lib/mpos/board/unphone.py | 536 ++++++++++++++++++ internal_filesystem/lib/mpos/main.py | 6 +- scripts/build_mpos.sh | 16 +- 7 files changed, 814 insertions(+), 5 deletions(-) create mode 100644 internal_filesystem/lib/drivers/display/hx8357d/__init__.py create mode 100644 internal_filesystem/lib/drivers/display/hx8357d/_hx8357d_init.py create mode 100644 internal_filesystem/lib/drivers/display/hx8357d/hx8357d.py create mode 100644 internal_filesystem/lib/drivers/indev/xpt2046.py create mode 100644 internal_filesystem/lib/mpos/board/unphone.py diff --git a/internal_filesystem/lib/drivers/display/hx8357d/__init__.py b/internal_filesystem/lib/drivers/display/hx8357d/__init__.py new file mode 100644 index 00000000..481c92ce --- /dev/null +++ b/internal_filesystem/lib/drivers/display/hx8357d/__init__.py @@ -0,0 +1,26 @@ +import sys +from . import hx8357d +from . import _hx8357d_init + +# Register _hx8357d_init in sys.modules so __import__('_hx8357d_init') can find it +# This is needed because display_driver_framework.py uses __import__('_hx8357d_init') +# expecting a top-level module, but _hx8357d_init is in the hx8357d package subdirectory +sys.modules['_hx8357d_init'] = _hx8357d_init + +# Explicitly define __all__ and re-export public symbols from hx8357d module +__all__ = [ + 'HX8357D', + 'STATE_HIGH', + 'STATE_LOW', + 'STATE_PWM', + 'BYTE_ORDER_RGB', + 'BYTE_ORDER_BGR', +] + +# Re-export the public symbols +HX8357D = hx8357d.HX8357D +STATE_HIGH = hx8357d.STATE_HIGH +STATE_LOW = hx8357d.STATE_LOW +STATE_PWM = hx8357d.STATE_PWM +BYTE_ORDER_RGB = hx8357d.BYTE_ORDER_RGB +BYTE_ORDER_BGR = hx8357d.BYTE_ORDER_BGR diff --git a/internal_filesystem/lib/drivers/display/hx8357d/_hx8357d_init.py b/internal_filesystem/lib/drivers/display/hx8357d/_hx8357d_init.py new file mode 100644 index 00000000..8ade855c --- /dev/null +++ b/internal_filesystem/lib/drivers/display/hx8357d/_hx8357d_init.py @@ -0,0 +1,93 @@ +# Copyright (c) 2024 - 2025 Kevin G. Schlosser + +import time +from micropython import const # NOQA + +import lvgl as lv # NOQA +import lcd_bus # NOQA + + +_SWRESET = const(0x01) +_SLPOUT = const(0x11) +_DISPON = const(0x29) +_COLMOD = const(0x3A) +_MADCTL = const(0x36) +_TEON = const(0x35) +_TEARLINE = const(0x44) +_SETOSC = const(0xB0) +_SETPWR1 = const(0xB1) +_SETRGB = const(0xB3) +_SETCOM = const(0xB6) +_SETCYC = const(0xB4) +_SETC = const(0xB9) +_SETSTBA = const(0xC0) +_SETPANEL = const(0xCC) +_SETGAMMA = const(0xE0) + + +def init(self): + param_buf = bytearray(34) + param_mv = memoryview(param_buf) + + time.sleep_ms(300) # NOQA + param_buf[:3] = bytearray([0xFF, 0x83, 0x57]) + self.set_params(_SETC, param_mv[:3]) + + param_buf[0] = 0x80 + self.set_params(_SETRGB, param_mv[:1]) + + param_buf[:4] = bytearray([0x00, 0x06, 0x06, 0x25]) + self.set_params(_SETCOM, param_mv[:4]) + + param_buf[0] = 0x68 + self.set_params(_SETOSC, param_mv[:1]) + + param_buf[0] = 0x05 + self.set_params(_SETPANEL, param_mv[:1]) + + param_buf[:6] = bytearray([0x00, 0x15, 0x1C, 0x1C, 0x83, 0xAA]) + self.set_params(_SETPWR1, param_mv[:6]) + + param_buf[:6] = bytearray([0x50, 0x50, 0x01, 0x3C, 0x1E, 0x08]) + self.set_params(_SETSTBA, param_mv[:6]) + + param_buf[:7] = bytearray([0x02, 0x40, 0x00, 0x2A, 0x2A, 0x0D, 0x78]) + self.set_params(_SETCYC, param_mv[:7]) + + param_buf[:34] = bytearray([ + 0x02, 0x0A, 0x11, 0x1d, 0x23, 0x35, 0x41, 0x4b, 0x4b, 0x42, 0x3A, + 0x27, 0x1B, 0x08, 0x09, 0x03, 0x02, 0x0A, 0x11, 0x1d, 0x23, 0x35, + 0x41, 0x4b, 0x4b, 0x42, 0x3A, 0x27, 0x1B, 0x08, 0x09, 0x03, 0x00, 0x01]) + self.set_params(_SETGAMMA, param_mv[:34]) + + param_buf[0] = ( + self._madctl( + self._color_byte_order, + self._ORIENTATION_TABLE # NOQA + ) + ) + self.set_params(_MADCTL, param_mv[:1]) + + color_size = lv.color_format_get_size(self._color_space) + if color_size == 2: # NOQA + pixel_format = 0x55 + else: + raise RuntimeError( + f'{self.__class__.__name__} IC only supports ' + 'lv.COLOR_FORMAT.RGB565' + ) + + param_buf[0] = pixel_format + self.set_params(_COLMOD, param_mv[:1]) + + param_buf[0] = 0x00 + self.set_params(_TEON, param_mv[:1]) + + param_buf[:2] = bytearray([0x00, 0x02]) + self.set_params(_TEARLINE, param_mv[:2]) + + time.sleep_ms(150) # NOQA + self.set_params(_SLPOUT) + + time.sleep_ms(50) # NOQA + self.set_params(_DISPON) diff --git a/internal_filesystem/lib/drivers/display/hx8357d/hx8357d.py b/internal_filesystem/lib/drivers/display/hx8357d/hx8357d.py new file mode 100644 index 00000000..90b91306 --- /dev/null +++ b/internal_filesystem/lib/drivers/display/hx8357d/hx8357d.py @@ -0,0 +1,15 @@ +# Copyright (c) 2024 - 2025 Kevin G. Schlosser + +import display_driver_framework + + +STATE_HIGH = display_driver_framework.STATE_HIGH +STATE_LOW = display_driver_framework.STATE_LOW +STATE_PWM = display_driver_framework.STATE_PWM + +BYTE_ORDER_RGB = display_driver_framework.BYTE_ORDER_RGB +BYTE_ORDER_BGR = display_driver_framework.BYTE_ORDER_BGR + + +class HX8357D(display_driver_framework.DisplayDriver): + pass diff --git a/internal_filesystem/lib/drivers/indev/xpt2046.py b/internal_filesystem/lib/drivers/indev/xpt2046.py new file mode 100644 index 00000000..dde01c70 --- /dev/null +++ b/internal_filesystem/lib/drivers/indev/xpt2046.py @@ -0,0 +1,127 @@ +# Copyright (c) 2024 - 2025 Kevin G. Schlosser + +import lvgl as lv # NOQA +from micropython import const # NOQA +import micropython # NOQA +import machine # NOQA +import pointer_framework +import time + + +_CMD_X_READ = const(0xD0) # 12 bit resolution +_CMD_Y_READ = const(0x90) # 12 bit resolution +_CMD_Z1_READ = const(0xB0) +_CMD_Z2_READ = const(0xC0) +_MIN_RAW_COORD = const(10) +_MAX_RAW_COORD = const(4090) + + +class XPT2046(pointer_framework.PointerDriver): + touch_threshold = 400 + confidence = 5 + margin = 50 + + def __init__( + self, + device: machine.SPI.Bus, + display_width: int, + display_height: int, + lcd_cs: int, + touch_cs: int, + touch_cal=None, + startup_rotation=lv.DISPLAY_ROTATION._0, + debug=False, + ): + self._device = device # machine.SPI.Bus() instance, shared with display + self._debug = debug + + self.lcd_cs = machine.Pin(lcd_cs, machine.Pin.OUT, value=0) + self.touch_cs = machine.Pin(touch_cs, machine.Pin.OUT, value=1) + + self._width = display_width + self._height = display_height + + self._tx_buf = bytearray(3) + self._tx_mv = memoryview(self._tx_buf) + + self._rx_buf = bytearray(3) + self._rx_mv = memoryview(self._rx_buf) + + self.__confidence = max(min(self.confidence, 25), 3) + self.__points = [[0, 0] for _ in range(self.__confidence)] + + margin = max(min(self.margin, 100), 1) + self.__margin = margin * margin + + super().__init__( + touch_cal=touch_cal, startup_rotation=startup_rotation, debug=debug + ) + + def _read_reg(self, reg, num_bytes): + self._tx_buf[0] = reg + self._device.write_readinto(self._tx_mv[:num_bytes], self._rx_mv[:num_bytes]) + return ((self._rx_buf[1] << 8) | self._rx_buf[2]) >> 3 + + def _get_coords(self): + try: + self.lcd_cs.value(1) # deselect LCD to avoid conflicts + self.touch_cs.value(0) # select touch chip + + z1 = self._read_reg(_CMD_Z1_READ, 3) + z2 = self._read_reg(_CMD_Z2_READ, 3) + z = z1 + ((_MAX_RAW_COORD + 6) - z2) + if z < self.touch_threshold: + return None # Not touched + + points = self.__points + count = 0 + end_time = time.ticks_us() + 5000 + while time.ticks_us() < end_time: + if count == self.__confidence: + break + + raw_x = self._read_reg(_CMD_X_READ, 3) + if raw_x < _MIN_RAW_COORD: + continue + + raw_y = self._read_reg(_CMD_Y_READ, 3) + if raw_y > _MAX_RAW_COORD: + continue + + # put in buff + points[count][0] = raw_x + points[count][1] = raw_y + count += 1 + + finally: + self.touch_cs.value(1) # deselect touch chip + self.lcd_cs.value(0) # select LCD + + if not count: + return None # Not touched + + meanx = sum([points[i][0] for i in range(count)]) // count + meany = sum([points[i][1] for i in range(count)]) // count + dev = ( + sum( + [ + (points[i][0] - meanx) ** 2 + (points[i][1] - meany) ** 2 + for i in range(count) + ] + ) + / count + ) + if dev >= self.__margin: + return None # Not touched + + x = pointer_framework.remap( + meanx, _MIN_RAW_COORD, _MAX_RAW_COORD, 0, self._orig_width + ) + y = pointer_framework.remap( + meany, _MIN_RAW_COORD, _MAX_RAW_COORD, 0, self._orig_height + ) + if self._debug: + print( + f"{self.__class__.__name__}_TP_DATA({count=} {meanx=} {meany=} {z1=} {z2=} {z=})" + ) # NOQA + return self.PRESSED, x, y diff --git a/internal_filesystem/lib/mpos/board/unphone.py b/internal_filesystem/lib/mpos/board/unphone.py new file mode 100644 index 00000000..299afda0 --- /dev/null +++ b/internal_filesystem/lib/mpos/board/unphone.py @@ -0,0 +1,536 @@ +print("unphone.py initialization") +""" +Hardware initialization for the unPhone 9 +https://unphone.net/ + +Based on C++ implementation (unPhone.h, unPhone.cpp) from: +https://gitlab.com/hamishcunningham/unphonelibrary/ + +other references: +https://gitlab.com/hamishcunningham/unphone/-/blob/master/examples/circuitpython/LCD.py +https://www.espboards.dev/esp32/unphone9/ +https://github.com/espressif/arduino-esp32/blob/master/variants/unphone9/pins_arduino.h +https://github.com/meshtastic/device-ui/blob/master/include/graphics/LGFX/LGFX_UNPHONE.h + +Original author: https://github.com/jedie +""" + +import struct +import sys +import time + +import esp32 +import i2c +import lcd_bus +import lvgl as lv +import machine +import mpos.ui +from drivers.display.hx8357d import hx8357d +from drivers.indev.xpt2046 import XPT2046 +from machine import Pin +from micropython import const +from mpos import InputManager + +SDA = const(3) +SCL = const(4) +SCK = const(39) +MOSI = const(40) +MISO = const(41) + +SPI_HOST = const(1) # Shared SPI for hx8357d display and xpt2046 touch controller + +# 27Mhz used in extras/port-lvgl/lib9/TFT_eSPI_files/Setup15_HX8357D.h +SPI_LCD_FREQ = const(27_000_000) +# SPI_LCD_FREQ = const(20_000_000) +# SPI_LCD_FREQ = const(10_000_000) +# SPI_LCD_FREQ = const(1_000_000) + +I2C_BUS = const(0) +I2C_FREQ = const(100_000) # rates > 100k used to trigger an unPhoneTCA bug...? + +LCD_CS = const(48) # Chip select control pin +LCD_DC = const(47) # Data Command control pin +LCD_RESET = const(46) + +# FIXME: Two backlights? One on the TCA9555 expander, one directly controlled by the ESP32? +LCD_BACKLIGHT = const(2) # 0x02 +BACKLIGHT = const(0x42) + +TFT_WIDTH = const(320) +TFT_HEIGHT = const(480) + +TOUCH_I2C_ADDR = const(106) # 0x6a - Touchscreen controller +TOUCH_REGBITS = const(8) +TOUCH_CS = const(38) # Chip select pin (T_CS) of touch screen + +# 2,5Mhz used in extras/port-lvgl/lib9/TFT_eSPI_files/Setup15_HX8357D.h +SPI_TOUCH_FREQ = const(2_500_000) +# SPI_TOUCH_FREQ = const(500_000) +# SPI_TOUCH_FREQ = const(100_000) + +EXPANDER_POWER = const(0x40) +LED_GREEN = const(0x49) +LED_BLUE = const(0x4D) # 13 | 0x40 +LED_RED = const(13) + +# Power management (known variously as PMU, BMU or just BM): +BM_I2C_ADDR = const(107) # 0x6b + +LORA_CS = const(44) +LORA_RESET = const(42) +SD_CS = const(43) +VIBE = const(0x47) +IR_LEDS = const(12) +USB_VSENSE = const(78) # 14 | 0x40 + +POWER_SWITCH = const(18) +BUTTON_LEFT = const(45) +BUTTON_MIDDLE = const(0) +BUTTON_RIGHT = const(21) + + +print("unphone.py turn on red LED") +machine.Pin(LED_RED, machine.Pin.OUT).on() +time.sleep(1) +print("unphone.py init...") + + +class UnPhoneTCA: + """ + unPhone spin 9 - TCA9555 IO expansion chip + """ + + I2C_DEV_ID = const(38) # 0x26 - TI TCA9555's I²C addr + + # Register addresses + REG_INPUT = const(0x00) + REG_OUTPUT = const(0x02) + REG_CONFIG = const(0x06) + + def __init__(self, i2c_bus: i2c.I2C.Bus): + self.tca_dev = i2c.I2C.Device(bus=i2c_bus, dev_id=self.I2C_DEV_ID) + self.directions = 0xFFFF # All inputs by default + self.output_states = 0x0000 # All low by default + + # Set IO expander initially as all inputs + self._write_word(0x06, self.directions) + + # Read current directions and states + self.directions = self._read_word(0x06) + self.output_states = self._read_word(0x02) + + def _write_word(self, reg, value): + print(f"Writing to TCA9555: reg={reg:#02x}, value={value:#04x}") + self.tca_dev.write(bytes([reg, value & 0xFF, (value >> 8) & 0xFF])) + + def _read_word(self, reg): + self.tca_dev.write(bytes([reg])) + data = self.tca_dev.read(2) + return struct.unpack("= self.STORE_SIZE: + # self.current_store_index = 0 + # self.nvs.set_i8("unPhoneStoreIdx", self.current_store_index) + # self.nvs.commit() + + def power_switch_is_on(self): + return bool(self.tca.digital_read(POWER_SWITCH)) + + def usb_power_connected(self): + status = self.i2c.readfrom_mem(self.BM_I2CADD, self.BM_STATUS, 1)[0] + connected = bool((status >> 2) & 1) # Bit 2 indicates USB connection + print(f"USB power connected: {connected}") + return connected + + def _wake_on_power_switch(self): + print("Configuring ESP32 wake on power switch...") + wake_pin = machine.Pin(POWER_SWITCH, machine.Pin.IN) + esp32.wake_on_ext0(pin=wake_pin, level=esp32.WAKEUP_ALL_LOW) + + def set_shipping(self, *, enable): + print(f"Setting shipping mode to: {enable=}") + wdt = self.i2c.readfrom_mem(self.BM_I2CADD, self.BM_WATCHDOG, 1)[0] + opcon = self.i2c.readfrom_mem(self.BM_I2CADD, self.BM_OPCON, 1)[0] + if enable: + print("Asks BM chip to powering down and shutting off USB power") + wdt = wdt & ~(1 << 5) & ~(1 << 4) # Clear bits 5 and 4 + opcon = opcon | (1 << 5) # Set bit 5 + else: + print("Asks BM chip to power up and enable USB power") + wdt = (wdt & ~(1 << 5)) | (1 << 4) # Clear 5, Set 4 + opcon = opcon & ~(1 << 5) # Clear bit 5 + self.i2c.writeto_mem(self.BM_I2CADD, self.BM_WATCHDOG, bytes([wdt])) + self.i2c.writeto_mem(self.BM_I2CADD, self.BM_OPCON, bytes([opcon])) + + def turn_peripherals_off(self): + print("Turning off peripherals...") + self.expander_power(on=False) + self.backlight(on=False) + self.ir(on=False) + self.rgb(0, 0, 0) + + def turn_off(self): + print("turning unPhone off...") + self.turn_peripherals_off() + if not self.usb_power_connected(): + print("switch is off, power is OFF: going to shipping mode") + self.set_shipping(enable=True) + else: + print("switch is off, but power is ON: going to deep sleep") + self._wake_on_power_switch() + machine.deepsleep(60000) # Deep sleep + + def check_power_switch(self): + if not self.power_switch_is_on(): + print("Power switch is OFF, initiating shutdown sequence...") + self.turn_off() + + def reset(self): + print("Resetting unPhone TCA9555 to default state...") + + # Setup pins: + self.tca.pin_mode(EXPANDER_POWER, machine.Pin.OUT) + self.tca.pin_mode(VIBE, machine.Pin.OUT) + self.tca.pin_mode(BUTTON_LEFT, machine.Pin.IN) + self.tca.pin_mode(BUTTON_MIDDLE, machine.Pin.IN) + self.tca.pin_mode(BUTTON_RIGHT, machine.Pin.IN) + self.tca.pin_mode(IR_LEDS, machine.Pin.OUT) + self.tca.pin_mode(LED_RED, machine.Pin.OUT) + self.tca.pin_mode(LED_GREEN, machine.Pin.OUT) + self.tca.pin_mode(LED_BLUE, machine.Pin.OUT) + + # Initialise unPhone hardware to default state: + self.backlight(on=True) + self.expander_power(on=True) + self.vibe(on=False) + self.ir(on=False) + + # Mute devices on the SPI bus by deselecting them: + for pin in [LCD_CS, TOUCH_CS, LORA_CS, SD_CS]: + machine.Pin(pin, machine.Pin.OUT, value=1) + + time.sleep_ms(200) # Short delay to help things settle + + # Turn RGB LED blue to indicate reset is done: + self.rgb(0, 0, 1) + + +def recover_i2c(): + """ + NOTE: only do this in setup **BEFORE** Wire.begin! + from: https://gitlab.com/hamishcunningham/unphonelibrary/-/blob/main/unPhone.cpp#L220 + """ + print("try to recover I2C bus in case it's locked up...") + scl = machine.Pin(SCL, machine.Pin.OUT) + sda = machine.Pin(SDA, machine.Pin.OUT) + sda.value(1) + + for _ in range(10): # 9th cycle acts as NACK + scl.value(1) + time.sleep_us(5) + scl.value(0) + time.sleep_us(5) + + # STOP signal (SDA from low to high while SCL is high) + sda.value(0) + time.sleep_us(5) + scl.value(1) + time.sleep_us(2) + sda.value(1) + time.sleep_us(2) + + # Short delay to help things settle + time.sleep_ms(200) + + +try: + recover_i2c() + print(f"unphone.py init i2c Bus with: scl={SCL}, sda={SDA}...") + i2c_bus = i2c.I2C.Bus( + host=I2C_BUS, scl=SCL, sda=SDA, freq=I2C_FREQ, use_locks=False + ) +except Exception as e: + sys.print_exception(e) + print("Attempting hard reset in 3sec...") + time.sleep(3) + machine.reset() +else: + print("Scanning I2C bus for devices...") + for dev in i2c_bus.scan(): + print(f"Found I2C device at address: {dev} (${dev:#02X})") + # Typical output here is: + # Found I2C device at address: 38 ($0x26) -> TCA9555 IO expansion chip + # Found I2C device at address: 106 ($0x6A) -> Touchscreen controller + # Found I2C device at address: 107 ($0x6B) -> Power management unit (PMU/BMU) + + unphone = UnPhone(i2c=i2c_bus) + + +# Manually set MISO pin to input with pull-up to avoid it floating and causing issues on the SPI bus, +# since it's shared between display and touch controller: +Pin(MISO, Pin.IN, Pin.PULL_UP) + + +print("unphone.py shared SPI bus initialization") +time.sleep_ms(200) # Short delay to help things settle +try: + spi_bus = machine.SPI.Bus(host=SPI_HOST, sck=SCK, mosi=MOSI, miso=MISO) +except Exception as e: + sys.print_exception(e) + print("Attempting hard reset in 3sec...") + time.sleep(3) + machine.reset() + + +print("unphone.py HX8357D() display initialization") +try: + display_bus = lcd_bus.SPIBus( + spi_bus=spi_bus, # Use **the same** SPI bus hx8357d display + freq=SPI_LCD_FREQ, + dc=LCD_DC, + cs=LCD_CS, + ) + mpos.ui.main_display = hx8357d.HX8357D( + data_bus=display_bus, + display_width=TFT_WIDTH, + display_height=TFT_HEIGHT, + color_space=lv.COLOR_FORMAT.RGB565, + color_byte_order=hx8357d.BYTE_ORDER_BGR, + rgb565_byte_swap=True, + reset_pin=LCD_RESET, + reset_state=hx8357d.STATE_LOW, + backlight_pin=LCD_BACKLIGHT, + backlight_on_state=hx8357d.STATE_PWM, + ) +except Exception as e: + sys.print_exception(e) + print("Attempting hard reset in 3sec...") + time.sleep(3) + machine.reset() + + +print("unphone.py display.init()") +mpos.ui.main_display.init() + +print("unphone.py XPT2046() touch controller initialization") +time.sleep_ms(200) # Short delay to help things settle +startup_rotation = lv.DISPLAY_ROTATION._0 +try: + touch_dev = machine.SPI.Device( + spi_bus=spi_bus, # Use **the same** SPI bus for xpt2046 touch + freq=SPI_TOUCH_FREQ, + cs=TOUCH_CS, + ) +except Exception as e: + sys.print_exception(e) + print("Attempting hard reset in 3sec...") + time.sleep(3) + machine.reset() +else: + print(f"unphone.py init touch...") + touch_input_dev = XPT2046( + device=touch_dev, + lcd_cs=LCD_CS, + touch_cs=TOUCH_CS, + display_width=TFT_WIDTH, + display_height=TFT_HEIGHT, + startup_rotation=startup_rotation, + # debug=True, + ) + print(f"{touch_input_dev.is_calibrated=}") + # FIXME: Persistent calibration data is not working yet? + # if touch_input_dev.is_calibrated: + # print('Touch input is already calibrated, skipping calibration step.') + # else: + # print("Starting touch calibration...") + # touch_input_dev.calibrate() + InputManager.register_indev(touch_input_dev) + + +print("unphone.py display.set_rotation() initialization") +mpos.ui.main_display.set_rotation( + startup_rotation +) # must be done after initializing display and creating the touch drivers, to ensure proper handling + + +print("unphone.py button initialization...") +button_left = Pin(BUTTON_LEFT, Pin.IN, Pin.PULL_UP) +button_middle = Pin(BUTTON_MIDDLE, Pin.IN, Pin.PULL_UP) +button_right = Pin(BUTTON_RIGHT, Pin.IN, Pin.PULL_UP) + + +REPEAT_INITIAL_DELAY_MS = 300 # Delay before first repeat +REPEAT_RATE_MS = 100 # Interval between repeats +next_repeat = None # Used for auto-repeat key handling +last_power_switch = None +next_check = time.time() + 1 + + +def input_callback(indev, data): + global next_repeat, last_power_switch, next_check + + current_key = None + + if button_left.value() == 0: + current_key = lv.KEY.ESC + elif button_middle.value() == 0: + current_key = lv.KEY.NEXT + elif button_right.value() == 0: + current_key = lv.KEY.ENTER + + else: + # No buttons pressed + + if data.key: # A key was previously pressed and now released + # print(f"Key {data.key=} released") + data.key = 0 + data.state = lv.INDEV_STATE.RELEASED + next_repeat = None + + if time.time() > next_check: + # Check power switch state and update backlight accordingly + unphone.check_power_switch() + next_check = time.time() + 1 # Check every second + + return + + # A key is currently pressed + + current_time = time.ticks_ms() + repeat = current_time > next_repeat if next_repeat else False # Auto repeat? + if repeat or current_key != data.key: + print(f"Key {current_key} pressed {repeat=}") + + data.key = current_key + data.state = lv.INDEV_STATE.PRESSED + + if current_key == lv.KEY.ESC: # Handle ESC for back navigation + mpos.ui.back_screen() + elif current_key == lv.KEY.RIGHT: + mpos.ui.focus_direction.move_focus_direction(90) + elif current_key == lv.KEY.LEFT: + mpos.ui.focus_direction.move_focus_direction(270) + elif current_key == lv.KEY.UP: + mpos.ui.focus_direction.move_focus_direction(0) + elif current_key == lv.KEY.DOWN: + mpos.ui.focus_direction.move_focus_direction(180) + + if not repeat: + # Initial press: Delay before first repeat + next_repeat = current_time + REPEAT_INITIAL_DELAY_MS + else: + # Faster auto repeat after initial press + next_repeat = current_time + REPEAT_RATE_MS + + +group = lv.group_create() +group.set_default() + +# Create and set up the input device +indev = lv.indev_create() +indev.set_type(lv.INDEV_TYPE.KEYPAD) +indev.set_read_cb(input_callback) +indev.set_group( + group +) # is this needed? maybe better to move the default group creation to main.py so it's available everywhere... +disp = lv.display_get_default() # NOQA +indev.set_display(disp) # different from display +indev.enable(True) # NOQA +InputManager.register_indev(indev) + +unphone.rgb(0, 1, 0) # Green to indicate init is done + +print("\nunphone.py init finished\n") diff --git a/internal_filesystem/lib/mpos/main.py b/internal_filesystem/lib/mpos/main.py index c6e065e7..2fc5e69c 100644 --- a/internal_filesystem/lib/mpos/main.py +++ b/internal_filesystem/lib/mpos/main.py @@ -16,7 +16,7 @@ def init_rootscreen(): # Initialize DisplayMetrics with actual display values DisplayMetrics.set_resolution(width, height) - DisplayMetrics.set_dpi(dpi) + DisplayMetrics.set_dpi(dpi) print(f"init_rootscreen set resolution to {width}x{height} at {dpi} DPI") # Show logo @@ -92,6 +92,10 @@ def detect_board(): import machine unique_id_prefixes = machine.unique_id()[0:3] + print("unPhone ?") + if unique_id_prefixes == b'00\xf9': # '30:30:F9' + return "unphone" + print("(emulated) lilygo_t_display_s3 ?") if unique_id_prefixes == b'\x10\x01\x00' or unique_id_prefixes == b'\xc0\x4e\x30': return "lilygo_t_display_s3" # display gets confused by the i2c stuff below diff --git a/scripts/build_mpos.sh b/scripts/build_mpos.sh index b8c6e158..4f916a76 100755 --- a/scripts/build_mpos.sh +++ b/scripts/build_mpos.sh @@ -14,6 +14,7 @@ if [ -z "$target" ]; then echo "Example: $0 macOS" echo "Example: $0 esp32" echo "Example: $0 esp32s3" + echo "Example: $0 unphone" exit 1 fi @@ -97,12 +98,18 @@ popd echo "Refreshing freezefs..." "$codebasedir"/scripts/freezefs_mount_builtin.sh -if [ "$target" == "esp32" -o "$target" == "esp32s3" ]; then +if [ "$target" == "esp32" -o "$target" == "esp32s3" -o "$target" == "unphone" ]; then + partition_size="4194304" + flash_size="16" extra_configs="" - if [ "$target" == "esp32" ]; then + if [ "$target" == "esp32" ]; then BOARD=ESP32_GENERIC BOARD_VARIANT=SPIRAM - else # esp32s3 + else # esp32s3 or unphone + if [ "$target" == "unphone" ]; then + partition_size="3900000" + flash_size="8" + fi BOARD=ESP32_GENERIC_S3 BOARD_VARIANT=SPIRAM_OCT # These options disable hardware AES, SHA and MPI because they give warnings in QEMU: [AES] Error reading from GDMA buffer @@ -131,7 +138,8 @@ if [ "$target" == "esp32" -o "$target" == "esp32s3" ]; then # CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=y # CONFIG_ADC_MIC_TASK_CORE=1 because with the default (-1) it hangs the CPU # CONFIG_SPIRAM_XIP_FROM_PSRAM: load entire firmware into RAM to reduce SD vs PSRAM contention (recommended at https://github.com/MicroPythonOS/MicroPythonOS/issues/17) - python3 make.py --ota --partition-size=4194304 --flash-size=16 esp32 BOARD=$BOARD BOARD_VARIANT=$BOARD_VARIANT \ +# python3 make.py --ota --partition-size=$partition_size --flash-size=$flash_size esp32 BOARD=$BOARD BOARD_VARIANT=$BOARD_VARIANT \ + python3 make.py --optimize-size --partition-size=$partition_size --flash-size=$flash_size esp32 BOARD=$BOARD BOARD_VARIANT=$BOARD_VARIANT \ USER_C_MODULE="$codebasedir"/micropython-camera-API/src/micropython.cmake \ USER_C_MODULE="$codebasedir"/secp256k1-embedded-ecdh/micropython.cmake \ USER_C_MODULE="$codebasedir"/c_mpos/micropython.cmake \