Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 59 additions & 2 deletions OWNd/message/automation.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,13 +176,70 @@ def stop_shutter(cls, where: str | int) -> OWNAutomationCommand:
@classmethod
def set_shutter_level(cls, where: str | int, level: int = 30) -> OWNAutomationCommand:
target = require_non_empty_where(where)
lvl = int(level)
if not 0 <= lvl <= 100:
if (
isinstance(level, bool)
or (isinstance(level, float) and level % 1 != 0)
or not 0 <= int(level) <= 100
):
raise ValueError("shutter level must be between 0 and 100")
lvl = int(level)
message = cls(f"*#2*{target}*#11#001*{lvl}##")
message._human_readable_log = f"Setting shutter {message._where}{message._interface_log_text} position to {lvl}%."
return message

@property
def is_status_query(self) -> bool:
"""True if this message is a shutter status query (*#2*WHERE##)."""
return self.is_status_request and bool(self._where)

@property
def is_shutter_status_query(self) -> bool:
"""True if this message is an advanced shutter status query (*#2*WHERE*10##)."""
return (
self.is_dimension_request
and self._dimension == 10
and bool(self._where)
)

@classmethod
def report_shutter_level(
cls,
where: str | int,
level: int,
state: int = 10,
priority: int | str = 0,
info: int = 0,
) -> OWNAutomationCommand:
"""Build a WHO 2 Dimension 10 advanced shutter status reply (*#2*WHERE*10*STATE*LEVEL*PRIORITY*INFO##)."""
target = require_non_empty_where(where)
if any(
isinstance(x, bool) or (isinstance(x, float) and x % 1 != 0)
for x in (level, state, priority, info)
):
raise ValueError("shutter level, state, priority, and info must be integers")
lvl = int(level)
if not (0 <= lvl <= 100 or lvl == 255):
raise ValueError("shutter level must be between 0 and 100 or 255")
st = int(state)
if not 10 <= st <= 15:
raise ValueError("shutter state must be between 10 and 15")
pr = int(priority)
inf = int(info)
if not (0 <= pr <= 255 and 0 <= inf <= 255):
raise ValueError("priority and info must be between 0 and 255")
pr_str = (
priority.strip()
if isinstance(priority, str)
and priority.strip().isdigit()
and len(priority.strip()) <= 3
else str(pr)
)
message = cls(f"*#2*{target}*10*{st}*{lvl}*{pr_str}*{inf}##")
message._human_readable_log = (
f"Reporting shutter {message._where}{message._interface_log_text} position at {lvl}% (state {st})."
)
return message


register_event_parser(2, OWNAutomationEvent)
register_command_parser(2, OWNAutomationCommand)
25 changes: 25 additions & 0 deletions OWNd/message/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,31 @@ def is_request(self) -> bool:
def is_translation(self) -> bool:
return self._family == "COMMAND_TRANSLATION"

@property
def is_status_request(self) -> bool:
return getattr(self, "_message_type", None) == "STATUS_REQUEST"

@property
def is_dimension_request(self) -> bool:
return getattr(self, "_message_type", None) == "DIMENSION_REQUEST"

@property
def is_dimension_writing(self) -> bool:
return getattr(self, "_message_type", None) == "DIMENSION_WRITING"

@property
def is_dimension_reply(self) -> bool:
return getattr(self, "_message_type", None) == "DIMENSION_REQUEST_REPLY"

@property
def effective_what(self) -> int | None:
"""The effective 'what' ID, unwrapping Command Translation (1000#WHAT) frames."""
what = getattr(self, "_what", None)
what_param = getattr(self, "_what_param", [])
if what == 1000 and what_param:
return int(what_param[0])
return what

@property
def is_valid(self) -> bool:
return self._is_valid_message
Expand Down
158 changes: 158 additions & 0 deletions OWNd/message/energy.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import datetime
import math
import re
from typing import Any

Expand Down Expand Up @@ -464,6 +465,163 @@ def get_total_consumption(cls, where: str | int) -> OWNEnergyCommand:
)
return message

@property
def is_dimension_query(self) -> bool:
"""True if this message is a WHO 18 dimension request (*#18*WHERE*DIM##)."""
return self.is_dimension_request and bool(self._where)

@property
def is_auto_update_command(self) -> bool:
"""True if this message is a WHO 18 Dimension 1200 streaming write (*#18*WHERE*#1200#TYPE*TIME##)."""
return (
self.is_dimension_writing
and self._dimension == 1200
and bool(self._where)
)

@property
def stream_duration(self) -> int | None:
"""Decoded stream duration in minutes (0..255) for a Dimension 1200 command, or None."""
if self._dimension == 1200 and self._dimension_value:
return max(0, min(255, _integer_value(self._dimension_value, 0)))
return None

@classmethod
def report_active_power(
cls, where: str | int, watts: int | float
) -> OWNEnergyCommand:
"""Build a WHO 18 Dimension 113 active power report (*#18*WHERE*113*W##)."""
target = _normalize_actuator_where(where)
w = _validate_non_negative_energy(watts, "watts")
message = cls(f"*#18*{target}*113*{w}##")
message._human_readable_log = (
f"Reporting active power of {w} W for sensor {target}."
)
return message

@classmethod
def report_total_energy(
cls, where: str | int, wh: int | float
) -> OWNEnergyCommand:
"""Build a WHO 18 Dimension 51 total energy report (*#18*WHERE*51*Wh##)."""
target = _normalize_actuator_where(where)
val = _validate_non_negative_energy(wh, "wh")
message = cls(f"*#18*{target}*51*{val}##")
message._human_readable_log = (
f"Reporting total energy consumption of {val} Wh for sensor {target}."
)
return message

@classmethod
def report_current_month_energy(
cls,
where: str | int,
wh: int | float,
year: int | None = None,
month: int | None = None,
) -> OWNEnergyCommand:
"""Build a WHO 18 Dimension 52 monthly energy report (*#18*WHERE*52[#YY#M]*Wh##)."""
target = _normalize_actuator_where(where)
val = _validate_non_negative_energy(wh, "wh")
if (year is None) != (month is None):
raise ValueError("year and month must both be provided or both omitted")
if year is not None and month is not None:
if any(
isinstance(x, bool) or (isinstance(x, float) and x % 1 != 0)
for x in (year, month)
):
raise ValueError("year and month must be integers")
y = int(year)
m = int(month)
if not 2000 <= y <= 2099:
raise ValueError("year must be between 2000 and 2099")
if not 1 <= m <= 12:
raise ValueError("month must be between 1 and 12")
message = cls(f"*#18*{target}*52#{y % 100:02d}#{m}*{val}##")
else:
message = cls(f"*#18*{target}*52*{val}##")
message._human_readable_log = (
f"Reporting monthly energy consumption of {val} Wh for sensor {target}."
)
return message

@classmethod
def report_partial_month_energy(
cls, where: str | int, wh: int | float, month: int | None = None
) -> OWNEnergyCommand:
"""Build a WHO 18 Dimension 53 current-month partial energy report (*#18*WHERE*53[#M]*Wh##)."""
target = _normalize_actuator_where(where)
val = _validate_non_negative_energy(wh, "wh")
if month is not None:
if (
isinstance(month, bool)
or (isinstance(month, float) and month % 1 != 0)
or not 1 <= int(month) <= 12
):
raise ValueError("month must be between 1 and 12")
message = cls(f"*#18*{target}*53#{int(month)}*{val}##")
else:
message = cls(f"*#18*{target}*53*{val}##")
message._human_readable_log = (
f"Reporting partial monthly energy consumption of {val} Wh for sensor {target}."
)
return message

@classmethod
def report_partial_day_energy(
cls, where: str | int, wh: int | float, day: int | None = None
) -> OWNEnergyCommand:
"""Build a WHO 18 Dimension 54 current-day partial energy report (*#18*WHERE*54[#D]*Wh##)."""
target = _normalize_actuator_where(where)
val = _validate_non_negative_energy(wh, "wh")
if day is not None:
if (
isinstance(day, bool)
or (isinstance(day, float) and day % 1 != 0)
or not 1 <= int(day) <= 31
):
raise ValueError("day must be between 1 and 31")
message = cls(f"*#18*{target}*54#{int(day)}*{val}##")
else:
message = cls(f"*#18*{target}*54*{val}##")
message._human_readable_log = (
f"Reporting partial daily energy consumption of {val} Wh for sensor {target}."
)
return message

@classmethod
def report_streaming_state(
cls, where: str | int, duration: int, energy_type: int = 1
) -> OWNEnergyCommand:
"""Build a WHO 18 Dimension 1200 streaming confirmation/expiry reply (*#18*WHERE*1200#TYPE*TIME##)."""
target = _normalize_actuator_where(where)
if any(
isinstance(x, bool) or (isinstance(x, float) and x % 1 != 0)
for x in (duration, energy_type)
):
raise ValueError("duration and energy_type must be integers")
et = int(energy_type)
if not 1 <= et <= 4:
raise ValueError("energy_type must be between 1 and 4")
dur = int(duration)
if not 0 <= dur <= 255:
raise ValueError("duration must be between 0 and 255")
message = cls(f"*#18*{target}*1200#{et}*{dur}##")
message._human_readable_log = (
f"Reporting instant power stream state ({dur} min) for sensor {target}."
)
return message


def _validate_non_negative_energy(value: int | float, name: str) -> int:
"""Validate that an energy or power value is a finite non-negative number."""
if isinstance(value, bool):
raise ValueError(f"{name} must be a finite non-negative number")
fval = float(value)
if not math.isfinite(fval) or fval < 0:
raise ValueError(f"{name} must be a finite non-negative number")
return int(round(fval))


def _normalize_stop_and_go_where(where: str | int) -> str:
"""Normalize Stop&Go line address to 1N format (11..19)."""
Expand Down
Loading
Loading