From 86de77c6a0cf6b652f933dc09c69861cfc30252a Mon Sep 17 00:00:00 2001 From: Sassan Haradji Date: Wed, 30 Oct 2024 21:57:27 +0400 Subject: [PATCH] feat(core): add recording and replaying indicators, avoid replaying while recording and vice versa, move keypad events to its own reducer as it has grown too big --- CHANGELOG.md | 1 + .../store-desktop-000.jsonc | 1 + .../app_runs_and_exits/store-rpi-000.jsonc | 1 + .../store-desktop-000.jsonc | 5 + .../all_services_register/store-rpi-000.jsonc | 5 + ubo_app/main.py | 3 +- ubo_app/menu_app/menu_header.py | 60 ++++++ ubo_app/services/000-keypad/reducer.py | 190 ++++++++++++++++++ ubo_app/services/000-keypad/ubo_handle.py | 6 +- ubo_app/side_effects.py | 7 +- ubo_app/store/core/reducer.py | 178 +++------------- ubo_app/store/core/types.py | 13 ++ ubo_app/store/services/keypad.py | 5 + ubo_app/utils/store.py | 10 +- 14 files changed, 329 insertions(+), 156 deletions(-) create mode 100644 ubo_app/services/000-keypad/reducer.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 47d9ab02..57147b05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - 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 - feat(core): use new `SpinnerWidget` of ubo-gui to show unknown progress in notifications, and add `General` sub menu to `System` settings menu to host ubo-pod/ubo-app related settings, currently it has `Debug` toggle to control a debug feature of `HeadlessWidget` - closes #190 +- feat(core): add recording and replaying indicators, avoid replaying while recording and vice versa, move keypad events to its own reducer as it has grown too big ## Version 1.0.0 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 6f72dec7..19ee9f0d 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 @@ -9,6 +9,7 @@ "is_footer_visible": true, "is_header_visible": true, "is_recording": false, + "is_replaying": false, "menu": { "_type": "HeadlessMenu", "items": [ 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 a6dfcb7b..a56c6075 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 @@ -9,6 +9,7 @@ "is_footer_visible": true, "is_header_visible": true, "is_recording": false, + "is_replaying": false, "menu": { "_type": "HeadlessMenu", "items": [ 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 32602394..3c5e9085 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 @@ -98,6 +98,10 @@ ], "is_connected": true }, + "keypad": { + "_type": "KeypadState", + "depth": 1 + }, "lightdm": { "_type": "LightDMState", "is_active": true, @@ -111,6 +115,7 @@ "is_footer_visible": true, "is_header_visible": true, "is_recording": false, + "is_replaying": false, "menu": { "_type": "HeadlessMenu", "items": [ 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 94750e28..0bce0f8e 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 @@ -98,6 +98,10 @@ ], "is_connected": true }, + "keypad": { + "_type": "KeypadState", + "depth": 1 + }, "lightdm": { "_type": "LightDMState", "is_active": false, @@ -111,6 +115,7 @@ "is_footer_visible": true, "is_header_visible": true, "is_recording": false, + "is_replaying": false, "menu": { "_type": "HeadlessMenu", "items": [ diff --git a/ubo_app/main.py b/ubo_app/main.py index 8224dbf4..c6f25d08 100644 --- a/ubo_app/main.py +++ b/ubo_app/main.py @@ -10,6 +10,7 @@ from ubo_app.error_handlers import setup_error_handling from ubo_app.logging import setup_logging from ubo_app.setup import setup +from ubo_app.utils import IS_RPI dotenv.load_dotenv(Path(__file__).parent / '.dev.env') dotenv.load_dotenv(Path(__file__).parent / '.env') @@ -55,7 +56,7 @@ def main() -> None: headless_kivy.config.setup_headless_kivy( headless_kivy.config.SetupHeadlessConfig( - bandwidth_limit=70 * 1000 * 1000 // 8 // 2, + bandwidth_limit=70 * 1000 * 1000 // 8 // 2 if IS_RPI else 0, bandwidth_limit_window=0.03, bandwidth_limit_overhead=10000, region_size=60, diff --git a/ubo_app/menu_app/menu_header.py b/ubo_app/menu_app/menu_header.py index 0e5ac345..f3a25913 100644 --- a/ubo_app/menu_app/menu_header.py +++ b/ubo_app/menu_app/menu_header.py @@ -5,8 +5,10 @@ from functools import cached_property from typing import TYPE_CHECKING +from kivy.animation import Animation from kivy.metrics import dp from kivy.uix.boxlayout import BoxLayout +from kivy.uix.label import Label from kivy.uix.relativelayout import RelativeLayout from redux import AutorunOptions from ubo_gui.app import UboApp @@ -97,6 +99,30 @@ def handle_is_header_visible_change( elif self.header_content in self.header_layout.children: self.header_layout.remove_widget(self.header_content) + def handle_is_recording_change( + self: MenuAppHeader, + is_recording: bool, # noqa: FBT001 + ) -> None: + if is_recording: + if self.recording_sign not in self.header_content.children: + self.header_content.add_widget(self.recording_sign) + self.sign_animation.start(self.recording_sign) + elif self.recording_sign in self.header_content.children: + self.header_content.remove_widget(self.recording_sign) + self.sign_animation.cancel(self.recording_sign) + + def handle_is_replaying_change( + self: MenuAppHeader, + is_replaying: bool, # noqa: FBT001 + ) -> None: + if is_replaying: + if self.replaying_sign not in self.header_content.children: + self.header_content.add_widget(self.replaying_sign) + self.sign_animation.start(self.replaying_sign) + elif self.replaying_sign in self.header_content.children: + self.header_content.remove_widget(self.replaying_sign) + self.sign_animation.cancel(self.replaying_sign) + @cached_property def header(self: MenuAppHeader) -> Widget | None: self.header_content = RelativeLayout() @@ -115,6 +141,30 @@ def header(self: MenuAppHeader) -> Widget | None: ) self.header_content.add_widget(self.progress_layout) + self.recording_sign = Label( + text='󰑊', + font_size=dp(20), + color=(1, 0, 0, 1), + pos_hint={'right': 1}, + size_hint=(None, 1), + ) + self.recording_sign.bind(texture_size=self.recording_sign.setter('size')) + self.replaying_sign = Label( + text='󰑙', + font_size=dp(20), + color=(0, 1, 0, 1), + pos_hint={'right': 1}, + size_hint=(None, 1), + ) + self.replaying_sign.bind(texture_size=self.replaying_sign.setter('size')) + self.sign_animation = ( + Animation(opacity=1, duration=0.1) + + Animation(duration=1) + + Animation(opacity=0, duration=0.1) + + Animation(duration=0.5) + ) + self.sign_animation.repeat = True + self.notification_widgets = {} self.header_layout = BoxLayout() @@ -134,4 +184,14 @@ def header(self: MenuAppHeader) -> Widget | None: options=AutorunOptions(keep_ref=False), )(self.handle_is_header_visible_change) + store.autorun( + lambda state: state.main.is_recording, + options=AutorunOptions(keep_ref=False), + )(self.handle_is_recording_change) + + store.autorun( + lambda state: state.main.is_replaying, + options=AutorunOptions(keep_ref=False), + )(self.handle_is_replaying_change) + return self.header_layout diff --git a/ubo_app/services/000-keypad/reducer.py b/ubo_app/services/000-keypad/reducer.py new file mode 100644 index 00000000..654847ef --- /dev/null +++ b/ubo_app/services/000-keypad/reducer.py @@ -0,0 +1,190 @@ +"""Keypad reducer.""" + +from __future__ import annotations + +import math +from dataclasses import replace +from typing import TYPE_CHECKING + +from redux import ( + CombineReducerInitAction, + CompleteReducerResult, + FinishEvent, + InitializationActionError, + ReducerResult, +) + +from ubo_app.store.core.types import ( + MainEvent, + MenuChooseByIndexEvent, + MenuEvent, + MenuGoBackEvent, + MenuGoHomeEvent, + MenuScrollDirection, + MenuScrollEvent, + ReplayRecordedSequenceAction, + ScreenshotEvent, + SetMenuPathAction, + SnapshotEvent, + ToggleRecordingAction, +) +from ubo_app.store.services.audio import AudioChangeVolumeAction, AudioDevice +from ubo_app.store.services.keypad import ( + Key, + KeypadAction, + KeypadKeyPressAction, + KeypadKeyReleaseAction, + KeypadState, +) +from ubo_app.store.services.notifications import Notification, NotificationsAddAction + +if TYPE_CHECKING: + from ubo_app.store.services.audio import AudioAction + + +def reducer( + state: KeypadState | None, + action: KeypadAction, +) -> ( + ReducerResult[ + KeypadState, + AudioAction + | NotificationsAddAction + | ToggleRecordingAction + | ReplayRecordedSequenceAction, + FinishEvent | MenuEvent | MainEvent, + ] + | None +): + """Keypad reducer.""" + if state is None: + if isinstance(action, CombineReducerInitAction): + return KeypadState() + + raise InitializationActionError(action) + + if isinstance(action, KeypadKeyPressAction): + if action.pressed_keys == {action.key}: + if action.key == Key.UP and state.depth == 1: + return CompleteReducerResult( + state=state, + actions=[ + AudioChangeVolumeAction( + amount=0.05, + device=AudioDevice.OUTPUT, + ), + ], + ) + if action.key == Key.DOWN and state.depth == 1: + return CompleteReducerResult( + state=state, + actions=[ + AudioChangeVolumeAction( + amount=-0.05, + device=AudioDevice.OUTPUT, + ), + ], + ) + + if action.key == Key.L1: + return CompleteReducerResult( + state=state, + events=[MenuChooseByIndexEvent(index=0)], + ) + if action.key == Key.L2: + return CompleteReducerResult( + state=state, + events=[MenuChooseByIndexEvent(index=1)], + ) + if action.key == Key.L3: + return CompleteReducerResult( + state=state, + events=[MenuChooseByIndexEvent(index=2)], + ) + if action.key == Key.UP: + return CompleteReducerResult( + state=state, + events=[MenuScrollEvent(direction=MenuScrollDirection.UP)], + ) + if action.key == Key.DOWN: + return CompleteReducerResult( + state=state, + events=[MenuScrollEvent(direction=MenuScrollDirection.DOWN)], + ) + else: + if action.pressed_keys == {Key.HOME, Key.L1} and action.key == Key.L1: + return CompleteReducerResult( + state=state, + events=[ScreenshotEvent()], + ) + if action.pressed_keys == {Key.HOME, Key.L2} and action.key == Key.L2: + return CompleteReducerResult( + state=state, + events=[SnapshotEvent()], + ) + if action.pressed_keys == {Key.HOME, Key.L3} and action.key == Key.L3: + return CompleteReducerResult( + state=state, + actions=[ToggleRecordingAction()], + ) + if action.pressed_keys == {Key.BACK, Key.L3} and action.key == Key.L3: + return CompleteReducerResult( + state=state, + actions=[ReplayRecordedSequenceAction()], + ) + if action.pressed_keys == {Key.HOME, Key.BACK} and action.key == Key.BACK: + return CompleteReducerResult( + state=state, + events=[FinishEvent()], + ) + + # DEMO { + if action.pressed_keys == {Key.HOME, Key.UP} and action.key == Key.UP: + return CompleteReducerResult( + state=state, + actions=[ + NotificationsAddAction( + notification=Notification( + title='Test notification with progress', + content='This is a test notification with progress', + progress=0.5, + ), + ), + ], + ) + if action.pressed_keys == {Key.HOME, Key.DOWN} and action.key == Key.DOWN: + return CompleteReducerResult( + state=state, + actions=[ + NotificationsAddAction( + notification=Notification( + icon='', + title='Test notification with spinner', + content='This is a test notification with spinner', + progress=math.nan, + ), + ), + ], + ) + # DEMO } + return state + + if isinstance(action, KeypadKeyReleaseAction): + if len(action.pressed_keys) == 0: + if action.key == Key.BACK: + return CompleteReducerResult( + state=state, + events=[MenuGoBackEvent()], + ) + if action.key == Key.HOME: + return CompleteReducerResult( + state=state, + events=[MenuGoHomeEvent()], + ) + + return state + + if isinstance(action, SetMenuPathAction): + return replace(state, depth=action.depth) + + return state diff --git a/ubo_app/services/000-keypad/ubo_handle.py b/ubo_app/services/000-keypad/ubo_handle.py index a0f5d4f0..2c43b987 100644 --- a/ubo_app/services/000-keypad/ubo_handle.py +++ b/ubo_app/services/000-keypad/ubo_handle.py @@ -4,12 +4,14 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from ubo_handle import register + from ubo_handle import Service, register -def setup() -> None: +def setup(service: Service) -> None: + from reducer import reducer from setup import init_service + service.register_reducer(reducer) init_service() diff --git a/ubo_app/side_effects.py b/ubo_app/side_effects.py index b7aa2b68..b0881a01 100644 --- a/ubo_app/side_effects.py +++ b/ubo_app/side_effects.py @@ -8,6 +8,7 @@ from pathlib import Path from typing import TYPE_CHECKING +import numpy as np from redux import FinishAction, FinishEvent from ubo_app import display @@ -72,6 +73,8 @@ def write_image(image_path: Path, array: NDArray) -> None: """Write the `NDAarray` as an image to the given path.""" import png + array = np.flipud(array) + png.Writer( alpha=True, width=array.shape[0], @@ -129,9 +132,9 @@ def store_recorded_sequence(event: StoreRecordedSequenceEvent) -> None: file.write(json_dump) -def replay_recorded_sequence() -> None: +async def replay_recorded_sequence() -> None: """Replay the recorded sequence.""" - replay_actions(store, Path('recordings/active.json')) + await replay_actions(store, Path('recordings/active.json')) def setup_side_effects() -> None: diff --git a/ubo_app/store/core/reducer.py b/ubo_app/store/core/reducer.py index d2a72ed2..c3ed79aa 100644 --- a/ubo_app/store/core/reducer.py +++ b/ubo_app/store/core/reducer.py @@ -1,14 +1,12 @@ # ruff: noqa: D100, D101, D102, D103, D104, D107 from __future__ import annotations -import math from collections.abc import Sequence from dataclasses import replace from typing import cast from redux import ( CompleteReducerResult, - FinishEvent, InitAction, InitializationActionError, ReducerResult, @@ -27,13 +25,11 @@ MenuChooseByIndexEvent, MenuChooseByLabelAction, MenuChooseByLabelEvent, - MenuEvent, MenuGoBackAction, MenuGoBackEvent, MenuGoHomeAction, MenuGoHomeEvent, MenuScrollAction, - MenuScrollDirection, MenuScrollEvent, OpenApplicationAction, OpenApplicationEvent, @@ -43,30 +39,20 @@ RebootEvent, RegisterRegularAppAction, RegisterSettingAppAction, + ReplayRecordedSequenceAction, ReplayRecordedSequenceEvent, - ScreenshotEvent, + ReportReplayingDoneAction, SetAreEnclosuresVisibleAction, SetMenuPathAction, - SnapshotEvent, StoreRecordedSequenceEvent, + ToggleRecordingAction, ) -from ubo_app.store.services.audio import AudioChangeVolumeAction, AudioDevice -from ubo_app.store.services.keypad import ( - Key, - KeypadKeyPressAction, - KeypadKeyReleaseAction, -) -from ubo_app.store.services.notifications import Notification, NotificationsAddAction def reducer( state: MainState | None, action: MainAction, -) -> ReducerResult[ - MainState, - AudioChangeVolumeAction | NotificationsAddAction, - InitEvent | MenuEvent | FinishEvent | MainEvent, -]: +) -> ReducerResult[MainState, None, InitEvent | MainEvent]: from ubo_gui.menu.types import Item, Menu, SubMenuItem, menu_items if state is None: @@ -135,136 +121,32 @@ def reducer( events=[CloseApplicationEvent(application=action.application)], ) - if isinstance(action, KeypadKeyPressAction): - if action.pressed_keys == {action.key}: - if action.key == Key.UP and state.depth == 1: - return CompleteReducerResult( - state=state, - actions=[ - AudioChangeVolumeAction( - amount=0.05, - device=AudioDevice.OUTPUT, - ), - ], - ) - if action.key == Key.DOWN and state.depth == 1: - return CompleteReducerResult( - state=state, - actions=[ - AudioChangeVolumeAction( - amount=-0.05, - device=AudioDevice.OUTPUT, - ), - ], - ) - - if action.key == Key.L1: - return CompleteReducerResult( - state=state, - events=[MenuChooseByIndexEvent(index=0)], - ) - if action.key == Key.L2: - return CompleteReducerResult( - state=state, - events=[MenuChooseByIndexEvent(index=1)], - ) - if action.key == Key.L3: - return CompleteReducerResult( - state=state, - events=[MenuChooseByIndexEvent(index=2)], - ) - if action.key == Key.UP: - return CompleteReducerResult( - state=state, - events=[MenuScrollEvent(direction=MenuScrollDirection.UP)], - ) - if action.key == Key.DOWN: - return CompleteReducerResult( - state=state, - events=[MenuScrollEvent(direction=MenuScrollDirection.DOWN)], - ) - else: - if action.pressed_keys == {Key.HOME, Key.L1} and action.key == Key.L1: - return CompleteReducerResult( - state=state, - events=[ScreenshotEvent()], - ) - if action.pressed_keys == {Key.HOME, Key.L2} and action.key == Key.L2: - return CompleteReducerResult( - 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, - events=[FinishEvent()], - ) - - # DEMO { - if action.pressed_keys == {Key.HOME, Key.UP} and action.key == Key.UP: - return CompleteReducerResult( - state=state, - actions=[ - NotificationsAddAction( - notification=Notification( - title='Test notification with progress', - content='This is a test notification with progress', - progress=0.5, - ), - ), - ], - ) - if action.pressed_keys == {Key.HOME, Key.DOWN} and action.key == Key.DOWN: - return CompleteReducerResult( - state=state, - actions=[ - NotificationsAddAction( - notification=Notification( - icon='', - title='Test notification with spinner', - content='This is a test notification with spinner', - progress=math.nan, - ), - ), - ], - ) - # DEMO } - return state - - if isinstance(action, KeypadKeyReleaseAction): - if len(action.pressed_keys) == 0: - if action.key == Key.BACK: - return CompleteReducerResult( - state=state, - events=[MenuGoBackEvent()], - ) - if action.key == Key.HOME: - return CompleteReducerResult( - state=state, - events=[MenuGoHomeEvent()], - ) - - return state + if isinstance(action, ToggleRecordingAction) and not state.is_replaying: + 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 ( + isinstance(action, ReplayRecordedSequenceAction) + and not state.is_recording + and not state.is_replaying + ): + return CompleteReducerResult( + state=replace(state, is_replaying=True), + events=[ReplayRecordedSequenceEvent()], + ) + + if isinstance(action, ReportReplayingDoneAction): + return replace(state, is_replaying=False) if isinstance(action, RegisterSettingAppAction): parent_index = 1 diff --git a/ubo_app/store/core/types.py b/ubo_app/store/core/types.py index 4b773e44..7b50884e 100644 --- a/ubo_app/store/core/types.py +++ b/ubo_app/store/core/types.py @@ -174,16 +174,28 @@ class SnapshotEvent(MainEvent): """Event for taking a snapshot of the store.""" +class ToggleRecordingAction(MainAction): + """Action for toggling recording.""" + + class StoreRecordedSequenceEvent(MainEvent): """Event for storing a recorded sequence.""" recorded_sequence: list[BaseAction] +class ReplayRecordedSequenceAction(MainAction): + """Action for replaying a recorded sequence.""" + + class ReplayRecordedSequenceEvent(MainEvent): """Event for replaying a recorded sequence.""" +class ReportReplayingDoneAction(MainAction): + """Action for reporting that replaying is done.""" + + class MainState(Immutable): menu: Menu | None = None path: Sequence[str] = field(default_factory=list) @@ -192,4 +204,5 @@ class MainState(Immutable): is_footer_visible: bool = True settings_items_priorities: dict[str, int] = field(default_factory=dict) is_recording: bool = False + is_replaying: bool = False recorded_sequence: list[BaseAction] = field(default_factory=list) diff --git a/ubo_app/store/services/keypad.py b/ubo_app/store/services/keypad.py index 27f04574..8d89eb84 100644 --- a/ubo_app/store/services/keypad.py +++ b/ubo_app/store/services/keypad.py @@ -5,6 +5,7 @@ from dataclasses import field from enum import StrEnum +from immutable import Immutable from redux import BaseAction @@ -34,3 +35,7 @@ class KeypadKeyPressAction(KeypadAction): ... class KeypadKeyReleaseAction(KeypadAction): ... + + +class KeypadState(Immutable): + depth: int = 0 diff --git a/ubo_app/utils/store.py b/ubo_app/utils/store.py index 9c73b50e..43b8955d 100644 --- a/ubo_app/utils/store.py +++ b/ubo_app/utils/store.py @@ -1,20 +1,24 @@ # ruff: noqa: D100, D101, D102, D103, D104, D107 from __future__ import annotations +import asyncio import json -import time from typing import TYPE_CHECKING, Any, cast +from ubo_app.store.core.types import ReportReplayingDoneAction + if TYPE_CHECKING: from pathlib import Path from ubo_app.store.main import UboStore -def replay_actions(store: UboStore, path: Path) -> None: +async 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) + await asyncio.sleep(0.5) + await asyncio.sleep(1.5) + store.dispatch(ReportReplayingDoneAction())