Skip to content

Commit 5fe84cd

Browse files
committed
Add copilot-instructions.md
1 parent fcc57f8 commit 5fe84cd

1 file changed

Lines changed: 145 additions & 0 deletions

File tree

.github/copilot-instructions.md

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
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

Comments
 (0)