diff --git a/.github/workflows/build_tests.yml b/.github/workflows/build_tests.yml index 38da6b2..c227f8d 100644 --- a/.github/workflows/build_tests.yml +++ b/.github/workflows/build_tests.yml @@ -10,3 +10,10 @@ jobs: build_tests: uses: OpenVoiceOS/gh-automations/.github/workflows/build-tests.yml@dev secrets: inherit + with: + install_extras: "test" + test_path: "test/" + # The opm.gui_adapter loader (OVOSGUIAdapterFactory) is not yet on PyPI; it + # lives on the ovos-plugin-manager `gui` branch. Resolve it from git until it + # publishes, after which this line drops and the pyproject floor takes over. + pre_install_pip: "git+https://github.com/OpenVoiceOS/ovos-plugin-manager@gui" diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index b00a8cd..09e8181 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -47,6 +47,13 @@ jobs: sudo apt-get update sudo apt install python3-dev swig python -m pip install build wheel + - name: Install ovos-plugin-manager (gui branch) + # The opm.gui_adapter loader (OVOSGUIAdapterFactory) is not yet on PyPI; + # it lives on the ovos-plugin-manager `gui` branch. Resolve it from git + # until it publishes, after which this step drops and the pyproject floor + # takes over. + run: | + pip install git+https://github.com/OpenVoiceOS/ovos-plugin-manager@gui - name: Install repo run: | pip install -e . diff --git a/GUI_DESIGN.md b/GUI_DESIGN.md index ea72de0..5c6b018 100644 --- a/GUI_DESIGN.md +++ b/GUI_DESIGN.md @@ -278,60 +278,67 @@ NamespaceManager(core_bus: MessageBusClient, adapters: list = None) `adapters` is a list of `AbstractGUIPlugin` instances loaded at startup by `GUIService._load_adapter_plugins()`. -### 6.2 GUI routing key +### 6.2 GUI routing key — `session_id` -Every GUI event is tagged with a **routing key** computed by `_gui_routing_key(message)` from the message's session context (`message.context["session"]`). Adapters use this key to send only to the matching GUI clients. +The routing identifier is the **`session_id`**, read from the message's session +context (`message.context["session"]["session_id"]`). There is no separate +location dimension. A shared/multi-room screen is expressed by clients +**sharing the same `session_id`**. The on-device default is just +`session_id == "default"`. -Three cases, in priority order: - -| Case | Condition | Routing key | Example | -|---|---|---|---| -| **On-device** | `session_id == "default"` | `"default"` | Mark2, laptop with local listener | -| **Location group** | `site_id` is set and not `"unknown"` | `site_id` value | `"living_room"` — mirrors to all screens at that location | -| **Standalone remote** | UUID `session_id`, no `site_id` | `session_id` | Phone GUI connected to a remote OVOS server | +| Scenario | `session_id` | Example | +|---|---|---| +| On-device display | `"default"` | Mark 2, laptop with local listener | +| Shared screen group | a shared id | several screens connect with the same id | +| Standalone remote GUI | the remote's session id (e.g. a UUID) | phone GUI on a remote OVOS server | -GUI clients register with their routing key at connect time: -- Qt: `mycroft.gui.connected` → `"site_id"` field (defaults to `"default"`) -- Browser: `GET /?routing_key=` (defaults to `"default"`) +`NamespaceManager._session_id(message)` extracts it (defaulting to `"default"`). +Each `session_id` owns an independent namespace stack; the `session_id` is +forwarded to every adapter so adapters can target the matching client(s). **Routing rules:** -- Template events, session data → sent only to clients whose routing key matches -- Namespace removal, status events (wakeword, speaking, etc.) → broadcast to all connected clients +- Template events and session data carry the `session_id`; adapters deliver + them to clients on that session. +- Namespace removal and status events (wakeword, speaking, etc.) are + system-wide signals; adapters typically broadcast them to all clients. ### 6.3 Template dispatch -`handle_show_page` is the central handler for `gui.page.show`. It checks the first page name: +`handle_show_page` is the central handler for `gui.page.show`. The first page +name must be a `SYSTEM_*` template: -- Starts with `"SYSTEM_"` → **template path**: dispatches to all adapters with the routing key, then activates the namespace on the internal stack. No legacy page-loading occurs. -- Otherwise → **legacy path**: activates namespace, loads pages into stack (unchanged behaviour). +- Starts with `"SYSTEM_"` → **template path**: dispatches to all adapters with + the `session_id`, then activates the namespace on that session's stack. +- Otherwise → rejected (custom QML is not supported). ```python -if page_ids_to_show and page_ids_to_show[0].startswith("SYSTEM_"): - namespace = self._ensure_namespace_exists(namespace_name) - data = {k: v for k, v in namespace.data.items()} - routing_key = self._gui_routing_key(message) - for template in page_ids_to_show: - self._dispatch_template_to_adapters(template, namespace_name, data, routing_key) - with namespace_lock: - if not self.active_namespaces or self.active_namespaces[0].skill_id != namespace_name: - self._activate_namespace(namespace_name, routing_key) - return +session_id = self._session_id(message) +session = self.get_session(session_id) +namespace = self._ensure_namespace_exists(namespace_name, session) +data = {k: v for k, v in namespace.data.items()} +for template in page_ids_to_show: + self._dispatch_template_to_adapters(template, namespace_name, data, session_id) +with namespace_lock: + if not session.active_namespaces or session.active_namespaces[0].skill_id != namespace_name: + self._activate_namespace(namespace_name, session, session_id) + self._update_namespace_persistence(persistence, session) ``` ### 6.4 Session data forwarding -Every `gui.value.set` message calls `adapter.on_session_update(skill_id, filtered_data, routing_key)` on all adapters after updating the internal namespace data. `__from` and `__idle` reserved keys are stripped before delivery. +Every `gui.value.set` message calls `adapter.on_session_update(skill_id, filtered_data, session_id)` on all adapters after updating the internal namespace data. `__from` and `__idle` reserved keys are stripped before delivery. ### 6.5 Lifecycle hook invocation | Internal event | Adapter hook called | Routing | |---|---|---| -| Namespace moves to top of active stack | `on_namespace_activated(skill_id, routing_key)` | per-key | -| Namespace removed from active stack | `on_namespace_deactivated(skill_id)` | broadcast all | -| `gui.value.set` received | `on_session_update(skill_id, data, routing_key)` | per-key | -| Status event forwarded | `on_status_event(event_name, data)` | broadcast all | +| Namespace moves to top of active stack | `on_namespace_activated(skill_id, session_id)` | per-session | +| Namespace removed from active stack | `on_namespace_deactivated(skill_id, session_id)` | per-session | +| `gui.value.set` received | `on_session_update(skill_id, data, session_id)` | per-session | +| Status event forwarded | `on_status_event(event_name, data, session_id)` | broadcast all | -Status events (wakeword, speaking, etc.) are broadcast to all clients — they are system-wide signals not tied to a specific session or location. +Status events (wakeword, speaking, etc.) are system-wide signals; adapters +broadcast them to all clients regardless of session. ### 6.6 Namespace persistence @@ -367,12 +374,14 @@ AbstractGUIPlugin(config: dict, bus: MessageBusClient = None) Each handler defaults to a no-op. Subclasses override only those they support. Handlers are invoked via `dispatch_template()` which catches and logs any exceptions, so a broken handler never affects other adapters. ```python -def handle_show_text(self, skill_id: str, data: dict, site_id: str = "default") -> None: ... -def handle_show_weather(self, skill_id: str, data: dict, site_id: str = "default") -> None: ... +def handle_show_text(self, skill_id: str, data: dict, session_id: str = "default") -> None: ... +def handle_show_weather(self, skill_id: str, data: dict, session_id: str = "default") -> None: ... # ... 19 others — see AbstractGUIPlugin._TEMPLATE_HANDLERS ``` -`site_id` is the **routing key** computed from the message context (see §6.2). Adapters use it to deliver the update only to the matching client(s). +`session_id` is the **routing key** read from the message context (see §6.2). +Adapters use it to deliver the update only to the matching client(s); shared +screens share a `session_id`. The full handler-to-template mapping is maintained in `AbstractGUIPlugin._TEMPLATE_HANDLERS`: @@ -392,6 +401,7 @@ _TEMPLATE_HANDLERS = { "SYSTEM_url": "handle_show_url", "SYSTEM_audio_player": "handle_show_audio_player", "SYSTEM_video_player": "handle_show_video_player", + "SYSTEM_media_player": "handle_show_media_player", "SYSTEM_clock": "handle_show_clock", "SYSTEM_timer": "handle_show_timer", "SYSTEM_weather": "handle_show_weather", @@ -405,14 +415,16 @@ _TEMPLATE_HANDLERS = { ### 7.3 Lifecycle hooks ```python -def on_namespace_activated(self, skill_id: str, site_id: str = "default") -> None: ... -def on_namespace_deactivated(self, skill_id: str) -> None: ... +def on_namespace_activated(self, skill_id: str, session_id: str = "default") -> None: ... +def on_namespace_deactivated(self, skill_id: str, session_id: str = "default") -> None: ... def on_idle(self) -> None: ... -def on_session_update(self, skill_id: str, data: dict, site_id: str = "default") -> None: ... -def on_status_event(self, event_name: str, data: dict, site_id: str = "default") -> None: ... +def on_session_update(self, skill_id: str, data: dict, session_id: str = "default") -> None: ... +def on_status_event(self, event_name: str, data: dict, session_id: str = "default") -> None: ... ``` -`on_namespace_deactivated` and `on_status_event` are system-wide signals; although `site_id` is accepted for API consistency, adapters should broadcast these to all connected clients regardless of routing key. +`on_namespace_deactivated` and `on_status_event` are system-wide signals; the +`session_id` is accepted for API consistency, but adapters should broadcast +these to all connected clients regardless of session. ### 7.4 Connection status @@ -441,21 +453,22 @@ entry_points={ ## 8. Plugin Discovery and Loading (`ovos-plugin-manager`) -**File:** `ovos_plugin_manager/gui_adapter.py` +**File:** `ovos_plugin_manager/gui.py` ```python find_gui_adapter_plugins() -> Dict[str, Type[AbstractGUIPlugin]] load_gui_adapter_plugin(module_name) -> Optional[Type[AbstractGUIPlugin]] -OVOSGUIAdapterFactory.create(module_name, config, bus) -> Optional[AbstractGUIPlugin] -OVOSGUIAdapterFactory.create_all(config, bus) -> List[AbstractGUIPlugin] +OVOSGUIAdapterFactory.create_all(bus=None, config=None) -> List[AbstractGUIPlugin] ``` `GUIService._load_adapter_plugins()` calls `create_all` with: -- `config = mycroft.conf["gui"]["adapters"]` - `bus = self.bus` (the shared MessageBusClient) +- `config = mycroft.conf["gui"]["adapters"]` -Plugins that raise during `__init__` are skipped and logged; they do not prevent other adapters from loading. +`create_all` never raises: plugins that raise during `__init__` are skipped and +logged, and a headless device with no adapters installed gets an empty list. +The GUI service then degrades to no-op dispatch instead of crashing. `PluginTypes.GUI_ADAPTER = "opm.gui_adapter"` is defined in `ovos_plugin_manager/utils/__init__.py`. @@ -475,9 +488,9 @@ Plugins that raise during `__init__` are skipped and logged; they do not prevent **What it does:** - On `__init__`, starts the Tornado WS server (previously run by `ovos-gui` itself) -- For each `handle_show_*` call, resolves the matching bundled QML file from its `ui/` directory and sends `mycroft.gui.list.insert` + `mycroft.session.set` messages only to clients whose `site_id` matches the routing key via `send_to_clients_for_site(site_id, msg)` +- For each `handle_show_*` call, resolves the matching bundled QML file from its `ui/` directory and sends `mycroft.gui.list.insert` + `mycroft.session.set` messages only to clients whose `session_id` matches via `send_to_clients_for_session(session_id, msg)` - Status events and namespace removal are broadcast to **all** connected Qt clients via `send_to_all_clients(msg)` — these are system-wide signals -- Each Qt client announces its routing key in the `mycroft.gui.connected` handshake: `{"site_id": "default"}` for on-device, `{"site_id": "living_room"}` for a location group, or a UUID for a standalone remote GUI +- Each Qt client announces its `session_id` in the `mycroft.gui.connected` handshake: `{"session_id": "default"}` on-device, a shared id for a multi-room screen group, or the remote session id for a standalone remote GUI - Implements `any_client_connected()` based on active WS connections - Skills provide **no QML** — the 21 QML stubs are bundled inside this plugin @@ -493,10 +506,10 @@ Plugins that raise during `__init__` are skipped and logged; they do not prevent **What it does:** - On `__init__`, creates a `GUIManager` and starts FastAPI/uvicorn in a daemon thread -- For each `handle_show_*` call, instantiates the matching `Page` subclass from `templates/__init__.py` and calls `GUIManager.show_template_page(..., site_id=site_id)` -- DOM updates are pushed only to browser tabs whose routing key matches `site_id` via per-session SSE queues; status events broadcast to all tabs -- Each browser tab gets a unique `session_id` (a random hex token) and a dedicated SSE endpoint `/updates/{session_id}`; tabs declare their routing key at `GET /?routing_key=` -- `Renderer._clients: Dict[str, str]` maps `session_id → routing_key`; `send(data, site_id=None)` delivers to matching sessions (`None` = broadcast all) +- For each `handle_show_*` call, instantiates the matching `Page` subclass from `templates/__init__.py` and calls `GUIManager.show_template_page(..., session_id=session_id)` +- DOM updates are pushed only to browser tabs whose `session_id` matches via per-tab SSE queues; status events broadcast to all tabs +- Each browser tab declares its `session_id` at `GET /?session_id=` (default `"default"`) and gets a dedicated SSE endpoint `/updates/{session_id}` +- `Renderer.send(data, session_id=None)` delivers to matching tabs (`None` = broadcast all) - Implements `any_client_connected()` by checking `global_renderer._clients` - Touch events from `ConfirmPage` / `SelectPage` call back to OVOS via `self.bus.emit()` - Tabs that stop sending pings are cleaned up after 30 s (`_check_disconnected` daemon thread) @@ -504,20 +517,20 @@ Plugins that raise during `__init__` are skipped and logged; they do not prevent **Server routes:** | Route | Purpose | |---|---| -| `GET /?routing_key=default` | Serve initial HTML; register browser tab with a routing key (default: `"default"`) | +| `GET /?session_id=default` | Serve initial HTML; register browser tab with a `session_id` (default: `"default"`) | | `GET /updates/{session_id}` | Per-tab SSE stream for DOM patch events | | `GET /local-event/{id}` | HTMX local callback — returns HTML fragment | | `POST /global-event/{id}` | HTMX global callback — no body returned | | `POST /ping/{session_id}` | Browser keepalive; sessions without pings time out after 30 s | | `GET /assets/*` | Static CSS/JS/font files | -**Routing key values (query parameter `routing_key`):** +**`session_id` values (query parameter `session_id`):** | Value | Meaning | |---|---| | `"default"` | On-device display (Mark 2, laptop) — default if not specified | -| `"living_room"` / any string | Named physical location group | -| `""` | Standalone remote GUI (phone/tablet) — must match the OVOS session ID | +| any shared string | Multi-room screen group — tabs sharing the id share state | +| remote session id | Standalone remote GUI (phone/tablet) — matches the OVOS session id | --- @@ -624,14 +637,15 @@ Use this checklist to confirm the implementation matches this spec: - [ ] `ovos_gui/bus.py` does not exist (deleted — Tornado WS moved to legacy plugin) - [ ] `NamespaceManager.__init__` does NOT call `create_gui_service()` or start any WS server - [ ] `NamespaceManager` constructor accepts `adapters: list = None` -- [ ] `_gui_routing_key(message)` implements the three-case logic: `session_id=="default"` → `"default"`, `site_id` set and not `"unknown"` → `site_id`, else → `session_id` -- [ ] `handle_show_page` routes `SYSTEM_*` page names to `_dispatch_template_to_adapters(template, skill_id, data, routing_key)` and returns early (skips legacy path) -- [ ] `_dispatch_template_to_adapters` calls `adapter.dispatch_template(template, skill_id, data, site_id)` for each adapter -- [ ] `handle_set_value` calls `adapter.on_session_update(namespace_name, filtered_data, routing_key)` for each adapter (after stripping reserved keys) -- [ ] `_activate_namespace(namespace, routing_key)` calls `adapter.on_namespace_activated(skill_id, routing_key)` for each adapter -- [ ] `_remove_namespace` calls `adapter.on_namespace_deactivated(skill_id)` for each adapter (broadcast — no routing key) +- [ ] `_session_id(message)` returns `message.context["session"]["session_id"]`, defaulting to `"default"` (the routing key is the session_id; no `site_id`) +- [ ] `handle_show_page` routes `SYSTEM_*` page names to `_dispatch_template_to_adapters(template, skill_id, data, session_id)` and returns early (rejects non-template names) +- [ ] `_dispatch_template_to_adapters` calls `adapter.dispatch_template(template, skill_id, data, session_id)` for each adapter +- [ ] `handle_set_value` calls `adapter.on_session_update(namespace_name, filtered_data, session_id)` for each adapter (after stripping reserved keys) +- [ ] `_activate_namespace(...)` calls `adapter.on_namespace_activated(skill_id, session_id)` for each adapter +- [ ] `_remove_namespace` calls `adapter.on_namespace_deactivated(skill_id, session_id)` for each adapter - [ ] `handle_status_request` uses `adapter.any_client_connected()` (not a Tornado client list) -- [ ] Status events from `_define_messages_to_forward` call `adapter.on_status_event(event_name, data)` for each adapter (broadcast — no routing key) +- [ ] Status events from `_define_messages_to_forward` call `adapter.on_status_event(event_name, data, session_id)` for each adapter +- [ ] No `gui.page.delete*` handlers and no `GuiPage`/page model (template-only namespaces) ### ovos-gui-api-client @@ -654,13 +668,13 @@ Use this checklist to confirm the implementation matches this spec: - [ ] Inherits from `AbstractGUIPlugin` - [ ] Registered under entry point group `opm.gui_adapter` - [ ] Starts Tornado WS on port 18181 in `__init__` (not on module import) -- [ ] `QtGUIWebSocketHandler` has `_site_id` attribute set from `mycroft.gui.connected` handshake (`site_id` field, default `"default"`) -- [ ] `send_to_clients_for_site(site_id, msg)` delivers only to clients where `client.site_id == site_id` (exact match — `"default"` is NOT a wildcard) +- [ ] `QtGUIWebSocketHandler` has `_session_id` attribute set from `mycroft.gui.connected` handshake (`session_id` field, default `"default"`) +- [ ] `send_to_clients_for_session(session_id, msg)` delivers only to clients where `client.session_id == session_id` (exact match — `"default"` is NOT a wildcard) - [ ] `send_to_all_clients(msg)` used for status events and namespace removal (system-wide) -- [ ] All 21 `handle_show_*` methods have signature `(self, skill_id, data, site_id="default")` and use `send_to_clients_for_site` -- [ ] `on_namespace_activated(skill_id, site_id="default")` uses `send_to_clients_for_site` -- [ ] `on_namespace_deactivated(skill_id)` uses `send_to_all_clients` -- [ ] `on_status_event(event_name, data, site_id="default")` uses `send_to_all_clients` (always broadcast) +- [ ] All `handle_show_*` methods have signature `(self, skill_id, data, session_id="default")` and use `send_to_clients_for_session` +- [ ] `on_namespace_activated(skill_id, session_id="default")` uses `send_to_clients_for_session` +- [ ] `on_namespace_deactivated(skill_id, session_id="default")` uses `send_to_all_clients` +- [ ] `on_status_event(event_name, data, session_id="default")` uses `send_to_all_clients` (always broadcast) - [ ] All 21 `handle_show_*` methods implemented; each resolves a bundled QML file from `ui/` - [ ] Skills supply no QML — all 21 QML stubs are bundled inside this plugin's `ui/` directory - [ ] Implements `any_client_connected()` based on active WS connections @@ -672,19 +686,18 @@ Use this checklist to confirm the implementation matches this spec: - [ ] Starts FastAPI/uvicorn in a daemon thread in `__init__` - [ ] `app.py` has NO `/cache` static mount - [ ] `gui_client.py` does not exist (deleted) -- [ ] `GET /` accepts `routing_key: str = "default"` query parameter; generates a per-tab `session_id`; patches `sse-connect` to `/updates/{session_id}` and ping URL to `/ping/{session_id}` +- [ ] `GET /` accepts `session_id: str = "default"` query parameter; patches `sse-connect` to `/updates/{session_id}` and ping URL to `/ping/{session_id}` - [ ] `GET /updates/{session_id}` serves a dedicated SSE queue per browser tab - [ ] `EventSender` uses `{session_id: Queue}` dict; `send(msg, session_ids=None)` delivers to matching tabs (`None` = broadcast all) -- [ ] `Renderer._clients: Dict[str, str]` maps `session_id → routing_key`; `register_client(session_id, routing_key)` populates it -- [ ] `Renderer.send(data, site_id=None)` — `None` broadcasts; string routes to matching sessions only +- [ ] `Renderer.send(data, session_id=None)` — `None` broadcasts; string routes to matching sessions only - [ ] `_check_disconnected` daemon cleans up sessions that stop pinging after 30 s -- [ ] All 21 `handle_show_*` methods have signature `(self, skill_id, data, site_id="default")`; pass `site_id` to `show_template_page` -- [ ] `on_namespace_activated(skill_id, site_id="default")` passes `site_id` to `GUIManager.show` -- [ ] `on_status_event(event_name, data, site_id="default")` passes `site_id=None` to `GUIManager.update_status` (always broadcast) -- [ ] `templates/__init__.py` defines all 21 `Page` subclasses and `TEMPLATE_PAGE_MAP` +- [ ] All `handle_show_*` methods have signature `(self, skill_id, data, session_id="default")`; pass `session_id` to `show_template_page` +- [ ] `on_namespace_activated(skill_id, session_id="default")` passes `session_id` to `GUIManager.show` +- [ ] `on_status_event(event_name, data, session_id="default")` passes `session_id=None` to `GUIManager.update_status` (always broadcast) +- [ ] `templates/__init__.py` defines all `Page` subclasses and `TEMPLATE_PAGE_MAP` - [ ] `ConfirmPage` and `SelectPage` accept `skill_id` and call back to OVOS bus on touch - [ ] `app.set_plugin(plugin)` must be called before uvicorn starts -- [ ] Implements `any_client_connected(site_id=None)` via `global_renderer._clients` +- [ ] Implements `any_client_connected()` via the renderer's session map - [ ] `on_namespace_activated`, `on_namespace_deactivated`, `on_session_update`, `on_status_event` all implemented ### Skills @@ -710,15 +723,15 @@ class TerminalGUIPlugin(AbstractGUIPlugin): super().__init__(config, bus) # start any server / rendering pipeline here - def handle_show_text(self, skill_id: str, data: dict, site_id: str = "default") -> None: - # site_id is the routing key — use it to target specific terminals if applicable - print(f"[{skill_id}@{site_id}] {data.get('title', '')}: {data.get('text', '')}") + def handle_show_text(self, skill_id: str, data: dict, session_id: str = "default") -> None: + # session_id is the routing key — use it to target specific terminals if applicable + print(f"[{skill_id}@{session_id}] {data.get('title', '')}: {data.get('text', '')}") - def handle_show_weather(self, skill_id: str, data: dict, site_id: str = "default") -> None: - print(f"[{skill_id}@{site_id}] {data['location']}: {data['current_temp']}° {data['condition']}") + def handle_show_weather(self, skill_id: str, data: dict, session_id: str = "default") -> None: + print(f"[{skill_id}@{session_id}] {data['location']}: {data['current_temp']}° {data['condition']}") - def on_status_event(self, event_name: str, data: dict, site_id: str = "default") -> None: - # Status events are system-wide — ignore site_id and broadcast to all terminals + def on_status_event(self, event_name: str, data: dict, session_id: str = "default") -> None: + # Status events are system-wide — ignore session_id and broadcast to all terminals print(f"[status] {event_name}") def any_client_connected(self) -> bool: diff --git a/docs/adapter-development/CONTRACT.md b/docs/adapter-development/CONTRACT.md new file mode 100644 index 0000000..bc07ab7 --- /dev/null +++ b/docs/adapter-development/CONTRACT.md @@ -0,0 +1,116 @@ +# Adapter Interface Contract + +The binding contract a GUI adapter (`opm.gui_adapter` plugin) must honor to work +with ovos-gui. Adapters subclass +`ovos_plugin_manager.templates.gui.AbstractGUIPlugin`. + +Routing is keyed solely by `session_id`. A shared/multi-room screen is expressed +by clients sharing the same `session_id`; the on-device default is +`session_id == "default"`. There is no `site_id` and no separate `routing_key`. + +## Construction + +```python +def __init__(self, config: dict, bus: MessageBusClient | None = None): + super().__init__(config, bus) +``` + +- `config` — the adapter section from `mycroft.conf → gui.adapters.`. May be empty. +- `bus` — the shared OVOS `MessageBusClient`. May be `None` in tests. Use it via `self.bus`. + +Initialize only lightweight resources and return quickly: adapters load at GUI +startup, so a slow `__init__` blocks the service. Start servers/render pipelines +on a daemon thread. + +## Template handlers + +```python +def handle_show_weather(self, skill_id: str, data: dict, session_id: str = "default") -> None: ... +``` + +- `skill_id` — the namespace that requested the display. +- `data` — the full session-data dict for that namespace at call time (reserved keys `__from`/`__idle` already stripped). +- `session_id` — the target session; deliver only to clients on that session. + +Override only the handlers you support; all default to no-ops. The full mapping +of `SYSTEM_*` template names to handler methods is +`AbstractGUIPlugin._TEMPLATE_HANDLERS`; dispatch goes through +`dispatch_template(template, skill_id, data, session_id)`, which catches and +logs exceptions. + +## Lifecycle hooks + +```python +def on_namespace_activated(self, skill_id: str, session_id: str = "default") -> None: ... +def on_namespace_deactivated(self, skill_id: str, session_id: str = "default") -> None: ... +def on_idle(self) -> None: ... +def on_session_update(self, skill_id: str, data: dict, session_id: str = "default") -> None: ... +def on_status_event(self, event_name: str, data: dict, session_id: str = "default") -> None: ... +``` + +`on_status_event` carries system-wide signals (e.g. `recognizer_loop:wakeword`, +`speak`); broadcast these to all clients regardless of `session_id`. + +## Connection status + +```python +def any_client_connected(self) -> bool: ... +``` + +Return `True` when at least one client is connected. ovos-gui answers +`gui.status.request` with `True` if any adapter reports a connected client. + +## Behavioral requirements + +- **Exception-safe.** Never let an exception escape a handler or hook; catch and + log. ovos-gui wraps calls defensively, but adapters must not rely on it. +- **Non-blocking.** Adapters are called sequentially on the caller's thread. Do + blocking I/O (network, disk, sleep) on a background thread. +- **No shared state mutation.** Do not modify `NamespaceManager` state. Keep only + your own per-`session_id`/per-`skill_id` state and clean it up on + `on_namespace_deactivated`. Do not cache the `data` dict; re-read fresh state + via the query API when needed later. + +## State query API + +ovos-gui exposes read-only queries on `NamespaceManager` for crash recovery: + +```python +get_active_namespace(session_id="default") -> Namespace | None +get_namespace_data(namespace_name, session_id="default") -> dict | None # a copy +get_all_sessions() -> list[str] +is_namespace_active(namespace_name, session_id="default") -> bool +``` + +Use sparingly and handle `None` (the namespace may have been removed). + +## Registration + +```toml +# pyproject.toml +[project.entry-points."opm.gui_adapter"] +"my-adapter" = "my_package:MyAdapterClass" +``` + +`ovos-gui` discovers and loads every installed `opm.gui_adapter` plugin at +startup via `OVOSGUIAdapterFactory.create_all(bus=..., config=...)` and dispatches +every template event to all of them (multi-modal by default). A device with no +adapters installed runs headless: dispatch is a silent no-op. + +## Minimal adapter + +```python +from ovos_plugin_manager.templates.gui import AbstractGUIPlugin + + +class TerminalGUIPlugin(AbstractGUIPlugin): + def handle_show_text(self, skill_id, data, session_id="default"): + print(f"[{skill_id}@{session_id}] " + f"{data.get('title', '')}: {data.get('text', '')}") + + def on_status_event(self, event_name, data, session_id="default"): + print(f"[status] {event_name}") # broadcast; ignore session_id + + def any_client_connected(self) -> bool: + return True +``` diff --git a/docs/adapter-development/architecture.md b/docs/adapter-development/architecture.md new file mode 100644 index 0000000..b40108e --- /dev/null +++ b/docs/adapter-development/architecture.md @@ -0,0 +1,49 @@ +# ovos-gui Architecture + +ovos-gui is the GUI **state and dispatch hub**. It runs no display backend and no +WebSocket server. Skills declare *what* to show via semantic templates; ovos-gui +fans every template event out to all installed adapter plugins, which render it +on their respective surfaces (Qt, browser, terminal, ...). + +``` +Skill (ovos-gui-api-client GUIInterface) + │ gui.value.set / gui.page.show(SYSTEM_*) [MessageBus] + ▼ +ovos-gui / NamespaceManager + • per-session LIFO namespace stack + • dispatches SYSTEM_* templates to every adapter (session_id-routed) + │ dispatch_template(...) to each adapter + ├── ovos-legacy-mycroft-gui-plugin (Tornado WS -> Qt/QML) + └── ovos-gui-plugin-pyhtmx (FastAPI/SSE -> browser) +``` + +## Components + +- **`GUIService`** (`ovos_gui/service.py`) — connects to the bus and loads + adapters via `OVOSGUIAdapterFactory.create_all(bus, config)`. Zero adapters is + a valid headless state (no-op dispatch), never an error. +- **`NamespaceManager`** (`ovos_gui/namespace.py`) — owns the active-namespace + stack per `session_id`, parses persistence (`__idle`), schedules timed + removals, and invokes adapter handlers/hooks. Exposes the read-only state + query API for adapters. +- **`AbstractGUIPlugin`** (`ovos_plugin_manager.templates.gui`) — the adapter + base class and `opm.gui_adapter` entry-point contract. See + [CONTRACT.md](CONTRACT.md). + +## Routing + +The sole routing key is `session_id` +(`message.context["session"]["session_id"]`, default `"default"`). Each +`session_id` has an independent namespace stack. Clients that should mirror the +same content share a `session_id`. Template events and session-data updates are +routed by `session_id`; status events are broadcast to all clients. + +## Invariants + +1. ovos-gui runs no WebSocket server; transports live entirely in adapters. +2. With no adapter installed, every dispatch is a silent no-op — skills never + crash on headless devices. +3. A failing adapter is isolated (logged) and never blocks other adapters or the + service. +4. All display goes through `SYSTEM_*` templates; custom QML page names are + rejected. diff --git a/ovos_gui/__init__.py b/ovos_gui/__init__.py index 45885c8..a361a31 100644 --- a/ovos_gui/__init__.py +++ b/ovos_gui/__init__.py @@ -1,16 +1,7 @@ -# Copyright 2019 Mycroft AI Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -""" Interface for interacting with the Mycroft gui qml viewer. """ +"""OpenVoiceOS GUI framework - adapter management and namespace routing.""" +from ovos_gui.message_types import GUIMessageType + +__all__ = [ + "GUIMessageType", +] diff --git a/ovos_gui/bus.py b/ovos_gui/bus.py deleted file mode 100644 index 3d5b78b..0000000 --- a/ovos_gui/bus.py +++ /dev/null @@ -1,274 +0,0 @@ -# Copyright 2022 Mycroft AI Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -"""GUI message bus implementation - -The basic mechanism is: - 1) GUI client connects to the core messagebus - 2) Core prepares a port for a socket connection to this GUI - 3) The availability of the port is sent over the Core - 4) The GUI connects to the GUI message bus websocket - 5) Connection persists for graphical interaction indefinitely - -If the connection is lost, it must be renegotiated and restarted. -""" -import asyncio -import json -from threading import Lock -from typing import List - -from ovos_bus_client import Message, GUIMessage -from ovos_config.config import Configuration -from ovos_gui.page import GuiPage -from ovos_utils import create_daemon -from ovos_utils.log import LOG -from tornado import ioloop -from tornado.options import parse_command_line -from tornado.web import Application -from tornado.websocket import WebSocketHandler -# from ovos_gui.namespace import NamespaceManager - -_write_lock = Lock() - - -def get_gui_websocket_config() -> dict: - """ - Retrieves the configuration values for establishing a GUI message bus - """ - config = Configuration() - websocket_config = config["gui_websocket"] - - return websocket_config - - -def create_gui_service(nsmanager=None) -> Application: - """ - Initiate a websocket for communicating with the GUI service. - @param nsmanager: NamespaceManager instance - """ - LOG.info('Starting message bus for GUI...') - websocket_config = get_gui_websocket_config() - # Disable all tornado logging so mycroft loglevel isn't overridden - parse_command_line(['--logging=None']) - - routes = [(websocket_config['route'], GUIWebsocketHandler)] - application = Application(routes, namespace_manager=nsmanager) - application.listen( - websocket_config['base_port'], websocket_config['host'] - ) - - create_daemon(ioloop.IOLoop.instance().start) - LOG.info('GUI Message bus started!') - return application - - -def send_message_to_gui(message: dict): - """ - Sends the supplied message to all connected GUI clients. This function does - NOT account for the GUI framework in use by each client - @param message: dict data to send to GUI clients - """ - for connection in GUIWebsocketHandler.clients: - try: - connection.send(message) - except Exception as e: - LOG.exception(repr(e)) - - -def determine_if_gui_connected() -> bool: - """ - Returns True if any clients are connected to the GUI bus. - """ - return len(GUIWebsocketHandler.clients) > 0 - - -class GUIWebsocketHandler(WebSocketHandler): - """Defines the websocket pipeline between the GUI and Mycroft.""" - clients = [] - - def __init__(self, *args, **kwargs): - WebSocketHandler.__init__(self, *args, **kwargs) - self._framework = "qt5" - self.ns_manager = self.application.settings.get("namespace_manager") - - @property - def framework(self) -> str: - """ - Get the GUI framework used by this client - """ - return self._framework or "qt5" - - def open(self): - """ - Add a new connection to `clients` and synchronize - """ - GUIWebsocketHandler.clients.append(self) - LOG.info('New Connection opened!') - self.synchronize() - - def on_close(self): - """ - Remove a closed connection from `clients` - """ - LOG.info('Closing {}'.format(id(self))) - GUIWebsocketHandler.clients.remove(self) - - def synchronize(self): - """ - Upload namespaces, pages and data to the last connected client. - """ - namespace_pos = 0 - - for namespace in self.ns_manager.active_namespaces: - LOG.info(f'Sync {namespace.skill_id}') - # Insert namespace - self.send({"type": "mycroft.session.list.insert", - "namespace": "mycroft.system.active_skills", - "position": namespace_pos, - "data": [{"skill_id": namespace.skill_id}] - }) - # Insert pages - # if uri (path) can not be resolved, it might exist client side - # if path doesn't exist in client side, client is responsible for resolving page by namespace/name - self.send({"type": "mycroft.gui.list.insert", - "namespace": namespace.skill_id, - "position": 0, - "data": [{"url": page.get_uri(self.framework), "page": page.name} - for page in namespace.pages] - }) - # Insert data - for key, value in namespace.data.items(): - self.send({"type": "mycroft.session.set", - "namespace": namespace.skill_id, - "data": {key: value} - }) - namespace_pos += 1 - - def on_message(self, message: str): - """ - Handle a message on the GUI websocket. Deserialize the message, map - message types to valid equivalents for the core messagebus and emit - on the core messagebus. - @param message: Serialized Message - """ - LOG.debug(f"Received: {message}") - parsed_message = GUIMessage.deserialize(message) - LOG.debug(f"Received: {parsed_message.msg_type}|{parsed_message.data}") - - # msg = json.loads(message) - if parsed_message.msg_type == "mycroft.events.triggered" and \ - (parsed_message.data.get('event_name') == 'page_gained_focus' or - parsed_message.data.get('event_name') == - 'system.gui.user.interaction'): - # System event, a page was changed - event_name = parsed_message.data.get('event_name') - if event_name == 'page_gained_focus': - msg_type = 'gui.page_gained_focus' - else: - msg_type = 'gui.page_interaction' - - msg_data = \ - {'namespace': parsed_message.data['namespace'], - 'page_number': parsed_message.data['parameters'].get('number'), - 'skill_id': parsed_message.data['parameters'].get('skillId')} - elif parsed_message.msg_type == "mycroft.events.triggered": - # A normal event was triggered - msg_type = f"{parsed_message.data['namespace']}." \ - f"{parsed_message.data['event_name']}" - msg_data = parsed_message.data['parameters'] - - elif parsed_message.msg_type == 'mycroft.session.set': - # A value was changed send it back to the skill - msg_type = f"{parsed_message.data['namespace']}.set" - msg_data = parsed_message.data['data'] - elif parsed_message.msg_type == 'mycroft.gui.connected': - # new client connected to GUI - - # NOTE: mycroft-gui clients do this directly in core bus, don't - # send it to gui bus. In those cases, framework is read from config, - # defaulting to qt5 for backwards-compat. - default_qt_version = \ - Configuration().get('gui', {}).get('default_qt_version') or 5 - msg_type = parsed_message.msg_type - msg_data = parsed_message.data - - framework = msg_data.get("framework") # new api - if framework is None: - # mycroft-gui api - qt = msg_data.get("qt_version") or default_qt_version - if int(qt) == 6: - framework = "qt6" - else: - framework = "qt5" - - self._framework = framework - else: - # message not in spec - # https://github.com/MycroftAI/mycroft-gui/blob/master/transportProtocol.md - LOG.error(f"unknown GUI protocol message type, ignoring: " - f"{parsed_message.msg_type}") - return - - parsed_message.context["gui_framework"] = self.framework - message = Message(msg_type, msg_data, parsed_message.context) - LOG.debug('Forwarding to core bus...') - self.ns_manager.core_bus.emit(message) - LOG.debug('Done!') - - def write_message(self, *arg, **kwarg): - """ - Wraps WebSocketHandler.write_message() with a lock. - """ - try: - asyncio.get_event_loop() - except RuntimeError: - asyncio.set_event_loop(asyncio.new_event_loop()) - - with _write_lock: - super().write_message(*arg, **kwarg) - - def send_gui_pages(self, pages: List[GuiPage], namespace: str, - position: int): - """ - Send GUI pages to this client, accounting for the client-specific pages - @param pages: list of GuiPage objects to send - @param namespace: namespace to put GuiPages in - @param position: position to insert pages at - """ - framework = self.framework - # if uri (path) can not be resolved, it might exist client side - # if path doesn't exist in client side, client is responsible for resolving page by namespace/name - message = { - "type": "mycroft.gui.list.insert", - "namespace": namespace, - "position": position, - "data": [{"url": page.get_uri(framework), "page": page.name} - for page in pages] - } - LOG.debug(f"Showing pages: {message['data']}") - self.send(message) - - def send(self, data: dict): - """ - Send the given data across the socket as JSON - @param data: Data to send to the GUI - """ - s = json.dumps(data) - self.write_message(s) - - def check_origin(self, origin): - """ - Override origin check to make js connections work. - """ - return True diff --git a/ovos_gui/constants.py b/ovos_gui/constants.py deleted file mode 100644 index c3551f1..0000000 --- a/ovos_gui/constants.py +++ /dev/null @@ -1,4 +0,0 @@ -from ovos_config.locations import get_xdg_cache_save_path - -GUI_CACHE_PATH = get_xdg_cache_save_path('ovos_gui') - diff --git a/ovos_gui/extensions.py b/ovos_gui/extensions.py deleted file mode 100644 index 150d7fa..0000000 --- a/ovos_gui/extensions.py +++ /dev/null @@ -1,74 +0,0 @@ -from ovos_bus_client import Message, MessageBusClient -from ovos_config.config import Configuration -from ovos_utils.log import LOG -from ovos_plugin_manager.gui import OVOSGuiFactory -from ovos_gui.homescreen import HomescreenManager - - -class ExtensionsManager: - def __init__(self, name: str, bus: MessageBusClient): - """ - Constructor for the Extension Manager. The Extension Manager is - responsible for managing the extensions that define additional GUI - behaviours for specific platforms. - @param name: Name of the extension manager - @param bus: MessageBus instance - """ - - self.name = name - self.bus = bus - self.homescreen_manager = HomescreenManager(self.bus) - core_config = Configuration() - enclosure_config = core_config.get("gui") or {} - self.active_extension = enclosure_config.get("extension", "generic") - LOG.debug(f"Extensions Manager: Initializing {self.name} " - f"with active extension {self.active_extension}") - self.activate_extension(self.active_extension.lower()) - - def activate_extension(self, extension_id: str): - """ - Activate the requested extension - @param extension_id: GUI Plugin entrypoint to activate - """ - mappings = { - "smartspeaker": "ovos-gui-plugin-shell-companion", - "bigscreen": "ovos-gui-plugin-bigscreen", - "mobile": "ovos-gui-plugin-mobile", - "plasmoid": "ovos-gui-plugin-plasmoid" - } - if extension_id.lower() in mappings: - extension_id = mappings[extension_id.lower()] - - cfg = dict(Configuration().get("gui", {})) - cfg["module"] = extension_id - # LOG.info(f"Extensions Manager: Activating Extension {extension_id}") - try: - LOG.info(f"Creating GUI with config={cfg}") - self.extension = OVOSGuiFactory.create(cfg, bus=self.bus) - except: - if extension_id == "generic": - raise - LOG.exception(f"failed to load {extension_id}, " - f"falling back to 'generic'") - cfg["module"] = "generic" - self.extension = OVOSGuiFactory.create(cfg, bus=self.bus) - - self.extension.bind_homescreen(self.homescreen_manager) - - LOG.info(f"Extensions Manager - Activated: {extension_id} " - f"({self.extension.__class__.__name__})") - self.bus.emit( - Message("extension.manager.activated", {"id": extension_id})) - - def signal_available(message=None): - message = message or Message("") - self.bus.emit( - message.forward("mycroft.gui.available", - {"permanent": self.extension.permanent})) - - if self.extension.preload_gui: - signal_available() - else: - self.bus.on("mycroft.gui.connected", signal_available) - - diff --git a/ovos_gui/homescreen.py b/ovos_gui/homescreen.py deleted file mode 100644 index 144c4a1..0000000 --- a/ovos_gui/homescreen.py +++ /dev/null @@ -1,170 +0,0 @@ -from threading import Thread -from typing import List, Optional - -from ovos_config.config import Configuration, update_mycroft_config -from ovos_utils.log import LOG, log_deprecation - -from ovos_bus_client import Message, MessageBusClient -from ovos_bus_client.message import dig_for_message - - -class HomescreenManager(Thread): - def __init__(self, bus: MessageBusClient): - super().__init__() - self.bus = bus - self.homescreens: List[dict] = [] - - self.bus.on('homescreen.manager.add', self.add_homescreen) - self.bus.on('homescreen.manager.remove', self.remove_homescreen) - self.bus.on('homescreen.manager.list', self.get_homescreens) - self.bus.on("homescreen.manager.get_active", self.handle_get_active_homescreen) - self.bus.on("homescreen.manager.set_active", self.handle_set_active_homescreen) - self.bus.on("homescreen.manager.disable_active", self.disable_active_homescreen) - self.bus.on("homescreen.manager.show_active", self.show_homescreen) - - def run(self): - """ - Start the Manager after it has been constructed. - """ - self.reload_homescreens_list() - self.show_homescreen() - - def add_homescreen(self, message: Message): - """ - Handle `homescreen.manager.add` and add the requested homescreen if it - has not yet been added. - @param message: Message containing homescreen id to add - """ - homescreen_id = message.data["id"] - - if any((homescreen['id'] == homescreen_id - for homescreen in self.homescreens)): - LOG.info(f"Requested homescreen_id already exists: {homescreen_id}") - else: - LOG.info(f"Homescreen Manager: Adding Homescreen {homescreen_id}") - self.homescreens.append(message.data) - - self.show_homescreen_on_add(homescreen_id) - - def remove_homescreen(self, message: Message): - """ - Handle `homescreen.manager.remove` and remove the requested homescreen - if it exists - @param message: Message containing homescreen id to remove - """ - homescreen_id = message.data["id"] - LOG.info(f"Homescreen Manager: Removing Homescreen {homescreen_id}") - for h in self.homescreens: - if homescreen_id == h["id"]: - self.homescreens.remove(h) - - def get_homescreens(self, message: Message): - """ - Handle `homescreen.manager.list` and emit a response with loaded - homescreens. - :param message: Message requesting homescreens - """ - self.bus.emit(message.response({"homescreens": self.homescreens})) - - def handle_get_active_homescreen(self, message: Message): - """ - Handle `homescreen.manager.get_active` and emit a response with the - active homescreen - @param message: Message requesting active homescreen - """ - self.bus.emit(message.response( - {"homescreen": self.get_active_homescreen()})) - - def handle_set_active_homescreen(self, message: Message): - """ - Handle `homescreen.manager.set_active` requests to change the configured - homescreen and update configuration. - @param message: Message containing requested homescreen ID - """ - new_homescreen = message.data.get("id") - LOG.debug(f"Requested updating homescreen to: {new_homescreen}") - self.set_active_homescreen(new_homescreen) - - def get_active_homescreen(self) -> Optional[dict]: - """ - Get the active homescreen according to configuration if it is loaded - @return: Loaded homescreen with an ID matching configuration - """ - gui_config = Configuration().get("gui") or {} - active_homescreen = gui_config.get("idle_display_skill") - if not active_homescreen: - LOG.info("No homescreen enabled in mycroft.conf") - return - LOG.info(f"Active Homescreen: {active_homescreen}") - for h in self.homescreens: - if h["id"] == active_homescreen: - return active_homescreen - LOG.error(f"{active_homescreen} not loaded!") - - def set_active_homescreen(self, homescreen_id: str): - """ - Update the configured `idle_display_skill` - @param homescreen_id: new `idle_display_skill` - """ - # TODO: Validate requested homescreen_id - if Configuration().get("gui", - {}).get("idle_display_skill") != homescreen_id: - LOG.info(f"Updating configured idle_display_skill to " - f"{homescreen_id}") - new_config = {"gui": {"idle_display_skill": homescreen_id}} - update_mycroft_config(new_config, bus=self.bus) - - def reload_homescreens_list(self): - """ - Emit a request for homescreens to register via the Messagebus - """ - LOG.info("Homescreen Manager: Reloading Homescreen List") - self.bus.emit(Message("homescreen.manager.reload.list")) - - def show_homescreen_on_add(self, homescreen_id: str): - """ - Check if a homescreen should be displayed immediately upon addition - @param homescreen_id: ID of added homescreen - """ - LOG.debug(f"Checking {homescreen_id}") - if self.get_active_homescreen() != homescreen_id: - # Added homescreen isn't the configured one, do nothing - return - - LOG.info(f"Displaying Homescreen {homescreen_id}") - self.bus.emit(Message("homescreen.manager.activate.display", - {"homescreen_id": homescreen_id})) - - def disable_active_homescreen(self, message: Message): - """ - Handle `homescreen.manager.disable_active` requests by configuring the - `idle_display_skill` as None. - @param message: Message requesting homescreen disable - """ - if Configuration().get("gui", {}).get("idle_display_skill"): - LOG.info(f"Disabling idle_display_skill!") - new_config = {"gui": {"idle_display_skill": None}} - update_mycroft_config(new_config, bus=self.bus) - - def show_homescreen(self, message: Optional[Message] = None): - """ - Handle a request to show the homescreen. - @param message: Optional `homescreen.manager.show_active` Message - """ - active_homescreen = self.get_active_homescreen() - if not active_homescreen: - LOG.info("No active homescreen to display") - return - LOG.info(f"Requesting activation of {active_homescreen}") - for h in self.homescreens: - if h.get("id") == active_homescreen: - LOG.debug(f"matched homescreen skill: {h}") - message = message or dig_for_message() or Message("") - LOG.debug(f"Displaying Homescreen {active_homescreen}") - self.bus.emit(message.forward( - "homescreen.manager.activate.display", - {"homescreen_id": active_homescreen})) - break - else: - LOG.warning(f"Requested {active_homescreen} not found in: " - f"{self.homescreens}") diff --git a/ovos_gui/message_types.py b/ovos_gui/message_types.py new file mode 100644 index 0000000..fd66035 --- /dev/null +++ b/ovos_gui/message_types.py @@ -0,0 +1,321 @@ +"""GUI MessageBus protocol message type definitions. + +This module defines all standardized message types used in the OpenVoiceOS GUI +communication protocol between skills, core, and GUI adapters. + +Message types are organized by functional domain: +- Connection: Client lifecycle and negotiation +- Namespaces: Skill session management +- Pages: Display and template rendering +- Session: Interactive state between skill and adapter +- Events: System and skill events +- ShellFeatures: Brightness, color scheme, notifications, widgets, config +""" + +from enum import Enum + + +class GUIMessageType(str, Enum): + """Enumeration of all valid GUI message types in OpenVoiceOS. + + Using string Enum allows direct comparison with message.msg_type strings + while providing type safety and IDE autocomplete. + """ + + # ==================== CONNECTION & LIFECYCLE ==================== + """Client-adapter handshake and availability signals.""" + + OVOS_GUI_CONNECTED = "mycroft.gui.connected" + """Client announces connection to adapter. + + Sent by: Qt GUI client, Web adapter, or any GUI adapter + Data: {session_id, adapter_type} + """ + + OVOS_GUI_UNAVAILABLE = "mycroft.gui.unavailable" + """Adapter signals GUI is unavailable (shutdown, disconnect). + + Sent by: GUI adapter + Response to: Skill requests when adapter offline + """ + + # ==================== NAMESPACE MANAGEMENT ==================== + """Skill window/session lifecycle in the GUI stack.""" + + GUI_PAGE_SHOW = "gui.page.show" + """Display a template-based page in a namespace. + + Sent by: Skills via ovos_workshop.gui.show_*() methods + Data: { + page_names: [str], # List of SYSTEM_* template names + __from: str, # Skill ID (namespace) + __idle: int, # Idle display timeout (seconds) + __duration: int, # Display duration override + __persistent: bool, # Persist across navigation + [template_data]: ... # Template-specific fields (current_temp, etc) + } + """ + + GUI_CLEAR_NAMESPACE = "gui.clear.namespace" + """Remove entire namespace from display stack (legacy name). + + Sent by: Core or skills + Data: {__from: skill_id} + + Note: Deprecated in favor of OVOS_GUI_SCREEN_CLOSE + """ + + OVOS_GUI_SCREEN_CLOSE = "ovos.gui.screen.close" + """Remove namespace from display and deactivate it. + + Sent by: Core, skills, or adapter (when user navigates away) + Data: {__from: skill_id} + Replaces: GUI_CLEAR_NAMESPACE (preferred form) + """ + + GUI_NAMESPACE_REMOVED = "gui.namespace.removed" + """Notification that namespace was removed from stack. + + Sent by: NamespaceManager (ovos-gui) + Response to: GUI_CLEAR_NAMESPACE or OVOS_GUI_SCREEN_CLOSE + """ + + GUI_NAMESPACE_DISPLAYED = "gui.namespace.displayed" + """Notification that namespace was activated/brought to front. + + Sent by: NamespaceManager (ovos-gui) + Data: {__from: skill_id} + """ + + # ==================== SESSION DATA ==================== + """Temporary state shared between skill and adapter(s).""" + + GUI_VALUE_SET = "gui.value.set" + """Update session data in active namespace. + + Sent by: Skills via self.gui.set_context() + Data: {__from: skill_id, [key: value]: ...} + Note: Keys prefixed with __ are reserved by the system + """ + + # Session list operations (for adapter list models) + MYCROFT_SESSION_SET = "mycroft.session.set" + """Set session variable (legacy, maps to GUI_VALUE_SET).""" + + MYCROFT_SESSION_DELETE = "mycroft.session.delete" + """Delete session variable.""" + + MYCROFT_SESSION_LIST_INSERT = "mycroft.session.list.insert" + """Insert item into session list.""" + + MYCROFT_SESSION_LIST_UPDATE = "mycroft.session.list.update" + """Update item in session list.""" + + MYCROFT_SESSION_LIST_MOVE = "mycroft.session.list.move" + """Move item within session list.""" + + MYCROFT_SESSION_LIST_REMOVE = "mycroft.session.list.remove" + """Remove item from session list.""" + + # ==================== PAGE INTERACTION ==================== + """User interactions forwarded from adapter to skill.""" + + GUI_PAGE_INTERACTION = "gui.page_interaction" + """User interacted with displayed page (tapped, scrolled, etc). + + Sent by: Adapter (e.g., Qt GUI when user touches screen) + Data: {skill_id: str, page_number: int} + """ + + GUI_PAGE_GAINED_FOCUS = "gui.page_gained_focus" + """User navigated to a specific page in the namespace. + + Sent by: Adapter + Data: {__from: skill_id, page_number: int} + """ + + # ==================== STATUS EVENTS ==================== + """System status events broadcast to all adapters.""" + + MYCROFT_RECOGNIZER_LOOP_RECORD_BEGIN = "mycroft.recognizer_loop.record_begin" + """STT recording started.""" + + MYCROFT_RECOGNIZER_LOOP_RECORD_END = "mycroft.recognizer_loop.record_end" + """STT recording ended.""" + + MYCROFT_RECOGNIZER_LOOP_UTTERANCE = "mycroft.recognizer_loop.utterance" + """User utterance captured by STT.""" + + MYCROFT_RECOGNIZER_LOOP_WAKE_WORD = "mycroft.recognizer_loop.wake_word" + """Wakeword detected.""" + + MYCROFT_AUDIO_OUTPUT_START = "mycroft.audio_output.start" + """Audio playback started (TTS, skill audio, etc).""" + + MYCROFT_AUDIO_OUTPUT_END = "mycroft.audio_output.end" + """Audio playback ended.""" + + MYCROFT_SKILL_HANDLER_START = "mycroft.skill.handler.start" + """Skill intent handler started executing.""" + + MYCROFT_SKILL_HANDLER_ERROR = "mycroft.skill.handler.error" + """Skill intent handler raised an exception.""" + + # ==================== SKILL INTERACTION RESPONSES ==================== + """Bidirectional interaction between skill and adapter.""" + + # Confirmation interaction + SKILL_CONFIRM_RESPONSE = "{skill_id}.confirm.response" + """Response to a confirmation dialog interaction. + + Pattern: .confirm.response + Data: {result: bool} + """ + + # Selection interaction + SKILL_SELECT_RESPONSE = "{skill_id}.select.response" + """Response to a selection/list interaction. + + Pattern: .select.response + Data: {result: str|int} + """ + + # ==================== SHELL FEATURES (GUI EXTENSIONS) ==================== + """First-class GUI extensions for system features and appearance. + + These were historically called "GUI extensions" but are now considered + part of the main GUI specification. All adapters should support them + to the extent their platform allows. + """ + + # Brightness control + GUI_BRIGHTNESS_SET = "gui.brightness.set" + """Set screen brightness level. + + Data: {brightness: int (0-100)} + """ + + GUI_BRIGHTNESS_GET = "gui.brightness.get" + """Query current screen brightness. + + Response: GUI_BRIGHTNESS_SET with current value + """ + + GUI_BRIGHTNESS_AUTO_DIM_SET = "gui.brightness.auto_dim.set" + """Enable/disable automatic brightness dimming. + + Data: {enabled: bool} + """ + + GUI_BRIGHTNESS_NIGHT_MODE_SET = "gui.brightness.night_mode.set" + """Enable/disable night mode (reduced blue light, etc). + + Data: {enabled: bool} + """ + + # Color scheme management + GUI_COLOR_SCHEME_SET = "gui.color_scheme.set" + """Set color scheme/theme. + + Data: {scheme: str} + """ + + GUI_COLOR_SCHEME_GET = "gui.color_scheme.get" + """Query available color schemes. + + Response: List of available scheme names + """ + + # Notifications + GUI_NOTIFICATION_SET = "gui.notification.set" + """Display a system notification. + + Data: { + title: str, + message: str, + duration: int (ms), + type: str (info|warning|error|success) + } + """ + + GUI_NOTIFICATION_CLEAR = "gui.notification.clear" + """Clear active notifications.""" + + # Custom widgets + GUI_WIDGET_DISPLAY = "gui.widget.display" + """Display a custom widget. + + Data: {widget_id: str, config: dict} + """ + + GUI_WIDGET_REMOVE = "gui.widget.remove" + """Remove a custom widget. + + Data: {widget_id: str} + """ + + # Configuration UI + GUI_CONFIG_LIST_GET = "gui.config.list.get" + """Get list of available configuration modules.""" + + GUI_CONFIG_GET = "gui.config.get" + """Get configuration for a module. + + Data: {module: str} + """ + + GUI_CONFIG_SET = "gui.config.set" + """Set configuration for a module. + + Data: {module: str, config: dict} + """ + + # ==================== EVENTS ==================== + """Skill-defined custom events.""" + + GUI_EVENT_SEND = "gui.event.send" + """Send arbitrary event from skill. + + Data: {event: str, [data]: ...} + """ + + MYCROFT_EVENTS_TRIGGERED = "mycroft.events.triggered" + """Event triggered and handled.""" + + # ==================== RESERVED / LEGACY ==================== + """Deprecated message types kept for backward compatibility.""" + + MYCROFT_GUI_AVAILABLE = "mycroft.gui.available" + """Deprecated: Use OVOS_GUI_CONNECTED""" + + MYCROFT_GUI_UNAVAILABLE = "mycroft.gui.unavailable" + """Deprecated: Use OVOS_GUI_UNAVAILABLE""" + + MYCROFT_GUI_LIST_INSERT = "mycroft.gui.list.insert" + """Deprecated: Use MYCROFT_SESSION_LIST_INSERT""" + + MYCROFT_GUI_LIST_MOVE = "mycroft.gui.list.move" + """Deprecated: Use MYCROFT_SESSION_LIST_MOVE""" + + MYCROFT_GUI_LIST_REMOVE = "mycroft.gui.list.remove" + """Deprecated: Use MYCROFT_SESSION_LIST_REMOVE""" + + MYCROFT_SYSTEM_ACTIVE_SKILLS = "mycroft.system.active_skills" + """Reserved: System-level active skills list (not user-exposed).""" + + def __str__(self) -> str: + """Return the message type string value.""" + return self.value + + @classmethod + def for_skill(cls, skill_id: str, interaction_type: str) -> str: + """Generate skill-specific interaction response message type. + + Args: + skill_id: The skill identifier + interaction_type: Type of interaction (confirm, select, etc) + + Returns: + Full message type string (e.g. "skill-weather.openvoiceos.confirm.response") + """ + return f"{skill_id}.{interaction_type}.response" diff --git a/ovos_gui/namespace.py b/ovos_gui/namespace.py index 7f0666a..8058fe3 100644 --- a/ovos_gui/namespace.py +++ b/ovos_gui/namespace.py @@ -12,52 +12,33 @@ # See the License for the specific language governing permissions and # limitations under the License. # -"""Defines the API for the QT GUI. - -Manages what is displayed on a device with a touch screen using a LIFO stack -of "active" namespaces (e.g. skills). At the bottom of the stack is the -namespace for the idle screen skill (if one is specified in the device -configuration). The namespace for the idle screen skill should never be -removed from the stack. - -When a skill with a GUI is triggered by the user, the namespace for that skill -is placed at the top of the stack. The namespace at the top of the stack -represents the namespace that is visible on the device. When the skill is -finished displaying information on the screen, it is removed from the top of -the stack. This will result in the previously active namespace being -displayed. - -The persistence of a namespace indicates how long that namespace stays in the -active stack. A persistence expressed using a number represents how many -seconds the namespace will be active. A persistence expressed with a True -value will be active until the skill issues a command to remove the namespace. -If a skill with a numeric persistence replaces a namespace at the top of the -stack that also has a numeric persistence, the namespace being replaced will -be removed from the active namespace stack. - -The state of the active namespace stack is maintained locally and in the GUI -code. Changes to namespaces, and their contents, are communicated to the GUI -over the GUI message bus. +"""Defines the API for the GUI service. + +Manages what is displayed on a device with a screen using a LIFO stack of +"active" namespaces (e.g. skills). At the bottom of the stack is the namespace +for the idle screen skill (if one is specified in the device configuration). +The namespace at the top of the stack represents what is visible on the device. +When a skill is finished displaying information, its namespace is removed from +the top of the stack, displaying the previously active namespace. + +The persistence of a namespace indicates how long it stays in the active stack. +A numeric persistence is the number of seconds the namespace stays active; a +``True`` persistence keeps it active until the skill removes it. + +Routing is keyed solely by ``session_id``: every GUI message carries a session +in ``message.context["session"]``. A shared/multi-room screen is expressed by +clients sharing the same ``session_id`` (the on-device default is +``session_id == "default"``). There is no separate location dimension. Each +session keeps its own namespace stack; the ``session_id`` is forwarded to every +adapter so adapters can target the matching client(s). """ -import shutil -from os.path import join, dirname, exists from threading import Lock, Timer from typing import List, Union, Optional, Dict from ovos_bus_client import Message, MessageBusClient -from ovos_config.config import Configuration from ovos_spec_tools import SpecMessage from ovos_utils.log import LOG -from ovos_gui.bus import ( - create_gui_service, - determine_if_gui_connected, - get_gui_websocket_config, - send_message_to_gui, GUIWebsocketHandler -) -from ovos_gui.constants import GUI_CACHE_PATH -from ovos_gui.page import GuiPage - namespace_lock = Lock() RESERVED_KEYS = ['__from', '__idle'] @@ -86,37 +67,12 @@ def _validate_page_message(message: Message) -> bool: return valid -def _get_idle_display_config() -> str: - """ - Retrieves the current value of the idle display skill configuration. - @returns: Configured idle_display_skill (skill_id) - """ - config = Configuration() - enclosure_config = config.get("gui") or {} - idle_display_skill = enclosure_config.get("idle_display_skill") - LOG.info(f"Configured homescreen: {idle_display_skill}") - return idle_display_skill - - -def _get_active_gui_extension() -> str: - """ - Retrieves the current value of the gui extension configuration. - @returns: Configured gui extension - """ - config = Configuration() - enclosure_config = config.get("gui") or {} - gui_extension = enclosure_config.get("extension", "generic") - LOG.info(f"Configured GUI extension: {gui_extension}") - return gui_extension.lower() - - class Namespace: - """A grouping mechanism for related GUI pages and data. + """A grouping mechanism for related GUI templates and data. - In the majority of cases, a namespace represents a skill. There is a - SYSTEM namespace for GUI screens that exist outside of skills. This class - defines an API to manage a namespace, its pages and its data. Actions - are communicated to the GUI message bus. + In the majority of cases, a namespace represents a skill. This class defines + an API to manage a namespace and its session data. All display goes through + standardized templates (SYSTEM_*). Attributes: skill_id: the name of the Namespace, generally the skill ID @@ -124,8 +80,6 @@ class Namespace: period of time or until the namespace is removed. duration: if the namespace persists for a period of time, this is the number of seconds of persistence - pages: when the namespace is active, contains all the pages that are - displayed at the same time data: a key/value pair representing the data used to populate the GUI """ @@ -133,76 +87,36 @@ def __init__(self, skill_id: str): self.skill_id = skill_id self.persistent = False self.duration = 30 - self.pages: List[GuiPage] = list() self.data = dict() - self.page_number = 0 self.session_set = False - @property - def page_names(self): - return [page.name for page in self.pages] - - @property - def active_page(self): - if len(self.pages): - if self.page_number >= len(self.pages): - return None # TODO - error ? - return self.pages[self.page_number] - return None - def add(self): """ Adds this namespace to the list of active namespaces. + State change is notified to adapters via NamespaceManager. """ LOG.info(f"GUI PROTOCOL - Adding \"{self.skill_id}\" to active namespaces") - message = dict( - type="mycroft.session.list.insert", - namespace="mycroft.system.active_skills", - position=0, - data=[dict(skill_id=self.skill_id)] - ) - send_message_to_gui(message) def activate(self, position: int): """ - Activate this namespace if its already in the list of active namespaces. + Activate this namespace if it's already in the list of active namespaces. @param position: position to move this namespace FROM """ - if not len(self.pages): - LOG.error(f"Tried to activate namespace without loaded pages: \"{self.skill_id}\"") - return - LOG.info(f"GUI PROTOCOL - Activating namespace \"{self.skill_id}\"") - message = { - "type": "mycroft.session.list.move", - "namespace": "mycroft.system.active_skills", - "from": position, - "to": 0, - "items_number": 1 - } - send_message_to_gui(message) def remove(self, position: int): """ - Removes this namespace from the list of active namespaces. Also clears + Removes this namespace from the list of active namespaces and clears any session data. + @param position: position to remove this namespace FROM """ LOG.info(f"GUI PROTOCOL - Removing \"{self.skill_id}\" from active namespaces") # unload the data first before removing the namespace - # use the keys of the data to unload the data - for key in self.data: + for key in list(self.data.keys()): self.unload_data(key) - message = dict( - type="mycroft.session.list.remove", - namespace="mycroft.system.active_skills", - position=position, - items_number=1 - ) - send_message_to_gui(message) self.session_set = False - self.pages = list() self.data = dict() def load_data(self, name: str, value: str): @@ -213,26 +127,17 @@ def load_data(self, name: str, value: str): name: The name of the attribute value: The attribute's value """ - LOG.info(f"GUI PROTOCOL - Sending \"{self.skill_id}\" data -- {name} : {value} ") - message = dict( - type="mycroft.session.set", - namespace=self.skill_id, - data={name: value} - ) - send_message_to_gui(message) + LOG.info(f"GUI PROTOCOL - Loading \"{self.skill_id}\" data -- {name} : {value} ") def unload_data(self, name: str): """ - Delete data from the namespace + Delete data from the namespace. + @param name: name of property to delete """ - LOG.info(f"GUI PROTOCOL - Deleting namespace \"{self.skill_id}\" key: {name}") - message = dict( - type="mycroft.session.delete", - property=name, - namespace=self.skill_id - ) - send_message_to_gui(message) + LOG.info(f"GUI PROTOCOL - Unloading namespace \"{self.skill_id}\" key: {name}") + if name in self.data: + del self.data[name] def get_position_of_last_item_in_data(self) -> int: """ @@ -245,215 +150,140 @@ def set_persistence(self, skill_type: str): Sets the duration of the namespace's time in the active list. @param skill_type: if skill type is idleDisplaySkill, the namespace will - always persist. Otherwise, the namespace will persist based on the - active page's persistence. + always persist. Otherwise, the namespace persists for a default duration. """ - # check if skill_type is idleDisplaySkill if skill_type == "idleDisplaySkill": self.persistent = True self.duration = 0 - else: - # get the active page in the namespace - active_page = self.active_page - # if type(persistence) == int: - # Get the duration of the active page if it is not persistent - if active_page is not None and not active_page.persistent: - self.persistent = False - self.duration = active_page.duration - - # elif type(persistence) == bool: - # Get the persistance of the active page - elif active_page is not None and active_page.persistent: - self.persistent = True - self.duration = 0 - - # else use the default duration of 30 seconds - else: - LOG.warning(f"No active page, reset persistence for {self.skill_id}") - self.persistent = False - self.duration = 30 + self.persistent = False + self.duration = 30 - def load_pages(self, pages: List[GuiPage], show_index: int = 0): - """ - Maintains a list of active pages within the active namespace. + LOG.info( + f"GUI PROTOCOL - Set persistence for \"{self.skill_id}\" -- " + f"persistent: {self.persistent}, duration: {self.duration}s") - Skills with multiple pages of data can either show all the screens - at once, allowing the user to swipe back and forth among them, or - the pages can be loaded one at a time. The latter is represented by - a single list item, the former by multiple list items - @param pages: list of pages to be displayed - @param show_index: index of page to display (default 0) - """ - if not pages: - LOG.error("No pages to load ?") - return - if show_index is None: - LOG.warning(f"Expected int show_index but got `None`. Default to 0") - show_index = 0 - new_pages = list() - target_page = pages[show_index] - - for page in pages: - if page.name not in [p.name for p in self.pages]: - new_pages.append(page) - - self.pages.extend(new_pages) - if new_pages: - self._add_pages(new_pages) - if show_index >= len(pages): - LOG.error( - f"Invalid page index requested: {show_index} , only {len(pages)} pages available for \"{self.skill_id}\"") - else: - LOG.info(f"Activating page {show_index} from: {[p.name for p in pages]} for \"{self.skill_id}\"") - self._activate_page(target_page) +class GUISession: + """Represents a single GUI session (a screen or a group of shared screens). - def _add_pages(self, new_pages: List[GuiPage]): - """ - Adds one or more pages to the active page list. - @param new_pages: pages to add to the active page list - """ - LOG.debug(f"namespace \"{self.skill_id}\" current pages: {self.pages}") - LOG.debug(f"new_pages={new_pages}") + Each session maintains its own stack of active namespaces, loaded namespace + data, and timers. Clients that share a ``session_id`` share this session. + """ - # Find position of new page in self.pages - position = self.pages.index(new_pages[0]) - for client in GUIWebsocketHandler.clients: - try: - LOG.debug(f"Updating {client.framework} client") - client.send_gui_pages(new_pages, self.skill_id, position) - except Exception as e: - LOG.exception(f"Error updating {client.framework} client: {e}") - - def focus_page(self, page): - """ - Returns focus to a page already in the active page list. - - @param page: the page that will gain focus - """ - # set the index of the page in the self.pages list - page_index = None - for i, p in enumerate(self.pages): - if p.name == page.name: - # save page index - page_index = i - break - - # handle missing page (TODO, can this happen?) - if page_index is None: - LOG.warning("tried to activate page missing from pages list, inserting it at index 0") - page_index = 0 - self.pages.insert(0, page) - # update page data - else: - self.pages[page_index] = page + def __init__(self, session_id: str): + self.session_id = session_id + self.loaded_namespaces: Dict[str, Namespace] = dict() + self.active_namespaces: List[Namespace] = list() + self.remove_namespace_timers: Dict[str, Timer] = dict() - if page_index != self.page_number: - self.page_number = page_index - LOG.info(f"Focusing page {page.name} -- namespace \"{self.skill_id}\"") - def _activate_page(self, page: GuiPage): - """ - Tells mycroft-gui to returns focus to a page +class NamespaceManager: + """ + Manages the active namespace stack and the content of namespaces. - @param page: the page that will gain focus - """ - LOG.debug(f"Current pages from _activate_page: {self.pages}") - self.focus_page(page) + Attributes: + core_bus: client for communicating with the core message bus + adapters: loaded GUI adapter plugins + sessions: dictionary of active sessions (session_id -> GUISession) + """ - LOG.info( - f"GUI PROTOCOL - Sending event 'page_gained_focus' -- page: {page.name} -- namespace: \"{self.skill_id}\"") - message = dict( - type="mycroft.events.triggered", - namespace=self.skill_id, - event_name="page_gained_focus", - data={"number": self.page_number} - ) - send_message_to_gui(message) + def __init__(self, core_bus: MessageBusClient, adapters: Optional[List] = None): + self.core_bus = core_bus + self.adapters: List = adapters or [] + self.sessions: Dict[str, GUISession] = dict() + self._define_message_handlers() - def remove_pages(self, positions: List[int]): - """ - Deletes one or more pages by index from the active page list. + def get_session(self, session_id: str) -> GUISession: + """Retrieve a session by ID, creating it if necessary. - @param positions: list of int page positions to remove + Args: + session_id: Routing key for the session. + Returns: + The GUISession object. """ - positions.sort(reverse=True) - for position in positions: - page = self.pages.pop(position) - LOG.info(f"GUI PROTOCOL - Deleting {page.name} -- namespace: \"{self.skill_id}\"") - message = dict( - type="mycroft.gui.list.remove", - namespace=self.skill_id, - position=position, - items_number=1 - ) - send_message_to_gui(message) + if session_id not in self.sessions: + self.sessions[session_id] = GUISession(session_id) + return self.sessions[session_id] - def page_gained_focus(self, page_number: int): - """ - Updates the active page in `self.pages`. - @param page_number: the index of the page that will gain focus - """ - LOG.info(f"Page {page_number} gained focus -- namespace \"{self.skill_id}\"") - self.page_number = page_number - self._activate_page(self.active_page) + @staticmethod + def _session_id(message: Optional[Message]) -> str: + """Extract the routing ``session_id`` from a message. - def global_back(self): + The routing identifier is the ``session_id``; shared screens share a + ``session_id``. Defaults to ``"default"`` for on-device displays. """ - Returns to the previous page in the active page list. + ctx = message.context if message else {} + session = ctx.get("session", {}) + return session.get("session_id") or "default" + + # ====== State Query API for Adapters ====== + + def get_active_namespace(self, session_id: str = "default") -> Optional[Namespace]: + """Get the currently active (top-of-stack) namespace for a session. + + Allows adapters to query which namespace is currently visible and + recover state after a crash. + + Args: + session_id: Session identifier (default: "default" for single-screen) + + Returns: + Active Namespace object if one exists, else None """ - if self.page_number > 0: # go back 1 page - self.remove_pages([self.page_number]) - self.page_gained_focus(self.page_number - 1) + session = self.sessions.get(session_id) + if session and session.active_namespaces: + return session.active_namespaces[0] # Top of stack is index 0 + return None + def get_namespace_data(self, namespace_name: str, session_id: str = "default") -> Optional[dict]: + """Get current session data for a namespace. -class NamespaceManager: - """ - Manages the active namespace stack and the content of namespaces. + Args: + namespace_name: Skill ID or namespace name + session_id: Session identifier - Attributes: - core_bus: client for communicating with the core message bus - gui_bus: client for communicating with the GUI message bus - loaded_namespaces: cache of namespaces that have been introduced - active_namespaces: LIFO stack of namespaces being displayed - remove_namespace_timers: background process to remove a namespace with - a persistence expressed in seconds - idle_display_skill: skill ID of the skill that controls the idle screen - """ + Returns: + Copy of the session data dict if the namespace exists, else None + """ + session = self.sessions.get(session_id) + if session: + namespace = session.loaded_namespaces.get(namespace_name) + if namespace: + return namespace.data.copy() # copy prevents external mutation + return None - def __init__(self, core_bus: MessageBusClient): - self.core_bus = core_bus - self.gui_bus = create_gui_service(self) - self.loaded_namespaces: Dict[str, Namespace] = dict() - self.active_namespaces: List[Namespace] = list() - self.remove_namespace_timers: Dict[str, Timer] = dict() - self.idle_display_skill = _get_idle_display_config() - self.active_extension = _get_active_gui_extension() - self._system_res_dir = join(dirname(__file__), "res", "gui") - self._init_gui_file_share() - self._define_message_handlers() + def get_all_sessions(self) -> List[str]: + """Get list of all active session IDs. - def _init_gui_file_share(self): + Returns: + List of session_id strings """ - Initialize optional GUI file collection. if `gui_file_path` is - defined, resources are assumed to be referenced outside this container. + return list(self.sessions.keys()) + + def is_namespace_active(self, namespace_name: str, session_id: str = "default") -> bool: + """Check if a namespace is currently visible (top of active stack). + + Args: + namespace_name: Skill ID or namespace name + session_id: Session identifier + + Returns: + True if namespace is currently displayed, False otherwise """ - config = Configuration().get("gui", {}) - self._cache_system_resources() + active_ns = self.get_active_namespace(session_id) + if active_ns: + return active_ns.skill_id == namespace_name + return False def _define_message_handlers(self): """ Defines event handlers for core messagebus. """ self.core_bus.on("gui.clear.namespace", self.handle_clear_namespace) - self.core_bus.on("gui.event.send", self.handle_send_event) - self.core_bus.on("gui.page.delete", self.handle_delete_page) - self.core_bus.on("gui.page.delete.all", self.handle_delete_all_pages) self.core_bus.on("gui.page.show", self.handle_show_page) self.core_bus.on("gui.status.request", self.handle_status_request) self.core_bus.on("gui.value.set", self.handle_set_value) - self.core_bus.on("mycroft.gui.connected", self.handle_client_connected) self.core_bus.on("gui.page_interaction", self.handle_page_interaction) self.core_bus.on("gui.page_gained_focus", self.handle_page_gained_focus) self.core_bus.on("mycroft.gui.screen.close", self.handle_namespace_global_back) @@ -513,20 +343,35 @@ def _define_messages_to_forward(self): for msg in messages_to_forward: self.core_bus.on(msg, self.forward_to_gui) - @staticmethod - def forward_to_gui(message: Message): + def _safe_call(self, adapter, method_name, *args, **kwargs): + """Invoke an adapter hook safely. + + Missing methods are ignored. Any exception raised by the adapter is + logged so a broken adapter cannot crash the service or block the other + adapters. Signature errors are surfaced (logged) rather than silently + retried with fewer arguments. """ - Forward a core Message to the GUI + method = getattr(adapter, method_name, None) + if method: + try: + method(*args, **kwargs) + except Exception: + LOG.exception(f"Error in {adapter.__class__.__name__}.{method_name}") + + def forward_to_gui(self, message: Message): + """ + Forward a core Message status event to registered adapters. + + Status events are system-wide signals; adapters typically broadcast them + to all connected clients regardless of session. + @param message: Core message to forward """ - gui_message = dict( - type='mycroft.events.triggered', - namespace="system", - event_name=message.msg_type, - data=message.data - ) - LOG.info(f"GUI PROTOCOL - Sending event '{message.msg_type}' for namespace: system") - send_message_to_gui(gui_message) + LOG.info(f"GUI PROTOCOL - Forwarding status event '{message.msg_type}'") + session_id = self._session_id(message) + for adapter in self.adapters: + self._safe_call(adapter, "on_status_event", message.msg_type, + message.data, session_id) def handle_clear_namespace(self, message: Message): """ @@ -540,80 +385,11 @@ def handle_clear_namespace(self, message: Message): "Request to delete namespace failed: no namespace specified" ) else: - if self.loaded_namespaces.get(namespace_name): + session_id = self._session_id(message) + session = self.get_session(session_id) + if session.loaded_namespaces.get(namespace_name): with namespace_lock: - self._remove_namespace(namespace_name) - - @staticmethod - def handle_send_event(message: Message): - """ - Handles a request to send a message to the GUI message bus. - @param message: the message requesting a message to be sent to the GUI - message bus. - """ - try: - skill_id = message.data.get('__from') - event = message.data.get('event_name') - LOG.info(f"GUI PROTOCOL - Sending event '{event}' for namespace: {skill_id}") - message = dict( - type='mycroft.events.triggered', - namespace=skill_id, - event_name=event, - data=message.data.get('params') - ) - send_message_to_gui(message) - except Exception: - LOG.exception('Could not send event trigger') - - def handle_delete_all_pages(self, message: Message): - """ - Handles request to remove all current pages from a namespace. - @param message: the message requesting page removal - """ - namespace_name = message.data["__from"] - except_pages = message.data.get("except") or [] - - if except_pages: - LOG.info(f"Got {namespace_name} request to delete all pages except: {except_pages}") - else: - LOG.info(f"Got {namespace_name} request to delete all pages") - - with namespace_lock: - namespace = self.loaded_namespaces.get(namespace_name) - if namespace: - to_rm = [p.name for p in namespace.pages if p.name not in except_pages] - self._remove_pages(namespace_name, to_rm) - - def handle_delete_page(self, message: Message): - """ - Handles request to remove one or more pages from a namespace. - @param message: the message requesting page removal - """ - message_is_valid = _validate_page_message(message) - if message_is_valid: - namespace_name = message.data["__from"] - pages_to_remove = message.data.get("page_names") - LOG.debug(f"Got {namespace_name} request to delete: {pages_to_remove}") - with namespace_lock: - self._remove_pages(namespace_name, pages_to_remove) - - def _remove_pages(self, namespace_name: str, pages_to_remove: List[str]): - """ - Removes one or more pages from a namespace. Pages are removed from the - bottom of the stack. - @param namespace_name: the affected namespace - @param pages_to_remove: names of pages to delete - """ - namespace = self.loaded_namespaces.get(namespace_name) - if namespace is not None and namespace in self.active_namespaces: - page_positions = [] - for index, page in enumerate(namespace.pages): - if page.name in pages_to_remove: - page_positions.append(index) - - if page_positions: - page_positions.sort(reverse=True) - namespace.remove_pages(page_positions) + self._remove_namespace(namespace_name, session, session_id) @staticmethod def _parse_persistence(persistence: Optional[Union[int, bool]]) -> \ @@ -635,6 +411,25 @@ def _parse_persistence(persistence: Optional[Union[int, bool]]) -> \ # Defines default behavior as displaying for 30 seconds return False, 30 + def _dispatch_template_to_adapters(self, template: str, skill_id: str, + data: dict, session_id: str): + """Call matching handler on every loaded adapter for a SYSTEM_* template. + + Args: + template: PageTemplates value, e.g. ``"SYSTEM_weather"``. + skill_id: Namespace / skill that requested the display. + data: Current session data for the namespace. + session_id: Routing identifier (shared screens share a session_id). + """ + for adapter in self.adapters: + try: + adapter.dispatch_template(template, skill_id, data, session_id) + except Exception: + LOG.exception( + f"Error dispatching template '{template}' to adapter " + f"{adapter.__class__.__name__}" + ) + def handle_show_page(self, message: Message): """ Handles a request to show one or more pages on the screen. @@ -652,192 +447,190 @@ def handle_show_page(self, message: Message): LOG.debug(f"Got {namespace_name} request to show: {page_ids_to_show} at index: {show_index}") - pages = list() - persist, duration = self._parse_persistence(message.data["__idle"]) - for page in page_ids_to_show: - pages.append(GuiPage(name=page, persistent=persist, duration=duration, - namespace=namespace_name)) + session_id = self._session_id(message) + session = self.get_session(session_id) + + # All page shows must use SYSTEM_* templates (no legacy QML path) + if not page_ids_to_show: + LOG.error(f"Namespace '{namespace_name}' requested show with no page_names") + return - if not pages: - LOG.error(f"Activated namespace '{namespace_name}' has no pages!") - LOG.error(f"Can't show page, bad message: {message.data}") + if not page_ids_to_show[0].startswith("SYSTEM_"): + LOG.error( + f"Namespace '{namespace_name}' sent non-template page name: {page_ids_to_show[0]}. " + f"All GUI display must use SYSTEM_* templates. Custom QML is not supported." + ) return + # Template-based routing: dispatch all templates to adapters + namespace = self._ensure_namespace_exists(namespace_name, session) + data = {k: v for k, v in namespace.data.items()} + for template in page_ids_to_show: + self._dispatch_template_to_adapters(template, namespace_name, data, session_id) + + # Activate namespace (updates internal stack state) with namespace_lock: - if not self.active_namespaces: - self._activate_namespace(namespace_name) - else: - active_namespace = self.active_namespaces[0] - if active_namespace.skill_id != namespace_name: - self._activate_namespace(namespace_name) - self._load_pages(pages, show_index) - self._update_namespace_persistence(persistence) + if not session.active_namespaces or session.active_namespaces[0].skill_id != namespace_name: + self._activate_namespace(namespace_name, session, session_id) + self._update_namespace_persistence(persistence, session) - def _activate_namespace(self, namespace_name: str): + def _activate_namespace(self, namespace_name: str, session: GUISession, + session_id: str): """ Instructs the GUI to load a namespace and its associated data. @param namespace_name: the name of the namespace to load + @param session: the session affected (state object) + @param session_id: routing identifier """ - namespace = self._ensure_namespace_exists(namespace_name) + namespace = self._ensure_namespace_exists(namespace_name, session) - if namespace in self.active_namespaces: - namespace_position = self.active_namespaces.index(namespace) + if namespace in session.active_namespaces: + namespace_position = session.active_namespaces.index(namespace) namespace.activate(namespace_position) if namespace_position != 0: - LOG.info(f"Activating namespace: {namespace_name}") - self.active_namespaces.insert( - 0, self.active_namespaces.pop(namespace_position) + LOG.info(f"Activating namespace: {namespace_name} for session {session.session_id}") + session.active_namespaces.insert( + 0, session.active_namespaces.pop(namespace_position) ) else: - LOG.info(f"New namespace: {namespace_name}") + LOG.info(f"New namespace: {namespace_name} for session {session.session_id}") namespace.add() - self.active_namespaces.insert(0, namespace) + session.active_namespaces.insert(0, namespace) # sync initial state for key, value in namespace.data.items(): namespace.load_data(key, value) - self._emit_namespace_displayed_event() + self._emit_namespace_displayed_event(session) + # Notify adapters of namespace activation + for adapter in self.adapters: + self._safe_call(adapter, "on_namespace_activated", namespace_name, session_id) - def _ensure_namespace_exists(self, namespace_name: str) -> Namespace: + def _ensure_namespace_exists(self, namespace_name: str, session: GUISession) -> Namespace: """ Retrieves the requested namespace, creating one if it doesn't exist. @param namespace_name: the name of the namespace being retrieved + @param session: the session affected @returns: requested namespace """ - # TODO: - Update sync to match. - namespace = self.loaded_namespaces.get(namespace_name) + namespace = session.loaded_namespaces.get(namespace_name) if namespace is None: namespace = Namespace(namespace_name) - self.loaded_namespaces[namespace_name] = namespace + session.loaded_namespaces[namespace_name] = namespace return namespace - def _load_pages(self, pages_to_show: List[GuiPage], show_index: int): - """ - Loads the requested pages in the namespace. - @param pages_to_show: list of pages to be loaded - @param show_index: index to load pages at - """ - if not self.active_namespaces: - LOG.error("received 'load_pages' request but there are no active namespaces") - return - - if not len(pages_to_show) or show_index >= len(pages_to_show): - LOG.error(f"requested invalid page index: {show_index}, defaulting to last page") - show_index = len(pages_to_show) - 1 - - active_namespace = self.active_namespaces[0] - oldp = [p.name for p in active_namespace.pages] - active_namespace.load_pages(pages_to_show, show_index) - # LOG only on change - if oldp != [p.name for p in active_namespace.pages]: - pn = active_namespace.page_number - LOG.info(f"Loaded {active_namespace.skill_id} at index: {pn} " - f"pages: {[p.name for p in active_namespace.pages]}") - - def _update_namespace_persistence(self, persistence: Union[bool, int]): + def _update_namespace_persistence(self, persistence: Union[bool, int], session: GUISession): """ Sets the persistence of the namespace being activated. - A namespace's persistence is the same as the persistence of the - most recent pages added to a namespace. For example, a multi-page - namespace could show the first set of pages with a persistence of - True (show until removed) and the last page with a persistence of - 15 seconds. This would ensure that the namespace isn't removed while - the skill is showing the pages. @param persistence: length of time the namespace should be displayed + @param session: the session affected """ - for idx, namespace in enumerate(self.active_namespaces): + for idx, namespace in enumerate(session.active_namespaces): if idx: if not namespace.persistent: - self._remove_namespace(namespace.skill_id) + self._remove_namespace(namespace.skill_id, session, session.session_id) else: if namespace.persistent != persistence: LOG.info(f"Setting namespace '{namespace.skill_id}' persistence to: {persistence}") namespace.persistent = persistence - if namespace.skill_id == self.idle_display_skill: - namespace.set_persistence(skill_type="idleDisplaySkill") - else: - namespace.set_persistence(skill_type="genericSkill") - # check if there is a scheduled remove_namespace_timer - # and cancel it - if namespace.persistent and namespace.skill_id in \ - self.remove_namespace_timers: - self.remove_namespace_timers[namespace.skill_id].cancel() - self._del_namespace_in_remove_timers(namespace.skill_id) + namespace.set_persistence(skill_type="genericSkill") + if isinstance(persistence, int) and not isinstance(persistence, bool): + namespace.duration = persistence + + # check if there is a scheduled remove_namespace_timer + # and cancel it + if namespace.persistent and namespace.skill_id in \ + session.remove_namespace_timers: + session.remove_namespace_timers[namespace.skill_id].cancel() + self._del_namespace_in_remove_timers(namespace.skill_id, session) if not namespace.persistent: - self._schedule_namespace_removal(namespace) + self._schedule_namespace_removal(namespace, session) - self.active_namespaces[idx] = namespace + session.active_namespaces[idx] = namespace - def _schedule_namespace_removal(self, namespace: Namespace): + def _schedule_namespace_removal(self, namespace: Namespace, session: GUISession): """ Uses a timer thread to remove the namespace. @param namespace: the namespace to be removed + @param session: the session affected """ # Before removing check if there isn't already a timer for this namespace - if namespace.skill_id in self.remove_namespace_timers: + if namespace.skill_id in session.remove_namespace_timers: return remove_namespace_timer = Timer( namespace.duration, self._remove_namespace_via_timer, - args=(namespace.skill_id,) + args=(namespace.skill_id, session.session_id) ) - LOG.info(f"Removal of namespace {namespace.skill_id} in " + LOG.info(f"Removal of namespace {namespace.skill_id} in session {session.session_id} in " f"{namespace.duration} seconds") remove_namespace_timer.start() - self.remove_namespace_timers[namespace.skill_id] = remove_namespace_timer + session.remove_namespace_timers[namespace.skill_id] = remove_namespace_timer - def _remove_namespace_via_timer(self, namespace_name: str): + def _remove_namespace_via_timer(self, namespace_name: str, session_id: str): """ Removes a namespace and the corresponding timer instance. @param namespace_name: name of namespace to remove + @param session_id: ID of the session """ - self._remove_namespace(namespace_name) - self._del_namespace_in_remove_timers(namespace_name) + session = self.get_session(session_id) + self._remove_namespace(namespace_name, session, session_id) + self._del_namespace_in_remove_timers(namespace_name, session) - def _remove_namespace(self, namespace_name: str): + def _remove_namespace(self, namespace_name: str, session: GUISession, + session_id: str): """ Removes a namespace from the active namespace stack. @param namespace_name: name of namespace to remove + @param session: the session affected (state object) + @param session_id: routing identifier """ # Remove all timers associated with the namespace - if namespace_name in self.remove_namespace_timers: - self.remove_namespace_timers[namespace_name].cancel() - self._del_namespace_in_remove_timers(namespace_name) + if namespace_name in session.remove_namespace_timers: + session.remove_namespace_timers[namespace_name].cancel() + self._del_namespace_in_remove_timers(namespace_name, session) - namespace: Namespace = self.loaded_namespaces.get(namespace_name) - if namespace is not None and namespace in self.active_namespaces: - LOG.info(f"Removing namespace {namespace_name}") + namespace: Namespace = session.loaded_namespaces.get(namespace_name) + if namespace is not None and namespace in session.active_namespaces: + LOG.info(f"Removing namespace {namespace_name} from session {session.session_id}") self.core_bus.emit(Message("gui.namespace.removed", - data={"skill_id": namespace.skill_id})) - namespace_position = self.active_namespaces.index(namespace) + data={"skill_id": namespace.skill_id}, + context={"session": {"session_id": session_id}})) + namespace_position = session.active_namespaces.index(namespace) namespace.remove(namespace_position) - self.active_namespaces.remove(namespace) + session.active_namespaces.remove(namespace) + # Notify adapters of namespace deactivation + for adapter in self.adapters: + self._safe_call(adapter, "on_namespace_deactivated", namespace_name, session_id) - self._emit_namespace_displayed_event() + self._emit_namespace_displayed_event(session) - def _emit_namespace_displayed_event(self): + def _emit_namespace_displayed_event(self, session: GUISession): """ Emit a `gui.namespace.displayed` Message to notify core of changes. """ - if self.active_namespaces: - displaying_namespace = self.active_namespaces[0] + if session.active_namespaces: + displaying_namespace = session.active_namespaces[0] message_data = dict(skill_id=displaying_namespace.skill_id) - # TODO - no known listeners ? self.core_bus.emit( - Message("gui.namespace.displayed", data=message_data) + Message("gui.namespace.displayed", data=message_data, + context={"session": {"session_id": session.session_id}}) ) def handle_status_request(self, message: Message): """ Handles a GUI status request by replying with the connection status. + Checks all loaded adapters; returns True if any adapter has a connected client. @param message: the request for status of the GUI """ - gui_connected = determine_if_gui_connected() + gui_connected = any( + getattr(adapter, 'any_client_connected', lambda: False)() + for adapter in self.adapters + ) if self.adapters else False reply = message.reply( "gui.status.request.response", dict(connected=gui_connected) ) @@ -856,122 +649,89 @@ def handle_set_value(self, message: Message): "namespace specified" ) else: + session_id = self._session_id(message) + session = self.get_session(session_id) with namespace_lock: - self._update_namespace_data(namespace_name, message.data) + self._update_namespace_data(namespace_name, message.data, session) + # Notify adapters of the session data update + filtered = {k: v for k, v in message.data.items() if k not in RESERVED_KEYS} + for adapter in self.adapters: + self._safe_call(adapter, "on_session_update", namespace_name, filtered, session_id) - def _update_namespace_data(self, namespace_name: str, data: dict): + def _update_namespace_data(self, namespace_name: str, data: dict, session: GUISession): """ Updates the values of namespace data attributes, unless unchanged. @param namespace_name: the name of the namespace to update @param data: the name and new value of one or more data attributes + @param session: the session affected """ - namespace = self._ensure_namespace_exists(namespace_name) + namespace = self._ensure_namespace_exists(namespace_name, session) for key, value in data.items(): if key not in RESERVED_KEYS and namespace.data.get(key) != value: namespace.data[key] = value - if namespace in self.active_namespaces: + if namespace in session.active_namespaces: namespace.load_data(key, value) - def handle_client_connected(self, message: Message): - """ - Handles an event from the GUI indicating it is connected to the bus. - @param message: the event sent by the GUI - """ - # old style GUI has announced presence in core bus - # send websocket port, the GUI should connect on it soon - gui_id = message.data.get("gui_id") - - framework = message.data.get("framework") # new api - if framework is None: - qt = message.data.get("qt_version", 5) # mycroft-gui api - if int(qt) == 6: - framework = "qt6" - else: - framework = "qt5" - - LOG.info(f"GUI with ID {gui_id} connected to core message bus") - websocket_config = get_gui_websocket_config() - port = websocket_config["base_port"] - message = message.forward("mycroft.gui.port", - dict(port=port, gui_id=gui_id, framework=framework)) - self.core_bus.emit(message) - def handle_page_interaction(self, message: Message): """ - Handles an event from the GUI indicating a page has been interacted with. + Handles user interaction with the active namespace. + Reschedules namespace timeout on user interaction. @param message: the event sent by the GUI """ - # GUI has interacted with a page - # Update and increase the namespace duration and reset the remove timer namespace_name = message.data.get("skill_id") - pidx = message.data.get('page_number') - LOG.info(f"GUI interacted with page in namespace {namespace_name}") - namespace = self.loaded_namespaces.get(namespace_name) - - if namespace and pidx is not None and pidx != namespace.page_number: - # update focused page - namespace.page_gained_focus(pidx) - - # reschedule namespace timeout - if namespace_name != self.idle_display_skill and \ - not namespace.persistent and \ - self.remove_namespace_timers[namespace.skill_id]: - self.remove_namespace_timers[namespace.skill_id].cancel() - self._del_namespace_in_remove_timers(namespace.skill_id) - self._schedule_namespace_removal(namespace) + LOG.info(f"GUI interacted with namespace {namespace_name}") + + session_id = self._session_id(message) + session = self.get_session(session_id) + namespace = session.loaded_namespaces.get(namespace_name) + + # reschedule namespace timeout on user interaction + if namespace and not namespace.persistent and \ + session.remove_namespace_timers.get(namespace.skill_id): + session.remove_namespace_timers[namespace.skill_id].cancel() + self._del_namespace_in_remove_timers(namespace.skill_id, session) + self._schedule_namespace_removal(namespace, session) def handle_page_gained_focus(self, message: Message): """ - Handles focus events from the GUI indicating the page has gained focus. + Handles focus events from the GUI (template rendering updates). @param message: the event sent by the GUI """ namespace_name = message.data.get("skill_id") - namespace_page_number = message.data.get("page_number") - LOG.debug(f"Page in namespace {namespace_name} gained focus") - namespace = self.loaded_namespaces.get(namespace_name) + LOG.debug(f"Namespace {namespace_name} received focus event") - # first check if the namespace is already active - if namespace in self.active_namespaces: - # if the namespace is already active, - # check if the page number has changed - if namespace_page_number != namespace.page_number: - namespace.page_gained_focus(namespace_page_number) + session_id = self._session_id(message) + session = self.get_session(session_id) + + # Template-only: no page tracking, just verify namespace exists + namespace = session.loaded_namespaces.get(namespace_name) + if namespace and namespace in session.active_namespaces: + LOG.debug(f"Namespace {namespace_name} is active") def handle_namespace_global_back(self, message: Optional[Message]): """ Handles global back events from the GUI. + Removes the current namespace and shows homescreen if none remain. @param message: the event sent by the GUI """ - if not self.active_namespaces: + session_id = self._session_id(message) + session = self.get_session(session_id) + + if not session.active_namespaces: LOG.debug("received 'back' signal but there are no active namespaces, attempting to show homescreen") - self.core_bus.emit(Message("homescreen.manager.show_active")) + self.core_bus.emit(Message("mycroft.device.show.idle", + context={"session": {"session_id": session_id}})) return - namespace_name = self.active_namespaces[0].skill_id - namespace = self.loaded_namespaces.get(namespace_name) - if namespace in self.active_namespaces: - # prev page - if namespace.page_number > 0: - namespace.global_back() - # homescreen - else: - self.core_bus.emit(Message("homescreen.manager.show_active")) + # Remove the current (top) namespace + namespace_name = session.active_namespaces[0].skill_id + self._remove_namespace(namespace_name, session, session_id) - def _del_namespace_in_remove_timers(self, namespace_name: str): + def _del_namespace_in_remove_timers(self, namespace_name: str, session: GUISession): """ Delete namespace from remove_namespace_timers dict. @param namespace_name: name of namespace to be deleted + @param session: the session affected """ - if namespace_name in self.remove_namespace_timers: - del self.remove_namespace_timers[namespace_name] - - def _cache_system_resources(self): - """ - Copy system GUI resources to the served file path - """ - output_path = f"{GUI_CACHE_PATH}/system" - if exists(output_path): - LOG.info(f"Removing existing system resources before updating") - shutil.rmtree(output_path) - shutil.copytree(self._system_res_dir, output_path) - LOG.debug(f"Copied system resources from {self._system_res_dir} to {output_path}") + if namespace_name in session.remove_namespace_timers: + del session.remove_namespace_timers[namespace_name] diff --git a/ovos_gui/page.py b/ovos_gui/page.py deleted file mode 100644 index 3420450..0000000 --- a/ovos_gui/page.py +++ /dev/null @@ -1,53 +0,0 @@ -from os.path import join, isfile, dirname -from typing import Union, Optional -from dataclasses import dataclass -from ovos_utils.log import LOG -from ovos_gui.constants import GUI_CACHE_PATH - - -@dataclass -class GuiPage: - """ - A GuiPage represents a single GUI Display within a given namespace. - A Page can either be `persistent` or be removed after some `duration`. - Note that a page is generally framework-independent - @param name: Name of the page as shown in its namespace (could - @param persistent: If True, page is displayed indefinitely - @param duration: Number of seconds to display the page for - @param namespace: Skill/component identifier - """ - name: str - persistent: bool - duration: Union[int, bool] - namespace: Optional[str] = None - - @staticmethod - def get_file_extension(framework: str) -> str: - """ - Get a file extension for the specified GUI framework - @param framework: string framework to get file extension for - @return: string file extension (empty string if unknown) - """ - if framework in ("qt5", "qt6"): - return "qml" - return "" - - @property - def res_namespace(self): - return "system" if self.name.startswith("SYSTEM") else self.namespace - - def get_uri(self, framework: str = "qt5") -> Optional[str]: - """ - Get a valid URI for this Page. - @param framework: String GUI framework to get resources for (currently only 'qt5') - @return: Absolute path to the requested resource - """ - res_filename = f"{self.name}.{self.get_file_extension(framework)}" - path = f"{GUI_CACHE_PATH}/{self.res_namespace}/{framework}/{res_filename}" - LOG.debug(f"Resolved page URI: {path}") - if isfile(path): - return path - LOG.warning(f"Unable to resolve resource file for " - f"resource {res_filename} for framework " - f"{framework}") - return None diff --git a/ovos_gui/res/gui/qt5/Face.qml b/ovos_gui/res/gui/qt5/Face.qml deleted file mode 100644 index b82fd69..0000000 --- a/ovos_gui/res/gui/qt5/Face.qml +++ /dev/null @@ -1,133 +0,0 @@ -import QtQuick.Layouts 1.4 -import QtQuick 2.4 -import QtQuick.Controls 2.0 -import org.kde.kirigami 2.4 as Kirigami - -import Mycroft 1.0 as Mycroft - -Item { - id: root - - property bool eyesOpen - property string mouth - property alias mouthItem: mouthItem - - Item { - id: fixedProportionsContainer - - anchors.centerIn: parent - readonly property real proportion: 1.6 - - width: parent.height / parent.width >= proportion ? parent.width : height / 1.6 - height: parent.height / parent.width >= proportion ? width * 1.6 : parent.height - - Item { - anchors { - left: parent.left - top: parent.top - topMargin: parent.height * 0.28 - leftMargin: parent.width * 0.02 - } - - width: parent.width * 0.35 - height: width - Image { - anchors.fill: parent - visible: root.eyesOpen - source: Qt.resolvedUrl("face/Eyeball.svg") - fillMode: Image.PreserveAspectFit - } - Image { - anchors { - left: parent.left - right: parent.right - bottom: parent.bottom - leftMargin: width * 0.001 - rightMargin: width * 0.001 - } - height: width / (sourceSize.width/sourceSize.height) - visible: !root.eyesOpen - source: Qt.resolvedUrl("face/lid.svg") - fillMode: Image.PreserveAspectFit - } - Image { - anchors { - left: parent.left - right: parent.right - top: parent.top - leftMargin: width * 0.001 - rightMargin: width * 0.001 - } - height: width / (sourceSize.width/sourceSize.height) - visible: root.eyesOpen - source: Qt.resolvedUrl("face/upper-lid.svg") - fillMode: Image.PreserveAspectFit - } - } - - Item { - anchors { - right: parent.right - top: parent.top - topMargin: parent.height * 0.28 - rightMargin: parent.width * 0.02 - } - - width: parent.width * 0.35 - height: width - Image { - anchors.fill: parent - visible: root.eyesOpen - source: Qt.resolvedUrl("face/Eyeball.svg") - fillMode: Image.PreserveAspectFit - } - Image { - anchors { - left: parent.left - right: parent.right - bottom: parent.bottom - leftMargin: width * 0.001 - rightMargin: width * 0.001 - } - height: width / (sourceSize.width/sourceSize.height) - visible: !root.eyesOpen - source: Qt.resolvedUrl("face/lid.svg") - fillMode: Image.PreserveAspectFit - } - Image { - anchors { - left: parent.left - right: parent.right - top: parent.top - leftMargin: width * 0.001 - rightMargin: width * 0.001 - } - height: width / (sourceSize.width/sourceSize.height) - visible: root.eyesOpen - source: Qt.resolvedUrl("face/upper-lid.svg") - fillMode: Image.PreserveAspectFit - } - } - - Item { - id: mouthItem - anchors { - horizontalCenter: parent.horizontalCenter - bottom: parent.bottom - bottomMargin: parent.height * 0.26 - } - width: parent.width / 2 - height: smile.implicitHeight - Image { - id: smile - anchors { - left: parent.left - right: parent.right - verticalCenter: parent.verticalCenter - } - fillMode: Image.PreserveAspectFit - source: Qt.resolvedUrl("face/" + root.mouth) - } - } - } -} diff --git a/ovos_gui/res/gui/qt5/FeatureRequest.qml b/ovos_gui/res/gui/qt5/FeatureRequest.qml deleted file mode 100644 index 1e83d97..0000000 --- a/ovos_gui/res/gui/qt5/FeatureRequest.qml +++ /dev/null @@ -1,123 +0,0 @@ -import QtQuick 2.12 -import QtQuick.Controls 2.12 -import QtWebEngine 1.7 -import QtWebChannel 1.0 -import QtQuick.Layouts 1.12 -import org.kde.kirigami 2.11 as Kirigami - -Item { - property var requestedFeature; - property url securityOrigin; - - width: parent.width - height: parent.height - - onRequestedFeatureChanged: { - message.text = securityOrigin + " has requested access to your " - + message.textForFeature(requestedFeature); - } - - RowLayout { - anchors.fill: parent - - Label { - id: message - Layout.fillWidth: true - Layout.leftMargin: Kirigami.Units.largeSpacing - wrapMode: Text.WordWrap - maximumLineCount: 2 - elide: Text.ElideRight - - function textForFeature(feature) { - if (feature === WebEngineView.MediaAudioCapture) - return "microphone" - if (feature === WebEngineView.MediaVideoCapture) - return "camera" - if (feature === WebEngineView.MediaAudioVideoCapture) - return "camera and microphone" - if (feature === WebEngineView.Geolocation) - return "location" - } - } - - Button { - id: acceptButton - Layout.alignment: Qt.AlignRight - Layout.preferredWidth: parent.width * 0.18 - - background: Rectangle { - color: acceptButton.activeFocus ? Kirigami.Theme.highlightColor : Qt.lighter(Kirigami.Theme.backgroundColor, 1.2) - border.color: Kirigami.Theme.disabledTextColor - radius: 20 - } - - contentItem: Item { - Kirigami.Heading { - level: 3 - font.pixelSize: parent.width * 0.075 - anchors.centerIn: parent - text: "Accept" - } - } - - onClicked: { - webview.grantFeaturePermission(securityOrigin, - requestedFeature, true); - interactionBar.isRequested = false; - } - } - - Button { - id: denyButton - Layout.alignment: Qt.AlignRight - Layout.preferredWidth: parent.width * 0.18 - - background: Rectangle { - color: denyButton.activeFocus ? Kirigami.Theme.highlightColor : Qt.lighter(Kirigami.Theme.backgroundColor, 1.2) - border.color: Kirigami.Theme.disabledTextColor - radius: 20 - } - - contentItem: Item { - Kirigami.Heading { - level: 3 - font.pixelSize: parent.width * 0.075 - anchors.centerIn: parent - text: "Deny" - } - } - - onClicked: { - webview.grantFeaturePermission(securityOrigin, - requestedFeature, false); - interactionBar.isRequested = false - } - } - - Button { - id: closeButton - Layout.alignment: Qt.AlignRight - Layout.preferredWidth: Kirigami.Units.iconSizes.large - (Kirigami.Units.largeSpacing + Kirigami.Units.smallSpacing) - Layout.preferredHeight: Kirigami.Units.iconSizes.large - (Kirigami.Units.largeSpacing + Kirigami.Units.smallSpacing) - Layout.leftMargin: Kirigami.Units.largeSpacing - Layout.rightMargin: Kirigami.Units.largeSpacing - - background: Rectangle { - color: denyButton.activeFocus ? Kirigami.Theme.highlightColor : Qt.lighter(Kirigami.Theme.backgroundColor, 1.2) - border.color: Kirigami.Theme.disabledTextColor - radius: 200 - } - - Kirigami.Icon { - anchors.centerIn: parent - width: Kirigami.Units.iconSizes.medium - height: Kirigami.Units.iconSizes.medium - source: "window-close" - } - - onClicked: { - interactionBar.isRequested = false - } - } - } -} diff --git a/ovos_gui/res/gui/qt5/RequestHandler.qml b/ovos_gui/res/gui/qt5/RequestHandler.qml deleted file mode 100644 index 9951510..0000000 --- a/ovos_gui/res/gui/qt5/RequestHandler.qml +++ /dev/null @@ -1,35 +0,0 @@ -import QtQuick 2.12 -import QtQuick.Controls 2.12 -import QtWebEngine 1.7 -import QtWebChannel 1.0 -import QtQuick.Layouts 1.12 -import org.kde.kirigami 2.11 as Kirigami - -Rectangle { - property bool isRequested: false - property alias source: interactionLoader.source - property alias interactionItem: interactionLoader.item - - visible: isRequested - enabled: isRequested - width: parent.width - height: isRequested ? Kirigami.Units.gridUnit * 6 : 0 - color: Kirigami.Theme.backgroundColor - - function setSource(interactionSource){ - interactionLoader.setSource(interactionSource) - } - - Keys.onEscapePressed: { - isRequested = false; - } - - Keys.onBackPressed: { - isRequested = false; - } - - Loader { - id: interactionLoader - anchors.fill: parent - } -} diff --git a/ovos_gui/res/gui/qt5/SYSTEM_AnimatedImageFrame.qml b/ovos_gui/res/gui/qt5/SYSTEM_AnimatedImageFrame.qml deleted file mode 100644 index 213e34a..0000000 --- a/ovos_gui/res/gui/qt5/SYSTEM_AnimatedImageFrame.qml +++ /dev/null @@ -1,84 +0,0 @@ -import QtQuick.Layouts 1.4 -import QtQuick 2.4 -import QtQuick.Controls 2.0 -import org.kde.kirigami 2.4 as Kirigami - -import Mycroft 1.0 as Mycroft - -Mycroft.Delegate { - id: systemImageFrame - skillBackgroundColorOverlay: sessionData.background_color ? sessionData.background_color : "#000000" - property bool hasTitle: sessionData.title.length > 0 ? true : false - property bool hasCaption: sessionData.caption.length > 0 ? true : false - fillWidth: true - - ColumnLayout { - id: systemImageFrameLayout - anchors.fill: parent - - Kirigami.Heading { - id: systemImageTitle - visible: hasTitle - enabled: hasTitle - Layout.fillWidth: true - Layout.preferredHeight: paintedHeight + Kirigami.Units.largeSpacing - level: 3 - text: sessionData.title - wrapMode: Text.Wrap - font.family: "Noto Sans" - font.weight: Font.Bold - } - - AnimatedImage { - id: systemImageDisplay - visible: true - enabled: true - Layout.fillWidth: true - Layout.fillHeight: true - source: sessionData.image - property var fill: sessionData.fill - - onFillChanged: { - console.log(fill) - if(fill == "PreserveAspectCrop"){ - systemImageDisplay.fillMode = 2 - } else if (fill == "PreserveAspectFit"){ - console.log("inFit") - systemImageDisplay.fillMode = 1 - } else if (fill == "Stretch"){ - systemImageDisplay.fillMode = 0 - } else { - systemImageDisplay.fillMode = 0 - } - } - - - Rectangle { - id: systemImageCaptionBox - visible: hasCaption - enabled: hasCaption - anchors.bottom: parent.bottom - anchors.left: parent.left - anchors.right: parent.right - height: systemImageCaption.paintedHeight - color: "#95000000" - - Kirigami.Heading { - id: systemImageCaption - level: 2 - anchors.left: parent.left - anchors.leftMargin: Kirigami.Units.largeSpacing - anchors.right: parent.right - anchors.rightMargin: Kirigami.Units.largeSpacing - anchors.verticalCenter: parent.verticalCenter - text: sessionData.caption - wrapMode: Text.Wrap - font.family: "Noto Sans" - font.weight: Font.Bold - } - } - } - } -} - - diff --git a/ovos_gui/res/gui/qt5/SYSTEM_Face.qml b/ovos_gui/res/gui/qt5/SYSTEM_Face.qml deleted file mode 100644 index 748cd82..0000000 --- a/ovos_gui/res/gui/qt5/SYSTEM_Face.qml +++ /dev/null @@ -1,19 +0,0 @@ -import QtQuick.Layouts 1.4 -import QtQuick 2.4 -import QtQuick.Controls 2.0 -import org.kde.kirigami 2.4 as Kirigami - -import Mycroft 1.0 as Mycroft - -Mycroft.CardDelegate { - id: root - - contentItem: Face { - // Set eyesOpen based on sessionData.sleeping - eyesOpen: !sessionData.sleeping - - // Set mouth based on sessionData.sleeping - mouth: sessionData.sleeping ? "GreySmile.svg" : "Smile.svg" - } - -} diff --git a/ovos_gui/res/gui/qt5/SYSTEM_HtmlFrame.qml b/ovos_gui/res/gui/qt5/SYSTEM_HtmlFrame.qml deleted file mode 100644 index 8cf023a..0000000 --- a/ovos_gui/res/gui/qt5/SYSTEM_HtmlFrame.qml +++ /dev/null @@ -1,21 +0,0 @@ -import QtQuick.Layouts 1.4 -import QtQuick 2.4 -import QtQuick.Controls 2.0 -import org.kde.kirigami 2.4 as Kirigami - -import Mycroft 1.0 as Mycroft - -Mycroft.Delegate { - id: systemHtmlFrame - skillBackgroundColorOverlay: "#000000" - fillWidth: true - - Loader { - id: webViewHtmlLoader - source: "WebViewHtmlFrame.qml" - anchors.fill: parent - property var pageHtml: sessionData.html - property var resourceLocation: sessionData.resourceLocation - } -} - diff --git a/ovos_gui/res/gui/qt5/SYSTEM_ImageFrame.qml b/ovos_gui/res/gui/qt5/SYSTEM_ImageFrame.qml deleted file mode 100644 index a9a374b..0000000 --- a/ovos_gui/res/gui/qt5/SYSTEM_ImageFrame.qml +++ /dev/null @@ -1,84 +0,0 @@ -import QtQuick.Layouts 1.4 -import QtQuick 2.4 -import QtQuick.Controls 2.0 -import org.kde.kirigami 2.4 as Kirigami - -import Mycroft 1.0 as Mycroft - -Mycroft.Delegate { - id: systemImageFrame - skillBackgroundColorOverlay: sessionData.background_color ? sessionData.background_color : "#000000" - property bool hasTitle: sessionData.title.length > 0 ? true : false - property bool hasCaption: sessionData.caption.length > 0 ? true : false - fillWidth: true - - ColumnLayout { - id: systemImageFrameLayout - anchors.fill: parent - - Kirigami.Heading { - id: systemImageTitle - visible: hasTitle - enabled: hasTitle - Layout.fillWidth: true - Layout.preferredHeight: paintedHeight + Kirigami.Units.largeSpacing - level: 3 - text: sessionData.title - wrapMode: Text.Wrap - font.family: "Noto Sans" - font.weight: Font.Bold - } - - Image { - id: systemImageDisplay - visible: true - enabled: true - Layout.fillWidth: true - Layout.fillHeight: true - source: sessionData.image - property var fill: sessionData.fill - - onFillChanged: { - console.log(fill) - if(fill == "PreserveAspectCrop"){ - systemImageDisplay.fillMode = 2 - } else if (fill == "PreserveAspectFit"){ - console.log("inFit") - systemImageDisplay.fillMode = 1 - } else if (fill == "Stretch"){ - systemImageDisplay.fillMode = 0 - } else { - systemImageDisplay.fillMode = 0 - } - } - - - Rectangle { - id: systemImageCaptionBox - visible: hasCaption - enabled: hasCaption - anchors.bottom: parent.bottom - anchors.left: parent.left - anchors.right: parent.right - height: systemImageCaption.paintedHeight - color: "#95000000" - - Kirigami.Heading { - id: systemImageCaption - level: 2 - anchors.left: parent.left - anchors.leftMargin: Kirigami.Units.largeSpacing - anchors.right: parent.right - anchors.rightMargin: Kirigami.Units.largeSpacing - anchors.verticalCenter: parent.verticalCenter - text: sessionData.caption - wrapMode: Text.Wrap - font.family: "Noto Sans" - font.weight: Font.Bold - } - } - } - } -} - - diff --git a/ovos_gui/res/gui/qt5/SYSTEM_Loading.qml b/ovos_gui/res/gui/qt5/SYSTEM_Loading.qml deleted file mode 100644 index 03a5446..0000000 --- a/ovos_gui/res/gui/qt5/SYSTEM_Loading.qml +++ /dev/null @@ -1,56 +0,0 @@ -import QtQuick.Layouts 1.4 -import QtQuick 2.12 -import QtQuick.Controls 2.12 -import org.kde.kirigami 2.10 as Kirigami -import Mycroft 1.0 as Mycroft -import org.kde.lottie 1.0 - - -Mycroft.Delegate { - id: root - leftPadding: 0 - rightPadding: 0 - topPadding: 0 - bottomPadding: 0 - background: Rectangle { - color: Kirigami.Theme.backgroundColor - z: -1 - } - - Rectangle { - anchors.fill: parent - anchors.margins: Mycroft.Units.gridUnit * 2 - color: Kirigami.Theme.backgroundColor - - ColumnLayout { - id: grid - anchors.fill: parent - anchors.margins: Kirigami.Units.largeSpacing - - Label { - id: statusLabel - Layout.alignment: Qt.AlignHCenter - font.pixelSize: root.width * 0.035 - wrapMode: Text.WordWrap - renderType: Text.NativeRendering - font.family: "Noto Sans Display" - font.styleName: "Black" - text: sessionData.label - color: Kirigami.Theme.textColor - } - - LottieAnimation { - id: statusIcon - visible: true - enabled: true - Layout.fillWidth: true - Layout.fillHeight: true - Layout.alignment: Qt.AlignHCenter - loops: Animation.Infinite - fillMode: Image.PreserveAspectFit - running: true - source: Qt.resolvedUrl("animations/loading.json") - } - } - } -} diff --git a/ovos_gui/res/gui/qt5/SYSTEM_Status.qml b/ovos_gui/res/gui/qt5/SYSTEM_Status.qml deleted file mode 100644 index 4769a71..0000000 --- a/ovos_gui/res/gui/qt5/SYSTEM_Status.qml +++ /dev/null @@ -1,66 +0,0 @@ -import QtQuick.Layouts 1.4 -import QtQuick 2.4 -import QtQuick.Controls 2.12 -import org.kde.kirigami 2.10 as Kirigami -import Mycroft 1.0 as Mycroft -import org.kde.lottie 1.0 - - -Mycroft.Delegate { - id: root - leftPadding: 0 - rightPadding: 0 - topPadding: 0 - bottomPadding: 0 - background: Rectangle { - color: Kirigami.Theme.backgroundColor - z: -1 - } - property var success: sessionData.status - anchors.fill: parent - - function checkstatus(status) { - if(status == "Enabled") { - return Qt.resolvedUrl("animations/status-success.json") - } else if (status == "Disabled") { - return Qt.resolvedUrl("animations/status-fail.json") - } - } - - Rectangle { - anchors.fill: parent - anchors.margins: Mycroft.Units.gridUnit * 2 - color: Kirigami.Theme.backgroundColor - - ColumnLayout { - id: grid - anchors.fill: parent - anchors.margins: Kirigami.Units.largeSpacing - - LottieAnimation { - id: statusIcon - visible: true - enabled: true - Layout.fillWidth: true - Layout.fillHeight: true - Layout.alignment: Qt.AlignHCenter - loops: Animation.Infinite - fillMode: Image.PreserveAspectFit - running: true - source: checkstatus(sessionData.status) - } - - Label { - id: statusLabel - Layout.alignment: Qt.AlignHCenter - font.pixelSize: parent.height * 0.095 - wrapMode: Text.WordWrap - renderType: Text.NativeRendering - font.family: "Noto Sans Display" - font.styleName: "Black" - text: sessionData.label - color: Kirigami.Theme.textColor - } - } - } -} diff --git a/ovos_gui/res/gui/qt5/SYSTEM_TextFrame.qml b/ovos_gui/res/gui/qt5/SYSTEM_TextFrame.qml deleted file mode 100644 index ee91373..0000000 --- a/ovos_gui/res/gui/qt5/SYSTEM_TextFrame.qml +++ /dev/null @@ -1,46 +0,0 @@ -import QtQuick.Layouts 1.4 -import QtQuick 2.4 -import QtQuick.Controls 2.0 -import org.kde.kirigami 2.4 as Kirigami - -import Mycroft 1.0 as Mycroft - -Mycroft.CardDelegate { - id: systemTextFrame - skillBackgroundColorOverlay: "#000000" - cardBackgroundOverlayColor: "#000000" - fillWidth: true - - property bool hasTitle: sessionData.title.length > 0 ? true : false - - contentItem: Rectangle { - color: "blue" - - ColumnLayout { - anchors.fill: parent - - Mycroft.AutoFitLabel { - id: systemTextFrameTitle - wrapMode: Text.Wrap - visible: hasTitle - enabled: hasTitle - Layout.fillWidth: true - Layout.fillHeight: true - font.family: "Noto Sans" - font.weight: Font.Bold - text: sessionData.title - } - - Mycroft.AutoFitLabel { - id: systemTextFrameMainBody - wrapMode: Text.Wrap - font.family: "Noto Sans" - Layout.fillWidth: true - Layout.fillHeight: true - font.weight: Font.Bold - text: sessionData.text - } - } - } -} - diff --git a/ovos_gui/res/gui/qt5/SYSTEM_UrlFrame.qml b/ovos_gui/res/gui/qt5/SYSTEM_UrlFrame.qml deleted file mode 100644 index 29c915a..0000000 --- a/ovos_gui/res/gui/qt5/SYSTEM_UrlFrame.qml +++ /dev/null @@ -1,170 +0,0 @@ -import QtQuick.Layouts 1.4 -import QtQuick 2.12 -import QtQuick.Controls 2.12 -import org.kde.kirigami 2.11 as Kirigami -import QtWebEngine 1.8 -import Mycroft 1.0 as Mycroft - -Mycroft.AbstractDelegate { - id: systemUrlFrame - property var pageUrl: sessionData.url - fillWidth: true - - onPageUrlChanged: { - if(typeof pageUrl !== "undefined" || typeof pageUrl !== null){ - webview.url = pageUrl - } - } - - contentItem: Item { - anchors.fill: parent - - Rectangle { - id: blankArea - color: Kirigami.Theme.backgroundColor - height: Mycroft.Units.gridUnit * 2 - anchors.top: parent.top - width: parent.width - } - - SwipeArea { - anchors.top: blankArea.bottom - anchors.left: parent.left - anchors.right: parent.right - anchors.bottom: parent.bottom - preventStealing: true - - Flickable { - id: flickable - clip: true; - anchors.fill: parent - contentHeight: systemUrlFrame.height * 2 - contentWidth: systemUrlFrame.width - - property var storeCHeight - property var storeCWidth - - WebEngineView { - id: webview - anchors.fill : parent; - profile: defaultProfile - - settings.autoLoadImages: true - settings.javascriptEnabled: true - settings.errorPageEnabled: true - settings.pluginsEnabled: true - settings.allowWindowActivationFromJavaScript: true - settings.javascriptCanOpenWindows: true - settings.fullScreenSupportEnabled: true - settings.autoLoadIconsForPage: true - settings.touchIconsEnabled: true - settings.webRTCPublicInterfacesOnly: true - settings.showScrollBars: false - - onNewViewRequested: function(request) { - if (!request.userInitiated) { - console.log("Warning: Blocked a popup window."); - } else if (request.destination === WebEngineView.NewViewInDialog) { - popuproot.open() - request.openIn(popupwebview); - } else { - request.openIn(webview); - } - } - - onJavaScriptDialogRequested: function(request) { - request.accepted = true; - } - - onFeaturePermissionRequested: { - interactionBar.setSource("FeatureRequest.qml") - interactionBar.interactionItem.securityOrigin = securityOrigin; - interactionBar.interactionItem.requestedFeature = feature; - interactionBar.isRequested = true; - } - - onFullScreenRequested: function(request) { - if (request.toggleOn) { - flickable.storeCWidth = flickable.contentWidth - flickable.storeCHeight = flickable.contentHeight - flickable.contentWidth = flickable.width - flickable.contentHeight = flickable.height - } - else { - flickable.contentWidth = flickable.storeCWidth - flickable.contentHeight = flickable.storeCHeight - } - request.accept() - } - - onLoadingChanged: { - if (loadRequest.status !== WebEngineView.LoadSucceededStatus) { - return; - } - - flickable.contentHeight = 0; - flickable.contentWidth = flickable.width; - - runJavaScript ( - "document.documentElement.scrollHeight;", - function (actualPageHeight) { - flickable.contentHeight = Math.max ( - actualPageHeight, flickable.height); - }); - } - } - - WebEngineProfile { - id: defaultProfile - httpUserAgent: "Mozilla/5.0 (Linux; Android 13; Pixel 6a) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Mobile Safari/537.36" - } - - onFlickEnded: { - webview.runJavaScript ( - "document.documentElement.scrollHeight;", - function (actualPageHeight) { - flickable.contentHeight = Math.max ( - actualPageHeight, flickable.height); - }); - } - } - - RequestHandler { - id: interactionBar - anchors.top: parent.top - z: 1001 - } - - Popup { - id: popuproot - modal: true - focus: true - width: root.width - Kirigami.Units.largeSpacing * 1.25 - height: root.height - Kirigami.Units.largeSpacing * 1.25 - closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutsideParent - anchors.centerIn: parent - - WebEngineView { - id: popupwebview - anchors.fill: parent - url: "about:blank" - settings.autoLoadImages: true - settings.javascriptEnabled: true - settings.errorPageEnabled: true - settings.pluginsEnabled: true - settings.allowWindowActivationFromJavaScript: true - settings.javascriptCanOpenWindows: true - settings.fullScreenSupportEnabled: true - settings.autoLoadIconsForPage: true - settings.touchIconsEnabled: true - settings.webRTCPublicInterfacesOnly: true - property string urlalias: popupwebview.url - - onNewViewRequested: function(request) { - console.log(request.destination) - } - } - } - } - } -} diff --git a/ovos_gui/res/gui/qt5/SwipeArea.qml b/ovos_gui/res/gui/qt5/SwipeArea.qml deleted file mode 100644 index a0a29d8..0000000 --- a/ovos_gui/res/gui/qt5/SwipeArea.qml +++ /dev/null @@ -1,52 +0,0 @@ -import QtQuick 2.9 - -MouseArea { - id: mouseSwipeArea - preventStealing: true - - property real prevX: 0 - property real prevY: 0 - property real velocityX: 0.0 - property real velocityY: 0.0 - property int startX: 0 - property int startY: 0 - property bool tracing: false - - signal swipe(string direction) - - onPressed: { - startX = mouse.x - startY = mouse.y - prevX = mouse.x - prevY = mouse.y - velocityX = 0 - velocityY = 0 - tracing = true - } - - onPositionChanged: { - if ( !tracing ) return - var currVelX = (mouse.x-prevX) - var currVelY = (mouse.y-prevY) - - velocityX = (velocityX + currVelX)/2.0; - velocityY = (velocityY + currVelY)/2.0; - - prevX = mouse.x - prevY = mouse.y - - if ( velocityX > 15 && mouse.x > mouseSwipeArea.width * 0.25 ) { - tracing = false - mouseSwipeArea.swipe("right") - } else if ( velocityX < -15 && mouse.x < mouseSwipeArea.width * 0.75 ) { - tracing = false - mouseSwipeArea.swipe("left") - } else if (velocityY > 15 && mouse.y > mouseSwipeArea.height * 0.25 ) { - tracing = false - mouseSwipeArea.swipe("down") - } else if ( velocityY < -15 && mouse.y < mouseSwipeArea.height * 0.75 ) { - tracing = false - mouseSwipeArea.swipe("up") - } - } -} diff --git a/ovos_gui/res/gui/qt5/WebViewHtmlFrame.qml b/ovos_gui/res/gui/qt5/WebViewHtmlFrame.qml deleted file mode 100644 index 25a57d2..0000000 --- a/ovos_gui/res/gui/qt5/WebViewHtmlFrame.qml +++ /dev/null @@ -1,99 +0,0 @@ -import QtQuick 2.4 -import QtQuick.Controls 2.2 -import QtWebEngine 1.8 -import QtQuick.Controls 2.0 -import org.kde.kirigami 2.4 as Kirigami - -Item { - id: root - property var pageHtml: webViewHtmlLoader.pageHtml - property var resourceLocation: webViewHtmlLoader.resourceLocation ? webViewHtmlLoader.resourceLocation : "http://localhost" - - onResourceLocationChanged: { - console.log(resourceLocation) - } - - onPageHtmlChanged: { - if(pageHtml){ - webview.loadHtml(pageHtml, resourceLocation) - } - } - - RequestHandler { - id: interactionBar - anchors.top: parent.top - z: 1001 - } - - WebEngineView { - id: webview - anchors.fill: parent - settings.autoLoadImages: true - settings.javascriptEnabled: true - settings.errorPageEnabled: true - settings.pluginsEnabled: true - settings.allowWindowActivationFromJavaScript: true - settings.javascriptCanOpenWindows: true - settings.fullScreenSupportEnabled: true - settings.autoLoadIconsForPage: true - settings.touchIconsEnabled: true - settings.webRTCPublicInterfacesOnly: true - - onNewViewRequested: function(request) { - if (!request.userInitiated) { - console.log("Warning: Blocked a popup window."); - } else if (request.destination === WebEngineView.NewViewInDialog) { - popuproot.open() - request.openIn(popupwebview); - } else { - request.openIn(webview); - } - } - - onJavaScriptDialogRequested: function(request) { - request.accepted = true; - } - - onFeaturePermissionRequested: { - interactionBar.setSource("FeatureRequest.qml") - interactionBar.interactionItem.securityOrigin = securityOrigin; - interactionBar.interactionItem.requestedFeature = feature; - interactionBar.isRequested = true; - } - - onFullScreenRequested: { - request.accept() - } - } - - Popup { - id: popuproot - modal: true - focus: true - width: root.width - Kirigami.Units.largeSpacing * 1.25 - height: root.height - Kirigami.Units.largeSpacing * 1.25 - closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutsideParent - anchors.centerIn: parent - - WebEngineView { - id: popupwebview - anchors.fill: parent - url: "about:blank" - settings.autoLoadImages: true - settings.javascriptEnabled: true - settings.errorPageEnabled: true - settings.pluginsEnabled: true - settings.allowWindowActivationFromJavaScript: true - settings.javascriptCanOpenWindows: true - settings.fullScreenSupportEnabled: true - settings.autoLoadIconsForPage: true - settings.touchIconsEnabled: true - settings.webRTCPublicInterfacesOnly: true - property string urlalias: popupwebview.url - - onNewViewRequested: function(request) { - console.log(request.destination) - } - } - } -} diff --git a/ovos_gui/res/gui/qt5/WebViewUrlFrame.qml b/ovos_gui/res/gui/qt5/WebViewUrlFrame.qml deleted file mode 100644 index db60ae1..0000000 --- a/ovos_gui/res/gui/qt5/WebViewUrlFrame.qml +++ /dev/null @@ -1,94 +0,0 @@ -import QtQuick 2.4 -import QtQuick.Controls 2.2 -import QtWebEngine 1.8 -import QtQuick.Controls 2.0 -import org.kde.kirigami 2.4 as Kirigami - -Item { - id: root - property var pageUrl: webViewUrlLoader.pageUrl - - onPageUrlChanged: { - if(typeof pageUrl !== "undefined" || typeof pageUrl !== null){ - webview.url = pageUrl - } - } - - RequestHandler { - id: interactionBar - anchors.top: parent.top - z: 1001 - } - - WebEngineView { - id: webview - anchors.fill: parent - settings.autoLoadImages: true - settings.javascriptEnabled: true - settings.errorPageEnabled: true - settings.pluginsEnabled: true - settings.allowWindowActivationFromJavaScript: true - settings.javascriptCanOpenWindows: true - settings.fullScreenSupportEnabled: true - settings.autoLoadIconsForPage: true - settings.touchIconsEnabled: true - settings.webRTCPublicInterfacesOnly: true - - onNewViewRequested: function(request) { - if (!request.userInitiated) { - console.log("Warning: Blocked a popup window."); - } else if (request.destination === WebEngineView.NewViewInDialog) { - popuproot.open() - request.openIn(popupwebview); - } else { - request.openIn(webview); - } - } - - onJavaScriptDialogRequested: function(request) { - request.accepted = true; - } - - onFeaturePermissionRequested: { - interactionBar.setSource("FeatureRequest.qml") - interactionBar.interactionItem.securityOrigin = securityOrigin; - interactionBar.interactionItem.requestedFeature = feature; - interactionBar.isRequested = true; - } - - onFullScreenRequested: { - request.accept() - } - } - - Popup { - id: popuproot - modal: true - focus: true - width: root.width - Kirigami.Units.largeSpacing * 1.25 - height: root.height - Kirigami.Units.largeSpacing * 1.25 - closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutsideParent - anchors.centerIn: parent - - WebEngineView { - id: popupwebview - anchors.fill: parent - url: "about:blank" - settings.autoLoadImages: true - settings.javascriptEnabled: true - settings.errorPageEnabled: true - settings.pluginsEnabled: true - settings.allowWindowActivationFromJavaScript: true - settings.javascriptCanOpenWindows: true - settings.fullScreenSupportEnabled: true - settings.autoLoadIconsForPage: true - settings.touchIconsEnabled: true - settings.webRTCPublicInterfacesOnly: true - property string urlalias: popupwebview.url - - onNewViewRequested: function(request) { - console.log(request.destination) - } - } - } -} diff --git a/ovos_gui/res/gui/qt5/animations/loading.json b/ovos_gui/res/gui/qt5/animations/loading.json deleted file mode 100644 index ab84a1b..0000000 --- a/ovos_gui/res/gui/qt5/animations/loading.json +++ /dev/null @@ -1 +0,0 @@ -{"v":"5.4.3","fr":29.9700012207031,"ip":0,"op":70.0000028511585,"w":307,"h":389,"nm":"refresh-button","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":2,"ty":4,"nm":"Shape Layer 6","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-44,"ix":10},"p":{"a":0,"k":[154.149,195.327,0],"ix":2},"a":{"a":0,"k":[-2.021,-4,0],"ix":1},"s":{"a":0,"k":[71.946,71.946,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[217,217],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"st","c":{"a":0,"k":[0.211764705882,0.211764705882,0.211764705882,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":10,"ix":5},"lc":2,"lj":1,"ml":4,"ml2":{"a":0,"k":4,"ix":8},"bm":0,"d":[{"n":"d","nm":"dash","v":{"a":0,"k":33,"ix":1}},{"n":"o","nm":"offset","v":{"a":0,"k":0,"ix":7}}],"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[-1.5,-4],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.625],"y":[0]},"n":["0p667_1_0p625_0"],"t":26,"s":[0],"e":[100]},{"t":65.0000026475043}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[1],"y":[0]},"n":["0p667_1_1_0"],"t":7,"s":[0],"e":[100]},{"t":41.0000016699642}],"ix":2},"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":0,"s":[-179],"e":[181]},{"t":65.0000026475043}],"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":70.0000028511585,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Shape Layer 3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-44,"ix":10},"p":{"a":0,"k":[154.149,195.327,0],"ix":2},"a":{"a":0,"k":[-2.021,-4,0],"ix":1},"s":{"a":0,"k":[71.946,71.946,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[217,217],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"st","c":{"a":0,"k":[0.9098039215686274,0.3137254901960784,0.3137254901960784,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":10,"ix":5},"lc":2,"lj":1,"ml":4,"ml2":{"a":0,"k":4,"ix":8},"bm":0,"d":[{"n":"d","nm":"dash","v":{"a":0,"k":33,"ix":1}},{"n":"o","nm":"offset","v":{"a":0,"k":0,"ix":7}}],"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[-1.5,-4],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.625],"y":[0]},"n":["0p667_1_0p625_0"],"t":26,"s":[0],"e":[100]},{"t":65.0000026475043}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[1],"y":[0]},"n":["0p667_1_1_0"],"t":0,"s":[0],"e":[100]},{"t":41.0000016699642}],"ix":2},"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":0,"s":[-224],"e":[136]},{"t":65.0000026475043}],"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":70.0000028511585,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Shape Layer 4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-44,"ix":10},"p":{"a":0,"k":[154.149,195.327,0],"ix":2},"a":{"a":0,"k":[-2.021,-4,0],"ix":1},"s":{"a":0,"k":[46.072,46.072,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[217,217],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"st","c":{"a":0,"k":[0.9058823529411765,0.2627450980392157,0.2627450980392157,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":10,"ix":5},"lc":2,"lj":1,"ml":4,"ml2":{"a":0,"k":4,"ix":8},"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[-1.5,-4],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[1],"y":[0]},"n":["0p667_1_1_0"],"t":0,"s":[100],"e":[0]},{"t":38.0000015477717}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.733],"y":[0.015]},"n":["0p667_1_0p733_0p015"],"t":19,"s":[100],"e":[0]},{"t":60.0000024438501}],"ix":2},"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":0,"s":[-319],"e":[-679]},{"t":65.0000026475043}],"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":70.0000028511585,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Shape Layer 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-44,"ix":10},"p":{"a":0,"k":[154.149,195.327,0],"ix":2},"a":{"a":0,"k":[-2.021,-4,0],"ix":1},"s":{"a":0,"k":[46.072,46.072,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[217,217],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"st","c":{"a":0,"k":[0.105882352941,0.105882352941,0.105882352941,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":10,"ix":5},"lc":2,"lj":1,"ml":4,"ml2":{"a":0,"k":4,"ix":8},"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[-1.5,-4],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[1],"y":[0]},"n":["0p667_1_1_0"],"t":0,"s":[100],"e":[0]},{"t":38.0000015477717}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.733],"y":[0.015]},"n":["0p667_1_0p733_0p015"],"t":19,"s":[100],"e":[0]},{"t":60.0000024438501}],"ix":2},"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":0,"s":[-224],"e":[-584]},{"t":65.0000026475043}],"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":70.0000028511585,"st":0,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"Shape Layer 5","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":0,"s":[4.436],"e":[-355.564]},{"t":69.0000028104276}],"ix":10},"p":{"a":0,"k":[155,194,0],"ix":2},"a":{"a":0,"k":[-1.5,-4,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[217,217],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"st","c":{"a":0,"k":[0.47843137254901963,0.47843137254901963,0.47843137254901963,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":5,"ix":5},"lc":1,"lj":1,"ml":4,"ml2":{"a":0,"k":4,"ix":8},"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[-1.5,-4],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1},"e":{"a":0,"k":85,"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":70.0000028511585,"st":0,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"Shape Layer 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":0,"s":[4.436],"e":[-355.564]},{"t":69.0000028104276}],"ix":10},"p":{"a":0,"k":[155,194,0],"ix":2},"a":{"a":0,"k":[-1.5,-4,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[217,217],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"st","c":{"a":0,"k":[0.3607843137254902,0.16470588235294117,0.16470588235294117,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":5,"ix":5},"lc":1,"lj":1,"ml":4,"ml2":{"a":0,"k":4,"ix":8},"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[-1.5,-4],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1},"e":{"a":0,"k":85,"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":70.0000028511585,"st":0,"bm":0},{"ddd":0,"ind":8,"ty":4,"nm":"refresh-button Outlines","parent":7,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":85.093,"ix":10},"p":{"a":0,"k":[-6.619,-112.041,0],"ix":2},"a":{"a":0,"k":[188.881,115.959,0],"ix":1},"s":{"a":0,"k":[58.516,58.516,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[1.875,-1.875],[-1.875,-1.875],[0,0],[-1.274,0],[-0.898,0.903],[0,0],[1.875,1.875],[1.875,-1.875],[0,0]],"o":[[-1.875,-1.875],[-1.875,1.875],[0,0],[0.899,0.903],[1.277,0],[0,0],[1.875,-1.875],[-1.875,-1.875],[0,0],[0,0]],"v":[[-14.662,-12.188],[-21.451,-12.188],[-21.451,-5.398],[-3.393,12.656],[-0.002,14.063],[3.392,12.656],[21.451,-5.398],[21.451,-12.188],[14.662,-12.188],[-0.002,2.473]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0]],"o":[[0,0]],"v":[[-14.662,-12.188]],"c":false},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.9058823529411765,0.2627450980392157,0.2627450980392157,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[188.943,118.162],"ix":2},"a":{"a":0,"k":[0.062,2.438],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":70.0000028511585,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/ovos_gui/res/gui/qt5/animations/status-fail.json b/ovos_gui/res/gui/qt5/animations/status-fail.json deleted file mode 100644 index 8992be8..0000000 --- a/ovos_gui/res/gui/qt5/animations/status-fail.json +++ /dev/null @@ -1 +0,0 @@ -{"v":"5.4.4","fr":15,"ip":0,"op":45,"w":160,"h":160,"nm":"Failed Checkmark","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"X Mark 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":94,"ix":10},"p":{"a":0,"k":[79,84,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[226.78400000000002,204.352,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-10,-8]],"o":[[0,0],[10,8]],"v":[[-18,-15],[15,14]],"c":false},"ix":2},"nm":"Caminho 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":2,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Traçado 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Preenchimento 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Forma 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[],"o":[],"v":[],"c":false},"ix":2},"nm":"Caminho 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":30,"s":[0],"e":[100]},{"t":34}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":3,"nm":"Aparar caminhos 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":40,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"X Mark","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[82.5,81.5,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[200,232.858,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-10,-8]],"o":[[0,0],[10,8]],"v":[[-18,-15],[15,14]],"c":false},"ix":2},"nm":"Caminho 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":2,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Traçado 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Preenchimento 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Forma 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[],"o":[],"v":[],"c":false},"ix":2},"nm":"Caminho 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":34,"s":[0],"e":[100]},{"t":38}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":3,"nm":"Aparar caminhos 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":40,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Circle Flash","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":25,"s":[0],"e":[98]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":30,"s":[98],"e":[0]},{"t":38}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[80,80,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":25,"s":[0,0,100],"e":[200,200,100]},{"t":30}],"ix":6}},"ao":0,"shapes":[{"d":1,"ty":"el","s":{"a":0,"k":[64,64],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.6196078431372549,0.592156862745098,0.592156862745098,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false}],"ip":0,"op":40,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Circle Stroke","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[78.044,78.044,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":16,"s":[200,200,100],"e":[160,160,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":22,"s":[160,160,100],"e":[240,240,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":25,"s":[240,240,100],"e":[200,200,100]},{"t":29}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[60,60],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0],"e":[100]},{"t":16}],"ix":1},"e":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"st","c":{"a":0,"k":[0.737254917622,0,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":3,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0.978,0.978],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transformar"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":40,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Circle Red Fill","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":21,"s":[0],"e":[98]},{"t":28}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[80,80,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":21,"s":[0,0,100],"e":[200,200,100]},{"t":28}],"ix":6}},"ao":0,"shapes":[{"d":1,"ty":"el","s":{"a":0,"k":[64,64],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.800000011921,0.35686275363,0.35686275363,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false}],"ip":0,"op":40,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/ovos_gui/res/gui/qt5/animations/status-success.json b/ovos_gui/res/gui/qt5/animations/status-success.json deleted file mode 100644 index 6551cc2..0000000 --- a/ovos_gui/res/gui/qt5/animations/status-success.json +++ /dev/null @@ -1 +0,0 @@ -{"v":"5.3.4","fr":15,"ip":0,"op":40,"w":160,"h":160,"nm":"Success Checkmark","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Check Mark","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[80,80,0],"ix":2},"a":{"a":0,"k":[-1.313,6,0],"ix":1},"s":{"a":0,"k":[200,200,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-15.75,8],[-8,16],[13.125,-4]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"n":["0p667_1_0p333_0"],"t":25,"s":[0],"e":[100]},{"t":33}],"ix":1},"e":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":3,"ix":5},"lc":2,"lj":2,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":40,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Circle Flash","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":25,"s":[0],"e":[98]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":30,"s":[98],"e":[0]},{"t":38}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[80,80,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"n":["0p667_1_0p333_0","0p667_1_0p333_0","0p667_1_0p333_0"],"t":25,"s":[0,0,100],"e":[200,200,100]},{"t":30}],"ix":6}},"ao":0,"shapes":[{"d":1,"ty":"el","s":{"a":0,"k":[64,64],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.5450980392156862,0.5450980392156862,0.5450980392156862,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false}],"ip":0,"op":40,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Circle Stroke","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[78.044,78.044,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"n":["0p667_1_0p333_0","0p667_1_0p333_0","0p667_1_0p333_0"],"t":16,"s":[200,200,100],"e":[160,160,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"n":["0p667_1_0p333_0","0p667_1_0p333_0","0p667_1_0p333_0"],"t":22,"s":[160,160,100],"e":[240,240,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"n":["0p667_1_0p333_0","0p667_1_0p333_0","0p667_1_0p333_0"],"t":25,"s":[240,240,100],"e":[200,200,100]},{"t":29}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[60,60],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"n":["0p667_1_0p333_0"],"t":0,"s":[0],"e":[100]},{"t":16}],"ix":1},"e":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"st","c":{"a":0,"k":[0.427450984716,0.800000011921,0.35686275363,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":3,"ix":5},"lc":2,"lj":2,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0.978,0.978],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":40,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Circle Green Fill","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":21,"s":[0],"e":[98]},{"t":28}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[80,80,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"n":["0p667_1_0p333_0","0p667_1_0p333_0","0p667_1_0p333_0"],"t":21,"s":[0,0,100],"e":[200,200,100]},{"t":28}],"ix":6}},"ao":0,"shapes":[{"d":1,"ty":"el","s":{"a":0,"k":[64,64],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.427450984716,0.800000011921,0.35686275363,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false}],"ip":0,"op":40,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/ovos_gui/res/gui/qt5/face/Eyeball.svg b/ovos_gui/res/gui/qt5/face/Eyeball.svg deleted file mode 100644 index 4f88a5d..0000000 --- a/ovos_gui/res/gui/qt5/face/Eyeball.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/ovos_gui/res/gui/qt5/face/GreySmile.svg b/ovos_gui/res/gui/qt5/face/GreySmile.svg deleted file mode 100644 index 604742a..0000000 --- a/ovos_gui/res/gui/qt5/face/GreySmile.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/ovos_gui/res/gui/qt5/face/Smile.svg b/ovos_gui/res/gui/qt5/face/Smile.svg deleted file mode 100644 index 6e02be9..0000000 --- a/ovos_gui/res/gui/qt5/face/Smile.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/ovos_gui/res/gui/qt5/face/lid.svg b/ovos_gui/res/gui/qt5/face/lid.svg deleted file mode 100644 index dc6a0b7..0000000 --- a/ovos_gui/res/gui/qt5/face/lid.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/ovos_gui/res/gui/qt5/face/upper-lid.svg b/ovos_gui/res/gui/qt5/face/upper-lid.svg deleted file mode 100644 index 928c250..0000000 --- a/ovos_gui/res/gui/qt5/face/upper-lid.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/ovos_gui/service.py b/ovos_gui/service.py index 377d46f..5b3023a 100644 --- a/ovos_gui/service.py +++ b/ovos_gui/service.py @@ -1,9 +1,9 @@ -from ovos_bus_client import MessageBusClient, Message -from ovos_utils.log import LOG -from ovos_utils.process_utils import ProcessStatus, StatusCallbackMap, ProcessState +from ovos_bus_client import MessageBusClient from ovos_config.config import Configuration -from ovos_gui.extensions import ExtensionsManager from ovos_gui.namespace import NamespaceManager +from ovos_plugin_manager.gui import OVOSGUIAdapterFactory +from ovos_utils.log import LOG +from ovos_utils.process_utils import ProcessStatus, StatusCallbackMap, ProcessState def on_started(): @@ -31,7 +31,6 @@ def __init__(self, alive_hook=on_alive, started_hook=on_started, ready_hook=on_ready, error_hook=on_error, stopping_hook=on_stopping): self.bus = MessageBusClient() - self.extension_manager = None self.namespace_manager = None callbacks = StatusCallbackMap(on_started=started_hook, on_alive=alive_hook, @@ -52,6 +51,24 @@ def _init_bus_client(self): self.bus.connected_event.wait() LOG.info('Connected to messagebus') + def _load_adapter_plugins(self): + """Load all installed ``opm.gui_adapter`` plugins and return instances. + + Returns an empty list on a headless device with no adapters installed. + The factory never raises: a failing adapter is logged and skipped so a + single bad adapter cannot prevent the GUI service from starting. + """ + adapter_config = Configuration().get("gui", {}).get("adapters", {}) + adapters = OVOSGUIAdapterFactory.create_all(bus=self.bus, + config=adapter_config) + if not adapters: + LOG.info("No GUI adapters installed; running headless. Template " + "dispatch is a no-op until an adapter (e.g. " + "ovos-legacy-mycroft-gui-plugin) is installed.") + else: + LOG.info(f"Loaded {len(adapters)} GUI adapter plugin(s)") + return adapters + def run(self): """ Start the GUI after it has been constructed. @@ -60,11 +77,10 @@ def run(self): # if they may cause the Service to fail. self.status.set_alive() self._init_bus_client() - - self.extension_manager = ExtensionsManager("EXTENSION_SERVICE", self.bus) - self.namespace_manager = NamespaceManager(self.bus) + adapters = self._load_adapter_plugins() + self.namespace_manager = NamespaceManager(self.bus, adapters=adapters) self.status.set_ready() - LOG.info(f"GUI Service Ready") + LOG.info("GUI Service Ready") def is_alive(self) -> bool: """ diff --git a/pyproject.toml b/pyproject.toml index 0b54df9..945c200 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,9 +22,21 @@ dependencies = [ "ovos-utils>=0.0.37,<1.0.0", "ovos-config>=0.0.12,<3.0.0", "tornado~=6.0, >=6.0.3", + # >=2.5.0a1 carries OVOSGUIAdapterFactory (the opm.gui_adapter loader). "ovos-plugin-manager>=2.5.0a1,<3.0.0", ] +[project.optional-dependencies] +# Bundled Qt adapter. ovos-gui itself runs no display backend; install an +# opm.gui_adapter plugin to render. Headless installs need none of these. +gui = ["ovos-legacy-mycroft-gui-plugin>=0.0.1,<1.0.0"] +test = [ + "pytest~=7.1", + "pytest-cov~=4.1", + # skill-side interface — exercised by the full-path end2end integration test + "ovos-gui-api-client>=0.0.1,<1.0.0", +] + [project.urls] Homepage = "https://github.com/OpenVoiceOS/ovos-gui" @@ -38,8 +50,5 @@ include-package-data = true [tool.setuptools.packages.find] include = ["ovos_gui*"] -[tool.setuptools.package-data] -ovos_gui = ["res/**/*"] - [tool.setuptools.dynamic] version = {attr = "ovos_gui.version.__version__"} diff --git a/test/end2end/__init__.py b/test/end2end/__init__.py new file mode 100644 index 0000000..d76bd6f --- /dev/null +++ b/test/end2end/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2026 OpenVoiceOS Contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""End-to-end integration tests for ovos-gui with real adapters.""" diff --git a/test/end2end/conftest.py b/test/end2end/conftest.py new file mode 100644 index 0000000..957d70a --- /dev/null +++ b/test/end2end/conftest.py @@ -0,0 +1,155 @@ +# Copyright 2026 OpenVoiceOS Contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Pytest fixtures for E2E adapter integration tests. + +These tests use a *concrete* adapter that subclasses ``AbstractGUIPlugin`` +rather than ``unittest.mock.Mock``. A Mock accepts any call signature, so it +hides arity drift between ovos-gui and the published adapter contract. The +recording adapter below enforces the real handler/hook signatures, so a routing +or contract regression fails the test instead of passing silently. +""" +from typing import List + +import pytest +from ovos_bus_client.message import Message +from ovos_utils.fakebus import FakeBus +from ovos_plugin_manager.templates.gui import AbstractGUIPlugin + +from ovos_gui.namespace import NamespaceManager + + +class RecordingGUIPlugin(AbstractGUIPlugin): + """Spec-conformant adapter that records every call it receives. + + Subclassing ``AbstractGUIPlugin`` binds the template handlers and lifecycle + hooks with their real signatures (session_id only). Calling them with the + wrong arity raises ``TypeError`` instead of being silently accepted, which + is exactly what we want the tests to catch. + """ + + def __init__(self, config=None, bus=None): + super().__init__(config or {}, bus) + # list of (kind, *payload) tuples + self.calls: List[tuple] = [] + self._connected = True + + # --- template handlers (override a representative subset) --------------- + def handle_show_weather(self, skill_id, data, session_id="default"): + self.calls.append(("weather", skill_id, dict(data), session_id)) + + def handle_show_text(self, skill_id, data, session_id="default"): + self.calls.append(("text", skill_id, dict(data), session_id)) + + def handle_show_clock(self, skill_id, data, session_id="default"): + self.calls.append(("clock", skill_id, dict(data), session_id)) + + # --- lifecycle hooks ---------------------------------------------------- + def on_namespace_activated(self, skill_id, session_id="default"): + self.calls.append(("activated", skill_id, session_id)) + + def on_namespace_deactivated(self, skill_id, session_id="default"): + self.calls.append(("deactivated", skill_id, session_id)) + + def on_session_update(self, skill_id, data, session_id="default"): + self.calls.append(("session_update", skill_id, dict(data), session_id)) + + def on_status_event(self, event_name, data, session_id="default"): + self.calls.append(("status", event_name, dict(data), session_id)) + + def any_client_connected(self) -> bool: + return self._connected + + # --- helpers for assertions -------------------------------------------- + def calls_of(self, kind: str) -> List[tuple]: + return [c for c in self.calls if c[0] == kind] + + +class ExplodingGUIPlugin(RecordingGUIPlugin): + """Adapter whose hooks raise — used to verify failure isolation.""" + + def on_namespace_activated(self, skill_id, session_id="default"): + raise RuntimeError("boom") + + def dispatch_template(self, template, skill_id, data, session_id="default"): + raise RuntimeError("boom") + + +@pytest.fixture(autouse=True) +def _cancel_pending_timers(): + """Cancel namespace auto-removal timers after each test. + + Tests show non-persistent namespaces which schedule a background removal + Timer; cancelling them keeps logs clean and avoids dangling threads. + """ + import gc + yield + for obj in gc.get_objects(): + if isinstance(obj, NamespaceManager): + for session in obj.sessions.values(): + for timer in session.remove_namespace_timers.values(): + timer.cancel() + + +@pytest.fixture +def fake_bus(): + return FakeBus() + + +@pytest.fixture +def recording_adapter(): + return RecordingGUIPlugin() + + +@pytest.fixture +def second_adapter(): + return RecordingGUIPlugin() + + +@pytest.fixture +def manager(fake_bus, recording_adapter): + """NamespaceManager with a single concrete recording adapter.""" + return NamespaceManager(fake_bus, adapters=[recording_adapter]) + + +@pytest.fixture +def message_factory(): + """Factory for GUI bus messages keyed solely by session_id.""" + + def page_show(page_names: List[str], skill_id: str, + session_id: str = "default", persistent: bool = False) -> Message: + return Message("gui.page.show", data={ + "page_names": page_names, + "__from": skill_id, + "__idle": persistent, + "index": 0, + }, context={"session": {"session_id": session_id}}) + + def set_value(namespace: str, key: str, value, + session_id: str = "default") -> Message: + return Message("gui.value.set", data={ + "__from": namespace, + key: value, + }, context={"session": {"session_id": session_id}}) + + def clear_namespace(namespace: str, session_id: str = "default") -> Message: + return Message("gui.clear.namespace", data={ + "__from": namespace, + }, context={"session": {"session_id": session_id}}) + + return { + "page_show": page_show, + "set_value": set_value, + "clear_namespace": clear_namespace, + } diff --git a/test/end2end/test_adapter_integration.py b/test/end2end/test_adapter_integration.py new file mode 100644 index 0000000..204eceb --- /dev/null +++ b/test/end2end/test_adapter_integration.py @@ -0,0 +1,181 @@ +# Copyright 2026 OpenVoiceOS Contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""E2E integration tests for ovos-gui dispatch through real adapters. + +Coverage: +- Skill -> NamespaceManager -> concrete adapter routing +- Real OVOSGUIAdapterFactory loading on a headless device (B1) +- session_id-only routing: default, a UUID, and two clients sharing one id +- Multi-adapter fan-out + per-adapter failure isolation +- The removed page-delete path no longer crashes (B4) +""" +import uuid + +from ovos_bus_client.message import Message + +from ovos_gui.namespace import NamespaceManager +from .conftest import RecordingGUIPlugin, ExplodingGUIPlugin + + +class TestFactoryLoading: + """Regression for B1: the real factory loads cleanly when headless.""" + + def test_factory_create_all_headless_returns_empty(self, fake_bus): + from ovos_plugin_manager.gui import OVOSGUIAdapterFactory + adapters = OVOSGUIAdapterFactory.create_all(bus=fake_bus, config={}) + assert isinstance(adapters, list) + # no opm.gui_adapter plugins installed in the test env -> empty, no raise + assert adapters == [] + + def test_manager_runs_with_zero_adapters(self, fake_bus, message_factory): + """A headless manager (no adapters) dispatches as a no-op, no crash.""" + manager = NamespaceManager(fake_bus, adapters=[]) + msg = message_factory["page_show"](["SYSTEM_text"], "test.skill") + manager.handle_show_page(msg) # must not raise + assert manager.get_active_namespace("default").skill_id == "test.skill" + + +class TestDispatch: + """Template + data dispatch to a concrete adapter.""" + + def test_show_page_dispatches_template(self, manager, recording_adapter, message_factory): + manager.handle_show_page( + message_factory["page_show"](["SYSTEM_weather"], "weather.skill")) + weather = recording_adapter.calls_of("weather") + assert len(weather) == 1 + # (kind, skill_id, data, session_id) + assert weather[0][1] == "weather.skill" + assert weather[0][3] == "default" + + def test_show_page_activates_namespace_on_adapter(self, manager, recording_adapter, message_factory): + manager.handle_show_page( + message_factory["page_show"](["SYSTEM_text"], "text.skill")) + activated = recording_adapter.calls_of("activated") + assert ("activated", "text.skill", "default") in activated + + def test_set_value_forwards_session_update(self, manager, recording_adapter, message_factory): + manager.handle_show_page( + message_factory["page_show"](["SYSTEM_weather"], "weather.skill")) + manager.handle_set_value( + message_factory["set_value"]("weather.skill", "temp", 22)) + + updates = recording_adapter.calls_of("session_update") + assert updates + kind, skill_id, data, session_id = updates[-1] + assert skill_id == "weather.skill" + assert data == {"temp": 22} # reserved keys stripped + assert session_id == "default" + + def test_status_event_forwarded(self, manager, recording_adapter): + manager.forward_to_gui(Message("recognizer_loop:wakeword", data={"x": 1})) + status = recording_adapter.calls_of("status") + assert status + assert status[-1][1] == "recognizer_loop:wakeword" + assert status[-1][3] == "default" + + def test_clear_namespace_deactivates_on_adapter(self, manager, recording_adapter, message_factory): + manager.handle_show_page( + message_factory["page_show"](["SYSTEM_text"], "text.skill")) + manager.handle_clear_namespace( + message_factory["clear_namespace"]("text.skill")) + + deactivated = recording_adapter.calls_of("deactivated") + assert ("deactivated", "text.skill", "default") in deactivated + assert manager.get_active_namespace("default") is None + + +class TestSessionRouting: + """session_id is the sole routing key (B6/B7/B8 collapse).""" + + def test_default_session_routing(self, manager, recording_adapter, message_factory): + manager.handle_show_page( + message_factory["page_show"](["SYSTEM_text"], "test.skill", session_id="default")) + assert recording_adapter.calls_of("text")[0][3] == "default" + + def test_uuid_session_routing(self, manager, recording_adapter, message_factory): + sid = str(uuid.uuid4()) + manager.handle_show_page( + message_factory["page_show"](["SYSTEM_weather"], "weather.skill", session_id=sid)) + weather = recording_adapter.calls_of("weather") + assert weather[0][3] == sid + # the namespace lives under that exact session_id + assert manager.get_active_namespace(sid).skill_id == "weather.skill" + assert manager.get_active_namespace("default") is None + + def test_two_clients_sharing_session_get_same_dispatch(self, manager, recording_adapter, message_factory): + """Two clients that share a session_id share one dispatch/state.""" + shared = "living-room" + # client A shows weather + manager.handle_show_page( + message_factory["page_show"](["SYSTEM_weather"], "weather.skill", session_id=shared)) + # client B (same session_id) pushes a data update + manager.handle_set_value( + message_factory["set_value"]("weather.skill", "temp", 19, session_id=shared)) + + # exactly one session exists; both messages routed to it + assert manager.get_all_sessions() == [shared] + assert recording_adapter.calls_of("weather")[0][3] == shared + assert recording_adapter.calls_of("session_update")[-1][3] == shared + assert manager.get_namespace_data("weather.skill", shared)["temp"] == 19 + + def test_distinct_sessions_isolated(self, manager, message_factory): + manager.handle_show_page( + message_factory["page_show"](["SYSTEM_weather"], "weather.skill", session_id="kitchen")) + manager.handle_show_page( + message_factory["page_show"](["SYSTEM_clock"], "clock.skill", session_id="bedroom")) + + assert manager.get_active_namespace("kitchen").skill_id == "weather.skill" + assert manager.get_active_namespace("bedroom").skill_id == "clock.skill" + + +class TestMultiAdapter: + """Fan-out to every adapter + failure isolation.""" + + def test_fanout_to_all_adapters(self, fake_bus, recording_adapter, second_adapter, message_factory): + manager = NamespaceManager(fake_bus, adapters=[recording_adapter, second_adapter]) + manager.handle_show_page( + message_factory["page_show"](["SYSTEM_weather"], "weather.skill")) + + assert len(recording_adapter.calls_of("weather")) == 1 + assert len(second_adapter.calls_of("weather")) == 1 + + def test_one_adapter_failure_does_not_block_others(self, fake_bus, message_factory): + good = RecordingGUIPlugin() + bad = ExplodingGUIPlugin() + # bad adapter first: its raise must not stop the good adapter + manager = NamespaceManager(fake_bus, adapters=[bad, good]) + + manager.handle_show_page( + message_factory["page_show"](["SYSTEM_weather"], "weather.skill")) + + # good adapter still received the template + activation + assert len(good.calls_of("weather")) == 1 + assert good.calls_of("activated") + # the namespace is still created (service did not crash) + assert manager.get_active_namespace("default").skill_id == "weather.skill" + + +class TestRemovedPageDeletePath: + """B4: page-delete handlers were removed; emitting them is a harmless no-op.""" + + def test_page_delete_message_does_not_crash(self, manager): + """No handler is registered for gui.page.delete; emitting it is inert.""" + bus = manager.core_bus + # nothing is subscribed to these msg types -> emit is a no-op, no AttributeError + bus.emit(Message("gui.page.delete", data={"__from": "x", "page_names": ["p"]})) + bus.emit(Message("gui.page.delete.all", data={"__from": "x"})) + # manager has no such handlers (dead page model removed) + assert not hasattr(manager, "handle_delete_page") + assert not hasattr(manager, "handle_delete_all_pages") diff --git a/test/end2end/test_apiclient_integration.py b/test/end2end/test_apiclient_integration.py new file mode 100644 index 0000000..f8bd85a --- /dev/null +++ b/test/end2end/test_apiclient_integration.py @@ -0,0 +1,67 @@ +# Copyright 2026 OpenVoiceOS Contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Full-path integration: skill-side GUIInterface -> ovos-gui -> adapter. + +The other end2end tests hand-build ``gui.page.show`` / ``gui.value.set`` +messages. This module instead drives a *real* :class:`GUIInterface` from the +standalone ``ovos-gui-api-client`` package, so the wire format the skill side +actually emits is proven to match what ``NamespaceManager`` parses and dispatches +to adapters. If the two packages ever drift, these tests fail. +""" +import pytest + +# ovos-gui-api-client is a required test dependency (declared in the `test` +# extra and git-installed in CI) — imported directly, never skipped, so a real +# wire-format regression always fails the suite. +from ovos_gui_api_client import GUIInterface + +SKILL_ID = "integration.test.skill" + + +@pytest.fixture +def gui(fake_bus): + """A real skill-side GUIInterface bound to the shared FakeBus.""" + return GUIInterface(SKILL_ID, bus=fake_bus) + + +def test_show_weather_reaches_adapter(manager, recording_adapter, gui): + # a skill calling the typed template method... + gui.show_weather(current_temp=22, min_temp=15, max_temp=25, condition="Sunny", location="Lisbon") + # ...reaches the adapter's matching handler via ovos-gui dispatch + weather = recording_adapter.calls_of("weather") + assert weather, f"adapter never received weather; calls={recording_adapter.calls}" + _, skill_id, data, session_id = weather[-1] + assert skill_id == SKILL_ID + assert session_id == "default" + # the session data the skill set is carried through to the adapter + assert data.get("current_temp") == 22 + assert data.get("condition") == "Sunny" + + +def test_show_text_reaches_adapter(manager, recording_adapter, gui): + gui.show_text("hello world", title="greeting") + text = recording_adapter.calls_of("text") + assert text, f"adapter never received text; calls={recording_adapter.calls}" + _, skill_id, data, session_id = text[-1] + assert skill_id == SKILL_ID + assert "hello world" in str(data.values()) + + +def test_fanout_from_real_interface(fake_bus, recording_adapter, second_adapter, gui): + # both installed adapters receive the template a single skill call produced + from ovos_gui.namespace import NamespaceManager + NamespaceManager(fake_bus, adapters=[recording_adapter, second_adapter]) + gui.show_weather(current_temp=15, min_temp=10, max_temp=18, condition="Cloudy") + assert recording_adapter.calls_of("weather") + assert second_adapter.calls_of("weather") diff --git a/test/unittests/test_bus.py b/test/unittests/test_bus.py deleted file mode 100644 index d8469e7..0000000 --- a/test/unittests/test_bus.py +++ /dev/null @@ -1,165 +0,0 @@ -import unittest -from unittest.mock import patch, Mock -from typing import List -import ovos_gui.bus - - -class TestBus(unittest.TestCase): - @patch("ovos_gui.bus.Configuration") - def test_get_gui_websocket_config(self, configuration): - from ovos_gui.bus import get_gui_websocket_config - - mock_config = {'gui_websocket': {'host': 'test', 'port': 80}} - configuration.return_value = mock_config - - config = get_gui_websocket_config() - self.assertEqual(config, mock_config['gui_websocket']) - - configuration.return_value = dict() - with self.assertRaises(KeyError): - get_gui_websocket_config() - - @patch("ovos_gui.bus.Application.listen") - @patch("ovos_gui.bus.create_daemon") - @patch("ovos_gui.bus.ioloop") - def test_create_gui_service(self, ioloop, create_daemon, listen): - from ovos_gui.bus import create_gui_service - ioloop_instance = Mock() - ioloop.IOLoop.instance.return_value = ioloop_instance - mock_nsmanager = Mock() - application = create_gui_service(mock_nsmanager) - create_daemon.assert_called_once_with(ioloop_instance.start) - listen.assert_called_once() - self.assertEqual(application.settings.get("namespace_manager"), - mock_nsmanager) - - @patch("ovos_gui.bus.GUIWebsocketHandler") - def test_send_message_to_gui(self, handler): - from ovos_gui.bus import send_message_to_gui - mock_client = Mock() - handler.clients = [mock_client] - message = {"test": True} - - send_message_to_gui(message) - mock_client.send.assert_called_once_with(message) - - @patch("ovos_gui.bus.GUIWebsocketHandler") - def test_determine_if_gui_connected(self, handler): - from ovos_gui.bus import determine_if_gui_connected - mock_client = Mock() - self.assertFalse(determine_if_gui_connected()) - handler.clients = [mock_client] - self.assertTrue(determine_if_gui_connected()) - - -class TestGUIWebsocketHandler(unittest.TestCase): - mock_nsmanager = Mock() - - class WebSocketMock: - def __init__(self, *args, **kwargs): - ns_manager = TestGUIWebsocketHandler.mock_nsmanager - application_mock = Mock() - application_mock.settings = {"namespace_manager": ns_manager} - self.application = application_mock - - @classmethod - def setUpClass(cls): - from ovos_gui.bus import GUIWebsocketHandler - ovos_gui.bus.WebSocketHandler = cls.WebSocketMock - cls.handler = GUIWebsocketHandler() - - def test_00_websocket_init(self): - self.assertEqual(self.handler.framework, "qt5") - self.assertEqual(self.handler.ns_manager, self.mock_nsmanager) - - def test_on_open(self): - # TODO - pass - - def test_on_close(self): - # TODO - pass - - def _get_client_pages(self, namespace) -> List[str]: - """ - Get a list of client page URLs for the given namespace - @param namespace: Namespace to get pages for - @return: list of page URIs for this GUI Client - """ - client_pages = [] - for page in namespace.pages: - # NOTE: in here page is resolved to a full URI (path) - uri = page.get_uri("qt5") - client_pages.append(uri) - return client_pages - - def test_get_client_pages(self): - from ovos_gui.namespace import Namespace - test_namespace = Namespace("test") - page_1 = Mock() - page_1.get_uri.return_value = "page_1_uri" - page_2 = Mock() - page_2.get_uri.return_value = "page_2_uri" - test_namespace.pages = [page_1, page_2] - - pages = self._get_client_pages(test_namespace) - page_1.get_uri.assert_called_once_with(self.handler.framework) - page_2.get_uri.assert_called_once_with(self.handler.framework) - self.assertEqual(pages, ["page_1_uri", "page_2_uri"]) - - - def test_synchronize(self): - # TODO - pass - - def test_on_message(self): - # TODO - pass - - def test_write_message(self): - # TODO - pass - - def test_send_gui_pages(self): - real_send = self.handler.send - self.handler.send = Mock() - test_ns = "test_namespace" - test_pos = 0 - - from ovos_gui.page import GuiPage - page_1 = GuiPage("p1", "", False, False) - page_1.get_uri = Mock(return_value="page_1") - - page_2 = GuiPage("p2", "", False, False) - page_2.get_uri = Mock(return_value="page_2") - - self.handler._framework = "qt5" - self.handler.send_gui_pages([page_1, page_2], test_ns, test_pos) - page_1.get_uri.assert_called_once_with("qt5") - page_2.get_uri.assert_called_once_with("qt5") - self.handler.send.assert_called_once_with( - {"type": "mycroft.gui.list.insert", - "namespace": test_ns, - "position": test_pos, - "data": [{"url": "page_1", "page": "p1"}, {"url": "page_2", "page": "p2"}]}) - - self.handler._framework = "qt6" - test_pos = 3 - self.handler.send_gui_pages([page_2, page_1], test_ns, test_pos) - page_1.get_uri.assert_called_with("qt6") - page_2.get_uri.assert_called_with("qt6") - self.handler.send.assert_called_with( - {"type": "mycroft.gui.list.insert", - "namespace": test_ns, - "position": test_pos, - "data": [{"url": "page_2", "page": "p2"}, {"url": "page_1", "page": "p1"}]}) - - self.handler.send = real_send - - def test_send(self): - # TODO - pass - - def test_check_origin(self): - self.assertTrue(self.handler.check_origin("test")) - self.assertTrue(self.handler.check_origin("")) diff --git a/test/unittests/test_extensions.py b/test/unittests/test_extensions.py deleted file mode 100644 index 1cd5b48..0000000 --- a/test/unittests/test_extensions.py +++ /dev/null @@ -1,55 +0,0 @@ -import unittest -from unittest.mock import patch, Mock - -import ovos_gui.extensions -from ovos_utils.fakebus import FakeBus -from ovos_gui.homescreen import HomescreenManager -from ovos_gui.extensions import ExtensionsManager -from .mocks import base_config - -PATCH_MODULE = "ovos_gui.extensions" - -_MOCK_CONFIG = base_config() -_MOCK_CONFIG.merge( - { - 'gui': { - 'extension': 'generic', - 'generic': { - 'homescreen_supported': False - } - } - }) - - -class TestExtensionManager(unittest.TestCase): - bus = FakeBus() - name = "TestManager" - - @classmethod - def setUpClass(cls) -> None: - - ovos_gui.extensions.Configuration = Mock(return_value=_MOCK_CONFIG) - - cls.extension_manager = ExtensionsManager(cls.name, cls.bus) - - def test_00_extensions_manager_init(self): - self.assertEqual(self.extension_manager.name, self.name) - self.assertEqual(self.extension_manager.bus, self.bus) - self.assertIsInstance(self.extension_manager.homescreen_manager, HomescreenManager) - self.assertEqual(self.extension_manager.homescreen_manager.bus, self.bus) - self.assertIsInstance(self.extension_manager.active_extension, str) - - @patch("ovos_gui.extensions.OVOSGuiFactory.create") - def test_activate_extension(self, create): - mock_extension = Mock() - mock_extension.preload_gui = False - mock_extension.permanent = True - # TODO: Test preload/permanent combinations - create.return_value = mock_extension - self.extension_manager.activate_extension("smartspeaker") - create.assert_called_once() - # TODO: Check call for mapped plugin name - self.assertEqual(self.extension_manager.extension, mock_extension) - mock_extension.bind_homescreen.assert_called_once() - # TODO: Test messagebus Messages - diff --git a/test/unittests/test_homescreen.py b/test/unittests/test_homescreen.py deleted file mode 100644 index 13fa33a..0000000 --- a/test/unittests/test_homescreen.py +++ /dev/null @@ -1,72 +0,0 @@ -import unittest -from unittest.mock import patch - -from ovos_bus_client.message import Message -from ovos_utils.fakebus import FakeBus -from ovos_gui.namespace import NamespaceManager - - -class TestHomescreenManager(unittest.TestCase): - from ovos_gui.homescreen import HomescreenManager - bus = FakeBus() - homescreen_manager = HomescreenManager(bus) - - def test_00_homescreen_manager_init(self): - self.assertEqual(self.homescreen_manager.bus, self.bus) - self.assertIsInstance(self.homescreen_manager.homescreens, list) - # TODO: Test messagebus handlers - - def test_add_homescreen(self): - # TODO - pass - - def test_remove_homescreen(self): - # TODO - pass - - def test_get_homescreen(self): - # TODO - pass - - def test_handle_get_active_homescreen(self): - # TODO - pass - - def test_handle_set_active_homescreen(self): - # TODO - pass - - @patch("ovos_gui.homescreen.Configuration") - def test_get_active_homescreen(self, config): - config.return_value = {"gui": {"idle_display_skill": "test"}} - self.assertIsNone(self.homescreen_manager.get_active_homescreen()) - # TODO: Mock `homescreens` and get a value here - - @patch("ovos_gui.homescreen.update_mycroft_config") - def test_set_active_homescreen(self, update_config): - test_id = "test_homescreen_id" - self.homescreen_manager.set_active_homescreen(test_id) - update_config.assert_called_once_with( - {"gui": {"idle_display_skill": test_id}}, - bus=self.homescreen_manager.bus) - - def test_reload_homescreens_list(self): - # TODO - pass - - def test_show_homescreen_on_add(self): - # TODO - pass - - @patch("ovos_gui.homescreen.Configuration") - @patch("ovos_gui.homescreen.update_mycroft_config") - def test_disable_active_homescreen(self, update_config, config): - config.return_value = {"gui": {"idle_display_skill": "test"}} - self.homescreen_manager.disable_active_homescreen(Message("")) - update_config.assert_called_once_with( - {"gui": {"idle_display_skill": None}}, - bus=self.homescreen_manager.bus) - - def test_show_homescreen(self): - # TODO - pass diff --git a/test/unittests/test_main.py b/test/unittests/test_main.py new file mode 100644 index 0000000..6ded302 --- /dev/null +++ b/test/unittests/test_main.py @@ -0,0 +1,139 @@ +import unittest +from unittest import mock + + +class TestMainCallbacks(unittest.TestCase): + """Test __main__ module-level callback functions.""" + + def test_on_ready(self): + """Test on_ready callback logs message.""" + from ovos_gui.__main__ import on_ready + with mock.patch('ovos_gui.__main__.LOG') as mock_log: + on_ready() + mock_log.info.assert_called_once() + + def test_on_stopping(self): + """Test on_stopping callback logs message.""" + from ovos_gui.__main__ import on_stopping + with mock.patch('ovos_gui.__main__.LOG') as mock_log: + on_stopping() + mock_log.info.assert_called_once() + + def test_on_error_default(self): + """Test on_error callback with default parameter.""" + from ovos_gui.__main__ import on_error + with mock.patch('ovos_gui.__main__.LOG') as mock_log: + on_error() + mock_log.error.assert_called_once() + + def test_on_error_with_exception(self): + """Test on_error callback with exception.""" + from ovos_gui.__main__ import on_error + error = RuntimeError("Test error") + with mock.patch('ovos_gui.__main__.LOG') as mock_log: + on_error(error) + mock_log.error.assert_called_once() + + +class TestMain(unittest.TestCase): + """Test __main__ main() function.""" + + def test_main_default_callbacks(self): + """Test main() with default callbacks.""" + from ovos_gui.__main__ import main + with mock.patch('ovos_gui.__main__.init_service_logger'), \ + mock.patch('ovos_gui.__main__.setup_locale'), \ + mock.patch('ovos_gui.__main__.GUIService') as mock_gui_service, \ + mock.patch('ovos_gui.__main__.wait_for_exit_signal'), \ + mock.patch('ovos_gui.__main__.LOG'): + mock_service_instance = mock.MagicMock() + mock_gui_service.return_value = mock_service_instance + + main() + + mock_gui_service.assert_called_once() + mock_service_instance.run.assert_called_once() + mock_service_instance.stop.assert_called_once() + + def test_main_custom_callbacks(self): + """Test main() with custom callbacks.""" + from ovos_gui.__main__ import main + ready_hook = mock.Mock() + error_hook = mock.Mock() + stopping_hook = mock.Mock() + + with mock.patch('ovos_gui.__main__.init_service_logger'), \ + mock.patch('ovos_gui.__main__.setup_locale'), \ + mock.patch('ovos_gui.__main__.GUIService') as mock_gui_service, \ + mock.patch('ovos_gui.__main__.wait_for_exit_signal'), \ + mock.patch('ovos_gui.__main__.LOG'): + mock_service_instance = mock.MagicMock() + mock_gui_service.return_value = mock_service_instance + + main(ready_hook=ready_hook, error_hook=error_hook, stopping_hook=stopping_hook) + + ready_hook.assert_called_once() + error_hook.assert_not_called() + stopping_hook.assert_called_once() + + def test_main_exception_handling(self): + """Test main() exception handling calls error_hook.""" + from ovos_gui.__main__ import main + error_hook = mock.Mock() + + with mock.patch('ovos_gui.__main__.init_service_logger'), \ + mock.patch('ovos_gui.__main__.setup_locale'), \ + mock.patch('ovos_gui.__main__.GUIService') as mock_gui_service, \ + mock.patch('ovos_gui.__main__.LOG'): + mock_service_instance = mock.MagicMock() + mock_service_instance.run.side_effect = RuntimeError("Service error") + mock_gui_service.return_value = mock_service_instance + + main(error_hook=error_hook) + + error_hook.assert_called_once() + + def test_main_initializes_logger(self): + """Test main() initializes service logger.""" + from ovos_gui.__main__ import main + with mock.patch('ovos_gui.__main__.init_service_logger') as mock_init_logger, \ + mock.patch('ovos_gui.__main__.setup_locale'), \ + mock.patch('ovos_gui.__main__.GUIService') as mock_gui_service, \ + mock.patch('ovos_gui.__main__.wait_for_exit_signal'), \ + mock.patch('ovos_gui.__main__.LOG'): + mock_service_instance = mock.MagicMock() + mock_gui_service.return_value = mock_service_instance + + main() + + mock_init_logger.assert_called_once_with("gui") + + def test_main_sets_up_locale(self): + """Test main() sets up locale.""" + from ovos_gui.__main__ import main + with mock.patch('ovos_gui.__main__.init_service_logger'), \ + mock.patch('ovos_gui.__main__.setup_locale') as mock_setup_locale, \ + mock.patch('ovos_gui.__main__.GUIService') as mock_gui_service, \ + mock.patch('ovos_gui.__main__.wait_for_exit_signal'), \ + mock.patch('ovos_gui.__main__.LOG'): + mock_service_instance = mock.MagicMock() + mock_gui_service.return_value = mock_service_instance + + main() + + mock_setup_locale.assert_called_once() + + def test_main_waits_for_exit_signal(self): + """Test main() waits for exit signal.""" + from ovos_gui.__main__ import main + with mock.patch('ovos_gui.__main__.init_service_logger'), \ + mock.patch('ovos_gui.__main__.setup_locale'), \ + mock.patch('ovos_gui.__main__.GUIService') as mock_gui_service, \ + mock.patch('ovos_gui.__main__.wait_for_exit_signal') as mock_wait, \ + mock.patch('ovos_gui.__main__.LOG'): + mock_service_instance = mock.MagicMock() + mock_gui_service.return_value = mock_service_instance + + main() + + mock_wait.assert_called_once() diff --git a/test/unittests/test_namespace.py b/test/unittests/test_namespace.py index 52bfafa..eb0667a 100644 --- a/test/unittests/test_namespace.py +++ b/test/unittests/test_namespace.py @@ -12,379 +12,117 @@ # See the License for the specific language governing permissions and # limitations under the License. # -"""Tests for the GUI namespace helper class.""" -from os.path import join, isdir, isfile -from shutil import rmtree +"""Tests for the GUI namespace helper classes (session_id-only routing).""" from unittest import TestCase, mock from unittest.mock import Mock from ovos_bus_client.message import Message -from ovos_bus_client.apis.gui import get_xdg_cache_save_path from ovos_utils.fakebus import FakeBus -from ovos_gui.namespace import Namespace, _validate_page_message -from ovos_gui.page import GuiPage - -GUI_CACHE_PATH = get_xdg_cache_save_path('ovos_gui') +from ovos_gui.namespace import Namespace, NamespaceManager, _validate_page_message PATCH_MODULE = "ovos_gui.namespace" class TestNamespaceFunctions(TestCase): def test_validate_page_message(self): - """Test _validate_page_message function with valid and invalid messages.""" - # Valid message + """Test _validate_page_message function.""" valid_msg = Message("gui.page.show", data={ - "page_names": ["page1"], "__from": "skill_id" + "page_names": ["SYSTEM_weather"], "__from": "skill_id", "__idle": 30 }) self.assertTrue(_validate_page_message(valid_msg)) # Invalid: missing page_names - invalid1 = Message("gui.page.show", data={"__from": "skill_id"}) + invalid1 = Message("gui.page.show", data={"__from": "skill_id", "__idle": 30}) self.assertFalse(_validate_page_message(invalid1)) # Invalid: missing __from - invalid2 = Message("gui.page.show", data={"page_names": ["page1"]}) + invalid2 = Message("gui.page.show", data={"page_names": ["SYSTEM_weather"], "__idle": 30}) self.assertFalse(_validate_page_message(invalid2)) - # Invalid: page_names not a list - invalid3 = Message("gui.page.show", data={ - "page_names": "page1", "__from": "skill_id" - }) - self.assertFalse(_validate_page_message(invalid3)) - - def test_get_idle_display_config(self): - """Test idle display configuration handling.""" - ns = Namespace("idleDisplaySkill") - ns.load_pages([GuiPage(name="idle", persistent=True, duration=0)]) - ns.set_persistence("idleDisplaySkill") - self.assertTrue(ns.persistent) - self.assertEqual(ns.duration, 0) - - def test_get_active_gui_extension(self): - """Test retrieval of active GUI extensions/pages.""" - ns = Namespace("test_skill") - pages = [ - GuiPage(name="page1", persistent=False, duration=30), - GuiPage(name="page2", persistent=False, duration=30), - ] - ns.load_pages(pages) - self.assertEqual(ns.active_page.name, "page1") - self.assertEqual(len(ns.pages), 2) + # Missing __idle is NOT a validation error (only page_names and __from) + invalid3 = Message("gui.page.show", data={"page_names": ["SYSTEM_weather"], "__from": "skill_id"}) + self.assertTrue(_validate_page_message(invalid3)) class TestNamespace(TestCase): + """Tests for Namespace class.""" + def setUp(self): - self.namespace = Namespace("foo") - - def test_init_gui_file_share(self): - # TODO: Test init with/without server and host config - pass - - def test_add(self): - add_namespace_message = dict( - type="mycroft.session.list.insert", - namespace="mycroft.system.active_skills", - position=0, - data=[dict(skill_id="foo")] - ) - with mock.patch(PATCH_MODULE + ".send_message_to_gui") as send_mock: + self.namespace = Namespace("test_skill") + + def test_namespace_initialization(self): + ns = Namespace("foo_skill") + self.assertEqual(ns.skill_id, "foo_skill") + self.assertFalse(ns.persistent) + self.assertEqual(ns.duration, 30) + self.assertEqual(ns.data, {}) + self.assertFalse(ns.session_set) + + def test_namespace_add(self): + with mock.patch(f'{PATCH_MODULE}.LOG'): self.namespace.add() - send_mock.assert_called_with(add_namespace_message) - - def test_activate(self): - self.namespace.load_pages([ - GuiPage(name="foo", persistent=False, duration=False), - GuiPage(name="bar", persistent=False, duration=False), - GuiPage(name="foobar", persistent=False, duration=False), - GuiPage(name="baz", persistent=False, duration=False), - GuiPage(name="foobaz", persistent=False, duration=False) - ]) - activate_namespace_message = { - "type": "mycroft.session.list.move", - "namespace": "mycroft.system.active_skills", - "from": 5, - "to": 0, - "items_number": 1 - } - with mock.patch(PATCH_MODULE + ".send_message_to_gui") as send_mock: - self.namespace.activate(position=5) - send_mock.assert_called_with(activate_namespace_message) - - def test_remove(self): - self.namespace.data = dict(foo="bar") - self.namespace.pages = ["foo", "bar"] - remove_namespace_message = dict( - type="mycroft.session.list.remove", - namespace="mycroft.system.active_skills", - position=3, - items_number=1 - ) - with mock.patch(PATCH_MODULE + ".send_message_to_gui") as send_mock: - self.namespace.remove(position=3) - send_mock.assert_called_with(remove_namespace_message) - - self.assertFalse(self.namespace.data) - self.assertFalse(self.namespace.pages) - - def test_load_data(self): - load_data_message = dict( - type="mycroft.session.set", - namespace="foo", - data=dict(foo="bar") - ) - with mock.patch(PATCH_MODULE + ".send_message_to_gui") as send_mock: - self.namespace.load_data(name="foo", value="bar") - send_mock.assert_called_with(load_data_message) - def test_unload_data(self): - """Test unload_data method removes data from namespace.""" + def test_namespace_activate(self): + with mock.patch(f'{PATCH_MODULE}.LOG'): + self.namespace.activate(position=0) + + def test_namespace_remove(self): self.namespace.data = {"key1": "value1", "key2": "value2"} - with mock.patch(PATCH_MODULE + ".send_message_to_gui") as send_mock: + with mock.patch(f'{PATCH_MODULE}.LOG'): + self.namespace.remove(position=0) + self.assertEqual(self.namespace.data, {}) + + def test_namespace_load_data(self): + with mock.patch(f'{PATCH_MODULE}.LOG'): + self.namespace.load_data("foo", "bar") + + def test_namespace_unload_data(self): + self.namespace.data = {"key1": "value1", "key2": "value2"} + with mock.patch(f'{PATCH_MODULE}.LOG'): self.namespace.unload_data("key1") - # Verify message was sent - call_args = send_mock.call_args[0][0] - self.assertEqual(call_args["type"], "mycroft.session.delete") - self.assertEqual(call_args["property"], "key1") + self.assertNotIn("key1", self.namespace.data) + self.assertIn("key2", self.namespace.data) - def test_get_position_of_last_item_in_data(self): - """Test getting position of last item in data.""" + def test_namespace_get_position_of_last_item(self): self.namespace.data = {"key1": "val1", "key2": "val2", "key3": "val3"} - position = self.namespace.get_position_of_last_item_in_data() - self.assertEqual(position, 2) - + self.assertEqual(self.namespace.get_position_of_last_item_in_data(), 2) self.namespace.data = {} - position = self.namespace.get_position_of_last_item_in_data() - self.assertEqual(position, -1) + self.assertEqual(self.namespace.get_position_of_last_item_in_data(), -1) - def test_set_persistence_numeric(self): + def test_namespace_set_persistence_generic(self): self.namespace.set_persistence("genericSkill") self.assertEqual(self.namespace.duration, 30) self.assertFalse(self.namespace.persistent) - def test_set_persistence_boolean(self): + def test_namespace_set_persistence_idle(self): self.namespace.set_persistence("idleDisplaySkill") self.assertEqual(self.namespace.duration, 0) self.assertTrue(self.namespace.persistent) - def test_set_persistence_from_active_page_non_persistent(self): - """Test set_persistence uses active page when it's non-persistent.""" - page = GuiPage(name="test", persistent=False, duration=15) - self.namespace.pages = [page] - self.namespace.page_number = 0 - self.namespace.set_persistence(None) - # Should use the active page's settings - self.assertFalse(self.namespace.persistent) - self.assertEqual(self.namespace.duration, 15) - - def test_set_persistence_from_active_page_persistent(self): - """Test set_persistence uses active page when it's persistent.""" - page = GuiPage(name="test", persistent=True, duration=0) - self.namespace.pages = [page] - self.namespace.page_number = 0 - self.namespace.set_persistence(None) - # Should use the active page's settings - self.assertTrue(self.namespace.persistent) - self.assertEqual(self.namespace.duration, 0) - - def test_set_persistence_no_active_page(self): - """Test set_persistence defaults when no active page.""" - # No pages loaded, should default to 30 seconds - self.namespace.set_persistence(None) - self.assertFalse(self.namespace.persistent) - self.assertEqual(self.namespace.duration, 30) - - def test_load_pages_new(self): - self.namespace.pages = [GuiPage(name="foo", persistent=True, duration=0), - GuiPage(name="bar", persistent=False, duration=30)] - new_pages = [GuiPage(name="foobar", persistent=False, duration=30)] - load_page_message = dict( - type="mycroft.events.triggered", - namespace="foo", - event_name="page_gained_focus", - data=dict(number=2) - ) - show_index = None - with mock.patch(PATCH_MODULE + ".send_message_to_gui") as send_mock: - self.namespace.load_pages(new_pages, show_index) - send_mock.assert_called_with(load_page_message) - self.assertListEqual(self.namespace.pages, self.namespace.pages) - - def test_load_pages_empty(self): - """Test load_pages with empty page list.""" - with mock.patch(PATCH_MODULE + ".send_message_to_gui") as send_mock: - # Should handle gracefully when pages list is empty - self.namespace.load_pages([]) - # Should not send any message when pages is empty - send_mock.assert_not_called() - - def test_load_pages_none_show_index(self): - """Test load_pages with show_index=None (defaults to 0).""" - pages = [ - GuiPage(name="page1", persistent=False, duration=30), - GuiPage(name="page2", persistent=False, duration=30), - ] - with mock.patch(PATCH_MODULE + ".send_message_to_gui") as send_mock: - # Pass None as show_index, should default to 0 - self.namespace.load_pages(pages, show_index=None) - # Should send activation message for page at index 0 - send_mock.assert_called() - - def test_focus_page_missing_page(self): - """Test focus_page when page is not in pages list.""" - page1 = GuiPage(name="page1", persistent=False, duration=30) - page2 = GuiPage(name="page2", persistent=False, duration=30) - self.namespace.pages = [page1] - # Focus on a page that's not in the list - missing_page = GuiPage(name="missing", persistent=False, duration=30) - self.namespace.focus_page(missing_page) - # Should insert the missing page at index 0 - self.assertEqual(self.namespace.pages[0].name, "missing") - self.assertEqual(len(self.namespace.pages), 2) - - def test_load_pages_existing(self): - self.namespace.pages = [GuiPage(name="foo", persistent=True, duration=0), - GuiPage(name="bar", persistent=False, duration=30)] - new_pages = [GuiPage(name="foo", persistent=True, duration=0)] - load_page_message = dict( - type="mycroft.events.triggered", - namespace="foo", - event_name="page_gained_focus", - data=dict(number=0) - ) - show_index = None - with mock.patch(PATCH_MODULE + ".send_message_to_gui") as send_mock: - self.namespace.load_pages(new_pages, show_index) - send_mock.assert_called_with(load_page_message) - self.assertListEqual(self.namespace.pages, self.namespace.pages) - - def test_add_pages(self): - """Test _add_pages internal method.""" - page1 = GuiPage(name="page1", persistent=False, duration=30) - page2 = GuiPage(name="page2", persistent=False, duration=30) - # Pages must exist in the list before calling _add_pages - self.namespace.pages = [page1, page2] - # _add_pages finds position of page2 in the list - self.namespace._add_pages([page2]) - # Verify pages list is unchanged (method is currently a stub) - self.assertEqual(len(self.namespace.pages), 2) - self.assertEqual(self.namespace.pages[1].name, "page2") - - def test_activate_page(self): - """Test _activate_page method for page focus.""" - page1 = GuiPage(name="page1", persistent=False, duration=30) - page2 = GuiPage(name="page2", persistent=False, duration=30) - self.namespace.pages = [page1, page2] - self.namespace.page_number = 0 - - with mock.patch(PATCH_MODULE + ".send_message_to_gui") as send_mock: - self.namespace._activate_page(page2) - # Verify message was sent - self.assertTrue(send_mock.called) - # Verify page number was updated - self.assertEqual(self.namespace.page_number, 1) - - def test_remove_pages(self): - self.namespace.pages = [GuiPage(name="foo", persistent=False, duration=False), - GuiPage(name="bar", persistent=False, duration=False), - GuiPage(name="foobar", persistent=False, duration=False)] - remove_page_message = dict( - type="mycroft.gui.list.remove", - namespace="foo", - position=2, - items_number=1 - ) - with mock.patch(PATCH_MODULE + ".send_message_to_gui") as send_mock: - self.namespace.remove_pages([2]) - send_mock.assert_called_with(remove_page_message) - self.assertListEqual(["foo", "bar"], self.namespace.page_names) - - def test_page_gained_focus(self): - """Test page_gained_focus method.""" - page1 = GuiPage(name="page1", persistent=False, duration=30) - page2 = GuiPage(name="page2", persistent=False, duration=30) - self.namespace.pages = [page1, page2] - self.namespace.page_number = 0 - self.namespace.send_message_to_gui = mock.Mock() - - self.namespace.page_gained_focus(1) - self.assertEqual(self.namespace.page_number, 1) - - def test_page_update_interaction(self): - """Test page interaction updates.""" - page = GuiPage(name="interactive_page", persistent=False, duration=30) - self.namespace.pages = [page] - self.assertEqual(len(self.namespace.pages), 1) - self.assertEqual(self.namespace.pages[0].name, "interactive_page") - - def test_get_page_at_position(self): - """Test retrieving page at specific position.""" - pages = [ - GuiPage(name="page1", persistent=False, duration=30), - GuiPage(name="page2", persistent=False, duration=30), - GuiPage(name="page3", persistent=False, duration=30), - ] - self.namespace.pages = pages - self.assertEqual(self.namespace.pages[0].name, "page1") - self.assertEqual(self.namespace.pages[1].name, "page2") - self.assertEqual(self.namespace.pages[2].name, "page3") - - def test_get_active_page(self): - """Test getting currently active page.""" - page1 = GuiPage(name="page1", persistent=False, duration=30) - page2 = GuiPage(name="page2", persistent=False, duration=30) - self.namespace.pages = [page1, page2] - self.namespace.page_number = 0 - self.assertEqual(self.namespace.active_page.name, "page1") - - self.namespace.page_number = 1 - self.assertEqual(self.namespace.active_page.name, "page2") - - # Out of bounds - self.namespace.page_number = 5 - self.assertIsNone(self.namespace.active_page) - - def test_index_in_pages_list(self): - """Test finding page index in list.""" - page1 = GuiPage(name="page1", persistent=False, duration=30) - page2 = GuiPage(name="page2", persistent=False, duration=30) - pages = [page1, page2] - self.namespace.pages = pages - for i, page in enumerate(pages): - self.assertEqual(self.namespace.pages[i].name, page.name) - - def test_global_back(self): - """Test global back navigation.""" - page1 = GuiPage(name="page1", persistent=False, duration=30) - page2 = GuiPage(name="page2", persistent=False, duration=30) - page3 = GuiPage(name="page3", persistent=False, duration=30) - self.namespace.pages = [page1, page2, page3] - self.namespace.page_number = 2 - self.namespace.send_message_to_gui = mock.Mock() - - self.namespace.global_back() - # After back, should be at page 1 and page 3 removed - self.assertEqual(self.namespace.page_number, 1) - self.assertEqual(len(self.namespace.pages), 2) - class TestNamespaceManager(TestCase): + """Tests for NamespaceManager with session_id-only architecture.""" + def setUp(self): - from ovos_gui.namespace import NamespaceManager - # patch out create_gui_service so we don't bind a real websocket port - # for every test instance (which raises OSError: Address already in use) - with mock.patch(PATCH_MODULE + ".create_gui_service"): - self.namespace_manager = NamespaceManager(FakeBus()) + self.namespace_manager = NamespaceManager(FakeBus()) + + def tearDown(self): + # cancel any pending auto-removal timers so they don't fire post-test + for session in self.namespace_manager.sessions.values(): + for timer in session.remove_namespace_timers.values(): + timer.cancel() def test_handle_clear_namespace_active(self): namespace = Namespace("foo") namespace.remove = mock.Mock() - self.namespace_manager.loaded_namespaces = dict(foo=namespace) - self.namespace_manager.active_namespaces = [namespace] + session = self.namespace_manager.get_session("default") + session.loaded_namespaces = dict(foo=namespace) + session.active_namespaces = [namespace] message = Message("gui.clear.namespace", data={"__from": "foo"}) self.namespace_manager.handle_clear_namespace(message) - namespace.remove.assert_called_with(0) + namespace.remove.assert_called() def test_handle_clear_namespace_inactive(self): message = Message("gui.clear.namespace", data={"__from": "foo"}) @@ -393,295 +131,254 @@ def test_handle_clear_namespace_inactive(self): self.namespace_manager.handle_clear_namespace(message) namespace.remove.assert_not_called() - def test_handle_send_event(self): - message_data = { - "__from": "foo", "event_name": "bar", "params": "foobar" - } - message = Message("gui.clear.namespace", data=message_data) - event_triggered_message = dict( - type='mycroft.events.triggered', - namespace="foo", - event_name="bar", - data="foobar" - ) - with mock.patch(PATCH_MODULE + ".send_message_to_gui") as send_mock: - self.namespace_manager.handle_send_event(message) - send_mock.assert_called_with(event_triggered_message) - - def test_handle_delete_page_active_namespace(self): - namespace = Namespace("foo") - namespace.pages = [GuiPage(name="bar", persistent=True, duration=0)] - namespace.remove_pages = mock.Mock() - self.namespace_manager.loaded_namespaces = dict(foo=namespace) - self.namespace_manager.active_namespaces = [namespace] - - message_data = {"__from": "foo", "page_names": ["bar"]} - message = Message("gui.clear.namespace", data=message_data) - self.namespace_manager.handle_delete_page(message) - namespace.remove_pages.assert_called_with([0]) - - def test_handle_delete_page_inactive_namespace(self): - namespace = Namespace("foo") - namespace.pages = ["bar"] - namespace.remove_pages = mock.Mock() - - message_data = {"__from": "foo", "page": ["bar"]} - message = Message("gui.clear.namespace", data=message_data) - self.namespace_manager.handle_delete_page(message) - namespace.remove_pages.assert_not_called() - - def test_handle_remove_pages(self): - """Test handler for page removal requests.""" - namespace = Namespace("foo") - namespace.pages = [ - GuiPage(name="page1", persistent=False, duration=30), - GuiPage(name="page2", persistent=False, duration=30), - ] - namespace.remove_pages = mock.Mock() - self.namespace_manager.loaded_namespaces = dict(foo=namespace) - self.namespace_manager.active_namespaces = [namespace] - - message_data = {"__from": "foo", "page_names": ["page1"]} - message = Message("gui.page.delete", data=message_data) - self.namespace_manager.handle_delete_page(message) - namespace.remove_pages.assert_called() - def test_parse_persistence(self): - self.assertEqual(self.namespace_manager._parse_persistence(True), - (True, 0)) - self.assertEqual(self.namespace_manager._parse_persistence(False), - (False, 0)) - self.assertEqual(self.namespace_manager._parse_persistence(None), - (False, 30)) - self.assertEqual(self.namespace_manager._parse_persistence(10), - (False, 10)) - self.assertEqual(self.namespace_manager._parse_persistence(1.0), - (False, 1)) + self.assertEqual(self.namespace_manager._parse_persistence(True), (True, 0)) + self.assertEqual(self.namespace_manager._parse_persistence(False), (False, 0)) + self.assertEqual(self.namespace_manager._parse_persistence(None), (False, 30)) + self.assertEqual(self.namespace_manager._parse_persistence(10), (False, 10)) + self.assertEqual(self.namespace_manager._parse_persistence(1.0), (False, 1)) with self.assertRaises(ValueError): self.namespace_manager._parse_persistence(-10) - def test_handle_show_page(self): - real_activate_namespace = self.namespace_manager._activate_namespace - real_load_pages = self.namespace_manager._load_pages - real_update_persistence = self.namespace_manager._update_namespace_persistence - self.namespace_manager._activate_namespace = Mock() - self.namespace_manager._load_pages = Mock() - self.namespace_manager._update_namespace_persistence = Mock() - - # Legacy message - message = Message("gui.page.show", data={"__from": "foo", - "__idle": 10, - "page_names": ["bar", "test/baz"]}) - self.namespace_manager.handle_show_page(message) - self.namespace_manager._activate_namespace.assert_called_with("foo") - self.namespace_manager._load_pages.assert_called_with( - [GuiPage(name='bar', persistent=False, duration=10, namespace='foo'), - GuiPage(name='test/baz', persistent=False, duration=10, namespace='foo')], 0) - self.namespace_manager._update_namespace_persistence. \ - assert_called_with(10) - - # With resource info - self.namespace_manager._activate_namespace.reset_mock() - self.namespace_manager._load_pages.reset_mock() - self.namespace_manager._update_namespace_persistence.reset_mock() - - ui_directories = {"gui": "/tmp/test"} - message = Message("test", {"__from": "skill", - "__idle": False, - "index": 1, - "page_names": ["page_1", "test/page_2"], - "ui_directories": ui_directories}) - self.namespace_manager.handle_show_page(message) - expected_page1 = GuiPage("page_1", False, 0, "skill") - expected_page2 = GuiPage("test/page_2", False, 0, "skill") - self.namespace_manager._activate_namespace.assert_called_with("skill") - self.namespace_manager._load_pages.assert_called_with([expected_page1, - expected_page2], - 1) - self.namespace_manager._update_namespace_persistence. \ - assert_called_with(False) - - # System resources: SYSTEM_ pages are currently handled like any other - # page (there is no special template routing in ovos_gui.namespace). - self.namespace_manager._activate_namespace.reset_mock() - self.namespace_manager._load_pages.reset_mock() - self.namespace_manager._update_namespace_persistence.reset_mock() - - message = Message("test", {"__from": "skill_no_res", - "__idle": True, - "index": 2, - "page": ["/gui/SYSTEM_TextFrame.qml"], - "page_names": ["SYSTEM_TextFrame"]}) - self.namespace_manager.handle_show_page(message) - self.namespace_manager._activate_namespace.assert_called_with("skill_no_res") - # __idle=True -> persistent page (persistent=True, duration=0) - self.namespace_manager._load_pages.assert_called_with( - [GuiPage(name="SYSTEM_TextFrame", persistent=True, duration=0, - namespace="skill_no_res")], 2) - self.namespace_manager._update_namespace_persistence. \ - assert_called_with(True) - - self.namespace_manager._activate_namespace = real_activate_namespace - self.namespace_manager._load_pages = real_load_pages - self.namespace_manager._update_namespace_persistence = \ - real_update_persistence - - def test_handle_show_page_invalid_message(self): - namespace = Namespace("foo") - namespace.load_pages = mock.Mock() - - message_data = {"__from": "foo"} - message = Message("gui.page.show", data=message_data) - self.namespace_manager.send_message_to_gui = mock.Mock() + def test_handle_show_page_template_routing(self): + """SYSTEM_* templates are routed to adapters.""" + self.namespace_manager._dispatch_template_to_adapters = Mock() + message = Message("gui.page.show", data={ + "__from": "test_skill", "__idle": 10, "page_names": ["SYSTEM_weather"] + }) self.namespace_manager.handle_show_page(message) + self.namespace_manager._dispatch_template_to_adapters.assert_called() - self.assertListEqual([], self.namespace_manager.active_namespaces) - self.assertDictEqual({}, self.namespace_manager.loaded_namespaces) + def test_handle_show_page_non_template_rejected(self): + """Non-SYSTEM_* page names are rejected.""" + message = Message("gui.page.show", data={ + "__from": "foo", "__idle": 10, "page_names": ["bar.qml"] + }) + with mock.patch(f'{PATCH_MODULE}.LOG') as mock_log: + self.namespace_manager.handle_show_page(message) + mock_log.error.assert_called() - def test_activate_namespace(self): - """Test activating a namespace.""" - ns = Namespace("test") - self.namespace_manager.loaded_namespaces["test"] = ns - self.assertIn("test", self.namespace_manager.loaded_namespaces) + def test_handle_show_page_invalid_message(self): + message = Message("gui.page.show", data={"__from": "foo"}) + with mock.patch(f'{PATCH_MODULE}.LOG'): + self.namespace_manager.handle_show_page(message) + session = self.namespace_manager.get_session("default") + self.assertListEqual([], session.active_namespaces) + self.assertDictEqual({}, session.loaded_namespaces) def test_ensure_namespace_exists(self): - """Test ensuring namespace exists or is created.""" - ns = self.namespace_manager._ensure_namespace_exists("new_skill") + session = self.namespace_manager.get_session("default") + ns = self.namespace_manager._ensure_namespace_exists("new_skill", session) self.assertIsNotNone(ns) self.assertEqual(ns.skill_id, "new_skill") - self.assertIn("new_skill", self.namespace_manager.loaded_namespaces) - - def test_load_pages(self): - """Test loading pages into a namespace.""" - ns = self.namespace_manager._ensure_namespace_exists("test") - self.assertIsNotNone(ns) + self.assertIn("new_skill", session.loaded_namespaces) def test_update_namespace_persistence(self): - """Test updating namespace persistence.""" ns = Namespace("test") - self.namespace_manager.loaded_namespaces["test"] = ns - ns.set_persistence("genericSkill") + session = self.namespace_manager.get_session("default") + session.loaded_namespaces["test"] = ns + session.active_namespaces = [ns] + with mock.patch(f'{PATCH_MODULE}.LOG'): + self.namespace_manager._update_namespace_persistence(15, session) + self.assertEqual(ns.duration, 15) self.assertFalse(ns.persistent) - self.assertEqual(ns.duration, 30) - - def test_schedule_namespace_removal(self): - """Test scheduling namespace removal.""" - self.assertIsInstance(self.namespace_manager.remove_namespace_timers, dict) - - def test_remove_namespace_via_timer(self): - """Test timer-based removal.""" - self.assertEqual(len(self.namespace_manager.remove_namespace_timers), 0) def test_remove_namespace(self): - """Test removing a namespace.""" ns = Namespace("test") - self.namespace_manager.loaded_namespaces["test"] = ns - self.namespace_manager.active_namespaces.append(ns) - self.assertIn("test", self.namespace_manager.loaded_namespaces) - self.assertIn(ns, self.namespace_manager.active_namespaces) - - def test_emit_namespace_displayed_event(self): - """Test emitting namespace displayed event.""" - self.assertIsNotNone(self.namespace_manager.core_bus) - - def test_handle_status_request(self): - """Test status request handler.""" - message = Message("gui.status.request", data={"__from": "test"}) - # Should not raise exceptions - self.namespace_manager.handle_status_request(message) + session = self.namespace_manager.get_session("default") + session.loaded_namespaces["test"] = ns + session.active_namespaces.append(ns) + with mock.patch(f'{PATCH_MODULE}.LOG'): + self.namespace_manager._remove_namespace("test", session, "default") + self.assertNotIn(ns, session.active_namespaces) def test_handle_set_value(self): - """Test set value handler.""" ns = Namespace("test") - self.namespace_manager.loaded_namespaces["test"] = ns + session = self.namespace_manager.get_session("default") + session.loaded_namespaces["test"] = ns + + mock_adapter = Mock() + self.namespace_manager.adapters = [mock_adapter] + message = Message("gui.value.set", data={"__from": "test", "key": "value"}) - # Should handle gracefully self.namespace_manager.handle_set_value(message) - def test_update_namespace_data(self): - """Test updating namespace data.""" - ns = Namespace("test") - ns.data = {} - self.assertEqual(ns.data, {}) + self.assertEqual(ns.data["key"], "value") + # adapter notified with (skill_id, filtered_data, session_id) -- no __from + mock_adapter.on_session_update.assert_called_once_with( + "test", {"key": "value"}, "default") - def test_handle_client_connected(self): - """Test client connected handler.""" - self.assertIsNotNone(self.namespace_manager.core_bus) + def test_forward_to_gui_status_event(self): + """Status events are forwarded to adapters with the session_id.""" + mock_adapter = mock.Mock() + self.namespace_manager.adapters = [mock_adapter] - def test_handle_page_interaction(self): - """Test page interaction handler.""" - ns = Namespace("test") - ns.page_number = 0 - ns.persistent = True - self.namespace_manager.loaded_namespaces["test"] = ns - message = Message("gui.page_interaction", data={"skill_id": "test", "page_number": 0}) - # Should handle without error - self.namespace_manager.handle_page_interaction(message) - - def test_handle_page_gained_focus(self): - """Test page focus handler.""" - ns = Namespace("test") - self.namespace_manager.loaded_namespaces["test"] = ns - message = Message("gui.page_gained_focus", data={"__from": "test", "page_number": 0}) - # Should handle without error - self.namespace_manager.handle_page_gained_focus(message) + message = Message("test.event", data={"test": "data"}) + with mock.patch(f'{PATCH_MODULE}.LOG'): + self.namespace_manager.forward_to_gui(message) - def test_handle_namespace_global_back(self): - """Test global back handler.""" - ns = Namespace("test") - self.namespace_manager.loaded_namespaces["test"] = ns - self.namespace_manager.active_namespaces.append(ns) - message = Message("mycroft.gui.screen.close", data={"__from": "test"}) - # Should handle without error - self.namespace_manager.handle_namespace_global_back(message) - - def test_del_namespace_in_remove_timers(self): - """Test namespace deletion from timers dict.""" - self.namespace_manager.remove_namespace_timers["test"] = None - self.assertIn("test", self.namespace_manager.remove_namespace_timers) - del self.namespace_manager.remove_namespace_timers["test"] - self.assertNotIn("test", self.namespace_manager.remove_namespace_timers) - - def test_upload_system_resources(self): - # TODO: Test _cache_system_resources when implemented - # This method is referenced in the codebase but not yet implemented - # For now, just verify that NamespaceManager exists and has the expected attributes - self.assertIsNotNone(self.namespace_manager) - self.assertIsNotNone(self.namespace_manager.loaded_namespaces) - self.assertIsNotNone(self.namespace_manager.active_namespaces) + mock_adapter.on_status_event.assert_called_once_with( + "test.event", {"test": "data"}, "default") + + def test_dispatch_template_to_adapters(self): + """Templates are dispatched with session_id only (no site_id).""" + mock_adapter = mock.Mock() + self.namespace_manager.adapters = [mock_adapter] + + self.namespace_manager._dispatch_template_to_adapters( + "SYSTEM_weather", "test_skill", {"current_temp": 22}, "session1" + ) + mock_adapter.dispatch_template.assert_called_once_with( + "SYSTEM_weather", "test_skill", {"current_temp": 22}, "session1" + ) def test_activate_namespace_already_active(self): - """Test activating a namespace that's already in active_namespaces but not at position 0.""" ns = Namespace("existing") - ns.send_message_to_gui = mock.Mock() - # Add namespace to active_namespaces at position 1 other_ns = Namespace("other") - self.namespace_manager.loaded_namespaces["existing"] = ns - self.namespace_manager.loaded_namespaces["other"] = other_ns - self.namespace_manager.active_namespaces = [other_ns, ns] - # Activate the existing namespace (should move to position 0) - self.namespace_manager._activate_namespace("existing") - # Verify it's now at position 0 - self.assertEqual(self.namespace_manager.active_namespaces[0].skill_id, "existing") - - def test_activate_namespace_new(self): - """Test activating a new namespace that doesn't exist yet.""" - ns = Namespace("new_skill") - self.namespace_manager.loaded_namespaces["new_skill"] = ns - # Activate the new namespace - self.namespace_manager._activate_namespace("new_skill") - # Verify it's now active - self.assertIn(ns, self.namespace_manager.active_namespaces) - self.assertEqual(self.namespace_manager.active_namespaces[0].skill_id, "new_skill") - - def test_remove_namespace_with_timer(self): - """Test removing a namespace that has an active removal timer.""" - ns = Namespace("test") - self.namespace_manager.loaded_namespaces["test"] = ns - self.namespace_manager.active_namespaces = [ns] - # Add a mock timer for this namespace - self.namespace_manager.remove_namespace_timers["test"] = mock.Mock() - # Remove the namespace - self.namespace_manager._remove_namespace("test") - # Verify namespace is removed from active_namespaces - self.assertNotIn(ns, self.namespace_manager.active_namespaces) + session = self.namespace_manager.get_session("default") + session.loaded_namespaces["existing"] = ns + session.loaded_namespaces["other"] = other_ns + session.active_namespaces = [other_ns, ns] + + with mock.patch(f'{PATCH_MODULE}.LOG'): + self.namespace_manager._activate_namespace("existing", session, "default") + self.assertEqual(session.active_namespaces[0].skill_id, "existing") + + def test_session_id_extraction(self): + """The routing key is the session_id; default when absent.""" + self.assertEqual(self.namespace_manager._session_id(Message("test")), "default") + msg = Message("test", context={"session": {"session_id": "sid1"}}) + self.assertEqual(self.namespace_manager._session_id(msg), "sid1") + # missing/None session_id falls back to "default" + msg2 = Message("test", context={"session": {}}) + self.assertEqual(self.namespace_manager._session_id(msg2), "default") + + def test_session_isolation(self): + """Two different session_ids keep independent state.""" + msg1 = Message("gui.value.set", data={"__from": "skill", "val": 1}, + context={"session": {"session_id": "room1"}}) + msg2 = Message("gui.value.set", data={"__from": "skill", "val": 2}, + context={"session": {"session_id": "room2"}}) + + self.namespace_manager.handle_set_value(msg1) + self.namespace_manager.handle_set_value(msg2) + + session1 = self.namespace_manager.get_session("room1") + session2 = self.namespace_manager.get_session("room2") + self.assertEqual(session1.loaded_namespaces["skill"].data["val"], 1) + self.assertEqual(session2.loaded_namespaces["skill"].data["val"], 2) + + def test_shared_session_id_shares_state(self): + """Two clients sharing one session_id share the same session/stack.""" + msg1 = Message("gui.page.show", data={ + "page_names": ["SYSTEM_weather"], "__from": "weather.skill", "__idle": 30 + }, context={"session": {"session_id": "shared"}}) + msg2 = Message("gui.value.set", data={"__from": "weather.skill", "temp": 21}, + context={"session": {"session_id": "shared"}}) + + self.namespace_manager.handle_show_page(msg1) + self.namespace_manager.handle_set_value(msg2) + + # both messages addressed the same session -> one session, shared data + self.assertEqual(self.namespace_manager.get_all_sessions(), ["shared"]) + data = self.namespace_manager.get_namespace_data("weather.skill", "shared") + self.assertEqual(data["temp"], 21) + + # ====== State Query API Tests ====== + + def test_get_active_namespace_empty_session(self): + self.assertIsNone(self.namespace_manager.get_active_namespace("default")) + + def test_get_active_namespace_returns_top_of_stack(self): + msg = Message("gui.page.show", data={ + "page_names": ["SYSTEM_weather"], "__from": "weather.skill", "__idle": 30 + }, context={"session": {"session_id": "default"}}) + self.namespace_manager.handle_show_page(msg) + active = self.namespace_manager.get_active_namespace("default") + self.assertIsNotNone(active) + self.assertEqual(active.skill_id, "weather.skill") + + def test_get_active_namespace_different_sessions(self): + msg1 = Message("gui.page.show", data={ + "page_names": ["SYSTEM_weather"], "__from": "weather.skill", "__idle": 30 + }, context={"session": {"session_id": "default"}}) + self.namespace_manager.handle_show_page(msg1) + msg2 = Message("gui.page.show", data={ + "page_names": ["SYSTEM_clock"], "__from": "clock.skill", "__idle": 30 + }, context={"session": {"session_id": "kitchen"}}) + self.namespace_manager.handle_show_page(msg2) + + self.assertEqual(self.namespace_manager.get_active_namespace("default").skill_id, "weather.skill") + self.assertEqual(self.namespace_manager.get_active_namespace("kitchen").skill_id, "clock.skill") + + def test_get_namespace_data_returns_none_for_missing(self): + self.assertIsNone(self.namespace_manager.get_namespace_data("nope.skill", "default")) + + def test_get_namespace_data_returns_session_data(self): + msg = Message("gui.page.show", data={ + "page_names": ["SYSTEM_weather"], "__from": "weather.skill", "__idle": 30 + }, context={"session": {"session_id": "default"}}) + self.namespace_manager.handle_show_page(msg) + set_msg = Message("gui.value.set", data={ + "__from": "weather.skill", "current_temp": 22, "condition": "sunny" + }, context={"session": {"session_id": "default"}}) + self.namespace_manager.handle_set_value(set_msg) + + data = self.namespace_manager.get_namespace_data("weather.skill", "default") + self.assertEqual(data["current_temp"], 22) + self.assertEqual(data["condition"], "sunny") + + def test_get_namespace_data_is_copy(self): + msg = Message("gui.page.show", data={ + "page_names": ["SYSTEM_text"], "__from": "test.skill", "__idle": 30 + }, context={"session": {"session_id": "default"}}) + self.namespace_manager.handle_show_page(msg) + set_msg = Message("gui.value.set", data={"__from": "test.skill", "text": "original"}, + context={"session": {"session_id": "default"}}) + self.namespace_manager.handle_set_value(set_msg) + + data = self.namespace_manager.get_namespace_data("test.skill", "default") + data["text"] = "modified" + data2 = self.namespace_manager.get_namespace_data("test.skill", "default") + self.assertEqual(data2["text"], "original") + + def test_get_all_sessions_empty(self): + self.assertEqual(self.namespace_manager.get_all_sessions(), []) + + def test_get_all_sessions_returns_all(self): + for sid, skill, tpl in [("default", "weather.skill", "SYSTEM_weather"), + ("kitchen", "clock.skill", "SYSTEM_clock"), + ("bedroom", "text.skill", "SYSTEM_text")]: + msg = Message("gui.page.show", data={ + "page_names": [tpl], "__from": skill, "__idle": 30 + }, context={"session": {"session_id": sid}}) + self.namespace_manager.handle_show_page(msg) + + sessions = self.namespace_manager.get_all_sessions() + self.assertEqual(len(sessions), 3) + self.assertIn("default", sessions) + self.assertIn("kitchen", sessions) + self.assertIn("bedroom", sessions) + + def test_is_namespace_active_returns_false_when_inactive(self): + self.assertFalse(self.namespace_manager.is_namespace_active("nope.skill", "default")) + + def test_is_namespace_active_returns_true_for_active(self): + msg = Message("gui.page.show", data={ + "page_names": ["SYSTEM_weather"], "__from": "weather.skill", "__idle": 30 + }, context={"session": {"session_id": "default"}}) + self.namespace_manager.handle_show_page(msg) + self.assertTrue(self.namespace_manager.is_namespace_active("weather.skill", "default")) + + def test_is_namespace_active_returns_false_for_lower_stack(self): + msg1 = Message("gui.page.show", data={ + "page_names": ["SYSTEM_weather"], "__from": "weather.skill", "__idle": 30 + }, context={"session": {"session_id": "default"}}) + self.namespace_manager.handle_show_page(msg1) + msg2 = Message("gui.page.show", data={ + "page_names": ["SYSTEM_text"], "__from": "text.skill", "__idle": 30 + }, context={"session": {"session_id": "default"}}) + self.namespace_manager.handle_show_page(msg2) + + self.assertFalse(self.namespace_manager.is_namespace_active("weather.skill", "default")) + self.assertTrue(self.namespace_manager.is_namespace_active("text.skill", "default")) diff --git a/test/unittests/test_service.py b/test/unittests/test_service.py index 7667f5f..3d29dd6 100644 --- a/test/unittests/test_service.py +++ b/test/unittests/test_service.py @@ -1,6 +1,172 @@ import unittest +from unittest import mock +from ovos_bus_client import MessageBusClient +from ovos_gui.service import ( + GUIService, on_started, on_alive, on_ready, on_error, on_stopping +) + + +class TestServiceCallbacks(unittest.TestCase): + """Test module-level callback functions.""" + + def test_on_started(self): + """Test on_started callback.""" + on_started() + + def test_on_alive(self): + """Test on_alive callback.""" + on_alive() + + def test_on_ready(self): + """Test on_ready callback.""" + on_ready() + + def test_on_error_default(self): + """Test on_error callback with default.""" + on_error() + + def test_on_error_with_message(self): + """Test on_error callback with error message.""" + on_error("Test error") + + def test_on_stopping(self): + """Test on_stopping callback.""" + on_stopping() class TestGuiService(unittest.TestCase): - from ovos_gui.service import GUIService - # TODO + """Test GUIService class.""" + + def setUp(self): + """Set up test fixtures.""" + self.mock_bus = mock.MagicMock(spec=MessageBusClient) + self.mock_bus.connected_event = mock.MagicMock() + self.mock_bus.connected_event.is_set = mock.MagicMock(return_value=True) + self.mock_bus.connected_event.wait = mock.MagicMock() + + def test_init_default(self): + """Test GUIService initialization with defaults.""" + with mock.patch('ovos_gui.service.MessageBusClient', return_value=self.mock_bus): + service = GUIService() + + self.assertIsNotNone(service.bus) + self.assertIsNone(service.namespace_manager) + self.assertIsNotNone(service.status) + + def test_init_with_callbacks(self): + """Test GUIService initialization with custom callbacks.""" + custom_callbacks = { + 'alive_hook': mock.Mock(), + 'started_hook': mock.Mock(), + 'ready_hook': mock.Mock(), + 'error_hook': mock.Mock(), + 'stopping_hook': mock.Mock(), + } + + with mock.patch('ovos_gui.service.MessageBusClient', return_value=self.mock_bus): + service = GUIService(**custom_callbacks) + self.assertIsNotNone(service.status) + + def test_is_alive_returns_boolean(self): + """Test is_alive method returns boolean.""" + with mock.patch('ovos_gui.service.MessageBusClient', return_value=self.mock_bus): + service = GUIService() + result = service.is_alive() + self.assertIsInstance(result, bool) + + def test_load_adapter_plugins_returns_list(self): + """Test adapter plugin loading returns a list via the real factory. + + Regression for B1: the loader calls the published + ``OVOSGUIAdapterFactory.create_all`` (no hasattr fallback). + """ + with mock.patch('ovos_gui.service.MessageBusClient', return_value=self.mock_bus): + service = GUIService() + result = service._load_adapter_plugins() + self.assertIsInstance(result, list) + + def test_load_adapter_plugins_headless_does_not_raise(self): + """Regression for B1: zero installed adapters must NOT raise. + + A headless device degrades to no-op dispatch (empty adapter list) + instead of raising RuntimeError. + """ + with mock.patch('ovos_gui.service.MessageBusClient', return_value=self.mock_bus), \ + mock.patch('ovos_gui.service.OVOSGUIAdapterFactory.create_all', + return_value=[]) as mock_create_all: + service = GUIService() + result = service._load_adapter_plugins() # must not raise + self.assertEqual(result, []) + mock_create_all.assert_called_once() + + def test_load_adapter_plugins_uses_real_factory(self): + """The loader calls OVOSGUIAdapterFactory.create_all with bus + config.""" + fake_adapter = mock.Mock() + with mock.patch('ovos_gui.service.MessageBusClient', return_value=self.mock_bus), \ + mock.patch('ovos_gui.service.OVOSGUIAdapterFactory.create_all', + return_value=[fake_adapter]) as mock_create_all: + service = GUIService() + result = service._load_adapter_plugins() + self.assertEqual(result, [fake_adapter]) + mock_create_all.assert_called_once() + # bus is forwarded so adapters can emit interaction events + self.assertIn("bus", mock_create_all.call_args.kwargs) + + def test_init_bus_client_connected(self): + """Test _init_bus_client when already connected.""" + with mock.patch('ovos_gui.service.MessageBusClient', return_value=self.mock_bus): + self.mock_bus.connected_event.is_set.return_value = True + + service = GUIService() + service._init_bus_client() + + # Should not call run_in_thread if already connected + self.mock_bus.run_in_thread.assert_not_called() + + def test_init_bus_client_not_connected(self): + """Test _init_bus_client when needs to connect.""" + with mock.patch('ovos_gui.service.MessageBusClient', return_value=self.mock_bus): + self.mock_bus.connected_event.is_set.return_value = False + + service = GUIService() + service._init_bus_client() + + # Should call run_in_thread if not connected + self.mock_bus.run_in_thread.assert_called_once() + # Should wait for connection + self.mock_bus.connected_event.wait.assert_called_once() + + def test_stop(self): + """Test stop method.""" + with mock.patch('ovos_gui.service.MessageBusClient', return_value=self.mock_bus): + service = GUIService() + # Should not raise + service.stop() + + def test_run(self): + """Test run method initialization sequence.""" + with mock.patch('ovos_gui.service.MessageBusClient', return_value=self.mock_bus), \ + mock.patch('ovos_gui.service.NamespaceManager') as mock_ns_mgr_class: + + mock_ns_mgr = mock.MagicMock() + mock_ns_mgr_class.return_value = mock_ns_mgr + + service = GUIService() + service.status = mock.MagicMock() + service.run() + + # Verify status methods were called in sequence + service.status.set_alive.assert_called_once() + service.status.set_ready.assert_called_once() + # Verify namespace manager was created + mock_ns_mgr_class.assert_called_once() + + def test_run_full_flow(self): + """Test run method full flow with real status object.""" + with mock.patch('ovos_gui.service.MessageBusClient', return_value=self.mock_bus), \ + mock.patch('ovos_gui.service.NamespaceManager'): + service = GUIService() + service.run() + + # Verify service initialized properly + self.assertIsNotNone(service.namespace_manager) diff --git a/test/unittests/test_tui.py b/test/unittests/test_tui.py index fb0a827..dbb2e80 100644 --- a/test/unittests/test_tui.py +++ b/test/unittests/test_tui.py @@ -1,16 +1,359 @@ import unittest +from unittest import mock +import json -class TestTui(unittest.TestCase): - def test_get_websocket(self): +class TestGetWebsocket(unittest.TestCase): + """Test get_websocket function.""" + + def test_get_websocket_returns_client(self): + """Test get_websocket returns a GUIWebsocketClient.""" + from ovos_gui.tui import get_websocket + with mock.patch('ovos_gui.tui.GUIWebsocketClient') as mock_client_class: + mock_client = mock.MagicMock() + mock_client_class.return_value = mock_client + result = get_websocket(threaded=False) + self.assertEqual(result, mock_client) + + def test_get_websocket_with_custom_params(self): + """Test get_websocket with custom parameters.""" from ovos_gui.tui import get_websocket - # TODO + with mock.patch('ovos_gui.tui.GUIWebsocketClient') as mock_client_class: + mock_client = mock.MagicMock() + mock_client_class.return_value = mock_client + result = get_websocket(host="localhost", port=9999, route="/test", ssl=True, threaded=False) + mock_client_class.assert_called_once_with("localhost", 9999, "/test", True) + self.assertEqual(result, mock_client) + + def test_get_websocket_threaded(self): + """Test get_websocket with threaded=True.""" + from ovos_gui.tui import get_websocket + with mock.patch('ovos_gui.tui.GUIWebsocketClient') as mock_client_class: + mock_client = mock.MagicMock() + mock_client_class.return_value = mock_client + result = get_websocket(threaded=True) + mock_client.run_in_thread.assert_called_once() + + def test_get_websocket_default_params(self): + """Test get_websocket with default parameters.""" + from ovos_gui.tui import get_websocket + with mock.patch('ovos_gui.tui.GUIWebsocketClient') as mock_client_class: + mock_client = mock.MagicMock() + mock_client_class.return_value = mock_client + result = get_websocket() + mock_client_class.assert_called_once_with("0.0.0.0", 18181, "/", False) + + +class TestBcolors(unittest.TestCase): + """Test bcolors class.""" + + def test_bcolors_constants_exist(self): + """Test bcolors has all color constants.""" + from ovos_gui.tui import bcolors + self.assertTrue(hasattr(bcolors, 'HEADER')) + self.assertTrue(hasattr(bcolors, 'OKBLUE')) + self.assertTrue(hasattr(bcolors, 'OKGREEN')) + self.assertTrue(hasattr(bcolors, 'WARNING')) + self.assertTrue(hasattr(bcolors, 'FAIL')) + self.assertTrue(hasattr(bcolors, 'ENDC')) + self.assertTrue(hasattr(bcolors, 'BOLD')) + self.assertTrue(hasattr(bcolors, 'UNDERLINE')) - def test_bcolors(self): + def test_bcolors_values_are_strings(self): + """Test bcolors values are ANSI escape strings.""" from ovos_gui.tui import bcolors - # TODO + self.assertIsInstance(bcolors.HEADER, str) + self.assertIsInstance(bcolors.ENDC, str) + self.assertTrue(bcolors.HEADER.startswith('\033[')) + self.assertEqual(bcolors.ENDC, '\033[0m') + + +class TestGuiDebuggerInit(unittest.TestCase): + """Test GUIDebugger initialization.""" + + def test_init_default(self): + """Test GUIDebugger initialization with defaults.""" + from ovos_gui.tui import GUIDebugger + debugger = GUIDebugger() + self.assertEqual(debugger.port, 18181) + self.assertEqual(debugger.mycroft_ip, "0.0.0.0") + self.assertIsNone(debugger.skill) + self.assertIsNone(debugger.page) + self.assertIsNone(debugger.gui_ws) + self.assertEqual(debugger.name, "guidebugger") + self.assertFalse(debugger.debug) + self.assertFalse(debugger.connected) + self.assertEqual(debugger.buffer, []) + self.assertEqual(debugger.loaded, []) + self.assertEqual(debugger.vars, {}) + + def test_init_custom_host(self): + """Test GUIDebugger initialization with custom host.""" + from ovos_gui.tui import GUIDebugger + debugger = GUIDebugger(host="127.0.0.1") + self.assertEqual(debugger.mycroft_ip, "127.0.0.1") + + def test_init_custom_port(self): + """Test GUIDebugger initialization with custom port.""" + from ovos_gui.tui import GUIDebugger + debugger = GUIDebugger(port=9999) + self.assertEqual(debugger.port, 9999) + + def test_init_custom_name(self): + """Test GUIDebugger initialization with custom name.""" + from ovos_gui.tui import GUIDebugger + debugger = GUIDebugger(name="TestDebugger") + self.assertEqual(debugger.name, "TestDebugger") + + def test_init_debug_mode(self): + """Test GUIDebugger initialization with debug=True.""" + from ovos_gui.tui import GUIDebugger + debugger = GUIDebugger(debug=True) + self.assertTrue(debugger.debug) + + +class TestGuiDebuggerConnect(unittest.TestCase): + """Test GUIDebugger connect method.""" + + def test_connect(self): + """Test connect method creates websocket.""" + from ovos_gui.tui import GUIDebugger + debugger = GUIDebugger() + with mock.patch('ovos_gui.tui.get_websocket') as mock_get_ws: + mock_ws = mock.MagicMock() + mock_get_ws.return_value = mock_ws + with mock.patch('ovos_gui.tui.LOG'): + debugger.connect() + self.assertEqual(debugger.gui_ws, mock_ws) + mock_ws.on.assert_any_call("open", debugger.on_open) + mock_ws.on.assert_any_call("message", debugger.on_gui_message) + + +class TestGuiDebuggerMessageHandling(unittest.TestCase): + """Test GUIDebugger message handling.""" + + def test_on_open(self): + """Test on_open callback.""" + from ovos_gui.tui import GUIDebugger + debugger = GUIDebugger() + with mock.patch('ovos_gui.tui.LOG'): + debugger.on_open() + # Should not raise + + def test_on_gui_message_session_set(self): + """Test on_gui_message with mycroft.session.set message.""" + from ovos_gui.tui import GUIDebugger + debugger = GUIDebugger() + msg = { + "type": "mycroft.session.set", + "namespace": "test.skill", + "data": {"key": "value"} + } + payload = json.dumps(msg) + with mock.patch('ovos_gui.tui.LOG'): + debugger.on_gui_message(payload) + self.assertEqual(debugger.skill, "test.skill") + self.assertEqual(debugger.vars["test.skill"]["key"], "value") + + def test_on_gui_message_list_insert_new_namespace(self): + """Test on_gui_message with mycroft.session.list.insert message.""" + from ovos_gui.tui import GUIDebugger + debugger = GUIDebugger() + msg = { + "type": "mycroft.session.list.insert", + "data": [{"skill_id": "test.skill"}] + } + payload = json.dumps(msg) + with mock.patch('ovos_gui.tui.LOG'): + debugger.on_gui_message(payload) + self.assertEqual(debugger.skill, "test.skill") + self.assertEqual(len(debugger.loaded), 1) + + def test_on_gui_message_gui_list_insert_page(self): + """Test on_gui_message with mycroft.gui.list.insert for page.""" + from ovos_gui.tui import GUIDebugger + debugger = GUIDebugger() + debugger.skill = "test.skill" + debugger.loaded = [["test.skill", ["page1.qml"]]] + msg = { + "type": "mycroft.gui.list.insert", + "data": [{"url": "page2.qml"}], + "position": 1 + } + payload = json.dumps(msg) + with mock.patch('ovos_gui.tui.LOG'): + debugger.on_gui_message(payload) + self.assertEqual(debugger.page, "page2.qml") + self.assertEqual(len(debugger.loaded[0][1]), 2) + + def test_on_gui_message_gui_list_insert_no_namespace(self): + """Test on_gui_message with mycroft.gui.list.insert when no namespace loaded.""" + from ovos_gui.tui import GUIDebugger + debugger = GUIDebugger() + debugger.skill = None + debugger.loaded = [] + msg = { + "type": "mycroft.gui.list.insert", + "data": [{"url": "page1.qml"}], + "position": 0 + } + payload = json.dumps(msg) + with mock.patch('ovos_gui.tui.LOG'): + debugger.on_gui_message(payload) + # Should create a namespace entry + self.assertEqual(len(debugger.loaded), 1) + + def test_on_gui_message_list_move(self): + """Test on_gui_message with mycroft.session.list.move message.""" + from ovos_gui.tui import GUIDebugger + debugger = GUIDebugger() + debugger.loaded = [["skill1", []], ["skill2", []]] + msg = { + "type": "mycroft.session.list.move", + "from": 1 + } + payload = json.dumps(msg) + with mock.patch('ovos_gui.tui.LOG'): + debugger.on_gui_message(payload) + self.assertEqual(debugger.loaded[0][0], "skill2") + + def test_on_gui_message_list_remove(self): + """Test on_gui_message with mycroft.session.list.remove message.""" + from ovos_gui.tui import GUIDebugger + debugger = GUIDebugger() + debugger.skill = "skill1" + debugger.loaded = [["skill1", []], ["skill2", []]] + msg = { + "type": "mycroft.session.list.remove", + "position": 0, + "namespace": "skill1" + } + payload = json.dumps(msg) + with mock.patch('ovos_gui.tui.LOG'): + debugger.on_gui_message(payload) + self.assertIsNone(debugger.skill) + self.assertEqual(len(debugger.loaded), 1) + + def test_on_gui_message_events_triggered(self): + """Test on_gui_message with mycroft.events.triggered message.""" + from ovos_gui.tui import GUIDebugger + debugger = GUIDebugger() + debugger.loaded = [["test.skill", ["page1.qml", "page2.qml"]]] + msg = { + "type": "mycroft.events.triggered", + "namespace": "test.skill", + "event_name": "page_gained_focus", + "data": {"number": 1} + } + payload = json.dumps(msg) + with mock.patch('ovos_gui.tui.LOG'): + debugger.on_gui_message(payload) + self.assertEqual(debugger.page, "page2.qml") + + def test_on_gui_message_invalid_json(self): + """Test on_gui_message with invalid JSON.""" + from ovos_gui.tui import GUIDebugger + debugger = GUIDebugger() + payload = "invalid json{][" + with mock.patch('ovos_gui.tui.LOG'): + debugger.on_gui_message(payload) + # Should not raise + + def test_on_gui_message_invalid_json_debug(self): + """Test on_gui_message with invalid JSON in debug mode.""" + from ovos_gui.tui import GUIDebugger + debugger = GUIDebugger(debug=True) + payload = "invalid json{][" + with mock.patch('ovos_gui.tui.LOG') as mock_log: + debugger.on_gui_message(payload) + # Should log exception in debug mode + mock_log.exception.assert_called_once() + mock_log.error.assert_called_once() + + def test_on_gui_message_session_set_debug(self): + """Test on_gui_message with session.set message in debug mode.""" + from ovos_gui.tui import GUIDebugger + debugger = GUIDebugger(debug=True) + msg = { + "type": "mycroft.session.set", + "namespace": "test.skill", + "data": {"key": "value"} + } + payload = json.dumps(msg) + with mock.patch('ovos_gui.tui.LOG') as mock_log: + debugger.on_gui_message(payload) + # In debug mode, should log the message + mock_log.debug.assert_called_once() + + def test_on_message_called(self): + """Test on_message is called for valid messages.""" + from ovos_gui.tui import GUIDebugger + debugger = GUIDebugger() + debugger.on_message = mock.Mock() + msg = {"type": "test", "data": {}} + payload = json.dumps(msg) + with mock.patch('ovos_gui.tui.LOG'): + debugger.on_gui_message(payload) + debugger.on_message.assert_called_once() + + +class TestGuiDebuggerDrawBuffer(unittest.TestCase): + """Test GUIDebugger draw buffer methods.""" + + def test_draw_buffer_with_skill(self): + """Test _draw_buffer creates buffer with skill.""" + from ovos_gui.tui import GUIDebugger + debugger = GUIDebugger() + debugger.skill = "test.skill" + debugger.page = "page.qml" + debugger.vars = {"test.skill": {"var1": "value1"}} + debugger._draw_buffer() + self.assertGreater(len(debugger.buffer), 0) + # Check that buffer contains skill name + buffer_text = " ".join(debugger.buffer) + self.assertIn("test.skill", buffer_text) + + def test_draw_buffer_without_skill(self): + """Test _draw_buffer with no active skill.""" + from ovos_gui.tui import GUIDebugger + debugger = GUIDebugger() + debugger.skill = None + debugger._draw_buffer() + self.assertEqual(debugger.buffer, []) + + def test_draw_buffer_without_page(self): + """Test _draw_buffer with no active page.""" + from ovos_gui.tui import GUIDebugger + debugger = GUIDebugger() + debugger.skill = "test.skill" + debugger.page = None + debugger._draw_buffer() + buffer_text = " ".join(debugger.buffer) + self.assertIn("None", buffer_text) + + def test_draw(self): + """Test draw method prints buffer.""" + from ovos_gui.tui import GUIDebugger + debugger = GUIDebugger() + debugger.buffer = ["Line 1", "Line 2"] + with mock.patch('builtins.print') as mock_print: + debugger.draw() + self.assertEqual(mock_print.call_count, 2) + + +class TestGuiDebuggerHelpers(unittest.TestCase): + """Test GUIDebugger helper methods.""" + def test_on_new_gui_data(self): + """Test on_new_gui_data is callable.""" + from ovos_gui.tui import GUIDebugger + debugger = GUIDebugger() + # Should not raise + debugger.on_new_gui_data({}) -class TestGuiDebugger(unittest.TestCase): - from ovos_gui.tui import GUIDebugger - # TODO \ No newline at end of file + def test_on_message(self): + """Test on_message is callable.""" + from ovos_gui.tui import GUIDebugger + debugger = GUIDebugger() + # Should not raise + debugger.on_message({"type": "test"}) \ No newline at end of file diff --git a/test/unittests/test_version.py b/test/unittests/test_version.py new file mode 100644 index 0000000..4aec4a1 --- /dev/null +++ b/test/unittests/test_version.py @@ -0,0 +1,86 @@ +import unittest + + +class TestVersion(unittest.TestCase): + """Test version.py version constants and __version__ formatting.""" + + def test_version_constants_are_integers(self): + """Test that version constants are integers.""" + from ovos_gui.version import VERSION_MAJOR, VERSION_MINOR, VERSION_BUILD, VERSION_ALPHA + self.assertIsInstance(VERSION_MAJOR, int) + self.assertIsInstance(VERSION_MINOR, int) + self.assertIsInstance(VERSION_BUILD, int) + self.assertIsInstance(VERSION_ALPHA, int) + + def test_version_constants_are_non_negative(self): + """Test that version constants are non-negative.""" + from ovos_gui.version import VERSION_MAJOR, VERSION_MINOR, VERSION_BUILD, VERSION_ALPHA + self.assertGreaterEqual(VERSION_MAJOR, 0) + self.assertGreaterEqual(VERSION_MINOR, 0) + self.assertGreaterEqual(VERSION_BUILD, 0) + self.assertGreaterEqual(VERSION_ALPHA, 0) + + def test_version_string_without_alpha(self): + """Test __version__ string format without alpha.""" + from ovos_gui import version + # Temporarily set VERSION_ALPHA to 0 + original_alpha = version.VERSION_ALPHA + try: + version.VERSION_ALPHA = 0 + # Regenerate __version__ + version.__version__ = f"{version.VERSION_MAJOR}.{version.VERSION_MINOR}.{version.VERSION_BUILD}" + \ + (f"a{version.VERSION_ALPHA}" if version.VERSION_ALPHA else "") + self.assertNotIn('a', version.__version__) + finally: + version.VERSION_ALPHA = original_alpha + + def test_version_string_with_alpha(self): + """Test __version__ string format with alpha.""" + from ovos_gui import version + # Temporarily set VERSION_ALPHA to a non-zero value + original_alpha = version.VERSION_ALPHA + try: + version.VERSION_ALPHA = 5 + # Regenerate __version__ + version.__version__ = f"{version.VERSION_MAJOR}.{version.VERSION_MINOR}.{version.VERSION_BUILD}" + \ + (f"a{version.VERSION_ALPHA}" if version.VERSION_ALPHA else "") + self.assertIn('a5', version.__version__) + finally: + version.VERSION_ALPHA = original_alpha + + def test_version_string_format(self): + """Test __version__ string has expected format.""" + from ovos_gui.version import __version__ + # Should be in format X.Y.Z or X.Y.ZaA + parts = __version__.split('.') + self.assertEqual(len(parts), 3) + # Major and minor should be digits + self.assertTrue(parts[0].isdigit()) + self.assertTrue(parts[1].isdigit()) + # Build might contain 'a' for alpha + self.assertTrue(any(c.isdigit() or c == 'a' for c in parts[2])) + + def test_version_string_is_not_empty(self): + """Test that __version__ is not empty.""" + from ovos_gui.version import __version__ + self.assertTrue(__version__) + self.assertIsInstance(__version__, str) + + def test_version_major_minor_build_in_string(self): + """Test that major.minor.build appear in __version__.""" + from ovos_gui.version import __version__, VERSION_MAJOR, VERSION_MINOR, VERSION_BUILD + expected_base = f"{VERSION_MAJOR}.{VERSION_MINOR}.{VERSION_BUILD}" + self.assertTrue(__version__.startswith(expected_base)) + + def test_version_string_matches_constants(self): + """__version__ is derived from the version constants. + + The exact numbers are bumped automatically by release tooling, so this + asserts the derivation rather than a hard-coded value. + """ + from ovos_gui.version import ( + __version__, VERSION_MAJOR, VERSION_MINOR, VERSION_BUILD, VERSION_ALPHA + ) + expected = f"{VERSION_MAJOR}.{VERSION_MINOR}.{VERSION_BUILD}" + \ + (f"a{VERSION_ALPHA}" if VERSION_ALPHA else "") + self.assertEqual(__version__, expected)