Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# qToggleServer – Copilot Instructions

## Environment

The Python virtualenv lives at `.venv` in the repo root. Activate it with `source .venv/bin/activate` before running any Python commands. If the virtualenv exists, do not attempt to install `uv`, `pip`, or other tools globally — they are already available inside `.venv`.

## Commands

```bash
Expand All @@ -12,9 +16,16 @@ ruff format qtoggleserver # format

Pre-commit hooks run `ruff check` and `ruff format` automatically.

Frontend (run from `qtoggleserver/frontend/`):
```bash
npx webpack --mode production # build — output goes to dist/
npx eslint js/ # lint JS
QUI_PATH=/path/to/qui npx webpack --mode development # dev build with local QUI checkout
```

## Architecture

qToggleServer is a [qToggle protocol](https://github.com/qtoggle/docs) server. The main concepts:
qToggleServer is a [qToggle protocol](https://github.com/qtoggle/docs) server. The HTTP API implementation derives largely from the [qToggle API 1.3 spec](https://github.com/qtoggle/docs/wiki/The-qToggle-API-1.3). The main concepts:

- **Ports** (`qtoggleserver/core/ports.py`) — the central abstraction. A port has a boolean or number value and a set of attributes. `BasePort` is the base class; hardware drivers subclass it.
- **Peripherals** (`qtoggleserver/peripherals/`) — hardware devices that own one or more ports. Subclass `Peripheral` and implement `make_port_args()` to declare ports. Peripherals may run blocking I/O in a `ThreadedRunner`.
Expand Down Expand Up @@ -143,3 +154,28 @@ async def get_something(request: core_api.APIRequest) -> dict:
**Access levels:** `ACCESS_LEVEL_NONE=0`, `ACCESS_LEVEL_VIEWONLY=10`, `ACCESS_LEVEL_NORMAL=20`, `ACCESS_LEVEL_ADMIN=30`.

**Input validation** — use `core_api_schema.validate(data, json_schema, ...)` (wraps `jsonschema`). Pass `unexpected_field_code` to customise the error code for unrecognised fields.

## Frontend

The frontend lives at `qtoggleserver/frontend/` and is a single-page application built with vanilla ES2018 modules, webpack 4, and LESS.

**UI framework** — [`@qtoggle/qui`](https://github.com/qtoggle/qui) (QUI). Imported via the `$qui/` path alias. The webpack config delegates to `qui/webpack/webpack-common.js`; set `QUI_PATH` env var to use a local checkout instead of the npm package.

**Path aliases:**
- `$qui/` → QUI framework (`node_modules/@qtoggle/qui/js/`)
- `$app/` → the app's own `js/` directory
- `$node/` → `node_modules/`

**Directory layout (`js/`):**
- `api/` — thin wrappers around every HTTP endpoint (`ports.js`, `devices.js`, `notifications.js`, etc.). `base.js` handles request signing, time-skew, and error normalisation into `APIError`.
- `cache.js` — loads and caches device attributes and port list; exposes `Cache.load()`, `Cache.reload()`, `Cache.getMainDevice()`, etc.
- `events.js` — subscribes to the server notifications (listen) stream and dispatches events to registered listeners.
- `auth.js` — manages the current access level; exposes `Auth.init()` and access-level-change signals.
- `dashboard/`, `ports/`, `devices/`, `peripherals/`, `settings/`, `login/` — one QUI *section* per area of the UI.
- `common/` — shared form mixins and page components (backup/restore, firmware update, reboot, etc.).
- `widgets/` — reusable QUI widgets specific to this app.

**Key conventions:**
- 4-space indent, single quotes, no semicolons, 120-char line limit (enforced by ESLint via `eslint.config.mjs`).
- Sections are registered with `Sections.register(SectionClass)` in `index.js`; each section class lives in its own subdirectory and is a QUI `Section` subclass.
- Server-side frontend events (`DashboardUpdateEvent`, etc.) are defined in `qtoggleserver/frontend/events.py` and extend `core_events.Event`; the frontend's `events.js` handles them via the notifications API.
101 changes: 101 additions & 0 deletions tests/integration/expressions/test_eval_triggers.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,23 @@
import asyncio

import pytest

from qtoggleserver.conf import settings
from qtoggleserver.core import main
from qtoggleserver.core import ports as core_ports
from qtoggleserver.core.events import handlers as event_handlers
from tests.unit.qtoggleserver.mock.ports import MockNumberPort


@pytest.fixture
def with_attr_change_handler():
"""Register the main module's attr-change event handler for the duration of the test."""
event_handlers._registered_handlers.append(main._attr_change_handler)
main._attr_change_handler._pending.clear()
yield
if main._attr_change_handler in event_handlers._registered_handlers:
event_handlers._registered_handlers.remove(main._attr_change_handler)
main._attr_change_handler._pending.clear()


async def test_eval_trigger_set_expression(mock_num_port1, mock_num_port2, mocker):
Expand Down Expand Up @@ -81,3 +97,88 @@ async def test_eval_trigger_port_enabled(mock_num_port1, mocker):
await main.read_ports()
await asyncio.sleep(settings.core.tick_interval / 1000)
mock_num_port1.transform_and_write_value.assert_called_once_with(60)


async def test_eval_trigger_port_attr_change(mock_num_port1, mock_num_port2, with_attr_change_handler, mocker):
"""Should trigger expression evaluation when a port's attribute changes."""

mock_num_port2.set_writable(True)
mock_num_port2.set_expression("$nid1:enabled")
mocker.patch.object(mock_num_port2, "transform_and_write_value")

# Changing port1's display_name fires a debounced PortUpdate event
await mock_num_port1.set_attr("display_name", "updated")
# Wait for the debounced _after_set_attr task to run and dispatch the PortUpdate event
await asyncio.sleep(settings.core.tick_interval / 1000)

await main.read_ports()
await asyncio.sleep(settings.core.tick_interval / 1000)
mock_num_port2.transform_and_write_value.assert_called_once_with(1)


async def test_eval_trigger_port_add(mock_num_port1, with_attr_change_handler, mocker):
"""Should trigger expression evaluation when a new port is added."""

mock_num_port1.set_writable(True)
mock_num_port1.set_last_read_value(0)
# Expression depends on an attribute of nid3, which doesn't exist yet
mock_num_port1.set_expression("$nid3:enabled")
mocker.patch.object(mock_num_port1, "transform_and_write_value")

# Loading the port fires a PortAdd event for nid3 → $nid3: added to pending attr-change deps
nid3 = (await core_ports.load([{"driver": MockNumberPort, "port_id": "nid3", "value": None}]))[0]
try:
await nid3.enable()

await main.read_ports()
await asyncio.sleep(settings.core.tick_interval / 1000)
mock_num_port1.transform_and_write_value.assert_called_once_with(1)
finally:
await nid3.remove(persisted_data=False)


async def test_eval_trigger_port_remove(mock_num_port1, with_attr_change_handler, mocker):
"""Should trigger expression evaluation when a port is removed."""

# Load a temporary port that will be removed during the test
nid3 = (await core_ports.load([{"driver": MockNumberPort, "port_id": "nid3", "value": None}]))[0]
await nid3.enable()

mock_num_port1.set_writable(True)
# Expression depends on an attribute of nid3; removing nid3 must trigger re-evaluation
mock_num_port1.set_expression("$nid3:enabled")
spy = mocker.spy(mock_num_port1, "eval_and_push_write")

# Removing the port fires a PortRemove event → $nid3: added to pending attr-change deps
await nid3.remove(persisted_data=False)

await main.read_ports()
spy.assert_called_once()


async def test_eval_trigger_device_attr_change(mock_num_port1, with_attr_change_handler, mocker):
"""Should trigger expression evaluation when a device attribute changes."""

from qtoggleserver.core.device import attrs as device_attrs
from qtoggleserver.core.device import events as device_events

original_display_name = device_attrs.display_name
try:
mock_num_port1.set_writable(True)
mock_num_port1.set_last_read_value(0)
# Expression depends on the device's display_name attribute
mock_num_port1.set_expression("#:display_name")
mocker.patch.object(mock_num_port1, "transform_and_write_value")

# Set a non-empty display_name so the expression evaluates to 1 (truthy string → 1)
device_attrs.display_name = "test"
device_attrs.invalidate_attrs()
# Fire the DeviceUpdate event (normally emitted by the device attrs update loop)
await device_events.trigger_update()

await main.read_ports()
await asyncio.sleep(settings.core.tick_interval / 1000)
mock_num_port1.transform_and_write_value.assert_called_once_with(1)
finally:
device_attrs.display_name = original_display_name
device_attrs.invalidate_attrs()
Loading