diff --git a/.github/workflows/integration_delivery.yml b/.github/workflows/integration_delivery.yml index d55f1449..9024ba21 100644 --- a/.github/workflows/integration_delivery.yml +++ b/.github/workflows/integration_delivery.yml @@ -2,6 +2,10 @@ name: CI/CD on: push: + branches: + - main + tags: + - 'v*' pull_request: workflow_dispatch: @@ -373,17 +377,19 @@ jobs: name: Publish if: >- github.event_name == 'push' && github.ref == 'refs/heads/main' || - github.event_name == 'pull_request' && github.head_ref == 'main' + github.event_name == 'pull_request' && github.event.pull_request.base.ref + == 'main' needs: - type-check - lint - test - build - - images runs-on: ubuntu-latest environment: name: pypi - url: https://pypi.org/p/${{ needs.build.outputs.name }} + url: + https://pypi.org/project/${{ needs.build.outputs.name }}/${{ + needs.build.outputs.version }} permissions: id-token: write steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 60a4ac65..c69241ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - chore: remove what has remained from poetry in the codebase - refactor(core): avoid truncating or coloring logs in log files - feat(web-ui): add web-ui service +- feat(web-ui): process input demands dispatched on the bus ## Version 1.0.0 diff --git a/pyproject.toml b/pyproject.toml index 0bc9d97c..5988c0c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ dependencies = [ "platformdirs >=4.2.0", "dill >=0.3.8", "simpleaudio >=1.0.4", - "python-redux >=0.17.1", + "python-redux >=0.17.2", "python-debouncer >=0.1.5", "python-strtobool >=1.0.0", "python-fake >=0.1.3", diff --git a/setup_scm_schemes.py b/setup_scm_schemes.py index 923f54c6..c3f3044d 100644 --- a/setup_scm_schemes.py +++ b/setup_scm_schemes.py @@ -1,6 +1,6 @@ -from setuptools_scm.version import get_local_node_and_date +from setuptools_scm.version import get_local_node_and_date # pyright: ignore import re -from datetime import datetime +from datetime import datetime, timezone def local_scheme(version): @@ -11,4 +11,4 @@ def local_scheme(version): ) original_local_version = get_local_node_and_date(version) numeric_version = original_local_version.replace('+', '').replace('.d', '') - return datetime.utcnow().strftime('%y%m%d') + numeric_version + return datetime.now(timezone.utc).strftime('%y%m%d') + numeric_version diff --git a/tests/flows/results/test_wireless/wireless_flow/window-rpi-001.hash b/tests/flows/results/test_wireless/wireless_flow/window-rpi-001.hash index e6f622ae..badfa647 100644 --- a/tests/flows/results/test_wireless/wireless_flow/window-rpi-001.hash +++ b/tests/flows/results/test_wireless/wireless_flow/window-rpi-001.hash @@ -1,2 +1,2 @@ // window-rpi-001 -37ce32a12a80915276ecbf3176fdf9162f152f74a9f67e34e1df640f48e3f741 +80d7df0e74ea16d07c0fb984760f5e350ce2ecc0781f18978019ea833206e795 diff --git a/tests/flows/results/test_wireless/wireless_flow/window-rpi-002.hash b/tests/flows/results/test_wireless/wireless_flow/window-rpi-002.hash index ab41966a..3fb43e2b 100644 --- a/tests/flows/results/test_wireless/wireless_flow/window-rpi-002.hash +++ b/tests/flows/results/test_wireless/wireless_flow/window-rpi-002.hash @@ -1,2 +1,2 @@ // window-rpi-002 -37ce32a12a80915276ecbf3176fdf9162f152f74a9f67e34e1df640f48e3f741 +80d7df0e74ea16d07c0fb984760f5e350ce2ecc0781f18978019ea833206e795 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 78c02de4..13cce587 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 @@ -1106,7 +1106,10 @@ "is_pending": false, "status": null }, - "web_ui": null, + "web_ui": { + "_type": "WebUIState", + "active_inputs": [] + }, "wifi": { "_type": "WiFiState", "connections": [ 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 8a190040..eaad198f 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 @@ -1176,7 +1176,10 @@ "is_pending": false, "status": null }, - "web_ui": null, + "web_ui": { + "_type": "WebUIState", + "active_inputs": [] + }, "wifi": { "_type": "WiFiState", "connections": [], diff --git a/ubo_app/constants.py b/ubo_app/constants.py index 2326f551..c797f903 100644 --- a/ubo_app/constants.py +++ b/ubo_app/constants.py @@ -32,7 +32,7 @@ GRPC_LISTEN_HOST = os.environ.get('UBO_GRPC_LISTEN_HOST', '127.0.0.1') GRPC_LISTEN_PORT = int(os.environ.get('UBO_GRPC_LISTEN_PORT', '50051')) -WEB_UI_LISTEN_HOST = os.environ.get('UBO_WEB_UI_LISTEN_HOST', '127.0.0.1') +WEB_UI_LISTEN_HOST = os.environ.get('UBO_WEB_UI_LISTEN_HOST', '0.0.0.0') # noqa: S104 WEB_UI_LISTEN_PORT = int(os.environ.get('UBO_WEB_UI_LISTEN_PORT', '21215')) WEB_UI_DEBUG_MODE = str_to_bool(os.environ.get('UBO_WEB_UI_DEBUG_MODE', 'False')) == 1 diff --git a/ubo_app/rpc/service.py b/ubo_app/rpc/service.py index 4bc906b4..e031bef5 100644 --- a/ubo_app/rpc/service.py +++ b/ubo_app/rpc/service.py @@ -20,8 +20,7 @@ from ubo_app.rpc.generated.ubo.v1 import Event from ubo_app.rpc.message_to_object import get_class, rebuild_object, reduce_group from ubo_app.rpc.object_to_message import build_message -from ubo_app.store.main import store -from ubo_app.store.operations import UboAction, UboEvent +from ubo_app.store.main import UboAction, UboEvent, store if TYPE_CHECKING: from collections.abc import AsyncIterator diff --git a/ubo_app/services/010-voice/setup.py b/ubo_app/services/010-voice/setup.py index ac722d32..15c0b9aa 100644 --- a/ubo_app/services/010-voice/setup.py +++ b/ubo_app/services/010-voice/setup.py @@ -77,6 +77,7 @@ async def act() -> None: '.*', prompt='Convert the Picovoice access key to a QR code and ' 'scan it.', + title='Picovoice Access Key', ) )[0] secrets.write_secret(key=PICOVOICE_ACCESS_KEY, value=access_key) 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 ba02b547..301eb4f8 100644 --- a/ubo_app/services/030-wifi/pages/create_wireless_connection.py +++ b/ubo_app/services/030-wifi/pages/create_wireless_connection.py @@ -57,7 +57,7 @@ async def create_wireless_connection(self: CreateWirelessConnectionPage) -> None try: _, match = await qrcode_input( BARCODE_PATTERN, - prompt='Scan WiFi QR-Code With Front Camera', + prompt='Enter WiFi connection', extra_information=NotificationExtraInformation( text='Go to your phone settings, choose QR code and hold it in ' 'front of the camera to scan it.', diff --git a/ubo_app/services/040-camera/reducer.py b/ubo_app/services/040-camera/reducer.py index be5123bc..283cfdfb 100644 --- a/ubo_app/services/040-camera/reducer.py +++ b/ubo_app/services/040-camera/reducer.py @@ -11,20 +11,21 @@ ReducerResult, ) +from ubo_app.store.operations import ( + InputDemandAction, + InputProvideAction, + InputProvideEvent, +) from ubo_app.store.services.camera import ( - CameraAction, - CameraBarcodeEvent, CameraEvent, CameraReportBarcodeAction, - CameraStartViewfinderAction, CameraStartViewfinderEvent, CameraState, CameraStopViewfinderEvent, - InputDescription, ) from ubo_app.store.services.keypad import Key, KeypadKeyPressAction -Action = InitAction | CameraAction +Action = InitAction | InputDemandAction def pop_queue(state: CameraState) -> CameraState: @@ -41,28 +42,45 @@ def pop_queue(state: CameraState) -> CameraState: def reducer( state: CameraState | None, action: Action, -) -> ReducerResult[CameraState, Action, CameraEvent]: +) -> ReducerResult[CameraState, Action, CameraEvent | InputProvideEvent]: if state is None: if isinstance(action, InitAction): return CameraState(is_viewfinder_active=False, queue=[]) raise InitializationActionError(action) - if isinstance(action, CameraStartViewfinderAction): + if isinstance(action, InputDemandAction): if state.is_viewfinder_active: return replace( state, queue=[ *state.queue, - InputDescription(id=action.id, pattern=action.pattern), + action.description, ], ) return CompleteReducerResult( state=replace( state, is_viewfinder_active=True, - current=InputDescription(id=action.id, pattern=action.pattern), + current=action.description, ), - events=[CameraStartViewfinderEvent(pattern=action.pattern)], + events=[CameraStartViewfinderEvent(pattern=action.description.pattern)], + ) + + if isinstance(action, InputProvideAction): + if state.current and state.current.id == action.id: + return CompleteReducerResult( + state=pop_queue(state), + events=[ + CameraStopViewfinderEvent(id=state.current.id), + ], + ) + return replace( + state, + queue=[ + description + for description in state.queue + if description.id != action.id + ], ) if isinstance(action, CameraReportBarcodeAction) and state.current: @@ -73,10 +91,10 @@ def reducer( return CompleteReducerResult( state=pop_queue(state), events=[ - CameraBarcodeEvent( + InputProvideEvent( id=state.current.id, - code=code, - group_dict=match.groupdict(), + value=code, + data=match.groupdict(), ), CameraStopViewfinderEvent(id=None), ], @@ -85,10 +103,10 @@ def reducer( return CompleteReducerResult( state=pop_queue(state), events=[ - CameraBarcodeEvent( + InputProvideEvent( id=state.current.id, - code=code, - group_dict=None, + value=code, + data=None, ), CameraStopViewfinderEvent(id=None), ], diff --git a/ubo_app/services/040-camera/setup.py b/ubo_app/services/040-camera/setup.py index ad6f4c36..0a4ddcd2 100644 --- a/ubo_app/services/040-camera/setup.py +++ b/ubo_app/services/040-camera/setup.py @@ -12,7 +12,6 @@ import png from debouncer import DebounceOptions, debounce from kivy.clock import Clock, mainthread -from pyzbar.pyzbar import decode from typing_extensions import override from ubo_gui.page import PageWidget @@ -115,6 +114,8 @@ def feed_viewfinder(picamera2: Picamera2 | None) -> None: data = None if data is not None: + from pyzbar.pyzbar import decode + barcodes = decode(data) if len(barcodes) > 0: create_task( diff --git a/ubo_app/services/050-vscode/commands.py b/ubo_app/services/050-vscode/commands.py index acd51c49..016b2cb4 100644 --- a/ubo_app/services/050-vscode/commands.py +++ b/ubo_app/services/050-vscode/commands.py @@ -102,7 +102,7 @@ async def check_status() -> None: ), ), ) - logger.info( + logger.debug( 'Checked VSCode Tunnel Status', extra={ 'status': status_data, diff --git a/ubo_app/services/080-docker/setup.py b/ubo_app/services/080-docker/setup.py index 3ca7ce87..7bfc6b1f 100644 --- a/ubo_app/services/080-docker/setup.py +++ b/ubo_app/services/080-docker/setup.py @@ -5,7 +5,7 @@ import asyncio import contextlib import functools -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast import docker import docker.errors @@ -210,8 +210,10 @@ async def act() -> None: try: credentials = ( await qrcode_input( - r'^[^|]*\|[^|]*\|[^|]*$|^[^|]*|[^|]*$', + r'^(?P[^|]*)\|(?P[^|]*)\|(?P[^|]*)$|' + r'(?P^[^|]*)|(?P[^|]*)$', prompt='Format: [i]SERVICE|USERNAME|PASSWORD[/i]', + title='Enter Docker Credentials', extra_information=NotificationExtraInformation( text="""To generate your QR code for login, format your \ details by separating your service, username, and password with the pipe symbol. For \ @@ -231,15 +233,15 @@ async def act() -> None: default.""", ), ) - )[0] - if credentials.count('|') == 1: - username, password = credentials.split('|') - registry = 'docker.io' - else: - registry, username, password = credentials.split('|') - registry = registry.strip() - username = username.strip() - password = password.strip() + )[1] + if not credentials: + return + username = credentials.get('Username', credentials.get('Username_', '')) + password = credentials.get('Password', credentials.get('Password_', '')) + registry = credentials.get('Service', 'docker.io') + username = cast(str, username).strip() + password = cast(str, password).strip() + registry = cast(str, registry).strip() docker_client = docker.from_env() docker_client.login( username=username, diff --git a/ubo_app/services/090-web-ui/reducer.py b/ubo_app/services/090-web-ui/reducer.py index 657b9dd8..c5b1e6b4 100644 --- a/ubo_app/services/090-web-ui/reducer.py +++ b/ubo_app/services/090-web-ui/reducer.py @@ -1,15 +1,45 @@ # ruff: noqa: D100, D101, D102, D103, D104, D107 from __future__ import annotations +from dataclasses import replace from typing import TYPE_CHECKING +from redux import InitAction, InitializationActionError + +from ubo_app.store.operations import ( + InputAction, + InputDemandAction, + InputProvideAction, +) +from ubo_app.store.services.web_ui import WebUIState + if TYPE_CHECKING: - from redux import BaseAction, ReducerResult + from redux import ReducerResult def reducer( - state: None, - action: BaseAction, -) -> ReducerResult[None, None, None]: - _ = action + state: WebUIState | None, + action: InputAction, +) -> WebUIState | ReducerResult[WebUIState, None, None]: + if state is None: + if isinstance(action, InitAction): + return WebUIState(active_inputs=[]) + raise InitializationActionError(action) + + if isinstance(action, InputDemandAction): + return replace( + state, + active_inputs=[*state.active_inputs, action.description], + ) + + if isinstance(action, InputProvideAction): + return replace( + state, + active_inputs=[ + description + for description in state.active_inputs + if description.id != action.id + ], + ) + return state diff --git a/ubo_app/services/090-web-ui/setup.py b/ubo_app/services/090-web-ui/setup.py index a632f372..8c4ed67b 100644 --- a/ubo_app/services/090-web-ui/setup.py +++ b/ubo_app/services/090-web-ui/setup.py @@ -1,24 +1,53 @@ """Implementation of the web-ui service.""" import asyncio +import re from pathlib import Path -from quart import Quart +from quart import Quart, render_template, request from redux import FinishEvent from ubo_app.constants import WEB_UI_DEBUG_MODE, WEB_UI_LISTEN_HOST, WEB_UI_LISTEN_PORT from ubo_app.store.main import store +from ubo_app.store.operations import InputDescription, InputProvideEvent async def init_service() -> None: """Initialize the web-ui service.""" - app = Quart('ubo-app') - app.debug = False + app = Quart( + 'ubo-app', + template_folder=(Path(__file__).parent / 'templates').absolute().as_posix(), + ) + app.debug = WEB_UI_DEBUG_MODE shutdown_event: asyncio.Event = asyncio.Event() - @app.get('/') - async def hello_world() -> str: - return (Path(__file__).parent / 'static' / 'index.html').read_text() + @store.view(lambda state: state.web_ui.active_inputs) + def inputs(inputs: list[InputDescription]) -> list[InputDescription]: + return inputs + + @app.route('/', methods=['GET', 'POST']) + async def inputs_form() -> str: + if request.method == 'POST': + data = dict(await request.form) + id = data.pop('id') + value = data.pop('value', '') + store.dispatch( + InputProvideEvent( + id=id, + value=value, + data=data, + ), + ) + await asyncio.sleep(0.2) + return await render_template('index.jinja2', inputs=inputs(), re=re) + + if WEB_UI_DEBUG_MODE: + + @app.errorhandler(Exception) + async def handle_error(_: Exception) -> str: + import traceback + + return f'
{traceback.format_exc()}
' store.subscribe_event(FinishEvent, shutdown_event.set) diff --git a/ubo_app/services/090-web-ui/static/index.html b/ubo_app/services/090-web-ui/static/index.html deleted file mode 100644 index 5c511233..00000000 --- a/ubo_app/services/090-web-ui/static/index.html +++ /dev/null @@ -1,8 +0,0 @@ - -ubo - web-ui - - -

Hello World!

- - - diff --git a/ubo_app/services/090-web-ui/templates/index.jinja2 b/ubo_app/services/090-web-ui/templates/index.jinja2 new file mode 100644 index 00000000..5cf4f6a9 --- /dev/null +++ b/ubo_app/services/090-web-ui/templates/index.jinja2 @@ -0,0 +1,39 @@ + + + + + ubo - web-ui + + + + {% for input in inputs %} +
+ +

+

+ +

+ {% endfor %} + {% else %} + + {% endif %} + +

+ + {% if not loop.last %} +
+ {% endif %} +
+ {% endfor %} + + diff --git a/ubo_app/store/dispatch_action.py b/ubo_app/store/dispatch_action.py index c6afda6a..5a037a13 100644 --- a/ubo_app/store/dispatch_action.py +++ b/ubo_app/store/dispatch_action.py @@ -11,7 +11,7 @@ if TYPE_CHECKING: from collections.abc import Callable - from ubo_app.store.operations import UboAction, UboEvent + from ubo_app.store.main import UboAction, UboEvent def _default_action() -> Callable[[], None]: diff --git a/ubo_app/store/main.py b/ubo_app/store/main.py index 539f5e6a..42b228c3 100644 --- a/ubo_app/store/main.py +++ b/ubo_app/store/main.py @@ -9,14 +9,16 @@ from datetime import datetime from pathlib import Path from types import GenericAlias -from typing import TYPE_CHECKING, Any, TypeVar, cast, get_origin, overload +from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar, cast, get_origin, overload import dill from fake import Fake from immutable import Immutable from redux import ( BaseCombineReducerState, + CombineReducerAction, CreateStoreOptions, + FinishAction, FinishEvent, InitAction, Store, @@ -25,9 +27,36 @@ from ubo_app.constants import DEBUG_MODE, STORE_GRACE_PERIOD from ubo_app.logging import logger +from ubo_app.store.core import MainAction, MainEvent from ubo_app.store.core.reducer import reducer as main_reducer -from ubo_app.store.operations import UboAction, UboEvent +from ubo_app.store.operations import ( + InputDemandAction, + InputProvideEvent, + ScreenshotEvent, + SnapshotEvent, +) +from ubo_app.store.services.audio import AudioAction, AudioEvent +from ubo_app.store.services.camera import CameraAction, CameraEvent +from ubo_app.store.services.display import DisplayAction, DisplayEvent +from ubo_app.store.services.docker import DockerAction +from ubo_app.store.services.ip import IpAction, IpEvent +from ubo_app.store.services.keypad import KeypadAction, KeypadEvent +from ubo_app.store.services.lightdm import LightDMAction +from ubo_app.store.services.notifications import ( + NotificationsAction, + NotificationsEvent, +) +from ubo_app.store.services.rgb_ring import RgbRingAction +from ubo_app.store.services.rpi_connect import RPiConnectAction +from ubo_app.store.services.sensors import SensorsAction +from ubo_app.store.services.ssh import SSHAction +from ubo_app.store.services.users import UsersAction, UsersEvent +from ubo_app.store.services.voice import VoiceAction +from ubo_app.store.services.vscode import VSCodeAction +from ubo_app.store.services.wifi import WiFiAction, WiFiEvent +from ubo_app.store.status_icons import StatusIconsAction from ubo_app.store.status_icons.reducer import reducer as status_icons_reducer +from ubo_app.store.update_manager import UpdateManagerAction from ubo_app.store.update_manager.reducer import reducer as update_manager_reducer from ubo_app.utils.serializer import add_type_field @@ -51,10 +80,55 @@ from ubo_app.store.services.users import UsersState from ubo_app.store.services.voice import VoiceState from ubo_app.store.services.vscode import VSCodeState + from ubo_app.store.services.web_ui import WebUIState from ubo_app.store.services.wifi import WiFiState from ubo_app.store.status_icons import StatusIconsState from ubo_app.store.update_manager import UpdateManagerState +UboAction: TypeAlias = ( + # Core Actions + CombineReducerAction + | StatusIconsAction + | UpdateManagerAction + | MainAction + | InputDemandAction + | InitAction + | FinishAction + # Services Actions + | AudioAction + | CameraAction + | DisplayAction + | DockerAction + | IpAction + | KeypadAction + | LightDMAction + | NotificationsAction + | RgbRingAction + | RPiConnectAction + | SensorsAction + | SSHAction + | UsersAction + | VoiceAction + | VSCodeAction + | WiFiAction +) +UboEvent: TypeAlias = ( + # Core Events + MainEvent + | ScreenshotEvent + | InputProvideEvent + # Services Events + | AudioEvent + | CameraEvent + | DisplayEvent + | IpEvent + | KeypadEvent + | NotificationsEvent + | SnapshotEvent + | UsersEvent + | WiFiEvent +) + if threading.current_thread() is not threading.main_thread(): msg = 'Store should be created in the main thread' raise RuntimeError(msg) @@ -89,6 +163,7 @@ class RootState(BaseCombineReducerState): users: UsersState voice: VoiceState vscode: VSCodeState + web_ui: WebUIState wifi: WiFiState diff --git a/ubo_app/store/operations.py b/ubo_app/store/operations.py index fdb426b6..0ffe0aa9 100644 --- a/ubo_app/store/operations.py +++ b/ubo_app/store/operations.py @@ -2,37 +2,8 @@ from __future__ import annotations -from typing import TypeAlias - -from redux import ( - BaseEvent, - CombineReducerAction, - FinishAction, - InitAction, -) - -from ubo_app.store.core import MainAction, MainEvent -from ubo_app.store.services.audio import AudioAction, AudioEvent -from ubo_app.store.services.camera import CameraAction, CameraEvent -from ubo_app.store.services.display import DisplayAction, DisplayEvent -from ubo_app.store.services.docker import DockerAction -from ubo_app.store.services.ip import IpAction, IpEvent -from ubo_app.store.services.keypad import KeypadAction, KeypadEvent -from ubo_app.store.services.lightdm import LightDMAction -from ubo_app.store.services.notifications import ( - NotificationsAction, - NotificationsEvent, -) -from ubo_app.store.services.rgb_ring import RgbRingAction -from ubo_app.store.services.rpi_connect import RPiConnectAction -from ubo_app.store.services.sensors import SensorsAction -from ubo_app.store.services.ssh import SSHAction -from ubo_app.store.services.users import UsersAction, UsersEvent -from ubo_app.store.services.voice import VoiceAction -from ubo_app.store.services.vscode import VSCodeAction -from ubo_app.store.services.wifi import WiFiAction, WiFiEvent -from ubo_app.store.status_icons import StatusIconsAction -from ubo_app.store.update_manager import UpdateManagerAction +from immutable import Immutable +from redux import BaseAction, BaseEvent class ScreenshotEvent(BaseEvent): @@ -43,44 +14,35 @@ class SnapshotEvent(BaseEvent): """Event for taking a snapshot of the store.""" -UboAction: TypeAlias = ( - # Core Actions - CombineReducerAction - | StatusIconsAction - | UpdateManagerAction - | MainAction - | InitAction - | FinishAction - # Services Actions - | AudioAction - | CameraAction - | DisplayAction - | DockerAction - | IpAction - | KeypadAction - | LightDMAction - | NotificationsAction - | RgbRingAction - | RPiConnectAction - | SensorsAction - | SSHAction - | UsersAction - | VoiceAction - | VSCodeAction - | WiFiAction -) -UboEvent: TypeAlias = ( - # Core Events - MainEvent - | ScreenshotEvent - # Services Events - | AudioEvent - | CameraEvent - | DisplayEvent - | IpEvent - | KeypadEvent - | NotificationsEvent - | SnapshotEvent - | UsersEvent - | WiFiEvent -) +class InputDescription(Immutable): + """Description of an input demand.""" + + title: str + id: str + pattern: str | None + + +class InputAction(BaseAction): + """Base class for input actions.""" + + +class InputDemandAction(InputAction): + """Action for demanding input from the user.""" + + description: InputDescription + + +class InputProvideAction(InputAction): + """Action for reporting input from the user.""" + + id: str + value: str + data: dict[str, str | None] | None + + +class InputProvideEvent(BaseEvent): + """Event for reporting input from the user.""" + + id: str + value: str + data: dict[str, str | None] | None diff --git a/ubo_app/store/services/camera.py b/ubo_app/store/services/camera.py index ab710b2f..4dc8f7ba 100644 --- a/ubo_app/store/services/camera.py +++ b/ubo_app/store/services/camera.py @@ -1,16 +1,20 @@ # ruff: noqa: D100, D101, D102, D103, D104, D107, N999 from __future__ import annotations +from typing import TYPE_CHECKING + from immutable import Immutable from redux import BaseAction, BaseEvent +if TYPE_CHECKING: + from ubo_app.store.operations import InputDescription + class CameraAction(BaseAction): ... -class CameraStartViewfinderAction(CameraAction): - id: str - pattern: str | None +class CameraReportBarcodeAction(CameraAction): + codes: list[str] class CameraEvent(BaseEvent): ... @@ -24,21 +28,6 @@ class CameraStopViewfinderEvent(CameraEvent): id: str | None -class CameraReportBarcodeAction(CameraAction): - codes: list[str] - - -class CameraBarcodeEvent(CameraEvent): - id: str | None - code: str - group_dict: dict[str, str | None] | None - - -class InputDescription(Immutable): - id: str - pattern: str | None - - class CameraState(Immutable): current: InputDescription | None = None is_viewfinder_active: bool diff --git a/ubo_app/store/services/web-ui.py b/ubo_app/store/services/web-ui.py deleted file mode 100644 index f97a28c0..00000000 --- a/ubo_app/store/services/web-ui.py +++ /dev/null @@ -1,7 +0,0 @@ -# ruff: noqa: D100, D101, D102, D103, D104, D107, N999 -from __future__ import annotations - -from immutable import Immutable - - -class WebUIState(Immutable): ... diff --git a/ubo_app/store/services/web_ui.py b/ubo_app/store/services/web_ui.py new file mode 100644 index 00000000..ac3dff71 --- /dev/null +++ b/ubo_app/store/services/web_ui.py @@ -0,0 +1,13 @@ +# ruff: noqa: D100, D101, D102, D103, D104, D107, N999 +from __future__ import annotations + +from typing import TYPE_CHECKING + +from immutable import Immutable + +if TYPE_CHECKING: + from ubo_app.store.operations import InputDescription + + +class WebUIState(Immutable): + active_inputs: list[InputDescription] diff --git a/ubo_app/utils/qrcode.py b/ubo_app/utils/qrcode.py index 5f5c8edb..59d787e2 100644 --- a/ubo_app/utils/qrcode.py +++ b/ubo_app/utils/qrcode.py @@ -11,11 +11,12 @@ from typing_extensions import TypeVar from ubo_app.store.main import store -from ubo_app.store.services.camera import ( - CameraBarcodeEvent, - CameraStartViewfinderAction, - CameraStopViewfinderEvent, +from ubo_app.store.operations import ( + InputDemandAction, + InputDescription, + InputProvideEvent, ) +from ubo_app.store.services.camera import CameraStopViewfinderEvent from ubo_app.store.services.notifications import ( Notification, NotificationActionItem, @@ -70,7 +71,7 @@ async def qrcode_input( notification=Notification( id='qrcode', icon='󰄀󰐲', - title='QR Code' if title is None else title, + title='QR Code', content=f'[size=18dp]{prompt}[/size]', display_type=NotificationDisplayType.STICKY, is_read=True, @@ -99,40 +100,60 @@ async def qrcode_input( await notification_future + subscriptions: set[Callable[[], None]] = set() future: Future[tuple[str, QrCodeGroupDict]] = loop.create_future() - def handle_barcode_event(event: CameraBarcodeEvent) -> None: + def unsubscribe() -> None: + for subscription in subscriptions: + subscription() + + def handle_barcode_event(event: InputProvideEvent) -> None: + unsubscribe() if event.id == prompt_id: from kivy.utils import get_color_from_hex - loop.call_soon_threadsafe(future.set_result, (event.code, event.group_dict)) + loop.call_soon_threadsafe( + future.set_result, + (event.value, event.data), + ) kivy_color = get_color_from_hex('#21E693') + color = tuple(round(c * 255) for c in kivy_color[:3]) store.dispatch( RgbRingBlinkAction( - color=( - round(kivy_color[0] * 255), - round(kivy_color[1] * 255), - round(kivy_color[2] * 255), - ), + color=color, repetitions=1, wait=200, ), ) def handle_cancel(event: CameraStopViewfinderEvent) -> None: + unsubscribe() if event.id == prompt_id: loop.call_soon_threadsafe(future.cancel) - store.subscribe_event( - CameraBarcodeEvent, - handle_barcode_event, - keep_ref=False, + subscriptions.add( + store.subscribe_event( + InputProvideEvent, + handle_barcode_event, + keep_ref=False, + ), ) - store.subscribe_event( - CameraStopViewfinderEvent, - handle_cancel, + subscriptions.add( + store.subscribe_event( + CameraStopViewfinderEvent, + handle_cancel, + keep_ref=False, + ), + ) + store.dispatch( + InputDemandAction( + description=InputDescription( + title=title or prompt or 'Untitled input', + id=prompt_id, + pattern=pattern, + ), + ), ) - store.dispatch(CameraStartViewfinderAction(id=prompt_id, pattern=pattern)) result = await future diff --git a/uv.lock b/uv.lock index ae1acfa9..93d2e266 100644 --- a/uv.lock +++ b/uv.lock @@ -938,6 +938,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c3/b1/d1ca22a7b18e7b2b90152a78a0c2d09a96fdb924f87be1914d70d9bee543/kivy_deps.angle-0.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:574381d4e66f3198bc48aa10f238e7a3816ad56b80ec939f5d56fb33a378d0b1", size = 5130936 }, { url = "https://files.pythonhosted.org/packages/c1/89/bb8b9a0fee422972fcf38a406ee9d0b1636968d7d2b5e97aafea8fdec251/kivy_deps.angle-0.4.0-cp312-cp312-win32.whl", hash = "sha256:4fa7a6366899fba13f7624baf4645787165f45731db08d14557da29c12ee48f0", size = 4588969 }, { url = "https://files.pythonhosted.org/packages/c7/f2/d1500b880d3079454af0f935408ddd37cfce4fd11f53d0917e169d478869/kivy_deps.angle-0.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:668e670d4afd2551af0af2c627ceb0feac884bd799fb6a3dff78fdbfa2ea0451", size = 5130935 }, + { url = "https://files.pythonhosted.org/packages/47/7e/ad805773fb76f07cb1bdf5147e66ba264a94f5ac54553cd9dee809a161bb/kivy_deps.angle-0.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:9afbf702f8bb9a993c48f39c018ca3b4d2ec381a5d3f82fe65bdaa6af0bba29b", size = 5133260 }, ] [[package]] @@ -949,6 +950,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d6/37/884034260818569547347cc2ba89780ff3f83a9ce6b9a894360c1d86e82c/kivy_deps.glew-0.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:22e155ec59ce717387f5d8804811206d200a023ba3d0bc9bbf1393ee28d0053e", size = 123574 }, { url = "https://files.pythonhosted.org/packages/2b/3b/a960053dccd627e4483db4765fa84318a831cbf3af648aee20297ae56815/kivy_deps.glew-0.3.1-cp312-cp312-win32.whl", hash = "sha256:b64ee4e445a04bc7c848c0261a6045fc2f0944cc05d7f953e3860b49f2703424", size = 126458 }, { url = "https://files.pythonhosted.org/packages/ad/3a/37a0a051dd3c7298d9e149a489457a6196665444c1a1473ad4fa617e05af/kivy_deps.glew-0.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:3acbbd30da05fc10c185b5d4bb75fbbc882a6ef2192963050c1c94d60a6e795a", size = 123573 }, + { url = "https://files.pythonhosted.org/packages/21/99/e3478c34afed7a820b3348ce7fefc53f2034fa340348dca57162695e69d9/kivy_deps.glew-0.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:f4aa8322078359862ccd9e16e5cea61976d75fb43125d87922e20c916fa31a11", size = 123595 }, ] [[package]] @@ -1542,7 +1544,7 @@ wheels = [ [[package]] name = "python-redux" -version = "0.17.1" +version = "0.17.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyright" }, @@ -1550,9 +1552,9 @@ dependencies = [ { name = "python-strtobool" }, { name = "ruff" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/aa/80/f8f508233455dad8edc239dd37fc65d0587f3a8203d4147e7c03119c90a9/python_redux-0.17.1.tar.gz", hash = "sha256:e16aaa434f09a1b0e064958458a0f7376f2e0c306718d8f79df38cb8286db5b3", size = 22677 } +sdist = { url = "https://files.pythonhosted.org/packages/26/e0/476b776d5410d97bf57d069240385b4ce0ec9e6e1c972ef6946cd3a46b27/python_redux-0.17.2.tar.gz", hash = "sha256:185e491390c6ae4eecf1e6f3c34cab25bed21667bc4ca49e02a94494ae497bf3", size = 22940 } wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/ff/305aaeb1ef73cf25861a16428db9e83112886193d174806dc55e16d21298/python_redux-0.17.1-py3-none-any.whl", hash = "sha256:f98f9c483172786f7a8e762f98f63d0afaa7466025a8f7aab7762d76f174eedd", size = 25059 }, + { url = "https://files.pythonhosted.org/packages/49/47/a9de36c30dfc0aa8e30d5fbd58aa9152fba47dd0a49f17193068fa1bf50f/python_redux-0.17.2-py3-none-any.whl", hash = "sha256:c10d505ac1861e0e221cf694268f207f7fd15f1d0d73d1d2b25e31425f7fe880", size = 25390 }, ] [[package]] @@ -1873,7 +1875,7 @@ wheels = [ [[package]] name = "ubo-app" -version = "0.17.2.dev4+unknown" +version = "1.0.1.dev6+unknown" source = { editable = "." } dependencies = [ { name = "adafruit-circuitpython-aw9523" }, @@ -1950,7 +1952,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.1" }, + { name = "python-redux", specifier = ">=0.17.2" }, { name = "python-strtobool", specifier = ">=1.0.0" }, { name = "pyzbar", specifier = ">=0.1.9" }, { name = "quart", specifier = ">=0.19.6" },