diff --git a/python_payload/apps/__init__.py b/python_payload/apps/__init__.py deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/python_payload/apps/demo_cap_touch/__init__.py b/python_payload/apps/demo_cap_touch/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..27e712415b092c31e48d0302fd63e361514f643f --- /dev/null +++ b/python_payload/apps/demo_cap_touch/__init__.py @@ -0,0 +1,3 @@ +from .main import CapTouchDemo + +App = CapTouchDemo diff --git a/python_payload/apps/demo_cap_touch/flow3r.toml b/python_payload/apps/demo_cap_touch/flow3r.toml new file mode 100644 index 0000000000000000000000000000000000000000..468449d8cf832ef30c23c9027e11b8236c748ab3 --- /dev/null +++ b/python_payload/apps/demo_cap_touch/flow3r.toml @@ -0,0 +1,11 @@ +[app] +name = "Captouch Demo" +menu = "Apps" + +[entry] +class = "App" + +[metadata] +author = "Flow3r Badge Authors" +license = "LGPL-3.0-only" +url = "https://git.flow3r.garden/flow3r/flow3r-firmware" diff --git a/python_payload/apps/cap_touch_demo.py b/python_payload/apps/demo_cap_touch/main.py similarity index 98% rename from python_payload/apps/cap_touch_demo.py rename to python_payload/apps/demo_cap_touch/main.py index 1e7a80c86c490736cadd9b2caae0825f2275a24c..6bd490c5eb7b89a8f4353fc4e4b454b2483e518a 100644 --- a/python_payload/apps/cap_touch_demo.py +++ b/python_payload/apps/demo_cap_touch/main.py @@ -84,6 +84,3 @@ class CapTouchDemo(application.Application): # log.info("Performing captouch autocalibration") # captouch.calibration_request() # self.last_calib = 50 - - -app = CapTouchDemo("cap touch") diff --git a/python_payload/apps/harmonic_demo.py b/python_payload/apps/demo_harmonic/__init__.py similarity index 98% rename from python_payload/apps/harmonic_demo.py rename to python_payload/apps/demo_harmonic/__init__.py index 4f5c3ac541fb90e9ba88820bf888c8d11d06dd5f..573ae3c144778c60e2574189bf93aa6f27dffaf1 100644 --- a/python_payload/apps/harmonic_demo.py +++ b/python_payload/apps/demo_harmonic/__init__.py @@ -90,6 +90,3 @@ class HarmonicApp(Application): self.synths[k].stop() self.synths[k + 5].stop() self.synths[k + 10].stop() - - -app = HarmonicApp("harmonic") diff --git a/python_payload/apps/demo_harmonic/flow3r.toml b/python_payload/apps/demo_harmonic/flow3r.toml new file mode 100644 index 0000000000000000000000000000000000000000..a7ff1ffe73c2c1fa1e1fb752aef56a93bf0fe601 --- /dev/null +++ b/python_payload/apps/demo_harmonic/flow3r.toml @@ -0,0 +1,11 @@ +[app] +name = "Harmonic" +menu = "Music" + +[entry] +class = "HarmonicApp" + +[metadata] +author = "Flow3r Badge Authors" +license = "LGPL-3.0-only" +url = "https://git.flow3r.garden/flow3r/flow3r-firmware" diff --git a/python_payload/apps/melodic_demo.py b/python_payload/apps/demo_melodic/__init__.py similarity index 98% rename from python_payload/apps/melodic_demo.py rename to python_payload/apps/demo_melodic/__init__.py index 2619e8c3a80c49ab6e1097fa7c9c79e651455ba0..26e3fc8435ee00f7ce67d436cbe9873a543fea1f 100644 --- a/python_payload/apps/melodic_demo.py +++ b/python_payload/apps/demo_melodic/__init__.py @@ -96,11 +96,9 @@ class MelodicApp(Application): ctx.fill() def on_enter(self, vm: Optional[ViewManager]) -> None: + super().on_enter(vm) foreground() def think(self, ins: InputState, delta_ms: int) -> None: super().think(ins, delta_ms) run(ins) - - -app = MelodicApp("melodic") diff --git a/python_payload/apps/demo_melodic/flow3r.toml b/python_payload/apps/demo_melodic/flow3r.toml new file mode 100644 index 0000000000000000000000000000000000000000..6b80e9ed0ab28fde1e241071f27a51d123242e34 --- /dev/null +++ b/python_payload/apps/demo_melodic/flow3r.toml @@ -0,0 +1,11 @@ +[app] +name = "Melodic" +menu = "Music" + +[entry] +class = "MelodicApp" + +[metadata] +author = "Flow3r Badge Authors" +license = "LGPL-3.0-only" +url = "https://git.flow3r.garden/flow3r/flow3r-firmware" diff --git a/python_payload/apps/scroll_demo.py b/python_payload/apps/demo_scroll/__init__.py similarity index 100% rename from python_payload/apps/scroll_demo.py rename to python_payload/apps/demo_scroll/__init__.py diff --git a/python_payload/apps/demo_scroll/flow3r.toml b/python_payload/apps/demo_scroll/flow3r.toml new file mode 100644 index 0000000000000000000000000000000000000000..4fd9d7c0707bcdab12a00edbd471abdd7f2c6a84 --- /dev/null +++ b/python_payload/apps/demo_scroll/flow3r.toml @@ -0,0 +1,11 @@ +[app] +name = "Scroll Demo" +menu = "Apps" + +[entry] +class = "ScrollDemo" + +[metadata] +author = "Flow3r Badge Authors" +license = "LGPL-3.0-only" +url = "https://git.flow3r.garden/flow3r/flow3r-firmware" diff --git a/python_payload/apps/demo_worms4.py b/python_payload/apps/demo_worms/__init__.py similarity index 99% rename from python_payload/apps/demo_worms4.py rename to python_payload/apps/demo_worms/__init__.py index 5aa6157684998aefe56ce84d9b5830ab2051e9a3..9a462e817be74a807d2d40cec4528782af33ea69 100644 --- a/python_payload/apps/demo_worms4.py +++ b/python_payload/apps/demo_worms/__init__.py @@ -147,6 +147,3 @@ class Worm: self.direction = -math.atan2(dy, dx) self.mutate() self._lastdist = dist - - -app = AppWorms("worms") diff --git a/python_payload/apps/demo_worms/flow3r.toml b/python_payload/apps/demo_worms/flow3r.toml new file mode 100644 index 0000000000000000000000000000000000000000..f12d18716d9f28b319aae932986a419cb967d93a --- /dev/null +++ b/python_payload/apps/demo_worms/flow3r.toml @@ -0,0 +1,11 @@ +[app] +name = "Worms" +menu = "Apps" + +[entry] +class = "AppWorms" + +[metadata] +author = "Flow3r Badge Authors" +license = "LGPL-3.0-only" +url = "https://git.flow3r.garden/flow3r/flow3r-firmware" diff --git a/python_payload/apps/nick.py b/python_payload/apps/nick/__init__.py similarity index 99% rename from python_payload/apps/nick.py rename to python_payload/apps/nick/__init__.py index 6555410cee447eb483cc3b555c329df8499382a3..fae347cc10444473e886d9f323c7bea134318f37 100644 --- a/python_payload/apps/nick.py +++ b/python_payload/apps/nick/__init__.py @@ -91,6 +91,3 @@ class NickApp(Application): self._led += delta_ms / 45 if self._led >= 40: self._led = 0 - - -app = NickApp("nick") diff --git a/python_payload/apps/nick/flow3r.toml b/python_payload/apps/nick/flow3r.toml new file mode 100644 index 0000000000000000000000000000000000000000..f2ce65ed67512c11fe7effd7fc879d84dab42e34 --- /dev/null +++ b/python_payload/apps/nick/flow3r.toml @@ -0,0 +1,11 @@ +[app] +name = "Nick" +menu = "Badge" + +[entry] +class = "NickApp" + +[metadata] +author = "Flow3r Badge Authors" +license = "LGPL-3.0-only" +url = "https://git.flow3r.garden/flow3r/flow3r-firmware" diff --git a/python_payload/main.py b/python_payload/main.py index 56366dac0df718c23fccfd9743a8ca876c7d7780..4ba77cbffb0d025915727e2d298e821aba6563c0 100644 --- a/python_payload/main.py +++ b/python_payload/main.py @@ -18,6 +18,7 @@ settings.load_all() from st3m.ui.view import View, ViewManager, ViewTransitionBlend from st3m.ui.menu import ( + MenuItem, MenuItemBack, MenuItemForeground, MenuItemNoop, @@ -44,47 +45,21 @@ leds.set_rgb(0, 255, 0, 0) vm = ViewManager(ViewTransitionBlend()) +from st3m.application import discover_bundles -# Preload all applications. -# TODO(q3k): only load these on demand. -from apps.demo_worms4 import app as worms -from apps.harmonic_demo import app as harmonic -from apps.melodic_demo import app as melodic -from apps.nick import app as nick -from apps.cap_touch_demo import app as captouch_demo -from apps.scroll_demo import app as scroll_demo +bundles = discover_bundles("/flash/sys/apps") # Build menu structure menu_settings = settings.build_menu() -menu_music = SimpleMenu( - [ - MenuItemBack(), - MenuItemForeground("Harmonic", harmonic), - MenuItemForeground("Melodic", melodic), - # MenuItemNoop("TinySynth"), - # MenuItemNoop("CrazySynth"), - MenuItemNoop("NOOP Sequencer"), - ], -) - -menu_apps = SimpleMenu( - [ - MenuItemBack(), - MenuItemForeground("captouch", captouch_demo), - MenuItemForeground("worms", worms), - MenuItemForeground("cap scroll", scroll_demo), - ], -) +def _make_bundle_menu(kind: str) -> SimpleMenu: + entries: List[MenuItem] = [MenuItemBack()] + for bundle in bundles: + entries += bundle.menu_entries(kind) + return SimpleMenu(entries) -menu_badge = SimpleMenu( - [ - MenuItemBack(), - MenuItemForeground("nick", nick), - ], -) menu_system = SimpleMenu( [ @@ -98,9 +73,9 @@ menu_system = SimpleMenu( menu_main = SunMenu( [ - MenuItemForeground("Badge", menu_badge), - MenuItemForeground("Music", menu_music), - MenuItemForeground("Apps", menu_apps), + MenuItemForeground("Badge", _make_bundle_menu("Badge")), + MenuItemForeground("Music", _make_bundle_menu("Music")), + MenuItemForeground("Apps", _make_bundle_menu("Apps")), MenuItemForeground("System", menu_system), ], ) diff --git a/python_payload/st3m/application.py b/python_payload/st3m/application.py index 7f71101ae42f5c7209b2cc8ea4ed218e43a3f6c8..a938af858555b4361cd642c7e1dd011364db86ca 100644 --- a/python_payload/st3m/application.py +++ b/python_payload/st3m/application.py @@ -1,6 +1,21 @@ -from st3m.ui.view import ViewWithInputState, ViewTransitionSwipeRight, ViewManager +from st3m.ui.view import ( + ViewWithInputState, + ViewTransitionSwipeRight, + ViewTransitionSwipeLeft, + ViewManager, +) +from st3m.ui.menu import MenuItem from st3m.input import InputState -from st3m.goose import Optional +from st3m.goose import Optional, List, Enum +from st3m.logging import Log + +import toml +import os +import os.path +import stat +import sys + +log = Log(__name__) class Application(ViewWithInputState): @@ -23,3 +38,223 @@ class Application(ViewWithInputState): if self._view_manager is not None: self.on_exit() self._view_manager.pop(ViewTransitionSwipeRight()) + + +class BundleLoadException(BaseException): + MSG = "failed to load" + + def __init__(self, msg: Optional[str] = None) -> None: + res = self.MSG + if msg is not None: + res += ": " + msg + super().__init__(res) + + +class BundleMetadataNotFound(BundleLoadException): + MSG = "flow3r.toml not found" + + +class BundleMetadataCorrupt(BundleLoadException): + MSG = "flow3r.toml corrupt" + + +class BundleMetadataBroken(BundleLoadException): + MSG = "flow3r.toml broken" + + +class BundleMetadata: + """ + Collects data from a flow3r.toml-defined 'bundle', eg. a redistribuable app. + + A flow3r.toml file contains the following sections: + + [app] + # Required, displayed in the menu. + name = "Name of the application" + # One of "Apps", "Badge", "Music". Picks which menu the bundle's class + # will be loadable from. + menu = "Apps" + + [entry] + # Required for app to actually load. Defines the name of the class that + # will be imported from the __init__.py next to flow3r.toml. The class + # must inherit from st3m.application.Application. + class = "DemoApp" + + # Optional, but recommended. Might end up getting displayed somewhere in + # a distribution web page or in system menus. + [metadata] + author = "Hans Acker" + # A SPDX-compatible license identifier. + license = "..." + url = "https://example.com/demoapp" + + This data is used to discover bundles and load them as applications. + """ + + __slots__ = ["path", "name", "menu", "_t"] + + def __init__(self, path: str) -> None: + self.path = path.rstrip("/") + try: + f = open(self.path + "/flow3r.toml") + except Exception: + raise BundleMetadataNotFound() + + try: + t = toml.load(f) + except Exception as e: + raise BundleMetadataCorrupt(str(e)) + + if "app" not in t or type(t["app"]) != dict: + raise BundleMetadataBroken("missing app section") + + app = t["app"] + if "name" not in app or type(app["name"]) != str: + raise BundleMetadataBroken("missing app.name key") + self.name = app["name"] + if "menu" not in app or type(app["menu"]) != str: + raise BundleMetadataBroken("missing app.menu key") + self.menu = app["menu"] + if self.menu not in ["Apps", "Music", "Badge"]: + raise BundleMetadataBroken("app.menu must be either Apps, Music or Badge") + + self._t = t + + @staticmethod + def _sys_path_set(v: List[str]) -> None: + # Can't just assign to sys.path in Micropython. + sys.path.clear() + for el in v: + sys.path.append(el) + + def _load_class(self, class_entry: str) -> Application: + # Micropython doesn't have a good importlib-like API for doing dynamic + # imports of modules at custom paths. That means we have to, for now, + # resort to good ol' sys.path manipulation. + # + # TODO(q3k): extend micropython to make this less messy + old_sys_path = sys.path[:] + + log.info(f"Loading {self.name} via class entry {class_entry}...") + containing_path = os.path.dirname(self.path) + package_name = os.path.basename(self.path) + + if sys.path[0].endswith("python_payload"): + # We are in the simulator. Hack around to get this to work. + prefix = "/flash/sys" + assert containing_path.startswith(prefix) + containing_path = containing_path.replace(prefix, sys.path[0]) + + new_sys_path = old_sys_path + [containing_path] + self._sys_path_set(new_sys_path) + try: + m = __import__(package_name) + self._sys_path_set(old_sys_path) + log.info(f"Loaded {self.name} module: {m}") + klass = getattr(m, class_entry) + log.info(f"Loaded {self.name} class: {klass}") + inst = klass(package_name) + log.info(f"Instantiated {self.name} class: {inst}") + return inst # type: ignore + except Exception as e: + self._sys_path_set(old_sys_path) + raise BundleLoadException(f"load error: {e}") + + def load(self) -> Application: + """ + Return Application loaded form this bundle. + + Raises a BundleMetadataException if something goes wrong. + """ + entry = self._t.get("entry", None) + if entry is None: + raise BundleMetadataBroken("missing entry section") + if "class" in entry and type(entry["class"]) == str: + class_entry = entry["class"] + return self._load_class(class_entry) + + raise BundleMetadataBroken("no valid entry method specified") + + def menu_entries(self, kind: str) -> List["MenuItemAppLaunch"]: + """ + Returns MenuItemAppLauch entries for this bundle for a given menu kind. + + Kind is one of 'Apps', 'Badge', 'Music'. + """ + if self.menu != kind: + return [] + return [MenuItemAppLaunch(self)] + + def __repr__(self) -> str: + return f"<BundleMetadata: {self.name} at {self.path}>" + + +class MenuItemAppLaunch(MenuItem): + """ + A MenuItem which launches an app from a BundleMetadata. + + The underlying app class is imported and instantiated on first use. + """ + + def __init__(self, bundle: BundleMetadata): + self._bundle = bundle + self._instance: Optional[Application] = None + + def press(self, vm: Optional[ViewManager]) -> None: + if vm is None: + log.warning(f"Could not launch {self.label()} as no ViewManager is present") + return + + if self._instance is None: + try: + self._instance = self._bundle.load() + except BundleLoadException as e: + log.error(f"Could not load {self.label()}: {e}") + return + assert self._instance is not None + vm.push(self._instance, ViewTransitionSwipeLeft()) + + def label(self) -> str: + return self._bundle.name + + +def discover_bundles(path: str) -> List[BundleMetadata]: + """ + Discover valid bundles (directories containing flow3r.toml) inside a given + path. + + Only direct descendents will be checks - this function doesn't check + directories recursively. + + Invalid bundles will be skipped and an error will be logged. + """ + path = path.rstrip("/") + try: + l = os.listdir(path) + except Exception as e: + log.warning(f"Could not discover bundles in {path}: {e}") + l = [] + + bundles = [] + for d in l: + dirpath = path + "/" + d + st = os.stat(dirpath) + if not stat.S_ISDIR(st[0]): + continue + + tomlpath = dirpath + "/flow3r.toml" + try: + st = os.stat(tomlpath) + if not stat.S_ISREG(st[0]): + continue + except Exception: + continue + + try: + b = BundleMetadata(dirpath) + except BundleLoadException as e: + log.error(f"Failed to bundle from {dirpath}: {e}") + continue + bundles.append(b) + return bundles