From bf963f861f31919df8f8500c14b005241407c8cc Mon Sep 17 00:00:00 2001 From: Sassan Haradji Date: Sat, 14 Sep 2024 08:27:36 +0400 Subject: [PATCH] feat(display): add display service and put display content in the bus via `DisplayRenderEvent` --- CHANGELOG.md | 4 + pyproject.toml | 8 +- scripts/deploy.sh | 2 +- tests/flows/test_wireless.py | 2 +- .../store-desktop-000.jsonc | 10 +- .../all_services_register/store-rpi-000.jsonc | 12 +- .../window-desktop-000.hash | 2 +- .../all_services_register/window-rpi-000.hash | 2 +- tests/integration/test_services.py | 1 + ubo_app/display.py | 169 ++++++------------ ubo_app/services/000-display/reducer.py | 36 ++++ ubo_app/services/000-display/setup.py | 37 ++++ ubo_app/services/000-display/ubo_handle.py | 22 +++ ubo_app/services/040-camera/setup.py | 11 +- ubo_app/services/050-users/setup.py | 2 +- ubo_app/setup.py | 3 +- ubo_app/store/main.py | 4 + ubo_app/store/services/audio.py | 2 +- ubo_app/store/services/display.py | 27 +++ 19 files changed, 225 insertions(+), 131 deletions(-) create mode 100644 ubo_app/services/000-display/reducer.py create mode 100644 ubo_app/services/000-display/setup.py create mode 100644 ubo_app/services/000-display/ubo_handle.py create mode 100644 ubo_app/store/services/display.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ab4a941f..11ff0ac5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Upcoming + +- feat(display): add display service and put display content in the bus via `DisplayRenderEvent` + ## Version 0.16.1 - feat(lightdm): set wayland as the default session for lightdm after installing raspberrypi-ui-mods diff --git a/pyproject.toml b/pyproject.toml index 4de62028..660ded46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -161,11 +161,15 @@ profile = "black" exclude = ["typings"] [[tool.pyright.executionEnvironments]] -root = "ubo_app/services/000-keypad" +root = "ubo_app/services/000-audio" extraPaths = ["."] [[tool.pyright.executionEnvironments]] -root = "ubo_app/services/000-audio" +root = "ubo_app/services/000-display" +extraPaths = ["."] + +[[tool.pyright.executionEnvironments]] +root = "ubo_app/services/000-keypad" extraPaths = ["."] [[tool.pyright.executionEnvironments]] diff --git a/scripts/deploy.sh b/scripts/deploy.sh index c2a67c55..968dcca6 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -11,7 +11,6 @@ function cleanup() { trap cleanup ERR trap cleanup EXIT -LATEST_VERSION=$(basename $(ls -rt dist/*.whl | tail -n 1)) deps=${deps:-"False"} bootstrap=${bootstrap:-"False"} kill=${kill:-"False"} @@ -21,6 +20,7 @@ env=${env:-"False"} perl -i -pe 's/^(packages = \[.*)$/\1\nexclude = ["ubo_app\/services\/*-voice\/models\/*"]/' pyproject.toml poetry build cleanup +LATEST_VERSION=$(basename $(ls -rt dist/*.whl | tail -n 1)) function run_on_pod() { if [ $# -lt 1 ]; then diff --git a/tests/flows/test_wireless.py b/tests/flows/test_wireless.py index 11e496cf..b6a58415 100644 --- a/tests/flows/test_wireless.py +++ b/tests/flows/test_wireless.py @@ -61,7 +61,7 @@ def store_snapshot_selector(state: RootState) -> WiFiState: app = MenuApp() app_context.set_app(app) - load_services(['camera', 'wifi', 'notifications']) + load_services(['camera', 'display', 'notifications', 'wifi']) @wait_for(wait=wait_fixed(1), run_async=True) def check_icon(expected_icon: str) -> None: 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 729b365a..4a89bf59 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 @@ -7,7 +7,7 @@ "capture_volume": 0.5, "is_capture_mute": false, "is_playback_mute": false, - "playback_volume": 0.5 + "playback_volume": 0.15 }, "camera": { "_type": "CameraState", @@ -15,8 +15,12 @@ "is_viewfinder_active": false, "queue": [] }, + "display": { + "_type": "DisplayState", + "is_paused": false + }, "docker": { - "_id": "b5d32b1666194cb1d71037d1b83e90ec", + "_id": "a0116be5ab0c1681c8f8e3d0d3290a4c", "_type": "DockerState", "home_assistant": { "_type": "ImageState", @@ -577,7 +581,7 @@ { "_type": "DispatchItem", "action": ">", - "background_color": "#FF3F51", + "background_color": "#FFC107", "color": [ 1, 1, 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 3f0dc76c..93528b7e 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 @@ -7,7 +7,7 @@ "capture_volume": 0.5, "is_capture_mute": true, "is_playback_mute": false, - "playback_volume": 0.5 + "playback_volume": 0.15 }, "camera": { "_type": "CameraState", @@ -15,8 +15,12 @@ "is_viewfinder_active": false, "queue": [] }, + "display": { + "_type": "DisplayState", + "is_paused": false + }, "docker": { - "_id": "b5d32b1666194cb1d71037d1b83e90ec", + "_id": "a0116be5ab0c1681c8f8e3d0d3290a4c", "_type": "DockerState", "home_assistant": { "_type": "ImageState", @@ -577,7 +581,7 @@ { "_type": "DispatchItem", "action": ">", - "background_color": "#FF3F51", + "background_color": "#FFC107", "color": [ 1, 1, @@ -642,7 +646,7 @@ { "_type": "DispatchItem", "action": ">", - "background_color": "#FF3F51", + "background_color": "#FFC107", "color": [ 1, 1, diff --git a/tests/integration/results/test_services/all_services_register/window-desktop-000.hash b/tests/integration/results/test_services/all_services_register/window-desktop-000.hash index 37a9d8fc..7e1cdb91 100644 --- a/tests/integration/results/test_services/all_services_register/window-desktop-000.hash +++ b/tests/integration/results/test_services/all_services_register/window-desktop-000.hash @@ -1,2 +1,2 @@ // window-desktop-000 -12cac7ceb6198b823530c73a9965c46a793f38eccb63aa77d51490cf8c9b72e5 +3620d71f464832b8c78da353873df66e3f2daf54a324150709b4e8190b6dbafb diff --git a/tests/integration/results/test_services/all_services_register/window-rpi-000.hash b/tests/integration/results/test_services/all_services_register/window-rpi-000.hash index 2f78b37e..6fa41853 100644 --- a/tests/integration/results/test_services/all_services_register/window-rpi-000.hash +++ b/tests/integration/results/test_services/all_services_register/window-rpi-000.hash @@ -1,2 +1,2 @@ // window-rpi-000 -b64fc25d16a3142afc786cd59c2700fc0b0abbb85ed8ea209e3faaff64c73713 +006d4f47428eb62a54e3111ed4ffae84fbf14a4baff55a93a213475f7457a13b diff --git a/tests/integration/test_services.py b/tests/integration/test_services.py index e833d1ea..ed4bbe88 100644 --- a/tests/integration/test_services.py +++ b/tests/integration/test_services.py @@ -13,6 +13,7 @@ ALL_SERVICES_IDS = [ 'audio', 'camera', + 'display', 'docker', 'ethernet', 'ip', diff --git a/ubo_app/display.py b/ubo_app/display.py index 929bf38a..4e202a8a 100644 --- a/ubo_app/display.py +++ b/ubo_app/display.py @@ -2,11 +2,15 @@ from __future__ import annotations -import atexit from typing import TYPE_CHECKING, cast import numpy as np from adafruit_rgb_display.st7789 import ST7789 +from fake import Fake + +from ubo_app.store.main import store +from ubo_app.store.services.display import DisplayRenderEvent +from ubo_app.utils import IS_RPI if TYPE_CHECKING: from threading import Thread @@ -14,41 +18,29 @@ from numpy._typing import NDArray -from fake import Fake - -from ubo_app.constants import BYTES_PER_PIXEL -from ubo_app.utils import IS_RPI - if IS_RPI: import board import digitalio + from ubo_app.constants import HEIGHT, WIDTH + cs_pin = digitalio.DigitalInOut(board.CE0) dc_pin = digitalio.DigitalInOut(board.D25) reset_pin = digitalio.DigitalInOut(board.D24) spi = board.SPI() - - -def setup_display() -> ST7789: - """Set the display for the Raspberry Pi.""" - if IS_RPI: - from ubo_app.constants import HEIGHT, WIDTH - - display = ST7789( - spi, - height=HEIGHT, - width=WIDTH, - y_offset=80, - x_offset=0, - cs=cs_pin, - dc=dc_pin, - rst=reset_pin, - baudrate=60000000, - ) - else: - display = cast(ST7789, Fake()) - - return display + display = ST7789( + spi, + height=HEIGHT, + width=WIDTH, + y_offset=80, + x_offset=0, + cs=cs_pin, + dc=dc_pin, + rst=reset_pin, + baudrate=60000000, + ) +else: + display = cast(ST7789, Fake()) def render_on_display( @@ -59,89 +51,46 @@ def render_on_display( last_render_thread: Thread, ) -> None: """Transfer data to the display via SPI controller.""" - if IS_RPI and state.is_running: - from ubo_app.logging import logger - - logger.verbose('Rendering frame', extra={'data_hash': data_hash}) - - data_ = data.astype(np.uint16) - color = ( - ((data_[:, :, 0] & 0xF8) << 8) - | ((data_[:, :, 1] & 0xFC) << 3) - | (data_[:, :, 2] >> 3) - ) - data_bytes = bytes( - np.dstack(((color >> 8) & 0xFF, color & 0xFF)).flatten().tolist(), - ) - - # Wait for the last render thread to finish - if last_render_thread: - last_render_thread.join() - - # Only render when running on a Raspberry Pi - state.block(rectangle, data_bytes) - - -class _DisplayState: - """The state of the display.""" - - is_running = True - display = setup_display() - - def __init__(self: _DisplayState, splash_screen: bytes | None = None) -> None: - if IS_RPI: - from RPi import GPIO # pyright: ignore [reportMissingModuleSource] - - GPIO.setmode(GPIO.BCM) - GPIO.setup(26, GPIO.OUT) - GPIO.output(26, GPIO.HIGH) - - from ubo_app.constants import HEIGHT, WIDTH - - self.block( - (0, 0, WIDTH - 1, HEIGHT - 1), - bytes(WIDTH * HEIGHT * BYTES_PER_PIXEL) - if splash_screen is None - else splash_screen, - ) - - atexit.register(self.turn_off) - - def pause(self: _DisplayState) -> None: - """Pause the display.""" - self.is_running = False - - def resume(self: _DisplayState) -> None: - """Resume the display.""" - self.is_running = True - - def turn_off(self: _DisplayState) -> None: - """Destroy the display.""" - from ubo_app.constants import HEIGHT, WIDTH - - self.block( - (0, 0, WIDTH - 1, HEIGHT - 1), - np.zeros((WIDTH, HEIGHT, BYTES_PER_PIXEL), dtype=np.uint8).tobytes(), - ) - - if IS_RPI: - from RPi import GPIO # pyright: ignore [reportMissingModuleSource] + data_ = data.astype(np.uint16) + color = ( + ((data_[:, :, 0] & 0xF8) << 8) + | ((data_[:, :, 1] & 0xFC) << 3) + | (data_[:, :, 2] >> 3) + ) + data_bytes = bytes( + np.dstack(((color >> 8) & 0xFF, color & 0xFF)).flatten().tolist(), + ) + if last_render_thread: + last_render_thread.join() + render_block(rectangle, data_bytes) + store.dispatch( + DisplayRenderEvent( + data=data.tobytes(), + data_hash=data_hash, + rectangle=rectangle, + ), + ) + + +@store.view(lambda state: state.display.is_paused) +def render_block( + is_paused: bool, # noqa: FBT001 + rectangle: tuple[int, int, int, int], + data_bytes: bytes, + *, + bypass_pause: bool = False, +) -> None: + """Block the display.""" + if not is_paused or bypass_pause: + display._block(*rectangle, data_bytes) # noqa: SLF001 - GPIO.setmode(GPIO.BCM) - GPIO.setup(26, GPIO.OUT) - GPIO.output(26, GPIO.LOW) - GPIO.cleanup(26) - def block( - self: _DisplayState, - rectangle: tuple[int, int, int, int], - data_bytes: bytes, - *, - bypass_pause: bool = False, - ) -> None: - """Block the display.""" - if self.is_running or bypass_pause: - self.display._block(*rectangle, data_bytes) # noqa: SLF001 +def turn_off() -> None: + """Turn off the display.""" + display._block = lambda *args, **kwargs: (args, kwargs) # noqa: SLF001 + render_blank() -state = _DisplayState() +def render_blank() -> None: + """Render a blank screen.""" + display.fill(0) diff --git a/ubo_app/services/000-display/reducer.py b/ubo_app/services/000-display/reducer.py new file mode 100644 index 00000000..7fbddf02 --- /dev/null +++ b/ubo_app/services/000-display/reducer.py @@ -0,0 +1,36 @@ +# ruff: noqa: D100, D101, D102, D103, D104, D107, N999 +from __future__ import annotations + +from dataclasses import replace + +from redux import ( + InitAction, + InitializationActionError, +) + +from ubo_app.store.services.display import ( + DisplayAction, + DisplayPauseAction, + DisplayResumeAction, + DisplayState, +) + +Action = InitAction | DisplayAction + + +def reducer( + state: DisplayState | None, + action: Action, +) -> DisplayState: + if state is None: + if isinstance(action, InitAction): + return DisplayState() + raise InitializationActionError(action) + + if isinstance(action, DisplayPauseAction): + return replace(state, is_paused=True) + + if isinstance(action, DisplayResumeAction): + return replace(state, is_paused=False) + + return state diff --git a/ubo_app/services/000-display/setup.py b/ubo_app/services/000-display/setup.py new file mode 100644 index 00000000..0862788d --- /dev/null +++ b/ubo_app/services/000-display/setup.py @@ -0,0 +1,37 @@ +"""Sets up the display for the Raspberry Pi and manages its state.""" + +from __future__ import annotations + +import atexit + +from ubo_app import display +from ubo_app.utils import IS_RPI + +splash_screen = None + + +def turn_off() -> None: + """Destroy the display.""" + if IS_RPI: + from RPi import GPIO # pyright: ignore [reportMissingModuleSource] + + display.render_blank() + + GPIO.setmode(GPIO.BCM) + GPIO.setup(26, GPIO.OUT) + GPIO.output(26, GPIO.LOW) + GPIO.cleanup(26) + + +def init_service() -> None: + """Initialize the display service.""" + if IS_RPI: + from RPi import GPIO # pyright: ignore [reportMissingModuleSource] + + GPIO.setmode(GPIO.BCM) + GPIO.setup(26, GPIO.OUT) + GPIO.output(26, GPIO.HIGH) + + display.render_blank() + + atexit.register(turn_off) diff --git a/ubo_app/services/000-display/ubo_handle.py b/ubo_app/services/000-display/ubo_handle.py new file mode 100644 index 00000000..0a270c37 --- /dev/null +++ b/ubo_app/services/000-display/ubo_handle.py @@ -0,0 +1,22 @@ +# ruff: noqa: D100, D101, D102, D103, D104, D107, N999 +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ubo_handle import Service, register + + +def setup(service: Service) -> None: + from reducer import reducer + from setup import init_service + + service.register_reducer(reducer) + init_service() + + +register( + service_id='display', + label='Display', + setup=setup, +) diff --git a/ubo_app/services/040-camera/setup.py b/ubo_app/services/040-camera/setup.py index b6fcf0ad..d9689e36 100644 --- a/ubo_app/services/040-camera/setup.py +++ b/ubo_app/services/040-camera/setup.py @@ -24,6 +24,7 @@ CameraStartViewfinderEvent, CameraStopViewfinderEvent, ) +from ubo_app.store.services.display import DisplayPauseAction, DisplayResumeAction from ubo_app.utils import IS_RPI from ubo_app.utils.async_ import create_task @@ -163,7 +164,7 @@ def feed_viewfinder(picamera2: Picamera2 | None) -> None: np.dstack(((color >> 8) & 0xFF, color & 0xFF)).flatten().tolist(), ) - display.state.block( + display.render_block( (0, 0, width - 1, height - 1), data_bytes, bypass_pause=True, @@ -187,15 +188,17 @@ def feed_viewfinder_locked(_: object) -> None: feed_viewfinder_scheduler = Clock.schedule_interval(feed_viewfinder_locked, 0.04) - display.state.pause() + store.dispatch(DisplayPauseAction()) def handle_stop_viewfinder() -> None: with fs_lock: nonlocal is_running is_running = False feed_viewfinder_scheduler.cancel() - store.dispatch(CloseApplicationEvent(application=application)) - display.state.resume() + store.dispatch( + CloseApplicationEvent(application=application), + DisplayResumeAction(), + ) cancel_subscription() if picamera2: picamera2.stop() diff --git a/ubo_app/services/050-users/setup.py b/ubo_app/services/050-users/setup.py index 24060d2f..030e6d7a 100644 --- a/ubo_app/services/050-users/setup.py +++ b/ubo_app/services/050-users/setup.py @@ -237,7 +237,7 @@ def users_menu(state: UsersState) -> Menu: label='Reset Password', icon='󰯄', operation=UsersResetPasswordAction(id=user.id), - background_color=DANGER_COLOR, + background_color=WARNING_COLOR, ), DispatchItem( label='Delete', diff --git a/ubo_app/setup.py b/ubo_app/setup.py index 6c15a649..2cb005a3 100644 --- a/ubo_app/setup.py +++ b/ubo_app/setup.py @@ -147,8 +147,7 @@ def signal_handler(signum: int, _: object) -> None: clear_signal_handlers() - display.state.turn_off() - display.state.pause() + display.turn_off() if signum == signal.SIGINT: logger.info('Exiting gracefully, sending the signal again will force exit!') diff --git a/ubo_app/store/main.py b/ubo_app/store/main.py index be87a44c..43df8772 100644 --- a/ubo_app/store/main.py +++ b/ubo_app/store/main.py @@ -32,6 +32,7 @@ from ubo_app.store.core.reducer import reducer as main_reducer from ubo_app.store.services.audio import AudioAction, AudioEvent, AudioState from ubo_app.store.services.camera import CameraAction, CameraEvent, CameraState +from ubo_app.store.services.display import DisplayAction, DisplayEvent, DisplayState from ubo_app.store.services.docker import DockerAction, DockerState from ubo_app.store.services.ip import IpAction, IpEvent, IpState from ubo_app.store.services.keypad import KeypadAction, KeypadEvent @@ -82,6 +83,7 @@ class RootState(BaseCombineReducerState): audio: AudioState camera: CameraState + display: DisplayState docker: DockerState ip: IpState lightdm: LightDMState @@ -114,6 +116,7 @@ class SnapshotEvent(BaseEvent): ... # Services Actions | AudioAction | CameraAction + | DisplayAction | DockerAction | IpAction | KeypadAction @@ -135,6 +138,7 @@ class SnapshotEvent(BaseEvent): ... # Services Events | AudioEvent | CameraEvent + | DisplayEvent | IpEvent | KeypadEvent | NotificationsEvent diff --git a/ubo_app/store/services/audio.py b/ubo_app/store/services/audio.py index 2af0ff7f..6fef1649 100644 --- a/ubo_app/store/services/audio.py +++ b/ubo_app/store/services/audio.py @@ -72,7 +72,7 @@ class AudioState(Immutable): playback_volume: float = field( default_factory=lambda: read_from_persistent_store( 'audio_state:playback_volume', - default=0.5, + default=0.15, ), ) is_playback_mute: bool = field( diff --git a/ubo_app/store/services/display.py b/ubo_app/store/services/display.py new file mode 100644 index 00000000..f830a454 --- /dev/null +++ b/ubo_app/store/services/display.py @@ -0,0 +1,27 @@ +# ruff: noqa: D100, D101, D102, D103, D104, D107 +from __future__ import annotations + +from immutable import Immutable +from redux import BaseAction, BaseEvent + + +class DisplayAction(BaseAction): ... + + +class DisplayEvent(BaseEvent): ... + + +class DisplayPauseAction(DisplayAction): ... + + +class DisplayResumeAction(DisplayAction): ... + + +class DisplayRenderEvent(DisplayEvent): + data: bytes + data_hash: int + rectangle: tuple[int, int, int, int] + + +class DisplayState(Immutable): + is_paused: bool = False