Skip to content
Open
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
6 changes: 6 additions & 0 deletions custom_components/zone_mapper/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
ATTR_CX,
ATTR_CY,
ATTR_DATA,
ATTR_INPUT_UNITS,
ATTR_NAME,
ATTR_POINTS,
ATTR_ROTATION_DEG,
Expand Down Expand Up @@ -52,6 +53,7 @@
SHAPE_RECT,
STORE_ENTITIES,
STORE_ZONES,
SUPPORTED_INPUT_UNITS,
SUPPORTED_SHAPES,
WARN_ELLIPSE_INVALID,
WARN_ELLIPSE_NON_POSITIVE,
Expand Down Expand Up @@ -394,6 +396,7 @@ async def handle_update_zone(call: ServiceCall) -> None:
data = call.data.get("data")
entities = _normalize_entities(call.data.get("entities"))
rotation = _sanitize_rotation(call.data.get(ATTR_ROTATION_DEG))
input_units = call.data.get(ATTR_INPUT_UNITS)
zone_name = _coerce_zone_name(call.data.get("name"))
delete_zone = bool(call.data.get("delete"))

Expand All @@ -404,6 +407,8 @@ async def handle_update_zone(call: ServiceCall) -> None:

if entities is not None:
store[STORE_ENTITIES] = entities
if input_units in SUPPORTED_INPUT_UNITS:
store[ATTR_INPUT_UNITS] = input_units

if delete_zone and zone_id is not None:
_remove_zone(hass, location, zone_id)
Expand Down Expand Up @@ -458,6 +463,7 @@ async def handle_update_zone(call: ServiceCall) -> None:
vol.Optional("shape"): vol.In(list(SUPPORTED_SHAPES)),
vol.Optional("data"): vol.Any(None, dict),
vol.Optional(ATTR_ROTATION_DEG): vol.Coerce(float),
vol.Optional(ATTR_INPUT_UNITS): vol.In(list(SUPPORTED_INPUT_UNITS)),
vol.Optional("name"): cv.string,
vol.Optional("delete"): cv.boolean,
vol.Optional("entities"): vol.All(
Expand Down
78 changes: 62 additions & 16 deletions custom_components/zone_mapper/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
ATTR_CX,
ATTR_CY,
ATTR_DATA,
ATTR_INPUT_UNITS,
ATTR_POINTS,
ATTR_ROTATION_DEG,
ATTR_RX,
Expand All @@ -50,6 +51,25 @@
_LOGGER = logging.getLogger(__name__)

_LINE_INTERSECTION_EPSILON = 1e-12
_UNIT_ALIASES = {
"millimeter": "mm",
"millimeters": "mm",
"millimetre": "mm",
"millimetres": "mm",
"centimeter": "cm",
"centimeters": "cm",
"centimetre": "cm",
"centimetres": "cm",
"meter": "m",
"meters": "m",
"metre": "m",
"metres": "m",
"inch": "in",
"inches": "in",
"foot": "ft",
"feet": "ft",
}
_UNIT_TO_MM = {"mm": 1.0, "cm": 10.0, "m": 1000.0, "in": 25.4, "ft": 304.8}

ShapeData = Mapping[str, Any] | None
ShapeTester = Callable[[float, float, ShapeData], bool]
Expand All @@ -64,6 +84,27 @@ def _coerce_float(value: Any) -> float | None:
return None


def _normalize_unit(value: Any) -> str | None:
if not isinstance(value, str):
return None
unit = value.strip().lower()
unit = _UNIT_ALIASES.get(unit, unit)
return unit if unit in _UNIT_TO_MM else None


def _convert_to_mm(value: Any, unit: Any) -> float | None:
numeric = _coerce_float(value)
normalized_unit = _normalize_unit(unit)
if numeric is None or normalized_unit is None:
return None
return numeric * _UNIT_TO_MM[normalized_unit]


def _state_unit(state: State, fallback_unit: Any) -> str:
state_unit = _normalize_unit(state.attributes.get("unit_of_measurement"))
return state_unit or _normalize_unit(fallback_unit) or "mm"


def _slugify_location(location: str) -> str:
return slugify(str(location))

Expand Down Expand Up @@ -314,7 +355,7 @@ def handle_entity_update(self, _event: Event[EventStateChangedData]) -> None:

async def async_update(self) -> None:
"""Fetch new state data for the sensor."""
zone_def, rotation_raw = self._resolve_zone_definition()
zone_def, rotation_raw, input_units = self._resolve_zone_definition()

if not self._tracked_entities or zone_def is None:
self._is_on = False
Expand All @@ -332,7 +373,7 @@ async def async_update(self) -> None:

rotate_point = _build_point_rotator(rotation_raw)

for x_val, y_val in self._iter_rotated_coordinates(rotate_point):
for x_val, y_val in self._iter_rotated_coordinates(rotate_point, input_units):
if shape_tester(x_val, y_val, data):
self._is_on = True
return
Expand All @@ -352,33 +393,35 @@ def _iter_tracked_entity_pairs(self) -> Iterator[tuple[str, str]]:
yield x_id, y_id

def _iter_rotated_coordinates(
self, rotate: Callable[[float, float], tuple[float, float]]
self,
rotate: Callable[[float, float], tuple[float, float]],
input_units: Any,
) -> Iterator[tuple[float, float]]:
for x_id, y_id in self._iter_tracked_entity_pairs():
coords = self._get_coordinate_pair(x_id, y_id)
coords = self._get_coordinate_pair(x_id, y_id, input_units)
if coords is None:
continue
yield rotate(*coords)

def _get_coordinate_pair(
self, x_entity_id: str, y_entity_id: str
self, x_entity_id: str, y_entity_id: str, input_units: Any
) -> tuple[float, float] | None:
x_state = self.hass.states.get(x_entity_id)
y_state = self.hass.states.get(y_entity_id)
if not self._states_are_valid(x_state, y_state):
return None
if x_state is None or y_state is None:
return None
try:
x_val = float(x_state.state)
y_val = float(y_state.state)
except (TypeError, ValueError):
x_unit = _state_unit(x_state, input_units)
y_unit = _state_unit(y_state, input_units)
x_val = _convert_to_mm(x_state.state, x_unit)
y_val = _convert_to_mm(y_state.state, y_unit)
if x_val is None or y_val is None:
return None
else:
# Ignore origin (0,0) so default or uninitialized readings are skipped.
if x_val == 0.0 and y_val == 0.0:
return None
return x_val, y_val
# Ignore origin (0,0) so default or uninitialized readings are skipped.
if x_val == 0.0 and y_val == 0.0:
return None
return x_val, y_val

@staticmethod
def _states_are_valid(x_state: State | None, y_state: State | None) -> bool:
Expand All @@ -389,12 +432,14 @@ def _states_are_valid(x_state: State | None, y_state: State | None) -> bool:
and y_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE)
)

def _resolve_zone_definition(self) -> tuple[Mapping[str, Any] | None, Any]:
def _resolve_zone_definition(
self,
) -> tuple[Mapping[str, Any] | None, Any, Any]:
integration = self.hass.data.get(DOMAIN, {})
locations = integration.get(DATA_LOCATIONS, {})
device_store = locations.get(self._location_name, {})
if not isinstance(device_store, Mapping):
return None, None
return None, None, None
zone_store = device_store.get(STORE_ZONES, {})
if isinstance(zone_store, Mapping):
zone_def = zone_store.get(self._zone_id)
Expand All @@ -404,4 +449,5 @@ def _resolve_zone_definition(self) -> tuple[Mapping[str, Any] | None, Any]:
return (
resolved_zone,
device_store.get(ATTR_ROTATION_DEG),
device_store.get(ATTR_INPUT_UNITS),
)
2 changes: 2 additions & 0 deletions custom_components/zone_mapper/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
ATTR_RX = "rx"
ATTR_RY = "ry"
ATTR_ROTATION_DEG = "rotation_deg"
ATTR_INPUT_UNITS = "input_units"
ATTR_NAME = "name"

# Shapes
Expand All @@ -38,6 +39,7 @@
SHAPE_POLYGON = "polygon"
SHAPE_NONE = "none"
SUPPORTED_SHAPES = (SHAPE_NONE, SHAPE_RECT, SHAPE_ELLIPSE, SHAPE_POLYGON)
SUPPORTED_INPUT_UNITS = ("mm", "cm", "m", "in", "ft")

# Limits / defaults
POLYGON_MAX_POINTS = 32
Expand Down
9 changes: 9 additions & 0 deletions custom_components/zone_mapper/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from .const import (
ATTR_DATA,
ATTR_INPUT_UNITS,
ATTR_NAME,
ATTR_ROTATION_DEG,
ATTR_SHAPE,
Expand All @@ -23,6 +24,7 @@
EVENT_ZONE_UPDATED,
STORE_ENTITIES,
STORE_ZONES,
SUPPORTED_INPUT_UNITS,
)

_LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -161,6 +163,10 @@ def extra_state_attributes(self) -> dict[str, Any]:
if rotation is not None:
base[ATTR_ROTATION_DEG] = rotation

input_units = loc.get(ATTR_INPUT_UNITS)
if input_units in SUPPORTED_INPUT_UNITS:
base[ATTR_INPUT_UNITS] = input_units

zones = loc.get(STORE_ZONES, {})
zone_def = zones.get(self._zone_id) if isinstance(zones, Mapping) else None
name = zone_def.get(ATTR_NAME) if isinstance(zone_def, Mapping) else None
Expand Down Expand Up @@ -195,6 +201,7 @@ async def async_added_to_hass(self) -> None:
data = last_state.attributes.get(ATTR_DATA)
entities = _normalize_entity_pairs(last_state.attributes.get("entities"))
rotation = last_state.attributes.get(ATTR_ROTATION_DEG)
input_units = last_state.attributes.get(ATTR_INPUT_UNITS)
zname = last_state.attributes.get(ATTR_NAME)

# Seed hass.data so binary sensors can evaluate immediately
Expand All @@ -208,6 +215,8 @@ async def async_added_to_hass(self) -> None:
if rotation is not None:
with suppress(TypeError, ValueError):
store[ATTR_ROTATION_DEG] = round(float(rotation))
if input_units in SUPPORTED_INPUT_UNITS:
store[ATTR_INPUT_UNITS] = input_units

# Cache locally for our attributes
self._coords = {ATTR_SHAPE: shape, ATTR_DATA: data}
Expand Down
12 changes: 12 additions & 0 deletions custom_components/zone_mapper/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,18 @@ update_zone:
min: -180
max: 180
step: 1
input_units:
name: Input units
description: Optional units used by tracked X/Y entities. Entity unit metadata takes precedence; values are converted to millimetres for zone evaluation.
required: false
selector:
select:
options:
- mm
- cm
- m
- in
- ft
entities:
name: Tracked Entities
description: Optional list of x/y entity id pairs to evaluate presence. Location-wide; replaces the list for this location.
Expand Down