From b0cebc71727e34c74a4aa8544a80a108f9a65639 Mon Sep 17 00:00:00 2001 From: Sassan Haradji Date: Thu, 24 Oct 2024 17:18:41 +0400 Subject: [PATCH] feat(core): user can start/end recording actioning by hitting r, actions will be recorded in `recordings/` directory and the last recording can be replayed by hitting `ctrl+r` - closes #187 --- .gitignore | 4 +- CHANGELOG.md | 1 + pyproject.toml | 4 +- setup_scm_schemes.py | 18 +- tests/flows/test_wireless.py | 36 +-- .../store-desktop-000.jsonc | 20 +- .../app_runs_and_exits/store-rpi-000.jsonc | 20 +- .../store-desktop-000.jsonc | 38 +-- .../all_services_register/store-rpi-000.jsonc | 50 +-- ubo_app/display.py | 27 +- ubo_app/menu_app/menu_central.py | 14 + ubo_app/menu_app/menu_notification_handler.py | 8 +- ubo_app/rpc/generate_proto.py | 2 +- ubo_app/rpc/proto/ubo/v1/ubo.proto | 287 +++++++++--------- ubo_app/rpc/service.py | 23 -- ubo_app/services/000-audio/audio_manager.py | 4 +- ubo_app/services/000-audio/reducer.py | 9 + ubo_app/services/000-audio/setup.py | 8 +- ubo_app/services/010-notifications/reducer.py | 12 + ubo_app/services/010-voice/setup.py | 36 +-- ubo_app/services/020-keyboard/setup.py | 111 ++++--- .../pages/create_wireless_connection.py | 12 +- ubo_app/services/030-wifi/pages/main.py | 4 +- ubo_app/services/040-camera/reducer.py | 2 +- ubo_app/services/040-camera/setup.py | 6 +- ubo_app/services/050-lightdm/setup.py | 24 +- .../services/050-rpi-connect/sign_in_page.py | 4 +- ubo_app/services/050-users/setup.py | 6 +- ubo_app/services/050-vscode/login_page.py | 4 +- ubo_app/side_effects.py | 45 ++- ubo_app/store/core/__init__.py | 53 ++++ ubo_app/store/core/_menus.py | 11 +- ubo_app/store/core/reducer.py | 99 +++++- ubo_app/store/dispatch_action.py | 16 +- ubo_app/store/main.py | 43 ++- ubo_app/store/operations.py | 8 - ubo_app/store/services/audio.py | 4 + ubo_app/store/services/notifications.py | 6 + ubo_app/store/update_manager/utils.py | 10 +- ubo_app/utils/store.py | 20 ++ uv.lock | 18 +- 41 files changed, 710 insertions(+), 417 deletions(-) create mode 100644 ubo_app/utils/store.py diff --git a/.gitignore b/.gitignore index 8263c1ea..bb20b834 100644 --- a/.gitignore +++ b/.gitignore @@ -90,5 +90,5 @@ scripts/packer/output-* ubo_app/_version.py /screenshots -/snapshot.json -/snapshot.bin +/snapshots +/recordings diff --git a/CHANGELOG.md b/CHANGELOG.md index 616d9252..54cccef2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - feat(web-ui): add `fields` in `InputDescription` with `InputFieldDescription` data structures to describe the fields of an input demand in detail - fix(users): avoid setting user as sudoer when it performs a password reset - feat(ip): use pythonping to perform a real ping test instead to determine the internet connection status instead of opening a socket +- feat(core): user can start/end recording actioning by hitting r, actions will be recorded in `recordings/` directory and the last recording can be replayed by hitting `ctrl+r` - closes #187 ## Version 1.0.0 diff --git a/pyproject.toml b/pyproject.toml index 1d695f86..1d54e18c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ description = "Ubo main app, running on device initialization. A platform for ru license = { text = "Apache-2.0" } readme = "README.md" requires-python = ">=3.11" +keywords = ['ubo', 'ubo-pod', 'raspberry pi', 'rpi', 'home assistance'] dependencies = [ "psutil >=6.0.0", "ubo-gui >=0.13.3", @@ -22,7 +23,7 @@ dependencies = [ "platformdirs >=4.2.0", "dill >=0.3.8", "simpleaudio >=1.0.4", - "python-redux >=0.17.2", + "python-redux >=0.18.2", "python-debouncer >=0.1.5", "python-strtobool >=1.0.0", "python-fake >=0.1.3", @@ -146,7 +147,6 @@ cmd = "scripts/deploy.sh" [tool.ruff] target-version = 'py311' -extend-exclude = ["setup_scm_schemes.py"] [tool.ruff.lint] select = ["ALL"] diff --git a/setup_scm_schemes.py b/setup_scm_schemes.py index c3f3044d..12d5c75b 100644 --- a/setup_scm_schemes.py +++ b/setup_scm_schemes.py @@ -1,14 +1,14 @@ -from setuptools_scm.version import get_local_node_and_date # pyright: ignore +# ruff: noqa: D100, D103 import re -from datetime import datetime, timezone +from datetime import UTC, datetime +from setuptools_scm.version import ( # pyright: ignore [reportMissingImports] + get_local_node_and_date, +) -def local_scheme(version): - version.node = re.sub( - r'.', - lambda match: str(ord(match.group(0))), - version.node - ) + +def local_scheme(version) -> str: # noqa: ANN001 + version.node = re.sub(r'.', lambda match: str(ord(match.group(0))), version.node) original_local_version = get_local_node_and_date(version) numeric_version = original_local_version.replace('+', '').replace('.d', '') - return datetime.now(timezone.utc).strftime('%y%m%d') + numeric_version + return datetime.now(UTC).strftime('%y%m%d') + numeric_version diff --git a/tests/flows/test_wireless.py b/tests/flows/test_wireless.py index 0a3f4985..210e8f60 100644 --- a/tests/flows/test_wireless.py +++ b/tests/flows/test_wireless.py @@ -53,9 +53,9 @@ async def strength() -> int: from ubo_app.menu_app.menu import MenuApp from ubo_app.store.core import ( - MenuChooseByIconEvent, - MenuChooseByLabelEvent, - MenuGoBackEvent, + MenuChooseByIconAction, + MenuChooseByLabelAction, + MenuGoBackAction, ) from ubo_app.store.main import store @@ -86,32 +86,32 @@ def check_icon(expected_icon: str) -> None: store_snapshot.take(selector=store_snapshot_selector) # Select the main menu - store.dispatch(MenuChooseByIconEvent(icon='󰍜')) + store.dispatch(MenuChooseByIconAction(icon='󰍜')) await stability() # Select the settings menu - store.dispatch(MenuChooseByLabelEvent(label='Settings')) + store.dispatch(MenuChooseByLabelAction(label='Settings')) await stability() # Go to network category - store.dispatch(MenuChooseByLabelEvent(label='Network')) + store.dispatch(MenuChooseByLabelAction(label='Network')) await stability() # Open the wireless menu - store.dispatch(MenuChooseByLabelEvent(label='WiFi')) + store.dispatch(MenuChooseByLabelAction(label='WiFi')) await stability() window_snapshot.take() # Select "Select" to open the wireless connection list - store.dispatch(MenuChooseByLabelEvent(label='Select')) + store.dispatch(MenuChooseByLabelAction(label='Select')) await stability() # Back to the wireless menu - store.dispatch(MenuGoBackEvent()) + store.dispatch(MenuGoBackAction()) await stability() # Select "Add" to add a new connection - store.dispatch(MenuChooseByLabelEvent(label='Add')) + store.dispatch(MenuChooseByLabelAction(label='Add')) await stability() window_snapshot.take() @@ -119,7 +119,7 @@ def check_icon(expected_icon: str) -> None: camera.set_image('qrcode/wifi') # Select "QR code" to scan a QR code for credentials - store.dispatch(MenuChooseByIconEvent(icon='󰄀')) + store.dispatch(MenuChooseByIconAction(icon='󰄀')) # Success notification should be shown window_snapshot.take() @@ -127,11 +127,11 @@ def check_icon(expected_icon: str) -> None: # Dismiss the notification informing the user that the connection was added await check_icon('󰤨') await wait_for_menu_item(label='', icon='󰆴') - store.dispatch(MenuChooseByIconEvent(icon='󰆴')) + store.dispatch(MenuChooseByIconAction(icon='󰆴')) await stability() # Select "Select" to open the wireless connection list and see the new connection - store.dispatch(MenuChooseByLabelEvent(label='Select')) + store.dispatch(MenuChooseByLabelAction(label='Select')) @wait_for(wait=wait_fixed(1), run_async=True) def check_connections() -> None: @@ -146,13 +146,13 @@ def check_connections() -> None: window_snapshot.take() # Select the connection - store.dispatch(MenuChooseByLabelEvent(label='ubo-test-ssid')) + store.dispatch(MenuChooseByLabelAction(label='ubo-test-ssid')) # Wait for the "Disconnect" item to show up await wait_for_menu_item(label='Disconnect') await stability() window_snapshot.take() - store.dispatch(MenuChooseByLabelEvent(label='Disconnect')) + store.dispatch(MenuChooseByLabelAction(label='Disconnect')) # Wait for the "Connect" item to show up await wait_for_menu_item(label='Connect') @@ -160,14 +160,14 @@ def check_connections() -> None: await stability() store_snapshot.take(selector=store_snapshot_selector) window_snapshot.take() - store.dispatch(MenuChooseByLabelEvent(label='Connect')) + store.dispatch(MenuChooseByLabelAction(label='Connect')) await wait_for_menu_item(label='Disconnect') await check_icon('󰤨') await stability() store_snapshot.take(selector=store_snapshot_selector) window_snapshot.take() - store.dispatch(MenuChooseByLabelEvent(label='Delete')) + store.dispatch(MenuChooseByLabelAction(label='Delete')) @wait_for(wait=wait_fixed(1), run_async=True) def check_no_connections() -> None: @@ -182,7 +182,7 @@ def check_no_connections() -> None: # Dismiss the notification informing the user that the connection was deleted await wait_for_menu_item(label='', icon='󰆴') window_snapshot.take() - store.dispatch(MenuChooseByIconEvent(icon='󰆴')) + store.dispatch(MenuChooseByIconAction(icon='󰆴')) await wait_for_empty_menu(placeholder='No Wi-Fi connections found') window_snapshot.take() diff --git a/tests/integration/results/test_core/app_runs_and_exits/store-desktop-000.jsonc b/tests/integration/results/test_core/app_runs_and_exits/store-desktop-000.jsonc index f16ecddf..07310321 100644 --- a/tests/integration/results/test_core/app_runs_and_exits/store-desktop-000.jsonc +++ b/tests/integration/results/test_core/app_runs_and_exits/store-desktop-000.jsonc @@ -6,6 +6,7 @@ "main": { "_type": "MainState", "depth": 1, + "is_recording": false, "menu": { "_type": "HeadlessMenu", "items": [ @@ -211,11 +212,11 @@ "key": null, "label": "Already up to date!", "opacity": null, - "operation": { + "progress": null, + "store_action": { "_type": "UpdateManagerSetStatusAction", "status": "checking" - }, - "progress": null + } } ], "placeholder": null, @@ -278,10 +279,10 @@ "key": null, "label": "Reboot", "opacity": null, - "operation": { + "progress": null, + "store_action": { "_type": "RebootAction" - }, - "progress": null + } }, { "_type": "DispatchItem", @@ -298,10 +299,10 @@ "key": null, "label": "Power off", "opacity": null, - "operation": { + "progress": null, + "store_action": { "_type": "PowerOffAction" - }, - "progress": null + } } ], "placeholder": null, @@ -313,6 +314,7 @@ "title": "󰋜test-hostname.local" }, "path": [], + "recorded_sequence": [], "settings_items_priorities": {} }, "status_icons": { diff --git a/tests/integration/results/test_core/app_runs_and_exits/store-rpi-000.jsonc b/tests/integration/results/test_core/app_runs_and_exits/store-rpi-000.jsonc index d2e7a979..316867f8 100644 --- a/tests/integration/results/test_core/app_runs_and_exits/store-rpi-000.jsonc +++ b/tests/integration/results/test_core/app_runs_and_exits/store-rpi-000.jsonc @@ -6,6 +6,7 @@ "main": { "_type": "MainState", "depth": 1, + "is_recording": false, "menu": { "_type": "HeadlessMenu", "items": [ @@ -211,11 +212,11 @@ "key": null, "label": "Already up to date!", "opacity": null, - "operation": { + "progress": null, + "store_action": { "_type": "UpdateManagerSetStatusAction", "status": "checking" - }, - "progress": null + } } ], "placeholder": null, @@ -278,10 +279,10 @@ "key": null, "label": "Reboot", "opacity": null, - "operation": { + "progress": null, + "store_action": { "_type": "RebootAction" - }, - "progress": null + } }, { "_type": "DispatchItem", @@ -298,10 +299,10 @@ "key": null, "label": "Power off", "opacity": null, - "operation": { + "progress": null, + "store_action": { "_type": "PowerOffAction" - }, - "progress": null + } } ], "placeholder": null, @@ -313,6 +314,7 @@ "title": "󰋜test-hostname.local" }, "path": [], + "recorded_sequence": [], "settings_items_priorities": {} }, "status_icons": { diff --git a/tests/integration/results/test_services/all_services_register/store-desktop-000.jsonc b/tests/integration/results/test_services/all_services_register/store-desktop-000.jsonc index 42161ee0..cf89616f 100644 --- a/tests/integration/results/test_services/all_services_register/store-desktop-000.jsonc +++ b/tests/integration/results/test_services/all_services_register/store-desktop-000.jsonc @@ -108,6 +108,7 @@ "main": { "_type": "MainState", "depth": 1, + "is_recording": false, "menu": { "_type": "HeadlessMenu", "items": [ @@ -556,10 +557,10 @@ "key": null, "label": "Add", "opacity": null, - "operation": { + "progress": null, + "store_action": { "_type": "UsersCreateUserAction" - }, - "progress": null + } }, { "_type": "SubMenuItem", @@ -594,11 +595,11 @@ "key": null, "label": "Reset Password", "opacity": null, - "operation": { + "progress": null, + "store_action": { "_type": "UsersResetPasswordAction", "id": "" - }, - "progress": null + } }, { "_type": "DispatchItem", @@ -615,11 +616,11 @@ "key": null, "label": "Delete", "opacity": null, - "operation": { + "progress": null, + "store_action": { "_type": "UsersDeleteUserAction", "id": "" - }, - "progress": null + } } ], "placeholder": null, @@ -891,11 +892,11 @@ "key": null, "label": "Already up to date!", "opacity": null, - "operation": { + "progress": null, + "store_action": { "_type": "UpdateManagerSetStatusAction", "status": "checking" - }, - "progress": null + } } ], "placeholder": null, @@ -958,10 +959,10 @@ "key": null, "label": "Reboot", "opacity": null, - "operation": { + "progress": null, + "store_action": { "_type": "RebootAction" - }, - "progress": null + } }, { "_type": "DispatchItem", @@ -978,10 +979,10 @@ "key": null, "label": "Power off", "opacity": null, - "operation": { + "progress": null, + "store_action": { "_type": "PowerOffAction" - }, - "progress": null + } } ], "placeholder": null, @@ -993,6 +994,7 @@ "title": "󰋜test-hostname.local" }, "path": [], + "recorded_sequence": [], "settings_items_priorities": { "Desktop": 0, "IP Addresses": 0, diff --git a/tests/integration/results/test_services/all_services_register/store-rpi-000.jsonc b/tests/integration/results/test_services/all_services_register/store-rpi-000.jsonc index cd3e98f4..3403262c 100644 --- a/tests/integration/results/test_services/all_services_register/store-rpi-000.jsonc +++ b/tests/integration/results/test_services/all_services_register/store-rpi-000.jsonc @@ -108,6 +108,7 @@ "main": { "_type": "MainState", "depth": 1, + "is_recording": false, "menu": { "_type": "HeadlessMenu", "items": [ @@ -556,10 +557,10 @@ "key": null, "label": "Add", "opacity": null, - "operation": { + "progress": null, + "store_action": { "_type": "UsersCreateUserAction" - }, - "progress": null + } }, { "_type": "SubMenuItem", @@ -594,11 +595,11 @@ "key": null, "label": "Reset Password", "opacity": null, - "operation": { + "progress": null, + "store_action": { "_type": "UsersResetPasswordAction", "id": "ubo" - }, - "progress": null + } }, { "_type": "DispatchItem", @@ -615,11 +616,11 @@ "key": null, "label": "Delete", "opacity": null, - "operation": { + "progress": null, + "store_action": { "_type": "UsersDeleteUserAction", "id": "ubo" - }, - "progress": null + } } ], "placeholder": null, @@ -659,11 +660,11 @@ "key": null, "label": "Reset Password", "opacity": null, - "operation": { + "progress": null, + "store_action": { "_type": "UsersResetPasswordAction", "id": "pi" - }, - "progress": null + } }, { "_type": "DispatchItem", @@ -680,11 +681,11 @@ "key": null, "label": "Delete", "opacity": null, - "operation": { + "progress": null, + "store_action": { "_type": "UsersDeleteUserAction", "id": "pi" - }, - "progress": null + } } ], "placeholder": null, @@ -956,11 +957,11 @@ "key": null, "label": "Already up to date!", "opacity": null, - "operation": { + "progress": null, + "store_action": { "_type": "UpdateManagerSetStatusAction", "status": "checking" - }, - "progress": null + } } ], "placeholder": null, @@ -1023,10 +1024,10 @@ "key": null, "label": "Reboot", "opacity": null, - "operation": { + "progress": null, + "store_action": { "_type": "RebootAction" - }, - "progress": null + } }, { "_type": "DispatchItem", @@ -1043,10 +1044,10 @@ "key": null, "label": "Power off", "opacity": null, - "operation": { + "progress": null, + "store_action": { "_type": "PowerOffAction" - }, - "progress": null + } } ], "placeholder": null, @@ -1058,6 +1059,7 @@ "title": "󰋜test-hostname.local" }, "path": [], + "recorded_sequence": [], "settings_items_priorities": { "Desktop": 0, "IP Addresses": 0, diff --git a/ubo_app/display.py b/ubo_app/display.py index ccf16423..f66328ef 100644 --- a/ubo_app/display.py +++ b/ubo_app/display.py @@ -67,20 +67,21 @@ def render_on_display( if last_render_thread: last_render_thread.join() render_block(rectangle, data_bytes) - store.dispatch( - DisplayRenderEvent( - data=data.tobytes(), - data_hash=data_hash, - rectangle=rectangle, - ), - ) compressor = zlib.compressobj(wbits=-zlib.MAX_WBITS) - store.dispatch( - DisplayCompressedRenderEvent( - compressed_data=compressor.compress(data.tobytes()) + compressor.flush(), - data_hash=data_hash, - rectangle=rectangle, - ), + store._dispatch( # noqa: SLF001 + [ + DisplayRenderEvent( + data=data.tobytes(), + data_hash=data_hash, + rectangle=rectangle, + ), + DisplayCompressedRenderEvent( + compressed_data=compressor.compress(data.tobytes()) + + compressor.flush(), + data_hash=data_hash, + rectangle=rectangle, + ), + ], ) diff --git a/ubo_app/menu_app/menu_central.py b/ubo_app/menu_app/menu_central.py index c0a87ffc..50eb12fe 100644 --- a/ubo_app/menu_app/menu_central.py +++ b/ubo_app/menu_app/menu_central.py @@ -21,6 +21,8 @@ MenuChooseByLabelEvent, MenuGoBackEvent, MenuGoHomeEvent, + MenuScrollDirection, + MenuScrollEvent, OpenApplicationEvent, SetMenuPathAction, ) @@ -171,6 +173,11 @@ def central(self: MenuAppCentral) -> Widget | None: self.select_by_index, keep_ref=False, ) + store.subscribe_event( + MenuScrollEvent, + self.scroll, + keep_ref=False, + ) return self.menu_widget @@ -237,3 +244,10 @@ def select_by_index( event: MenuChooseByIndexEvent, ) -> None: self.menu_widget.select(event.index) + + @mainthread + def scroll(self: MenuAppCentral, event: MenuScrollEvent) -> None: + if event.direction == MenuScrollDirection.UP: + self.menu_widget.go_up() + elif event.direction == MenuScrollDirection.DOWN: + self.menu_widget.go_down() diff --git a/ubo_app/menu_app/menu_notification_handler.py b/ubo_app/menu_app/menu_notification_handler.py index 5652c07d..31788e1a 100644 --- a/ubo_app/menu_app/menu_notification_handler.py +++ b/ubo_app/menu_app/menu_notification_handler.py @@ -14,7 +14,7 @@ from ubo_gui.page import PAGE_MAX_ITEMS from ubo_app.menu_app.notification_info import NotificationInfo -from ubo_app.store.core import CloseApplicationEvent, OpenApplicationEvent +from ubo_app.store.core import CloseApplicationAction, OpenApplicationAction from ubo_app.store.main import store from ubo_app.store.services.notifications import ( Notification, @@ -74,7 +74,7 @@ def close(_: object = None) -> None: for unsubscribe in subscriptions: unsubscribe() notification_application.unbind(on_close=close) - store.dispatch(CloseApplicationEvent(application=notification_application)) + store.dispatch(CloseApplicationAction(application=notification_application)) if notification.value.dismiss_on_close: store.dispatch( NotificationsClearAction(notification=notification.value), @@ -145,7 +145,7 @@ def renew_notification(event: NotificationsDisplayEvent) -> None: renew_notification(event) - store.dispatch(OpenApplicationEvent(application=notification_application)) + store.dispatch(OpenApplicationAction(application=notification_application)) def _notification_items( self: MenuNotificationHandler, @@ -175,7 +175,7 @@ def run_notification_action(action: NotificationActionItem) -> None: def open_info() -> None: info_application = NotificationInfo(text=text) - store.dispatch(OpenApplicationEvent(application=info_application)) + store.dispatch(OpenApplicationAction(application=info_application)) items.append( NotificationActionItem( diff --git a/ubo_app/rpc/generate_proto.py b/ubo_app/rpc/generate_proto.py index 6da45382..e731cb31 100644 --- a/ubo_app/rpc/generate_proto.py +++ b/ubo_app/rpc/generate_proto.py @@ -478,7 +478,7 @@ def get_field_type( # noqa: C901, PLR0912 value_type = self.get_field_type(value=value.slice.elts[1]) return _DictType(key_type=key_type, value_type=value_type) - if value.value.id in ('Sequence', 'list'): + if value.value.id in ('Sequence', 'list', 'set'): return _ListType(type=self.get_field_type(value=value.slice)) if value.value.id == 'tuple' and isinstance(value.slice, ast.Tuple): diff --git a/ubo_app/rpc/proto/ubo/v1/ubo.proto b/ubo_app/rpc/proto/ubo/v1/ubo.proto index 4c7e34a6..70498909 100644 --- a/ubo_app/rpc/proto/ubo/v1/ubo.proto +++ b/ubo_app/rpc/proto/ubo/v1/ubo.proto @@ -95,24 +95,51 @@ message Menu { HeadlessMenu headless_menu = 2; } } -message ScreenshotEvent { +enum InputFieldType { + INPUT_FIELD_TYPE_UBO_APP_DOT_STORE_DOT_OPERATIONS_UNSPECIFIED = 0; + INPUT_FIELD_TYPE_LONG = 1; + INPUT_FIELD_TYPE_TEXT = 2; + INPUT_FIELD_TYPE_PASSWORD = 3; + INPUT_FIELD_TYPE_NUMBER = 4; + INPUT_FIELD_TYPE_CHECKBOX = 5; + INPUT_FIELD_TYPE_COLOR = 6; + INPUT_FIELD_TYPE_SELECT = 7; + INPUT_FIELD_TYPE_FILE = 8; + INPUT_FIELD_TYPE_DATE = 9; + INPUT_FIELD_TYPE_TIME = 10; +} + +message InputFieldDescription { option (package_info.v1.package_name) = "ubo_app.store.operations"; optional string meta_field_package_name_ubo_app_dot_store_dot_operations = 1000; -} -message SnapshotEvent { - option (package_info.v1.package_name) = "ubo_app.store.operations"; - optional string meta_field_package_name_ubo_app_dot_store_dot_operations = 1000; + message Options { + repeated string items = 1; + } + string name = 2; + string label = 3; + InputFieldType type = 4; + optional string description = 5; + optional string title = 6; + optional string pattern = 7; + optional string default = 8; + optional Options options = 9; + optional bool required = 10; } message InputDescription { option (package_info.v1.package_name) = "ubo_app.store.operations"; optional string meta_field_package_name_ubo_app_dot_store_dot_operations = 1000; + + message Fields { + repeated InputFieldDescription items = 1; + } string title = 2; string prompt = 3; optional NotificationExtraInformation extra_information = 4; string id = 5; string pattern = 6; + optional Fields fields = 7; } message InputAction { @@ -243,6 +270,12 @@ message AudioPlayAudioAction { int64 width = 6; } +message AudioPlaybackDoneAction { + option (package_info.v1.package_name) = "ubo_app.store.services.audio"; + optional string meta_field_package_name_ubo_app_dot_store_dot_services_dot_audio = 1000; + string id = 2; +} + message AudioEvent { option (package_info.v1.package_name) = "ubo_app.store.services.audio"; optional string meta_field_package_name_ubo_app_dot_store_dot_services_dot_audio = 1000; @@ -541,56 +574,40 @@ message KeypadAction { option (package_info.v1.package_name) = "ubo_app.store.services.keypad"; optional string meta_field_package_name_ubo_app_dot_store_dot_services_dot_keypad = 1000; Key key = 2; - optional float time = 3; + repeated Key pressed_keys = 3; + optional float time = 4; } message KeypadKeyUpAction { option (package_info.v1.package_name) = "ubo_app.store.services.keypad"; optional string meta_field_package_name_ubo_app_dot_store_dot_services_dot_keypad = 1000; Key key = 2; - optional float time = 3; + repeated Key pressed_keys = 3; + optional float time = 4; } message KeypadKeyDownAction { option (package_info.v1.package_name) = "ubo_app.store.services.keypad"; optional string meta_field_package_name_ubo_app_dot_store_dot_services_dot_keypad = 1000; Key key = 2; - optional float time = 3; + repeated Key pressed_keys = 3; + optional float time = 4; } message KeypadKeyPressAction { option (package_info.v1.package_name) = "ubo_app.store.services.keypad"; optional string meta_field_package_name_ubo_app_dot_store_dot_services_dot_keypad = 1000; Key key = 2; - optional float time = 3; + repeated Key pressed_keys = 3; + optional float time = 4; } message KeypadKeyReleaseAction { option (package_info.v1.package_name) = "ubo_app.store.services.keypad"; optional string meta_field_package_name_ubo_app_dot_store_dot_services_dot_keypad = 1000; Key key = 2; - optional float time = 3; -} - -message KeypadEvent { - option (package_info.v1.package_name) = "ubo_app.store.services.keypad"; - optional string meta_field_package_name_ubo_app_dot_store_dot_services_dot_keypad = 1000; - Key key = 2; - float time = 3; -} - -message KeypadKeyPressEvent { - option (package_info.v1.package_name) = "ubo_app.store.services.keypad"; - optional string meta_field_package_name_ubo_app_dot_store_dot_services_dot_keypad = 1000; - Key key = 2; - float time = 3; -} - -message KeypadKeyReleaseEvent { - option (package_info.v1.package_name) = "ubo_app.store.services.keypad"; - optional string meta_field_package_name_ubo_app_dot_store_dot_services_dot_keypad = 1000; - Key key = 2; - float time = 3; + repeated Key pressed_keys = 3; + optional float time = 4; } message LightDMAction { @@ -1320,89 +1337,90 @@ message Action { AudioChangeVolumeAction audio_change_volume_action = 2; AudioPlayAudioAction audio_play_audio_action = 3; AudioPlayChimeAction audio_play_chime_action = 4; - AudioSetMuteStatusAction audio_set_mute_status_action = 5; - AudioSetVolumeAction audio_set_volume_action = 6; - AudioToggleMuteStatusAction audio_toggle_mute_status_action = 7; - CameraAction camera_action = 8; - CameraReportBarcodeAction camera_report_barcode_action = 9; - CameraStartViewfinderAction camera_start_viewfinder_action = 10; - DisplayAction display_action = 11; - DisplayPauseAction display_pause_action = 12; - DisplayResumeAction display_resume_action = 13; - DockerAction docker_action = 14; - DockerImageAction docker_image_action = 15; - DockerImageSetDockerIdAction docker_image_set_docker_id_action = 16; - DockerImageSetStatusAction docker_image_set_status_action = 17; - DockerRemoveUsernameAction docker_remove_username_action = 18; - DockerSetStatusAction docker_set_status_action = 19; - DockerStoreUsernameAction docker_store_username_action = 20; - InputAction input_action = 21; - InputCancelAction input_cancel_action = 22; - InputDemandAction input_demand_action = 23; - InputProvideAction input_provide_action = 24; - InputResolveAction input_resolve_action = 25; - IpAction ip_action = 26; - IpSetIsConnectedAction ip_set_is_connected_action = 27; - IpUpdateInterfacesAction ip_update_interfaces_action = 28; - KeypadAction keypad_action = 29; - KeypadKeyDownAction keypad_key_down_action = 30; - KeypadKeyPressAction keypad_key_press_action = 31; - KeypadKeyReleaseAction keypad_key_release_action = 32; - KeypadKeyUpAction keypad_key_up_action = 33; - LightDMAction light_dm_action = 34; - LightDMClearEnabledStateAction light_dm_clear_enabled_state_action = 35; - LightDMUpdateStateAction light_dm_update_state_action = 36; - NotificationsAction notifications_action = 37; - NotificationsAddAction notifications_add_action = 38; - NotificationsClearAction notifications_clear_action = 39; - NotificationsClearAllAction notifications_clear_all_action = 40; - NotificationsClearByIdAction notifications_clear_by_id_action = 41; - RPiConnectAction r_pi_connect_action = 42; - RPiConnectDoneDownloadingAction r_pi_connect_done_downloading_action = 43; - RPiConnectSetPendingAction r_pi_connect_set_pending_action = 44; - RPiConnectSetStatusAction r_pi_connect_set_status_action = 45; - RPiConnectStartDownloadingAction r_pi_connect_start_downloading_action = 46; - RPiConnectUpdateServiceStateAction r_pi_connect_update_service_state_action = 47; - RgbRingAction rgb_ring_action = 48; - RgbRingBlankAction rgb_ring_blank_action = 49; - RgbRingBlinkAction rgb_ring_blink_action = 50; - RgbRingColorfulCommandAction rgb_ring_colorful_command_action = 51; - RgbRingCommandAction rgb_ring_command_action = 52; - RgbRingFillDownfromAction rgb_ring_fill_downfrom_action = 53; - RgbRingFillUptoAction rgb_ring_fill_upto_action = 54; - RgbRingProgressWheelAction rgb_ring_progress_wheel_action = 55; - RgbRingProgressWheelStepAction rgb_ring_progress_wheel_step_action = 56; - RgbRingPulseAction rgb_ring_pulse_action = 57; - RgbRingRainbowAction rgb_ring_rainbow_action = 58; - RgbRingSetAllAction rgb_ring_set_all_action = 59; - RgbRingSetBrightnessAction rgb_ring_set_brightness_action = 60; - RgbRingSetEnabledAction rgb_ring_set_enabled_action = 61; - RgbRingSetIsBusyAction rgb_ring_set_is_busy_action = 62; - RgbRingSetIsConnectedAction rgb_ring_set_is_connected_action = 63; - RgbRingSpinningWheelAction rgb_ring_spinning_wheel_action = 64; - RgbRingWaitableCommandAction rgb_ring_waitable_command_action = 65; - SSHAction ssh_action = 66; - SSHClearEnabledStateAction ssh_clear_enabled_state_action = 67; - SSHUpdateStateAction ssh_update_state_action = 68; - SensorsAction sensors_action = 69; - SensorsReportReadingAction sensors_report_reading_action = 70; - UsersAction users_action = 71; - UsersCreateUserAction users_create_user_action = 72; - UsersDeleteUserAction users_delete_user_action = 73; - UsersResetPasswordAction users_reset_password_action = 74; - UsersSetUsersAction users_set_users_action = 75; - VSCodeAction vs_code_action = 76; - VSCodeDoneDownloadingAction vs_code_done_downloading_action = 77; - VSCodeSetPendingAction vs_code_set_pending_action = 78; - VSCodeSetStatusAction vs_code_set_status_action = 79; - VSCodeStartDownloadingAction vs_code_start_downloading_action = 80; - VoiceAction voice_action = 81; - VoiceReadTextAction voice_read_text_action = 82; - VoiceSetEngineAction voice_set_engine_action = 83; - WiFiAction wi_fi_action = 84; - WiFiSetHasVisitedOnboardingAction wi_fi_set_has_visited_onboarding_action = 85; - WiFiUpdateAction wi_fi_update_action = 86; - WiFiUpdateRequestAction wi_fi_update_request_action = 87; + AudioPlaybackDoneAction audio_playback_done_action = 5; + AudioSetMuteStatusAction audio_set_mute_status_action = 6; + AudioSetVolumeAction audio_set_volume_action = 7; + AudioToggleMuteStatusAction audio_toggle_mute_status_action = 8; + CameraAction camera_action = 9; + CameraReportBarcodeAction camera_report_barcode_action = 10; + CameraStartViewfinderAction camera_start_viewfinder_action = 11; + DisplayAction display_action = 12; + DisplayPauseAction display_pause_action = 13; + DisplayResumeAction display_resume_action = 14; + DockerAction docker_action = 15; + DockerImageAction docker_image_action = 16; + DockerImageSetDockerIdAction docker_image_set_docker_id_action = 17; + DockerImageSetStatusAction docker_image_set_status_action = 18; + DockerRemoveUsernameAction docker_remove_username_action = 19; + DockerSetStatusAction docker_set_status_action = 20; + DockerStoreUsernameAction docker_store_username_action = 21; + InputAction input_action = 22; + InputCancelAction input_cancel_action = 23; + InputDemandAction input_demand_action = 24; + InputProvideAction input_provide_action = 25; + InputResolveAction input_resolve_action = 26; + IpAction ip_action = 27; + IpSetIsConnectedAction ip_set_is_connected_action = 28; + IpUpdateInterfacesAction ip_update_interfaces_action = 29; + KeypadAction keypad_action = 30; + KeypadKeyDownAction keypad_key_down_action = 31; + KeypadKeyPressAction keypad_key_press_action = 32; + KeypadKeyReleaseAction keypad_key_release_action = 33; + KeypadKeyUpAction keypad_key_up_action = 34; + LightDMAction light_dm_action = 35; + LightDMClearEnabledStateAction light_dm_clear_enabled_state_action = 36; + LightDMUpdateStateAction light_dm_update_state_action = 37; + NotificationsAction notifications_action = 38; + NotificationsAddAction notifications_add_action = 39; + NotificationsClearAction notifications_clear_action = 40; + NotificationsClearAllAction notifications_clear_all_action = 41; + NotificationsClearByIdAction notifications_clear_by_id_action = 42; + RPiConnectAction r_pi_connect_action = 43; + RPiConnectDoneDownloadingAction r_pi_connect_done_downloading_action = 44; + RPiConnectSetPendingAction r_pi_connect_set_pending_action = 45; + RPiConnectSetStatusAction r_pi_connect_set_status_action = 46; + RPiConnectStartDownloadingAction r_pi_connect_start_downloading_action = 47; + RPiConnectUpdateServiceStateAction r_pi_connect_update_service_state_action = 48; + RgbRingAction rgb_ring_action = 49; + RgbRingBlankAction rgb_ring_blank_action = 50; + RgbRingBlinkAction rgb_ring_blink_action = 51; + RgbRingColorfulCommandAction rgb_ring_colorful_command_action = 52; + RgbRingCommandAction rgb_ring_command_action = 53; + RgbRingFillDownfromAction rgb_ring_fill_downfrom_action = 54; + RgbRingFillUptoAction rgb_ring_fill_upto_action = 55; + RgbRingProgressWheelAction rgb_ring_progress_wheel_action = 56; + RgbRingProgressWheelStepAction rgb_ring_progress_wheel_step_action = 57; + RgbRingPulseAction rgb_ring_pulse_action = 58; + RgbRingRainbowAction rgb_ring_rainbow_action = 59; + RgbRingSetAllAction rgb_ring_set_all_action = 60; + RgbRingSetBrightnessAction rgb_ring_set_brightness_action = 61; + RgbRingSetEnabledAction rgb_ring_set_enabled_action = 62; + RgbRingSetIsBusyAction rgb_ring_set_is_busy_action = 63; + RgbRingSetIsConnectedAction rgb_ring_set_is_connected_action = 64; + RgbRingSpinningWheelAction rgb_ring_spinning_wheel_action = 65; + RgbRingWaitableCommandAction rgb_ring_waitable_command_action = 66; + SSHAction ssh_action = 67; + SSHClearEnabledStateAction ssh_clear_enabled_state_action = 68; + SSHUpdateStateAction ssh_update_state_action = 69; + SensorsAction sensors_action = 70; + SensorsReportReadingAction sensors_report_reading_action = 71; + UsersAction users_action = 72; + UsersCreateUserAction users_create_user_action = 73; + UsersDeleteUserAction users_delete_user_action = 74; + UsersResetPasswordAction users_reset_password_action = 75; + UsersSetUsersAction users_set_users_action = 76; + VSCodeAction vs_code_action = 77; + VSCodeDoneDownloadingAction vs_code_done_downloading_action = 78; + VSCodeSetPendingAction vs_code_set_pending_action = 79; + VSCodeSetStatusAction vs_code_set_status_action = 80; + VSCodeStartDownloadingAction vs_code_start_downloading_action = 81; + VoiceAction voice_action = 82; + VoiceReadTextAction voice_read_text_action = 83; + VoiceSetEngineAction voice_set_engine_action = 84; + WiFiAction wi_fi_action = 85; + WiFiSetHasVisitedOnboardingAction wi_fi_set_has_visited_onboarding_action = 86; + WiFiUpdateAction wi_fi_update_action = 87; + WiFiUpdateRequestAction wi_fi_update_request_action = 88; } } @@ -1425,28 +1443,23 @@ message Event { InputProvideEvent input_provide_event = 15; InputResolveEvent input_resolve_event = 16; IpEvent ip_event = 17; - KeypadEvent keypad_event = 18; - KeypadKeyPressEvent keypad_key_press_event = 19; - KeypadKeyReleaseEvent keypad_key_release_event = 20; - NotificationsClearEvent notifications_clear_event = 21; - NotificationsDisplayEvent notifications_display_event = 22; - NotificationsEvent notifications_event = 23; - RPiConnectEvent r_pi_connect_event = 24; - RPiConnectLoginEvent r_pi_connect_login_event = 25; - RgbRingCommandEvent rgb_ring_command_event = 26; - RgbRingEvent rgb_ring_event = 27; - ScreenshotEvent screenshot_event = 28; - SnapshotEvent snapshot_event = 29; - UsersCreateUserEvent users_create_user_event = 30; - UsersDeleteUserEvent users_delete_user_event = 31; - UsersEvent users_event = 32; - UsersResetPasswordEvent users_reset_password_event = 33; - VSCodeEvent vs_code_event = 34; - VSCodeLoginEvent vs_code_login_event = 35; - VSCodeRestartEvent vs_code_restart_event = 36; - VoiceEvent voice_event = 37; - VoiceSynthesizeTextEvent voice_synthesize_text_event = 38; - WiFiEvent wi_fi_event = 39; - WiFiUpdateRequestEvent wi_fi_update_request_event = 40; + NotificationsClearEvent notifications_clear_event = 18; + NotificationsDisplayEvent notifications_display_event = 19; + NotificationsEvent notifications_event = 20; + RPiConnectEvent r_pi_connect_event = 21; + RPiConnectLoginEvent r_pi_connect_login_event = 22; + RgbRingCommandEvent rgb_ring_command_event = 23; + RgbRingEvent rgb_ring_event = 24; + UsersCreateUserEvent users_create_user_event = 25; + UsersDeleteUserEvent users_delete_user_event = 26; + UsersEvent users_event = 27; + UsersResetPasswordEvent users_reset_password_event = 28; + VSCodeEvent vs_code_event = 29; + VSCodeLoginEvent vs_code_login_event = 30; + VSCodeRestartEvent vs_code_restart_event = 31; + VoiceEvent voice_event = 32; + VoiceSynthesizeTextEvent voice_synthesize_text_event = 33; + WiFiEvent wi_fi_event = 34; + WiFiUpdateRequestEvent wi_fi_update_request_event = 35; } } diff --git a/ubo_app/rpc/service.py b/ubo_app/rpc/service.py index e031bef5..18391f43 100644 --- a/ubo_app/rpc/service.py +++ b/ubo_app/rpc/service.py @@ -11,8 +11,6 @@ from ubo_app.rpc.generated.store.v1 import ( DispatchActionRequest, DispatchActionResponse, - DispatchEventRequest, - DispatchEventResponse, StoreServiceBase, SubscribeEventRequest, SubscribeEventResponse, @@ -50,27 +48,6 @@ async def dispatch_action( store.dispatch(cast(UboAction, action)) return DispatchActionResponse() - async def dispatch_event( - self: StoreService, - dispatch_event_request: DispatchEventRequest, - ) -> DispatchEventResponse: - """Dispatch an event to the store.""" - logger.info( - 'Received event to be dispatched over gRPC', - extra={ - 'request': dispatch_event_request, - }, - ) - if not dispatch_event_request.event: - return DispatchEventResponse() - try: - event = rebuild_object(dispatch_event_request.event) - except Exception: - logger.exception('Failed to build object from dispatch event request') - else: - store.dispatch(cast(UboEvent, event)) - return DispatchEventResponse() - async def subscribe_event( self: StoreService, subscribe_event_request: SubscribeEventRequest, diff --git a/ubo_app/services/000-audio/audio_manager.py b/ubo_app/services/000-audio/audio_manager.py index 3b0170ec..7e792e3f 100644 --- a/ubo_app/services/000-audio/audio_manager.py +++ b/ubo_app/services/000-audio/audio_manager.py @@ -12,7 +12,7 @@ from ubo_app.logging import logger from ubo_app.store.main import store -from ubo_app.store.services.audio import AudioPlaybackDoneEvent +from ubo_app.store.services.audio import AudioPlaybackDoneAction from ubo_app.utils.async_ import create_task from ubo_app.utils.server import send_command @@ -135,7 +135,7 @@ async def play_sequence( extra={'tried_times': TRIALS}, ) if id is not None: - store.dispatch(AudioPlaybackDoneEvent(id=id)) + store.dispatch(AudioPlaybackDoneAction(id=id)) def set_playback_mute(self: AudioManager, *, mute: bool = False) -> None: """Set the playback mute of the audio output. diff --git a/ubo_app/services/000-audio/reducer.py b/ubo_app/services/000-audio/reducer.py index 2dad01e6..c0505d26 100644 --- a/ubo_app/services/000-audio/reducer.py +++ b/ubo_app/services/000-audio/reducer.py @@ -18,6 +18,8 @@ AudioEvent, AudioPlayAudioAction, AudioPlayAudioEvent, + AudioPlaybackDoneAction, + AudioPlaybackDoneEvent, AudioPlayChimeAction, AudioPlayChimeEvent, AudioSetMuteStatusAction, @@ -114,4 +116,11 @@ def reducer( ), ], ) + elif isinstance(action, AudioPlaybackDoneAction): + return CompleteReducerResult( + state=state, + events=[ + AudioPlaybackDoneEvent(id=action.id), + ], + ) return state diff --git a/ubo_app/services/000-audio/setup.py b/ubo_app/services/000-audio/setup.py index 72033ad2..788116e7 100644 --- a/ubo_app/services/000-audio/setup.py +++ b/ubo_app/services/000-audio/setup.py @@ -10,7 +10,11 @@ from constants import AUDIO_MIC_STATE_ICON_ID, AUDIO_MIC_STATE_ICON_PRIORITY from ubo_app.store.main import store -from ubo_app.store.services.audio import AudioPlayAudioEvent, AudioPlayChimeEvent +from ubo_app.store.services.audio import ( + AudioPlayAudioAction, + AudioPlayAudioEvent, + AudioPlayChimeEvent, +) from ubo_app.store.status_icons import StatusIconsRegisterAction from ubo_app.utils.async_ import to_thread from ubo_app.utils.persistent_store import register_persistent_store @@ -64,7 +68,7 @@ def play_file(event: AudioPlayChimeEvent) -> None: audio_data = wave_file.readframes(wave_file.getnframes()) store.dispatch( - AudioPlayAudioEvent( + AudioPlayAudioAction( rate=sample_rate, channels=channels, width=sample_width, diff --git a/ubo_app/services/010-notifications/reducer.py b/ubo_app/services/010-notifications/reducer.py index 2653a972..57c6b42a 100644 --- a/ubo_app/services/010-notifications/reducer.py +++ b/ubo_app/services/010-notifications/reducer.py @@ -22,6 +22,7 @@ NotificationsClearAllAction, NotificationsClearByIdAction, NotificationsClearEvent, + NotificationsDisplayAction, NotificationsDisplayEvent, NotificationsState, ) @@ -109,6 +110,17 @@ def reducer( ], events=events, ) + if isinstance(action, NotificationsDisplayAction): + return CompleteReducerResult( + state=state, + events=[ + NotificationsDisplayEvent( + notification=action.notification, + index=action.index, + count=action.count, + ), + ], + ) if isinstance(action, NotificationsClearAction): new_notifications = [ notification diff --git a/ubo_app/services/010-voice/setup.py b/ubo_app/services/010-voice/setup.py index 5c894b29..ecee85b8 100644 --- a/ubo_app/services/010-voice/setup.py +++ b/ubo_app/services/010-voice/setup.py @@ -195,6 +195,24 @@ def _menu_sub_heading(_: bool | None) -> str: } +def create_engine_selector(engine: VoiceEngine) -> Callable[[], None]: + """Select the voice engine.""" + + def _engine_selector() -> None: + store.dispatch( + VoiceSetEngineAction(engine=engine), + VoiceReadTextAction( + text={ + VoiceEngine.PIPER: 'Piper voice engine selected', + VoiceEngine.PICOVOICE: 'Picovoice voice engine selected', + }[engine], + engine=engine, + ), + ) + + return _engine_selector + + @store.autorun(lambda state: state.voice.selected_engine) def _voice_engine_items(selected_engine: VoiceEngine) -> Sequence[ActionItem]: selected_engine_parameters = { @@ -217,24 +235,6 @@ def _voice_engine_items(selected_engine: VoiceEngine) -> Sequence[ActionItem]: ] -def create_engine_selector(engine: VoiceEngine) -> Callable[[], None]: - """Select the voice engine.""" - - def _engine_selector() -> None: - store.dispatch( - VoiceSetEngineAction(engine=engine), - VoiceReadTextAction( - text={ - VoiceEngine.PIPER: 'Piper voice engine selected', - VoiceEngine.PICOVOICE: 'Picovoice voice engine selected', - }[engine], - engine=engine, - ), - ) - - return _engine_selector - - def init_service() -> None: """Initialize voice service.""" access_key = secrets.read_secret(PICOVOICE_ACCESS_KEY) diff --git a/ubo_app/services/020-keyboard/setup.py b/ubo_app/services/020-keyboard/setup.py index 826d99ba..75beab12 100644 --- a/ubo_app/services/020-keyboard/setup.py +++ b/ubo_app/services/020-keyboard/setup.py @@ -8,13 +8,77 @@ from ubo_app.store.main import store from ubo_app.store.services.audio import AudioDevice, AudioToggleMuteStatusAction -from ubo_app.store.services.keypad import Key, KeypadKeyPressAction +from ubo_app.store.services.keypad import ( + Key, + KeypadKeyPressAction, + KeypadKeyReleaseAction, +) if TYPE_CHECKING: Modifier = Literal['ctrl', 'alt', 'meta', 'shift'] +KEY_MAP = { + 'no_modifier': { + Keyboard.keycodes['up']: KeypadKeyPressAction( + key=Key.UP, + pressed_keys={Key.UP}, + ), + Keyboard.keycodes['k']: KeypadKeyPressAction(key=Key.UP, pressed_keys={Key.UP}), + Keyboard.keycodes['down']: KeypadKeyPressAction( + key=Key.DOWN, + pressed_keys={Key.DOWN}, + ), + Keyboard.keycodes['j']: KeypadKeyPressAction( + key=Key.DOWN, + pressed_keys={Key.DOWN}, + ), + Keyboard.keycodes['1']: KeypadKeyPressAction(key=Key.L1, pressed_keys={Key.L1}), + Keyboard.keycodes['2']: KeypadKeyPressAction(key=Key.L2, pressed_keys={Key.L2}), + Keyboard.keycodes['3']: KeypadKeyPressAction(key=Key.L3, pressed_keys={Key.L3}), + Keyboard.keycodes['left']: KeypadKeyReleaseAction( + key=Key.BACK, + pressed_keys=set(), + ), + Keyboard.keycodes['escape']: KeypadKeyReleaseAction( + key=Key.BACK, + pressed_keys=set(), + ), + Keyboard.keycodes['h']: KeypadKeyReleaseAction( + key=Key.BACK, + pressed_keys=set(), + ), + Keyboard.keycodes['backspace']: KeypadKeyReleaseAction( + key=Key.HOME, + pressed_keys=set(), + ), + Keyboard.keycodes['m']: AudioToggleMuteStatusAction(device=AudioDevice.INPUT), + Keyboard.keycodes['p']: KeypadKeyPressAction( + key=Key.L1, + pressed_keys={Key.HOME, Key.L1}, + ), + Keyboard.keycodes['s']: KeypadKeyPressAction( + key=Key.L2, + pressed_keys={Key.HOME, Key.L2}, + ), + Keyboard.keycodes['r']: KeypadKeyPressAction( + key=Key.L3, + pressed_keys={Key.HOME, Key.L3}, + ), + Keyboard.keycodes['q']: KeypadKeyPressAction( + key=Key.BACK, + pressed_keys={Key.HOME, Key.BACK}, + ), + }, + 'ctrl': { + Keyboard.keycodes['r']: KeypadKeyPressAction( + key=Key.L3, + pressed_keys={Key.BACK, Key.L3}, + ), + }, +} -def on_keyboard( # noqa: C901 + +def on_keyboard( window: WindowBase, key: int, scancode: int, @@ -25,45 +89,10 @@ def on_keyboard( # noqa: C901 _ = window, scancode, codepoint from ubo_app.store.main import store - if modifier == []: - if key in (Keyboard.keycodes['up'], Keyboard.keycodes['k']): - store.dispatch(KeypadKeyPressAction(key=Key.UP, pressed_keys=set())) - elif key in (Keyboard.keycodes['down'], Keyboard.keycodes['j']): - store.dispatch(KeypadKeyPressAction(key=Key.DOWN, pressed_keys=set())) - elif key == Keyboard.keycodes['1']: - store.dispatch(KeypadKeyPressAction(key=Key.L1, pressed_keys=set())) - elif key == Keyboard.keycodes['2']: - store.dispatch(KeypadKeyPressAction(key=Key.L2, pressed_keys=set())) - elif key == Keyboard.keycodes['3']: - store.dispatch(KeypadKeyPressAction(key=Key.L3, pressed_keys=set())) - elif key in ( - Keyboard.keycodes['left'], - Keyboard.keycodes['escape'], - Keyboard.keycodes['h'], - ): - store.dispatch(KeypadKeyPressAction(key=Key.BACK, pressed_keys=set())) - elif key == Keyboard.keycodes['backspace']: - store.dispatch(KeypadKeyPressAction(key=Key.HOME, pressed_keys=set())) - elif key == Keyboard.keycodes['m']: - from ubo_app.store.main import store - - store.dispatch( - AudioToggleMuteStatusAction( - device=AudioDevice.INPUT, - ), - ) - elif key == Keyboard.keycodes['p']: - store.dispatch( - KeypadKeyPressAction(key=Key.L1, pressed_keys={Key.HOME, Key.L1}), - ) - elif key == Keyboard.keycodes['s']: - store.dispatch( - KeypadKeyPressAction(key=Key.L2, pressed_keys={Key.HOME, Key.L2}), - ) - elif key == Keyboard.keycodes['q']: - store.dispatch( - KeypadKeyPressAction(key=Key.BACK, pressed_keys={Key.HOME, Key.BACK}), - ) + if modifier == [] and key in KEY_MAP['no_modifier']: + store.dispatch(KEY_MAP['no_modifier'][key]) + elif modifier == ['ctrl'] and key in KEY_MAP['ctrl']: + store.dispatch(KEY_MAP['ctrl'][key]) def init_service() -> None: diff --git a/ubo_app/services/030-wifi/pages/create_wireless_connection.py b/ubo_app/services/030-wifi/pages/create_wireless_connection.py index 44c05276..5248afaf 100644 --- a/ubo_app/services/030-wifi/pages/create_wireless_connection.py +++ b/ubo_app/services/030-wifi/pages/create_wireless_connection.py @@ -13,7 +13,7 @@ from wifi_manager import add_wireless_connection from ubo_app.logging import logger -from ubo_app.store.core import CloseApplicationEvent +from ubo_app.store.core import CloseApplicationAction from ubo_app.store.main import store from ubo_app.store.operations import InputFieldDescription, InputFieldType from ubo_app.store.services.notifications import ( @@ -100,15 +100,15 @@ async def create_wireless_connection(self: CreateWirelessConnectionPage) -> None ], ) except asyncio.CancelledError: - store.dispatch(CloseApplicationEvent(application=self)) + store.dispatch(CloseApplicationAction(application=self)) return if not data: - store.dispatch(CloseApplicationEvent(application=self)) + store.dispatch(CloseApplicationAction(application=self)) return ssid = data.get('SSID') or data.get('SSID_') if ssid is None: - store.dispatch(CloseApplicationEvent(application=self)) + store.dispatch(CloseApplicationAction(application=self)) return password = data.get('Password') or data.get('Password_') @@ -120,7 +120,7 @@ async def create_wireless_connection(self: CreateWirelessConnectionPage) -> None if not password: logger.warning('Password is required') - store.dispatch(CloseApplicationEvent(application=self)) + store.dispatch(CloseApplicationAction(application=self)) return self.creating = True @@ -154,7 +154,7 @@ async def create_wireless_connection(self: CreateWirelessConnectionPage) -> None chime=Chime.ADD, ), ), - CloseApplicationEvent(application=self), + CloseApplicationAction(application=self), ) diff --git a/ubo_app/services/030-wifi/pages/main.py b/ubo_app/services/030-wifi/pages/main.py index ac55af52..eedae63c 100644 --- a/ubo_app/services/030-wifi/pages/main.py +++ b/ubo_app/services/030-wifi/pages/main.py @@ -23,7 +23,7 @@ get_wifi_device, ) -from ubo_app.store.core import CloseApplicationEvent +from ubo_app.store.core import CloseApplicationAction from ubo_app.store.main import store from ubo_app.store.services.wifi import ( ConnectionState, @@ -52,7 +52,7 @@ def first_option_callback(self: WiFiConnectionPage) -> None: def second_option_callback(self: WiFiConnectionPage) -> None: create_task(forget_wireless_connection(self.ssid)) store.dispatch( - CloseApplicationEvent(application=self), + CloseApplicationAction(application=self), WiFiUpdateRequestAction(reset=True), ) diff --git a/ubo_app/services/040-camera/reducer.py b/ubo_app/services/040-camera/reducer.py index bec1109c..5a7714dd 100644 --- a/ubo_app/services/040-camera/reducer.py +++ b/ubo_app/services/040-camera/reducer.py @@ -94,7 +94,7 @@ def reducer( color='#ffffff', actions=[ NotificationDispatchItem( - operation=CameraStartViewfinderAction( + store_action=CameraStartViewfinderAction( pattern=action.description.pattern, ), icon='󰄀', diff --git a/ubo_app/services/040-camera/setup.py b/ubo_app/services/040-camera/setup.py index 23e58599..7713a378 100644 --- a/ubo_app/services/040-camera/setup.py +++ b/ubo_app/services/040-camera/setup.py @@ -15,7 +15,7 @@ from typing_extensions import override from ubo_gui.page import PageWidget -from ubo_app.store.core import CloseApplicationEvent, OpenApplicationEvent +from ubo_app.store.core import CloseApplicationAction, OpenApplicationAction from ubo_app.store.main import store from ubo_app.store.services.camera import ( CameraReportBarcodeAction, @@ -180,7 +180,7 @@ def start_camera_viewfinder() -> None: fs_lock = Lock() application = CameraApplication() - store.dispatch(OpenApplicationEvent(application=application)) + store.dispatch(OpenApplicationAction(application=application)) def feed_viewfinder_locked(_: object) -> None: with fs_lock: @@ -199,7 +199,7 @@ def handle_stop_viewfinder() -> None: is_running = False feed_viewfinder_scheduler.cancel() store.dispatch( - CloseApplicationEvent(application=application), + CloseApplicationAction(application=application), DisplayResumeAction(), ) if picamera2: diff --git a/ubo_app/services/050-lightdm/setup.py b/ubo_app/services/050-lightdm/setup.py index 77706e87..65b151ef 100644 --- a/ubo_app/services/050-lightdm/setup.py +++ b/ubo_app/services/050-lightdm/setup.py @@ -83,6 +83,18 @@ async def act() -> None: create_task(act()) +@store.autorun(lambda state: state.lightdm) +def lightdm_icon(state: LightDMState) -> str: + """Get the LightDM icon.""" + return '[color=#008000]󰪥[/color]' if state.is_active else '[color=#ffff00]󰝦[/color]' + + +@store.autorun(lambda state: state.lightdm) +def lightdm_title(_: LightDMState) -> str: + """Get the LightDM title.""" + return lightdm_icon() + ' Desktop' + + def disable_lightdm_service() -> None: """Disable the LightDM service.""" @@ -148,18 +160,6 @@ def lightdm_menu(state: LightDMState) -> Menu: ) -@store.autorun(lambda state: state.lightdm) -def lightdm_icon(state: LightDMState) -> str: - """Get the LightDM icon.""" - return '[color=#008000]󰪥[/color]' if state.is_active else '[color=#ffff00]󰝦[/color]' - - -@store.autorun(lambda state: state.lightdm) -def lightdm_title(_: LightDMState) -> str: - """Get the LightDM title.""" - return lightdm_icon() + ' Desktop' - - async def check_lightdm() -> None: """Check if the LightDM service is enabled.""" is_enabled, is_installed = await asyncio.gather( diff --git a/ubo_app/services/050-rpi-connect/sign_in_page.py b/ubo_app/services/050-rpi-connect/sign_in_page.py index dcf8fa4e..d7542ea5 100644 --- a/ubo_app/services/050-rpi-connect/sign_in_page.py +++ b/ubo_app/services/050-rpi-connect/sign_in_page.py @@ -14,7 +14,7 @@ from ubo_gui.page import PageWidget from ubo_app.logging import logger -from ubo_app.store.core import CloseApplicationEvent +from ubo_app.store.core import CloseApplicationAction from ubo_app.store.main import store from ubo_app.store.services.notifications import ( Chime, @@ -38,7 +38,7 @@ def __init__( super().__init__(*args, **kwargs, items=[]) store.subscribe_event( RPiConnectLoginEvent, - lambda: store.dispatch(CloseApplicationEvent(application=self)), + lambda: store.dispatch(CloseApplicationAction(application=self)), ) create_task(self.login()) diff --git a/ubo_app/services/050-users/setup.py b/ubo_app/services/050-users/setup.py index 43a9d0a5..dfd65522 100644 --- a/ubo_app/services/050-users/setup.py +++ b/ubo_app/services/050-users/setup.py @@ -221,7 +221,7 @@ def users_menu(state: UsersState) -> Menu: DispatchItem( label='Add', icon='󰀔', - operation=UsersCreateUserAction(), + store_action=UsersCreateUserAction(), background_color=WARNING_COLOR, ), *[ @@ -235,13 +235,13 @@ def users_menu(state: UsersState) -> Menu: DispatchItem( label='Reset Password', icon='󰯄', - operation=UsersResetPasswordAction(id=user.id), + store_action=UsersResetPasswordAction(id=user.id), background_color=WARNING_COLOR, ), DispatchItem( label='Delete', icon='󰀕', - operation=UsersDeleteUserAction(id=user.id), + store_action=UsersDeleteUserAction(id=user.id), background_color=DANGER_COLOR, ), ], diff --git a/ubo_app/services/050-vscode/login_page.py b/ubo_app/services/050-vscode/login_page.py index d8bfa693..6342f0f6 100644 --- a/ubo_app/services/050-vscode/login_page.py +++ b/ubo_app/services/050-vscode/login_page.py @@ -15,7 +15,7 @@ from ubo_gui.page import PageWidget from ubo_app.logging import logger -from ubo_app.store.core import CloseApplicationEvent +from ubo_app.store.core import CloseApplicationAction from ubo_app.store.main import store from ubo_app.store.services.notifications import ( Chime, @@ -40,7 +40,7 @@ def __init__( super().__init__(*args, **kwargs, items=[]) store.subscribe_event( VSCodeLoginEvent, - lambda: store.dispatch(CloseApplicationEvent(application=self)), + lambda: store.dispatch(CloseApplicationAction(application=self)), ) create_task(self.login()) diff --git a/ubo_app/side_effects.py b/ubo_app/side_effects.py index e73ffbed..931ce63c 100644 --- a/ubo_app/side_effects.py +++ b/ubo_app/side_effects.py @@ -11,9 +11,15 @@ from redux import FinishAction, FinishEvent from ubo_app import display -from ubo_app.store.core import PowerOffEvent, RebootEvent +from ubo_app.store.core import ( + PowerOffEvent, + RebootEvent, + ReplayRecordedSequenceEvent, + ScreenshotEvent, + SnapshotEvent, + StoreRecordedSequenceEvent, +) from ubo_app.store.main import store -from ubo_app.store.operations import ScreenshotEvent, SnapshotEvent from ubo_app.store.services.audio import AudioPlayChimeAction from ubo_app.store.services.notifications import Chime from ubo_app.store.update_manager import ( @@ -24,6 +30,7 @@ ) from ubo_app.store.update_manager.utils import check_version, update from ubo_app.utils.hardware import IS_RPI, initialize_board +from ubo_app.utils.store import replay_actions if TYPE_CHECKING: from numpy._typing import NDArray @@ -92,11 +99,39 @@ def take_screenshot() -> None: def take_snapshot() -> None: """Take a snapshot of the store.""" counter = 0 - while (path := Path(f'snapshots/ubo-screenshot-{counter:03d}.png')).exists(): + while (path := Path(f'snapshots/ubo-screenshot-{counter:03d}.json')).exists(): counter += 1 path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(store.snapshot, indent=2)) + with path.open('w') as file: + json.dump(store.snapshot, file, indent=2) + + +def store_recorded_sequence(event: StoreRecordedSequenceEvent) -> None: + """Store the recorded sequence.""" + counter = 0 + while (path := Path(f'recordings/ubo-recording-{counter:03d}.json')).exists(): + counter += 1 + + path.parent.mkdir(parents=True, exist_ok=True) + json_dump = json.dumps( + [ + store.serialize_value(action) + for action in event.recorded_sequence + if type(action).__name__.startswith('Keypad') + ], + indent=2, + ) + + with path.open('w') as file: + file.write(json_dump) + with Path('recordings/active.json').open('w') as file: + file.write(json_dump) + + +def replay_recorded_sequence() -> None: + """Replay the recorded sequence.""" + replay_actions(store, Path('recordings/active.json')) def setup_side_effects() -> None: @@ -110,5 +145,7 @@ def setup_side_effects() -> None: store.subscribe_event(UpdateManagerCheckEvent, check_version) store.subscribe_event(ScreenshotEvent, take_screenshot) store.subscribe_event(SnapshotEvent, take_snapshot) + store.subscribe_event(StoreRecordedSequenceEvent, store_recorded_sequence) + store.subscribe_event(ReplayRecordedSequenceEvent, replay_recorded_sequence) store.dispatch(UpdateManagerSetStatusAction(status=UpdateStatus.CHECKING)) diff --git a/ubo_app/store/core/__init__.py b/ubo_app/store/core/__init__.py index 356276b2..13042e09 100644 --- a/ubo_app/store/core/__init__.py +++ b/ubo_app/store/core/__init__.py @@ -80,6 +80,39 @@ class SetMenuPathAction(MainAction): depth: int +class MenuAction(MainAction): ... + + +class MenuGoBackAction(MenuAction): ... + + +class MenuGoHomeAction(MenuAction): ... + + +class MenuChooseByIconAction(MenuAction): + icon: str + + +class MenuChooseByLabelAction(MenuAction): + label: str + + +class MenuChooseByIndexAction(MenuAction): + index: int + + +class MenuScrollAction(MenuAction): + direction: MenuScrollDirection + + +class OpenApplicationAction(MainAction): + application: PageWidget + + +class CloseApplicationAction(MainAction): + application: PageWidget + + class MainEvent(BaseEvent): ... @@ -128,8 +161,28 @@ class PowerOffEvent(PowerEvent): ... class RebootEvent(PowerEvent): ... +class ScreenshotEvent(MainEvent): + """Event for taking a screenshot.""" + + +class SnapshotEvent(MainEvent): + """Event for taking a snapshot of the store.""" + + +class StoreRecordedSequenceEvent(MainEvent): + """Event for storing a recorded sequence.""" + + recorded_sequence: list[BaseAction] + + +class ReplayRecordedSequenceEvent(MainEvent): + """Event for replaying a recorded sequence.""" + + class MainState(Immutable): menu: Menu | None = None path: Sequence[str] = field(default_factory=list) depth: int = 0 settings_items_priorities: dict[str, int] = field(default_factory=dict) + is_recording: bool = False + recorded_sequence: list[BaseAction] = field(default_factory=list) diff --git a/ubo_app/store/core/_menus.py b/ubo_app/store/core/_menus.py index 7212f6fa..b0f01648 100644 --- a/ubo_app/store/core/_menus.py +++ b/ubo_app/store/core/_menus.py @@ -19,7 +19,10 @@ ) from ubo_app.store.dispatch_action import DispatchItem from ubo_app.store.main import store -from ubo_app.store.services.notifications import Notification, NotificationsDisplayEvent +from ubo_app.store.services.notifications import ( + Notification, + NotificationsDisplayAction, +) from ubo_app.store.update_manager.utils import ( BASE_IMAGE, CURRENT_VERSION, @@ -107,7 +110,7 @@ def notifications_menu_items(notifications: Sequence[Notification]) -> list[Item icon=notification.icon, color='black', background_color=notification.color, - operation=NotificationsDisplayEvent( + store_action=NotificationsDisplayAction( notification=notification, index=index, count=len(notifications), @@ -158,12 +161,12 @@ def notifications_color(unread_count: int) -> str: items=[ DispatchItem( label='Reboot', - operation=RebootAction(), + store_action=RebootAction(), icon='󰜉', ), DispatchItem( label='Power off', - operation=PowerOffAction(), + store_action=PowerOffAction(), icon='󰐥', ), ], diff --git a/ubo_app/store/core/reducer.py b/ubo_app/store/core/reducer.py index cce5464f..f0458ffd 100644 --- a/ubo_app/store/core/reducer.py +++ b/ubo_app/store/core/reducer.py @@ -14,25 +14,40 @@ ) from ubo_app.store.core import ( + CloseApplicationAction, + CloseApplicationEvent, InitEvent, MainAction, + MainEvent, MainState, + MenuChooseByIconAction, + MenuChooseByIconEvent, + MenuChooseByIndexAction, MenuChooseByIndexEvent, + MenuChooseByLabelAction, + MenuChooseByLabelEvent, MenuEvent, + MenuGoBackAction, MenuGoBackEvent, + MenuGoHomeAction, MenuGoHomeEvent, + MenuScrollAction, MenuScrollDirection, MenuScrollEvent, - PowerEvent, + OpenApplicationAction, + OpenApplicationEvent, PowerOffAction, PowerOffEvent, RebootAction, RebootEvent, RegisterRegularAppAction, RegisterSettingAppAction, + ReplayRecordedSequenceEvent, + ScreenshotEvent, SetMenuPathAction, + SnapshotEvent, + StoreRecordedSequenceEvent, ) -from ubo_app.store.operations import ScreenshotEvent, SnapshotEvent from ubo_app.store.services.audio import AudioChangeVolumeAction, AudioDevice from ubo_app.store.services.keypad import ( Key, @@ -47,7 +62,7 @@ def reducer( ) -> ReducerResult[ MainState, AudioChangeVolumeAction, - InitEvent | MenuEvent | ScreenshotEvent | SnapshotEvent | FinishEvent | PowerEvent, + InitEvent | MenuEvent | FinishEvent | MainEvent, ]: from ubo_gui.menu.types import Item, Menu, SubMenuItem, menu_items @@ -61,6 +76,62 @@ def reducer( ) raise InitializationActionError(action) + if state.is_recording: + state = replace( + state, + recorded_sequence=[ + *state.recorded_sequence, + action, + ], + ) + + if isinstance(action, MenuGoBackAction): + return CompleteReducerResult( + state=state, + events=[MenuGoBackEvent()], + ) + + if isinstance(action, MenuGoHomeAction): + return CompleteReducerResult( + state=state, + events=[MenuGoHomeEvent()], + ) + + if isinstance(action, MenuChooseByIconAction): + return CompleteReducerResult( + state=state, + events=[MenuChooseByIconEvent(icon=action.icon)], + ) + + if isinstance(action, MenuChooseByLabelAction): + return CompleteReducerResult( + state=state, + events=[MenuChooseByLabelEvent(label=action.label)], + ) + + if isinstance(action, MenuChooseByIndexAction): + return CompleteReducerResult( + state=state, + events=[MenuChooseByIndexEvent(index=action.index)], + ) + if isinstance(action, MenuScrollAction): + return CompleteReducerResult( + state=state, + events=[MenuScrollEvent(direction=action.direction)], + ) + + if isinstance(action, OpenApplicationAction): + return CompleteReducerResult( + state=state, + events=[OpenApplicationEvent(application=action.application)], + ) + + if isinstance(action, CloseApplicationAction): + return CompleteReducerResult( + state=state, + events=[CloseApplicationEvent(application=action.application)], + ) + if isinstance(action, KeypadKeyPressAction): if action.pressed_keys == {action.key}: if action.key == Key.UP and state.depth == 1: @@ -120,6 +191,26 @@ def reducer( state=state, events=[SnapshotEvent()], ) + if action.pressed_keys == {Key.HOME, Key.L3} and action.key == Key.L3: + return CompleteReducerResult( + state=replace( + state, + is_recording=not state.is_recording, + recorded_sequence=[], + ), + events=[ + StoreRecordedSequenceEvent( + recorded_sequence=state.recorded_sequence, + ), + ] + if state.is_recording + else [], + ) + if action.pressed_keys == {Key.BACK, Key.L3} and action.key == Key.L3: + return CompleteReducerResult( + state=state, + events=[ReplayRecordedSequenceEvent()], + ) if action.pressed_keys == {Key.HOME, Key.BACK} and action.key == Key.BACK: return CompleteReducerResult( state=state, @@ -187,6 +278,7 @@ def sort_key(item: Item) -> tuple[int, str]: msg = f"""Settings application with key "{key}", in category \ "{category_menu_item.label}", already exists. Consider providing a unique `key` field \ for the `RegisterSettingAppAction` instance.""" + return state raise ValueError(msg) menu_item = replace(action.menu_item, key=key) @@ -272,6 +364,7 @@ def sort_key(item: Item) -> tuple[int, str]: ): msg = f"""Regular application with key "{key}", already exists. Consider \ providing a unique `key` field for the `RegisterRegularAppAction` instance.""" + return state raise ValueError(msg) menu_item = replace(action.menu_item, key=key) diff --git a/ubo_app/store/dispatch_action.py b/ubo_app/store/dispatch_action.py index 5a037a13..53758ae0 100644 --- a/ubo_app/store/dispatch_action.py +++ b/ubo_app/store/dispatch_action.py @@ -1,4 +1,4 @@ -"""Implement a menu item that dispatches an action or an event.""" +"""Implement a menu item that dispatches an action.""" from __future__ import annotations @@ -11,7 +11,7 @@ if TYPE_CHECKING: from collections.abc import Callable - from ubo_app.store.main import UboAction, UboEvent + from ubo_app.store.main import UboAction def _default_action() -> Callable[[], None]: @@ -21,14 +21,16 @@ def _default_action() -> Callable[[], None]: from ubo_app.store.main import store parent_frame = sys._getframe().f_back # noqa: SLF001 - if not parent_frame or not (operation := parent_frame.f_locals.get('operation')): - msg = 'No operation provided for `DispatchItem`' + if not parent_frame or not ( + store_action := parent_frame.f_locals.get('store_action') + ): + msg = 'No store_action provided for `DispatchItem`' raise ValueError(msg) - return lambda: store.dispatch(operation) + return lambda: store.dispatch(store_action) class DispatchItem(ActionItem): - """Menu item that dispatches an action or an event.""" + """Menu item that dispatches an action.""" - operation: UboAction | UboEvent + store_action: UboAction action: Callable[[], None] = field(default_factory=_default_action) diff --git a/ubo_app/store/main.py b/ubo_app/store/main.py index 81bcb8c7..9bed5a68 100644 --- a/ubo_app/store/main.py +++ b/ubo_app/store/main.py @@ -32,9 +32,7 @@ from ubo_app.store.input.reducer import reducer as input_reducer from ubo_app.store.operations import ( InputAction, - InputProvideEvent, - ScreenshotEvent, - SnapshotEvent, + InputResolveEvent, ) from ubo_app.store.services.audio import AudioAction, AudioEvent from ubo_app.store.services.camera import CameraAction, CameraEvent @@ -116,15 +114,13 @@ UboEvent: TypeAlias = ( # Core Events MainEvent - | ScreenshotEvent - | InputProvideEvent + | InputResolveEvent # Services Events | AudioEvent | CameraEvent | DisplayEvent | IpEvent | NotificationsEvent - | SnapshotEvent | UsersEvent | WiFiEvent ) @@ -178,17 +174,33 @@ class RootState(BaseCombineReducerState): ) T = TypeVar('T') -LoadedObject = int | float | str | bool | None | Immutable | list['LoadedObject'] +LoadedObject = ( + int + | float + | str + | bytes + | bool + | None + | Immutable + | list['LoadedObject'] + | set['LoadedObject'] +) class UboStore(Store[RootState, UboAction, UboEvent]): @classmethod - def serialize_value(cls: type[UboStore], obj: object | type) -> SnapshotAtom: + def serialize_value(cls: type[UboStore], obj: object | type) -> SnapshotAtom: # noqa: C901 from redux.autorun import Autorun from ubo_gui.page import PageWidget if isinstance(obj, Autorun): obj = obj() + if isinstance(obj, set): + return {'_type': 'set', 'value': [cls.serialize_value(i) for i in obj]} + if isinstance(obj, bytes): + return {'_type': 'bytes', 'value': base64.b64encode(obj).decode('utf-8')} + if isinstance(obj, datetime): + return {'_type': 'datetime', 'value': obj.isoformat()} if isinstance(obj, type) and issubclass(obj, PageWidget): import ubo_app @@ -197,8 +209,10 @@ def serialize_value(cls: type[UboStore], obj: object | type) -> SnapshotAtom: ubo_app_path = sys.modules['ubo_app'].__file__ if file_path and ubo_app_path: root_path = Path(ubo_app_path).parent - return f"""{Path(file_path).relative_to(root_path).as_posix()}:{ - obj.__name__}""" + path = Path(file_path) + return f"""{(path.relative_to(root_path) + if file_path.startswith(root_path.as_posix()) + else path.absolute()).as_posix()}:{obj.__name__}""" return f'{obj.__module__}:{obj.__name__}' if isinstance(obj, functools.partial): return f'' @@ -206,8 +220,6 @@ def serialize_value(cls: type[UboStore], obj: object | type) -> SnapshotAtom: return f'' if isinstance(obj, dict): return {k: cls.serialize_value(v) for k, v in obj.items()} - if isinstance(obj, datetime): - return obj.isoformat() if isinstance(obj, Handle | Fake | PageWidget): return f'<{type(obj).__name__}>' return super().serialize_value(obj) @@ -233,7 +245,7 @@ def load_object( object_type: type[T], ) -> T: ... - def load_object( + def load_object( # noqa: C901 self: UboStore, data: Any, *, @@ -248,6 +260,11 @@ def load_object( and '_type' in data and isinstance(type_ := data.pop('_type'), str) ): + if type_ == 'set': + return {self.load_object(i) for i in data['value']} + if type_ == 'bytes': + return base64.b64decode(data['value'].encode('utf-8')) + if isinstance(type_, type): class_ = type_ elif isinstance(type_, str): diff --git a/ubo_app/store/operations.py b/ubo_app/store/operations.py index 302ce998..939779a7 100644 --- a/ubo_app/store/operations.py +++ b/ubo_app/store/operations.py @@ -12,14 +12,6 @@ from ubo_app.store.services.notifications import NotificationExtraInformation -class ScreenshotEvent(BaseEvent): - """Event for taking a screenshot.""" - - -class SnapshotEvent(BaseEvent): - """Event for taking a snapshot of the store.""" - - class InputFieldType(StrEnum): """Enumeration of input field types.""" diff --git a/ubo_app/store/services/audio.py b/ubo_app/store/services/audio.py index 6fef1649..40376f8d 100644 --- a/ubo_app/store/services/audio.py +++ b/ubo_app/store/services/audio.py @@ -49,6 +49,10 @@ class AudioPlayAudioAction(AudioAction): width: int +class AudioPlaybackDoneAction(AudioAction): + id: str + + class AudioEvent(BaseEvent): ... diff --git a/ubo_app/store/services/notifications.py b/ubo_app/store/services/notifications.py index 146f24d3..baffbe43 100644 --- a/ubo_app/store/services/notifications.py +++ b/ubo_app/store/services/notifications.py @@ -125,6 +125,12 @@ class NotificationsAddAction(NotificationsAction): notification: Notification +class NotificationsDisplayAction(NotificationsAction): + notification: Notification + index: int | None = None + count: int | None = None + + class NotificationsClearAction(NotificationsAction): notification: Notification diff --git a/ubo_app/store/update_manager/utils.py b/ubo_app/store/update_manager/utils.py index e9398a29..1f352789 100644 --- a/ubo_app/store/update_manager/utils.py +++ b/ubo_app/store/update_manager/utils.py @@ -23,7 +23,7 @@ UPDATE_LOCK_PATH, ) from ubo_app.logging import logger -from ubo_app.store.core import RebootEvent +from ubo_app.store.core import RebootAction from ubo_app.store.dispatch_action import DispatchItem from ubo_app.store.main import store from ubo_app.store.services.notifications import ( @@ -253,7 +253,7 @@ async def download_files() -> None: actions=[ NotificationDispatchItem( icon='󰜉', - operation=RebootEvent(), + store_action=RebootAction(), ), ], display_type=NotificationDisplayType.STICKY, @@ -302,7 +302,7 @@ def about_menu_items(state: UpdateManagerState) -> list[Item]: return [ DispatchItem( label='Failed to check for updates', - operation=UpdateManagerSetStatusAction(status=UpdateStatus.CHECKING), + store_action=UpdateManagerSetStatusAction(status=UpdateStatus.CHECKING), icon='󰜺', background_color=DANGER_COLOR, ), @@ -312,7 +312,7 @@ def about_menu_items(state: UpdateManagerState) -> list[Item]: DispatchItem( label='Already up to date!', icon='󰄬', - operation=UpdateManagerSetStatusAction(status=UpdateStatus.CHECKING), + store_action=UpdateManagerSetStatusAction(status=UpdateStatus.CHECKING), background_color=SUCCESS_COLOR, color='#000000', ), @@ -321,7 +321,7 @@ def about_menu_items(state: UpdateManagerState) -> list[Item]: return [ DispatchItem( label=f'Update to v{state.latest_version}', - operation=UpdateManagerSetStatusAction(status=UpdateStatus.UPDATING), + store_action=UpdateManagerSetStatusAction(status=UpdateStatus.UPDATING), icon='󰬬', ), ] diff --git a/ubo_app/utils/store.py b/ubo_app/utils/store.py new file mode 100644 index 00000000..9c73b50e --- /dev/null +++ b/ubo_app/utils/store.py @@ -0,0 +1,20 @@ +# ruff: noqa: D100, D101, D102, D103, D104, D107 +from __future__ import annotations + +import json +import time +from typing import TYPE_CHECKING, Any, cast + +if TYPE_CHECKING: + from pathlib import Path + + from ubo_app.store.main import UboStore + + +def replay_actions(store: UboStore, path: Path) -> None: + with path.open('r') as file: + data = json.load(file) + + for item in data: + store.dispatch(cast(Any, store.load_object(item))) + time.sleep(0.5) diff --git a/uv.lock b/uv.lock index fc3ff593..4eb503a2 100644 --- a/uv.lock +++ b/uv.lock @@ -1391,15 +1391,15 @@ wheels = [ [[package]] name = "pyright" -version = "1.1.383" +version = "1.1.386" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodeenv" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/a9/4654d15f4125d8dca6318d7be36a3283a8b3039661291c59bbdd1e576dcf/pyright-1.1.383.tar.gz", hash = "sha256:1df7f12407f3710c9c6df938d98ec53f70053e6c6bbf71ce7bcb038d42f10070", size = 21971 } +sdist = { url = "https://files.pythonhosted.org/packages/92/50/1a57054b5585fa72a93a6244c1b4b5639f8f7a1cc60b2e807cc67da8f0bc/pyright-1.1.386.tar.gz", hash = "sha256:8e9975e34948ba5f8e07792a9c9d2bdceb2c6c0b61742b068d2229ca2bc4a9d9", size = 21949 } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/55/40a6559cea209b551c81dcd31cb351a6ffdb5876e7865ee242e269af72d8/pyright-1.1.383-py3-none-any.whl", hash = "sha256:d864d1182a313f45aaf99e9bfc7d2668eeabc99b29a556b5344894fd73cb1959", size = 18577 }, + { url = "https://files.pythonhosted.org/packages/cc/68/47fd6b3ffa27c99d7e0c866c618f07784b8806712059049daa492ca7e526/pyright-1.1.386-py3-none-any.whl", hash = "sha256:7071ac495593b2258ccdbbf495f1a5c0e5f27951f6b429bed4e8b296eb5cd21d", size = 18577 }, ] [[package]] @@ -1544,17 +1544,15 @@ wheels = [ [[package]] name = "python-redux" -version = "0.17.2" +version = "0.18.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyright" }, { name = "python-immutable" }, { name = "python-strtobool" }, - { name = "ruff" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/26/e0/476b776d5410d97bf57d069240385b4ce0ec9e6e1c972ef6946cd3a46b27/python_redux-0.17.2.tar.gz", hash = "sha256:185e491390c6ae4eecf1e6f3c34cab25bed21667bc4ca49e02a94494ae497bf3", size = 22940 } +sdist = { url = "https://files.pythonhosted.org/packages/7f/e2/84eba40af9d0011811d2a6c026f4a2f6433faf9dbebddb9a116eb5e8189e/python_redux-0.18.2.tar.gz", hash = "sha256:131d4c5f9c9d4e37c470631f58bdbb1f79809ea6eb26c2f8fd84c65f13b8d825", size = 20247 } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/47/a9de36c30dfc0aa8e30d5fbd58aa9152fba47dd0a49f17193068fa1bf50f/python_redux-0.17.2-py3-none-any.whl", hash = "sha256:c10d505ac1861e0e221cf694268f207f7fd15f1d0d73d1d2b25e31425f7fe880", size = 25390 }, + { url = "https://files.pythonhosted.org/packages/11/4a/2a7e28f37dc6ea96ec3020c63a0d00fc84c321b00a26363c8f6be14c881b/python_redux-0.18.2-py3-none-any.whl", hash = "sha256:fa7a256c321891dd16cb735844f7579de404f6fc0fdd272998fc656a5f1030ee", size = 25823 }, ] [[package]] @@ -1884,7 +1882,7 @@ wheels = [ [[package]] name = "ubo-app" -version = "1.0.1.dev9+unknown" +version = "1.0.1.dev11+unknown" source = { editable = "." } dependencies = [ { name = "adafruit-circuitpython-aw9523" }, @@ -1962,7 +1960,7 @@ requires-dist = [ { name = "python-debouncer", specifier = ">=0.1.5" }, { name = "python-dotenv", specifier = ">=1.0.1" }, { name = "python-fake", specifier = ">=0.1.3" }, - { name = "python-redux", specifier = ">=0.17.2" }, + { name = "python-redux", specifier = ">=0.18.2" }, { name = "python-strtobool", specifier = ">=1.0.0" }, { name = "pythonping", specifier = ">=1.1.4" }, { name = "pyzbar", specifier = ">=0.1.9" },