Skip to content
26 changes: 13 additions & 13 deletions qtoggleserver/core/events/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
class Event(metaclass=abc.ABCMeta):
REQUIRED_ACCESS = core_api.ACCESS_LEVEL_NONE
TYPE = "base-event"
_UNINITIALIZED: dict = {}

def __init__(self, timestamp: float | None = None) -> None:
self._type: str = self.TYPE
Expand All @@ -25,28 +24,29 @@ def __init__(self, timestamp: float | None = None) -> None:
timestamp = 0

self._timestamp: float = timestamp
self._params: GenericJSONDict | None = self._UNINITIALIZED
self._params: GenericJSONDict | None = None

def __str__(self) -> str:
return f"{self._type} event"

async def to_json(self) -> GenericJSONDict:
if self._params is self._UNINITIALIZED:
raise Exception("Parameters are uninitialized")

result: GenericJSONDict = {"type": self._type}

if self._params:
result["params"] = self._params

return result
return {
"type": self._type,
"params": self.get_params(),
}

async def init_params(self) -> None:
self._params = await self.get_params()
self._params = await self.make_params()

async def get_params(self) -> GenericJSONDict:
async def make_params(self) -> GenericJSONDict:
return {}

def get_params(self) -> GenericJSONDict:
if self._params is None:
raise RuntimeError("Parameters are uninitialized")

return self._params

def get_type(self) -> str:
return self._type

Expand Down
2 changes: 1 addition & 1 deletion qtoggleserver/core/events/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class DeviceUpdate(DeviceEvent):
REQUIRED_ACCESS = core_api.ACCESS_LEVEL_ADMIN
TYPE = "device-update"

async def get_params(self) -> GenericJSONDict:
async def make_params(self) -> GenericJSONDict:
return await self.get_attrs()

def is_duplicate(self, event: Event) -> bool:
Expand Down
8 changes: 4 additions & 4 deletions qtoggleserver/core/events/port.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,23 +27,23 @@ class PortAdd(PortEvent):
REQUIRED_ACCESS = core_api.ACCESS_LEVEL_VIEWONLY
TYPE = "port-add"

async def get_params(self) -> GenericJSONDict:
async def make_params(self) -> GenericJSONDict:
return await self.get_port().to_json()


class PortRemove(PortEvent):
REQUIRED_ACCESS = core_api.ACCESS_LEVEL_VIEWONLY
TYPE = "port-remove"

async def get_params(self) -> GenericJSONDict:
async def make_params(self) -> GenericJSONDict:
return {"id": self.get_port().get_id()}


class PortUpdate(PortEvent):
REQUIRED_ACCESS = core_api.ACCESS_LEVEL_VIEWONLY
TYPE = "port-update"

async def get_params(self) -> GenericJSONDict:
async def make_params(self) -> GenericJSONDict:
return await self.get_port().to_json()

def is_duplicate(self, event: Event) -> bool:
Expand All @@ -60,5 +60,5 @@ def __init__(self, old_value: NullablePortValue, new_value: NullablePortValue, *

super().__init__(*args, **kwargs)

async def get_params(self) -> GenericJSONDict:
async def make_params(self) -> GenericJSONDict:
return {"id": self.get_port().get_id(), "value": self.new_value, "old_value": self.old_value}
2 changes: 1 addition & 1 deletion qtoggleserver/frontend/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,5 @@ def __init__(self, panels: GenericJSONList, **kwargs) -> None:

super().__init__(**kwargs)

async def get_params(self) -> GenericJSONDict:
async def make_params(self) -> GenericJSONDict:
return {"panels": self.panels}
6 changes: 2 additions & 4 deletions qtoggleserver/peripherals/__init__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import asyncio
import hashlib
import logging

from collections.abc import ValuesView
from typing import Any

from qtoggleserver import persist
from qtoggleserver.conf import settings
from qtoggleserver.core.ports import BasePort
from qtoggleserver.utils import dynload as dynload_utils

from .exceptions import DuplicatePeripheral, NoSuchDriver
Expand Down Expand Up @@ -91,9 +93,6 @@ async def prepare_migration(
- Phase 1 (prepare): Copy data to new IDs, keep old data intact
- Phase 2 (cleanup): Delete old data only after successful update
"""
import hashlib

from qtoggleserver.core.ports import BasePort

# Compute what the new peripheral ID will be using the same logic as Peripheral.__init__
new_id: str = new_name or ""
Expand Down Expand Up @@ -141,7 +140,6 @@ async def cleanup_migration(p: Peripheral, new_name: str | None) -> None:
Deletes old port persist data and old peripheral persist entry.
Only call this after the new peripheral has been successfully created and initialized.
"""
from qtoggleserver.core.ports import BasePort

old_id = p.get_id()

Expand Down
6 changes: 3 additions & 3 deletions qtoggleserver/peripherals/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,23 +22,23 @@ class PeripheralAdd(PeripheralEvent):
REQUIRED_ACCESS = core_api.ACCESS_LEVEL_ADMIN
TYPE = "peripheral-add"

async def get_params(self) -> GenericJSONDict:
async def make_params(self) -> GenericJSONDict:
return self.get_peripheral().to_json()


class PeripheralRemove(PeripheralEvent):
REQUIRED_ACCESS = core_api.ACCESS_LEVEL_ADMIN
TYPE = "peripheral-remove"

async def get_params(self) -> GenericJSONDict:
async def make_params(self) -> GenericJSONDict:
return {"id": self.get_peripheral().get_id()}


class PeripheralUpdate(PeripheralEvent):
REQUIRED_ACCESS = core_api.ACCESS_LEVEL_ADMIN
TYPE = "peripheral-update"

async def get_params(self) -> GenericJSONDict:
async def make_params(self) -> GenericJSONDict:
return self.get_peripheral().to_json()

def is_duplicate(self, event: core_events.Event) -> bool:
Expand Down
2 changes: 2 additions & 0 deletions qtoggleserver/slaves/devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -1199,6 +1199,7 @@ async def _handle_offline(self) -> None:
# Trigger a port-update so that online attribute is pushed to consumers
for port in self._get_local_ports():
if port.is_enabled():
port.invalidate_attrs()
await port.trigger_update()

async def _handle_online(self) -> None:
Expand Down Expand Up @@ -1232,6 +1233,7 @@ async def _handle_online(self) -> None:
# Trigger a port-update so that online attribute is pushed to consumers
for port in self._get_local_ports():
if port.is_enabled():
port.invalidate_attrs()
await port.trigger_update()

if not self._ready:
Expand Down
6 changes: 3 additions & 3 deletions qtoggleserver/slaves/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,23 +26,23 @@ class SlaveDeviceAdd(SlaveDeviceEvent):
REQUIRED_ACCESS = core_api.ACCESS_LEVEL_ADMIN
TYPE = "slave-device-add"

async def get_params(self) -> GenericJSONDict:
async def make_params(self) -> GenericJSONDict:
return self.get_slave().to_json()


class SlaveDeviceRemove(SlaveDeviceEvent):
REQUIRED_ACCESS = core_api.ACCESS_LEVEL_ADMIN
TYPE = "slave-device-remove"

async def get_params(self) -> GenericJSONDict:
async def make_params(self) -> GenericJSONDict:
return {"name": self.get_slave().get_name()}


class SlaveDeviceUpdate(SlaveDeviceEvent):
REQUIRED_ACCESS = core_api.ACCESS_LEVEL_ADMIN
TYPE = "slave-device-update"

async def get_params(self) -> GenericJSONDict:
async def make_params(self) -> GenericJSONDict:
return self.get_slave().to_json()

def is_duplicate(self, event: core_events.Event) -> bool:
Expand Down
1 change: 1 addition & 0 deletions qtoggleserver/slaves/ports.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,7 @@ def heart_beat_second(self) -> None:
self.debug("value expired")

if not self._trigger_update_task:
self.invalidate_attrs()
self._trigger_update_task = asyncio.create_task(self.trigger_update())

async def from_persisted(self, data: GenericJSONDict) -> None:
Expand Down
28 changes: 14 additions & 14 deletions qtoggleserver/startup.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,19 +323,6 @@ async def init_main() -> None:
logger.info("initializing main")
await main.init()

# Wait until slaves are also ready before actually considering main loop ready
if settings.slaves.enabled:
logger.debug("waiting for slaves to become ready")
while not slaves_devices.ready():
await asyncio.sleep(1)

logger.debug("slaves are ready")

# Mark main as ready after all slaves with their ports have been initialized and hopefully brought online. Allow an
# extra second for pending loop tasks.
await asyncio.sleep(1)
main.set_ready()


async def cleanup_main() -> None:
logger.info("cleaning up main")
Expand Down Expand Up @@ -370,11 +357,24 @@ async def init() -> None:
await init_device()
await init_webhooks()
await init_reverse()
await init_main()
await init_ports()
await init_slaves()
await init_main()
await init_web()

# Wait until slaves are also ready before actually considering main loop ready
if settings.slaves.enabled:
logger.debug("waiting for slaves to become ready")
while not slaves_devices.ready():
await asyncio.sleep(1)

logger.debug("slaves are ready")

# Mark main as ready after all slaves with their ports have been initialized and hopefully brought online. Allow an
# extra second for pending loop tasks.
await asyncio.sleep(1)
main.set_ready()


async def cleanup() -> None:
await cleanup_web()
Expand Down
10 changes: 4 additions & 6 deletions qtoggleserver/utils/expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,10 @@ def invalidate_deps_map() -> None:
async def build_context(now_ms: int) -> EvalContext:
"""Build an expression evaluation context for the current system state.

Gathers port values and attributes for all enabled ports, collects device-level attributes,
and includes slave device attributes if slaves are enabled. Returns a complete EvalContext
ready for expression evaluation.
Gathers port values and attributes for all ports (including disabled ones; disabled ports are
only special-cased for port *value* expressions, not attribute expressions), collects
device-level attributes, and includes slave device attributes if slaves are enabled. Returns a
complete EvalContext ready for expression evaluation.

Args:
now_ms: Current time in milliseconds since epoch.
Expand All @@ -51,9 +52,6 @@ async def build_context(now_ms: int) -> EvalContext:
port_values = {}
port_attrs = {}
for port in core_ports.get_all():
if not port.is_enabled():
continue

port_id = port.get_id()
port_values[port_id] = port.get_last_value()
port_attrs[port_id] = await port.get_attrs()
Expand Down
30 changes: 29 additions & 1 deletion qtoggleserver/utils/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,24 @@ class AttrChangeHandler(core_events.Handler):

Dep strings produced:
- ``$port_id:`` — for "port-add", "port-remove", "port-update"
- ``$port_id`` — additionally, for "port-add"/"port-remove"/"port-update", whenever a port's availability
(`enabled`/`online`) transitions, in either direction, relative to its last known state — so that value
expressions and functions like `AVAILABLE()`/`DEFAULT()` pick up the new state
- ``#:`` — for "device-update"
- ``#name:`` — for "slave-device-add", "slave-device-remove", "slave-device-update"
"""

FIRE_AND_FORGET = False

# Attribute names whose transition (in either direction) changes whether a port's *value* is available, and
# therefore must also be treated as a change of the port's value dependency (`$port_id`), not just its attribute
# dependency (`$port_id:`).
AVAILABILITY_ATTRS = ("enabled", "online")

def __init__(self) -> None:
super().__init__(name="attribute-changes")
self._pending: set[str] = set()
self._last_availability: dict[tuple[str, str], bool] = {}

def pop_pending(self) -> set[str]:
"""Return the pending changes and clear the internal set."""
Expand All @@ -29,7 +38,26 @@ def pop_pending(self) -> set[str]:

async def handle_event(self, event: core_events.Event) -> None:
if isinstance(event, (core_events.PortAdd, core_events.PortRemove, core_events.PortUpdate)):
self._pending.add(f"${event.get_port().get_id()}:")
port_id = event.get_port().get_id()
self._pending.add(f"${port_id}:")

if isinstance(event, core_events.PortRemove):
for attr_name in self.AVAILABILITY_ATTRS:
# Whenever one of the availability attributes changes, induce a `value-change`-like event so that
# expressions depending on this port's value are re-evaluated.
was_available = self._last_availability.pop((port_id, attr_name), False)
if was_available:
self._pending.add(f"${port_id}")
else:
# Also covers "port-add" — see class docstring.
params = event.get_params()
for attr_name in self.AVAILABILITY_ATTRS:
# Whenever one of the availability attributes changes, induce a `value-change`-like event so that
# expressions depending on this port's value are re-evaluated.
available = bool(params.get(attr_name))
if available != self._last_availability.get((port_id, attr_name), False):
self._pending.add(f"${port_id}")
self._last_availability[(port_id, attr_name)] = available
elif isinstance(event, core_events.DeviceUpdate):
self._pending.add("#:")
elif isinstance(
Expand Down
Loading
Loading