"""Runtime ``Variable`` — typed wrapper for IoX controller variables.
The IoX controller exposes two variable types — integer (``"1"``) and
state (``"2"``); each carries a current value, an init/restore-on-
startup value, decimal precision, a user-assigned name, and a last-
change timestamp. The wrapper surfaces those as read-only properties
plus three mutation coroutines (``set_value`` / ``set_init`` /
``rename``) that route through the controller's
``POST /api/variables/{type}/{id}`` endpoint.
Sourced from the parsed :class:`VariableRecord` in
:mod:`pyisyox.client`. Each :class:`Variable` instance shares the
underlying record with the controller's loaded state — local mutations
update the record in place, and WS variable-change frames (the
``<var>`` payload on ``_1`` action 6/7 events) likewise update the
record so reads always reflect the latest value.
"""
from __future__ import annotations
from dataclasses import asdict
from typing import TYPE_CHECKING, Any
from pyisyox.client import VariableField
if TYPE_CHECKING:
from pyisyox.client import IoXClient, VariableRecord
def _coerce_numeric(value: float | str) -> int | float:
"""Coerce a caller-supplied value to ``int | float`` for the wire.
Pass-through for ``int`` / ``float``. Strings are parsed as
``float`` if they carry a decimal point, otherwise ``int`` — this
matches the legacy PyISY 3.x contract that accepted string values
from generic callers.
"""
if isinstance(value, (int, float)) and not isinstance(value, bool):
return value
text = str(value).strip()
if "." in text or "e" in text.lower():
return float(text)
return int(text)
[docs]
class Variable:
"""User-facing handle for one controller variable."""
__slots__ = ("_client", "_record")
def __init__(self, record: VariableRecord, client: IoXClient) -> None:
"""Bind a :class:`VariableRecord` to the controller's HTTP client."""
self._record = record
self._client = client
[docs]
@classmethod
def from_record(cls, record: VariableRecord, client: IoXClient) -> Variable:
"""Construct a :class:`Variable` from a parsed record."""
return cls(record=record, client=client)
# --- introspection ------------------------------------------------
@property
def type_id(self) -> str:
"""Variable type — ``"1"`` (integer) or ``"2"`` (state)."""
return self._record.type_id
@property
def id(self) -> str:
"""Variable id within its type (string for ergonomic joins)."""
return self._record.id
@property
def address(self) -> str:
"""Composite ``"{type_id}.{id}"`` identifier."""
return self._record.address
@property
def name(self) -> str:
"""User-assigned label."""
return self._record.name
@property
def value(self) -> int | float:
"""Current value (wire field ``val``).
Reads reflect the latest write — mutations via :meth:`set_value`
update the underlying record in place after a successful POST.
Type is ``int | float``: most variables read back as ``int`` from
the wire (``/api/variables/{type}`` parses ``"val"`` as int), but
a controller may surface ``float`` on a fresh write that posted a
non-integer (the modern ``POST /api/variables/{type}/{id}``
endpoint accepts floats and the wrapper stores whatever was sent
on success).
"""
return self._record.value
@property
def init(self) -> int | float:
"""Restore-on-startup value. Same int-or-float surface as :attr:`value`."""
return self._record.init
@property
def precision(self) -> int:
"""Decimal precision. ``displayed = raw / 10**precision``."""
return self._record.precision
@property
def ts(self) -> str:
"""Last-change timestamp as the controller emits it.
ISO 8601 UTC string when present, ``""`` when the controller
doesn't stamp the entry (e.g. freshly created variables before
the first change).
"""
return self._record.ts
# --- mutation -----------------------------------------------------
[docs]
async def set_value(self, value: float) -> None:
"""Set the current value of this variable.
Wire shape: ``POST /api/variables/{type}/{id}`` with body
``{"value": <number>}``. The modern endpoint accepts both
``int`` and ``float`` — for a ``precision > 0`` variable, send
the *displayed* float (e.g. ``51.5``) and the controller
applies the ``* 10**precision`` scale on store. Sending an
``int`` on the same variable means the controller stores it
verbatim (no scale applied), which produces a mismatch
between consumer-displayed and controller-internal values — so
callers driving displayed-unit UIs should send floats.
Strings are tolerated for legacy callers (parsed as float if
they contain a decimal point, else int).
Updates the underlying record on success so subsequent reads
of :attr:`value` reflect the new state without waiting for a
WS frame.
"""
new_value = _coerce_numeric(value)
await self._client.post_variable_update(
self._record.type_id, self._record.id, {VariableField.VALUE: new_value}
)
self._record.value = new_value
[docs]
async def set_init(self, init: float) -> None:
"""Set the init / restore-on-startup value.
Wire shape: ``POST /api/variables/{type}/{id}`` with
``{"init": <number>}``. Same int-or-float semantics as
:meth:`set_value`.
"""
new_init = _coerce_numeric(init)
await self._client.post_variable_update(
self._record.type_id, self._record.id, {VariableField.INIT: new_init}
)
self._record.init = new_init
[docs]
async def rename(self, name: str) -> None:
"""Rename this variable on the controller.
Wire shape: ``POST /api/variables/{type}/{id}`` with
``{"name": "<str>"}``.
"""
await self._client.post_variable_update(
self._record.type_id, self._record.id, {VariableField.NAME: name}
)
self._record.name = name
[docs]
async def set_precision(self, prec: int) -> None:
"""Set decimal precision (``displayed = raw / 10**precision``).
Wire shape: ``POST /api/variables/{type}/{id}`` with
``{"prec": <int>}``.
A precision change fires ``_1``/``9`` (``VARIABLE_TABLE_CHANGED``)
on the WebSocket — *not* the per-value ``6``/``7`` frames — so
consumers that only listen on value/init updates won't see the
new precision until they refresh. The
:class:`pyisyox.Controller` auto-refreshes the affected type
on this event when its dispatcher is wired.
"""
if not isinstance(prec, int) or isinstance(prec, bool) or prec < 0:
raise ValueError(f"prec must be a non-negative int, got {prec!r}")
await self._client.post_variable_update(
self._record.type_id, self._record.id, {VariableField.PREC: prec}
)
self._record.precision = prec
[docs]
async def delete(self) -> None:
"""Delete this variable on the controller.
Wire shape: ``DELETE /api/variables/{type}/{id}``. Fires a
``VARIABLE_TABLE_CHANGED`` frame so an auto-refresh listener
can drop the entry from the registry; the wrapper itself
becomes inert (subsequent mutations would 404 — the wrapper
carries no flag for this; consumers should drop their
reference).
"""
await self._client.delete_variable(self._record.type_id, self._record.id)
[docs]
def to_dict(self) -> dict[str, Any]:
"""Flatten this variable to a JSON-compatible dict."""
return asdict(self._record)
def __repr__(self) -> str:
return f"Variable(type_id={self.type_id!r}, id={self.id!r}, name={self.name!r}, value={self.value})"