diff --git a/custom_components/zone_mapper/__init__.py b/custom_components/zone_mapper/__init__.py index fbabea6..8065790 100644 --- a/custom_components/zone_mapper/__init__.py +++ b/custom_components/zone_mapper/__init__.py @@ -24,6 +24,7 @@ ATTR_CX, ATTR_CY, ATTR_DATA, + ATTR_INPUT_UNITS, ATTR_NAME, ATTR_POINTS, ATTR_ROTATION_DEG, @@ -52,6 +53,7 @@ SHAPE_RECT, STORE_ENTITIES, STORE_ZONES, + SUPPORTED_INPUT_UNITS, SUPPORTED_SHAPES, WARN_ELLIPSE_INVALID, WARN_ELLIPSE_NON_POSITIVE, @@ -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")) @@ -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) @@ -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( diff --git a/custom_components/zone_mapper/binary_sensor.py b/custom_components/zone_mapper/binary_sensor.py index eae6529..f731c57 100644 --- a/custom_components/zone_mapper/binary_sensor.py +++ b/custom_components/zone_mapper/binary_sensor.py @@ -26,6 +26,7 @@ ATTR_CX, ATTR_CY, ATTR_DATA, + ATTR_INPUT_UNITS, ATTR_POINTS, ATTR_ROTATION_DEG, ATTR_RX, @@ -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] @@ -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)) @@ -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 @@ -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 @@ -352,16 +393,18 @@ 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) @@ -369,16 +412,16 @@ def _get_coordinate_pair( 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: @@ -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) @@ -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), ) diff --git a/custom_components/zone_mapper/const.py b/custom_components/zone_mapper/const.py index 74e8dae..9a5615a 100644 --- a/custom_components/zone_mapper/const.py +++ b/custom_components/zone_mapper/const.py @@ -30,6 +30,7 @@ ATTR_RX = "rx" ATTR_RY = "ry" ATTR_ROTATION_DEG = "rotation_deg" +ATTR_INPUT_UNITS = "input_units" ATTR_NAME = "name" # Shapes @@ -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 diff --git a/custom_components/zone_mapper/sensor.py b/custom_components/zone_mapper/sensor.py index d0363b0..9ec7b1a 100644 --- a/custom_components/zone_mapper/sensor.py +++ b/custom_components/zone_mapper/sensor.py @@ -14,6 +14,7 @@ from .const import ( ATTR_DATA, + ATTR_INPUT_UNITS, ATTR_NAME, ATTR_ROTATION_DEG, ATTR_SHAPE, @@ -23,6 +24,7 @@ EVENT_ZONE_UPDATED, STORE_ENTITIES, STORE_ZONES, + SUPPORTED_INPUT_UNITS, ) _LOGGER = logging.getLogger(__name__) @@ -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 @@ -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 @@ -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} diff --git a/custom_components/zone_mapper/services.yaml b/custom_components/zone_mapper/services.yaml index e278681..f8fe6cb 100644 --- a/custom_components/zone_mapper/services.yaml +++ b/custom_components/zone_mapper/services.yaml @@ -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.