From b849bd4d9144f75f4eaf4b9a1a398f59c70a6f6f Mon Sep 17 00:00:00 2001
From: ave <flow3r@ave.zone>
Date: Sat, 26 Aug 2023 21:58:51 +0000
Subject: [PATCH] Add w1f1 app & replace wifi in settings menu

---
 python_payload/apps/w1f1/__init__.py | 284 ++++++++++++++
 python_payload/apps/w1f1/flow3r.toml |  13 +
 python_payload/apps/w1f1/k3yboard.py | 549 +++++++++++++++++++++++++++
 python_payload/st3m/settings.py      |  23 +-
 python_payload/st3m/wifi.py          |   1 +
 5 files changed, 853 insertions(+), 17 deletions(-)
 create mode 100644 python_payload/apps/w1f1/__init__.py
 create mode 100644 python_payload/apps/w1f1/flow3r.toml
 create mode 100644 python_payload/apps/w1f1/k3yboard.py

diff --git a/python_payload/apps/w1f1/__init__.py b/python_payload/apps/w1f1/__init__.py
new file mode 100644
index 0000000000..607236843f
--- /dev/null
+++ b/python_payload/apps/w1f1/__init__.py
@@ -0,0 +1,284 @@
+from st3m.application import Application, ApplicationContext
+from st3m.input import InputState
+from st3m.goose import Optional
+from st3m.ui.view import ViewManager
+from ctx import Context
+import network
+import leds
+import os
+import json
+import math
+from .k3yboard import TextInputModel, KeyboardView
+
+
+class WifiApp(Application):
+    WIFI_CONFIG_FILE = "/flash/w1f1_config.json"
+    SETTINGS_JSON_FILE = "/flash/settings.json"
+
+    def __init__(self, app_ctx: ApplicationContext) -> None:
+        super().__init__(app_ctx)
+        self._petal_pressed = {}
+        self._nearby_wlans = []
+        self._status_text = "scanning"
+        self._error_text = ""
+        self._wlan_offset = 0
+        self._is_connecting = False
+        self._waiting_for_password = False
+        self._password_model = TextInputModel("")
+
+        if os.path.exists(self.WIFI_CONFIG_FILE):
+            with open(self.WIFI_CONFIG_FILE) as f:
+                self._wifi_config = json.load(f)
+        else:
+            self._wifi_config = {
+                "config_version": 2,
+                "networks": {
+                    "Example SSID": {"psk": "Example PSK"},
+                    "Camp2023-open": {"psk": None},
+                },
+            }
+            with open(self.WIFI_CONFIG_FILE, "w") as f:
+                json.dump(self._wifi_config, f)
+
+    def on_enter(self, vm: Optional[ViewManager]) -> None:
+        super().on_enter(vm)
+        self._connection_timer = 10
+        self._scan_timer = 0
+        self._iface = network.WLAN(network.STA_IF)
+        self._current_ssid = None
+        self._current_psk = None
+
+        self.input._ignore_pressed()
+        # TODO: big error display
+
+    def draw(self, ctx: Context) -> None:
+        ctx.text_align = ctx.CENTER
+        ctx.text_baseline = ctx.MIDDLE
+        ctx.font = ctx.get_font_name(8)
+
+        ctx.rgb(0, 0, 0).rectangle(-120, -90, 240, 180).fill()
+        ctx.rgb(0.2, 0.2, 0.2).rectangle(-120, -120, 240, 30).fill()
+        ctx.rgb(0.2, 0.2, 0.2).rectangle(-120, 90, 240, 30).fill()
+
+        ctx.font_size = 15
+        current_ssid = self._iface.config("ssid")
+
+        ctx.save()
+        ctx.rgb(1, 1, 1)
+        if self._iface.active():
+            ctx.rgb(0, 1, 0)
+        else:
+            ctx.rgb(1, 0, 0)
+        ctx.move_to(0, -105)
+        ctx.text("^")
+        ctx.move_to(0, -100)
+        ctx.text("toggle wlan")
+        ctx.restore()
+
+        ctx.rgb(1, 1, 1)
+        ctx.move_to(0, 100)
+        ctx.text(self._status_text)
+
+        wlan_draw_offset = self._wlan_offset * -20
+
+        for wlan in self._nearby_wlans:
+            ssid = wlan[0].decode()
+            if (
+                ssid == current_ssid
+                and self._iface.active()
+                and self._iface.isconnected()
+            ):
+                ctx.rgb(0, 1, 0)
+            elif ssid == self._is_connecting:
+                ctx.rgb(0, 0, 1)
+            elif ssid in self._wifi_config["networks"]:
+                ctx.rgb(1, 1, 0)
+            else:
+                ctx.rgb(1, 1, 1)
+            if math.fabs(wlan_draw_offset) > 90:
+                wlan_draw_offset += 20
+                continue
+            if self._nearby_wlans[self._wlan_offset] == wlan:
+                ctx.font_size = 25
+            else:
+                ctx.font_size = 15
+            ctx.move_to(0, wlan_draw_offset)
+            ctx.text(ssid)
+
+            # TODO: maybe add signal indicator?
+            # https://fonts.google.com/icons?selected=Material+Icons+Outlined:network_wifi_1_bar:&icon.query=network+wifi&icon.set=Material+Icons
+
+            # draw a key next to wifi if it isn't open
+            if wlan[4] != 0:
+                ctx.save()
+                ssid_width = ctx.text_width(ssid)
+                ctx.font = "Material Icons"
+                ctx.text_align = ctx.LEFT
+                ctx.move_to((ssid_width / 2) + 5, wlan_draw_offset + 2)
+                ctx.text("\ue897")
+                ctx.restore()
+
+            wlan_draw_offset += 20
+
+    def set_direction_leds(self, direction, r, g, b):
+        if direction == 0:
+            leds.set_rgb(39, r, g, b)
+        else:
+            leds.set_rgb((direction * 4) - 1, r, g, b)
+        leds.set_rgb(direction * 4, r, g, b)
+        leds.set_rgb((direction * 4) + 1, r, g, b)
+
+    def on_exit(self) -> None:
+        leds.set_all_rgb(0, 0, 0)
+        leds.update()
+
+    def scan_wifi(self):
+        # skip hidden WLANs
+        self._nearby_wlans = [
+            wlan for wlan in self._iface.scan() if not wlan[5] and wlan[0]
+        ]
+        # TODO: sort by known, then signal strength
+        print(self._nearby_wlans)
+
+    def update_settings_json(self, ssid: str, psk: str) -> None:
+        # weirdo case
+        if os.path.exists(self.SETTINGS_JSON_FILE):
+            with open(self.SETTINGS_JSON_FILE) as f:
+                settings_json = json.load(f)
+        else:
+            settings_json = {"system": {}}
+
+        if "wifi" not in settings_json["system"]:
+            settings_json["system"]["wifi"] = {
+                "enabled": True,
+                "ssid": "Camp2023-open",
+                "psk": None,
+            }
+        # clean up old config
+        if "camp_wifi_enabled" in settings_json["system"]:
+            del settings_json["system"]["camp_wifi_enabled"]
+
+        settings_json["system"]["wifi"]["ssid"] = ssid
+        settings_json["system"]["wifi"]["psk"] = psk
+
+        with open(self.SETTINGS_JSON_FILE, "w") as f:
+            json.dump(settings_json, f)
+
+    def add_to_config_json(self, ssid: str, psk: str) -> None:
+        self._wifi_config["networks"][ssid] = {"psk": psk}
+
+        with open(self.WIFI_CONFIG_FILE, "w") as f:
+            json.dump(self._wifi_config, f)
+
+    def connect_wifi(self, ssid: str, psk: str = None) -> None:
+        if ssid in self._wifi_config["networks"]:
+            psk = self._wifi_config["networks"][ssid]["psk"]
+
+        self._current_ssid = ssid
+        self._current_psk = psk
+
+        try:
+            self._is_connecting = ssid
+            self._iface.connect(
+                ssid,
+                psk,
+            )
+            self._status_text = "connecting"
+        except OSError as e:
+            self._status_text = str(e)
+            self._is_connecting = False
+
+    def think(self, ins: InputState, delta_ms: int) -> None:
+        super().think(ins, delta_ms)
+        leds.set_all_rgb(0, 0, 0)
+
+        if self.input.buttons.app.left.pressed and self._wlan_offset > 0:
+            self._wlan_offset -= 1
+        elif (
+            self.input.buttons.app.right.pressed
+            and self._wlan_offset < len(self._nearby_wlans) - 1
+        ):
+            self._wlan_offset += 1
+
+        if not self._nearby_wlans and self._iface.active() and self._scan_timer <= 0:
+            self._status_text = "scanning"
+            self.scan_wifi()
+            self._wlan_offset = 0
+            self._status_text = "ready"
+            self._scan_timer = 1
+
+            if not self._nearby_wlans:
+                self._iface.disconnect()
+
+        if not self._nearby_wlans:
+            self._scan_timer -= delta_ms / 1000
+
+        if ins.captouch.petals[0].pressed:
+            if not self._petal_pressed.get(0, False):
+                self._iface.active(not self._iface.active())
+                if not self._iface.active():
+                    self._nearby_wlans = []
+                else:
+                    self._status_text = "scanning"
+            self._petal_pressed[0] = True
+        else:
+            self._petal_pressed[0] = False
+
+        if self._iface.active():
+            self.set_direction_leds(0, 0, 1, 0)
+        else:
+            self.set_direction_leds(0, 1, 0, 0)
+            self._status_text = "wlan off"
+
+        if (
+            self.input.buttons.app.middle.pressed
+            and self._iface.active()
+            and self._nearby_wlans
+        ):
+            hovered_network = self._nearby_wlans[self._wlan_offset]
+            ssid = hovered_network[0].decode()
+            if self._iface.isconnected():
+                self._iface.disconnect()
+            # network[4] = security level, 0 = open
+            if ssid in self._wifi_config["networks"] or hovered_network[4] == 0:
+                self.connect_wifi(ssid)
+            else:
+                self._waiting_for_password = True
+                self.vm.push(KeyboardView(self._password_model))
+
+        if self._waiting_for_password and (
+            not self.vm._history or not isinstance(self.vm._history[-1], WifiApp)
+        ):
+            ssid = self._nearby_wlans[self._wlan_offset][0].decode()
+            psk = self._password_model.text
+            print(ssid, psk)
+            self.connect_wifi(ssid, psk)
+            self._password_model = TextInputModel("")
+            self._waiting_for_password = False
+
+        if self._is_connecting:
+            self._connection_timer -= delta_ms / 1000
+            if self._iface.isconnected():
+                self._connection_timer = 10
+                self._is_connecting = False
+
+                if self._current_ssid:
+                    self.update_settings_json(self._current_ssid, self._current_psk)
+                    if self._current_ssid not in self._wifi_config["networks"]:
+                        self.add_to_config_json(self._current_ssid, self._current_psk)
+            elif self._connection_timer <= 0:
+                self._iface.disconnect()
+                self._status_text = "conn timed out"
+                self._is_connecting = False
+
+        if self._iface.isconnected():
+            self._status_text = "connected"
+
+        leds.update()
+
+
+# For running with `mpremote run`:
+if __name__ == "__main__":
+    import st3m.run
+
+    st3m.run.run_view(WifiApp(ApplicationContext()))
diff --git a/python_payload/apps/w1f1/flow3r.toml b/python_payload/apps/w1f1/flow3r.toml
new file mode 100644
index 0000000000..34342edd64
--- /dev/null
+++ b/python_payload/apps/w1f1/flow3r.toml
@@ -0,0 +1,13 @@
+[app]
+name = "WiFi"
+menu = "Hidden"
+
+[entry]
+class = "WifiApp"
+
+[metadata]
+author = "ave"
+license = "LGPL-3.0-only"
+url = "https://git.flow3r.garden/flow3r/flow3r-firmware"
+description = "Lets you use multiple wireless networks."
+version = 4
diff --git a/python_payload/apps/w1f1/k3yboard.py b/python_payload/apps/w1f1/k3yboard.py
new file mode 100644
index 0000000000..f6ba232247
--- /dev/null
+++ b/python_payload/apps/w1f1/k3yboard.py
@@ -0,0 +1,549 @@
+# code from https://git.flow3r.garden/baldo/k3yboard
+# LGPL-v3-only, by baldo, 2023
+
+from ctx import Context
+
+import st3m.run
+from st3m import Responder, InputState
+from st3m.application import Application, ApplicationContext
+from st3m.goose import ABCBase, Enum
+from st3m.ui.view import BaseView, ViewManager
+from st3m.utils import tau
+
+
+class Model(ABCBase):
+    """
+    Common base-class for models holding state that can be used for rendering views.
+    """
+
+    def __init__(self):
+        pass
+
+
+class TextInputModel(Model):
+    """
+    Model used for rendering the TextInputView. Holds the current input.
+
+    The input is split into the part left and the part right of the cursor. The input candidate is a character that
+    might be added to the input next and is displayed at the position of the cursor. This is used to give viusal
+    feedback while toggling through multiple characters inside a input group (associated with a petal).
+    """
+
+    class CursorDirection(Enum):
+        LEFT = -1
+        RIGHT = 1
+
+    def __init__(
+        self, input_left: str = "", input_right: str = "", input_candidate: str = ""
+    ) -> None:
+        super().__init__()
+
+        self._input_left = input_left
+        self._input_right = input_right
+        self.input_candidate = input_candidate
+
+    @property
+    def text(self) -> str:
+        """
+        The complete input string in its current state.
+        """
+        return self._input_left + self._input_right
+
+    @property
+    def input_left(self) -> str:
+        return self._input_left
+
+    @property
+    def input_right(self) -> str:
+        return self._input_right
+
+    def move_cursor(self, direction: self.CursorDirection) -> None:
+        """
+        Moves the cursor one step in specified direction. Any pending input will be committed beforehand.
+        """
+        self.commit_input()
+
+        if direction == self.CursorDirection.LEFT:
+            self._input_right = self._input_left[-1:] + self._input_right
+            self._input_left = self._input_left[:-1]
+
+        elif direction == self.CursorDirection.RIGHT:
+            self._input_left = self._input_left + self._input_right[0:1]
+            self._input_right = self._input_right[1:]
+
+    def add_input_character(self, char: str) -> None:
+        """
+        Adds an input character at the current cursor position.
+        """
+        self._input_left += char
+        self.input_candidate = ""
+
+    def delete_input_character(self) -> None:
+        """
+        Deletes the character left to the cursor (if any).
+
+        If an input candidate is pending, it will be removed instead.
+        """
+        if self.input_candidate == "":
+            self._input_left = self._input_left[:-1]
+
+        self.input_candidate = ""
+
+    def commit_input(self) -> None:
+        """
+        Adds the pending input candidate (if any) to input left of the cursor.
+        """
+        self.add_input_character(self.input_candidate)
+
+
+class TextInputFieldView(Responder):
+    """
+    Displays the current text input and cursor of a keyboard.
+    """
+
+    def __init__(
+        self, model: TextInputModel, cursor_blink_duration_ms: float = 500
+    ) -> None:
+        super().__init__()
+
+        self._model = model
+
+        self._cursor_blink_duration_ms = cursor_blink_duration_ms
+        self._cursor_timer_ms = 0
+
+    def draw_cursor(self, ctx: Context) -> None:
+        if self._cursor_blink_duration_ms < self._cursor_timer_ms:
+            return
+
+        ctx.begin_path()
+        ctx.rgb(0.0, 0.2, 1.0).rectangle(-1.0, -15.0, 2.0, 28.0).fill()
+        ctx.close_path()
+
+    def draw_text_input(self, ctx: Context) -> None:
+        ctx.begin_path()
+
+        cursor_offset = 1.5
+
+        ctx.text_baseline = ctx.MIDDLE
+        ctx.font_size = 24
+
+        left_input_width = ctx.text_width(self._model.input_left)
+        input_candidate_width = ctx.text_width(self._model.input_candidate)
+        right_input_width = ctx.text_width(self._model.input_right)
+
+        ctx.gray(0.2).rectangle(
+            -1.0 - cursor_offset - input_candidate_width - left_input_width,
+            ctx.font_size / 2.4,
+            left_input_width
+            + input_candidate_width
+            + right_input_width
+            + 2 * cursor_offset
+            + 2 * 1.0,
+            2,
+        ).fill()
+
+        ctx.text_align = ctx.END
+        ctx.gray(1.0).move_to(-cursor_offset - input_candidate_width, 0).text(
+            self._model.input_left
+        )
+
+        ctx.text_align = ctx.END
+        ctx.rgb(0.0, 0.2, 1.0).move_to(-cursor_offset, 0).text(
+            self._model.input_candidate
+        )
+
+        ctx.text_align = ctx.START
+        ctx.gray(1.0).move_to(cursor_offset, 0).text(self._model.input_right)
+
+        ctx.close_path()
+
+    def draw(self, ctx: Context) -> None:
+        ctx.begin_path()
+
+        self.draw_text_input(ctx)
+        self.draw_cursor(ctx)
+
+        ctx.close_path()
+
+    def think(self, ins: InputState, delta_ms: int) -> None:
+        self._cursor_timer_ms = (self._cursor_timer_ms + delta_ms) % (
+            2 * self._cursor_blink_duration_ms
+        )
+
+
+class InputControlsModel(Model):
+    """
+    Model used for rendering the InputControlsView.
+
+    Holds the current active input and control groups (icons displayed in a ring around the edge of the screen).
+
+    Input groups are groups of characters to toggle through to select the character to input.
+
+    Control groups are groups of icons to control the behaviour of the keyboard.
+    """
+
+    def __init__(
+        self,
+        input_groups: list[list[str]],
+        control_groups: list[list[str]],
+        active_input_group: int = 0,
+    ) -> None:
+        super().__init__()
+
+        self._input_groups = input_groups
+        self._control_groups = control_groups
+        self._active_input_control_group = active_input_group
+
+    @property
+    def active_input_control_group(self):
+        return self._active_input_control_group
+
+    def select_input_control_group(self, group: int) -> None:
+        self._active_input_control_group = group
+
+    @property
+    def active_input_groups(self) -> list[str]:
+        return self._input_groups[self._active_input_control_group]
+
+    @property
+    def active_control_groups(self) -> list[str]:
+        return self._control_groups[self._active_input_control_group]
+
+
+class InputControlsView(Responder):
+    """
+    Shows a ring of controls and input characters to choose from around the edge.
+    """
+
+    def __init__(self, model: InputControlsModel) -> None:
+        super().__init__()
+
+        self._model = model
+
+    def draw_input_group(self, ctx: Context, group: int) -> None:
+        inputs = self._model.active_input_groups[group]
+
+        ctx.begin_path()
+
+        bottom_input_group = 2 <= group <= 3
+
+        ctx.text_align = ctx.CENTER
+        ctx.text_baseline = ctx.MIDDLE
+
+        ctx.font_size = 18.0
+        ctx.gray(0.6)
+
+        angle_offset = tau / 5.0 / 10
+        angle = group * tau / 5.0 - angle_offset * (len(inputs) - 1) / 2.0
+
+        for input in reversed(inputs) if bottom_input_group else inputs:
+            ctx.save()
+            ctx.rotate(angle)
+            ctx.translate(0, -109)
+
+            if bottom_input_group:
+                ctx.rotate(tau / 2)
+
+            ctx.move_to(0, 0)
+            ctx.text(input)
+            ctx.restore()
+
+            angle += angle_offset
+
+        ctx.close_path()
+
+    def draw_control_group(self, ctx, group: int) -> None:
+        controls = self._model.active_control_groups[group]
+
+        ctx.begin_path()
+
+        ctx.text_align = ctx.CENTER
+        ctx.text_baseline = ctx.MIDDLE
+
+        ctx.font_size = 18.0
+        ctx.gray(0.6)
+
+        ctx.font = "Material Icons"
+
+        angle_offset = tau / 5.0 / 10
+        angle = (group + 0.5) * tau / 5.0 - angle_offset * (len(controls) - 1) / 2.0
+
+        for control in controls:
+            ctx.save()
+            ctx.rotate(angle)
+            ctx.translate(0, -109)
+
+            ctx.rotate(-angle)
+
+            ctx.move_to(0, 0)
+            ctx.text(control)
+            ctx.restore()
+
+            angle += angle_offset
+
+        ctx.close_path()
+
+    def draw(self, ctx: Context) -> None:
+        ctx.begin_path()
+
+        ctx.line_width = 4.0
+        ctx.gray(0).arc(0, 0, 98, 0, tau, 0).stroke()
+
+        ctx.line_width = 24.0
+        ctx.gray(0.1).arc(0, 0, 110, 0, tau, 0).stroke()
+
+        ctx.line_width = 2.0
+        ctx.gray(0.3).arc(0, 0, 99, 0, tau, 0).stroke()
+
+        for i in range(0, len(self._model.active_input_groups)):
+            self.draw_input_group(ctx, i)
+
+        for i in range(0, len(self._model.active_control_groups)):
+            self.draw_control_group(ctx, i)
+
+        ctx.close_path()
+
+    def think(self, ins: InputState, delta_ms: int) -> None:
+        pass
+
+
+class KeyboardView(BaseView):
+    class ControlPetal(Enum):
+        SPECIAL_CHARACTERS = 1
+        BACKSPACE = 3
+        SPACE = 5
+        CAPSLOCK = 7
+        NUMLOCK = 9
+
+    class InputControlGroup(Enum):
+        LOWERCASE_LETTERS = 0
+        UPPERCASE_LETTERS = 1
+        NUMBERS = 2
+        SPECIAL_CHARACTERS = 3
+
+    def __init__(self, model: TextInputModel) -> None:
+        super().__init__()
+
+        self._last_input_group_press = -1
+        self._last_input_group_character = -1
+        self._time_since_last_input_group_press = 0
+        self._input_group_timeout = 1000
+
+        self._text_input_model = model
+        self._text_input_view = TextInputFieldView(self._text_input_model)
+
+        self._input_controls_model = InputControlsModel(
+            [
+                [
+                    "fghij",
+                    "klmno",
+                    "uvwxyz",
+                    "pqrst",
+                    "abcde",
+                ],
+                [
+                    "FGHIJ",
+                    "KLMNO",
+                    "UVWXYZ",
+                    "PQRST",
+                    "ABCDE",
+                ],
+                [
+                    "34",
+                    "56",
+                    "90",
+                    "78",
+                    "12",
+                ],
+                [
+                    ".,!?:;",
+                    "'\"@#~",
+                    "%$&<>\\",
+                    "+-*/=",
+                    "()[]{}",
+                ],
+            ],
+            [
+                [
+                    "\ue9ef",  # Special characters
+                    "\ue14a",  # Backspace
+                    "\ue256",  # Space
+                    "\ue5ce",  # Shift
+                    "\ue400",  # Num
+                ],
+                [
+                    "\ue9ef",  # Special characters
+                    "\ue14a",  # Backspace
+                    "\ue256",  # Space
+                    "\ue5cf",  # Shift active
+                    "\ue400",  # Num
+                ],
+                [
+                    "\ue9ef",  # Special characters
+                    "\ue14a",  # Backspace
+                    "\ue256",  # Space
+                    "\ue264",  # Text
+                    "",
+                ],
+                [
+                    "",
+                    "\ue14a",  # Backspace
+                    "\ue256",  # Space
+                    "\ue264",  # Text
+                    "\ue400",  # Num
+                ],
+            ],
+        )
+        self._input_controls_view = InputControlsView(self._input_controls_model)
+
+    def on_enter(self, vm: Optional[ViewManager]) -> None:
+        super().on_enter(vm)
+
+    def on_exit(self) -> None:
+        super().on_exit()
+        self.reset_input_state()
+
+    def draw(self, ctx: Context) -> None:
+        ctx.begin_path()
+
+        ctx.gray(0).rectangle(-120, -120, 240, 240).fill()
+
+        self._text_input_view.draw(ctx)
+        self._input_controls_view.draw(ctx)
+
+        ctx.close_path()
+
+    def reset_input_state(self) -> None:
+        self._time_since_last_input_group_press = 0
+        self._last_input_group_press = -1
+        self._last_input_group_character = -1
+        self._text_input_model.input_candidate = ""
+
+    def add_input_character(self, char: str) -> None:
+        self._text_input_model.commit_input()
+        self._text_input_model.add_input_character(char)
+        self.reset_input_state()
+
+    def delete_input_character(self) -> None:
+        self._text_input_model.commit_input()
+        self._text_input_model.delete_input_character()
+        self.reset_input_state()
+
+    def commit_input(self) -> None:
+        self._text_input_model.commit_input()
+        self.reset_input_state()
+
+    def select_input_group(self, input_group) -> None:
+        self.commit_input()
+        self._input_controls_model.select_input_control_group(input_group)
+        self.reset_input_state()
+
+    def handle_shoulder_buttons(self) -> None:
+        if self.input.buttons.app.middle.pressed:
+            self.commit_input()
+            if self.vm:
+                self.vm.pop()
+
+        if self.input.buttons.app.left.pressed:
+            self._text_input_model.move_cursor(TextInputModel.CursorDirection.LEFT)
+
+        if self.input.buttons.app.right.pressed:
+            self._text_input_model.move_cursor(TextInputModel.CursorDirection.RIGHT)
+
+    def handle_control_inputs(self) -> None:
+        if self.input.captouch.petals[
+            self.ControlPetal.SPECIAL_CHARACTERS
+        ].whole.pressed:
+            self.select_input_group(self.InputControlGroup.SPECIAL_CHARACTERS)
+
+        if self.input.captouch.petals[self.ControlPetal.BACKSPACE].whole.pressed:
+            self.delete_input_character()
+
+        if self.input.captouch.petals[self.ControlPetal.SPACE].whole.pressed:
+            self.add_input_character(" ")
+
+        if self.input.captouch.petals[self.ControlPetal.CAPSLOCK].whole.pressed:
+            self.select_input_group(
+                self.InputControlGroup.UPPERCASE_LETTERS
+                if self._input_controls_model.active_input_control_group
+                == self.InputControlGroup.LOWERCASE_LETTERS
+                else self.InputControlGroup.LOWERCASE_LETTERS
+            )
+
+        if self.input.captouch.petals[self.ControlPetal.NUMLOCK].whole.pressed:
+            self.select_input_group(self.InputControlGroup.NUMBERS)
+
+    def handle_input_groups(self, delta_ms: int) -> bool:
+        for i in range(0, 5):
+            if self.input.captouch.petals[i * 2].whole.pressed:
+                if (
+                    self._last_input_group_press >= 0
+                    and self._last_input_group_press != i
+                ):
+                    self.commit_input()
+
+                self._last_input_group_press = i
+                self._last_input_group_character = (
+                    self._last_input_group_character + 1
+                ) % len(self._input_controls_model.active_input_groups[i])
+
+                self._time_since_last_input_group_press = 0
+                self._text_input_model.input_candidate = (
+                    self._input_controls_model.active_input_groups[i][
+                        self._last_input_group_character
+                    ]
+                )
+
+                return
+
+        self._time_since_last_input_group_press += delta_ms
+
+        if (
+            self._last_input_group_press >= 0
+            and self._time_since_last_input_group_press > self._input_group_timeout
+        ):
+            self.commit_input()
+
+    def think(self, ins: InputState, delta_ms: int) -> None:
+        super().think(ins, delta_ms)
+        self._text_input_view.think(ins, delta_ms)
+        self._input_controls_view.think(ins, delta_ms)
+
+        self.handle_shoulder_buttons()
+        self.handle_control_inputs()
+        self.handle_input_groups(delta_ms)
+
+
+class KeyboardDemoApp(Application):
+    def __init__(self, app_ctx: ApplicationContext) -> None:
+        super().__init__(app_ctx)
+
+        self._model = TextInputModel("Hello world!")
+
+    def draw(self, ctx: Context) -> None:
+        ctx.gray(0).rectangle(-120, -120, 240, 240).fill()
+
+        ctx.text_align = ctx.CENTER
+        ctx.text_baseline = ctx.MIDDLE
+
+        ctx.font_size = 24
+        ctx.gray(1).move_to(0, -50).text("Keyboard Demo")
+
+        ctx.font_size = 16
+        ctx.gray(1).move_to(0, -20).text("Press left button to edit")
+
+        ctx.font_size = 24
+        ctx.gray(1).move_to(0, 20).text("Current input:")
+
+        ctx.font_size = 16
+        ctx.gray(1).move_to(0, 50).text(self._model.text)
+
+    def think(self, ins: InputState, delta_ms: int) -> None:
+        super().think(ins, delta_ms)  # Let Application do its thing
+
+        if self.input.buttons.app.middle.pressed:
+            self.vm.push(KeyboardView(self._model))
+
+
+if __name__ == "__main__":
+    st3m.run.run_view(KeyboardDemoApp(ApplicationContext()))
diff --git a/python_payload/st3m/settings.py b/python_payload/st3m/settings.py
index 9b2daf5da3..e140526910 100644
--- a/python_payload/st3m/settings.py
+++ b/python_payload/st3m/settings.py
@@ -24,6 +24,7 @@ from st3m.goose import (
     TYPE_CHECKING,
 )
 from st3m.ui.menu import MenuController, MenuItem, MenuItemBack, MenuItemForeground
+from st3m.application import BundleMetadata, MenuItemAppLaunch
 from st3m.ui.elements.menus import SimpleMenu
 from st3m.ui.view import ViewManager
 from st3m.utils import lerp, ease_out_cubic, reduce
@@ -106,7 +107,7 @@ class TunableWidget(Responder):
 
 class UnaryTunable(Tunable):
     """
-    Basic implementation of a Tunable for single values. Most settings will are
+    Basic implementation of a Tunable for single values. Most settings will be
     UnaryTunables, with notable exceptions being things like lists or optional
     settings.
 
@@ -382,7 +383,7 @@ onoff_button_swap = OnOffTunable("Swap Buttons", "system.swap_buttons", False)
 onoff_debug = OnOffTunable("Debug Overlay", "system.debug", False)
 onoff_debug_touch = OnOffTunable("Touch Overlay", "system.debug_touch", False)
 onoff_show_tray = OnOffTunable("Show Icons", "system.show_icons", True)
-onoff_wifi = OnOffTunable("Enable WiFi", "system.wifi.enabled", False)
+onoff_wifi = OnOffTunable("Enable WiFi on Boot", "system.wifi.enabled", False)
 str_wifi_ssid = StringTunable("WiFi SSID", "system.wifi.ssid", "Camp2023-open")
 str_wifi_psk = ObfuscatedStringTunable("WiFi Password", "system.wifi.psk", None)
 str_hostname = StringTunable("Hostname", "system.hostname", "flow3r")
@@ -403,21 +404,14 @@ if TYPE_CHECKING:
     MenuStructureEntry = Union[UnaryTunable, Tuple[str, List["MenuStructureEntry"]]]
     MenuStructure = List[MenuStructureEntry]
 
-# WiFi submenu
-wifi_settings: "MenuStructure" = [
-    onoff_wifi,
-    str_wifi_ssid,
-    str_wifi_psk,
-    str_hostname,
-]
-
 # Main settings menu
 settings_menu_structure: "MenuStructure" = [
     onoff_show_tray,
     onoff_button_swap,
     onoff_debug,
     onoff_debug_touch,
-    ("WiFi", wifi_settings),
+    onoff_wifi,
+    MenuItemAppLaunch(BundleMetadata("/flash/sys/apps/w1f1")),
 ]
 
 
@@ -474,12 +468,7 @@ def build_menu_recursive(items: "MenuStructure") -> SimpleMenu:
     mib: MenuItem = MenuItemBack()
     positions: List[MenuItem] = [
         mib,
-    ] + [
-        SettingsMenuItem(t)
-        if isinstance(t, UnaryTunable)
-        else MenuItemForeground(t[0], build_menu_recursive(t[1]))
-        for t in items
-    ]
+    ] + [SettingsMenuItem(t) if isinstance(t, UnaryTunable) else t for t in items]
 
     return SettingsMenu(positions)
 
diff --git a/python_payload/st3m/wifi.py b/python_payload/st3m/wifi.py
index abc31a6628..f07ca3690a 100644
--- a/python_payload/st3m/wifi.py
+++ b/python_payload/st3m/wifi.py
@@ -15,6 +15,7 @@ def setup_wifi() -> None:
     assert iface
     try:
         if settings.str_wifi_ssid.value:
+            iface.disconnect()
             iface.connect(settings.str_wifi_ssid.value, settings.str_wifi_psk.value)
     except OSError as e:
         log.error(f"Could not connect to wifi: {e}")
-- 
GitLab