From 6c126a79cc60e5e7ae2c6f8180ee2a2a6d2ef98b Mon Sep 17 00:00:00 2001 From: Adam Dymek Date: Sun, 19 Jul 2026 12:00:00 +0200 Subject: [PATCH] Add possibility to per-map override a Custom Floor Plan --- .gitignore | 3 +- README.md | 34 +- .../roborock_custom_map/__init__.py | 64 +- .../roborock_custom_map/config_flow.py | 612 +++++++++++++++++- .../roborock_custom_map/const.py | 86 ++- .../roborock_custom_map/image.py | 322 +++++++-- .../roborock_custom_map/manifest.json | 4 +- .../roborock_custom_map/map_render.py | 377 +++++++++++ .../roborock_custom_map/preview.py | 303 +++++++++ .../roborock_custom_map/select.py | 53 +- .../roborock_custom_map/translations/de.json | 63 ++ .../roborock_custom_map/translations/en.json | 63 ++ 12 files changed, 1872 insertions(+), 112 deletions(-) create mode 100644 custom_components/roborock_custom_map/map_render.py create mode 100644 custom_components/roborock_custom_map/preview.py diff --git a/.gitignore b/.gitignore index 723ef36..3f1d055 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -.idea \ No newline at end of file +.idea +__pycache__/ diff --git a/README.md b/README.md index 7d1624f..080ba62 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Roborock Custom Map -you MUST be on 2025.4b or later +you MUST be on 2025.12.1 or later This allows you to use the core Roborock integration with the [Xiaomi Map Card](https://github.com/PiotrMachowski/lovelace-xiaomi-vacuum-map-card) @@ -26,27 +26,25 @@ map_source: calibration_source: camera: true ``` -### Map rotation (new) -If your map is displayed sideways or upside down, you can rotate the map directly in Home Assistant. +### Custom Floor Plan -This integration provides a **Select entity per map** to control rotation: -- `select.<...>_rotation` -- Options: `0°`, `90°`, `180°`, `270°` (labels depend on your HA language) +You can now override a Stock Floor Plan with your own Custom Floor Plan +(for example a tidied-up or stylized version). -This rotates **both**: -- the map image -- and the calibration points used by the Xiaomi Vacuum Map Card - (so rooms/zones and interactions stay aligned after rotation) +#### How? +1. `Settings` → `Devices & Services` → `Roborock Custom Map` → press `Configure`. +2. Pick a Floor (only if > 1 available). +3. Choose your Floor Plan (PNG, JPEG or WebP); a Live Preview appears as you do so. +4. Press `Submit` — the Floor Plan applies immediately as-is, with no adjustments. +5. On the next view, optionally adjust the horizontal/vertical offset/scale + of the Custom Floor Plan, or the relative rotation of the Physical Walls. + The Physical Walls are imposed over the Custom Floor Plan for your convenience. + The Live Preview refreshes in near-real-time. +6. Press `Submit` once more — to keep the adjustments, + or simply press `X` — to keep the Floor Plan as-is. -**How to use** -1. Go to **Settings → Devices & services → Roborock Custom Map** -2. Open the device/entities list -3. Find the `… rotation` select entity for your map and choose the correct rotation - -No reload is required; the map updates immediately. - -6. You can hit Edit on the card and then Generate Room Configs to allow for cleaning of rooms. It might generate extra keys, so check the yaml and make sure there are no extra 'predefined_sections' +Reopen `Configure` **at any time** to Adjust, Replace, or Remove the Custom Floor Plan. ### Installation diff --git a/custom_components/roborock_custom_map/__init__.py b/custom_components/roborock_custom_map/__init__.py index 88e1157..c12e5b5 100644 --- a/custom_components/roborock_custom_map/__init__.py +++ b/custom_components/roborock_custom_map/__init__.py @@ -2,16 +2,34 @@ from __future__ import annotations +import shutil + from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import CONF_MAP_ROTATION, DOMAIN +from .const import ( + CONF_BG_OVERRIDES, + CONF_MAP_ROTATION, + DATA_LAST_BG_OVERRIDES, + DATA_PREVIEW_SESSIONS, + DOMAIN, + signal_map_refresh, +) +from .map_render import clear_scaled_backgrounds, override_dir PLATFORMS = [Platform.IMAGE, Platform.SELECT] +def _overrides_snapshot(entry: ConfigEntry) -> dict[str, dict]: + return { + key: dict(record) + for key, record in entry.options.get(CONF_BG_OVERRIDES, {}).items() + } + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Roborock Custom map from a config entry.""" roborock_entries = hass.config_entries.async_entries("roborock") @@ -31,18 +49,54 @@ def unload_this_entry() -> None: entry.runtime_data = coordinators - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN].setdefault(entry.entry_id, {}) - hass.data[DOMAIN][entry.entry_id].setdefault(CONF_MAP_ROTATION, {}) + data = hass.data.setdefault(DOMAIN, {}).setdefault(entry.entry_id, {}) + data.setdefault(CONF_MAP_ROTATION, {}) + data.setdefault(DATA_PREVIEW_SESSIONS, {}) + data[DATA_LAST_BG_OVERRIDES] = _overrides_snapshot(entry) + + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Refresh only the maps whose background override actually changed. + + Image entities read entry.options at render time, so a dispatcher nudge is + enough — no entry reload, which would tear down entities and any other + open tuning session. + """ + data = hass.data.get(DOMAIN, {}).get(entry.entry_id) + new = _overrides_snapshot(entry) + old = data.get(DATA_LAST_BG_OVERRIDES) if data is not None else None + if data is not None: + data[DATA_LAST_BG_OVERRIDES] = new + + if old is None: + changed = set(new) + else: + changed = { + key for key in old.keys() | new.keys() if old.get(key) != new.get(key) + } + for key in changed: + async_dispatcher_send(hass, signal_map_refresh(entry.entry_id, key)) + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unloaded: hass.data.get(DOMAIN, {}).pop(entry.entry_id, None) - return unloaded \ No newline at end of file + clear_scaled_backgrounds() + return unloaded + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Clean up stored background override files when the last entry is removed.""" + if hass.config_entries.async_entries(DOMAIN): + return + await hass.async_add_executor_job( + lambda: shutil.rmtree(override_dir(hass), ignore_errors=True) + ) diff --git a/custom_components/roborock_custom_map/config_flow.py b/custom_components/roborock_custom_map/config_flow.py index 51791ef..f788c66 100644 --- a/custom_components/roborock_custom_map/config_flow.py +++ b/custom_components/roborock_custom_map/config_flow.py @@ -2,12 +2,117 @@ from __future__ import annotations +from dataclasses import dataclass +import hashlib +import logging from typing import Any +import voluptuous as vol + from homeassistant import config_entries -from homeassistant.data_entry_flow import FlowResult +from homeassistant.components.file_upload import process_uploaded_file +from homeassistant.config_entries import ConfigEntry, ConfigFlowResult, OptionsFlow +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.selector import ( + FileSelector, + FileSelectorConfig, + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from . import map_render +from .const import ( + CONF_ANCHOR_VX, + CONF_ANCHOR_VY, + CONF_BG_OVERRIDES, + CONF_CONFIRM, + CONF_FILE, + CONF_FILE_HASH, + CONF_MAP_ROTATION, + CONF_OFFSET_X, + CONF_OFFSET_Y, + CONF_SCALE_X, + CONF_SCALE_Y, + DOMAIN, + MAP_ROTATION_OPTIONS, + MAX_SCALE, + MIN_SCALE, + get_map_rotation, + map_key, + map_unique_id, + override_scales, + set_map_rotation, + signal_map_refresh, + signal_set_rotation, +) +from .preview import ( + PreviewSession, + async_remove_sessions_for_flow, + async_set_session, +) +from .preview import async_setup_preview as async_setup_preview_ws + +_LOGGER = logging.getLogger(__name__) + +CONF_MAP = "map" + +REMOVE_SCHEMA = vol.Schema({vol.Required(CONF_CONFIRM, default=False): bool}) + +UPLOAD_SCHEMA = vol.Schema( + { + vol.Optional(CONF_FILE): FileSelector( + FileSelectorConfig( + accept="image/png,image/jpeg,image/webp,.png,.jpg,.jpeg,.webp" + ) + ) + } +) + +OFFSET_SELECTOR = NumberSelector( + NumberSelectorConfig( + mode=NumberSelectorMode.BOX, + step="any", + min=-20000, + max=20000, + unit_of_measurement="px", + ) +) +SCALE_SELECTOR = NumberSelector( + NumberSelectorConfig( + mode=NumberSelectorMode.BOX, + step="any", + min=0, + max=round(MAX_SCALE * 100), + unit_of_measurement="%", + ) +) +ROTATION_SELECTOR = SelectSelector( + SelectSelectorConfig( + mode=SelectSelectorMode.DROPDOWN, + options=[ + SelectOptionDict(value=str(deg), label=f"{deg}°") + for deg in MAP_ROTATION_OPTIONS + ], + ) +) + + +@dataclass +class MapChoice: + """One configurable map of one vacuum.""" -from .const import DOMAIN + rotation_key: str + map_flag: int + label: str + entity_id: str | None + coordinator: Any class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -17,8 +122,507 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_user( self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + ) -> ConfigFlowResult: """Handle the initial step.""" - self.async_set_unique_id(DOMAIN) + await self.async_set_unique_id(DOMAIN) self._abort_if_unique_id_configured() return self.async_create_entry(title="Roborock Custom Map", data={}) + + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler: + """Return the options flow (background overrides).""" + return OptionsFlowHandler() + + +class OptionsFlowHandler(OptionsFlow): + """Manage per-map background overrides.""" + + def __init__(self) -> None: + """Initialize the options flow.""" + self._maps: dict[str, MapChoice] = {} + self._selected: MapChoice | None = None + self._new_bg: bytes | None = None + self._new_ext: str | None = None + + @property + def _choice(self) -> MapChoice: + """The selected map (only valid after async_step_init).""" + assert self._selected is not None + return self._selected + + @staticmethod + async def async_setup_preview(hass: HomeAssistant) -> None: + """Set up the preview websocket API (invoked by the flow manager).""" + async_setup_preview_ws(hass) + + @callback + def async_remove(self) -> None: + """Clean up the tuning session when the flow finishes or is abandoned.""" + async_remove_sessions_for_flow( + self.hass, self.config_entry.entry_id, self.flow_id + ) + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Select which map to configure (skipped when there is only one).""" + coordinators = getattr(self.config_entry, "runtime_data", None) + if not coordinators: + return self.async_abort(reason="not_loaded") + + entity_registry = er.async_get(self.hass) + self._maps = {} + for coordinator in coordinators: + home = coordinator.properties_api.home + if home is None: + continue + for map_info in (home.home_map_info or {}).values(): + rotation_key = map_key(coordinator.duid_slug, map_info.map_flag) + unique_id = map_unique_id( + coordinator.duid_slug, map_info.map_flag, map_info.name + ) + entity_id = entity_registry.async_get_entity_id( + "image", DOMAIN, unique_id + ) + label = rotation_key + if entity_id and (state := self.hass.states.get(entity_id)): + label = state.name or rotation_key + self._maps[rotation_key] = MapChoice( + rotation_key=rotation_key, + map_flag=map_info.map_flag, + label=label, + entity_id=entity_id, + coordinator=coordinator, + ) + + if not self._maps: + return self.async_abort(reason="no_maps") + + if len(self._maps) == 1: + self._selected = next(iter(self._maps.values())) + return await self.async_step_map() + + if user_input is not None: + if (choice := self._maps.get(user_input[CONF_MAP])) is None: + return self.async_abort(reason="unknown_map") + self._selected = choice + return await self.async_step_map() + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required(CONF_MAP): SelectSelector( + SelectSelectorConfig( + mode=SelectSelectorMode.LIST, + options=[ + SelectOptionDict(value=key, label=choice.label) + for key, choice in sorted(self._maps.items()) + ], + ) + ) + } + ), + ) + + async def async_step_map( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Route to upload or the manage menu depending on stored state.""" + if self._current_override() is not None: + return await self.async_step_map_menu() + return await self.async_step_upload() + + async def async_step_map_menu( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage an existing override.""" + return self.async_show_menu( + step_id="map_menu", + menu_options=["tune", "upload", "remove_confirm"], + ) + + async def async_step_upload( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Upload (or replace) the custom floor plan, with a live preview. + + Replacing an existing override keeps its tuned placement: the stored + anchor/scale are carried over both into the immediate commit and into + the preview session. + """ + choice = self._choice + errors: dict[str, str] = {} + override = self._current_override() + rotation = self._current_rotation() + + if user_input is not None: + if not (file_id := user_input.get(CONF_FILE)): + return self._async_close() + try: + self._new_bg, self._new_ext = await self.hass.async_add_executor_job( + self._read_uploaded_file, file_id + ) + except map_render.InvalidBackgroundImage as err: + errors["base"] = err.error_key + else: + off_x, off_y, scale_x, scale_y = self._stored_placement( + override, rotation + ) + if ( + result := await self._async_store( + off_x, off_y, scale_x, scale_y, rotation, finish=False + ) + ) is not None: + return result + return await self.async_step_tune() + + if self._raw_map_bytes() is None: + return self.async_abort(reason="no_raw_map") + if choice.entity_id is None: + return self.async_abort(reason="no_entity") + + fallback_bg = await self._async_current_bg_bytes() + off_x, off_y, scale_x, scale_y = self._stored_placement(override, rotation) + self._async_seed_preview( + bg_bytes=fallback_bg, + offset_x=off_x, + offset_y=off_y, + scale_x=scale_x, + scale_y=scale_y, + rotation=rotation, + mode="upload", + fallback_bg=fallback_bg, + ) + + return self.async_show_form( + step_id="upload", + data_schema=UPLOAD_SCHEMA, + errors=errors, + preview=DOMAIN, + ) + + async def async_step_tune( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Adjust the floor plan: offsets/scale with a live preview.""" + choice = self._choice + override = self._current_override() + + if user_input is not None: + rotation = int(user_input[CONF_MAP_ROTATION]) + set_map_rotation( + self.hass, self.config_entry.entry_id, choice.rotation_key, rotation + ) + async_dispatcher_send( + self.hass, + signal_set_rotation(self.config_entry.entry_id, choice.rotation_key), + rotation, + ) + async_dispatcher_send( + self.hass, + signal_map_refresh(self.config_entry.entry_id, choice.rotation_key), + ) + return await self._async_store( + user_input[CONF_OFFSET_X], + user_input[CONF_OFFSET_Y], + float(user_input[CONF_SCALE_X]) / 100, + float(user_input[CONF_SCALE_Y]) / 100, + rotation, + finish=True, + ) + + if self._raw_map_bytes() is None: + return self.async_abort(reason="no_raw_map") + if choice.entity_id is None: + return self.async_abort(reason="no_entity") + + bg = self._new_bg + if bg is None and override is not None: + try: + bg = await self.hass.async_add_executor_job( + map_render.read_override_file, self.hass, override[CONF_FILE] + ) + except OSError: + bg = None + if bg is None: + return self.async_abort(reason="missing_override_file") + + rotation = self._current_rotation() + if self._map_transform() is None: + return self.async_abort(reason="no_raw_map") + off_x, off_y, scale_x, scale_y = self._stored_placement(override, rotation) + pct_x = round(scale_x * 100, 2) + pct_y = round(scale_y * 100, 2) + defaults = { + CONF_OFFSET_X: round(off_x, 2), + CONF_OFFSET_Y: round(off_y, 2), + } + + self._async_seed_preview( + bg_bytes=bg, + offset_x=defaults[CONF_OFFSET_X], + offset_y=defaults[CONF_OFFSET_Y], + scale_x=scale_x, + scale_y=scale_y, + rotation=rotation, + invert_walls=True, + ) + + return self.async_show_form( + step_id="tune", + data_schema=vol.Schema( + { + vol.Required( + CONF_OFFSET_X, default=defaults[CONF_OFFSET_X] + ): OFFSET_SELECTOR, + vol.Required( + CONF_OFFSET_Y, default=defaults[CONF_OFFSET_Y] + ): OFFSET_SELECTOR, + vol.Required(CONF_SCALE_X, default=pct_x): SCALE_SELECTOR, + vol.Required(CONF_SCALE_Y, default=pct_y): SCALE_SELECTOR, + vol.Required( + CONF_MAP_ROTATION, default=str(rotation) + ): ROTATION_SELECTOR, + } + ), + preview=DOMAIN, + ) + + async def async_step_remove_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm removal of the floor plan via an explicit checkbox.""" + choice = self._choice + + if user_input is not None: + if not user_input.get(CONF_CONFIRM): + return self._async_close() + await self.hass.async_add_executor_job( + map_render.delete_override_files_for_key, + self.hass, + choice.rotation_key, + ) + return self._async_save_override_record(None) + + if choice.entity_id is not None: + override = self._current_override() + current_bg = await self._async_current_bg_bytes() + rotation = self._current_rotation() + off_x, off_y, scale_x, scale_y = self._stored_placement( + override, rotation + ) + self._async_seed_preview( + bg_bytes=current_bg, + offset_x=off_x, + offset_y=off_y, + scale_x=scale_x, + scale_y=scale_y, + rotation=rotation, + mode="remove", + fallback_bg=current_bg, + ) + + return self.async_show_form( + step_id="remove_confirm", + data_schema=REMOVE_SCHEMA, + preview=DOMAIN if choice.entity_id is not None else None, + ) + + @callback + def _async_seed_preview(self, **fields: Any) -> None: + """Register a preview session for the selected map and refresh it.""" + choice = self._choice + async_set_session( + self.hass, + self.config_entry.entry_id, + PreviewSession( + flow_id=self.flow_id, + rotation_key=choice.rotation_key, + entity_id=choice.entity_id, + **fields, + ), + ) + async_dispatcher_send( + self.hass, + signal_map_refresh(self.config_entry.entry_id, choice.rotation_key), + ) + + def _stored_placement( + self, override: dict[str, Any] | None, rotation: int + ) -> tuple[float, float, float, float]: + """Rotated-frame offsets and scales of the stored override, or defaults.""" + if ( + override + and override.get(CONF_ANCHOR_VX) is not None + and (transform := self._map_transform()) is not None + ): + off_x, off_y = map_render.anchor_topleft( + transform, + override[CONF_ANCHOR_VX], + override[CONF_ANCHOR_VY], + rotation, + ) + scale_x, scale_y = override_scales(override) + return off_x, off_y, scale_x, scale_y + return 0.0, 0.0, 1.0, 1.0 + + async def _async_store( + self, + offset_x: float, + offset_y: float, + scale_x: float, + scale_y: float, + rotation: int, + *, + finish: bool, + ) -> ConfigFlowResult | None: + """Persist the floor plan file (if newly uploaded) and its placement. + + offset_x/offset_y are the override's rotated-frame top-left px; they are + converted to a crop-invariant vacuum anchor before storing. finish=True + ends the flow; finish=False updates the entry in place and returns None + so the flow can continue — used to commit an upload before the adjust + step, so it survives if the dialog is closed without adjusting. + """ + choice = self._choice + override = self._current_override() + + transform = self._map_transform() + if transform is None: + return self.async_abort(reason="no_raw_map") + w_px, h_px = transform.base_size + bx, by = map_render.unrotate_point( + float(offset_x), float(offset_y), w_px, h_px, rotation + ) + anchor_vx, anchor_vy = transform.base_to_vacuum(bx, by) + + if self._new_bg is not None: + filename = f"{choice.rotation_key}.{self._new_ext}" + new_bg = self._new_bg + + def _persist() -> str: + map_render.delete_override_files_for_key( + self.hass, choice.rotation_key + ) + map_render.save_override_file(self.hass, filename, new_bg) + return hashlib.sha1(new_bg).hexdigest()[:16] + + try: + file_hash = await self.hass.async_add_executor_job(_persist) + except OSError as err: + _LOGGER.error( + "Could not store floor plan for %s: %s", + choice.rotation_key, + err, + ) + return self.async_abort(reason="write_failed") + self._new_bg = None + elif override is not None: + filename = override[CONF_FILE] + file_hash = override.get(CONF_FILE_HASH) + else: + return self.async_abort(reason="missing_override_file") + + options = self._merged_options( + { + CONF_FILE: filename, + CONF_FILE_HASH: file_hash, + CONF_ANCHOR_VX: anchor_vx, + CONF_ANCHOR_VY: anchor_vy, + CONF_SCALE_X: min(max(float(scale_x), MIN_SCALE), MAX_SCALE), + CONF_SCALE_Y: min(max(float(scale_y), MIN_SCALE), MAX_SCALE), + } + ) + if finish: + return self.async_create_entry(data=options) + self.hass.config_entries.async_update_entry( + self.config_entry, options=options + ) + return None + + @callback + def _merged_options(self, record: dict[str, Any] | None) -> dict[str, Any]: + """Entry options with the selected map's override record set or removed.""" + options = dict(self.config_entry.options) + overrides = dict(options.get(CONF_BG_OVERRIDES, {})) + if record is None: + overrides.pop(self._choice.rotation_key, None) + else: + overrides[self._choice.rotation_key] = record + options[CONF_BG_OVERRIDES] = overrides + return options + + @callback + def _async_save_override_record( + self, record: dict[str, Any] | None + ) -> ConfigFlowResult: + """Write (or delete) the selected map's override record into options.""" + return self.async_create_entry(data=self._merged_options(record)) + + @callback + def _async_close(self) -> ConfigFlowResult: + """Finish the flow without changing anything (a no-op action).""" + return self.async_create_entry(data=dict(self.config_entry.options)) + + def _read_uploaded_file(self, file_id: str) -> tuple[bytes, str]: + """Read and validate the uploaded file (runs in the executor). + + process_uploaded_file consumes the temp file, so re-submitting the same + (already consumed) id raises ValueError; treat that as an invalid image + so the user is simply asked to pick the file again. A failed read of + the temp file surfaces as a form error instead of crashing the flow. + """ + try: + with process_uploaded_file(self.hass, file_id) as path: + data = path.read_bytes() + except ValueError as err: + raise map_render.InvalidBackgroundImage("invalid_image") from err + except OSError as err: + raise map_render.InvalidBackgroundImage("upload_failed") from err + return data, map_render.validate_upload(data) + + def _raw_map_bytes(self) -> bytes | None: + """Return the selected map's raw payload, if available.""" + choice = self._choice + home = choice.coordinator.properties_api.home + if home is None or not home.home_map_content: + return None + map_content = home.home_map_content.get(choice.map_flag) + return map_content.raw_api_response if map_content else None + + def _map_transform(self) -> map_render.MapTransform | None: + """Vacuum<->pixel transform for the selected map's current crop, or None.""" + choice = self._choice + home = choice.coordinator.properties_api.home + if home is None or not home.home_map_content: + return None + map_content = home.home_map_content.get(choice.map_flag) + map_data = map_content.map_data if map_content else None + dims = getattr(map_data, "image", None) and map_data.image.dimensions + return map_render.MapTransform.from_dimensions(dims) if dims else None + + def _current_override(self) -> dict[str, Any] | None: + """Return the stored override record for the selected map.""" + return self.config_entry.options.get(CONF_BG_OVERRIDES, {}).get( + self._choice.rotation_key + ) + + def _current_rotation(self) -> int: + """Selected map's currently applied rotation.""" + return get_map_rotation( + self.hass, self.config_entry.entry_id, self._choice.rotation_key + ) + + async def _async_current_bg_bytes(self) -> bytes | None: + """Bytes of the map's stored floor plan, or None when there is none.""" + override = self._current_override() + if override is None or CONF_FILE not in override: + return None + try: + return await self.hass.async_add_executor_job( + map_render.read_override_file, self.hass, override[CONF_FILE] + ) + except OSError: + return None diff --git a/custom_components/roborock_custom_map/const.py b/custom_components/roborock_custom_map/const.py index f9bdcde..537fbbd 100644 --- a/custom_components/roborock_custom_map/const.py +++ b/custom_components/roborock_custom_map/const.py @@ -1,4 +1,11 @@ -"""Constants for Roborock Custom Map integration.""" +"""Constants and shared key helpers for Roborock Custom Map integration.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant DOMAIN = "roborock_custom_map" @@ -6,4 +13,79 @@ DEFAULT_MAP_ROTATION = 0 MAP_ROTATION_OPTIONS = (0, 90, 180, 270) -SIGNAL_ROTATION_CHANGED = "roborock_custom_map_rotation_changed" +SIGNAL_MAP_REFRESH = "roborock_custom_map_refresh" + +CONF_BG_OVERRIDES = "bg_overrides" +CONF_FILE = "file" +CONF_FILE_HASH = "file_hash" +CONF_OFFSET_X = "offset_x" +CONF_OFFSET_Y = "offset_y" +CONF_SCALE = "scale" +CONF_SCALE_X = "scale_x" +CONF_SCALE_Y = "scale_y" +CONF_CONFIRM = "confirm" +CONF_ANCHOR_VX = "anchor_vx" +CONF_ANCHOR_VY = "anchor_vy" + +DATA_PREVIEW_SESSIONS = "preview_sessions" +DATA_LAST_BG_OVERRIDES = "last_bg_overrides" + +PREVIEW_WS_TYPE = f"{DOMAIN}/start_preview" + +MAX_UPLOAD_BYTES = 20 * 1024 * 1024 +MAX_BG_DIMENSION = 8192 +MAX_SCALED_DIMENSION = 8192 +MAX_COMPOSITE_DIMENSION = 16384 +PREVIEW_SESSION_TTL = 1800 + +MIN_SCALE = 0.001 +MAX_SCALE = 1000.0 + + +def override_scales(record: dict) -> tuple[float, float]: + """Per-axis scales of an override record (legacy single-scale fallback).""" + legacy = record.get(CONF_SCALE, 1.0) + return ( + record.get(CONF_SCALE_X, legacy), + record.get(CONF_SCALE_Y, legacy), + ) + + +def map_key(duid_slug: str, map_flag: int) -> str: + """Per-map settings key, shared by rotation and background overrides.""" + return f"{duid_slug}_{map_flag}" + + +def map_unique_id(duid_slug: str, map_flag: int, map_name: str | None) -> str: + """Unique id of a map's image entity (must stay stable across releases).""" + return f"{duid_slug}_custom_map_{map_name or f'Map {map_flag}'}" + + +def signal_map_refresh(entry_id: str, key: str) -> str: + """Dispatcher signal telling one map's image entity to re-render.""" + return f"{SIGNAL_MAP_REFRESH}_{entry_id}_{key}" + + +def get_map_rotation(hass: HomeAssistant, entry_id: str, key: str) -> int: + """Return the currently applied rotation of one map.""" + return ( + hass.data.get(DOMAIN, {}) + .get(entry_id, {}) + .get(CONF_MAP_ROTATION, {}) + .get(key, DEFAULT_MAP_ROTATION) + ) + + +def set_map_rotation(hass: HomeAssistant, entry_id: str, key: str, rotation: int) -> None: + """Store one map's rotation in the integration's runtime data.""" + hass.data.setdefault(DOMAIN, {}).setdefault(entry_id, {}).setdefault( + CONF_MAP_ROTATION, {} + )[key] = rotation + + +SIGNAL_SET_ROTATION = "roborock_custom_map_set_rotation" + + +def signal_set_rotation(entry_id: str, key: str) -> str: + """Dispatcher signal asking the select entity to change a map's rotation.""" + return f"{SIGNAL_SET_ROTATION}_{entry_id}_{key}" diff --git a/custom_components/roborock_custom_map/image.py b/custom_components/roborock_custom_map/image.py index a43b917..1015ce5 100644 --- a/custom_components/roborock_custom_map/image.py +++ b/custom_components/roborock_custom_map/image.py @@ -9,8 +9,14 @@ from PIL import Image, UnidentifiedImageError from roborock.devices.traits.v1.home import HomeTrait from roborock.devices.traits.v1.map_content import MapContent +from vacuum_map_parser_base.config.drawable import Drawable from homeassistant.components.image import ImageEntity +from homeassistant.components.roborock.const import ( + DEFAULT_DRAWABLES, + DRAWABLES, + MAP_SCALE, +) from homeassistant.components.roborock.coordinator import RoborockDataUpdateCoordinator from homeassistant.components.roborock.entity import RoborockCoordinatedEntityV1 from homeassistant.config_entries import ConfigEntry @@ -21,13 +27,22 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util +from . import map_render from .const import ( - CONF_MAP_ROTATION, + CONF_ANCHOR_VX, + CONF_ANCHOR_VY, + CONF_BG_OVERRIDES, + CONF_FILE, + CONF_FILE_HASH, DEFAULT_MAP_ROTATION, - DOMAIN, MAP_ROTATION_OPTIONS, - SIGNAL_ROTATION_CHANGED, + get_map_rotation, + map_key, + map_unique_id, + override_scales, + signal_map_refresh, ) +from .preview import PreviewSession, async_get_session _LOGGER = logging.getLogger(__name__) @@ -47,27 +62,6 @@ def _png_dimensions(data: bytes) -> tuple[int, int] | None: return (width, height) -def _rotate_point_map_xy( - x: float, y: float, w: int, h: int, rotation: int -) -> tuple[float, float]: - """Rotate a point in map pixel space around the image bounds. - - rotation is counter-clockwise (PIL Image.rotate does CCW). - Uses continuous coordinates (w - x / h - y) to avoid off-by-one issues. - """ - if rotation == 0: - return (x, y) - if rotation == 90: - # CCW 90: new size (h, w) - return (y, w - x) - if rotation == 180: - return (w - x, h - y) - if rotation == 270: - # CCW 270 == CW 90: new size (h, w) - return (h - y, x) - return (x, y) - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -77,7 +71,7 @@ async def async_setup_entry( async_add_entities( RoborockMap( config_entry, - f"{coord.duid_slug}_custom_map_{map_info.name or f'Map {map_info.map_flag}'}", + map_unique_id(coord.duid_slug, map_info.map_flag, map_info.name), coord, coord.properties_api.home, map_info.map_flag, @@ -111,7 +105,7 @@ def __init__( self.config_entry = config_entry self.map_flag = map_flag - self.rotation_key = f"{coordinator.duid_slug}_{map_flag}" + self.rotation_key = map_key(coordinator.duid_slug, map_flag) self._home_trait = home_trait if not map_name: @@ -120,6 +114,12 @@ def __init__( self.cached_map = b"" self._raw_image_size: tuple[int, int] | None = None + self._overlay_cache: tuple[bytes, Image.Image] | None = None + self._mask_cache: tuple[bytes, Image.Image] | None = None + self._layers_generation = 0 + self._bg_cache: tuple[tuple[str, str], bytes] | None = None + self._composite_cache: tuple[tuple, bytes] | None = None + self._override_render_ok: bool | None = None self._attr_entity_category = EntityCategory.DIAGNOSTIC @@ -142,25 +142,19 @@ async def async_added_to_hass(self) -> None: self._attr_image_last_updated = self.coordinator.last_home_update - # Listen for rotation changes from the Select entity self.async_on_remove( async_dispatcher_connect( self.hass, - f"{SIGNAL_ROTATION_CHANGED}_{self.config_entry.entry_id}_{self.rotation_key}", - self._handle_rotation_changed, + signal_map_refresh(self.config_entry.entry_id, self.rotation_key), + self._async_handle_refresh, ) ) self.async_write_ha_state() - def _handle_rotation_changed(self) -> None: - """Rotation changed; schedule state update in the event loop.""" - self.hass.loop.call_soon_threadsafe(self._async_handle_rotation_changed) - - @callback - def _async_handle_rotation_changed(self) -> None: - """Rotation changed; bump last_updated to bust the image cache.""" + def _async_handle_refresh(self) -> None: + """Refresh signal; bump last_updated to bust the image cache.""" self._attr_image_last_updated = dt_util.utcnow() self.async_write_ha_state() @@ -185,14 +179,15 @@ def _rotate_image(self, raw: bytes, rotation: int) -> bytes: img.save(out, format="PNG") return out.getvalue() - def _get_rotation(self) -> int: - """Get configured rotation for this map from hass.data (set by select entity).""" - rotation = ( - self.hass.data.get(DOMAIN, {}) - .get(self.config_entry.entry_id, {}) - .get(CONF_MAP_ROTATION, {}) - .get(self.rotation_key, DEFAULT_MAP_ROTATION) - ) + def _get_rotation(self, preview: PreviewSession | None) -> int: + """Rotation for this map: the live preview's value while a session is + active, otherwise the value stored by the select entity.""" + if preview is not None: + rotation = preview.rotation + else: + rotation = get_map_rotation( + self.hass, self.config_entry.entry_id, self.rotation_key + ) if rotation not in MAP_ROTATION_OPTIONS: _LOGGER.debug( @@ -205,27 +200,213 @@ def _get_rotation(self) -> int: return rotation + def _override_topleft( + self, + map_content: MapContent, + rotation: int, + preview: PreviewSession | None, + ) -> tuple[float, float] | None: + """Top-left of the override in the rotated frame, or None when no override + composite is served. + + Preview session -> the session's px (already the rotated-frame top-left). + Saved override -> its vacuum anchor, forward-transformed with the CURRENT + crop dimensions via map_render.anchor_topleft (so it tracks the crop). + """ + map_data = map_content.map_data + dims = getattr(map_data, "image", None) and map_data.image.dimensions + if dims is None: + return None + + if preview is not None: + if preview.bg_bytes is None: + return None + return (preview.offset_x, preview.offset_y) + + override = self.config_entry.options.get(CONF_BG_OVERRIDES, {}).get( + self.rotation_key + ) + if override is None: + return None + ax = override.get(CONF_ANCHOR_VX) + ay = override.get(CONF_ANCHOR_VY) + if ax is None or ay is None: + return None + transform = map_render.MapTransform.from_dimensions(dims) + return map_render.anchor_topleft(transform, ax, ay, rotation) + + def _core_drawables(self) -> list[Drawable]: + """Drawables the core roborock entry has enabled (mirrors its setup).""" + configured = self.coordinator.config_entry.options.get(DRAWABLES, {}) + return [ + drawable + for drawable, default_value in DEFAULT_DRAWABLES.items() + if configured.get(drawable, default_value) + ] + + async def _async_get_overlay(self, raw: bytes) -> Image.Image: + """Return the parsed drawables overlay, re-parsing when raw changed.""" + if self._overlay_cache is None or self._overlay_cache[0] != raw: + overlay = await self.hass.async_add_executor_job( + map_render.build_overlay, raw, self._core_drawables(), MAP_SCALE + ) + self._overlay_cache = (raw, overlay) + self._layers_generation += 1 + return self._overlay_cache[1] + + async def _async_get_mask(self, raw: bytes) -> Image.Image: + """Return the wall/dock mask, built lazily (preview inversion only).""" + if self._mask_cache is None or self._mask_cache[0] != raw: + mask = await self.hass.async_add_executor_job( + map_render.build_wall_mask, raw, MAP_SCALE + ) + self._mask_cache = (raw, mask) + return self._mask_cache[1] + + async def _async_get_override_bytes( + self, filename: str, file_hash: str | None + ) -> bytes | None: + """Return the stored background image, cached per (filename, hash). + + Records without a hash (written before hashes existed) are read from + disk every time, so a replaced file can never be served stale. + """ + if ( + file_hash is not None + and self._bg_cache is not None + and self._bg_cache[0] == (filename, file_hash) + ): + return self._bg_cache[1] + try: + data = await self.hass.async_add_executor_job( + map_render.read_override_file, self.hass, filename + ) + except OSError as err: + _LOGGER.warning( + "Background override file %s is unreadable: %s", filename, err + ) + return None + if file_hash is not None: + self._bg_cache = ((filename, file_hash), data) + return data + + @callback + def _set_override_render_ok(self, ok: bool) -> None: + """Track whether the last override render succeeded, so the published + calibration points always describe the image actually served.""" + if self._override_render_ok is ok: + return + self._override_render_ok = ok + self.async_write_ha_state() + + async def _async_render_override( + self, + map_content: MapContent, + rotation: int, + preview: PreviewSession | None, + ) -> bytes | None: + """Render the background-override composite, or None for the normal map. + + A live tuning session (options flow open) takes precedence over the + persisted override. All session state is snapshotted before the first + await so a concurrent websocket update cannot produce a composite + mixing old and new parameters. + """ + topleft = self._override_topleft(map_content, rotation, preview) + if topleft is None: + return None + + if (raw := map_content.raw_api_response) is None: + _LOGGER.debug( + "Raw map data unavailable for %s; serving unmodified map", + self.rotation_key, + ) + self._set_override_render_ok(False) + return None + + if preview is not None: + bg = preview.bg_bytes + scale_x = preview.scale_x + scale_y = preview.scale_y + invert_walls = preview.invert_walls + variant: tuple = ("preview", preview.flow_id, preview.revision) + filename = file_hash = None + else: + override = self.config_entry.options.get(CONF_BG_OVERRIDES, {}).get( + self.rotation_key, {} + ) + bg = None + scale_x, scale_y = override_scales(override) + invert_walls = False + filename = override.get(CONF_FILE) + file_hash = override.get(CONF_FILE_HASH) + variant = ("saved", filename, file_hash) + if filename is None: + self._set_override_render_ok(False) + return None + + try: + overlay = await self._async_get_overlay(raw) + mask = await self._async_get_mask(raw) if invert_walls else None + + params = (topleft[0], topleft[1], scale_x, scale_y, rotation) + cache_key = (self._layers_generation, variant, params) + if ( + self._composite_cache is not None + and self._composite_cache[0] == cache_key + ): + self._set_override_render_ok(True) + return self._composite_cache[1] + + if bg is None: + bg = await self._async_get_override_bytes(filename, file_hash) + if bg is None: + self._set_override_render_ok(False) + return None + + composite = await self.hass.async_add_executor_job( + map_render.compose_final, overlay, mask, bg, *params + ) + self._composite_cache = (cache_key, composite) + self._set_override_render_ok(True) + return composite + except Exception as err: + _LOGGER.warning( + "Failed to render background override for %s: %s", + self.rotation_key, + err, + ) + self._set_override_render_ok(False) + return None + async def async_image(self) -> bytes | None: - """Get the image (with optional rotation).""" + """Get the image (with optional background override and rotation).""" if (map_content := self._map_content) is None: raise HomeAssistantError("Map flag not found in coordinator maps") - raw = map_content.image_content - rotation = self._get_rotation() + preview = async_get_session( + self.hass, self.config_entry.entry_id, self.rotation_key + ) + rotation = self._get_rotation(preview) + + composite = await self._async_render_override(map_content, rotation, preview) + if composite is not None: + return composite + base = map_content.image_content if rotation == DEFAULT_MAP_ROTATION: - return raw + return base try: return await self.hass.async_add_executor_job( - self._rotate_image, raw, rotation + self._rotate_image, base, rotation ) except (OSError, UnidentifiedImageError) as err: _LOGGER.debug( "Failed to rotate Roborock map image: %s, returning original image", err, ) - return raw + return base @property def extra_state_attributes(self): @@ -237,7 +418,6 @@ def extra_state_attributes(self): if map_data is None: return {} - # Attach room names (same behavior as before) if map_data.rooms is not None: for room in map_data.rooms.values(): name = self._home_trait._rooms_trait.room_map.get(room.number) @@ -245,33 +425,45 @@ def extra_state_attributes(self): calibration = map_data.calibration() - # Rotate ONLY the "map" (pixel-space) side of calibration points. - # Rooms/zones are in vacuum coordinate space and are mapped via calibration. - rotation = self._get_rotation() - size = self._raw_image_size - if rotation != DEFAULT_MAP_ROTATION and size is not None: - w, h = size - rotated_calibration = [] + preview = async_get_session( + self.hass, self.config_entry.entry_id, self.rotation_key + ) + rotation = self._get_rotation(preview) + topleft = self._override_topleft(map_content, rotation, preview) + if topleft is not None and self._override_render_ok is not False: + transform = map_render.MapTransform.from_dimensions( + map_data.image.dimensions + ) + w, h = transform.base_size + ox0, oy0 = map_render.canvas_origin(*topleft) + shift_x, shift_y = -ox0, -oy0 + elif rotation != DEFAULT_MAP_ROTATION and self._raw_image_size is not None: + w, h = self._raw_image_size + shift_x = shift_y = 0 + else: + w = h = None + + if calibration is not None and w is not None: + adjusted_calibration = [] for pt in calibration: mp = pt.get("map") or {} x = mp.get("x") y = mp.get("y") - # If missing/invalid, keep point as-is if x is None or y is None: - rotated_calibration.append(pt) + adjusted_calibration.append(pt) continue - nx, ny = _rotate_point_map_xy(float(x), float(y), w, h, rotation) + nx, ny = map_render.rotate_point(float(x), float(y), w, h, rotation) new_pt = dict(pt) new_map = dict(mp) - new_map["x"] = nx - new_map["y"] = ny + new_map["x"] = nx + shift_x + new_map["y"] = ny + shift_y new_pt["map"] = new_map - rotated_calibration.append(new_pt) + adjusted_calibration.append(new_pt) - calibration = rotated_calibration + calibration = adjusted_calibration return { "calibration_points": calibration, diff --git a/custom_components/roborock_custom_map/manifest.json b/custom_components/roborock_custom_map/manifest.json index 7da2f19..ebbf5c3 100644 --- a/custom_components/roborock_custom_map/manifest.json +++ b/custom_components/roborock_custom_map/manifest.json @@ -3,10 +3,10 @@ "name": "Roborock Custom Map", "codeowners": ["@Lash-L"], "config_flow": true, - "dependencies": ["roborock"], + "dependencies": ["file_upload", "roborock", "websocket_api"], "documentation": "https://github.com/Lash-L/RoborockCustomMap", "iot_class": "local_polling", "issue_tracker": "https://github.com/Lash-L/RoborockCustomMap/issues", "requirements": [], - "version": "0.1.6" + "version": "0.2.0" } diff --git a/custom_components/roborock_custom_map/map_render.py b/custom_components/roborock_custom_map/map_render.py new file mode 100644 index 0000000..f7950ea --- /dev/null +++ b/custom_components/roborock_custom_map/map_render.py @@ -0,0 +1,377 @@ +"""Background-override rendering for Roborock maps. + +Everything in this module is synchronous and CPU/IO bound — callers must run +it via hass.async_add_executor_job. + +The override composite re-parses the vacuum's raw map bytes with custom +palettes at the same scale the core roborock integration uses, so the canvas +dimensions (and therefore the calibration points exposed for the map card) +are identical to the unmodified render. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from functools import lru_cache +import io +import logging +import math +from pathlib import Path + +from PIL import Image, ImageChops, ImageOps +from vacuum_map_parser_base.config.color import Color, ColorsPalette, SupportedColor +from vacuum_map_parser_base.config.drawable import Drawable +from vacuum_map_parser_base.config.image_config import ImageConfig +from vacuum_map_parser_base.config.size import Size, Sizes +from vacuum_map_parser_base.map_data import ImageDimensions, Point +from vacuum_map_parser_roborock.map_data_parser import RoborockMapDataParser + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.storage import STORAGE_DIR + +from .const import ( + DOMAIN, + MAX_BG_DIMENSION, + MAX_COMPOSITE_DIMENSION, + MAX_SCALED_DIMENSION, + MAX_UPLOAD_BYTES, +) + +_LOGGER = logging.getLogger(__name__) + +_TRANSPARENT: Color = (0, 0, 0, 0) +_OPAQUE_WHITE: Color = (255, 255, 255, 255) + +_BASE_COLORS = ( + SupportedColor.MAP_OUTSIDE, + SupportedColor.MAP_INSIDE, + SupportedColor.MAP_WALL, + SupportedColor.MAP_WALL_V2, + SupportedColor.GREY_WALL, + SupportedColor.SCAN, + SupportedColor.CARPETS, + SupportedColor.UNKNOWN, + SupportedColor.NEW_DISCOVERED_AREA, +) + +_WALL_COLORS = ( + SupportedColor.MAP_WALL, + SupportedColor.MAP_WALL_V2, + SupportedColor.GREY_WALL, +) + +ALLOWED_FORMATS = {"png": "png", "jpeg": "jpg", "webp": "webp"} + + +class InvalidBackgroundImage(Exception): + """Uploaded background failed validation; error_key maps to a flow error.""" + + def __init__(self, error_key: str) -> None: + super().__init__(error_key) + self.error_key = error_key + + +def _transparent_room_colors() -> dict[str, Color]: + return dict.fromkeys(ColorsPalette.ROOM_COLORS, _TRANSPARENT) + + +def _overlay_palette() -> ColorsPalette: + """Palette rendering only drawables: all base pixels transparent.""" + return ColorsPalette( + dict.fromkeys(_BASE_COLORS, _TRANSPARENT), _transparent_room_colors() + ) + + +def _mask_palette() -> ColorsPalette: + """Palette rendering only physical walls (and the charger) opaque.""" + colors: dict[SupportedColor, Color] = dict.fromkeys(_BASE_COLORS, _TRANSPARENT) + for wall_color in _WALL_COLORS: + colors[wall_color] = _OPAQUE_WHITE + colors[SupportedColor.CHARGER] = _OPAQUE_WHITE + colors[SupportedColor.CHARGER_OUTLINE] = _OPAQUE_WHITE + return ColorsPalette(colors, _transparent_room_colors()) + + +def _create_parser( + palette: ColorsPalette, drawables: list[Drawable], map_scale: int +) -> RoborockMapDataParser: + return RoborockMapDataParser( + palette, + Sizes( + { + k: v * map_scale + for k, v in Sizes.SIZES.items() + if k != Size.MOP_PATH_WIDTH + } + ), + drawables, + ImageConfig(scale=map_scale), + [], + ) + + +def build_overlay(raw: bytes, drawables: list[Drawable], map_scale: int) -> Image.Image: + """Parse the raw map into the RGBA layer containing only the drawables. + + Raises ValueError/IndexError when the raw payload cannot be parsed + (same failure modes as python-roborock's own MapParser). + """ + overlay_map = _create_parser(_overlay_palette(), drawables, map_scale).parse(raw) + if overlay_map.image is None: + raise ValueError("Raw map data could not be rendered") + return overlay_map.image.data.convert("RGBA") + + +def build_wall_mask(raw: bytes, map_scale: int) -> Image.Image: + """Parse the raw map into a single-band alpha mask of walls + charger. + + Only needed while a tuning preview inverts the wall pixels, so callers + build it lazily instead of paying a second full parse on every map update. + """ + mask_map = _create_parser(_mask_palette(), [Drawable.CHARGER], map_scale).parse(raw) + if mask_map.image is None: + raise ValueError("Raw map data could not be rendered") + return mask_map.image.data.convert("RGBA").getchannel("A") + + +@lru_cache(maxsize=4) +def _scaled_background(bg_bytes: bytes, scale_x: float, scale_y: float) -> Image.Image: + """Decode and scale the background image (cached; treat result read-only). + + Each axis scale is capped so the scaled edge length never exceeds + MAX_SCALED_DIMENSION, protecting against runaway PIL allocations. + """ + bg = Image.open(io.BytesIO(bg_bytes)) + bg = ImageOps.exif_transpose(bg).convert("RGBA") + scale_x = min(scale_x, MAX_SCALED_DIMENSION / bg.width) + scale_y = min(scale_y, MAX_SCALED_DIMENSION / bg.height) + if scale_x != 1 or scale_y != 1: + bg = bg.resize( + (max(1, round(bg.width * scale_x)), max(1, round(bg.height * scale_y))), + Image.Resampling.LANCZOS, + ) + return bg + + +def clear_scaled_backgrounds() -> None: + """Drop cached decoded backgrounds (call on unload and when flows end).""" + _scaled_background.cache_clear() + + +_ROTATE_TRANSPOSE = { + 90: Image.Transpose.ROTATE_90, + 180: Image.Transpose.ROTATE_180, + 270: Image.Transpose.ROTATE_270, +} + + +@dataclass(frozen=True) +class MapTransform: + """Affine map between vacuum-world mm and base (unrotated) render pixels. + + The forward direction delegates to the parser's own ImageDimensions.to_img + (the transform MapData.calibration() uses), so override placement can never + drift from the calibration points. Only the translation moves as the map's + crop box changes, which is why an override anchored in vacuum coordinates + survives the base map being re-cropped. + """ + + dims: ImageDimensions + + @classmethod + def from_dimensions(cls, dims: ImageDimensions) -> MapTransform: + return cls(dims) + + @property + def base_size(self) -> tuple[int, int]: + """Unrotated base render size in px (equals the parsed overlay size).""" + return ( + int(self.dims.width * self.dims.scale), + int(self.dims.height * self.dims.scale), + ) + + def vacuum_to_base(self, wx: float, wy: float) -> tuple[float, float]: + """Vacuum-world mm -> base (unrotated) render pixel.""" + p = self.dims.to_img(Point(wx, wy)) + return (p.x, p.y) + + def base_to_vacuum(self, px: float, py: float) -> tuple[float, float]: + """Base (unrotated) render pixel -> vacuum-world mm. + + Exact inverse of vacuum_to_base, assuming the Roborock parser's mm/50 + image transformation. + """ + dims = self.dims + return ( + (px / dims.scale + dims.left) * 50, + (dims.top + dims.height - 1 - py / dims.scale) * 50, + ) + + +def rotate_point( + x: float, y: float, w: int, h: int, rotation: int +) -> tuple[float, float]: + """Unrotated base pixel (frame w x h) -> rotated frame. CCW, matching the + PIL transpose used in compose_final and the plain-map rotation.""" + if rotation == 90: + return (y, w - x) + if rotation == 180: + return (w - x, h - y) + if rotation == 270: + return (h - y, x) + return (x, y) + + +def unrotate_point( + x: float, y: float, w: int, h: int, rotation: int +) -> tuple[float, float]: + """Inverse of rotate_point; (w, h) is the UNROTATED base size (not swapped).""" + if rotation == 90: + return (w - y, x) + if rotation == 180: + return (w - x, h - y) + if rotation == 270: + return (y, h - x) + return (x, y) + + +def anchor_topleft( + transform: MapTransform, anchor_vx: float, anchor_vy: float, rotation: int +) -> tuple[float, float]: + """Rotated-frame top-left px of an override anchored in vacuum-world mm. + + Single source of the anchor geometry, shared by the image entity's render + and calibration and by the options flow's form defaults, so they cannot + disagree. + """ + bx, by = transform.vacuum_to_base(anchor_vx, anchor_vy) + w_px, h_px = transform.base_size + return rotate_point(bx, by, w_px, h_px, rotation) + + +def canvas_origin(offset_x: float, offset_y: float) -> tuple[int, int]: + """Top-left of the union canvas (<= 0 per axis). Depends only on the override + offset, never the background size, so calibration can compute the identical + origin without decoding the image. floor() so a fractional-negative offset + does not clip the override's top/left edge.""" + return (math.floor(min(0.0, offset_x)), math.floor(min(0.0, offset_y))) + + +def union_canvas( + rot_w: int, + rot_h: int, + offset_x: float, + offset_y: float, + ovr_w: int, + ovr_h: int, +) -> tuple[int, int, int, int]: + """Union bbox of the rotated base extent [0, rot] and the override + [offset, offset + ovr]. Returns (canvas_w, canvas_h, origin_x, origin_y). + + The canvas dimensions are capped at MAX_COMPOSITE_DIMENSION while the + origin is preserved, so content may be cropped at the right/bottom but the + origin-based calibration shift stays valid for every rendered pixel. + """ + origin_x, origin_y = canvas_origin(offset_x, offset_y) + canvas_w = math.ceil(max(rot_w, offset_x + ovr_w)) - origin_x + canvas_h = math.ceil(max(rot_h, offset_y + ovr_h)) - origin_y + return ( + min(canvas_w, MAX_COMPOSITE_DIMENSION), + min(canvas_h, MAX_COMPOSITE_DIMENSION), + origin_x, + origin_y, + ) + + +def compose_final( + overlay: Image.Image, + mask: Image.Image | None, + bg_bytes: bytes, + offset_x: float, + offset_y: float, + scale_x: float, + scale_y: float, + rotation: int, +) -> bytes: + """Composite the background under the drawables and encode as PNG. + + offset_x/offset_y are the scaled override's top-left in the ROTATED frame. + The canvas is the union bounding box of the rotated base-map extent and the + override extent; areas covered by neither are transparent. With a wall mask + (adjust preview) the wall/dock pixels invert whatever lies beneath them, + which also draws white outlines wherever they fall over transparent areas. + """ + if rotation in _ROTATE_TRANSPOSE: + overlay = overlay.transpose(_ROTATE_TRANSPOSE[rotation]) + if mask is not None: + mask = mask.transpose(_ROTATE_TRANSPOSE[rotation]) + rot_w, rot_h = overlay.size + + bg = _scaled_background(bg_bytes, scale_x, scale_y) + ovr_w, ovr_h = bg.size + + canvas_w, canvas_h, origin_x, origin_y = union_canvas( + rot_w, rot_h, offset_x, offset_y, ovr_w, ovr_h + ) + + canvas = Image.new("RGBA", (canvas_w, canvas_h), _TRANSPARENT) + canvas.paste(bg, (round(offset_x - origin_x), round(offset_y - origin_y)), bg) + + base_dest = (-origin_x, -origin_y) + if base_dest[0] < canvas_w and base_dest[1] < canvas_h: + canvas.alpha_composite(overlay, dest=base_dest) + if mask is not None: + full_mask = Image.new("L", (canvas_w, canvas_h), 0) + full_mask.paste(mask, base_dest) + inverted = ImageChops.invert(canvas.convert("RGB")).convert("RGBA") + canvas = Image.composite(inverted, canvas, full_mask) + + out = io.BytesIO() + canvas.save(out, format="PNG") + return out.getvalue() + + +def validate_upload(data: bytes) -> str: + """Validate an uploaded background image and return its file extension.""" + if len(data) > MAX_UPLOAD_BYTES: + raise InvalidBackgroundImage("file_too_large") + try: + with Image.open(io.BytesIO(data)) as image: + image_format = (image.format or "").lower() + width, height = image.size + image.verify() + except Image.DecompressionBombError as err: + raise InvalidBackgroundImage("image_too_large") from err + except Exception as err: + raise InvalidBackgroundImage("invalid_image") from err + if image_format not in ALLOWED_FORMATS: + raise InvalidBackgroundImage("invalid_image") + if width > MAX_BG_DIMENSION or height > MAX_BG_DIMENSION: + raise InvalidBackgroundImage("image_too_large") + return ALLOWED_FORMATS[image_format] + + +def override_dir(hass: HomeAssistant) -> Path: + """Directory where uploaded background files are stored.""" + return Path(hass.config.path(STORAGE_DIR, DOMAIN)) + + +def save_override_file(hass: HomeAssistant, filename: str, data: bytes) -> None: + """Persist an uploaded background image.""" + directory = override_dir(hass) + directory.mkdir(parents=True, exist_ok=True) + (directory / filename).write_bytes(data) + + +def read_override_file(hass: HomeAssistant, filename: str) -> bytes: + """Read a stored background image (raises OSError when missing).""" + return (override_dir(hass) / filename).read_bytes() + + +def delete_override_files_for_key(hass: HomeAssistant, rotation_key: str) -> None: + """Delete stored background files for a map (any extension).""" + directory = override_dir(hass) + if not directory.is_dir(): + return + for path in directory.glob(f"{rotation_key}.*"): + path.unlink(missing_ok=True) diff --git a/custom_components/roborock_custom_map/preview.py b/custom_components/roborock_custom_map/preview.py new file mode 100644 index 0000000..52c47cf --- /dev/null +++ b/custom_components/roborock_custom_map/preview.py @@ -0,0 +1,303 @@ +"""Live preview: session store and websocket command. + +While the options flow's upload, tune, or remove step is open, a +PreviewSession holds the not-yet-saved floor plan bytes and transform +parameters. The frontend's generic flow preview re-subscribes to +roborock_custom_map/start_preview (debounced) on every form change; the +handler updates the session and replies with the map image entity's id + +access token and a bumped revision, which makes the preview re-fetch +/api/image_proxy/... — served by RoborockMap.async_image(), which renders the +session's composite while the session is active. + +On the upload step the form's only field is the file picker, so the handler +reads the just-uploaded temp file by id (without consuming it — the flow's +submit still needs it) and shows it live; clearing the picker reverts to the +session's fallback (the default map for a fresh upload, or the current floor +plan when replacing). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import itertools +import math +import time +from typing import Any, Literal + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.components.file_upload import DOMAIN as FILE_UPLOAD_DOMAIN +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import UnknownFlow +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from . import map_render +from .const import ( + CONF_CONFIRM, + CONF_FILE, + CONF_MAP_ROTATION, + CONF_OFFSET_X, + CONF_OFFSET_Y, + CONF_SCALE_X, + CONF_SCALE_Y, + DATA_PREVIEW_SESSIONS, + DOMAIN, + MAP_ROTATION_OPTIONS, + MAX_SCALE, + MAX_UPLOAD_BYTES, + MIN_SCALE, + PREVIEW_SESSION_TTL, + PREVIEW_WS_TYPE, + signal_map_refresh, +) + +_preview_revision = itertools.count(1) + + +@dataclass +class PreviewSession: + """State of one active preview session (one per options flow). + + offset_x/offset_y are the override's top-left in the rotated (on-screen) + frame — the live-adjust px that config_flow converts to a crop-invariant + vacuum anchor on submit; the session itself never holds the anchor. + + revision seeds from the process-wide counter so the composite cache key + and the preview image URL can never collide with an earlier session. + """ + + flow_id: str + rotation_key: str + entity_id: str + bg_bytes: bytes | None + offset_x: float = 0.0 + offset_y: float = 0.0 + scale_x: float = 1.0 + scale_y: float = 1.0 + rotation: int = 0 + mode: Literal["upload", "tune", "remove"] = "tune" + invert_walls: bool = False + revision: int = field(default_factory=lambda: next(_preview_revision)) + file_id: str | None = None + fallback_bg: bytes | None = None + last_active: float = field(default_factory=time.monotonic) + + @property + def expired(self) -> bool: + """Return True when the session saw no activity within the TTL.""" + return time.monotonic() - self.last_active > PREVIEW_SESSION_TTL + + def touch(self) -> None: + """Record activity, extending the session's lifetime.""" + self.last_active = time.monotonic() + + +def _session_store( + hass: HomeAssistant, entry_id: str +) -> dict[str, PreviewSession] | None: + """Return the flow_id-keyed session store (guarded: gone during reloads).""" + return hass.data.get(DOMAIN, {}).get(entry_id, {}).get(DATA_PREVIEW_SESSIONS) + + +@callback +def async_set_session( + hass: HomeAssistant, entry_id: str, session: PreviewSession +) -> None: + """Register (or replace) the preview session of an options flow. + + Any other flow's session for the same map is dropped, so the image entity + deterministically follows the most recently opened dialog instead of + flip-flopping between concurrent flows. + """ + if (store := _session_store(hass, entry_id)) is None: + return + for flow_id, existing in list(store.items()): + if ( + flow_id != session.flow_id + and existing.rotation_key == session.rotation_key + ): + del store[flow_id] + store[session.flow_id] = session + + +@callback +def async_get_session( + hass: HomeAssistant, entry_id: str, rotation_key: str +) -> PreviewSession | None: + """Return the newest active preview session for a map, dropping expired ones.""" + if (store := _session_store(hass, entry_id)) is None: + return None + newest: PreviewSession | None = None + for flow_id, session in list(store.items()): + if session.expired: + del store[flow_id] + continue + if session.rotation_key == rotation_key and ( + newest is None or session.last_active > newest.last_active + ): + newest = session + return newest + + +@callback +def async_remove_sessions_for_flow( + hass: HomeAssistant, entry_id: str, flow_id: str +) -> None: + """Drop a finished/abandoned flow's session and refresh its map.""" + if not (store := _session_store(hass, entry_id)): + return + if (session := store.pop(flow_id, None)) is not None: + map_render.clear_scaled_backgrounds() + async_dispatcher_send( + hass, signal_map_refresh(entry_id, session.rotation_key) + ) + + +@callback +def async_setup_preview(hass: HomeAssistant) -> None: + """Register the preview websocket command (called by the flow manager).""" + websocket_api.async_register_command(hass, ws_start_preview) + + +def _as_float(value: Any, fallback: float) -> float: + """Coerce a form value to a finite float, falling back on invalid input.""" + try: + result = float(value) + except (TypeError, ValueError): + return fallback + return result if math.isfinite(result) else fallback + + +def _as_rotation(value: Any, fallback: int) -> int: + """Coerce a form value to a supported rotation, else keep the fallback.""" + try: + rotation = int(value) + except (TypeError, ValueError): + return fallback + return rotation if rotation in MAP_ROTATION_OPTIONS else fallback + + +def _clamp_scale(scale: float) -> float: + """Clamp a scale multiplier into the supported range.""" + return min(max(scale, MIN_SCALE), MAX_SCALE) + + +def _read_pending_upload(hass: HomeAssistant, file_id: str) -> bytes | None: + """Read and validate an uploaded temp file by id without consuming it. + + Runs in the executor. Returns None when the file is missing or would be + rejected by the flow's submit validation, so the preview never renders an + image that cannot be saved. + """ + store = hass.data.get(FILE_UPLOAD_DOMAIN) + if store is None or not store.has_file(file_id): + return None + try: + path = store.file_path(file_id) + if path.stat().st_size > MAX_UPLOAD_BYTES: + return None + data = path.read_bytes() + except OSError: + return None + try: + map_render.validate_upload(data) + except map_render.InvalidBackgroundImage: + return None + return data + + +@websocket_api.websocket_command( + { + vol.Required("type"): PREVIEW_WS_TYPE, + vol.Required("flow_id"): str, + vol.Required("flow_type"): "options_flow", + vol.Required("user_input"): dict, + } +) +@websocket_api.async_response +async def ws_start_preview( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Apply form values to the preview session and describe the preview image.""" + try: + flow_status = hass.config_entries.options.async_get(msg["flow_id"]) + except UnknownFlow: + connection.send_error(msg["id"], "unknown_flow", "Options flow not found") + return + + entry_id = flow_status["handler"] + store = _session_store(hass, entry_id) or {} + session = store.get(msg["flow_id"]) + if session is None or session.expired: + connection.send_error( + msg["id"], "unknown_preview_session", "No active preview session" + ) + return + + user_input = msg["user_input"] + + if session.mode == "upload": + new_file_id = user_input.get(CONF_FILE) or None + if new_file_id != session.file_id: + session.file_id = new_file_id + if new_file_id is None: + session.bg_bytes = session.fallback_bg + else: + data = await hass.async_add_executor_job( + _read_pending_upload, hass, new_file_id + ) + session.bg_bytes = data if data is not None else session.fallback_bg + elif session.mode == "remove": + session.bg_bytes = ( + None if user_input.get(CONF_CONFIRM) else session.fallback_bg + ) + else: + session.offset_x = _as_float(user_input.get(CONF_OFFSET_X), session.offset_x) + session.offset_y = _as_float(user_input.get(CONF_OFFSET_Y), session.offset_y) + session.scale_x = _clamp_scale( + _as_float(user_input.get(CONF_SCALE_X), session.scale_x * 100) / 100 + ) + session.scale_y = _clamp_scale( + _as_float(user_input.get(CONF_SCALE_Y), session.scale_y * 100) / 100 + ) + session.rotation = _as_rotation( + user_input.get(CONF_MAP_ROTATION), session.rotation + ) + + session.revision = next(_preview_revision) + session.touch() + + async_dispatcher_send( + hass, signal_map_refresh(entry_id, session.rotation_key) + ) + + connection.send_result(msg["id"]) + connection.subscriptions[msg["id"]] = lambda: None + + state = hass.states.get(session.entity_id) + access_token = state and state.attributes.get("access_token") + if not access_token: + connection.send_message( + websocket_api.event_message( + msg["id"], {"error": "Map image entity is unavailable"} + ) + ) + return + + connection.send_message( + websocket_api.event_message( + msg["id"], + { + "entity_id": session.entity_id, + "state": str(session.revision), + "domain": "image", + "attributes": { + "access_token": access_token, + "friendly_name": "", + }, + }, + ) + ) diff --git a/custom_components/roborock_custom_map/select.py b/custom_components/roborock_custom_map/select.py index a28d3ba..8143b09 100644 --- a/custom_components/roborock_custom_map/select.py +++ b/custom_components/roborock_custom_map/select.py @@ -7,17 +7,21 @@ from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from .const import ( - CONF_MAP_ROTATION, DEFAULT_MAP_ROTATION, - DOMAIN, MAP_ROTATION_OPTIONS, - SIGNAL_ROTATION_CHANGED, + map_key, + set_map_rotation, + signal_map_refresh, + signal_set_rotation, ) PARALLEL_UPDATES = 0 @@ -63,7 +67,7 @@ def __init__( self.config_entry = config_entry self.map_flag = map_flag - self.rotation_key = f"{coordinator.duid_slug}_{map_flag}" + self.rotation_key = map_key(coordinator.duid_slug, map_flag) if not map_name: map_name = f"Map {map_flag}" @@ -80,28 +84,47 @@ async def async_added_to_hass(self) -> None: if last.state in self._attr_options: self._attr_current_option = last.state - # Persist selection for the image entity to read - self.hass.data[DOMAIN][self.config_entry.entry_id][CONF_MAP_ROTATION][ - self.rotation_key - ] = int(self._attr_current_option) + set_map_rotation( + self.hass, + self.config_entry.entry_id, + self.rotation_key, + int(self._attr_current_option), + ) self.async_write_ha_state() + self.async_on_remove( + async_dispatcher_connect( + self.hass, + signal_set_rotation(self.config_entry.entry_id, self.rotation_key), + self._handle_set_rotation, + ) + ) + async def async_select_option(self, option: str) -> None: """Handle user selecting a rotation option.""" + self._apply_rotation(option) + + @callback + def _handle_set_rotation(self, rotation: int) -> None: + """Apply a rotation requested by the options flow's adjust step.""" + self._apply_rotation(str(rotation)) + + @callback + def _apply_rotation(self, option: str) -> None: + """Set the rotation, persist it, and refresh the map image.""" if option not in self._attr_options: return self._attr_current_option = option - self.hass.data[DOMAIN][self.config_entry.entry_id][CONF_MAP_ROTATION][ - self.rotation_key - ] = int(option) + set_map_rotation( + self.hass, self.config_entry.entry_id, self.rotation_key, int(option) + ) - # Notify the image entity to bust the cache via image_last_updated bump async_dispatcher_send( self.hass, - f"{SIGNAL_ROTATION_CHANGED}_{self.config_entry.entry_id}_{self.rotation_key}", + signal_map_refresh(self.config_entry.entry_id, self.rotation_key), ) self.async_write_ha_state() diff --git a/custom_components/roborock_custom_map/translations/de.json b/custom_components/roborock_custom_map/translations/de.json index fe3fd1c..0291e5e 100644 --- a/custom_components/roborock_custom_map/translations/de.json +++ b/custom_components/roborock_custom_map/translations/de.json @@ -4,6 +4,69 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } }, + "options": { + "step": { + "init": { + "title": "Karte auswählen", + "data": { + "map": "Karte" + } + }, + "map_menu": { + "title": "Eigener Grundriss", + "menu_options": { + "tune": "Eigenen Grundriss anpassen", + "upload": "Eigenen Grundriss ersetzen", + "remove_confirm": "Eigenen Grundriss entfernen" + } + }, + "upload": { + "title": "Eigenen Grundriss hochladen", + "data": { + "file": "Datei auswählen" + } + }, + "tune": { + "title": "Eigenen Grundriss anpassen", + "description": "Richte deinen eigenen Grundriss an den physischen Wänden aus.", + "data": { + "offset_x": "Horizontaler Versatz", + "offset_y": "Vertikaler Versatz", + "scale_x": "Horizontale Skalierung", + "scale_y": "Vertikale Skalierung", + "map_rotation": "Drehung" + }, + "data_description": { + "offset_x": "Versatz < 0px ⇒ Grundriss ◀ verschieben; Versatz > 0px ⇒ Grundriss ▶ verschieben", + "offset_y": "Versatz < 0px ⇒ Grundriss ▲ verschieben; Versatz > 0px ⇒ Grundriss ▼ verschieben", + "scale_x": "Horizontaler Faktor für deinen eigenen Grundriss", + "scale_y": "Vertikaler Faktor für deinen eigenen Grundriss", + "map_rotation": "Drehung der physischen Wände" + } + }, + "remove_confirm": { + "title": "Eigenen Grundriss entfernen", + "data": { + "confirm": "Grundriss auf die Basisversion zurücksetzen" + } + } + }, + "error": { + "invalid_image": "Die Datei ist kein unterstütztes Bild (PNG, JPEG oder WebP).", + "file_too_large": "Die Datei ist zu groß (maximal 20 MB).", + "image_too_large": "Die Bildabmessungen sind zu groß (maximal 8192 px).", + "upload_failed": "Die hochgeladene Datei konnte nicht gelesen werden. Bitte wähle die Datei erneut aus." + }, + "abort": { + "not_loaded": "Die Integration ist nicht geladen. Stelle sicher, dass die Roborock-Integration läuft, und versuche es erneut.", + "no_maps": "Es wurden keine Karten gefunden. Warte, bis die Roborock-Integration vollständig geladen ist, und versuche es erneut.", + "unknown_map": "Die ausgewählte Karte existiert nicht mehr.", + "no_raw_map": "Rohe Kartendaten sind noch nicht verfügbar. Warte auf die nächste Kartenaktualisierung und versuche es erneut.", + "no_entity": "Die Kartenbild-Entität wurde nicht gefunden. Lade die Integration neu und versuche es erneut.", + "missing_override_file": "Der gespeicherte Grundriss konnte nicht gelesen werden. Bitte lade ihn erneut hoch.", + "write_failed": "Der Grundriss konnte nicht gespeichert werden. Prüfe die Home-Assistant-Logs." + } + }, "entity": { "select": { "rotation": { diff --git a/custom_components/roborock_custom_map/translations/en.json b/custom_components/roborock_custom_map/translations/en.json index c65dfd3..17b9086 100644 --- a/custom_components/roborock_custom_map/translations/en.json +++ b/custom_components/roborock_custom_map/translations/en.json @@ -4,6 +4,69 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } }, + "options": { + "step": { + "init": { + "title": "Select map", + "data": { + "map": "Map" + } + }, + "map_menu": { + "title": "Custom Floor Plan", + "menu_options": { + "tune": "Adjust Custom Floor Plan", + "upload": "Replace Custom Floor Plan", + "remove_confirm": "Remove Custom Floor Plan" + } + }, + "upload": { + "title": "Upload Custom Floor Plan", + "data": { + "file": "Choose File" + } + }, + "tune": { + "title": "Adjust Custom Floor Plan", + "description": "Line your Custom Floor Plan up with the Physical Walls.", + "data": { + "offset_x": "Horizontal Offset", + "offset_y": "Vertical Offset", + "scale_x": "Horizontal Scale", + "scale_y": "Vertical Scale", + "map_rotation": "Rotation" + }, + "data_description": { + "offset_x": "offset < 0px ⇒ move your Floor Plan ◀; offset > 0px ⇒ move your Floor Plan ▶", + "offset_y": "offset < 0px ⇒ move your Floor Plan ▲; offset > 0px ⇒ move your Floor Plan ▼", + "scale_x": "Horizontal Multiplier applied to your Custom Floor Plan", + "scale_y": "Vertical Multiplier applied to your Custom Floor Plan", + "map_rotation": "Rotation applied to the Physical Walls" + } + }, + "remove_confirm": { + "title": "Remove Custom Floor Plan", + "data": { + "confirm": "Restore the Floor Plan to the base version" + } + } + }, + "error": { + "invalid_image": "The file is not a supported image (PNG, JPEG or WebP).", + "file_too_large": "The file is too large (20 MB maximum).", + "image_too_large": "The image dimensions are too large (8192 px maximum).", + "upload_failed": "The uploaded file could not be read. Please pick the file again." + }, + "abort": { + "not_loaded": "The integration is not loaded. Make sure the Roborock integration is running, then try again.", + "no_maps": "No maps were found. Wait for the Roborock integration to finish loading and try again.", + "unknown_map": "The selected map no longer exists.", + "no_raw_map": "Raw map data is not available yet. Wait for the next map refresh and try again.", + "no_entity": "The map image entity was not found. Reload the integration and try again.", + "missing_override_file": "The stored floor plan could not be read. Please upload it again.", + "write_failed": "The floor plan could not be saved. Check the Home Assistant logs." + } + }, "entity": { "select": { "rotation": {