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..5456fb6d --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.cellular/META-INF/MANIFEST.JSON @@ -0,0 +1,24 @@ +{ +"name": "Cellular", +"publisher": "Pavel Machek", +"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": [ + { + "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..a6877e50 --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/main.py @@ -0,0 +1,151 @@ +from mpos import Activity + +""" +Simple cellular-network example +""" + +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/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/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 + + 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() + +# ------------------------------------------------------------ +# User interface +# ------------------------------------------------------------ + +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_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.set_style_text_font(lv.font_montserrat_20, 0) + 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.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, 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. + # 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)) + + self.setContentView(self.screen) + cm.init() + + def onResume(self, screen): + self.timer = lv.timer_create(self.tick, 60000, None) + self.tick(0) + + def onPause(self, screen): + if self.timer: + self.timer.delete() + self.timer = None + + # -------------------- + + 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] + hh, mm, ss = now[3], now[4], now[5] + + self.lbl_month.set_text("busy") + + cm.poll() + s = "" + s += cm.signal["OperatorName"] + "\n" + s += "RegistrationState %d\n" % cm.signal["RegistrationState"] + s += "State %d " % 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)) + + + # -------------------- + + 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..948c19ff --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.cellular/assets/phone.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +from pydbus import SystemBus, Variant +import pydbus +import time +import sys +import json + +""" +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. + +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() + + 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' } + +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) + 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 00000000..662b3c89 Binary files /dev/null and b/internal_filesystem/apps/cz.ucw.pavel.cellular/res/mipmap-mdpi/icon_64x64.png differ 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 00000000..f0ca6f75 Binary files /dev/null and b/internal_filesystem/apps/cz.ucw.pavel.floodit/res/mipmap-mdpi/icon_64x64.png differ 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 00000000..4b54b993 Binary files /dev/null and b/internal_filesystem/apps/cz.ucw.pavel.gyro/res/gyro-help.png differ 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 00000000..fcb60f0c Binary files /dev/null and b/internal_filesystem/apps/cz.ucw.pavel.gyro/res/mipmap-mdpi/icon_64x64.png differ 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 00000000..dba74da3 Binary files /dev/null and b/internal_filesystem/apps/cz.ucw.pavel.navstar/res/mipmap-mdpi/icon_64x64.png differ 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) 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/imu/drivers/iio.py b/internal_filesystem/lib/mpos/imu/drivers/iio.py index 71d71830..d849a2a3 100644 --- a/internal_filesystem/lib/mpos/imu/drivers/iio.py +++ b/internal_filesystem/lib/mpos/imu/drivers/iio.py @@ -13,12 +13,17 @@ class IIODriver(IMUDriverBase): accel_path: str mag_path: str + gyro_path: str def __init__(self): super().__init__() self.accel_path = self.find_iio_device_with_file("in_accel_x_raw") + self.ensure_sampling_frequency_max(self.accel_path) self.mag_path = self.find_iio_device_with_file("in_magn_x_raw") - + self.ensure_sampling_frequency_max(self.mag_path) + self.gyro_path = self.find_iio_device_with_file("in_anglvel_x_raw") + self.ensure_sampling_frequency_max(self.gyro_path) + def _p(self, name: str): return self.accel_path + "/" + name @@ -69,13 +74,100 @@ class IIODriver(IMUDriverBase): return None def _read_text(self, name: str) -> 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) 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 \