diff --git a/.github/workflows/integration_delivery.yml b/.github/workflows/integration_delivery.yml index 106f486f..38ffffa1 100644 --- a/.github/workflows/integration_delivery.yml +++ b/.github/workflows/integration_delivery.yml @@ -16,7 +16,7 @@ jobs: - uses: actions/checkout@v4 name: Checkout - - name: Save Cached Poetry + - name: Load Cached Poetry id: cached-poetry uses: actions/cache@v4 with: @@ -125,7 +125,7 @@ jobs: - name: Run Tests run: | - poetry run poe test --make-screenshots --cov-report=xml --cov-report=html -n auto + poetry run poe test --make-screenshots --cov-report=xml --cov-report=html -n auto --log-level=DEBUG - name: Collect Window Screenshots uses: actions/upload-artifact@v4 @@ -186,7 +186,9 @@ jobs: - name: Add SENTRY_DSN to .env run: | - echo "SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> .env + echo "SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> ubo_app/.env + pwd + cat ubo_app/.env - name: Build run: poetry build @@ -306,9 +308,6 @@ jobs: name: binary path: /build/dist - - run: | - ls -l /build/dist - - name: Generate Image URL and Checksum id: generate_image_url run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index e504bc0e..ca60a233 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## Version 0.12.0 + +- feat(core): add `qrcode_input` utility function to let developers easily take input + using qrcode through camera, wireless flow now also uses this function, in dev + environment it reads the text from `/tmp/qrcode_input.txt` instead +- feat(docker): add `environment_variables` and `command` to image description, + both allowing functions as their values, these functions get evaluated when the + image is being created +- refactor(core): improve `load_services` so that `ubo_handle.py` files are enforced + to be pure and can't import anything, services can start importing once their + thread is started. +- fix(image): add `apt remove orca` to image creation scripts #48 +- fix(image): +- refactor(test): stability fixture now stops after 4 seconds +- feat(test): introduce `UBO_DEBUG_TEST_UUID` environment variable for tracking + the sequence of uuid generations in the tests, it prints the traceback for each + call to `uuid.uuid4` if it is set + ## Version 0.11.7 - refactor(style): update `ubo-gui` to the latest version and set placeholder diff --git a/poetry.lock b/poetry.lock index 5fb12b69..fe297f97 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2,18 +2,18 @@ [[package]] name = "adafruit-blinka" -version = "8.37.0" +version = "8.38.0" description = "CircuitPython APIs for non-CircuitPython versions of Python such as CPython on Linux and MicroPython." optional = false python-versions = ">=3.7.0" files = [ - {file = "Adafruit-Blinka-8.37.0.tar.gz", hash = "sha256:e8ab9e03fe131305be4ea20b145dc6461fa9c6afefe0e617f99e5a1b8ab92d66"}, - {file = "Adafruit_Blinka-8.37.0-py3-none-any.whl", hash = "sha256:0fe2ec27a0cba86fc1b12760277af192fb3792fd190226db8b3ea1cbd90c4c77"}, + {file = "Adafruit-Blinka-8.38.0.tar.gz", hash = "sha256:bb104a598a213053bf5624ed305579ef021efee633dfe91fd1d74c6ff2abdbe3"}, + {file = "Adafruit_Blinka-8.38.0-py3-none-any.whl", hash = "sha256:6e131b3c2379d67eb185543c4a3d7dfff7ddf3db093f7ad094b1a1423813e6be"}, ] [package.dependencies] adafruit-circuitpython-typing = "*" -Adafruit-PlatformDetect = ">=3.53.0" +Adafruit-PlatformDetect = ">=3.62.0" Adafruit-PureIO = ">=1.1.7" pyftdi = ">=0.40.0" @@ -1257,13 +1257,13 @@ files = [ [[package]] name = "pyright" -version = "1.1.355" +version = "1.1.356" description = "Command line wrapper for pyright" optional = false python-versions = ">=3.7" files = [ - {file = "pyright-1.1.355-py3-none-any.whl", hash = "sha256:bf30b6728fd68ae7d09c98292b67152858dd89738569836896df786e52b5fe48"}, - {file = "pyright-1.1.355.tar.gz", hash = "sha256:dca4104cd53d6484e6b1b50b7a239ad2d16d2ffd20030bcf3111b56f44c263bf"}, + {file = "pyright-1.1.356-py3-none-any.whl", hash = "sha256:a101b0f375f93d7082f9046cfaa7ba15b7cf8e1939ace45e984c351f6e8feb99"}, + {file = "pyright-1.1.356.tar.gz", hash = "sha256:f05b8b29d06b96ed4a0885dad5a31d9dff691ca12b2f658249f583d5f2754021"}, ] [package.dependencies] @@ -1439,13 +1439,13 @@ typing-extensions = ">=4.10.0,<5.0.0" [[package]] name = "python-redux" -version = "0.13.2" +version = "0.14.0" description = "Redux implementation for Python" optional = false python-versions = "<4.0,>=3.11" files = [ - {file = "python_redux-0.13.2-py3-none-any.whl", hash = "sha256:12ab0ddd9a7f074c59dd80835b92e3db8b4e39417e12c73dc91e4bd62fb99988"}, - {file = "python_redux-0.13.2.tar.gz", hash = "sha256:08b29652ac50199bad137cb2dc4f68bb2e70f0147ad2990776fad3a22465d43b"}, + {file = "python_redux-0.14.0-py3-none-any.whl", hash = "sha256:ffdc591ba20295a88598eb135a968058b2799ea91faf2d614cf8ab56b38e7c7e"}, + {file = "python_redux-0.14.0.tar.gz", hash = "sha256:a7004ada07eafe491428c14006da89ec38d24dc01fec911d03573a2901104cd9"}, ] [package.dependencies] @@ -1633,13 +1633,13 @@ files = [ [[package]] name = "sentry-sdk" -version = "1.43.0" +version = "1.44.0" description = "Python client for Sentry (https://sentry.io)" optional = false python-versions = "*" files = [ - {file = "sentry-sdk-1.43.0.tar.gz", hash = "sha256:41df73af89d22921d8733714fb0fc5586c3461907e06688e6537d01a27e0e0f6"}, - {file = "sentry_sdk-1.43.0-py2.py3-none-any.whl", hash = "sha256:8d768724839ca18d7b4c7463ef7528c40b7aa2bfbf7fe554d5f9a7c044acfd36"}, + {file = "sentry-sdk-1.44.0.tar.gz", hash = "sha256:f7125a9235795811962d52ff796dc032cd1d0dd98b59beaced8380371cd9c13c"}, + {file = "sentry_sdk-1.44.0-py2.py3-none-any.whl", hash = "sha256:eb65289da013ca92fad2694851ad2f086aa3825e808dc285bd7dcaf63602bb18"}, ] [package.dependencies] @@ -1743,13 +1743,13 @@ files = [ [[package]] name = "ubo-gui" -version = "0.10.3" +version = "0.10.4" description = "GUI sdk for Ubo Pod" optional = true python-versions = "<4.0,>=3.11" files = [ - {file = "ubo_gui-0.10.3-py3-none-any.whl", hash = "sha256:09380ef065a93864882d3299d02ac926409a5a1c953680fc43fe7b597756be81"}, - {file = "ubo_gui-0.10.3.tar.gz", hash = "sha256:54f1bb5c89503b76a12d70f31374dec4dfc3b214ff1502eaf69c9f47d1d8e766"}, + {file = "ubo_gui-0.10.4-py3-none-any.whl", hash = "sha256:736ae38e1c73641723116e88426b0affb42c758f26df09db0aee062e98e0b307"}, + {file = "ubo_gui-0.10.4.tar.gz", hash = "sha256:569872c839e905ac1808d5aaf62a003b6b4c31aeebc842fe56addced86e2a624"}, ] [package.dependencies] @@ -1891,4 +1891,4 @@ dev = ["ubo-gui", "ubo-gui"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "1bcf5e7d78323c08ff64e08b59d015fc4ddefd7b74f0538f1b2164a0efa5a274" +content-hash = "1321f5e5471a41d7667c11e7781b8213389d896a6758ecb5a6d51b7c69beb235" diff --git a/pyproject.toml b/pyproject.toml index 37ec5aea..e484cc30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "ubo-app" -version = "0.11.7" +version = "0.12.0" description = "Ubo main app, running on device initialization. A platform for running other apps." authors = ["Sassan Haradji "] license = "Apache-2.0" @@ -18,14 +18,14 @@ priority = "primary" python = "^3.11" psutil = "^5.9.8" ubo-gui = [ - { version = "^0.10.3", markers = "extra=='default'", extras = [ + { version = "^0.10.4", markers = "extra=='default'", extras = [ 'default', ] }, - { version = "^0.10.3", markers = "extra=='dev'", extras = [ + { version = "^0.10.4", markers = "extra=='dev'", extras = [ 'dev', ] }, ] -python-redux = "^0.13.2" +python-redux = "^0.14.0" pyzbar = "^0.1.9" sdbus-networkmanager = { version = "^2.0.0", markers = "platform_machine=='aarch64'" } rpi_ws281x = { version = "^5.0.0", markers = "platform_machine=='aarch64'" } @@ -45,13 +45,13 @@ optional = true [tool.poetry.group.dev.dependencies] poethepoet = "^0.24.4" -pyright = "^1.1.354" +pyright = "^1.1.356" pytest = "^8.0.0" pytest-asyncio = "^0.23.5.post1" pytest-cov = "^4.1.0" pytest-timeout = "^2.3.1" pytest-xdist = "^3.5.0" -ruff = "^0.3.3" +ruff = "^0.3.4" tenacity = "^8.2.3" toml = "^0.10.2" pytest-mock = "^3.14.0" @@ -98,6 +98,7 @@ multiline-quotes = "double" [tool.ruff.lint.per-file-ignores] "tests/*" = ["S101", "PLR0913"] +"ubo_app/services/*/ubo_handle.py" = ["TCH004"] [tool.ruff.format] quote-style = 'single' diff --git a/scripts/packer/image.pkr.hcl b/scripts/packer/image.pkr.hcl index 1142590d..2f9bdcf5 100644 --- a/scripts/packer/image.pkr.hcl +++ b/scripts/packer/image.pkr.hcl @@ -37,6 +37,9 @@ build { provisioner "shell" { inline = [ + "ls -ld /boot", + "ls -l /boot", + "ls -l /boot/firmware/", "chmod +x /install.sh", "/install.sh --for-packer --with-docker --source=/ubo_app-${var.ubo_app_version}-py3-none-any.whl", "rm /install.sh /ubo_app-${var.ubo_app_version}-py3-none-any.whl", diff --git a/ubo_app/.test.env b/tests/.env similarity index 73% rename from ubo_app/.test.env rename to tests/.env index 0d6a0a4d..38f363ff 100644 --- a/ubo_app/.test.env +++ b/tests/.env @@ -1,3 +1,4 @@ UBO_DEBUG=False UBO_DEBUG_DOCKER=False DOCKER_HOST=/var/run/docker.sock +UBO_DEBUG_TEST_UUID=False diff --git a/tests/conftest.py b/tests/conftest.py index 613a8f9a..e29425bc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,26 +5,19 @@ import atexit import datetime import random -import socket import sys import tracemalloc -import uuid from pathlib import Path +from typing import cast import dotenv import pytest -from redux_pytest.fixtures import ( - StoreMonitor, - Waiter, - WaitFor, - needs_finish, - store_monitor, - store_snapshot, - wait_for, -) + +dotenv.load_dotenv(Path(__file__).parent / '.env') pytest.register_assert_rewrite('tests.fixtures') +# isort: off from tests.fixtures import ( # noqa: E402 AppContext, LoadServices, @@ -37,7 +30,17 @@ window_snapshot, ) -dotenv.load_dotenv(Path(__file__).parent / '.test.env') +from redux_pytest.fixtures import ( # noqa: E402 + StoreMonitor, + Waiter, + WaitFor, + needs_finish, + store_monitor, + store_snapshot, + wait_for, +) +# isort: on + fixtures = ( AppContext, @@ -65,6 +68,20 @@ def pytest_addoption(parser: pytest.Parser) -> None: parser.addoption('--make-screenshots', action='store_true') +@pytest.fixture(autouse=True) +def _logger() -> None: + import logging + + from ubo_app.logging import ExtraFormatter + + extra_formatter = ExtraFormatter() + + for handler in logging.getLogger().handlers: + if handler.formatter: + handler.formatter.format = extra_formatter.format + cast(ExtraFormatter, handler.formatter).def_keys = extra_formatter.def_keys + + @pytest.fixture(autouse=True) def _monkeypatch(monkeypatch: pytest.MonkeyPatch) -> None: """Mock external resources.""" @@ -73,6 +90,8 @@ def _monkeypatch(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(atexit, 'register', lambda _: None) + import socket + import psutil monkeypatch.setattr(psutil, 'cpu_percent', lambda **_: 50) @@ -102,6 +121,16 @@ def _monkeypatch(monkeypatch: pytest.MonkeyPatch) -> None: 'create_connection', lambda *args, **kwargs: Fake(args, kwargs), ) + original_socket_socket = socket.socket + from ubo_app.constants import SERVER_SOCKET_PATH + + monkeypatch.setattr( + socket, + 'socket', + lambda *args, **kwargs: Fake(args, kwargs) + if args[0] == SERVER_SOCKET_PATH + else original_socket_socket(*args, **kwargs), + ) class FakeDockerClient: def ping(self: FakeDockerClient) -> bool: @@ -116,12 +145,44 @@ def now(cls: type[DateTime], tz: datetime.tzinfo | None = None) -> DateTime: return DateTime(2023, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc) monkeypatch.setattr(datetime, 'datetime', DateTime) - monkeypatch.setattr(uuid, 'uuid4', lambda: uuid.UUID(int=random.getrandbits(128))) - - monkeypatch.setattr('importlib.metadata.version', lambda _: '0.0.0') from ubo_app.utils.fake import Fake + counter = 0 + + def debug_uuid4() -> Fake: + nonlocal counter + counter += 1 + import logging + import traceback + + logging.debug( + '`uuid.uuid4` is being called', + extra={ + 'traceback': '\n'.join(traceback.format_stack()[:-1]), + 'counter': counter, + }, + ) + + result = Fake() + result.hex = f'{counter}' + return result + + from ubo_app.constants import DEBUG_MODE_TEST_UUID + + if DEBUG_MODE_TEST_UUID: + monkeypatch.setattr('uuid.uuid4', debug_uuid4) + else: + import uuid + + monkeypatch.setattr( + uuid, + 'uuid4', + lambda: uuid.UUID(int=random.getrandbits(128)), + ) + + monkeypatch.setattr('importlib.metadata.version', lambda _: '0.0.0') + class FakeUpdateResponse(Fake): async def json(self: FakeUpdateResponse) -> dict[str, object]: return { @@ -152,4 +213,4 @@ class FakeSensorModule(Fake): sys.modules['i2c'] = Fake() -_ = fixtures, _monkeypatch +_ = fixtures, _logger, _monkeypatch diff --git a/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-000.jsonc b/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-000.jsonc index de86509d..f99ea816 100644 --- a/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-000.jsonc +++ b/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-000.jsonc @@ -12,7 +12,7 @@ 1, 1 ], - "icon": "\udb80\udf5c", + "icon": "󰍜", "is_short": true, "label": "", "sub_menu": { @@ -25,7 +25,7 @@ 1, 1 ], - "icon": "\udb80\udc3b", + "icon": "󰀻", "is_short": false, "label": "Apps", "sub_menu": { @@ -42,7 +42,7 @@ 1, 1 ], - "icon": "\ue690", + "icon": "", "is_short": false, "label": "Settings", "sub_menu": { @@ -55,7 +55,7 @@ 1, 1 ], - "icon": "\udb81\udda9", + "icon": "󰖩", "is_short": false, "label": "WiFi", "sub_menu": { @@ -69,7 +69,7 @@ 1, 1 ], - "icon": "\udb85\udec3", + "icon": "󱛃", "is_short": false, "label": "Add" }, @@ -82,7 +82,7 @@ 1, 1 ], - "icon": "\udb85\uddab", + "icon": "󱖫", "is_short": false, "label": "Select" } @@ -104,23 +104,18 @@ 1, 1 ], - "icon": "\uf129", + "icon": "", "is_short": false, "label": "About", "sub_menu": { "heading": "Ubo v0.0.0", "items": [ { - "background_color": "#00000000", - "color": [ - 1, - 1, - 1, - 1 - ], - "icon": "\udb82\udf2c", + "background_color": "#03F7AE", + "color": "#000000", + "icon": "󰄬", "is_short": false, - "label": "Checking for updates..." + "label": "Already up to date!" } ], "placeholder": null, @@ -136,7 +131,7 @@ { "background_color": "#68B7FF", "color": "white", - "icon": "\ueaa2", + "icon": "", "is_short": true, "label": "", "sub_menu": { @@ -154,7 +149,7 @@ 1, 1 ], - "icon": "\udb81\udc25", + "icon": "󰐥", "is_short": true, "label": "Turn off" } @@ -172,14 +167,14 @@ "color": "white", "id": "wifi:state", "priority": -12, - "symbol": "\udb81\uddaa" + "symbol": "󰖪" } ] }, "update_manager": { - "current_version": null, - "latest_version": null, - "update_status": "checking" + "current_version": "0.0.0", + "latest_version": "0.0.0", + "update_status": "up_to_date" }, "wifi": { "connections": [], diff --git a/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-001.jsonc b/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-001.jsonc index d6b69653..5957f7fc 100644 --- a/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-001.jsonc +++ b/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-001.jsonc @@ -12,7 +12,7 @@ 1, 1 ], - "icon": "\udb80\udf5c", + "icon": "󰍜", "is_short": true, "label": "", "sub_menu": { @@ -25,7 +25,7 @@ 1, 1 ], - "icon": "\udb80\udc3b", + "icon": "󰀻", "is_short": false, "label": "Apps", "sub_menu": { @@ -42,7 +42,7 @@ 1, 1 ], - "icon": "\ue690", + "icon": "", "is_short": false, "label": "Settings", "sub_menu": { @@ -55,7 +55,7 @@ 1, 1 ], - "icon": "\udb81\udda9", + "icon": "󰖩", "is_short": false, "label": "WiFi", "sub_menu": { @@ -69,7 +69,7 @@ 1, 1 ], - "icon": "\udb85\udec3", + "icon": "󱛃", "is_short": false, "label": "Add" }, @@ -82,7 +82,7 @@ 1, 1 ], - "icon": "\udb85\uddab", + "icon": "󱖫", "is_short": false, "label": "Select" } @@ -104,23 +104,18 @@ 1, 1 ], - "icon": "\uf129", + "icon": "", "is_short": false, "label": "About", "sub_menu": { "heading": "Ubo v0.0.0", "items": [ { - "background_color": "#00000000", - "color": [ - 1, - 1, - 1, - 1 - ], - "icon": "\udb82\udf2c", + "background_color": "#03F7AE", + "color": "#000000", + "icon": "󰄬", "is_short": false, - "label": "Checking for updates..." + "label": "Already up to date!" } ], "placeholder": null, @@ -136,7 +131,7 @@ { "background_color": "#68B7FF", "color": "white", - "icon": "\ueaa2", + "icon": "", "is_short": true, "label": "", "sub_menu": { @@ -154,7 +149,7 @@ 1, 1 ], - "icon": "\udb81\udc25", + "icon": "󰐥", "is_short": true, "label": "Turn off" } @@ -173,14 +168,14 @@ "color": "white", "id": "wifi:state", "priority": -12, - "symbol": "\udb81\uddaa" + "symbol": "󰖪" } ] }, "update_manager": { - "current_version": null, - "latest_version": null, - "update_status": "checking" + "current_version": "0.0.0", + "latest_version": "0.0.0", + "update_status": "up_to_date" }, "wifi": { "connections": [], diff --git a/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-002.jsonc b/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-002.jsonc index c5ffffce..edc905ef 100644 --- a/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-002.jsonc +++ b/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-002.jsonc @@ -12,7 +12,7 @@ 1, 1 ], - "icon": "\udb80\udf5c", + "icon": "󰍜", "is_short": true, "label": "", "sub_menu": { @@ -25,7 +25,7 @@ 1, 1 ], - "icon": "\udb80\udc3b", + "icon": "󰀻", "is_short": false, "label": "Apps", "sub_menu": { @@ -42,7 +42,7 @@ 1, 1 ], - "icon": "\ue690", + "icon": "", "is_short": false, "label": "Settings", "sub_menu": { @@ -55,7 +55,7 @@ 1, 1 ], - "icon": "\udb81\udda9", + "icon": "󰖩", "is_short": false, "label": "WiFi", "sub_menu": { @@ -69,7 +69,7 @@ 1, 1 ], - "icon": "\udb85\udec3", + "icon": "󱛃", "is_short": false, "label": "Add" }, @@ -82,7 +82,7 @@ 1, 1 ], - "icon": "\udb85\uddab", + "icon": "󱖫", "is_short": false, "label": "Select" } @@ -104,23 +104,18 @@ 1, 1 ], - "icon": "\uf129", + "icon": "", "is_short": false, "label": "About", "sub_menu": { "heading": "Ubo v0.0.0", "items": [ { - "background_color": "#00000000", - "color": [ - 1, - 1, - 1, - 1 - ], - "icon": "\udb82\udf2c", + "background_color": "#03F7AE", + "color": "#000000", + "icon": "󰄬", "is_short": false, - "label": "Checking for updates..." + "label": "Already up to date!" } ], "placeholder": null, @@ -136,7 +131,7 @@ { "background_color": "#68B7FF", "color": "white", - "icon": "\ueaa2", + "icon": "", "is_short": true, "label": "", "sub_menu": { @@ -154,7 +149,7 @@ 1, 1 ], - "icon": "\udb81\udc25", + "icon": "󰐥", "is_short": true, "label": "Turn off" } @@ -174,14 +169,14 @@ "color": "white", "id": "wifi:state", "priority": -12, - "symbol": "\udb81\uddaa" + "symbol": "󰖪" } ] }, "update_manager": { - "current_version": null, - "latest_version": null, - "update_status": "checking" + "current_version": "0.0.0", + "latest_version": "0.0.0", + "update_status": "up_to_date" }, "wifi": { "connections": [], diff --git a/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-003.jsonc b/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-003.jsonc index 2cae0239..433eb091 100644 --- a/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-003.jsonc +++ b/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-003.jsonc @@ -12,7 +12,7 @@ 1, 1 ], - "icon": "\udb80\udf5c", + "icon": "󰍜", "is_short": true, "label": "", "sub_menu": { @@ -25,7 +25,7 @@ 1, 1 ], - "icon": "\udb80\udc3b", + "icon": "󰀻", "is_short": false, "label": "Apps", "sub_menu": { @@ -42,7 +42,7 @@ 1, 1 ], - "icon": "\ue690", + "icon": "", "is_short": false, "label": "Settings", "sub_menu": { @@ -55,7 +55,7 @@ 1, 1 ], - "icon": "\udb81\udda9", + "icon": "󰖩", "is_short": false, "label": "WiFi", "sub_menu": { @@ -69,7 +69,7 @@ 1, 1 ], - "icon": "\udb85\udec3", + "icon": "󱛃", "is_short": false, "label": "Add" }, @@ -82,7 +82,7 @@ 1, 1 ], - "icon": "\udb85\uddab", + "icon": "󱖫", "is_short": false, "label": "Select" } @@ -104,23 +104,18 @@ 1, 1 ], - "icon": "\uf129", + "icon": "", "is_short": false, "label": "About", "sub_menu": { "heading": "Ubo v0.0.0", "items": [ { - "background_color": "#00000000", - "color": [ - 1, - 1, - 1, - 1 - ], - "icon": "\udb82\udf2c", + "background_color": "#03F7AE", + "color": "#000000", + "icon": "󰄬", "is_short": false, - "label": "Checking for updates..." + "label": "Already up to date!" } ], "placeholder": null, @@ -136,7 +131,7 @@ { "background_color": "#68B7FF", "color": "white", - "icon": "\ueaa2", + "icon": "", "is_short": true, "label": "", "sub_menu": { @@ -154,7 +149,7 @@ 1, 1 ], - "icon": "\udb81\udc25", + "icon": "󰐥", "is_short": true, "label": "Turn off" } @@ -175,14 +170,14 @@ "color": "white", "id": "wifi:state", "priority": -12, - "symbol": "\udb81\uddaa" + "symbol": "󰖪" } ] }, "update_manager": { - "current_version": null, - "latest_version": null, - "update_status": "checking" + "current_version": "0.0.0", + "latest_version": "0.0.0", + "update_status": "up_to_date" }, "wifi": { "connections": [], diff --git a/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-004.jsonc b/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-004.jsonc index 4ab5458a..320cf87d 100644 --- a/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-004.jsonc +++ b/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-004.jsonc @@ -12,7 +12,7 @@ 1, 1 ], - "icon": "\udb80\udf5c", + "icon": "󰍜", "is_short": true, "label": "", "sub_menu": { @@ -25,7 +25,7 @@ 1, 1 ], - "icon": "\udb80\udc3b", + "icon": "󰀻", "is_short": false, "label": "Apps", "sub_menu": { @@ -42,7 +42,7 @@ 1, 1 ], - "icon": "\ue690", + "icon": "", "is_short": false, "label": "Settings", "sub_menu": { @@ -55,7 +55,7 @@ 1, 1 ], - "icon": "\udb81\udda9", + "icon": "󰖩", "is_short": false, "label": "WiFi", "sub_menu": { @@ -69,7 +69,7 @@ 1, 1 ], - "icon": "\udb85\udec3", + "icon": "󱛃", "is_short": false, "label": "Add" }, @@ -82,7 +82,7 @@ 1, 1 ], - "icon": "\udb85\uddab", + "icon": "󱖫", "is_short": false, "label": "Select" } @@ -104,23 +104,18 @@ 1, 1 ], - "icon": "\uf129", + "icon": "", "is_short": false, "label": "About", "sub_menu": { "heading": "Ubo v0.0.0", "items": [ { - "background_color": "#00000000", - "color": [ - 1, - 1, - 1, - 1 - ], - "icon": "\udb82\udf2c", + "background_color": "#03F7AE", + "color": "#000000", + "icon": "󰄬", "is_short": false, - "label": "Checking for updates..." + "label": "Already up to date!" } ], "placeholder": null, @@ -136,7 +131,7 @@ { "background_color": "#68B7FF", "color": "white", - "icon": "\ueaa2", + "icon": "", "is_short": true, "label": "", "sub_menu": { @@ -154,7 +149,7 @@ 1, 1 ], - "icon": "\udb81\udc25", + "icon": "󰐥", "is_short": true, "label": "Turn off" } @@ -176,14 +171,14 @@ "color": "white", "id": "wifi:state", "priority": -12, - "symbol": "\udb81\uddaa" + "symbol": "󰖪" } ] }, "update_manager": { - "current_version": null, - "latest_version": null, - "update_status": "checking" + "current_version": "0.0.0", + "latest_version": "0.0.0", + "update_status": "up_to_date" }, "wifi": { "connections": [], diff --git a/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-005.jsonc b/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-005.jsonc index 6b602548..92487b80 100644 --- a/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-005.jsonc +++ b/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-005.jsonc @@ -12,7 +12,7 @@ 1, 1 ], - "icon": "\udb80\udf5c", + "icon": "󰍜", "is_short": true, "label": "", "sub_menu": { @@ -25,7 +25,7 @@ 1, 1 ], - "icon": "\udb80\udc3b", + "icon": "󰀻", "is_short": false, "label": "Apps", "sub_menu": { @@ -42,7 +42,7 @@ 1, 1 ], - "icon": "\ue690", + "icon": "", "is_short": false, "label": "Settings", "sub_menu": { @@ -55,7 +55,7 @@ 1, 1 ], - "icon": "\udb81\udda9", + "icon": "󰖩", "is_short": false, "label": "WiFi", "sub_menu": { @@ -69,7 +69,7 @@ 1, 1 ], - "icon": "\udb85\udec3", + "icon": "󱛃", "is_short": false, "label": "Add" }, @@ -82,7 +82,7 @@ 1, 1 ], - "icon": "\udb85\uddab", + "icon": "󱖫", "is_short": false, "label": "Select" } @@ -104,23 +104,18 @@ 1, 1 ], - "icon": "\uf129", + "icon": "", "is_short": false, "label": "About", "sub_menu": { "heading": "Ubo v0.0.0", "items": [ { - "background_color": "#00000000", - "color": [ - 1, - 1, - 1, - 1 - ], - "icon": "\udb82\udf2c", + "background_color": "#03F7AE", + "color": "#000000", + "icon": "󰄬", "is_short": false, - "label": "Checking for updates..." + "label": "Already up to date!" } ], "placeholder": null, @@ -136,7 +131,7 @@ { "background_color": "#68B7FF", "color": "white", - "icon": "\ueaa2", + "icon": "", "is_short": true, "label": "", "sub_menu": { @@ -154,7 +149,7 @@ 1, 1 ], - "icon": "\udb81\udc25", + "icon": "󰐥", "is_short": true, "label": "Turn off" } @@ -175,14 +170,14 @@ "color": "white", "id": "wifi:state", "priority": -12, - "symbol": "\udb81\uddaa" + "symbol": "󰖪" } ] }, "update_manager": { - "current_version": null, - "latest_version": null, - "update_status": "checking" + "current_version": "0.0.0", + "latest_version": "0.0.0", + "update_status": "up_to_date" }, "wifi": { "connections": [], diff --git a/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-006.jsonc b/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-006.jsonc index 1676baf0..1b3e192a 100644 --- a/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-006.jsonc +++ b/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-006.jsonc @@ -12,7 +12,7 @@ 1, 1 ], - "icon": "\udb80\udf5c", + "icon": "󰍜", "is_short": true, "label": "", "sub_menu": { @@ -25,7 +25,7 @@ 1, 1 ], - "icon": "\udb80\udc3b", + "icon": "󰀻", "is_short": false, "label": "Apps", "sub_menu": { @@ -42,7 +42,7 @@ 1, 1 ], - "icon": "\ue690", + "icon": "", "is_short": false, "label": "Settings", "sub_menu": { @@ -55,7 +55,7 @@ 1, 1 ], - "icon": "\udb81\udda9", + "icon": "󰖩", "is_short": false, "label": "WiFi", "sub_menu": { @@ -69,7 +69,7 @@ 1, 1 ], - "icon": "\udb85\udec3", + "icon": "󱛃", "is_short": false, "label": "Add" }, @@ -82,7 +82,7 @@ 1, 1 ], - "icon": "\udb85\uddab", + "icon": "󱖫", "is_short": false, "label": "Select" } @@ -104,23 +104,18 @@ 1, 1 ], - "icon": "\uf129", + "icon": "", "is_short": false, "label": "About", "sub_menu": { "heading": "Ubo v0.0.0", "items": [ { - "background_color": "#00000000", - "color": [ - 1, - 1, - 1, - 1 - ], - "icon": "\udb82\udf2c", + "background_color": "#03F7AE", + "color": "#000000", + "icon": "󰄬", "is_short": false, - "label": "Checking for updates..." + "label": "Already up to date!" } ], "placeholder": null, @@ -136,7 +131,7 @@ { "background_color": "#68B7FF", "color": "white", - "icon": "\ueaa2", + "icon": "", "is_short": true, "label": "", "sub_menu": { @@ -154,7 +149,7 @@ 1, 1 ], - "icon": "\udb81\udc25", + "icon": "󰐥", "is_short": true, "label": "Turn off" } @@ -167,7 +162,7 @@ "Main", "Settings", "WiFi Settings", - "8d723104f77383c13458a748e9bb17bc" + "a3f2c9bf9c6316b950f244556f25e2a2" ] }, "status_icons": { @@ -176,14 +171,14 @@ "color": "white", "id": "wifi:state", "priority": -12, - "symbol": "\udb81\uddaa" + "symbol": "󰖪" } ] }, "update_manager": { - "current_version": null, - "latest_version": null, - "update_status": "checking" + "current_version": "0.0.0", + "latest_version": "0.0.0", + "update_status": "up_to_date" }, "wifi": { "connections": [], diff --git a/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-007.jsonc b/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-007.jsonc index 6f03280e..4eb86343 100644 --- a/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-007.jsonc +++ b/tests/end_to_end/results/test_wireless_flow/wireless_flow/store-007.jsonc @@ -12,7 +12,7 @@ 1, 1 ], - "icon": "\udb80\udf5c", + "icon": "󰍜", "is_short": true, "label": "", "sub_menu": { @@ -25,7 +25,7 @@ 1, 1 ], - "icon": "\udb80\udc3b", + "icon": "󰀻", "is_short": false, "label": "Apps", "sub_menu": { @@ -42,7 +42,7 @@ 1, 1 ], - "icon": "\ue690", + "icon": "", "is_short": false, "label": "Settings", "sub_menu": { @@ -55,7 +55,7 @@ 1, 1 ], - "icon": "\udb81\udda9", + "icon": "󰖩", "is_short": false, "label": "WiFi", "sub_menu": { @@ -69,7 +69,7 @@ 1, 1 ], - "icon": "\udb85\udec3", + "icon": "󱛃", "is_short": false, "label": "Add" }, @@ -82,7 +82,7 @@ 1, 1 ], - "icon": "\udb85\uddab", + "icon": "󱖫", "is_short": false, "label": "Select" } @@ -104,23 +104,18 @@ 1, 1 ], - "icon": "\uf129", + "icon": "", "is_short": false, "label": "About", "sub_menu": { "heading": "Ubo v0.0.0", "items": [ { - "background_color": "#00000000", - "color": [ - 1, - 1, - 1, - 1 - ], - "icon": "\udb82\udf2c", + "background_color": "#03F7AE", + "color": "#000000", + "icon": "󰄬", "is_short": false, - "label": "Checking for updates..." + "label": "Already up to date!" } ], "placeholder": null, @@ -136,7 +131,7 @@ { "background_color": "#68B7FF", "color": "white", - "icon": "\ueaa2", + "icon": "", "is_short": true, "label": "", "sub_menu": { @@ -154,7 +149,7 @@ 1, 1 ], - "icon": "\udb81\udc25", + "icon": "󰐥", "is_short": true, "label": "Turn off" } @@ -167,7 +162,7 @@ "Main", "Settings", "WiFi Settings", - "8d723104f77383c13458a748e9bb17bc" + "a3f2c9bf9c6316b950f244556f25e2a2" ] }, "status_icons": { @@ -176,14 +171,14 @@ "color": "white", "id": "wifi:state", "priority": -12, - "symbol": "\udb81\uddaa" + "symbol": "󰖪" } ] }, "update_manager": { - "current_version": null, - "latest_version": null, - "update_status": "checking" + "current_version": "0.0.0", + "latest_version": "0.0.0", + "update_status": "up_to_date" }, "wifi": { "connections": [], diff --git a/tests/end_to_end/test_wireless_flow.py b/tests/end_to_end/test_wireless_flow.py index 188def72..f18b2569 100644 --- a/tests/end_to_end/test_wireless_flow.py +++ b/tests/end_to_end/test_wireless_flow.py @@ -3,6 +3,9 @@ from __future__ import annotations from typing import TYPE_CHECKING +from unittest.mock import ANY + +from tenacity import stop_after_delay if TYPE_CHECKING: from redux_pytest.fixtures import StoreMonitor, StoreSnapshot, WaitFor @@ -89,11 +92,12 @@ def check_icon() -> None: window_snapshot.take() store_snapshot.take() - @wait_for + @wait_for(stop=stop_after_delay(3)) def camera_started() -> None: store_monitor.dispatched_actions.assert_called_with( CameraStartViewfinderAction( - barcode_pattern=( + id=ANY, + pattern=( r'^WIFI:S:(?P[^;]*);(?:T:(?P(?i:WEP|WPA|WPA2|nopass));)' r'?(?:P:(?P[^;]*);)?(?:H:(?P(?i:true|false));)?;$' ), diff --git a/tests/fixtures/app.py b/tests/fixtures/app.py index ac792191..58a72318 100644 --- a/tests/fixtures/app.py +++ b/tests/fixtures/app.py @@ -12,8 +12,6 @@ import pytest -from ubo_app.utils.garbage_collection import examine - if TYPE_CHECKING: from _pytest.fixtures import SubRequest @@ -27,6 +25,9 @@ class AppContext: def set_app(self: AppContext, app: MenuApp) -> None: """Set the application.""" + from ubo_app.utils.loop import setup_event_loop + + setup_event_loop() self.app = app loop = asyncio.get_event_loop() self.task = loop.create_task(self.app.async_run(async_lib='asyncio')) @@ -78,6 +79,8 @@ async def app_context(request: SubRequest) -> AsyncGenerator[AppContext, None]: gc.collect() for cell in gc.get_referrers(app): if type(cell).__name__ == 'cell': + from ubo_app.utils.garbage_collection import examine + logging.getLogger().debug( 'CELL EXAMINATION\n' + json.dumps({'cell': cell}), ) diff --git a/tests/fixtures/load_services.py b/tests/fixtures/load_services.py index 3cbd112b..74cd5e4f 100644 --- a/tests/fixtures/load_services.py +++ b/tests/fixtures/load_services.py @@ -2,7 +2,16 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Coroutine, Literal, Protocol, Sequence, cast, overload +from typing import ( + TYPE_CHECKING, + Coroutine, + Generator, + Literal, + Protocol, + Sequence, + cast, + overload, +) import pytest @@ -29,8 +38,9 @@ def __call__( @pytest.fixture() -def load_services(wait_for: WaitFor) -> LoadServices: +def load_services(wait_for: WaitFor) -> Generator[LoadServices, None, None]: """Load services and wait for them to be ready.""" + from ubo_app.load_services import REGISTERED_PATHS def load_services_and_wait( service_ids: Sequence[str], @@ -43,8 +53,6 @@ def load_services_and_wait( @wait_for(run_async=cast(Literal[True], run_async)) def check() -> None: - from ubo_app.load_services import REGISTERED_PATHS - for service_id in service_ids: assert any( service.service_id == service_id and service.is_alive() @@ -53,4 +61,6 @@ def check() -> None: return check() - return cast(LoadServices, load_services_and_wait) + yield cast(LoadServices, load_services_and_wait) + + REGISTERED_PATHS.clear() diff --git a/tests/fixtures/stability.py b/tests/fixtures/stability.py index 67a6da4a..64bb2a89 100644 --- a/tests/fixtures/stability.py +++ b/tests/fixtures/stability.py @@ -6,6 +6,7 @@ import pytest from redux_pytest.fixtures.wait_for import AsyncWaiter, WaitFor +from tenacity import stop_after_delay, wait_fixed if TYPE_CHECKING: from redux_pytest.fixtures import StoreSnapshot @@ -27,7 +28,7 @@ async def wrapper() -> None: latest_window_hash = None latest_store_snapshot = None - @wait_for(run_async=True) + @wait_for(run_async=True, wait=wait_fixed(1), stop=stop_after_delay(4)) def check() -> None: nonlocal latest_window_hash, latest_store_snapshot diff --git a/tests/integration/results/test_core/app_runs_and_exits/store-000.jsonc b/tests/integration/results/test_core/app_runs_and_exits/store-000.jsonc index 93e3a434..61e4c7d1 100644 --- a/tests/integration/results/test_core/app_runs_and_exits/store-000.jsonc +++ b/tests/integration/results/test_core/app_runs_and_exits/store-000.jsonc @@ -12,7 +12,7 @@ 1, 1 ], - "icon": "\udb80\udf5c", + "icon": "󰍜", "is_short": true, "label": "", "sub_menu": { @@ -25,7 +25,7 @@ 1, 1 ], - "icon": "\udb80\udc3b", + "icon": "󰀻", "is_short": false, "label": "Apps", "sub_menu": { @@ -42,7 +42,7 @@ 1, 1 ], - "icon": "\ue690", + "icon": "", "is_short": false, "label": "Settings", "sub_menu": { @@ -59,23 +59,18 @@ 1, 1 ], - "icon": "\uf129", + "icon": "", "is_short": false, "label": "About", "sub_menu": { "heading": "Ubo v0.0.0", "items": [ { - "background_color": "#00000000", - "color": [ - 1, - 1, - 1, - 1 - ], - "icon": "\udb82\udf2c", + "background_color": "#03F7AE", + "color": "#000000", + "icon": "󰄬", "is_short": false, - "label": "Checking for updates..." + "label": "Already up to date!" } ], "placeholder": null, @@ -91,7 +86,7 @@ { "background_color": "#68B7FF", "color": "white", - "icon": "\ueaa2", + "icon": "", "is_short": true, "label": "", "sub_menu": { @@ -109,7 +104,7 @@ 1, 1 ], - "icon": "\udb81\udc25", + "icon": "󰐥", "is_short": true, "label": "Turn off" } @@ -125,8 +120,8 @@ "icons": [] }, "update_manager": { - "current_version": null, - "latest_version": null, - "update_status": "checking" + "current_version": "0.0.0", + "latest_version": "0.0.0", + "update_status": "up_to_date" } } diff --git a/tests/integration/results/test_services/all_services_register/store-000.jsonc b/tests/integration/results/test_services/all_services_register/store-000.jsonc index a22e7465..0bbe03c5 100644 --- a/tests/integration/results/test_services/all_services_register/store-000.jsonc +++ b/tests/integration/results/test_services/all_services_register/store-000.jsonc @@ -2,14 +2,16 @@ { "_id": "e3e70682c2094cac629f6fbed82c07cd", "camera": { - "is_viewfinder_active": false + "current": null, + "is_viewfinder_active": false, + "queue": [] }, "docker": { "_id": "a3f2c9bf9c6316b950f244556f25e2a2", "home_assistant": { "container_ip": null, "docker_id": null, - "icon": "\udb81\udfd0", + "icon": "󰟐", "id": "home_assistant", "ip_addresses": [ "192.168.1.1" @@ -22,7 +24,7 @@ "home_bridge": { "container_ip": null, "docker_id": null, - "icon": "\udb81\ude18", + "icon": "󰘘", "id": "home_bridge", "ip_addresses": [ "192.168.1.1" @@ -35,7 +37,7 @@ "ngrok": { "container_ip": null, "docker_id": null, - "icon": "\udb81\udef6", + "icon": "󰛶", "id": "ngrok", "ip_addresses": [ "192.168.1.1" @@ -48,7 +50,7 @@ "ollama": { "container_ip": null, "docker_id": null, - "icon": "\udb83\udcc6", + "icon": "󰳆", "id": "ollama", "ip_addresses": [ "192.168.1.1" @@ -61,7 +63,7 @@ "open_webui": { "container_ip": null, "docker_id": null, - "icon": "\udb83\udf94", + "icon": "󰾔", "id": "open_webui", "ip_addresses": [ "192.168.1.1" @@ -74,7 +76,7 @@ "pi_hole": { "container_ip": null, "docker_id": null, - "icon": "\udb80\uddd6", + "icon": "󰇖", "id": "pi_hole", "ip_addresses": [ "192.168.1.1" @@ -87,7 +89,7 @@ "portainer": { "container_ip": null, "docker_id": null, - "icon": "\ue7b0", + "icon": "", "id": "portainer", "ip_addresses": [ "192.168.1.1" @@ -122,7 +124,7 @@ 1, 1 ], - "icon": "\udb80\udf5c", + "icon": "󰍜", "is_short": true, "label": "", "sub_menu": { @@ -135,7 +137,7 @@ 1, 1 ], - "icon": "\udb80\udc3b", + "icon": "󰀻", "is_short": false, "label": "Apps", "sub_menu": { @@ -149,7 +151,7 @@ 1, 1 ], - "icon": "\udb82\udc68", + "icon": "󰡨", "is_short": false, "label": "Docker" } @@ -166,7 +168,7 @@ 1, 1 ], - "icon": "\ue690", + "icon": "", "is_short": false, "label": "Settings", "sub_menu": { @@ -179,7 +181,7 @@ 1, 1 ], - "icon": "\udb82\ude5f", + "icon": "󰩟", "is_short": false, "label": "IP Addresses", "sub_menu": { @@ -192,7 +194,7 @@ 1, 1 ], - "icon": "\udb80\ude00", + "icon": "󰈀", "is_short": false, "label": "eth0", "sub_menu": { @@ -205,7 +207,7 @@ 1, 1 ], - "icon": "\udb82\ude60", + "icon": "󰩠", "is_short": false, "label": "192.168.1.1" } @@ -227,7 +229,71 @@ 1, 1 ], - "icon": "\udb81\udda9", + "icon": "󰣀", + "is_short": false, + "label": "SSH", + "sub_menu": { + "items": [ + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "", + "is_short": false, + "label": "Setup", + "sub_menu": { + "heading": "Create an SSH account", + "items": [ + { + "action": "", + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "󰗹", + "is_short": false, + "label": "Create" + } + ], + "placeholder": null, + "sub_heading": "This will create a temporary SSH account, you should delete it after use.", + "title": "SSH Setup" + } + }, + { + "application": "ubo_app/services/050-ssh/setup.py:ClearTemporaryUsersPrompt", + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "󱈊", + "is_short": false, + "label": "Remove all users" + } + ], + "placeholder": null, + "title": "SSH" + } + }, + { + "background_color": "#68B7FF", + "color": [ + 1, + 1, + 1, + 1 + ], + "icon": "󰖩", "is_short": false, "label": "WiFi", "sub_menu": { @@ -241,7 +307,7 @@ 1, 1 ], - "icon": "\udb85\udec3", + "icon": "󱛃", "is_short": false, "label": "Add" }, @@ -254,7 +320,7 @@ 1, 1 ], - "icon": "\udb85\uddab", + "icon": "󱖫", "is_short": false, "label": "Select" } @@ -276,23 +342,18 @@ 1, 1 ], - "icon": "\uf129", + "icon": "", "is_short": false, "label": "About", "sub_menu": { "heading": "Ubo v0.0.0", "items": [ { - "background_color": "#00000000", - "color": [ - 1, - 1, - 1, - 1 - ], - "icon": "\udb82\udf2c", + "background_color": "#03F7AE", + "color": "#000000", + "icon": "󰄬", "is_short": false, - "label": "Checking for updates..." + "label": "Already up to date!" } ], "placeholder": null, @@ -308,7 +369,7 @@ { "background_color": "#68B7FF", "color": "white", - "icon": "\ueaa2", + "icon": "", "is_short": true, "label": "", "sub_menu": { @@ -326,7 +387,7 @@ 1, 1 ], - "icon": "\udb81\udc25", + "icon": "󰐥", "is_short": true, "label": "Turn off" } @@ -366,32 +427,32 @@ "color": "white", "id": "sound:mic-state", "priority": -20, - "symbol": "\udb80\udf6d" + "symbol": "󰍭" }, { "color": "white", "id": "ethernet:state", "priority": -13, - "symbol": "\udb80\udf19" + "symbol": "󰌙" }, { "color": "white", "id": "wifi:state", "priority": -12, - "symbol": "\udb81\uddaa" + "symbol": "󰖪" }, { "color": "white", "id": "ip:internet-state", "priority": -11, - "symbol": "\udb81\udd9f" + "symbol": "󰖟" } ] }, "update_manager": { - "current_version": null, - "latest_version": null, - "update_status": "checking" + "current_version": "0.0.0", + "latest_version": "0.0.0", + "update_status": "up_to_date" }, "wifi": { "connections": [], diff --git a/tests/integration/test_core.py b/tests/integration/test_core.py index 7d0a1a2e..791a98de 100644 --- a/tests/integration/test_core.py +++ b/tests/integration/test_core.py @@ -4,6 +4,8 @@ from typing import TYPE_CHECKING +from tenacity import stop_after_attempt + if TYPE_CHECKING: from redux_pytest.fixtures import StoreSnapshot, WaitFor @@ -14,8 +16,8 @@ async def test_app_runs_and_exits( app_context: AppContext, window_snapshot: WindowSnapshot, store_snapshot: StoreSnapshot, - needs_finish: None, wait_for: WaitFor, + needs_finish: None, ) -> None: """Test the application starts, runs and quits.""" _ = needs_finish @@ -25,7 +27,7 @@ async def test_app_runs_and_exits( app_context.set_app(app) - @wait_for(run_async=True) + @wait_for(run_async=True, stop=stop_after_attempt(5)) def stack_is_loaded() -> None: assert len(app.menu_widget.stack) > 0, 'Menu stack not loaded yet' @@ -33,7 +35,7 @@ def stack_is_loaded() -> None: from headless_kivy_pi import HeadlessWidget, config - @wait_for(run_async=True) + @wait_for(run_async=True, stop=stop_after_attempt(5)) def check() -> None: headless_widget_instance = HeadlessWidget.get_instance(app.root) if headless_widget_instance: diff --git a/tests/integration/test_services.py b/tests/integration/test_services.py index 28967ebf..ebf38a19 100644 --- a/tests/integration/test_services.py +++ b/tests/integration/test_services.py @@ -4,15 +4,13 @@ from typing import TYPE_CHECKING -from ubo_app.utils.fake import Fake - if TYPE_CHECKING: import pytest from redux_pytest.fixtures import StoreSnapshot from tests.fixtures import AppContext, LoadServices, Stability, WindowSnapshot -ALL_SERVICES_LABELS = [ +ALL_SERVICES_IDS = [ 'rgb_ring', 'sound', 'ethernet', @@ -24,6 +22,7 @@ 'camera', 'sensors', 'docker', + 'ssh', ] @@ -38,6 +37,7 @@ async def test_all_services_register( ) -> None: """Test all services load.""" _ = needs_finish + from ubo_app.utils.fake import Fake class FakeProcess(Fake): returncode = 0 @@ -50,7 +50,7 @@ class FakeProcess(Fake): app = MenuApp() app_context.set_app(app) - load_services(ALL_SERVICES_LABELS) + load_services(ALL_SERVICES_IDS) await stability() store_snapshot.take() window_snapshot.take() diff --git a/ubo_app/constants.py b/ubo_app/constants.py index 13eab735..6044a6e5 100644 --- a/ubo_app/constants.py +++ b/ubo_app/constants.py @@ -19,7 +19,12 @@ if os.environ.get('UBO_SERVICES_PATH') else [] ) -SOCKET_PATH = Path('/run/ubo').joinpath('system_manager.sock').as_posix() +SERVER_SOCKET_PATH = Path('/run/ubo').joinpath('system_manager.sock').as_posix() +DISABLED_SERVICES = os.environ.get('UBO_DISABLED_SERVICES', '').split(',') + +# Enable it to replace UUIDs with numerical counters in tests and log the traceback +# each time a UUID is generated. +DEBUG_MODE_TEST_UUID = strtobool(os.environ.get('UBO_DEBUG_TEST_UUID', 'False')) == 1 DEBUG_MODE_DOCKER = strtobool(os.environ.get('UBO_DEBUG_DOCKER', 'False')) == 1 DOCKER_PREFIX = os.environ.get('UBO_DOCKER_PREFIX', '') diff --git a/ubo_app/error_handlers.py b/ubo_app/error_handlers.py new file mode 100644 index 00000000..4124d33d --- /dev/null +++ b/ubo_app/error_handlers.py @@ -0,0 +1,87 @@ +# ruff: noqa: D100, D101, D102, D103, D104, D107 +from __future__ import annotations + +import sys +import threading +import traceback +from typing import TYPE_CHECKING + +import sentry_sdk + +if TYPE_CHECKING: + from types import TracebackType + + +def setup_sentry() -> None: # pragma: no cover + from ubo_app.constants import SENTRY_DSN + + if SENTRY_DSN: + sentry_sdk.init( + dsn=SENTRY_DSN, + traces_sample_rate=1.0, + profiles_sample_rate=1.0, + ) + + +def get_all_thread_stacks() -> dict[str, list[str]]: + id_to_name = {th.ident: th.name for th in threading.enumerate()} + thread_stacks = {} + for thread_id, frame in sys._current_frames().items(): # noqa: SLF001 + thread_stacks[id_to_name.get(thread_id, f'unknown-{thread_id}')] = ( + traceback.format_stack(frame) + ) + return thread_stacks + + +def global_exception_handler( + exception_type: type[BaseException], + exception_value: BaseException, + exception_traceback: TracebackType, +) -> None: + from ubo_app.logging import logger + + error_message = ''.join( + traceback.format_exception( + exception_type, + exception_value, + exception_traceback, + ), + ) + threads_info = get_all_thread_stacks() + + logger.error( + f"""Uncaught exception: {exception_type}: {exception_value} +{error_message}""", + ) + logger.debug( + f"""Uncaught exception: {exception_type}: {exception_value} +{error_message}""", + extra={'threads': threads_info}, + ) + + +def thread_exception_handler(args: threading.ExceptHookArgs) -> None: + from ubo_app.logging import logger + + error_message = ''.join( + traceback.format_exception(*args[:3]), + ) + threads_info = get_all_thread_stacks() + + exception_type, exception_value, _, thread = args + + logger.error( + f"""Uncaught exception in thread {thread}: {exception_type}: {exception_value} +{error_message}""", + ) + logger.debug( + f"""Uncaught exception in thread {thread}: {exception_type}: {exception_value} +{error_message}""", + extra={'threads': threads_info, 'thread': thread}, + ) + + +def setup_error_handling() -> None: + setup_sentry() + threading.excepthook = thread_exception_handler + sys.excepthook = global_exception_handler diff --git a/ubo_app/load_services.py b/ubo_app/load_services.py index 1c2c4735..20938a8a 100644 --- a/ubo_app/load_services.py +++ b/ubo_app/load_services.py @@ -5,6 +5,7 @@ import importlib import importlib.abc import importlib.util +import inspect import os import sys import threading @@ -12,19 +13,22 @@ import uuid from importlib.machinery import PathFinder, SourceFileLoader from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, ClassVar, Coroutine, Sequence, cast +from typing import TYPE_CHECKING, Any, Callable, ClassVar, Sequence, cast -from redux import CombineReducerRegisterAction, FinishEvent, ReducerType +from redux import CombineReducerRegisterAction, ReducerType -from ubo_app.constants import DEBUG_MODE, SERVICES_PATH +from ubo_app.constants import DEBUG_MODE, DISABLED_SERVICES, SERVICES_PATH from ubo_app.logging import logger if TYPE_CHECKING: from importlib.machinery import ModuleSpec from types import ModuleType + from ubo_app.services import SetupFunction + ROOT_PATH = Path(__file__).parent REGISTERED_PATHS: dict[Path, UboServiceThread] = {} +WHITE_LIST = [] # Customized module finder and module loader for ubo services to avoid mistakenly @@ -77,7 +81,7 @@ def exec_module(self: UboServiceLoopLoader, module: ModuleType) -> None: ) def __repr__(self: UboServiceLoopLoader) -> str: - return f'{self.service.path}' + return f'{self.service.path}:LoopLoader' class UboServiceFinder(importlib.abc.MetaPathFinder): @@ -102,6 +106,13 @@ def find_spec( service = REGISTERED_PATHS[matching_path] module_name = f'{service.service_uid}:{fullname}' + if not service.is_alive(): + msg = ( + 'No import other than `ubo_app.services` is allowed in the global' + ' scope of `ubo_handle.py` files.' + ) + raise RuntimeError(msg) + if fullname == 'ubo_app.utils.loop': return importlib.util.spec_from_loader( module_name, @@ -127,26 +138,81 @@ class UboServiceThread(threading.Thread): def __init__( self: UboServiceThread, path: Path, - white_list: Sequence[str] | None = None, ) -> None: - name = path.name super().__init__() - self.service_uid = f'{uuid.uuid4().hex}:{name}' - self.name = name + self.name = path.name + self.service_uid = f'{uuid.uuid4().hex}:{self.name}' self.label = '' - self.service_id = '-not-set-' + self.service_id = '' self.path = path - self.white_list = white_list - self.loop = asyncio.new_event_loop() self.module = None - if DEBUG_MODE: - self.loop.set_debug(enabled=True) - def run(self: UboServiceThread) -> None: + def register_reducer(self: UboServiceThread, reducer: ReducerType) -> None: from ubo_app import store + logger.info( + 'Registering ubo service reducer', + extra={ + 'service_id': self.service_id, + 'label': self.label, + 'reducer': f'{reducer.__module__}.{reducer.__name__}', + }, + ) + + store.dispatch( + CombineReducerRegisterAction( + _id=store.root_reducer_id, + key=self.service_id, + reducer=reducer, + ), + ) + + def register( + self: UboServiceThread, + *, + service_id: str, + label: str, + setup: SetupFunction, + ) -> None: + if service_id in DISABLED_SERVICES: + logger.info( + 'Skipping disabled ubo service', + extra={ + 'service_id': service_id, + 'label': label, + 'disabled_services': DISABLED_SERVICES, + }, + ) + return + + if WHITE_LIST and service_id not in WHITE_LIST: + logger.info( + 'Service is not in services white list', + extra={ + 'service_id': service_id, + 'label': label, + 'white_list': WHITE_LIST, + }, + ) + return + + self.label = label + self.service_id = service_id + self.setup = setup + + logger.info( + 'Ubo service registered!', + extra={ + 'service_id': self.service_id, + 'label': self.label, + }, + ) + + self.start() + + def initiate(self: UboServiceThread) -> None: try: if self.path.exists(): module_name = f'{self.service_uid}:ubo_handle' @@ -166,109 +232,68 @@ def run(self: UboServiceThread) -> None: except Exception as exception: # noqa: BLE001 logger.error(f'Error loading "{self.path}"', exc_info=exception) + return - asyncio.set_event_loop(self.loop) if self.module and self.spec and self.spec.loader: try: + cast(UboServiceThread, self.module).register = self.register self.spec.loader.exec_module(self.module) except Exception as exception: # noqa: BLE001 logger.error(f'Error loading "{self.path}"', exc_info=exception) - return - store.subscribe_event(FinishEvent, self.stop) - self.loop.run_forever() - - def stop(self: UboServiceThread) -> None: - self.loop.call_soon_threadsafe(self.loop.stop) + def run(self: UboServiceThread) -> None: + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + if DEBUG_MODE: + self.loop.set_debug(enabled=True) - def __repr__(self: UboServiceThread) -> str: - return f'' + REGISTERED_PATHS[self.path] = self + result = None + if len(inspect.signature(self.setup).parameters) == 0: + result = cast(Callable[[], None], self.setup)() + elif len(inspect.signature(self.setup).parameters) == 1: + result = cast(Callable[[UboServiceThread], None], self.setup)(self) + if asyncio.iscoroutine(result): + self.loop.create_task(result) -def register_service( - service_id: str, - label: str, - reducer: ReducerType | None = None, - init: Callable[[], None | Coroutine] | None = None, -) -> None: - from ubo_app import store + from redux import FinishEvent - thread = threading.current_thread() - if not isinstance(thread, UboServiceThread): - logger.error( - 'Service registration outside of service thread', - extra={ - 'service_id': service_id, - 'label': label, - }, - ) - return + from ubo_app.store import store - if service_id in os.environ.get('UBO_DISABLED_SERVICES', '').split(','): - logger.info( - 'Skipping disabled ubo service', - extra={ - 'service_id': service_id, - 'label': label, - 'disabled_services': os.environ.get('UBO_DISABLED_SERVICES'), - }, - ) - thread.stop() - return + store.subscribe_event(FinishEvent, self.stop) + self.loop.run_forever() - if thread.white_list and service_id not in thread.white_list: - logger.info( - 'Service is not in services white list', - extra={ - 'service_id': service_id, - 'label': label, - 'white_list': thread.white_list, - }, - ) - thread.stop() - return - - thread.label = label - thread.service_id = service_id - - logger.info( - 'Registering ubo serivce', - extra={ - 'service_id': service_id, - 'label': label, - 'has_reducer': reducer is not None, - }, - ) - - if reducer is not None: - store.dispatch( - CombineReducerRegisterAction( - _id=store.root_reducer_id, - key=service_id, - reducer=reducer, - ), + def __repr__(self: UboServiceThread) -> str: + return ( + f'' ) - if init is not None: - result = init() - if asyncio.iscoroutine(result): - thread.loop.create_task(result) + def stop(self: UboServiceThread) -> None: + self.loop.call_soon_threadsafe(self.loop.stop) def load_services(service_ids: Sequence[str] | None = None) -> None: + WHITE_LIST.extend(service_ids or []) + import time + + services = [] for services_directory_path in [ ROOT_PATH.joinpath('services').as_posix(), *SERVICES_PATH, ]: if Path(services_directory_path).is_dir(): for service_path in Path(services_directory_path).iterdir(): - if not service_path.is_dir(): + if not service_path.is_dir() or service_path in REGISTERED_PATHS: continue current_path = Path().absolute() os.chdir(service_path.as_posix()) - service = UboServiceThread(service_path, white_list=service_ids) - REGISTERED_PATHS[service_path] = service - service.start() + services.append(UboServiceThread(service_path)) os.chdir(current_path) + + for service in services: + service.initiate() + time.sleep(0.05) diff --git a/ubo_app/logging.py b/ubo_app/logging.py index 6ef4534d..53d01aaa 100644 --- a/ubo_app/logging.py +++ b/ubo_app/logging.py @@ -82,7 +82,7 @@ def format(self: ExtraFormatter, record: logging.LogRecord) -> str: sort_keys=True, indent=4, default=str, - ) + ).replace('\\n', '\n') return string diff --git a/ubo_app/main.py b/ubo_app/main.py index fe835477..58febd92 100644 --- a/ubo_app/main.py +++ b/ubo_app/main.py @@ -3,17 +3,11 @@ import os import sys -import threading -import traceback from pathlib import Path -from typing import TYPE_CHECKING import dotenv -import sentry_sdk -from redux import FinishAction -if TYPE_CHECKING: - from types import TracebackType +from ubo_app.error_handlers import setup_error_handling dotenv.load_dotenv(Path(__file__).parent / '.dev.env') @@ -51,52 +45,6 @@ def setup_logging() -> None: ubo_gui.logger.add_stdout_handler(level) -def setup_sentry() -> None: # pragma: no cover - from ubo_app.constants import SENTRY_DSN - - if SENTRY_DSN: - sentry_sdk.init( - dsn=SENTRY_DSN, - traces_sample_rate=1.0, - profiles_sample_rate=1.0, - ) - - -def get_all_thread_stacks() -> dict[str, list[str]]: - id_to_name = {th.ident: th.name for th in threading.enumerate()} - thread_stacks = {} - for thread_id, frame in sys._current_frames().items(): # noqa: SLF001 - thread_stacks[id_to_name.get(thread_id, f'unknown-{thread_id}')] = ( - traceback.format_stack(frame) - ) - return thread_stacks - - -def global_exception_handler( - exception_type: type[BaseException], - exception_value: BaseException, - exception_traceback: TracebackType, -) -> None: - from ubo_app.logging import logger - - error_message = ''.join( - traceback.format_exception( - exception_type, - exception_value, - exception_traceback, - ), - ) - threads_info = get_all_thread_stacks() - - logger.error( - f'Uncaught exception: {exception_type}: {exception_value}\n{error_message}', - ) - logger.debug( - f'Uncaught exception: {exception_type}: {exception_value}\n{error_message}', - extra={'threads': threads_info}, - ) - - def main() -> None: """Instantiate the `MenuApp` and run it.""" os.environ['KIVY_NO_CONFIG'] = '1' @@ -104,12 +52,12 @@ def main() -> None: os.environ['KIVY_NO_CONSOLELOG'] = '1' os.environ['KCFG_KIVY_EXIT_ON_ESCAPE'] = '0' - setup_sentry() + setup_error_handling() setup_logging() - # Set the global exception handler - sys.excepthook = global_exception_handler - threading.excepthook = global_exception_handler + from ubo_app.utils.loop import setup_event_loop + + setup_event_loop() if len(sys.argv) > 1 and sys.argv[1] == 'bootstrap': from ubo_app.system.bootstrap import bootstrap @@ -135,6 +83,8 @@ def main() -> None: try: app.run() finally: + from redux import FinishAction + from ubo_app.store import dispatch dispatch(FinishAction()) diff --git a/ubo_app/menu_central.py b/ubo_app/menu_central.py index b2e5ba7d..788e326d 100644 --- a/ubo_app/menu_central.py +++ b/ubo_app/menu_central.py @@ -185,7 +185,10 @@ def display_notification( self.menu_widget.open_application(application) if notification.display_type is NotificationDisplayType.FLASH: - Clock.schedule_once(lambda _: application.dispatch('on_close'), 4) + Clock.schedule_once( + lambda _: application.dispatch('on_dismiss'), + notification.flash_time, + ) Builder.load_file( diff --git a/ubo_app/services.py b/ubo_app/services.py new file mode 100644 index 00000000..442017ab --- /dev/null +++ b/ubo_app/services.py @@ -0,0 +1,32 @@ +"""Signature of the `register_service` function.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Callable, Coroutine, Protocol, TypeAlias + +if TYPE_CHECKING: + from redux import ReducerType + + +class Service(Protocol): + """Utilities to initialize a service.""" + + def register_reducer(self: Service, reducer: ReducerType) -> None: + """Register a reducer.""" + + +SetupFunction: TypeAlias = ( + Callable[[Service], Coroutine | None] | Callable[[], Coroutine | None] +) + + +def register( + *, + service_id: str, + label: str, + setup: SetupFunction, +) -> None: + """Register a service, meant to be called in `ubo_handle.py` file of services.""" + _ = service_id, label, setup + msg = 'This function is not meant to be called out of the services' + raise NotImplementedError(msg) diff --git a/ubo_app/services/000-sound/ubo_handle.py b/ubo_app/services/000-sound/ubo_handle.py index dc394193..9d717b9f 100644 --- a/ubo_app/services/000-sound/ubo_handle.py +++ b/ubo_app/services/000-sound/ubo_handle.py @@ -1,12 +1,23 @@ # ruff: noqa: D100, D101, D102, D103, D104, D107, N999 -from reducer import reducer -from setup import init_service +from __future__ import annotations -from ubo_app.load_services import register_service +from typing import TYPE_CHECKING -register_service( +if TYPE_CHECKING: + from ubo_app.services 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='sound', label='Sound', - reducer=reducer, - init=init_service, + setup=setup, ) diff --git a/ubo_app/services/010-notifications/reducer.py b/ubo_app/services/010-notifications/reducer.py index 8e450721..68fc14b6 100644 --- a/ubo_app/services/010-notifications/reducer.py +++ b/ubo_app/services/010-notifications/reducer.py @@ -19,6 +19,7 @@ NotificationsAddAction, NotificationsClearAction, NotificationsClearAllAction, + NotificationsClearEvent, NotificationsDisplayEvent, NotificationsState, ) @@ -84,16 +85,19 @@ def reducer( events=events, ) if isinstance(action, NotificationsClearAction): - return replace( - state, - notifications=[ - notification - for notification in state.notifications - if notification is not action.notification - ], - unread_count=state.unread_count - 1 - if action.notification in state.notifications - else state.unread_count, + return CompleteReducerResult( + state=replace( + state, + notifications=[ + notification + for notification in state.notifications + if notification is not action.notification + ], + unread_count=state.unread_count - 1 + if action.notification in state.notifications + else state.unread_count, + ), + events=[NotificationsClearEvent(notification=action.notification)], ) if isinstance(action, NotificationsClearAllAction): return replace(state, notifications=[], unread_count=0) diff --git a/ubo_app/services/010-notifications/ubo_handle.py b/ubo_app/services/010-notifications/ubo_handle.py index 6d890ae3..a36f4bd5 100644 --- a/ubo_app/services/010-notifications/ubo_handle.py +++ b/ubo_app/services/010-notifications/ubo_handle.py @@ -1,10 +1,20 @@ # ruff: noqa: D100, D101, D102, D103, D104, D107, N999 -from reducer import reducer +from __future__ import annotations -from ubo_app.load_services import register_service +from typing import TYPE_CHECKING -register_service( +if TYPE_CHECKING: + from ubo_app.services import Service, register + + +def setup(service: Service) -> None: + from reducer import reducer + + service.register_reducer(reducer) + + +register( service_id='notifications', label='Notifications', - reducer=reducer, + setup=setup, ) diff --git a/ubo_app/services/020-keyboard/setup.py b/ubo_app/services/020-keyboard/setup.py index 8a9c5fe8..0a4ced31 100644 --- a/ubo_app/services/020-keyboard/setup.py +++ b/ubo_app/services/020-keyboard/setup.py @@ -25,9 +25,9 @@ def on_keyboard( from ubo_app.store import dispatch if modifier == []: - if key == Keyboard.keycodes['up']: + if key in (Keyboard.keycodes['up'], Keyboard.keycodes['k']): dispatch(KeypadKeyPressAction(key=Key.UP)) - elif key == Keyboard.keycodes['down']: + elif key in (Keyboard.keycodes['down'], Keyboard.keycodes['j']): dispatch(KeypadKeyPressAction(key=Key.DOWN)) elif key == Keyboard.keycodes['1']: dispatch(KeypadKeyPressAction(key=Key.L1)) @@ -35,7 +35,11 @@ def on_keyboard( dispatch(KeypadKeyPressAction(key=Key.L2)) elif key == Keyboard.keycodes['3']: dispatch(KeypadKeyPressAction(key=Key.L3)) - elif key == Keyboard.keycodes['escape']: + elif key in ( + Keyboard.keycodes['left'], + Keyboard.keycodes['escape'], + Keyboard.keycodes['h'], + ): dispatch(KeypadKeyPressAction(key=Key.BACK)) elif key == Keyboard.keycodes['q']: dispatch(FinishAction()) diff --git a/ubo_app/services/020-keyboard/ubo_handle.py b/ubo_app/services/020-keyboard/ubo_handle.py index eacfa477..0f0c4eb5 100644 --- a/ubo_app/services/020-keyboard/ubo_handle.py +++ b/ubo_app/services/020-keyboard/ubo_handle.py @@ -1,10 +1,20 @@ # ruff: noqa: D100, D101, D102, D103, D104, D107, N999 -from setup import init_service +from __future__ import annotations -from ubo_app.load_services import register_service +from typing import TYPE_CHECKING -register_service( +if TYPE_CHECKING: + from ubo_app.services import register + + +def setup() -> None: + from setup import init_service + + init_service() + + +register( service_id='keyboard', label='Keyboard', - init=init_service, + setup=setup, ) diff --git a/ubo_app/services/020-keypad/ubo_handle.py b/ubo_app/services/020-keypad/ubo_handle.py index 7c0ee7ce..5b24bc3c 100644 --- a/ubo_app/services/020-keypad/ubo_handle.py +++ b/ubo_app/services/020-keypad/ubo_handle.py @@ -1,10 +1,20 @@ # ruff: noqa: D100, D101, D102, D103, D104, D107, N999 -from setup import init_service +from __future__ import annotations -from ubo_app.load_services import register_service +from typing import TYPE_CHECKING -register_service( +if TYPE_CHECKING: + from ubo_app.services import register + + +def setup() -> None: + from setup import init_service + + init_service() + + +register( service_id='keypad', label='Keypad', - init=init_service, + setup=setup, ) diff --git a/ubo_app/services/030-ethernet/ubo_handle.py b/ubo_app/services/030-ethernet/ubo_handle.py index 2a2074fa..92840fde 100644 --- a/ubo_app/services/030-ethernet/ubo_handle.py +++ b/ubo_app/services/030-ethernet/ubo_handle.py @@ -1,10 +1,20 @@ # ruff: noqa: D100, D101, D102, D103, D104, D107, N999 -from setup import init_service +from __future__ import annotations -from ubo_app.load_services import register_service +from typing import TYPE_CHECKING -register_service( +if TYPE_CHECKING: + from ubo_app.services import register + + +def setup() -> None: + from setup import init_service + + init_service() + + +register( service_id='ethernet', label='Ethernet', - init=init_service, + setup=setup, ) diff --git a/ubo_app/services/030-ip/ubo_handle.py b/ubo_app/services/030-ip/ubo_handle.py index 2c0b414c..ee053ea9 100644 --- a/ubo_app/services/030-ip/ubo_handle.py +++ b/ubo_app/services/030-ip/ubo_handle.py @@ -1,12 +1,23 @@ # ruff: noqa: D100, D101, D102, D103, D104, D107, N999 -from reducer import reducer -from setup import init_service +from __future__ import annotations -from ubo_app.load_services import register_service +from typing import TYPE_CHECKING -register_service( +if TYPE_CHECKING: + from ubo_app.services import Service, register + + +async def setup(service: Service) -> None: + from reducer import reducer + from setup import init_service + + service.register_reducer(reducer) + + await init_service() + + +register( service_id='ip', label='IP', - reducer=reducer, - init=init_service, + setup=setup, ) 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 99c4e740..fa77ce28 100644 --- a/ubo_app/services/030-wifi/pages/create_wireless_connection.py +++ b/ubo_app/services/030-wifi/pages/create_wireless_connection.py @@ -2,7 +2,8 @@ from __future__ import annotations import pathlib -from typing import Sequence +from distutils.util import strtobool +from typing import Sequence, cast from kivy.clock import mainthread from kivy.lang.builder import Builder @@ -13,8 +14,7 @@ from wifi_manager import add_wireless_connection from ubo_app.logging import logger -from ubo_app.store import dispatch, subscribe_event -from ubo_app.store.services.camera import CameraStartViewfinderAction +from ubo_app.store import dispatch from ubo_app.store.services.notifications import ( Chime, Notification, @@ -22,16 +22,13 @@ NotificationsAddAction, ) from ubo_app.store.services.sound import SoundPlayChimeAction -from ubo_app.store.services.wifi import ( - WiFiCreateEvent, - WiFiType, - WiFiUpdateRequestAction, -) +from ubo_app.store.services.wifi import WiFiType, WiFiUpdateRequestAction from ubo_app.utils.async_ import create_task +from ubo_app.utils.qrcode import qrcode_input # Regular expression pattern # WIFI:S:;T:;P:;H:;; -barcode_pattern = ( +BARCODE_PATTERN = ( r'^WIFI:S:(?P[^;]*);(?:T:(?P(?i:WEP|WPA|WPA2|nopass));)' r'?(?:P:(?P[^;]*);)?(?:H:(?P(?i:true|false));)?;$' ) @@ -47,60 +44,60 @@ def __init__( **kwargs: object, ) -> None: super().__init__(*args, **kwargs, items=items) - self.unsubscribe = subscribe_event( - WiFiCreateEvent, - self.create_wireless_connection, - ) dispatch(SoundPlayChimeAction(name='scan')) - self.bind(on_close=lambda *_: self.unsubscribe()) - def create_wireless_connection( - self: CreateWirelessConnectionPage, - event: WiFiCreateEvent, - ) -> None: - connection = event.connection - ssid = connection.ssid - password = connection.password + async def create_wireless_connection(self: CreateWirelessConnectionPage) -> None: + _, match = await qrcode_input(BARCODE_PATTERN) + if not match: + return + ssid = match.get('SSID') + if ssid is None: + return + + password = match.get('Password') + type = cast(WiFiType, match.get('Type')) + hidden = strtobool(match.get('Hidden') or 'false') == 1 if not password: logger.warn('Password is required') return - async def act() -> None: - await add_wireless_connection( - ssid=ssid, - password=password, - type=connection.type or WiFiType.nopass, - hidden=connection.hidden, - ) + self.creating = True - logger.info( - 'Wireless connection created', - extra={ - 'ssid': ssid, - 'type': connection.type, - 'hidden': connection.hidden, - }, - ) + await add_wireless_connection( + ssid=ssid, + password=password, + type=type or WiFiType.nopass, + hidden=hidden, + ) + + logger.info( + 'Wireless connection created', + extra={ + 'ssid': ssid, + 'type': type, + 'hidden': hidden, + }, + ) - dispatch( - WiFiUpdateRequestAction(reset=True), - NotificationsAddAction( - notification=Notification( - title=f'"{ssid}" Added', - content=f"""WiFi connection with ssid "{ - ssid}" was added successfully""", - display_type=NotificationDisplayType.FLASH, - color=SUCCESS_COLOR, - icon='󱛃', - chime=Chime.ADD, - ), + dispatch( + WiFiUpdateRequestAction(reset=True), + NotificationsAddAction( + notification=Notification( + title=f'"{ssid}" Added', + content=f"""WiFi connection with ssid "{ + ssid}" was added successfully""", + display_type=NotificationDisplayType.FLASH, + color=SUCCESS_COLOR, + icon='󱛃', + chime=Chime.ADD, ), - ) - mainthread(self.dispatch)('on_close') + ), + ) + mainthread(self.dispatch)('on_close') - self.creating = True - create_task(act()) + def input_connection_information(self: CreateWirelessConnectionPage) -> None: + create_task(self.create_wireless_connection()) def get_item(self: CreateWirelessConnectionPage, index: int) -> ActionItem | None: if index == 2: # noqa: PLR2004 @@ -108,9 +105,7 @@ def get_item(self: CreateWirelessConnectionPage, index: int) -> ActionItem | Non label='start', is_short=True, icon='󰄀', - action=lambda: dispatch( - CameraStartViewfinderAction(barcode_pattern=barcode_pattern), - ), + action=self.input_connection_information, ) return super().get_item(index) diff --git a/ubo_app/services/030-wifi/reducer.py b/ubo_app/services/030-wifi/reducer.py index 7f7e07cf..1d186f19 100644 --- a/ubo_app/services/030-wifi/reducer.py +++ b/ubo_app/services/030-wifi/reducer.py @@ -2,8 +2,6 @@ from __future__ import annotations from dataclasses import replace -from distutils.util import strtobool -from typing import cast from constants import WIFI_STATE_ICON_ID, WIFI_STATE_ICON_PRIORITY, get_signal_icon from redux import ( @@ -14,18 +12,11 @@ ReducerResult, ) -from ubo_app.store.services.camera import ( - CameraBarcodeAction, - CameraStopViewfinderAction, -) from ubo_app.store.services.wifi import ( GlobalWiFiState, WiFiAction, - WiFiConnection, - WiFiCreateEvent, WiFiEvent, WiFiState, - WiFiType, WiFiUpdateAction, WiFiUpdateRequestAction, WiFiUpdateRequestEvent, @@ -35,7 +26,7 @@ def reducer( state: WiFiState | None, - action: CameraBarcodeAction | WiFiAction, + action: WiFiAction, ) -> ReducerResult[WiFiState, BaseAction, WiFiEvent]: if state is None: if isinstance(action, InitAction): @@ -49,29 +40,6 @@ def reducer( ) raise InitializationActionError(action) - if isinstance(action, CameraBarcodeAction): - ssid = action.match.get('SSID') - if ssid is None: - return state - - return CompleteReducerResult( - state=state, - actions=[CameraStopViewfinderAction()], - events=[ - WiFiCreateEvent( - connection=WiFiConnection( - ssid=ssid, - password=action.match.get('Password'), - type=cast(WiFiType, action.match.get('Type')), - hidden=strtobool( - action.match.get('Hidden') or 'false', - ) - == 1, - ), - ), - ], - ) - if isinstance(action, WiFiUpdateRequestAction): return CompleteReducerResult( state=replace(state, connections=None) if action.reset else state, diff --git a/ubo_app/services/030-wifi/ubo_handle.py b/ubo_app/services/030-wifi/ubo_handle.py index 47f57b98..27edaf22 100644 --- a/ubo_app/services/030-wifi/ubo_handle.py +++ b/ubo_app/services/030-wifi/ubo_handle.py @@ -1,12 +1,23 @@ # ruff: noqa: D100, D101, D102, D103, D104, D107, N999 -from reducer import reducer -from setup import init_service +from __future__ import annotations -from ubo_app.load_services import register_service +from typing import TYPE_CHECKING -register_service( +if TYPE_CHECKING: + from ubo_app.services 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='wifi', label='WiFi', - reducer=reducer, - init=init_service, + setup=setup, ) diff --git a/ubo_app/services/040-camera/reducer.py b/ubo_app/services/040-camera/reducer.py index 369b8f58..e7e2141a 100644 --- a/ubo_app/services/040-camera/reducer.py +++ b/ubo_app/services/040-camera/reducer.py @@ -1,6 +1,7 @@ # ruff: noqa: D100, D101, D102, D103, D104, D107, N999 from __future__ import annotations +import re from dataclasses import replace from redux import ( @@ -12,53 +13,97 @@ from ubo_app.store.services.camera import ( CameraAction, + CameraBarcodeEvent, CameraEvent, + CameraReportBarcodeAction, CameraStartViewfinderAction, CameraStartViewfinderEvent, CameraState, - CameraStopViewfinderAction, CameraStopViewfinderEvent, + InputDescription, ) from ubo_app.store.services.keypad import Key, KeypadAction Action = InitAction | CameraAction -def reducer( +def pop_queue(state: CameraState) -> CameraState: + if len(state.queue) > 0: + input_description, *queue = state.queue + return replace(state, current=input_description, queue=queue) + return replace( + state, + is_viewfinder_active=False, + current=None, + ) + + +def reducer( # noqa: C901 state: CameraState | None, action: Action, ) -> ReducerResult[CameraState, Action, CameraEvent]: if state is None: if isinstance(action, InitAction): - return CameraState( - is_viewfinder_active=False, - ) + return CameraState(is_viewfinder_active=False, queue=[]) raise InitializationActionError(action) if isinstance(action, CameraStartViewfinderAction): + if state.is_viewfinder_active: + return replace( + state, + queue=[ + *state.queue, + InputDescription(id=action.id, pattern=action.pattern), + ], + ) return CompleteReducerResult( - state=replace(state, is_viewfinder_active=True), - events=[ - CameraStartViewfinderEvent( - barcode_pattern=action.barcode_pattern, - ), - ], + state=replace( + state, + is_viewfinder_active=True, + current=InputDescription(id=action.id, pattern=action.pattern), + ), + events=[CameraStartViewfinderEvent(pattern=action.pattern)], ) - if isinstance(action, CameraStopViewfinderAction): - return CompleteReducerResult( - state=replace(state, is_viewfinder_active=False), - events=[ - CameraStopViewfinderEvent(), - ], - ) + if isinstance(action, CameraReportBarcodeAction) and state.current: + for code in action.codes: + if state.current.pattern: + match = re.match(state.current.pattern, code) + if match: + return CompleteReducerResult( + state=pop_queue(state), + events=[ + CameraBarcodeEvent( + id=state.current.id, + code=code, + group_dict=match.groupdict(), + ), + CameraStopViewfinderEvent(id=None), + ], + ) + else: + return CompleteReducerResult( + state=pop_queue(state), + events=[ + CameraBarcodeEvent( + id=state.current.id, + code=code, + group_dict=None, + ), + CameraStopViewfinderEvent(id=None), + ], + ) + + return state if isinstance(action, KeypadAction): # noqa: SIM102 if action.key == Key.BACK: return CompleteReducerResult( - state=replace(state), - actions=[ - CameraStopViewfinderAction(), + state=state, + events=[ + CameraStopViewfinderEvent( + id=state.current.id if state.current else None, + ), ], ) diff --git a/ubo_app/services/040-camera/setup.py b/ubo_app/services/040-camera/setup.py index 9f34d0a6..b582a242 100644 --- a/ubo_app/services/040-camera/setup.py +++ b/ubo_app/services/040-camera/setup.py @@ -1,22 +1,24 @@ # ruff: noqa: D100, D101, D102, D103, D104, D107 from __future__ import annotations -import re -import time +import asyncio +from pathlib import Path from typing import TYPE_CHECKING import headless_kivy_pi.config import numpy as np +from debouncer import DebounceOptions, debounce from kivy.clock import Clock +from pyzbar.pyzbar import decode -from ubo_app.logging import logger from ubo_app.store import dispatch, subscribe_event from ubo_app.store.services.camera import ( - CameraBarcodeAction, + CameraReportBarcodeAction, CameraStartViewfinderEvent, CameraStopViewfinderEvent, ) from ubo_app.utils import IS_RPI +from ubo_app.utils.async_ import create_task if TYPE_CHECKING: from numpy._typing import NDArray @@ -41,44 +43,50 @@ def resize_image( return resized[: new_size[0], : new_size[1]] -def check_codes(regex: re.Pattern, codes: list[str], last_match: float) -> float: - if time.time() - last_match < THROTTL_TIME: - return last_match - - for code in codes: - logger.info( - 'Read barcode, decoded value', - extra={'decoded_value': code}, - ) - match = regex.match(code) - if match: - logger.info( - 'Pattern match', - extra={ - 'pattern': regex.pattern, - 'match': match.groupdict(), - 'decoded_value': code, - }, +@debounce( + wait=THROTTL_TIME, + options=DebounceOptions(leading=True, trailing=False, time_window=THROTTL_TIME), +) +async def check_codes(codes: list[str]) -> None: + dispatch(CameraReportBarcodeAction(codes=codes)) + + +def run_fake_camera() -> None: # pragma: no cover + async def provide() -> None: + while True: + await asyncio.sleep(0.1) + path = Path('/tmp/qrcode_input.txt') # noqa: S108 + if not path.exists(): + continue + data = path.read_text().strip() + path.unlink(missing_ok=True) + await check_codes([data]) + + def run_provider() -> None: + def set_task(task: asyncio.Task) -> None: + def stop() -> None: + task.cancel() + cancel_subscription() + + cancel_subscription = subscribe_event( + CameraStopViewfinderEvent, + stop, ) - dispatch(CameraBarcodeAction(code=code, match=match.groupdict())) - return time.time() + create_task(provide(), set_task) + + subscribe_event( + CameraStartViewfinderEvent, + run_provider, + ) def init_service() -> None: if not IS_RPI: - subscribe_event( - CameraStartViewfinderEvent, - lambda event: check_codes( - re.compile(event.barcode_pattern or ''), - ['WIFI:S:SSID;T:WPA;P:password;;'], - 0, - ), - ) + run_fake_camera() return from picamera2 import Picamera2 # pyright: ignore [reportMissingImports] - from pyzbar.pyzbar import decode picam2 = Picamera2() preview_config = picam2.create_still_configuration( @@ -95,11 +103,7 @@ def init_service() -> None: picam2.start() - def start_camera_viewfinder(start_event: CameraStartViewfinderEvent) -> None: - regex_pattern = start_event.barcode_pattern - regex = re.compile(regex_pattern) if regex_pattern is not None else None - last_match = 0 - + def start_camera_viewfinder() -> None: display = headless_kivy_pi.config._display # noqa: SLF001 if not display: return @@ -111,12 +115,11 @@ def feed_viewfinder(_: object) -> None: data = picam2.capture_array('main') barcodes = decode(data) - if len(barcodes) > 0 and regex is not None: - nonlocal last_match - last_match = check_codes( - regex, - [barcode.data.decode() for barcode in barcodes], - last_match, + if len(barcodes) > 0: + create_task( + check_codes( + codes=[barcode.data.decode() for barcode in barcodes], + ), ) data = resize_image(data) diff --git a/ubo_app/services/040-camera/ubo_handle.py b/ubo_app/services/040-camera/ubo_handle.py index 9c5475de..1e038ec7 100644 --- a/ubo_app/services/040-camera/ubo_handle.py +++ b/ubo_app/services/040-camera/ubo_handle.py @@ -1,12 +1,23 @@ # ruff: noqa: D100, D101, D102, D103, D104, D107, N999 -from reducer import reducer -from setup import init_service +from __future__ import annotations -from ubo_app.load_services import register_service +from typing import TYPE_CHECKING -register_service( +if TYPE_CHECKING: + from ubo_app.services 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='camera', label='Camera', - reducer=reducer, - init=init_service, + setup=setup, ) diff --git a/ubo_app/services/040-rgb-ring/rgb_ring_client.py b/ubo_app/services/040-rgb-ring/rgb_ring_client.py index 2794fee9..fbe20371 100644 --- a/ubo_app/services/040-rgb-ring/rgb_ring_client.py +++ b/ubo_app/services/040-rgb-ring/rgb_ring_client.py @@ -27,9 +27,7 @@ class RgbRingClient: def send(self: RgbRingClient, cmd: Sequence[str]) -> None: try: send_command(' '.join(['led', *cmd])) + dispatch(RgbRingSetIsConnectedAction(is_connected=True)) except Exception as exception: # noqa: BLE001 dispatch(RgbRingSetIsConnectedAction(is_connected=False)) logger.error('Unable to connect to the socket', exc_info=exception) - return - else: - dispatch(RgbRingSetIsConnectedAction(is_connected=True)) diff --git a/ubo_app/services/040-rgb-ring/ubo_handle.py b/ubo_app/services/040-rgb-ring/ubo_handle.py index e8ab62b8..d8f359a1 100644 --- a/ubo_app/services/040-rgb-ring/ubo_handle.py +++ b/ubo_app/services/040-rgb-ring/ubo_handle.py @@ -1,12 +1,23 @@ # ruff: noqa: D100, D101, D102, D103, D104, D107, N999 -from reducer import reducer -from setup import init_service +from __future__ import annotations -from ubo_app.load_services import register_service +from typing import TYPE_CHECKING -register_service( +if TYPE_CHECKING: + from ubo_app.services 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='rgb_ring', label='RGB Ring', - reducer=reducer, - init=init_service, + setup=setup, ) diff --git a/ubo_app/services/040-sensors/ubo_handle.py b/ubo_app/services/040-sensors/ubo_handle.py index 85248e89..11fa9820 100644 --- a/ubo_app/services/040-sensors/ubo_handle.py +++ b/ubo_app/services/040-sensors/ubo_handle.py @@ -1,12 +1,23 @@ # ruff: noqa: D100, D101, D102, D103, D104, D107, N999 -from reducer import reducer -from setup import init_service +from __future__ import annotations -from ubo_app.load_services import register_service +from typing import TYPE_CHECKING -register_service( +if TYPE_CHECKING: + from ubo_app.services 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='sensors', label='Sensors', - reducer=reducer, - init=init_service, + setup=setup, ) diff --git a/ubo_app/services/050-ssh/ubo_handle.py b/ubo_app/services/050-ssh/ubo_handle.py index 80e38377..18bd4646 100644 --- a/ubo_app/services/050-ssh/ubo_handle.py +++ b/ubo_app/services/050-ssh/ubo_handle.py @@ -1,10 +1,20 @@ # ruff: noqa: D100, D101, D102, D103, D104, D107, N999 -from setup import init_service +from __future__ import annotations -from ubo_app.load_services import register_service +from typing import TYPE_CHECKING -register_service( +if TYPE_CHECKING: + from ubo_app.services import register + + +def setup() -> None: + from setup import init_service + + init_service() + + +register( service_id='ssh', label='SSH', - init=init_service, + setup=setup, ) diff --git a/ubo_app/services/080-docker/image.py b/ubo_app/services/080-docker/image.py index e4533014..86a55ab8 100644 --- a/ubo_app/services/080-docker/image.py +++ b/ubo_app/services/080-docker/image.py @@ -4,7 +4,8 @@ import contextlib import pathlib -from typing import Callable +from asyncio import iscoroutine +from typing import Any, Callable, Coroutine, Mapping, cast, overload import docker import docker.errors @@ -64,6 +65,7 @@ def update_container(image_id: str, container: Container) -> None: ], ip=container.attrs['NetworkSettings']['Networks']['bridge']['IPAddress'] if container.attrs + and 'bridge' in container.attrs['NetworkSettings']['Networks'] else None, ), ) @@ -148,7 +150,7 @@ def check_container(image_id: str) -> None: """Check the container status.""" path = IMAGES[image_id].path - async def act() -> None: + def act() -> None: logger.debug('Checking image', extra={'image': image_id, 'path': path}) docker_client = docker.from_env() try: @@ -213,7 +215,7 @@ def get_docker_id(docker_id: str) -> str: _monitor_events(image_id, get_docker_id, docker_client) docker_client.close() - create_task(act()) + run_in_executor(None, act) def _fetch_image(image: ImageState) -> None: @@ -270,10 +272,50 @@ def act() -> None: run_in_executor(None, act) +@overload +async def _process_str( + value: str + | Callable[[], str | Coroutine[Any, Any, str]] + | Coroutine[Any, Any, str], +) -> str: ... + + +@overload +async def _process_str( + value: str + | Callable[[], str | Coroutine[Any, Any, str | None] | None] + | Coroutine[Any, Any, str | None] + | None, +) -> str | None: ... + + +async def _process_str( + value: str + | Callable[[], str | Coroutine[Any, Any, str | None] | None] + | Coroutine[Any, Any, str | None] + | None, +) -> str | None: + if callable(value): + value = value() + if iscoroutine(value): + value = cast(str, await value) + return cast(str | None, value) + + +async def _process_environment_variables(image_id: str) -> Mapping[str, str]: + environment_variables = IMAGES[image_id].environment_vairables or {} + result: dict[str, str] = {} + + for key in environment_variables: + result[key] = await _process_str(environment_variables[key]) + + return result + + @autorun(lambda state: state.docker) def _run_container_generator(docker_state: DockerState) -> Callable[[ImageState], None]: def run_container(image: ImageState) -> None: - def act() -> None: + async def act() -> None: docker_client = docker.from_env() container = find_container(docker_client, image=image.path) if container: @@ -309,6 +351,7 @@ def act() -> None: hosts[key] = getattr(docker_state, value).container_ip else: hosts[key] = value + docker_client.containers.run( image.path, hostname=image.id, @@ -317,13 +360,14 @@ def act() -> None: volumes=IMAGES[image.id].volumes, ports=IMAGES[image.id].ports, network_mode=IMAGES[image.id].network_mode, - environment=IMAGES[image.id].environment, + environment=await _process_environment_variables(image.id), extra_hosts=hosts, - restart_policy='always', + restart_policy={'Name': 'always'}, + command=await _process_str(IMAGES[image.id].command), ) docker_client.close() - run_in_executor(None, act) + create_task(act()) return run_container @@ -463,7 +507,7 @@ def action() -> PageWidget: ImageStatus.FETCHING: 'Image is being fetched', ImageStatus.AVAILABLE: 'Image is ready but container is not running', ImageStatus.CREATED: 'Container is created but not running', - ImageStatus.RUNNING: 'Container is running', + ImageStatus.RUNNING: IMAGES[image.id].note or 'Container is running', ImageStatus.ERROR: 'Image has an error, please check the logs', }[image.status], items=items, diff --git a/ubo_app/services/080-docker/reducer.py b/ubo_app/services/080-docker/reducer.py index 9b6fd0f0..922364f6 100644 --- a/ubo_app/services/080-docker/reducer.py +++ b/ubo_app/services/080-docker/reducer.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import field, replace +from typing import Any, Callable, Coroutine from immutable import Immutable from redux import ( @@ -28,6 +29,7 @@ ImageState, ) from ubo_app.store.services.ip import IpUpdateAction +from ubo_app.utils.qrcode import qrcode_input Action = InitAction | DockerAction @@ -59,9 +61,23 @@ class ImageEntry(Immutable): ports: dict[str, str] = field(default_factory=dict) hosts: dict[str, str] = field(default_factory=dict) note: str | None = None - environment: dict[str, str] | None = None + environment_vairables: ( + dict[ + str, + str + | Coroutine[Any, Any, str] + | Callable[[], str | Coroutine[Any, Any, str]], + ] + | None + ) = None network_mode: str = 'bridge' volumes: list[str] | None = None + command: ( + str + | Coroutine[Any, Any, str] + | Callable[[], str | Coroutine[Any, Any, str]] + | None + ) = None IMAGES = { @@ -91,7 +107,7 @@ class ImageEntry(Immutable): id='pi_hole', label='Pi-hole', icon='󰇖', - environment={'WEBPASSWORD': 'admin'}, + environment_vairables={'WEBPASSWORD': 'admin'}, note='Password: admin', path=DOCKER_PREFIX + 'pihole/pihole:latest', ), @@ -118,7 +134,18 @@ class ImageEntry(Immutable): icon='󰛶', network_mode='host', path=DOCKER_PREFIX + 'ngrok/ngrok:latest', - ports={'22/tcp': '22'}, + environment_vairables={ + 'NGROK_AUTHTOKEN': lambda: qrcode_input( + r'^[a-zA-Z0-9]{20,30}_[a-zA-Z0-9]{20,30}$', + resolver=lambda code, _: code, + prompt='Enter the Ngrok Auth Token', + ), + }, + command=lambda: qrcode_input( + '', + resolver=lambda code, _: code, + prompt='Enter the command, for example: `http 80` or `tcp 22`', + ), ), *( [ diff --git a/ubo_app/services/080-docker/setup.py b/ubo_app/services/080-docker/setup.py index 9e3dbeed..351c67c6 100644 --- a/ubo_app/services/080-docker/setup.py +++ b/ubo_app/services/080-docker/setup.py @@ -14,7 +14,7 @@ from docker.models.images import Image from ubo_gui.menu.types import ActionItem, HeadedMenu, HeadlessMenu, Item, SubMenuItem -from ubo_app.constants import DOCKER_INSTALLATION_LOCK_FILE, SOCKET_PATH +from ubo_app.constants import DOCKER_INSTALLATION_LOCK_FILE, SERVER_SOCKET_PATH from ubo_app.store import autorun, dispatch from ubo_app.store.main import RegisterRegularAppAction from ubo_app.store.services.docker import ( @@ -31,7 +31,7 @@ def install_docker() -> None: """Install Docker.""" dispatch(DockerSetStatusAction(status=DockerStatus.INSTALLING)) - if Path(SOCKET_PATH).exists(): + if Path(SERVER_SOCKET_PATH).exists(): send_command('docker install') diff --git a/ubo_app/services/080-docker/ubo_handle.py b/ubo_app/services/080-docker/ubo_handle.py index c4bef7a4..ea9e49e2 100644 --- a/ubo_app/services/080-docker/ubo_handle.py +++ b/ubo_app/services/080-docker/ubo_handle.py @@ -1,12 +1,23 @@ # ruff: noqa: D100, D101, D102, D103, D104, D107, N999 -from reducer import reducer -from setup import init_service +from __future__ import annotations -from ubo_app.load_services import register_service +from typing import TYPE_CHECKING -register_service( +if TYPE_CHECKING: + from ubo_app.services 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='docker', label='Docker', - reducer=reducer, - init=init_service, + setup=setup, ) diff --git a/ubo_app/store/__init__.py b/ubo_app/store/__init__.py index c01532af..2a095382 100644 --- a/ubo_app/store/__init__.py +++ b/ubo_app/store/__init__.py @@ -4,6 +4,7 @@ import sys from dataclasses import replace from pathlib import Path +from threading import current_thread, main_thread from typing import TYPE_CHECKING, Callable, Coroutine from redux import ( @@ -36,6 +37,8 @@ if TYPE_CHECKING: from redux.basic_types import SnapshotAtom, TaskCreatorCallback +assert current_thread() is main_thread(), 'Store should be created in the main thread' # noqa: S101 + def scheduler(callback: Callable[[], None], *, interval: bool) -> None: from kivy.clock import Clock diff --git a/ubo_app/store/services/camera.py b/ubo_app/store/services/camera.py index b083a24a..ab710b2f 100644 --- a/ubo_app/store/services/camera.py +++ b/ubo_app/store/services/camera.py @@ -5,34 +5,41 @@ from redux import BaseAction, BaseEvent -class CameraAction(BaseAction): - ... +class CameraAction(BaseAction): ... class CameraStartViewfinderAction(CameraAction): - barcode_pattern: str | None + id: str + pattern: str | None -class CameraStopViewfinderAction(CameraAction): - ... +class CameraEvent(BaseEvent): ... -class CameraBarcodeAction(CameraAction): - code: str - match: dict[str, str | None] +class CameraStartViewfinderEvent(CameraEvent): + pattern: str | None -class CameraEvent(BaseEvent): - ... +class CameraStopViewfinderEvent(CameraEvent): + id: str | None -class CameraStartViewfinderEvent(CameraEvent): - barcode_pattern: str | None +class CameraReportBarcodeAction(CameraAction): + codes: list[str] -class CameraStopViewfinderEvent(CameraEvent): - ... +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 + queue: list[InputDescription] diff --git a/ubo_app/store/services/notifications.py b/ubo_app/store/services/notifications.py index 07e1a0b7..3f9815f4 100644 --- a/ubo_app/store/services/notifications.py +++ b/ubo_app/store/services/notifications.py @@ -20,10 +20,10 @@ class Importance(StrEnum): IMPORTANCE_ICONS = { - Importance.CRITICAL: '󰀦', - Importance.HIGH: '⚠', - Importance.MEDIUM: '󰋼', - Importance.LOW: '󰎚', + Importance.CRITICAL: '󰅚', + Importance.HIGH: '󰀪', + Importance.MEDIUM: '', + Importance.LOW: '󰌶', } IMPORTANCE_COLORS = { @@ -82,6 +82,7 @@ class Notification(Immutable): color: str = field(default_factory=default_color) expiry_date: datetime | None = None display_type: NotificationDisplayType = NotificationDisplayType.NOT_SET + flash_time: float = 4 class NotificationsAction(BaseAction): ... @@ -101,6 +102,10 @@ class NotificationsClearAllAction(NotificationsAction): ... class NotificationsEvent(BaseEvent): ... +class NotificationsClearEvent(NotificationsEvent): + notification: Notification + + class NotificationsDisplayEvent(NotificationsEvent): notification: Notification diff --git a/ubo_app/store/services/wifi.py b/ubo_app/store/services/wifi.py index 85073feb..68238dfd 100644 --- a/ubo_app/store/services/wifi.py +++ b/ubo_app/store/services/wifi.py @@ -39,8 +39,7 @@ class WiFiConnection(Immutable): hidden: bool = False -class WiFiAction(BaseAction): - ... +class WiFiAction(BaseAction): ... class WiFiUpdateAction(WiFiAction): @@ -53,16 +52,10 @@ class WiFiUpdateRequestAction(WiFiAction): reset: bool = False -class WiFiEvent(BaseEvent): - ... +class WiFiEvent(BaseEvent): ... -class WiFiCreateEvent(WiFiEvent): - connection: WiFiConnection - - -class WiFiUpdateRequestEvent(WiFiEvent): - ... +class WiFiUpdateRequestEvent(WiFiEvent): ... class WiFiState(Immutable): diff --git a/ubo_app/system/bootstrap.py b/ubo_app/system/bootstrap.py index b31506fb..dc85f1c6 100644 --- a/ubo_app/system/bootstrap.py +++ b/ubo_app/system/bootstrap.py @@ -1,4 +1,5 @@ """Implement `setup_service` function to set up and enable systemd service.""" + from __future__ import annotations import grp @@ -8,6 +9,7 @@ import time import warnings from pathlib import Path +from sys import stdout from typing import Literal, TypedDict from ubo_app.constants import INSTALLATION_PATH, USERNAME @@ -95,7 +97,8 @@ def reload_daemon() -> None: logger.info('Waiting for the user services to come up...') for i in range(RETRIES): time.sleep(1) - print('.', end='', flush=True) # noqa: T201 + stdout.write('.') + stdout.flush() try: subprocess.run( [ # noqa: S603 @@ -123,7 +126,7 @@ def reload_daemon() -> None: msg = f'Failed to reload user services after {RETRIES} times, giving up!' logger.error(msg) warnings.warn(msg, stacklevel=2) - print(flush=True) # noqa: T201 + stdout.flush() subprocess.run( ['/usr/bin/env', 'systemctl', 'daemon-reload'], # noqa: S603 check=True, @@ -178,7 +181,8 @@ def install_docker() -> None: logger.info('Installing docker...') for i in range(RETRIES): time.sleep(1) - print('.', end='', flush=True) # noqa: T201 + stdout.write('.') + stdout.flush() try: subprocess.run( [Path(__file__).parent.joinpath('install_docker.sh').as_posix()], # noqa: S603 @@ -197,7 +201,7 @@ def install_docker() -> None: else: logger.error(f'Failed to installed docker {RETRIES} times, giving up!') return - print(flush=True) # noqa: T201 + stdout.flush() def bootstrap(*, with_docker: bool = False, for_packer: bool = False) -> None: diff --git a/ubo_app/system/install.sh b/ubo_app/system/install.sh index 769ab2fc..126985cb 100755 --- a/ubo_app/system/install.sh +++ b/ubo_app/system/install.sh @@ -8,6 +8,7 @@ WITH_DOCKER=${WITH_DOCKER:-false} FOR_PACKER=false SOURCE=${SOURCE:-"ubo-app"} +export DEBIAN_FRONTEND=noninteractive # Parse arguments for arg in "$@" @@ -82,6 +83,7 @@ apt-get -y install \ python3-pyaudio \ python3-virtualenv \ --no-install-recommends --no-install-suggests +apt-get -y remove orca || true # Enable I2C and SPI sudo raspi-config nonint do_i2c 0 diff --git a/ubo_app/utils/async_.py b/ubo_app/utils/async_.py index 340e566b..79568347 100644 --- a/ubo_app/utils/async_.py +++ b/ubo_app/utils/async_.py @@ -6,7 +6,6 @@ from typing_extensions import TypeVar -from ubo_app.load_services import UboServiceThread from ubo_app.logging import logger if TYPE_CHECKING: @@ -23,9 +22,22 @@ def create_task( callback: TaskCreatorCallback | None = None, ) -> Handle: async def wrapper() -> None: + from ubo_app.load_services import UboServiceThread + if awaitable is None: return try: + logger.verbose( + 'Starting task', + extra={ + 'awaitable': awaitable, + **( + {'ubo_service': current_thread().name} + if isinstance(current_thread(), UboServiceThread) + else {} + ), + }, + ) await awaitable except BaseException as exception: # noqa: BLE001 thread = current_thread() diff --git a/ubo_app/utils/fake.py b/ubo_app/utils/fake.py index 62c5b4ce..633d8868 100644 --- a/ubo_app/utils/fake.py +++ b/ubo_app/utils/fake.py @@ -25,6 +25,13 @@ def __getattr__(self: Fake, attr: str) -> Fake: return cast(Fake, 'fake') return self + def __setattr__(self: Fake, attr: str, value: object) -> None: + logger.verbose( + 'Accessing fake attribute of a `Fake` insta', + extra={'attr': attr}, + ) + super().__setattr__(attr, value) + def __getitem__(self: Fake, key: object) -> Fake: logger.verbose( 'Accessing fake item of a `Fake` instance', diff --git a/ubo_app/utils/garbage_collection.py b/ubo_app/utils/garbage_collection.py index cdc6d6d3..9d3b850b 100644 --- a/ubo_app/utils/garbage_collection.py +++ b/ubo_app/utils/garbage_collection.py @@ -1,9 +1,11 @@ -# ruff: noqa: BLE001, S112, T100, T201 +# ruff: noqa: BLE001, S112 """Garbage collection investigation tools.""" + from __future__ import annotations import contextlib import gc +from sys import stdout from typing import TYPE_CHECKING, Callable from redux.main import inspect @@ -16,17 +18,19 @@ def short_print(obj: object) -> None: """Print the object.""" - print(type(obj), end=' ') + stdout.write(str(type(obj))) try: - print( + stdout.write( str(obj)[:SHORT_PRINT_LENGTH] + '...' if len(str(obj)) > SHORT_PRINT_LENGTH else str(obj), ) + stdout.write('\n') except Exception as exception: if isinstance(exception, KeyboardInterrupt): raise - print('Failed to print object') + stdout.write('Failed to print object\n') + stdout.flush() def examine( @@ -49,7 +53,8 @@ def examine( path.append(obj) with contextlib.suppress(Exception): if looking_for and looking_for in str(type(obj)): - print('Found') + stdout.write('Found\n') + stdout.flush() for i in path: short_print(i) break @@ -83,11 +88,15 @@ def search_stack_for_instance(ref: weakref.ReferenceType) -> None: local_vars = frame.frame.f_locals for var_name, var_value in local_vars.items(): if var_value is ref(): - print( - 'Found instance', - { - 'var_name': var_name, - 'var_value': var_value, - 'frame': frame, - }, + stdout.write( + 'Found instance' + + str( + { + 'var_name': var_name, + 'var_value': var_value, + 'frame': frame, + }, + ) + + '\n', ) + stdout.flush() diff --git a/ubo_app/utils/loop.py b/ubo_app/utils/loop.py index 60401aae..06ef3ee8 100644 --- a/ubo_app/utils/loop.py +++ b/ubo_app/utils/loop.py @@ -6,11 +6,8 @@ import threading from typing import TYPE_CHECKING, Callable, Coroutine, TypeVarTuple, Unpack -from redux.basic_types import FinishEvent from typing_extensions import TypeVar -from ubo_app.constants import DEBUG_MODE - if TYPE_CHECKING: from asyncio import Future, Handle from asyncio.tasks import Task @@ -25,17 +22,19 @@ class WorkerThread(threading.Thread): def __init__(self: WorkerThread) -> None: super().__init__() - self.loop = asyncio.new_event_loop() + try: + self.loop = asyncio.get_event_loop() + except RuntimeError: + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + from ubo_app.constants import DEBUG_MODE + if DEBUG_MODE: self.loop.set_debug(enabled=True) def run(self: WorkerThread) -> None: - asyncio.set_event_loop(self.loop) - - from ubo_app.store import subscribe_event - - subscribe_event(FinishEvent, self.stop) - self.loop.run_forever() + if not self.loop.is_running(): + self.loop.run_forever() def run_task( self: WorkerThread, @@ -58,6 +57,10 @@ def run_in_executor( return self.loop.run_in_executor(executor, task, *args) async def shutdown(self: WorkerThread) -> None: + from ubo_app.logging import logger + + logger.info('Shutting down worker thread') + while True: tasks = [ task @@ -72,11 +75,25 @@ async def shutdown(self: WorkerThread) -> None: self.loop.stop() def stop(self: WorkerThread) -> None: - self.loop.call_soon_threadsafe(lambda: self.loop.create_task(self.shutdown())) + self.loop.call_soon_threadsafe(self.loop.create_task, self.shutdown()) + + def subscribe_finish_event(self: WorkerThread) -> None: + from redux.basic_types import FinishEvent + + from ubo_app.store import subscribe_event + + subscribe_event(FinishEvent, self.stop) + + +def setup_event_loop() -> WorkerThread: + thread = WorkerThread() + thread.start() + thread.subscribe_finish_event() + return thread -thread = WorkerThread() -thread.start() +thread = setup_event_loop() +current_loop = thread.loop def _create_task( diff --git a/ubo_app/utils/qrcode.py b/ubo_app/utils/qrcode.py new file mode 100644 index 00000000..6f83d8fa --- /dev/null +++ b/ubo_app/utils/qrcode.py @@ -0,0 +1,122 @@ +"""Module for scanning QR codes using the camera.""" + +from __future__ import annotations + +import asyncio +import uuid +from asyncio import Future +from datetime import datetime, timezone +from typing import Callable, TypeAlias, overload + +from typing_extensions import TypeVar + +from ubo_app.store import dispatch, subscribe_event +from ubo_app.store.services.camera import ( + CameraBarcodeEvent, + CameraStartViewfinderAction, + CameraStopViewfinderEvent, +) +from ubo_app.store.services.notifications import ( + Notification, + NotificationDisplayType, + NotificationsAddAction, + NotificationsClearEvent, +) +from ubo_app.store.services.rgb_ring import RgbRingBlinkAction + +QrCodeGroupDict: TypeAlias = dict[str, str | None] | None + + +ReturnType = TypeVar('ReturnType', infer_variance=True) + + +@overload +async def qrcode_input( + pattern: str, + *, + prompt: str | None = None, +) -> tuple[str, QrCodeGroupDict]: ... + + +@overload +async def qrcode_input( + pattern: str, + *, + prompt: str | None = None, + resolver: Callable[[str, QrCodeGroupDict], ReturnType], +) -> ReturnType: ... + + +async def qrcode_input( + pattern: str, + *, + prompt: str | None = None, + resolver: Callable[[str, QrCodeGroupDict], ReturnType] | None = None, +) -> tuple[str, QrCodeGroupDict] | ReturnType: + """Use the camera to scan a QR code.""" + future = Future[tuple[str, QrCodeGroupDict]]() + id = uuid.uuid4().hex + loop = asyncio.get_running_loop() + + if prompt: + notification_future = Future() + notification = Notification( + id='qrcode', + title='QR Code', + content=prompt, + display_type=NotificationDisplayType.STICKY, + is_read=True, + expiry_date=datetime.now(tz=timezone.utc), + ) + + def clear_notification(event: NotificationsClearEvent) -> None: + if event.notification == notification: + loop.call_soon_threadsafe(notification_future.set_result, None) + + subscribe_event( + NotificationsClearEvent, + clear_notification, + ) + dispatch(NotificationsAddAction(notification=notification)) + + await notification_future + + async def handle_barcode_event(event: CameraBarcodeEvent) -> None: + if event.id == id: + from kivy.utils import get_color_from_hex + + loop.call_soon_threadsafe(future.set_result, (event.code, event.group_dict)) + kivy_color = get_color_from_hex('#21E693') + dispatch( + RgbRingBlinkAction( + color=( + round(kivy_color[0] * 256), + round(kivy_color[1] * 256), + round(kivy_color[2] * 256), + ), + repetitions=1, + wait=200, + ), + ) + + def handle_cancel(event: CameraStopViewfinderEvent) -> None: + if event.id == id: + future.cancel() + + subscribe_event( + CameraBarcodeEvent, + handle_barcode_event, + keep_ref=False, + ) + subscribe_event( + CameraStopViewfinderEvent, + handle_cancel, + ) + dispatch(CameraStartViewfinderAction(id=id, pattern=pattern)) + + result = await future + + if not resolver: + return result + + return resolver(*result) diff --git a/ubo_app/utils/server.py b/ubo_app/utils/server.py index 2364b883..7d80b5ba 100644 --- a/ubo_app/utils/server.py +++ b/ubo_app/utils/server.py @@ -2,11 +2,10 @@ from __future__ import annotations -import socket import threading from typing import Literal, overload -from ubo_app.constants import SOCKET_PATH +from ubo_app.constants import SERVER_SOCKET_PATH from ubo_app.logging import logger thread_lock = threading.Lock() @@ -22,9 +21,11 @@ def send_command(command: str, *, has_output: Literal[True]) -> str: ... def send_command(command: str, *, has_output: bool = False) -> str | None: """Send a command to the system manager socket.""" + import socket + client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) try: - client.connect(SOCKET_PATH) + client.connect(SERVER_SOCKET_PATH) except Exception as exception: # noqa: BLE001 logger.error('Unable to connect to the socket', exc_info=exception) if has_output: