|
| 1 | +# qToggleServer – Copilot Instructions |
| 2 | + |
| 3 | +## Commands |
| 4 | + |
| 5 | +```bash |
| 6 | +uv sync --extra opt # install all deps including optional (postgres, mongo, redis) |
| 7 | +uv run pytest # run all tests |
| 8 | +uv run pytest tests/unit/qtoggleserver/core/test_ports.py::test_name # single test |
| 9 | +ruff check qtoggleserver # lint |
| 10 | +ruff format qtoggleserver # format |
| 11 | +``` |
| 12 | + |
| 13 | +Pre-commit hooks run `ruff check` and `ruff format` automatically. |
| 14 | + |
| 15 | +## Architecture |
| 16 | + |
| 17 | +qToggleServer is a [qToggle protocol](https://github.com/qtoggle/docs) server. The main concepts: |
| 18 | + |
| 19 | +- **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. |
| 20 | +- **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`. |
| 21 | +- **Device** (`qtoggleserver/core/device/`) — represents the local device. Attributes are module-level variables in `device/attrs.py`; `device/__init__.py` loads/saves/resets them via `persist`. |
| 22 | +- **Slaves** (`qtoggleserver/slaves/`) — remote qToggle devices proxied over HTTP. Master discovers and manages slave ports alongside local ports. |
| 23 | +- **Expressions** (`qtoggleserver/core/expressions/`) — expression language evaluated per-port. Ports can have `expression`, `transform_read`, and `transform_write` fields. |
| 24 | +- **Events / Webhooks** (`qtoggleserver/core/events/`, `qtoggleserver/core/webhooks.py`) — internal event bus; webhooks deliver events to external URLs. |
| 25 | +- **Persistence** (`qtoggleserver/persist/`) — async, pluggable storage. Drivers: JSON (default), MongoDB, PostgreSQL, Redis. Never access a driver directly; use `persist.query/insert/update/remove/get_value/set_value`. |
| 26 | +- **Web layer** (`qtoggleserver/web/`) — Tornado HTTP server. API handlers live in `qtoggleserver/core/api/funcs/`. |
| 27 | +- **Config** (`qtoggleserver/conf/`) — HOCON format (pyhocon). Settings accessed as `settings.<section>.<key>`. |
| 28 | + |
| 29 | +## Key Conventions |
| 30 | + |
| 31 | +### Attribute resolution on ports |
| 32 | +`get_attr(name)` resolves in this order: |
| 33 | +1. `attr_get_<name>()` / `attr_is_<name>()` method on the port class |
| 34 | +2. `_<name>` instance variable |
| 35 | +3. `attr_get_default_<name>()` / `attr_is_default_<name>()` method |
| 36 | +4. Returns `None` (attribute unsupported) |
| 37 | + |
| 38 | +To handle attribute writes, implement `attr_set_<name>(value)`. Declare custom attributes in the `ADDITIONAL_ATTRDEFS` class dict. |
| 39 | + |
| 40 | +### `core_ports.get_all()` returns a `ValuesView` |
| 41 | +Always wrap it with `list()` before iterating across `await` points or during mutations: |
| 42 | +```python |
| 43 | +for port in list(core_ports.get_all()): |
| 44 | + await port.some_async_method() |
| 45 | +``` |
| 46 | + |
| 47 | +### Dynamic class loading |
| 48 | +Classes are referenced by dotted Python path in config and are loaded at runtime via `dynload_utils.load_attr("module.path.ClassName")`. |
| 49 | + |
| 50 | +### Async throughout |
| 51 | +Everything is `async`/`await` on a single Tornado event loop. Blocking I/O must be offloaded to `ThreadedRunner` (used by `Peripheral` subclasses). |
| 52 | + |
| 53 | +### Type annotations |
| 54 | +All production code requires type annotations (ruff `ANN` rules). `*args`, `**kwargs`, and `Any` return types are exempt. Line length is 120 characters. |
| 55 | + |
| 56 | +### Tests |
| 57 | +- `tests/unit/` and `tests/integration/`, run via `pytest` with `asyncio_mode = "auto"` (no `@pytest.mark.asyncio` needed). |
| 58 | +- Use `MockPersistDriver`, `MockBooleanPort`/`MockNumberPort`, and `MockPeripheral` from `tests/unit/qtoggleserver/mock/` in fixtures. |
| 59 | +- Mock `asyncio.Lock` with `mocker.patch("asyncio.Lock")` when creating ports outside the running event loop. |
| 60 | +- `tests/conftest.py` provides common fixtures (`mock_persist_driver`, `mock_num_port1`, etc.). |
| 61 | + |
| 62 | +## Expression Language |
| 63 | + |
| 64 | +Expressions are strings assigned to port `expression`, `transform_read`, or `transform_write` attributes. They are parsed by `qtoggleserver/core/expressions/__init__.py:parse()` and evaluated each core loop iteration. |
| 65 | + |
| 66 | +**Syntax forms:** |
| 67 | +| Prefix | Example | Meaning | |
| 68 | +|--------|---------|---------| |
| 69 | +| `$port_id` | `$sensor1` | value of port `sensor1` | |
| 70 | +| `$port_id:attr` | `$sensor1:enabled` | attribute `enabled` of port `sensor1` | |
| 71 | +| `$:attr` | `$:enabled` | attribute of *this* port (self-reference) | |
| 72 | +| `@port_id` | `@sensor1` | previous value of port `sensor1` (write context) | |
| 73 | +| `#:attr` | `#:name` | attribute of the local device | |
| 74 | +| `#slave_name:attr` | `#hub:name` | attribute of a slave device | |
| 75 | +| `func(...)` | `ADD($a, $b)` | function call | |
| 76 | +| literal | `42`, `true`, `"hi"` | literal value | |
| 77 | + |
| 78 | +**Roles** (`Role` enum in `base.py`): `VALUE`, `TRANSFORM_READ`, `TRANSFORM_WRITE`, `FILTER`. Port references to other ports are only allowed in `VALUE` role; `TRANSFORM_READ`/`TRANSFORM_WRITE` may only reference the port itself. |
| 79 | + |
| 80 | +**Time dependencies** (`DEP_SECOND`, `DEP_MINUTE`, `DEP_HOUR`, `DEP_DAY`, `DEP_MONTH`, `DEP_YEAR`, `DEP_ASAP`): expressions declare which time units they depend on via `get_deps()`. The core loop re-evaluates expressions only when a relevant dep has changed. |
| 81 | + |
| 82 | +**Implementing a new function:** |
| 83 | +```python |
| 84 | +from qtoggleserver.core.expressions.functions import Function, function |
| 85 | +from qtoggleserver.core.expressions.base import EvalContext, EvalResult, Role |
| 86 | + |
| 87 | +@function("MY_FUNC") |
| 88 | +class MyFunc(Function): |
| 89 | + MIN_ARGS = 2 |
| 90 | + MAX_ARGS = 2 |
| 91 | + DEPS = set() # add DEP_* constants if time-sensitive |
| 92 | + TRANSFORM_OK = True # set False to disallow in transform expressions |
| 93 | + |
| 94 | + async def _eval(self, context: EvalContext) -> EvalResult: |
| 95 | + args = await self.eval_args(context) |
| 96 | + return args[0] + args[1] |
| 97 | +``` |
| 98 | + |
| 99 | +The `@function("NAME")` decorator registers the class in the global `FUNCTIONS` dict. `EvalResult` is `int | float | str`. |
| 100 | + |
| 101 | +## Master / Slave Architecture |
| 102 | + |
| 103 | +A *master* qToggleServer device can proxy one or more *slave* qToggle devices over HTTP. |
| 104 | + |
| 105 | +**`Slave`** (`slaves/devices.py`) — represents a remote device. Key fields: |
| 106 | +- `_scheme`, `_host`, `_port`, `_path` — HTTP endpoint |
| 107 | +- `_poll_interval` — seconds between polling cycles (0 = use listen mode only) |
| 108 | +- `_listen_enabled` — whether to open a persistent listen session to receive push events |
| 109 | +- `_provisioning_attrs` — attribute names changed while the slave was offline, to be pushed when it comes back online |
| 110 | + |
| 111 | +**Sync modes:** A slave stays in sync via two complementary mechanisms that can be combined: |
| 112 | +1. **Polling** — master periodically GETs the slave's ports and device attrs. |
| 113 | +2. **Listen** — master holds an open HTTP request; the slave pushes events (value changes, attribute changes) as they happen. |
| 114 | + |
| 115 | +**Discovery** (`slaves/discover/`) — enabled when a WiFi AP interface is available (`discover.is_enabled()`). The master acts as a WiFi AP; unconfigured slave devices connect to it. The master probes the DHCP client list, connects to each new IP, reads device attributes, and stores the result as a `DiscoveredDevice`. The UI then lets the user confirm and add the device as a slave. |
| 116 | + |
| 117 | +**`SlavePort`** (`slaves/ports.py`) — subclass of `BasePort` that proxies a remote port. Its `read_value()` / `write_value()` forward over HTTP to the slave via the `Slave._parallel_api_caller` (throttled to `_MAX_PARALLEL_API_CALLS = 2` concurrent calls). Slave port IDs are prefixed with the slave name: `slave_name.port_id`. |
| 118 | + |
| 119 | +**Provisioning** — when a slave reconnects after being offline, the master replays any attribute changes or webhook/reverse-API configuration that were queued in `_provisioning_attrs`, `_provisioning_webhooks`, `_provisioning_reverse`. |
| 120 | + |
| 121 | +## API Handler Conventions |
| 122 | + |
| 123 | +The web layer is Tornado. Handlers live in `qtoggleserver/web/handlers.py`; business logic lives in `qtoggleserver/core/api/funcs/` (and per-subsystem `api/funcs/` files). |
| 124 | + |
| 125 | +**Defining an API function:** |
| 126 | +```python |
| 127 | +from qtoggleserver.core import api as core_api |
| 128 | + |
| 129 | +@core_api.api_call(core_api.ACCESS_LEVEL_ADMIN) # or NORMAL / VIEWONLY / NONE |
| 130 | +async def get_something(request: core_api.APIRequest) -> dict: |
| 131 | + ... |
| 132 | + return {"key": "value"} |
| 133 | +``` |
| 134 | + |
| 135 | +- The decorator enforces the minimum access level; raises `APIError(401, "authentication-required")` or `APIError(403, "forbidden")` automatically. |
| 136 | +- The function receives an `APIRequest` wrapping the Tornado handler. Useful properties: `request.access_level`, `request.username`, `request.query` (dict), `request.body` (bytes), `request.headers`. |
| 137 | +- Return value is JSON-serialised and sent as the response body. |
| 138 | +- Raise `core_api.APIError(status, "error-code", **extra_params)` to return an error. The `error-code` and any extra kwargs become the JSON response. |
| 139 | +- Raise `core_api.APIAccepted(response)` to return HTTP 202 with an optional body. |
| 140 | + |
| 141 | +**Registering a handler** — add a Tornado `URLSpec` entry in `web/server.py` and wire it to a `web/handlers.py` class that calls `self.call_api_func(func_module.func_name)`. |
| 142 | + |
| 143 | +**Access levels:** `ACCESS_LEVEL_NONE=0`, `ACCESS_LEVEL_VIEWONLY=10`, `ACCESS_LEVEL_NORMAL=20`, `ACCESS_LEVEL_ADMIN=30`. |
| 144 | + |
| 145 | +**Input validation** — use `core_api_schema.validate(data, json_schema, ...)` (wraps `jsonschema`). Pass `unexpected_field_code` to customise the error code for unrecognised fields. |
0 commit comments