From b1962a03d6343fe057b35dfafbb5496ce804689d Mon Sep 17 00:00:00 2001 From: Sassan Haradji Date: Tue, 7 Jan 2025 19:27:41 +0400 Subject: [PATCH] feat: setup a wifi hotspot for when the a web-ui input is demanded and the device is not connected to any network - closes #169 --- CHANGELOG.md | 1 + README.md | 3 + pyproject.toml | 1 + .../wireless_flow/window-rpi-001.hash | 2 +- .../wireless_flow/window-rpi-003.hash | 2 +- .../wireless_flow/window-rpi-004.hash | 2 +- .../wireless_flow/window-rpi-005.hash | 2 +- .../wireless_flow/window-rpi-006.hash | 2 +- .../wireless_flow/window-rpi-007.hash | 2 +- .../wireless_flow/window-rpi-008.hash | 2 +- .../wireless_flow/window-rpi-009.hash | 2 + tests/flows/test_wireless.py | 9 ++ ubo_app/logging.py | 5 +- ubo_app/menu_app/menu_notification_handler.py | 4 +- ubo_app/services/010-voice/reducer.py | 4 +- ubo_app/services/010-voice/setup.py | 18 +-- .../pages/create_wireless_connection.py | 6 +- ubo_app/services/030-wifi/setup.py | 4 +- ubo_app/services/050-rpi-connect/commands.py | 4 +- ubo_app/services/050-users/setup.py | 8 +- ubo_app/services/080-docker/docker_images.py | 7 +- ubo_app/services/080-docker/menus.py | 4 +- ubo_app/services/080-docker/setup.py | 6 +- ubo_app/services/090-web-ui/reducer.py | 45 ++----- ubo_app/services/090-web-ui/setup.py | 112 ++++++++++++++++++ ubo_app/store/core/reducer.py | 2 - ubo_app/store/input/types.py | 4 +- ubo_app/store/services/notifications.py | 10 +- ubo_app/store/services/voice.py | 36 +++++- ubo_app/store/services/web_ui.py | 11 ++ ubo_app/store/update_manager/utils.py | 6 +- ubo_app/system/install.sh | 3 + ubo_app/system/system_manager/hotspot.py | 42 +++++++ ubo_app/system/system_manager/main.py | 2 + ubo_app/system/system_manager/reset_button.py | 15 ++- ubo_app/utils/input.py | 27 ++--- ubo_app/utils/log_process.py | 4 +- ubo_app/utils/server.py | 5 +- uv.lock | 10 +- 39 files changed, 314 insertions(+), 120 deletions(-) create mode 100644 tests/flows/results/test_wireless/wireless_flow/window-rpi-009.hash create mode 100644 ubo_app/system/system_manager/hotspot.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4185223d..6d7e984e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - fix: pass raw bytes to `DisplayRenderEvent` and `DisplayCompressedRenderEvent` to avoid encoding issues - fix: add ".local" to hostname in users menus - closes #134 - fix: use stdout instead of stderr for reading rpi-connect process output - closes #174 +- feat: setup a wifi hotspot for when the a web-ui input is demanded and the device is not connected to any network - closes #169 ## Version 1.1.0 diff --git a/README.md b/README.md index 4456df5b..a8f8df12 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,10 @@ curl -sSL https://raw.githubusercontent.com/ubopod/ubo-app/main/ubo_app/system/i Note that as part of the installation process, these debian packages are installed: - accountsservice +- dhcpcd +- dnsmasq - git +- hostapd - i2c-tools - libasound2-dev - libcap-dev diff --git a/pyproject.toml b/pyproject.toml index ca2d3e94..4f201965 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ dependencies = [ "gpiozero >=2.0.1 ; platform_machine != 'aarch64'", "quart >=0.19.6", "pythonping>=1.1.4", + "netifaces>=0.11.0", ] [build-system] 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 96e4f6da..5402e33e 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 -c680100b9cf09561e1adb62abfa8eb985a00c0468b392a6a1e772921807b5878 +9f05a21f8d8a3ac3b65e10d2a3402c413b93cce20b27dcdf72b41abd9a6d6a4e diff --git a/tests/flows/results/test_wireless/wireless_flow/window-rpi-003.hash b/tests/flows/results/test_wireless/wireless_flow/window-rpi-003.hash index 42f72b97..2c26e83a 100644 --- a/tests/flows/results/test_wireless/wireless_flow/window-rpi-003.hash +++ b/tests/flows/results/test_wireless/wireless_flow/window-rpi-003.hash @@ -1,2 +1,2 @@ // window-rpi-003 -0f13319443f4340c4fc4b753226ba6e1605e569189dc8d58e403b4bc21c4c32a +c680100b9cf09561e1adb62abfa8eb985a00c0468b392a6a1e772921807b5878 diff --git a/tests/flows/results/test_wireless/wireless_flow/window-rpi-004.hash b/tests/flows/results/test_wireless/wireless_flow/window-rpi-004.hash index 0237597c..20142925 100644 --- a/tests/flows/results/test_wireless/wireless_flow/window-rpi-004.hash +++ b/tests/flows/results/test_wireless/wireless_flow/window-rpi-004.hash @@ -1,2 +1,2 @@ // window-rpi-004 -c26e7c4aaeb369597c6cb9426df8f0a5bb6e4fb899dfec04634ff7c2e4b747b1 +0f13319443f4340c4fc4b753226ba6e1605e569189dc8d58e403b4bc21c4c32a diff --git a/tests/flows/results/test_wireless/wireless_flow/window-rpi-005.hash b/tests/flows/results/test_wireless/wireless_flow/window-rpi-005.hash index d8d89756..82b8a9fa 100644 --- a/tests/flows/results/test_wireless/wireless_flow/window-rpi-005.hash +++ b/tests/flows/results/test_wireless/wireless_flow/window-rpi-005.hash @@ -1,2 +1,2 @@ // window-rpi-005 -f2a748ff52bd9c34a298ab624ff9a474ecae5883842941aca8ee9559cd92a6d7 +c26e7c4aaeb369597c6cb9426df8f0a5bb6e4fb899dfec04634ff7c2e4b747b1 diff --git a/tests/flows/results/test_wireless/wireless_flow/window-rpi-006.hash b/tests/flows/results/test_wireless/wireless_flow/window-rpi-006.hash index 85c47545..cbb05dd0 100644 --- a/tests/flows/results/test_wireless/wireless_flow/window-rpi-006.hash +++ b/tests/flows/results/test_wireless/wireless_flow/window-rpi-006.hash @@ -1,2 +1,2 @@ // window-rpi-006 -c26e7c4aaeb369597c6cb9426df8f0a5bb6e4fb899dfec04634ff7c2e4b747b1 +f2a748ff52bd9c34a298ab624ff9a474ecae5883842941aca8ee9559cd92a6d7 diff --git a/tests/flows/results/test_wireless/wireless_flow/window-rpi-007.hash b/tests/flows/results/test_wireless/wireless_flow/window-rpi-007.hash index 8e6b96ea..fdfb9429 100644 --- a/tests/flows/results/test_wireless/wireless_flow/window-rpi-007.hash +++ b/tests/flows/results/test_wireless/wireless_flow/window-rpi-007.hash @@ -1,2 +1,2 @@ // window-rpi-007 -12e31e10eadf55d4b824e563c2c18f2705d7261c6d401b6001d2b2e5ba92c808 +c26e7c4aaeb369597c6cb9426df8f0a5bb6e4fb899dfec04634ff7c2e4b747b1 diff --git a/tests/flows/results/test_wireless/wireless_flow/window-rpi-008.hash b/tests/flows/results/test_wireless/wireless_flow/window-rpi-008.hash index aae3bb0d..9e0b0da2 100644 --- a/tests/flows/results/test_wireless/wireless_flow/window-rpi-008.hash +++ b/tests/flows/results/test_wireless/wireless_flow/window-rpi-008.hash @@ -1,2 +1,2 @@ // window-rpi-008 -a47eeb0c63e483413e618f7c05dbcf97e832c945a0282876a4ee8c4b70767227 +12e31e10eadf55d4b824e563c2c18f2705d7261c6d401b6001d2b2e5ba92c808 diff --git a/tests/flows/results/test_wireless/wireless_flow/window-rpi-009.hash b/tests/flows/results/test_wireless/wireless_flow/window-rpi-009.hash new file mode 100644 index 00000000..88503379 --- /dev/null +++ b/tests/flows/results/test_wireless/wireless_flow/window-rpi-009.hash @@ -0,0 +1,2 @@ +// window-rpi-009 +a47eeb0c63e483413e618f7c05dbcf97e832c945a0282876a4ee8c4b70767227 diff --git a/tests/flows/test_wireless.py b/tests/flows/test_wireless.py index 7e279add..8c43ff24 100644 --- a/tests/flows/test_wireless.py +++ b/tests/flows/test_wireless.py @@ -113,11 +113,20 @@ def check_icon(expected_icon: str) -> None: # Select "Add" to add a new connection store.dispatch(MenuChooseByLabelAction(label='Add')) await stability() + + # Input method selection should be shown window_snapshot.take() # Set QR Code image of the WiFi credentials before camera is started camera.set_image('qrcode/wifi') + # Select "QR code" input method + store.dispatch(MenuChooseByIconAction(icon='󰄀')) + await stability() + + # QR code instructions should be shown + window_snapshot.take() + # Select "QR code" to scan a QR code for credentials store.dispatch(MenuChooseByIconAction(icon='󰄀')) diff --git a/ubo_app/logging.py b/ubo_app/logging.py index e115cd03..547c98a6 100644 --- a/ubo_app/logging.py +++ b/ubo_app/logging.py @@ -67,7 +67,9 @@ def verbose( def get_logger(name: str) -> UboLogger: - return cast(UboLogger, logging.getLogger(name)) + logger = cast(UboLogger, logging.getLogger(name)) + logger.propagate = False + return logger def supports_truecolor() -> bool: @@ -77,7 +79,6 @@ def supports_truecolor() -> bool: logger = get_logger('ubo-app') -logger.propagate = False class ExtraFormatter(logging.Formatter): diff --git a/ubo_app/menu_app/menu_notification_handler.py b/ubo_app/menu_app/menu_notification_handler.py index d50934c2..b4c60b73 100644 --- a/ubo_app/menu_app/menu_notification_handler.py +++ b/ubo_app/menu_app/menu_notification_handler.py @@ -113,9 +113,7 @@ def renew_notification(event: NotificationsDisplayEvent) -> None: notification.is_initialized = True store.dispatch( VoiceReadTextAction( - text=event.notification.extra_information.text, - piper_text=event.notification.extra_information.piper_text, - picovoice_text=event.notification.extra_information.picovoice_text, + information=event.notification.extra_information, ), ) diff --git a/ubo_app/services/010-voice/reducer.py b/ubo_app/services/010-voice/reducer.py index dffe1ef1..2fa5d120 100644 --- a/ubo_app/services/010-voice/reducer.py +++ b/ubo_app/services/010-voice/reducer.py @@ -36,9 +36,7 @@ def reducer( state=state, events=[ VoiceSynthesizeTextEvent( - text=action.text, - piper_text=action.piper_text or action.text, - picovoice_text=action.picovoice_text or action.text, + information=action.information, speech_rate=action.speech_rate, ), ], diff --git a/ubo_app/services/010-voice/setup.py b/ubo_app/services/010-voice/setup.py index e5bbd8c0..390a78b5 100644 --- a/ubo_app/services/010-voice/setup.py +++ b/ubo_app/services/010-voice/setup.py @@ -19,8 +19,8 @@ from ubo_app.store.core.types import RegisterSettingAppAction, SettingsCategory from ubo_app.store.main import store from ubo_app.store.services.audio import AudioPlayAudioAction, AudioPlaybackDoneEvent -from ubo_app.store.services.notifications import NotificationExtraInformation from ubo_app.store.services.voice import ( + ReadableInformation, VoiceEngine, VoiceReadTextAction, VoiceSetEngineAction, @@ -76,7 +76,7 @@ async def act() -> None: access_key = ( await ubo_input( title='Picovoice Access Key', - qr_code_generation_instructions=NotificationExtraInformation( + qr_code_generation_instructions=ReadableInformation( text='Convert the Picovoice access key to a QR code and hold ' 'it in front of the camera to scan it.', picovoice_text='Convert the Picovoice access key to a ' @@ -113,7 +113,7 @@ def synthesize_and_play(event: VoiceSynthesizeTextEvent) -> None: """Synthesize the text.""" engine = _engine() if engine == VoiceEngine.PIPER: - text = event.piper_text + text = event.information.piper_text if not _context.piper_voice: return id = hex(hash(text)) @@ -153,7 +153,7 @@ def synthesize_and_play(event: VoiceSynthesizeTextEvent) -> None: rate = _context.picovoice_instance.sample_rate audio_sequence = _context.picovoice_instance.synthesize( - text=event.picovoice_text, + text=event.information.picovoice_text, speech_rate=event.speech_rate, ) sample = b''.join(struct.pack('h', sample) for sample in audio_sequence[0]) @@ -205,10 +205,12 @@ def _engine_selector() -> None: store.dispatch( VoiceSetEngineAction(engine=engine), VoiceReadTextAction( - text={ - VoiceEngine.PIPER: 'Piper voice engine selected', - VoiceEngine.PICOVOICE: 'Picovoice voice engine selected', - }[engine], + information=ReadableInformation( + text={ + VoiceEngine.PIPER: 'Piper voice engine selected', + VoiceEngine.PICOVOICE: 'Picovoice voice engine selected', + }[engine], + ), engine=engine, ), ) 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 985ca33b..479ed71e 100644 --- a/ubo_app/services/030-wifi/pages/create_wireless_connection.py +++ b/ubo_app/services/030-wifi/pages/create_wireless_connection.py @@ -20,9 +20,9 @@ Chime, Notification, NotificationDisplayType, - NotificationExtraInformation, NotificationsAddAction, ) +from ubo_app.store.services.voice import ReadableInformation from ubo_app.store.services.wifi import WiFiType, WiFiUpdateRequestAction from ubo_app.utils.async_ import create_task from ubo_app.utils.input import ubo_input @@ -57,9 +57,9 @@ def __init__( async def create_wireless_connection(self: CreateWirelessConnectionPage) -> None: try: _, data = await ubo_input( - input_methods=InputMethod.CAMERA, + input_methods=InputMethod.ALL, prompt='Enter WiFi connection', - qr_code_generation_instructions=NotificationExtraInformation( + qr_code_generation_instructions=ReadableInformation( text='Go to your phone settings, choose QR code and hold it in ' 'front of the camera to scan it.', picovoice_text='Go to your phone settings, choose {QR|K Y UW AA R} ' diff --git a/ubo_app/services/030-wifi/setup.py b/ubo_app/services/030-wifi/setup.py index 124da1a3..ccaa44ca 100644 --- a/ubo_app/services/030-wifi/setup.py +++ b/ubo_app/services/030-wifi/setup.py @@ -23,9 +23,9 @@ Notification, NotificationActionItem, NotificationDisplayType, - NotificationExtraInformation, NotificationsAddAction, ) +from ubo_app.store.services.voice import ReadableInformation from ubo_app.store.services.wifi import ( ConnectionState, WiFiSetHasVisitedOnboardingAction, @@ -85,7 +85,7 @@ async def setup_listeners() -> None: dismiss_notification=True, ), ], - extra_information=NotificationExtraInformation( + extra_information=ReadableInformation( text='Press middle button to add WiFi network with QR code.\n' 'If you dismiss this, you can always add WiFi network through ' 'Settings → Network → WiFi', diff --git a/ubo_app/services/050-rpi-connect/commands.py b/ubo_app/services/050-rpi-connect/commands.py index a4c9fd43..621dd189 100644 --- a/ubo_app/services/050-rpi-connect/commands.py +++ b/ubo_app/services/050-rpi-connect/commands.py @@ -16,7 +16,6 @@ Importance, Notification, NotificationDisplayType, - NotificationExtraInformation, NotificationsAddAction, ) from ubo_app.store.services.rpi_connect import ( @@ -27,6 +26,7 @@ RPiConnectStatus, RPiConnectUpdateServiceStateAction, ) +from ubo_app.store.services.voice import ReadableInformation from ubo_app.utils.apt import is_package_installed from ubo_app.utils.async_ import create_task from ubo_app.utils.monitor_unit import is_unit_active @@ -208,7 +208,7 @@ async def act() -> None: notification=Notification( title='RPi-Connect', content='LightDM is not running', - extra_information=NotificationExtraInformation( + extra_information=ReadableInformation( text="""\ LightDM is not running so RPi-Connect will run without screen sharing and you can only \ use the remote shell feature. To enable screen sharing, start LightDM service in \ diff --git a/ubo_app/services/050-users/setup.py b/ubo_app/services/050-users/setup.py index e9b41e28..553565f6 100644 --- a/ubo_app/services/050-users/setup.py +++ b/ubo_app/services/050-users/setup.py @@ -33,7 +33,6 @@ Notification, NotificationActionItem, NotificationDisplayType, - NotificationExtraInformation, NotificationsAddAction, ) from ubo_app.store.services.users import ( @@ -47,6 +46,7 @@ UsersState, UserState, ) +from ubo_app.store.services.voice import ReadableInformation from ubo_app.utils import IS_RPI from ubo_app.utils.async_ import create_task from ubo_app.utils.bus_provider import get_system_bus @@ -85,7 +85,7 @@ async def create_account() -> None: importance=Importance.MEDIUM, icon='󰀈', display_type=NotificationDisplayType.STICKY, - extra_information=NotificationExtraInformation( + extra_information=ReadableInformation( text="""\ Note that in order to make ssh works for you, we had to make sure password \ authentication for ssh server is enabled, you may want to disable it later.""", @@ -113,7 +113,7 @@ async def delete_account(event: UsersDeleteUserEvent) -> None: content=f'Delete user "{event.id}"?', display_type=NotificationDisplayType.STICKY, is_read=True, - extra_information=NotificationExtraInformation( + extra_information=ReadableInformation( text='This will delete the system user account and its home ' 'directory.', ), @@ -190,7 +190,7 @@ async def reset_password(event: UsersResetPasswordEvent) -> None: importance=Importance.MEDIUM, icon='󰀈', display_type=NotificationDisplayType.STICKY, - extra_information=NotificationExtraInformation( + extra_information=ReadableInformation( text="""\ Note that in order to make ssh works for you, we had to make sure password \ authentication for ssh server is enabled, you may want to disable it later.""", diff --git a/ubo_app/services/080-docker/docker_images.py b/ubo_app/services/080-docker/docker_images.py index 5f135d05..39113488 100644 --- a/ubo_app/services/080-docker/docker_images.py +++ b/ubo_app/services/080-docker/docker_images.py @@ -4,6 +4,8 @@ from typing import TYPE_CHECKING, Any +from ubo_app.store.services.voice import ReadableInformation + if TYPE_CHECKING: from collections.abc import Callable, Coroutine @@ -14,7 +16,6 @@ from immutable import Immutable from ubo_app.constants import DEBUG_MODE_DOCKER, GRPC_ENVOY_LISTEN_PORT -from ubo_app.store.services.notifications import NotificationExtraInformation from ubo_app.utils.input import ubo_input if TYPE_CHECKING: @@ -113,7 +114,7 @@ class CompositionEntry(Immutable): 'NGROK_AUTHTOKEN': lambda: ubo_input( resolver=lambda code, _: code, prompt='Enter the Ngrok Auth Token', - qr_code_generation_instructions=NotificationExtraInformation( + qr_code_generation_instructions=ReadableInformation( text="""\ Follow these steps: @@ -135,7 +136,7 @@ class CompositionEntry(Immutable): command=lambda: ubo_input( resolver=lambda code, _: code, prompt='Enter the command, for example: `http 80` or `tcp 22`', - qr_code_generation_instructions=NotificationExtraInformation( + qr_code_generation_instructions=ReadableInformation( text='This is the command you would enter when running ngrok. ' 'Refer to ngrok documentation for further information', picovoice_text="""\ diff --git a/ubo_app/services/080-docker/menus.py b/ubo_app/services/080-docker/menus.py index 708eab4d..0bb0f2a2 100644 --- a/ubo_app/services/080-docker/menus.py +++ b/ubo_app/services/080-docker/menus.py @@ -39,9 +39,9 @@ ) from ubo_app.store.services.notifications import ( Notification, - NotificationExtraInformation, NotificationsAddAction, ) +from ubo_app.store.services.voice import ReadableInformation from ubo_app.utils.async_ import create_task if TYPE_CHECKING: @@ -162,7 +162,7 @@ def action() -> PageWidget: icon='󰋗', title='Instructions', content='', - extra_information=NotificationExtraInformation( + extra_information=ReadableInformation( text=image.instructions, ) if image.instructions diff --git a/ubo_app/services/080-docker/setup.py b/ubo_app/services/080-docker/setup.py index 03b13848..8da081fc 100644 --- a/ubo_app/services/080-docker/setup.py +++ b/ubo_app/services/080-docker/setup.py @@ -43,9 +43,9 @@ Importance, Notification, NotificationDisplayType, - NotificationExtraInformation, NotificationsAddAction, ) +from ubo_app.store.services.voice import ReadableInformation from ubo_app.utils import secrets from ubo_app.utils.apt import is_package_installed from ubo_app.utils.async_ import create_task @@ -219,7 +219,7 @@ async def act() -> None: credentials = ( await ubo_input( prompt='Enter Docker Credentials', - qr_code_generation_instructions=NotificationExtraInformation( + qr_code_generation_instructions=ReadableInformation( text="""To generate your QR code for login, format your \ details by separating your service, username, and password with the pipe symbol. For \ example, format it as "docker.io|johndoe|password" and then convert this text into a \ @@ -295,7 +295,7 @@ async def act() -> None: notification=Notification( title='Docker Credentials Error', content='Invalid credentials', - extra_information=NotificationExtraInformation( + extra_information=ReadableInformation( text=explanation, ), importance=Importance.HIGH, diff --git a/ubo_app/services/090-web-ui/reducer.py b/ubo_app/services/090-web-ui/reducer.py index 334ea38f..57250034 100644 --- a/ubo_app/services/090-web-ui/reducer.py +++ b/ubo_app/services/090-web-ui/reducer.py @@ -1,8 +1,6 @@ # ruff: noqa: D100, D101, D102, D103, D104, D107 from __future__ import annotations -import datetime -import functools from dataclasses import replace from typing import TYPE_CHECKING @@ -15,15 +13,11 @@ InputMethod, InputResolveAction, ) -from ubo_app.store.main import store from ubo_app.store.services.notifications import ( - Notification, - NotificationDisplayType, NotificationsAction, - NotificationsAddAction, NotificationsClearByIdAction, ) -from ubo_app.store.services.web_ui import WebUIState +from ubo_app.store.services.web_ui import WebUICheckHotspotEvent, WebUIState if TYPE_CHECKING: from redux import ReducerResult @@ -34,7 +28,7 @@ def reducer( state: WebUIState | None, action: InputAction, -) -> WebUIState | ReducerResult[WebUIState, DispatchAction, None]: +) -> WebUIState | ReducerResult[WebUIState, DispatchAction, WebUICheckHotspotEvent]: if state is None: if isinstance(action, InitAction): return WebUIState(active_inputs=[]) @@ -49,39 +43,22 @@ def reducer( state, active_inputs=[*state.active_inputs, action.description], ), - actions=[ - NotificationsAddAction( - notification=Notification( - id=f'web_ui:pending:{action.description.id}', - icon='󱋆', - title='Web UI', - content=f'[size=18dp]{action.description.prompt}[/size]', - display_type=NotificationDisplayType.STICKY, - is_read=True, - extra_information=action.description.extra_information, - expiration_timestamp=datetime.datetime.now(tz=datetime.UTC), - color='#ffffff', - show_dismiss_action=False, - dismiss_on_close=True, - on_close=functools.partial( - store.dispatch, - InputCancelAction(id=action.description.id), - ), - ), - ), - ], + events=[WebUICheckHotspotEvent(description=action.description)], ) if isinstance(action, InputResolveAction | InputCancelAction): return CompleteReducerResult( state=replace( state, - active_inputs=[ - description - for description in state.active_inputs - if description.id != action.id - ], + active_inputs=( + new_active_inputs := [ + description + for description in state.active_inputs + if description.id != action.id + ] + ), ), actions=[NotificationsClearByIdAction(id=f'web_ui:pending:{action.id}')], + events=[] if new_active_inputs else [WebUICheckHotspotEvent()], ) return state diff --git a/ubo_app/services/090-web-ui/setup.py b/ubo_app/services/090-web-ui/setup.py index 7cd2746c..750470bc 100644 --- a/ubo_app/services/090-web-ui/setup.py +++ b/ubo_app/services/090-web-ui/setup.py @@ -1,7 +1,11 @@ """Implementation of the web-ui service.""" import asyncio +import datetime +import functools import re +import socket +import subprocess from pathlib import Path from quart import Quart, render_template, request @@ -12,12 +16,117 @@ WEB_UI_LISTEN_ADDRESS, WEB_UI_LISTEN_PORT, ) +from ubo_app.logging import logger from ubo_app.store.input.types import ( InputCancelAction, InputDescription, InputProvideAction, ) from ubo_app.store.main import store +from ubo_app.store.services.notifications import ( + Notification, + NotificationDisplayType, + NotificationsAddAction, +) +from ubo_app.store.services.voice import ReadableInformation +from ubo_app.store.services.web_ui import WebUIInitializeEvent, WebUIStopEvent +from ubo_app.utils.server import send_command + + +async def has_gateway() -> bool: + """Check if any network is connected.""" + try: + # macOS uses 'route -n get default', Linux uses 'ip route' + process = await asyncio.create_subprocess_exec( + 'which', + 'ip', + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + await process.wait() + if process.returncode == 0: + # For Linux + process = await asyncio.create_subprocess_exec( + 'ip', + 'route', + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + await process.wait() + if process.returncode == 0 and process.stdout: + for line in (await process.stdout.read()).splitlines(): + if line.startswith(b'default'): + return True + else: + # For macOS + process = await asyncio.create_subprocess_exec( + 'route', + '-n', + 'get', + 'default', + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + await process.wait() + if process.returncode == 0 and process.stdout: + for line in (await process.stdout.read()).splitlines(): + if b'gateway:' in line: + return True + finally: + pass + return False + + +async def initialize(event: WebUIInitializeEvent) -> None: + """Start the hotspot if there is no network connection.""" + is_connected = await has_gateway() + logger.info( + 'web-ui - check_connection', + extra={ + 'is_connected': is_connected, + 'description': event.description, + }, + ) + if not is_connected: + await send_command('hotspot', 'start') + hostname = socket.gethostname() + if event.description: + store.dispatch( + NotificationsAddAction( + notification=Notification( + id=f'web_ui:pending:{event.description.id}', + icon='󱋆', + title='Web UI', + content=f'[size=18dp]{event.description.prompt}[/size]', + display_type=NotificationDisplayType.STICKY, + is_read=True, + extra_information=ReadableInformation( + text=( + 'Please make sure you are on the same network as this ' + f'ubo-pod and open http://{hostname}.local:{WEB_UI_LISTEN_PORT}' + 'in your browser.' + if is_connected + else 'Please connect to the "UBO" WiFi network and open ' + f'http://{hostname}.local:{WEB_UI_LISTEN_PORT} in your ' + 'browser.' + ), + ), + expiration_timestamp=datetime.datetime.now(tz=datetime.UTC), + color='#ffffff', + show_dismiss_action=False, + dismiss_on_close=True, + on_close=functools.partial( + store.dispatch, + InputCancelAction(id=event.description.id), + ), + ), + ), + ) + + +async def stop() -> None: + """Start the hotspot if there is no network connection.""" + await send_command('hotspot', 'stop') async def init_service() -> None: @@ -67,6 +176,9 @@ async def handle_error(_: Exception) -> str: store.subscribe_event(FinishEvent, shutdown_event.set) + store.subscribe_event(WebUIInitializeEvent, initialize) + store.subscribe_event(WebUIStopEvent, stop) + async def wait_for_shutdown() -> None: await shutdown_event.wait() diff --git a/ubo_app/store/core/reducer.py b/ubo_app/store/core/reducer.py index fd983f82..fa2d0f60 100644 --- a/ubo_app/store/core/reducer.py +++ b/ubo_app/store/core/reducer.py @@ -189,7 +189,6 @@ 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) @@ -275,7 +274,6 @@ 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) priorities = { diff --git a/ubo_app/store/input/types.py b/ubo_app/store/input/types.py index 6636e1c2..05c5c516 100644 --- a/ubo_app/store/input/types.py +++ b/ubo_app/store/input/types.py @@ -9,7 +9,7 @@ from redux import BaseAction, BaseEvent if TYPE_CHECKING: - from ubo_app.store.services.notifications import NotificationExtraInformation + from ubo_app.store.services.voice import ReadableInformation class InputFieldType(enum.StrEnum): @@ -54,7 +54,7 @@ class InputDescription(Immutable): title: str prompt: str | None - extra_information: NotificationExtraInformation | None = None + extra_information: ReadableInformation | None = None id: str pattern: str | None fields: list[InputFieldDescription] | None = None diff --git a/ubo_app/store/services/notifications.py b/ubo_app/store/services/notifications.py index 6abd0033..74de593b 100644 --- a/ubo_app/store/services/notifications.py +++ b/ubo_app/store/services/notifications.py @@ -21,6 +21,8 @@ from kivy.graphics.context_instructions import Color + from ubo_app.store.services.voice import ReadableInformation + class Importance(StrEnum): CRITICAL = auto() @@ -87,17 +89,11 @@ class NotificationActionItem(ActionItem): class NotificationDispatchItem(DispatchItem, NotificationActionItem): ... -class NotificationExtraInformation(Immutable): - text: str - piper_text: str | None = None - picovoice_text: str | None = None - - class Notification(Immutable): id: str = field(default_factory=lambda: uuid4().hex) title: str content: str - extra_information: NotificationExtraInformation | None = None + extra_information: ReadableInformation | None = None importance: Importance = Importance.LOW chime: Chime | None = None timestamp: datetime = field(default_factory=lambda: datetime.now(tz=UTC)) diff --git a/ubo_app/store/services/voice.py b/ubo_app/store/services/voice.py index 5260c748..d43f8fec 100644 --- a/ubo_app/store/services/voice.py +++ b/ubo_app/store/services/voice.py @@ -1,6 +1,7 @@ # ruff: noqa: D100, D101, D102, D103, D104, D107, N999 from __future__ import annotations +import sys from dataclasses import field from enum import StrEnum @@ -29,18 +30,41 @@ class VoiceSetEngineAction(VoiceAction): engine: VoiceEngine -class VoiceReadTextAction(VoiceAction): +def _default_text() -> str: + # WARNING: Dirty hack ahead + # This is to set the default value of `piper_text`/`picovoice_text` based on the + # provided value of `text` + parent_frame = sys._getframe().f_back # noqa: SLF001 + if not parent_frame: + return '' + return parent_frame.f_locals.get('text') or '' + + +class ReadableInformation(Immutable): text: str - piper_text: str | None = None - picovoice_text: str | None = None + piper_text: str = field(default_factory=_default_text) + picovoice_text: str = field(default_factory=_default_text) + + def __add__( + self, + other: ReadableInformation, + ) -> ReadableInformation: + """Concatenate two `ReadableInformation` objects.""" + return ReadableInformation( + text=self.text + other.text, + piper_text=self.piper_text + other.piper_text, + picovoice_text=self.picovoice_text + other.picovoice_text, + ) + + +class VoiceReadTextAction(VoiceAction): + information: ReadableInformation speech_rate: float | None = None engine: VoiceEngine | None = None class VoiceSynthesizeTextEvent(VoiceEvent): - text: str - piper_text: str - picovoice_text: str + information: ReadableInformation speech_rate: float | None = None diff --git a/ubo_app/store/services/web_ui.py b/ubo_app/store/services/web_ui.py index 3e38dbb2..358ae30f 100644 --- a/ubo_app/store/services/web_ui.py +++ b/ubo_app/store/services/web_ui.py @@ -4,10 +4,21 @@ from typing import TYPE_CHECKING from immutable import Immutable +from redux import BaseEvent if TYPE_CHECKING: from ubo_app.store.input.types import InputDescription +class WebUIEvent(BaseEvent): ... + + +class WebUIInitializeEvent(WebUIEvent): + description: InputDescription | None = None + + +class WebUIStopEvent(WebUIEvent): ... + + class WebUIState(Immutable): active_inputs: list[InputDescription] diff --git a/ubo_app/store/update_manager/utils.py b/ubo_app/store/update_manager/utils.py index 8c85447e..8ba6a9c6 100644 --- a/ubo_app/store/update_manager/utils.py +++ b/ubo_app/store/update_manager/utils.py @@ -32,10 +32,10 @@ Notification, NotificationDispatchItem, NotificationDisplayType, - NotificationExtraInformation, NotificationsAddAction, NotificationsClearByIdAction, ) +from ubo_app.store.services.voice import ReadableInformation from ubo_app.store.update_manager.types import ( UPDATE_MANAGER_NOTIFICATION_ID, UPDATE_MANAGER_SECOND_PHASE_NOTIFICATION_ID, @@ -105,7 +105,7 @@ async def update() -> None: """Update the Ubo app.""" logger.info('Updating Ubo app...') - extra_information = NotificationExtraInformation( + extra_information = ReadableInformation( text="""\ The download progress is shown in the radial progress bar at the top left corner of \ the screen. @@ -241,7 +241,7 @@ async def download_files() -> None: content="""\ All packages downloaded successfully. Press 󰜉 button to reboot now or dismiss this notification to reboot later.""", - extra_information=NotificationExtraInformation( + extra_information=ReadableInformation( text="""\ After the reboot, the system will apply the update. This part may take around 20 minutes to complete. diff --git a/ubo_app/system/install.sh b/ubo_app/system/install.sh index 2d59fa5d..91470329 100755 --- a/ubo_app/system/install.sh +++ b/ubo_app/system/install.sh @@ -76,7 +76,10 @@ apt-get -y update apt-get -y upgrade apt-get -y install \ accountsservice \ + dhcpcd \ + dnsmasq \ git \ + hostapd \ i2c-tools \ libasound2-dev \ libcap-dev \ diff --git a/ubo_app/system/system_manager/hotspot.py b/ubo_app/system/system_manager/hotspot.py new file mode 100644 index 00000000..826ce952 --- /dev/null +++ b/ubo_app/system/system_manager/hotspot.py @@ -0,0 +1,42 @@ +"""Set up a hotspot on the UBO.""" + +import pathlib +import subprocess + + +def hotspot_handler(desired_state: str) -> None: + """Set up a hotspot on the UBO.""" + if desired_state == 'start': + with pathlib.Path('/etc/dhcpcd.conf').open('w') as f: + f.write("""interface wlan0 +static ip_address=192.168.4.1/24 +nohook wpa_supplicant""") + with pathlib.Path('/etc/dnsmasq.conf').open('w') as f: + f.write("""interface=wlan0 +dhcp-range=192.168.4.2,192.168.4.20,255.255.255.0,24h""") + with pathlib.Path('/etc/hostapd/hostapd.conf').open('w') as f: + f.write("""interface=wlan0 +driver=nl80211 +ssid=UBO +hw_mode=g +channel=7 +wpa=2 +wpa_passphrase=ubo-pod-setup +wpa_key_mgmt=WPA-PSK +wpa_pairwise=TKIP CCMP +rsn_pairwise=CCMP""") + with pathlib.Path('/etc/default/hostapd').open('w') as f: + f.write('DAEMON_CONF="/etc/hostapd/hostapd.conf"') + + subprocess.run(['/usr/bin/env', 'systemctl', 'restart', 'dhcpcd'], check=True) # noqa: S603 + subprocess.run(['/usr/bin/env', 'systemctl', 'restart', 'dnsmasq'], check=True) # noqa: S603 + subprocess.run(['/usr/bin/env', 'systemctl', 'unmask', 'hostapd'], check=True) # noqa: S603 + subprocess.run(['/usr/bin/env', 'systemctl', 'enable', 'hostapd'], check=True) # noqa: S603 + subprocess.run(['/usr/bin/env', 'systemctl', 'start', 'hostapd'], check=True) # noqa: S603 + + elif desired_state == 'stop': + subprocess.run(['/usr/bin/env', 'systemctl', 'stop', 'hostapd'], check=True) # noqa: S603 + subprocess.run(['/usr/bin/env', 'systemctl', 'disable', 'hostapd'], check=True) # noqa: S603 + subprocess.run(['/usr/bin/env', 'systemctl', 'mask', 'hostapd'], check=True) # noqa: S603 + subprocess.run(['/usr/bin/env', 'systemctl', 'stop', 'dnsmasq'], check=True) # noqa: S603 + subprocess.run(['/usr/bin/env', 'systemctl', 'restart', 'dhcpcd'], check=True) # noqa: S603 diff --git a/ubo_app/system/system_manager/main.py b/ubo_app/system/system_manager/main.py index 211580be..00504cc2 100644 --- a/ubo_app/system/system_manager/main.py +++ b/ubo_app/system/system_manager/main.py @@ -23,6 +23,7 @@ from ubo_app.store.services.ethernet import NetState from ubo_app.system.system_manager.audio import audio_handler from ubo_app.system.system_manager.docker import docker_handler +from ubo_app.system.system_manager.hotspot import hotspot_handler from ubo_app.system.system_manager.led import LEDManager from ubo_app.system.system_manager.package import package_handler from ubo_app.system.system_manager.reset_button import setup_reset_button @@ -72,6 +73,7 @@ def handle_command(command: str) -> str | None: 'users': users_handler, 'package': package_handler, 'audio': audio_handler, + 'hotspot': hotspot_handler, } if header in handlers: return handlers[header](*arguments) diff --git a/ubo_app/system/system_manager/reset_button.py b/ubo_app/system/system_manager/reset_button.py index 190ec53e..f75577e2 100644 --- a/ubo_app/system/system_manager/reset_button.py +++ b/ubo_app/system/system_manager/reset_button.py @@ -8,7 +8,7 @@ logger = get_logger('system-manager') RESET_BUTTON_PIN = 27 -LONG_PRESS_TIME = 3 +LONG_PRESS_TIME = 5 SHORT_PRESS_TIME = 0.5 @@ -26,17 +26,22 @@ def reset_button_released(_: int) -> None: release_time = time.time() if 'press_time' in state: duration = release_time - state['press_time'] - if duration > LONG_PRESS_TIME: - logger.info('Reset button pressed for 3 seconds') + logger.info( + 'Reset button pressed', + extra={ + 'duration': duration, + 'SHORT_PRESS_TIME': SHORT_PRESS_TIME, + 'LONG_PRESS_TIME': LONG_PRESS_TIME, + }, + ) + if duration > LONG_PRESS_TIME: # Restart the pod subprocess.run( # noqa: S603 ['/usr/bin/env', 'systemctl', 'reboot', '-i'], check=True, ) elif duration > SHORT_PRESS_TIME: - logger.info('Reset button pressed for less than 3 seconds') - # Kill the UBO process subprocess.run( # noqa: S603 ['/usr/bin/env', 'killall', '-9', 'ubo'], diff --git a/ubo_app/utils/input.py b/ubo_app/utils/input.py index a3306f60..1479893a 100644 --- a/ubo_app/utils/input.py +++ b/ubo_app/utils/input.py @@ -5,14 +5,12 @@ import asyncio import datetime import functools -import socket import uuid from asyncio import Future from typing import TYPE_CHECKING, TypeAlias, overload from typing_extensions import TypeVar -from ubo_app.constants import WEB_UI_LISTEN_PORT from ubo_app.store.input.types import ( InputCancelEvent, InputDemandAction, @@ -27,10 +25,10 @@ Notification, NotificationActionItem, NotificationDisplayType, - NotificationExtraInformation, NotificationsAddAction, ) from ubo_app.store.services.rgb_ring import RgbRingBlinkAction +from ubo_app.store.services.voice import ReadableInformation if TYPE_CHECKING: from collections.abc import Callable @@ -63,6 +61,10 @@ async def select_input_method(input_methods: InputMethod) -> InputMethod: def set_result(method: InputMethod) -> None: loop.call_soon_threadsafe(input_method_future.set_result, method) + if len(input_methods) == 1: + set_result(next(iter(input_methods))) + return await input_method_future + store.dispatch( NotificationsAddAction( notification=Notification( @@ -72,7 +74,7 @@ def set_result(method: InputMethod) -> None: content='Do you want to use the camera or the web dashboard?', display_type=NotificationDisplayType.STICKY, is_read=True, - extra_information=NotificationExtraInformation( + extra_information=ReadableInformation( text='You can use either the camera or the web dashboard to ' 'enter this input. Please choose one by pressing one of the ' 'left buttons.', @@ -104,7 +106,7 @@ def set_result(method: InputMethod) -> None: async def ubo_input( *, prompt: str | None = None, - qr_code_generation_instructions: NotificationExtraInformation | None = None, + qr_code_generation_instructions: ReadableInformation | None = None, title: str | None = None, pattern: str, fields: list[InputFieldDescription] | None = None, @@ -114,7 +116,7 @@ async def ubo_input( async def ubo_input( *, prompt: str | None = None, - qr_code_generation_instructions: NotificationExtraInformation | None = None, + qr_code_generation_instructions: ReadableInformation | None = None, title: str | None = None, fields: list[InputFieldDescription], input_methods: InputMethod = InputMethod.ALL, @@ -123,7 +125,7 @@ async def ubo_input( async def ubo_input( *, prompt: str | None = None, - qr_code_generation_instructions: NotificationExtraInformation | None = None, + qr_code_generation_instructions: ReadableInformation | None = None, title: str | None = None, pattern: str, fields: list[InputFieldDescription] | None = None, @@ -134,7 +136,7 @@ async def ubo_input( async def ubo_input( *, prompt: str | None = None, - qr_code_generation_instructions: NotificationExtraInformation | None = None, + qr_code_generation_instructions: ReadableInformation | None = None, title: str | None = None, fields: list[InputFieldDescription], resolver: Callable[[str, InputResultGroupDict], ReturnType], @@ -143,7 +145,7 @@ async def ubo_input( async def ubo_input( # noqa: PLR0913 *, prompt: str | None = None, - qr_code_generation_instructions: NotificationExtraInformation | None = None, + qr_code_generation_instructions: ReadableInformation | None = None, title: str | None = None, pattern: str | None = None, fields: list[InputFieldDescription] | None = None, @@ -222,7 +224,6 @@ def handle_cancel(event: CameraStopViewfinderEvent) -> None: ), ) - hostname = socket.gethostname() store.dispatch( InputDemandAction( method=selected_input_method, @@ -231,11 +232,7 @@ def handle_cancel(event: CameraStopViewfinderEvent) -> None: prompt=prompt, extra_information=qr_code_generation_instructions if selected_input_method is InputMethod.CAMERA - else NotificationExtraInformation( - text=f"""\ -Web dashboard is served on port {hostname}.local:{WEB_UI_LISTEN_PORT} and it provides \ -an interface for entering this input.""", - ), + else None, id=prompt_id, pattern=pattern, fields=fields, diff --git a/ubo_app/utils/log_process.py b/ubo_app/utils/log_process.py index 856d4ea1..609e7db8 100644 --- a/ubo_app/utils/log_process.py +++ b/ubo_app/utils/log_process.py @@ -6,9 +6,9 @@ from ubo_app.store.services.notifications import ( Importance, Notification, - NotificationExtraInformation, NotificationsAddAction, ) +from ubo_app.store.services.voice import ReadableInformation async def log_async_process( @@ -37,7 +37,7 @@ async def log_async_process( notification=Notification( title=title, content=message, - extra_information=NotificationExtraInformation( + extra_information=ReadableInformation( text=logs, ), importance=Importance.HIGH, diff --git a/ubo_app/utils/server.py b/ubo_app/utils/server.py index 12b25a76..f281e5ac 100644 --- a/ubo_app/utils/server.py +++ b/ubo_app/utils/server.py @@ -36,7 +36,10 @@ async def send_command(*command_: str, has_output: bool = False) -> str | None: writer.close() except Exception: - logger.exception('Failed to send command to the server') + logger.exception( + 'Failed to send command to the server', + extra={'command': command}, + ) raise else: return response diff --git a/uv.lock b/uv.lock index d4068800..4ce97e70 100644 --- a/uv.lock +++ b/uv.lock @@ -1158,6 +1158,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, ] +[[package]] +name = "netifaces" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/91/86a6eac449ddfae239e93ffc1918cf33fd9bab35c04d1e963b311e347a73/netifaces-0.11.0.tar.gz", hash = "sha256:043a79146eb2907edf439899f262b3dfe41717d34124298ed281139a8b93ca32", size = 30106 } + [[package]] name = "nodeenv" version = "1.9.1" @@ -2025,7 +2031,7 @@ wheels = [ [[package]] name = "ubo-app" -version = "1.0.1.dev22+unknown" +version = "1.0.1.dev25+unknown" source = { editable = "." } dependencies = [ { name = "adafruit-circuitpython-aw9523" }, @@ -2040,6 +2046,7 @@ dependencies = [ { name = "fasteners" }, { name = "gpiozero", marker = "platform_machine != 'aarch64'" }, { name = "headless-kivy" }, + { name = "netifaces" }, { name = "piper-tts", marker = "sys_platform == 'linux'" }, { name = "platformdirs" }, { name = "psutil" }, @@ -2095,6 +2102,7 @@ requires-dist = [ { name = "fasteners", specifier = ">=0.19" }, { name = "gpiozero", marker = "platform_machine != 'aarch64'", specifier = ">=2.0.1" }, { name = "headless-kivy", specifier = ">=0.12.2" }, + { name = "netifaces", specifier = ">=0.11.0" }, { name = "piper-tts", marker = "sys_platform == 'linux'", specifier = ">=1.2.0" }, { name = "platformdirs", specifier = ">=4.2.0" }, { name = "psutil", specifier = ">=6.0.0" },