From c75db1b8dd0d51327d1fcd7617a80e3df84b11d4 Mon Sep 17 00:00:00 2001 From: hansherlighed <47210174+hansherlighed@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:42:58 +0200 Subject: [PATCH] added backgrpund image support and merged PR #37 --- .../roborock_custom_map/__init__.py | 32 ++- .../roborock_custom_map/config_flow.py | 81 +++++- .../roborock_custom_map/const.py | 33 +++ .../roborock_custom_map/image.py | 243 +++++++++++++++++- .../roborock_custom_map/select.py | 110 ++++++++ .../roborock_custom_map/translations/en.json | 64 +++++ 6 files changed, 546 insertions(+), 17 deletions(-) create mode 100644 custom_components/roborock_custom_map/select.py diff --git a/custom_components/roborock_custom_map/__init__.py b/custom_components/roborock_custom_map/__init__.py index bc1742c..80c718c 100644 --- a/custom_components/roborock_custom_map/__init__.py +++ b/custom_components/roborock_custom_map/__init__.py @@ -2,13 +2,14 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import Platform -from homeassistant.core import HomeAssistant -from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -PLATFORMS = [Platform.IMAGE] +from .const import CONF_MAP_ROTATION, DOMAIN + +PLATFORMS = [Platform.IMAGE, Platform.SELECT] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -16,17 +17,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: roborock_entries = hass.config_entries.async_entries("roborock") coordinators = [] - async def unload_this_entry(): - await hass.config_entries.async_reload(entry.entry_id) + @callback + def unload_this_entry() -> None: + hass.async_create_task(hass.config_entries.async_reload(entry.entry_id)) for r_entry in roborock_entries: if r_entry.state == ConfigEntryState.LOADED: coordinators.extend(r_entry.runtime_data.v1) - # If any unload, then we should reload as well in case there are major changes. - r_entry.async_on_unload(unload_this_entry) - if len(coordinators) == 0: + + r_entry.async_on_unload(unload_this_entry) + + if not coordinators: raise ConfigEntryNotReady("No Roborock entries loaded. Cannot start.") + 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, {}) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -34,4 +43,7 @@ async def unload_this_entry(): async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unloaded: + hass.data.get(DOMAIN, {}).pop(entry.entry_id, None) + return unloaded diff --git a/custom_components/roborock_custom_map/config_flow.py b/custom_components/roborock_custom_map/config_flow.py index 51791ef..2208225 100644 --- a/custom_components/roborock_custom_map/config_flow.py +++ b/custom_components/roborock_custom_map/config_flow.py @@ -2,12 +2,25 @@ from __future__ import annotations +from copy import deepcopy from typing import Any +import voluptuous as vol + from homeassistant import config_entries +from homeassistant.config_entries import OptionsFlowWithReload +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult -from .const import DOMAIN +from .const import ( + CONF_SHOW_BACKGROUND, + CONF_SHOW_FLOOR, + CONF_SHOW_ROOMS, + CONF_SHOW_WALLS, + DEFAULT_DRAWABLES, + DOMAIN, + DRAWABLES, +) class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -22,3 +35,69 @@ async def async_step_user( 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) -> RoborockCustomMapOptionsFlow: + """Create the options flow.""" + return RoborockCustomMapOptionsFlow(config_entry) + + +class RoborockCustomMapOptionsFlow(OptionsFlowWithReload): + """Handle options for Roborock Custom Map.""" + + def __init__(self, config_entry) -> None: + """Initialize options flow.""" + self.options = deepcopy(dict(config_entry.options)) + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options.""" + if user_input is not None: + self.options[CONF_SHOW_BACKGROUND] = user_input.pop(CONF_SHOW_BACKGROUND) + self.options[CONF_SHOW_WALLS] = user_input.pop(CONF_SHOW_WALLS) + self.options[CONF_SHOW_ROOMS] = user_input.pop(CONF_SHOW_ROOMS) + self.options[CONF_SHOW_FLOOR] = user_input.pop(CONF_SHOW_FLOOR) + self.options.setdefault(DRAWABLES, {}).update(user_input) + return self.async_create_entry(title="", data=self.options) + + data_schema: dict = {} + for drawable, default_value in DEFAULT_DRAWABLES.items(): + data_schema[ + vol.Required( + drawable.value, + default=self.config_entry.options.get(DRAWABLES, {}).get( + drawable.value, default_value + ), + ) + ] = bool + data_schema[ + vol.Required( + CONF_SHOW_BACKGROUND, + default=self.config_entry.options.get(CONF_SHOW_BACKGROUND, True), + ) + ] = bool + data_schema[ + vol.Required( + CONF_SHOW_WALLS, + default=self.config_entry.options.get(CONF_SHOW_WALLS, True), + ) + ] = bool + data_schema[ + vol.Required( + CONF_SHOW_ROOMS, + default=self.config_entry.options.get(CONF_SHOW_ROOMS, True), + ) + ] = bool + data_schema[ + vol.Required( + CONF_SHOW_FLOOR, + default=self.config_entry.options.get(CONF_SHOW_FLOOR, True), + ) + ] = bool + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema(data_schema), + ) diff --git a/custom_components/roborock_custom_map/const.py b/custom_components/roborock_custom_map/const.py index 8cdbb6f..d552487 100644 --- a/custom_components/roborock_custom_map/const.py +++ b/custom_components/roborock_custom_map/const.py @@ -1,3 +1,36 @@ """Constants for Roborock Custom Map integration.""" +from vacuum_map_parser_base.config.drawable import Drawable + DOMAIN = "roborock_custom_map" + +CONF_MAP_ROTATION = "map_rotation" +DEFAULT_MAP_ROTATION = 0 +MAP_ROTATION_OPTIONS = (0, 90, 180, 270) + +SIGNAL_ROTATION_CHANGED = "roborock_custom_map_rotation_changed" + +CONF_SHOW_BACKGROUND = "show_background" +CONF_SHOW_WALLS = "show_walls" +CONF_SHOW_ROOMS = "show_rooms" +CONF_SHOW_FLOOR = "show_floor" +DRAWABLES = "drawables" + +DEFAULT_DRAWABLES = { + Drawable.CHARGER: True, + Drawable.CLEANED_AREA: False, + Drawable.GOTO_PATH: False, + Drawable.IGNORED_OBSTACLES: False, + Drawable.IGNORED_OBSTACLES_WITH_PHOTO: False, + Drawable.MOP_PATH: False, + Drawable.NO_CARPET_AREAS: False, + Drawable.NO_GO_AREAS: False, + Drawable.NO_MOPPING_AREAS: False, + Drawable.OBSTACLES: False, + Drawable.OBSTACLES_WITH_PHOTO: False, + Drawable.PATH: True, + Drawable.PREDICTED_PATH: False, + Drawable.VACUUM_POSITION: True, + Drawable.VIRTUAL_WALLS: False, + Drawable.ZONES: False, +} diff --git a/custom_components/roborock_custom_map/image.py b/custom_components/roborock_custom_map/image.py index 2928793..8b8749f 100644 --- a/custom_components/roborock_custom_map/image.py +++ b/custom_components/roborock_custom_map/image.py @@ -1,24 +1,132 @@ """Support for Roborock image.""" +from __future__ import annotations + from datetime import datetime +import io import logging +from PIL import Image, UnidentifiedImageError +from roborock.devices.traits.v1.home import HomeTrait +from roborock.devices.traits.v1.map_content import MapContent + from homeassistant.components.image import ImageEntity from homeassistant.components.roborock.coordinator import RoborockDataUpdateCoordinator from homeassistant.components.roborock.entity import RoborockCoordinatedEntityV1 from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from roborock.devices.traits.v1.home import HomeTrait -from roborock.devices.traits.v1.map_content import MapContent from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util +from roborock.map.map_parser import MapParser, MapParserConfig + +from .const import ( + CONF_MAP_ROTATION, + CONF_SHOW_BACKGROUND, + CONF_SHOW_FLOOR, + CONF_SHOW_ROOMS, + CONF_SHOW_WALLS, + DEFAULT_DRAWABLES, + DEFAULT_MAP_ROTATION, + DOMAIN, + DRAWABLES, + MAP_ROTATION_OPTIONS, + SIGNAL_ROTATION_CHANGED, +) _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 +def _png_dimensions(data: bytes) -> tuple[int, int] | None: + """Return PNG (width, height) from raw bytes, or None if not a PNG.""" + if len(data) < 24: + return None + if data[:8] != b"\x89PNG\r\n\x1a\n": + return None + width = int.from_bytes(data[16:20], "big") + height = int.from_bytes(data[20:24], "big") + if width <= 0 or height <= 0: + return 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) + + +def _build_map_parser_config(options: dict) -> MapParserConfig: + """Build a MapParserConfig from config entry options.""" + drawables_options = options.get(DRAWABLES, {}) + drawables = [ + drawable + for drawable, default in DEFAULT_DRAWABLES.items() + if drawables_options.get(drawable.value, default) + ] + return MapParserConfig( + drawables=drawables, + show_background=options.get(CONF_SHOW_BACKGROUND, True), + show_walls=options.get(CONF_SHOW_WALLS, True), + show_rooms=options.get(CONF_SHOW_ROOMS, True), + ) + + +def _remove_floor_colors(image_bytes: bytes) -> bytes: + """Make floor-colored pixels transparent using PIL.""" + from vacuum_map_parser_base.config.color import ColorsPalette, SupportedColor + + palette = ColorsPalette() + cached = palette.cached_colors + floor_colors: set[tuple[int, int, int]] = set() + for key_name in ( + "MAP_INSIDE", "MAP_OUTSIDE", "SCAN", "UNKNOWN", + "CARPETS", "NEW_DISCOVERED_AREA", + "MAP_WALL", "MAP_WALL_V2", "GREY_WALL", + ): + key = getattr(SupportedColor, key_name, None) + if key is None: + continue + color = cached.get(key) + if color and len(color) >= 3: + floor_colors.add((color[0], color[1], color[2])) + + if not floor_colors: + _LOGGER.debug("roborock_custom_map: could not determine floor colors from palette") + return image_bytes + + _LOGGER.debug("roborock_custom_map: removing floor colors %s", floor_colors) + img = Image.open(io.BytesIO(image_bytes)).convert("RGBA") + data = list(img.getdata()) + new_data = [ + (0, 0, 0, 0) if (pixel[0], pixel[1], pixel[2]) in floor_colors else pixel + for pixel in data + ] + img.putdata(new_data) + buf = io.BytesIO() + img.save(buf, format="PNG") + return buf.getvalue() + + async def async_setup_entry( hass: HomeAssistant, config_entry, @@ -68,6 +176,8 @@ def __init__( self._home_trait = home_trait self.cached_map = b"" + self._custom_cached_map: bytes | None = None + self._raw_image_size: tuple[int, int] | None = None self._attr_entity_category = EntityCategory.DIAGNOSTIC @property @@ -87,37 +197,158 @@ async def async_added_to_hass(self) -> None: """When entity is added to hass load any previously cached maps from disk.""" await super().async_added_to_hass() 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.map_flag}", + self._handle_rotation_changed, + ) + ) + + self.async_write_ha_state() + + def _handle_rotation_changed(self) -> None: + """Rotation changed; clear custom cache and bump last_updated to bust the image cache.""" + self._custom_cached_map = None + self._attr_image_last_updated = dt_util.utcnow() self.async_write_ha_state() def _handle_coordinator_update(self) -> None: - # If the coordinator has updated the map, we can update the image. + """Handle coordinator update.""" if (map_content := self._map_content) is None: return if self.cached_map != map_content.image_content: self.cached_map = map_content.image_content + self._raw_image_size = _png_dimensions(self.cached_map) + self._custom_cached_map = None self._attr_image_last_updated = self.coordinator.last_home_update super()._handle_coordinator_update() + 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.map_flag, DEFAULT_MAP_ROTATION) + ) + if rotation not in MAP_ROTATION_OPTIONS: + _LOGGER.debug( + "Unsupported map rotation %s, allowed values: %s, falling back to %s", + rotation, + MAP_ROTATION_OPTIONS, + DEFAULT_MAP_ROTATION, + ) + return DEFAULT_MAP_ROTATION + return rotation + + def _rotate_image(self, raw: bytes, rotation: int) -> bytes: + """Rotate image in executor thread.""" + img = Image.open(io.BytesIO(raw)) + img = img.rotate(rotation, expand=True) + out = io.BytesIO() + img.save(out, format="PNG") + return out.getvalue() + async def async_image(self) -> bytes | None: - """Get the cached image.""" + """Get the cached image, re-rendered with custom options and rotation if configured.""" if (map_content := self._map_content) is None: raise HomeAssistantError("Map flag not found in coordinator maps") - return map_content.image_content + + options = self.config_entry.options + _LOGGER.debug( + "roborock_custom_map async_image: options=%s, raw_api_response is None=%s", + dict(options), + map_content.raw_api_response is None, + ) + + # If no options or no raw data, fall back to rotation-only on the default image + if not options or map_content.raw_api_response is None: + raw = map_content.image_content + rotation = self._get_rotation() + if rotation == DEFAULT_MAP_ROTATION: + return raw + try: + return await self.hass.async_add_executor_job( + self._rotate_image, raw, rotation + ) + except (OSError, UnidentifiedImageError) as err: + _LOGGER.debug("Failed to rotate map image: %s, returning original", err) + return raw + + if self._custom_cached_map is not None: + return self._custom_cached_map + + config = _build_map_parser_config(options) + parser = MapParser(config) + try: + parsed = await self.hass.async_add_executor_job( + parser.parse, map_content.raw_api_response + ) + custom_map = parsed.image_content + if not options.get(CONF_SHOW_FLOOR, True) and custom_map: + custom_map = await self.hass.async_add_executor_job( + _remove_floor_colors, custom_map + ) + rotation = self._get_rotation() + if rotation != DEFAULT_MAP_ROTATION and custom_map: + try: + custom_map = await self.hass.async_add_executor_job( + self._rotate_image, custom_map, rotation + ) + except (OSError, UnidentifiedImageError) as err: + _LOGGER.debug("Failed to rotate custom map image: %s", err) + self._custom_cached_map = custom_map + except Exception: + _LOGGER.exception("Failed to re-render map with custom options") + return map_content.image_content + + return self._custom_cached_map @property def extra_state_attributes(self): + """Return extra attributes for map card usage (rotation-aware calibration).""" if (map_content := self._map_content) is None: raise HomeAssistantError("Map flag not found in coordinator maps") map_data = map_content.map_data if map_data is None: return {} + + # Attach room names 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) room.name = name.name if name else "Unknown" + calibration = map_data.calibration() + + # Rotate ONLY the "map" (pixel-space) side of calibration points. + # Vacuum coordinate space (rooms/zones) is not affected. + rotation = self._get_rotation() + size = self._raw_image_size + if rotation != DEFAULT_MAP_ROTATION and size is not None: + w, h = size + rotated_calibration = [] + for pt in calibration: + mp = pt.get("map") or {} + x = mp.get("x") + y = mp.get("y") + if x is None or y is None: + rotated_calibration.append(pt) + continue + nx, ny = _rotate_point_map_xy(float(x), float(y), w, h, rotation) + new_pt = dict(pt) + new_map = dict(mp) + new_map["x"] = nx + new_map["y"] = ny + new_pt["map"] = new_map + rotated_calibration.append(new_pt) + calibration = rotated_calibration + return { "calibration_points": calibration, "rooms": map_data.rooms, diff --git a/custom_components/roborock_custom_map/select.py b/custom_components/roborock_custom_map/select.py new file mode 100644 index 0000000..e383e53 --- /dev/null +++ b/custom_components/roborock_custom_map/select.py @@ -0,0 +1,110 @@ +"""Support for Roborock map rotation select entities.""" + +from __future__ import annotations + +import logging + +from homeassistant.components.roborock.coordinator import RoborockDataUpdateCoordinator +from homeassistant.components.roborock.entity import RoborockCoordinatedEntityV1 +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.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, +) + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up rotation Select entities (one per map).""" + async_add_entities( + RoborockMapRotationSelect( + config_entry=config_entry, + unique_id=f"{coord.duid_slug}_map_rotation_{map_info.map_flag}", + coordinator=coord, + map_flag=map_info.map_flag, + map_name=map_info.name, + ) + for coord in config_entry.runtime_data + if coord.properties_api.home is not None + for map_info in (coord.properties_api.home.home_map_info or {}).values() + ) + + +class RoborockMapRotationSelect(RoborockCoordinatedEntityV1, RestoreEntity, SelectEntity): + """Select entity to control map rotation.""" + + _attr_has_entity_name = True + _attr_entity_category = EntityCategory.CONFIG + + def __init__( + self, + config_entry: ConfigEntry, + unique_id: str, + coordinator: RoborockDataUpdateCoordinator, + map_flag: int, + map_name: str, + ) -> None: + """Initialize rotation select.""" + RoborockCoordinatedEntityV1.__init__(self, unique_id, coordinator) + + self.config_entry = config_entry + self.map_flag = map_flag + + if not map_name: + map_name = f"Map {map_flag}" + + self._attr_name = f"{map_name} rotation" + self._attr_options = [str(v) for v in MAP_ROTATION_OPTIONS] + self._attr_current_option = str(DEFAULT_MAP_ROTATION) + self._attr_translation_key = "rotation" + + async def async_added_to_hass(self) -> None: + """Restore previous rotation setting and store in hass.data.""" + await super().async_added_to_hass() + + if (last := await self.async_get_last_state()) is not 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.map_flag + ] = int(self._attr_current_option) + + self.async_write_ha_state() + + async def async_select_option(self, option: str) -> None: + """Handle user selecting a rotation option.""" + 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.map_flag + ] = 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.map_flag}", + ) + + self.async_write_ha_state() diff --git a/custom_components/roborock_custom_map/translations/en.json b/custom_components/roborock_custom_map/translations/en.json index 9ef6414..845d4ea 100644 --- a/custom_components/roborock_custom_map/translations/en.json +++ b/custom_components/roborock_custom_map/translations/en.json @@ -3,5 +3,69 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } + }, + "options": { + "step": { + "init": { + "description": "Specify which features to draw on the map.", + "data": { + "charger": "Charger", + "cleaned_area": "Cleaned area", + "goto_path": "Go-to path", + "ignored_obstacles": "Ignored obstacles", + "ignored_obstacles_with_photo": "Ignored obstacles with photo", + "mop_path": "Mop path", + "no_carpet_zones": "No carpet zones", + "no_go_zones": "No-go zones", + "no_mopping_zones": "No mopping zones", + "obstacles": "Obstacles", + "obstacles_with_photo": "Obstacles with photo", + "path": "Path", + "predicted_path": "Predicted path", + "vacuum_position": "Vacuum position", + "virtual_walls": "Virtual walls", + "zones": "Zones", + "show_background": "Show background", + "show_walls": "Show walls", + "show_rooms": "Show rooms", + "show_floor": "Show floor (scanned area)" + }, + "data_description": { + "charger": "Show the charger on the map.", + "cleaned_area": "Show the area cleaned on the map.", + "goto_path": "Show the go-to path on the map.", + "ignored_obstacles": "Show ignored obstacles on the map.", + "ignored_obstacles_with_photo": "Show ignored obstacles with photos on the map.", + "mop_path": "Show the mop path on the map.", + "no_carpet_zones": "Show the no carpet zones on the map.", + "no_go_zones": "Show the no-go zones on the map.", + "no_mopping_zones": "Show the no-mop zones on the map.", + "obstacles": "Show obstacles on the map.", + "obstacles_with_photo": "Show obstacles with photos on the map.", + "path": "Show the path on the map.", + "predicted_path": "Show the predicted path on the map.", + "vacuum_position": "Show the vacuum position on the map.", + "virtual_walls": "Show virtual walls on the map.", + "zones": "Show zones on the map.", + "show_background": "Show the map background (the area outside the cleaned region). Disable for a transparent background image.", + "show_walls": "Show walls on the map. Disable for a transparent overlay.", + "show_rooms": "Show room colouring on the map. Disable for a transparent overlay.", + "show_floor": "Show the general scanned floor area (MAP_INSIDE/MAP_SCAN pixels). Disable to hide removed/hidden zones that appear as light green." + } + } + } + }, + "entity": { + "select": { + "rotation": { + "name": "Map rotation", + "state": { + "0": "0°", + "90": "90°", + "180": "180°", + "270": "270°" + } + } + } } }