Source code for pyisyox.schema.editor

"""Editor dataclasses and bidirectional codec for IoX profile editors.

An *editor* (e.g. ``"I_OL"``, ``"I_TSTAT_MODE"``) is referenced by both
nodedef properties and command parameters and defines a bidirectional
contract — read-side decode and write-side validation/encode. Write-side
constraints beyond ``min``/``max``/``prec`` are the ``subset`` mask
(``"0-3,5-7"`` excludes 4) and the ``names`` enum option list.

Send-side scaling
-----------------

The controller does **all** device-side scaling itself, keyed off the
UOM appended to the ``/cmd`` URL — proven on hardware:
``/cmd/DON/100/100`` → 39 % (100 read as a UOM-100 0-255 byte) vs
``/cmd/DON/100/51`` → 100 % (100 read as UOM-51 percent). So the codec
**validates** input (enum-name resolution, ``min``/``max``, ``subset``)
but sends the *displayed* value verbatim with its range UOM; it does
**not** rewrite the number (no ``*10**prec`` rescale, no half-degree doubling).
The eisy web UI does the same (``/cmd/setTemp/10.4/17``). ``decode``
keeps its precision-aware formatting for display helpers, but the
property read path normalises by the wire UOM and never calls it.
"""

from __future__ import annotations

from dataclasses import dataclass, field

#: UOMs that use the legacy "raw is 2x displayed" half-degree encoding.
#: ``101`` is the IoX 6+ id; ``"degrees"`` is the ISY-v4 alias kept for
#: legacy profiles. Mirrored in :mod:`pyisyox.helpers` for the
#: ``/rest/status`` decode path.
_HALF_DEGREE_UOMS = frozenset({"101", "degrees"})


def _parse_subset(spec: str, lo_default: int | None = None, hi_default: int | None = None) -> set[int]:
    """Expand a subset spec like ``"0-3,5-7"`` into ``{0,1,2,3,5,6,7}``.

    Some IoX 6.x firmware (eisy 6.0.5) emits an open-ended bound —
    ``"5-"``, ``"-7"``, ``"-"`` — resolved against ``lo_default`` /
    ``hi_default`` (the editor's min/max or names-index extremes). A
    piece that still can't resolve (or is garbage) is skipped rather
    than raising and aborting the whole profile load.
    """
    out: set[int] = set()
    for raw_piece in spec.split(","):
        piece = raw_piece.strip()
        if not piece:
            continue
        try:
            if "-" in piece:
                lo_s, hi_s = (p.strip() for p in piece.split("-", 1))
                lo = int(lo_s) if lo_s else lo_default
                hi = int(hi_s) if hi_s else hi_default
                if lo is None or hi is None:
                    continue
                out.update(range(lo, hi + 1))
            else:
                out.add(int(piece))
        except ValueError:
            continue
    return out


def _decode_signed_bound(token: str) -> int:
    """Decode an encoded-editor-id numeric bound; a leading ``m`` = negative."""
    return -int(token[1:]) if token.startswith("m") else int(token)


def _subset_from_hex_masks(low_hex: str, high_hex: str | None) -> set[int]:
    """Decode the ``_S_`` bitmask form: bit *i* set ⇒ value *i* is valid.

    ``low_hex`` covers bits 0-31; the optional ``high_hex`` bits 32-63.
    e.g. ``"FF00FF00"`` → ``{8..15, 24..31}``.
    """
    out: set[int] = set()
    low = int(low_hex, 16)
    for i in range(32):
        if low & (1 << i):
            out.add(i)
    if high_hex is not None:
        high = int(high_hex, 16)
        for i in range(32):
            if high & (1 << i):
                out.add(32 + i)
    return out


[docs] @dataclass(slots=True) class EditorRange: """One range entry within an editor. An editor may carry multiple ranges (e.g., a temperature editor with Fahrenheit and Celsius variants), each tied to a distinct UOM. Attributes: uom: Unit-of-measure identifier (string, indexes into the IoX UOM table). min: Lower numeric bound for raw values (inclusive). ``None`` when the range is purely enumerative (subset only). max: Upper numeric bound (inclusive). precision: Decimal precision applied to raw values (e.g., raw ``6839`` with ``precision=4`` displays as ``0.6839``). The wire keys it as ``"prec"``; Python attribute spells it out. subset: Resolved set of valid raw integers, narrower than ``[min, max]``. Empty when the full ``[min, max]`` range is valid. names: Mapping of raw integer → display name for enumerated values (e.g., ``{0: "Off", 1: "Heat", 2: "Cool"}``). step: Increment hint for numeric (slider-shaped) ranges, in *displayed* units — e.g. ``0.5`` on a half-degree setpoint editor. ``None`` when the editor doesn't specify one (callers then derive a step from ``precision``). Not used by the codec; it's a UI hint, surfaced for consumers that build number entities. nls_prefix: The NLS string-table prefix this range's enum option names live under (the ``_N_<nls>`` segment of an encoded editor id, or a named editor's index nls). ``names`` stays empty until something resolves it against an NLS table (the controller does it inline for ``/rest/profiles`` families; :meth:`Profile.find_editor` does it for encoded editors when the profile has an NLS table loaded). """ uom: str min: float | None = None max: float | None = None precision: int = 0 subset: set[int] = field(default_factory=set) names: dict[int, str] = field(default_factory=dict) step: float | None = None nls_prefix: str | None = None
[docs] @classmethod def from_json(cls, raw: dict) -> EditorRange: """Build a range from a JSON object.""" subset_raw = raw.get("subset") names_raw = raw.get("names", {}) or {} names = {int(k): v for k, v in names_raw.items()} step_raw = raw.get("step") # Open-ended-bound floor/ceiling: own min/max, else names extremes. rng_min, rng_max = raw.get("min"), raw.get("max") lo_default = int(rng_min) if isinstance(rng_min, (int, float)) else (min(names) if names else None) hi_default = int(rng_max) if isinstance(rng_max, (int, float)) else (max(names) if names else None) return cls( uom=str(raw.get("uom", "0")), min=rng_min, max=rng_max, precision=int(raw.get("prec", 0)), subset=( _parse_subset(subset_raw, lo_default, hi_default) if isinstance(subset_raw, str) else set() ), names=names, step=float(step_raw) if isinstance(step_raw, (int, float)) else None, )
[docs] def is_valid(self, raw_value: int) -> bool: """True if ``raw_value`` is acceptable for outbound commands. Used for **subset** validation only (prec=0, enum-shaped editors). Numeric editors with ``prec>0`` validate the displayed value and send it as-is (no scaling — the controller scales device-side from the UOM; see :meth:`Editor.encode`). ``min``/``max`` in the IoX schema are stored in **displayed form** (e.g. ``min=5.0`` on a UOM-4 °C setpoint editor with ``prec=1`` means 5.0 °C, not raw 5). """ if self.subset: return raw_value in self.subset if self.min is not None and raw_value < self.min: return False return not (self.max is not None and raw_value > self.max)
[docs] class EditorCodecError(ValueError): """Raised when an editor codec cannot encode or decode a value."""
[docs] @dataclass(slots=True) class Editor: """A profile editor — bidirectional codec for property and parameter values. Encoding direction (``encode``): user input (int or enum name) → raw int suitable to send to the controller, with subset/range validation. Decoding direction (``decode``): raw int from the controller → display string (enum name if known, else formatted number with prec/uom). For multi-range editors the codec selects the range whose UOM matches a caller-supplied ``uom`` hint, falling back to the first range. Most editors carry a single range. """ id: str ranges: list[EditorRange] = field(default_factory=list)
[docs] @classmethod def from_json(cls, raw: dict) -> Editor: """Build an :class:`Editor` from a JSON object.""" ranges = [EditorRange.from_json(r) for r in raw.get("ranges", [])] return cls(id=raw["id"], ranges=ranges)
[docs] @classmethod def from_encoded_id(cls, editor_id: str) -> Editor | None: """Decode a self-describing *encoded editor id* into an :class:`Editor`. IoX lets a simple editor be referenced by an id that fully encodes its (single) range instead of pointing at a named ``<editor>`` element — handy for the dynamically-generated Z-Wave nodedefs where most editors are spelled inline. The grammar (see https://developer.isy.io/docs/API/IoX/editors#encoded-editor-id): * ``_<uom>_<prec>`` — implied bounds ``[-2147483647, 2147483647]`` * optionally one of ``_R_<min>_<max>`` (numeric range; a leading ``m`` makes a bound negative — ``_17_2_R_m5_10`` => -5..10) or ``_S_<lowMask>[_<highMask>]`` (subset as a 32/64-bit hex bitmask — ``_17_1_S_FF00FF00`` ⇒ ``{8..15, 24..31}``) * optionally a trailing ``_N_<nls>`` NLS-prefix segment Returns ``None`` if ``editor_id`` doesn't parse as an encoding (so callers can fall back to a table lookup). The ``_N_<nls>`` segment is captured as ``EditorRange.nls_prefix`` but not resolved here — :meth:`Profile.find_editor` fills ``names`` from it when the profile carries an NLS table. Range / subset validation works regardless. """ if not editor_id.startswith("_"): return None parts = editor_id.split("_")[1:] # drop the leading empty token if len(parts) < 2 or not parts[0].isdigit() or not parts[1].isdigit(): return None uom, prec_s, rest = parts[0], parts[1], parts[2:] # Peel an optional trailing ``_N_<nls>`` (nls may itself contain "_"). nls_prefix: str | None = None if "N" in rest: nidx = rest.index("N") nls_prefix = "_".join(rest[nidx + 1 :]) or None rest = rest[:nidx] rng_min: float | None = None rng_max: float | None = None subset: set[int] = set() try: if not rest: pass elif rest[0] == "R" and len(rest) == 3: rng_min = _decode_signed_bound(rest[1]) rng_max = _decode_signed_bound(rest[2]) elif rest[0] == "S" and len(rest) in (2, 3): subset = _subset_from_hex_masks(rest[1], rest[2] if len(rest) == 3 else None) else: return None except ValueError: return None return cls( id=editor_id, ranges=[ EditorRange( uom=uom, min=rng_min, max=rng_max, precision=int(prec_s), subset=subset, nls_prefix=nls_prefix, ) ], )
[docs] def range_for(self, uom: str | None = None) -> EditorRange: """Pick the range matching ``uom``, or the first range if no hint.""" if not self.ranges: raise EditorCodecError(f"Editor {self.id!r} has no ranges") if uom is not None: for r in self.ranges: if r.uom == uom: return r return self.ranges[0]
[docs] def decode(self, raw_value: float, uom: str | None = None) -> str: """Decode a raw value to its display string. Enum lookup first (when ``names`` covers the value), otherwise a precision-aware numeric string. Does not append the unit — callers format the unit separately based on the range's ``uom``. When ``uom`` isn't given and the editor has multiple ranges, the enum-name lookup scans every range (so e.g. an editor whose first range is a 0-100 % scale and whose second is a tiny ``{1: "Previous Value"}`` index still decodes ``1`` to its name). UOM-101 / "degrees" with ``prec=0`` halves the raw value (Insteon half-degree convention). """ rng = self.range_for(uom) if isinstance(raw_value, int) or (isinstance(raw_value, float) and raw_value.is_integer()): ival = int(raw_value) if ival in rng.names: return rng.names[ival] if uom is None: for other in self.ranges: if ival in other.names: return other.names[ival] if rng.precision: return f"{raw_value / (10**rng.precision):.{rng.precision}f}" if rng.uom in _HALF_DEGREE_UOMS: return f"{raw_value / 2.0:.1f}" return str(raw_value)
[docs] def encode(self, value: float | str, uom: str | None = None) -> int | float: """Validate user input and return the value to put on the wire. Two paths within a range: * **Enum name (str matching ``names``)** — returns the matching raw int verbatim. ``min``/``max`` don't apply. * **Numeric (int/float, or string parsed as float)** — the *displayed* value. Validated against ``[min, max]`` and the ``subset`` mask (both stored in displayed form), then returned **as-is** (int when integral, else float). The controller does device-side scaling from the appended UOM — the codec does not rewrite the number (no ``*10**prec`` rescale, no half-degree doubling). When ``uom`` is given, only that range is tried. Otherwise every range is tried in order and the first that accepts ``value`` wins — multi-range editors like ``ZW_DIM_PERCENT`` (range 0 is a tiny ``{1: "Previous Value"}`` index, range 1 is the 0-100 % scale) need this so a plain ``75`` lands in the percent range instead of being rejected by the index range. Raises :class:`EditorCodecError` if no range accepts ``value``. """ return self.encode_param(value, uom)[0]
[docs] def encode_param(self, value: float | str, uom: str | None = None) -> tuple[int | float, str]: """Like :meth:`encode`, but also returns the UOM of the range used. Command-send code appends each parameter as ``/{value}/{uom}`` and the controller scales device-side from that UOM (proven: ``/cmd/DON/100/100`` → 39 %, ``/cmd/DON/100/51`` → 100 %), so the UOM has to be the one belonging to the range that actually accepted the value, not always ``ranges[0]`` — for a multi-range editor like ``ZW_DIM_PERCENT`` a plain ``75`` is encoded by the 0-100 % range (uom ``51``), so ``/cmd/DON/75/51`` goes on the wire, not ``/cmd/DON/75/25``. """ if uom is not None: rng = self.range_for(uom) return self._encode_in_range(rng, value), rng.uom ranges = self.ranges or [self.range_for()] # range_for() raises if truly empty last_error: EditorCodecError | None = None for rng in ranges: try: return self._encode_in_range(rng, value), rng.uom except EditorCodecError as exc: last_error = exc raise last_error or EditorCodecError(f"Editor {self.id!r}: cannot encode {value!r}")
def _encode_in_range(self, rng: EditorRange, value: float | str) -> int | float: """Validate ``value`` against a single range; return it as-is. Enum names resolve to their raw int. Numeric input is range- and subset-checked in *displayed* units and returned unchanged (int when integral so the URL stays ``/72`` not ``/72.0``, else float so a ``/cmd/setTemp/10.4/17`` survives). The controller does the precision / unit scaling from the appended UOM — see the module docstring. """ if isinstance(value, str): stripped = value.strip() if not stripped: raise EditorCodecError(f"Editor {self.id!r}: empty input") lowered = stripped.lower() inverse = {n.lower(): k for k, n in rng.names.items()} if lowered in inverse: return inverse[lowered] try: numeric: float = float(stripped) except ValueError as exc: valid = sorted(rng.names.values()) raise EditorCodecError( f"Editor {self.id!r}: {value!r} is not a recognised name (valid: {valid})" ) from exc else: numeric = float(value) if rng.min is not None and numeric < rng.min: raise EditorCodecError(f"Editor {self.id!r}: {numeric} is below min={rng.min}") if rng.max is not None and numeric > rng.max: raise EditorCodecError(f"Editor {self.id!r}: {numeric} is above max={rng.max}") # Subset masks are discrete integer/index sets (prec-0 index # editors; never co-occur with prec>0). Reject non-integral # input outright — a fractional index is meaningless and must # not reach the wire. if rng.subset: if not numeric.is_integer(): raise EditorCodecError( f"Editor {self.id!r}: {numeric} is not a valid index (subset {sorted(rng.subset)})" ) if int(numeric) not in rng.subset: raise EditorCodecError( f"Editor {self.id!r}: value {int(numeric)} is not in subset {sorted(rng.subset)}" ) return int(numeric) if numeric.is_integer() else numeric