diff --git a/custom_components/album_slideshow/__pycache__/__init__.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 2f08b75..0000000 Binary files a/custom_components/album_slideshow/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/custom_components/album_slideshow/__pycache__/button.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/button.cpython-314.pyc deleted file mode 100644 index c96fa01..0000000 Binary files a/custom_components/album_slideshow/__pycache__/button.cpython-314.pyc and /dev/null differ diff --git a/custom_components/album_slideshow/__pycache__/camera.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/camera.cpython-314.pyc deleted file mode 100644 index 570a7e1..0000000 Binary files a/custom_components/album_slideshow/__pycache__/camera.cpython-314.pyc and /dev/null differ diff --git a/custom_components/album_slideshow/__pycache__/config_flow.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/config_flow.cpython-314.pyc deleted file mode 100644 index 165176f..0000000 Binary files a/custom_components/album_slideshow/__pycache__/config_flow.cpython-314.pyc and /dev/null differ diff --git a/custom_components/album_slideshow/__pycache__/const.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/const.cpython-314.pyc deleted file mode 100644 index 9c07ada..0000000 Binary files a/custom_components/album_slideshow/__pycache__/const.cpython-314.pyc and /dev/null differ diff --git a/custom_components/album_slideshow/__pycache__/coordinator.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/coordinator.cpython-314.pyc deleted file mode 100644 index effad61..0000000 Binary files a/custom_components/album_slideshow/__pycache__/coordinator.cpython-314.pyc and /dev/null differ diff --git a/custom_components/album_slideshow/__pycache__/google_scraper.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/google_scraper.cpython-314.pyc deleted file mode 100644 index d4e6caa..0000000 Binary files a/custom_components/album_slideshow/__pycache__/google_scraper.cpython-314.pyc and /dev/null differ diff --git a/custom_components/album_slideshow/__pycache__/image_processing.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/image_processing.cpython-314.pyc deleted file mode 100644 index b10e410..0000000 Binary files a/custom_components/album_slideshow/__pycache__/image_processing.cpython-314.pyc and /dev/null differ diff --git a/custom_components/album_slideshow/__pycache__/number.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/number.cpython-314.pyc deleted file mode 100644 index 9dab212..0000000 Binary files a/custom_components/album_slideshow/__pycache__/number.cpython-314.pyc and /dev/null differ diff --git a/custom_components/album_slideshow/__pycache__/playlist.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/playlist.cpython-314.pyc deleted file mode 100644 index 4e7d519..0000000 Binary files a/custom_components/album_slideshow/__pycache__/playlist.cpython-314.pyc and /dev/null differ diff --git a/custom_components/album_slideshow/__pycache__/select.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/select.cpython-314.pyc deleted file mode 100644 index f18dd4d..0000000 Binary files a/custom_components/album_slideshow/__pycache__/select.cpython-314.pyc and /dev/null differ diff --git a/custom_components/album_slideshow/__pycache__/sensor.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/sensor.cpython-314.pyc deleted file mode 100644 index 05660c8..0000000 Binary files a/custom_components/album_slideshow/__pycache__/sensor.cpython-314.pyc and /dev/null differ diff --git a/custom_components/album_slideshow/__pycache__/store.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/store.cpython-314.pyc deleted file mode 100644 index 45fcff1..0000000 Binary files a/custom_components/album_slideshow/__pycache__/store.cpython-314.pyc and /dev/null differ diff --git a/custom_components/album_slideshow/__pycache__/switch.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/switch.cpython-314.pyc deleted file mode 100644 index c7b4b6e..0000000 Binary files a/custom_components/album_slideshow/__pycache__/switch.cpython-314.pyc and /dev/null differ diff --git a/custom_components/album_slideshow/__pycache__/text.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/text.cpython-314.pyc deleted file mode 100644 index 1181b20..0000000 Binary files a/custom_components/album_slideshow/__pycache__/text.cpython-314.pyc and /dev/null differ diff --git a/custom_components/album_slideshow/config_flow.py b/custom_components/album_slideshow/config_flow.py index 9b7f9fb..aefc9ab 100644 --- a/custom_components/album_slideshow/config_flow.py +++ b/custom_components/album_slideshow/config_flow.py @@ -61,12 +61,24 @@ from .const import ( CONF_SYNOLOGY_SPACE, CONF_SYNOLOGY_ALBUM_ID, CONF_SYNOLOGY_IMAGE_SIZE, + CONF_SYNOLOGY_PASSPHRASE, + CONF_SYNOLOGY_FAVORITE, + CONF_SYNOLOGY_SELECTION, DEFAULT_SYNOLOGY_IMAGE_SIZE, SYNOLOGY_SPACE_PERSONAL, SYNOLOGY_SPACE_SHARED, SYNOLOGY_IMAGE_SMALL, SYNOLOGY_IMAGE_MEDIUM, SYNOLOGY_IMAGE_LARGE, + CONF_NEXTCLOUD_URL, + CONF_NEXTCLOUD_USERNAME, + CONF_NEXTCLOUD_PASSWORD, + CONF_NEXTCLOUD_FOLDER, + CONF_NEXTCLOUD_RECURSIVE, + CONF_NEXTCLOUD_IMAGE_SIZE, + DEFAULT_NEXTCLOUD_IMAGE_SIZE, + NEXTCLOUD_IMAGE_PREVIEW, + NEXTCLOUD_IMAGE_ORIGINAL, DEFAULT_REVERSE_GEOCODE, PROVIDER_GOOGLE_SHARED, PROVIDER_LOCAL_FOLDER, @@ -75,6 +87,7 @@ from .const import ( PROVIDER_PHOTOPRISM, PROVIDER_ICLOUD, PROVIDER_SYNOLOGY, + PROVIDER_NEXTCLOUD, DEFAULT_RECURSIVE, ) @@ -129,6 +142,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._syn_device_id: str | None = None self._syn_space: str = SYNOLOGY_SPACE_PERSONAL self._syn_albums: dict[str, str] = {} + # option key -> {"album_id": id|None, "passphrase": str|None} + self._syn_album_meta: dict[str, dict[str, Any]] = {} + # id(str) -> name maps for the composite category multi-selects. + self._syn_people: dict[str, str] = {} + self._syn_places: dict[str, str] = {} + self._syn_tags: dict[str, str] = {} + self._syn_subjects: dict[str, str] = {} @staticmethod @callback @@ -147,7 +167,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ``__init__`` raises (the symptom is a 500 when the user clicks Configure). """ - if config_entry.data.get(CONF_PROVIDER) == PROVIDER_LOCAL_FOLDER: + if config_entry.data.get(CONF_PROVIDER) in ( + PROVIDER_LOCAL_FOLDER, + PROVIDER_NEXTCLOUD, + ): return LocalFolderOptionsFlow() return _NoOptionsFlow() @@ -166,6 +189,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_icloud() if self._provider == PROVIDER_SYNOLOGY: return await self.async_step_synology() + if self._provider == PROVIDER_NEXTCLOUD: + return await self.async_step_nextcloud() return await self.async_step_google_shared() schema = vol.Schema( @@ -177,6 +202,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): PROVIDER_PHOTOPRISM: "PhotoPrism (direct API, full metadata)", PROVIDER_ICLOUD: "iCloud Shared Album", PROVIDER_SYNOLOGY: "Synology Photos (direct API, full metadata)", + PROVIDER_NEXTCLOUD: "Nextcloud (WebDAV folder, full metadata)", PROVIDER_MEDIA_SOURCE: "Media Source (any source, no metadata)", }) } @@ -699,9 +725,23 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) try: await client.async_login(otp_code=otp or None) - albums = await client.async_list_albums() + # Albums and category browsing live only in the Personal space + # (there is no Shared Space album/category API). For the Shared + # Space, validate access up front so a permission problem + # surfaces here, not later. + if space == SYNOLOGY_SPACE_SHARED: + albums = people = places = tags = subjects = [] + await client.async_collect_assets(None) + else: + albums = await client.async_list_albums() + people = await client.async_list_people() + places = await client.async_list_places() + tags = await client.async_list_tags() + subjects = await client.async_list_subjects() except syn_api.SynologyOtpRequired: errors["otp_code"] = "synology_otp_required" + except syn_api.SynologyPermissionError: + errors["base"] = "synology_shared_unavailable" except Exception: # noqa: BLE001 - any failure means bad URL/creds errors["base"] = "synology_cannot_connect" else: @@ -712,10 +752,33 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # A trusted-device token is captured only on the OTP login; # store it so future logins skip the 2FA prompt. self._syn_device_id = client.captured_device_id - self._syn_albums = { - str(a["id"]): (a.get("name") or str(a["id"])) - for a in albums - if a.get("id") is not None + # Key each album by a synthetic value so an own album and a + # shared-with-me album that happen to share a numeric id don't + # collide. Track album_id + passphrase per option. + self._syn_albums = {} + self._syn_album_meta = {} + for a in albums: + if a.get("id") is None: + continue + shared = bool(a.get("shared")) + key = f"{'shared' if shared else 'own'}:{a['id']}" + label = a.get("name") or str(a["id"]) + self._syn_albums[key] = f"{label} (shared)" if shared else label + self._syn_album_meta[key] = { + "album_id": None if shared else a["id"], + "passphrase": a.get("passphrase") if shared else None, + } + self._syn_people = { + str(p["id"]): p["name"] for p in people if p.get("id") is not None + } + self._syn_places = { + str(p["id"]): p["name"] for p in places if p.get("id") is not None + } + self._syn_tags = { + str(t["id"]): t["name"] for t in tags if t.get("id") is not None + } + self._syn_subjects = { + str(s["id"]): s["name"] for s in subjects if s.get("id") is not None } await client.async_logout() return await self.async_step_synology_select() @@ -745,7 +808,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_synology_select( self, user_input: dict[str, Any] | None = None ) -> FlowResult: - """Pick a Synology album (or the whole space) and image size.""" + """Build a composite Synology selection and finish the entry. + + Like the Immich/PhotoPrism providers: tick any mix of favorites, + albums, people, places, tags and subjects. Synology has no OR across + categories, so each member is queried on its own and merged. An empty + selection means the whole space. + """ errors: dict[str, str] = {} if user_input is not None: @@ -753,13 +822,42 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): size = user_input.get( CONF_SYNOLOGY_IMAGE_SIZE, DEFAULT_SYNOLOGY_IMAGE_SIZE ) - album_id = user_input.get("album") - if album_id in (None, "", "__all__") or album_id not in self._syn_albums: - album_id = "" + favorites = bool(user_input.get("favorites")) + album_ids: list[Any] = [] + passphrases: list[str] = [] + for key in user_input.get("albums", []) or []: + meta = self._syn_album_meta.get(key) + if not meta: + continue + if meta.get("passphrase"): + passphrases.append(meta["passphrase"]) + elif meta.get("album_id") is not None: + album_ids.append(meta["album_id"]) + + def _ids(field: str, valid: dict[str, str]) -> list[int]: + out: list[int] = [] + for v in user_input.get(field, []) or []: + if v in valid: + try: + out.append(int(v)) + except (TypeError, ValueError): + pass + return out + + selection = { + "favorites": favorites, + "album_ids": album_ids, + "passphrases": passphrases, + "person_ids": _ids("people", self._syn_people), + "geocoding_ids": _ids("places", self._syn_places), + "tag_ids": _ids("tags", self._syn_tags), + "concept_ids": _ids("subjects", self._syn_subjects), + } + sel_id = json.dumps(selection, sort_keys=True) unique = ( f"{DOMAIN}:{PROVIDER_SYNOLOGY}:{self._syn_url}:" - f"{self._syn_space}:{album_id or 'all'}" + f"{self._syn_space}:{sel_id}" ) await self.async_set_unique_id(unique) self._abort_if_unique_id_configured() @@ -769,44 +867,128 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): CONF_SYNOLOGY_USERNAME: self._syn_username, CONF_SYNOLOGY_PASSWORD: self._syn_password, CONF_SYNOLOGY_SPACE: self._syn_space, + CONF_SYNOLOGY_SELECTION: sel_id, CONF_SYNOLOGY_IMAGE_SIZE: size, CONF_ALBUM_NAME: name, } - if album_id: - data[CONF_SYNOLOGY_ALBUM_ID] = album_id if self._syn_device_id: data[CONF_SYNOLOGY_DEVICE_ID] = self._syn_device_id return self.async_create_entry(title=name, data=data) - album_options = [ - selector.SelectOptionDict(value="__all__", label="All photos in this space") - ] + [ - selector.SelectOptionDict(value=aid, label=aname) - for aid, aname in self._syn_albums.items() - ] + def _multi(options: dict[str, str]): + return selector.SelectSelector( + selector.SelectSelectorConfig( + options=[ + selector.SelectOptionDict(value=v, label=l) + for v, l in options.items() + ], + multiple=True, + mode=selector.SelectSelectorMode.DROPDOWN, + custom_value=False, + ) + ) + + fields: dict[Any, Any] = {vol.Required(CONF_ALBUM_NAME): str} + # Favorites, albums and subjects are Personal-space concepts. + if self._syn_space == SYNOLOGY_SPACE_PERSONAL: + fields[vol.Optional("favorites", default=False)] = ( + selector.BooleanSelector() + ) + if self._syn_albums: + fields[vol.Optional("albums")] = _multi(self._syn_albums) + if self._syn_people: + fields[vol.Optional("people")] = _multi(self._syn_people) + if self._syn_places: + fields[vol.Optional("places")] = _multi(self._syn_places) + if self._syn_tags: + fields[vol.Optional("tags")] = _multi(self._syn_tags) + if self._syn_subjects: + fields[vol.Optional("subjects")] = _multi(self._syn_subjects) + fields[ + vol.Optional( + CONF_SYNOLOGY_IMAGE_SIZE, default=DEFAULT_SYNOLOGY_IMAGE_SIZE + ) + ] = vol.In( + { + SYNOLOGY_IMAGE_LARGE: "Large (best for slideshow)", + SYNOLOGY_IMAGE_MEDIUM: "Medium", + SYNOLOGY_IMAGE_SMALL: "Small (thumbnail, fastest)", + } + ) + return self.async_show_form( + step_id="synology_select", data_schema=vol.Schema(fields), errors=errors + ) + + async def async_step_nextcloud( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Collect and validate a Nextcloud WebDAV folder + app password.""" + errors: dict[str, str] = {} + + if user_input is not None: + name = user_input[CONF_ALBUM_NAME].strip() + url = user_input[CONF_NEXTCLOUD_URL].strip() + username = user_input[CONF_NEXTCLOUD_USERNAME].strip() + password = user_input.get(CONF_NEXTCLOUD_PASSWORD) or "" + folder = (user_input.get(CONF_NEXTCLOUD_FOLDER) or "").strip() + recursive = bool(user_input.get(CONF_NEXTCLOUD_RECURSIVE, False)) + size = user_input.get( + CONF_NEXTCLOUD_IMAGE_SIZE, DEFAULT_NEXTCLOUD_IMAGE_SIZE + ) + + from . import nextcloud as nc_api + + client = nc_api.NextcloudClient( + self.hass, url, username, password, folder + ) + try: + await client.async_validate() + except Exception: # noqa: BLE001 - any failure means bad URL/creds/folder + errors["base"] = "nextcloud_cannot_connect" + else: + await self.async_set_unique_id( + f"{DOMAIN}:{PROVIDER_NEXTCLOUD}:{client.base_url}:" + f"{username}:{client.folder}" + ) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=name, + data={ + CONF_PROVIDER: PROVIDER_NEXTCLOUD, + CONF_NEXTCLOUD_URL: client.base_url, + CONF_NEXTCLOUD_USERNAME: username, + CONF_NEXTCLOUD_PASSWORD: password, + CONF_NEXTCLOUD_FOLDER: client.folder, + CONF_NEXTCLOUD_RECURSIVE: recursive, + CONF_NEXTCLOUD_IMAGE_SIZE: size, + CONF_ALBUM_NAME: name, + }, + ) + schema = vol.Schema( { vol.Required(CONF_ALBUM_NAME): str, - vol.Optional("album", default="__all__"): selector.SelectSelector( - selector.SelectSelectorConfig( - options=album_options, - mode=selector.SelectSelectorMode.DROPDOWN, - custom_value=False, - ) + vol.Required(CONF_NEXTCLOUD_URL): str, + vol.Required(CONF_NEXTCLOUD_USERNAME): str, + vol.Required(CONF_NEXTCLOUD_PASSWORD): selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD) + ), + vol.Optional(CONF_NEXTCLOUD_FOLDER, default=""): str, + vol.Optional(CONF_NEXTCLOUD_RECURSIVE, default=False): ( + selector.BooleanSelector() ), vol.Optional( - CONF_SYNOLOGY_IMAGE_SIZE, default=DEFAULT_SYNOLOGY_IMAGE_SIZE + CONF_NEXTCLOUD_IMAGE_SIZE, default=DEFAULT_NEXTCLOUD_IMAGE_SIZE ): vol.In( { - SYNOLOGY_IMAGE_LARGE: "Large (best for slideshow)", - SYNOLOGY_IMAGE_MEDIUM: "Medium", - SYNOLOGY_IMAGE_SMALL: "Small (thumbnail, fastest)", + NEXTCLOUD_IMAGE_PREVIEW: "Preview (smoothest slideshow)", + NEXTCLOUD_IMAGE_ORIGINAL: "Original (full quality, slower)", } ), } ) return self.async_show_form( - step_id="synology_select", data_schema=schema, errors=errors + step_id="nextcloud", data_schema=schema, errors=errors ) diff --git a/custom_components/album_slideshow/const.py b/custom_components/album_slideshow/const.py index 84cadc0..b2f9c0f 100644 --- a/custom_components/album_slideshow/const.py +++ b/custom_components/album_slideshow/const.py @@ -58,6 +58,28 @@ PROVIDER_IMMICH = "immich" PROVIDER_PHOTOPRISM = "photoprism" PROVIDER_ICLOUD = "icloud" PROVIDER_SYNOLOGY = "synology" +PROVIDER_NEXTCLOUD = "nextcloud" + +# Nextcloud (authenticated WebDAV folder) provider. Points at any folder in a +# user's files and lists it over WebDAV. Auth is HTTP Basic with a username + +# app password (Settings > Security > Devices & sessions); the app password is +# stored so the coordinator can re-list on each refresh and is sent server-side +# only, never reaching the browser. +CONF_NEXTCLOUD_URL = "nextcloud_url" +CONF_NEXTCLOUD_USERNAME = "nextcloud_username" +CONF_NEXTCLOUD_PASSWORD = "nextcloud_password" +CONF_NEXTCLOUD_FOLDER = "nextcloud_folder" +CONF_NEXTCLOUD_RECURSIVE = "nextcloud_recursive" +CONF_NEXTCLOUD_IMAGE_SIZE = "nextcloud_image_size" + +# ``preview`` uses the core/preview thumbnail endpoint (smoother, smaller); +# ``original`` fetches the real file straight off the WebDAV collection. +NEXTCLOUD_IMAGE_PREVIEW = "preview" +NEXTCLOUD_IMAGE_ORIGINAL = "original" +NEXTCLOUD_IMAGE_SIZE_OPTIONS = [NEXTCLOUD_IMAGE_PREVIEW, NEXTCLOUD_IMAGE_ORIGINAL] +DEFAULT_NEXTCLOUD_IMAGE_SIZE = NEXTCLOUD_IMAGE_PREVIEW +# Long edge (px) requested from the core/preview endpoint for preview quality. +NEXTCLOUD_PREVIEW_PX = 1920 # Synology Photos (direct API) provider. Talks to a DSM Photos package over its # entry.cgi web API. The account password is stored so the coordinator can @@ -70,6 +92,21 @@ CONF_SYNOLOGY_DEVICE_ID = "synology_device_id" CONF_SYNOLOGY_SPACE = "synology_space" CONF_SYNOLOGY_ALBUM_ID = "synology_album_id" CONF_SYNOLOGY_IMAGE_SIZE = "synology_image_size" +# Passphrase for an album that was shared with the configured account. Present +# only when the chosen source is a shared-with-me album; such albums are +# reachable by passphrase rather than by album id. +CONF_SYNOLOGY_PASSPHRASE = "synology_passphrase" +# When True, the source is the account's Favorites (favorited photos) rather +# than the whole space or a specific album. +CONF_SYNOLOGY_FAVORITE = "synology_favorite" +# Composite selection: a client-side union of any mix of albums, people, +# places, tags, subjects and favorites. Synology has no OR across categories, +# so each selected member is queried on its own and the results are merged +# (see the Immich/PhotoPrism composite). Stored as a JSON object: +# ``{"favorites": bool, "album_ids": [...], "passphrases": [...], +# "person_ids": [...], "geocoding_ids": [...], "tag_ids": [...], +# "concept_ids": [...]}``; an empty composite means the whole space. +CONF_SYNOLOGY_SELECTION = "synology_selection" # Personal ("My Photos") vs shared ("Shared Space") library. SYNOLOGY_SPACE_PERSONAL = "personal" diff --git a/custom_components/album_slideshow/coordinator.py b/custom_components/album_slideshow/coordinator.py index b5e5a7b..8fb05b2 100644 --- a/custom_components/album_slideshow/coordinator.py +++ b/custom_components/album_slideshow/coordinator.py @@ -51,8 +51,20 @@ from .const import ( CONF_SYNOLOGY_SPACE, CONF_SYNOLOGY_ALBUM_ID, CONF_SYNOLOGY_IMAGE_SIZE, + CONF_SYNOLOGY_PASSPHRASE, + CONF_SYNOLOGY_FAVORITE, + CONF_SYNOLOGY_SELECTION, DEFAULT_SYNOLOGY_IMAGE_SIZE, SYNOLOGY_SPACE_PERSONAL, + CONF_NEXTCLOUD_URL, + CONF_NEXTCLOUD_USERNAME, + CONF_NEXTCLOUD_PASSWORD, + CONF_NEXTCLOUD_FOLDER, + CONF_NEXTCLOUD_RECURSIVE, + CONF_NEXTCLOUD_IMAGE_SIZE, + DEFAULT_NEXTCLOUD_IMAGE_SIZE, + NEXTCLOUD_IMAGE_ORIGINAL, + NEXTCLOUD_PREVIEW_PX, DEFAULT_REVERSE_GEOCODE, DOMAIN, PROVIDER_GOOGLE_SHARED, @@ -62,6 +74,7 @@ from .const import ( PROVIDER_PHOTOPRISM, PROVIDER_ICLOUD, PROVIDER_SYNOLOGY, + PROVIDER_NEXTCLOUD, ) from .store import SlideshowStore @@ -420,6 +433,11 @@ _NOMINATIM_TIMEOUT_S = 20 _EXIF_BATCH_SAVE = 25 _GEOCODE_BATCH_SAVE = 10 +# Cap a single Nextcloud enrichment download. EXIF/IPTC/XMP live in the first +# blocks of the file, but we read the whole thing since Pillow needs a complete +# image; this bounds memory for pathological files. 64 MB matches the camera. +_NEXTCLOUD_ENRICH_MAX_BYTES = 64 * 1024 * 1024 + # Inserted between background-enrichment iterations so the event loop # stays responsive on the fast path (items that are already scanned and # need zero work). @@ -674,53 +692,90 @@ def _read_local_exif(path: Path) -> dict[str, Any]: try: with Image.open(path) as img: - exif = img.getexif() - - if exif: - dt_raw = exif.get(_EXIF_TAG_DATETIME_ORIGINAL) or exif.get( - _EXIF_TAG_DATETIME - ) - offset_raw = exif.get(_EXIF_TAG_OFFSET_TIME_ORIGINAL) - parsed = _parse_exif_datetime(dt_raw, offset_raw) - if parsed is not None: - out["captured_at"] = parsed - - # Description can come from IPTC / XMP even when the file has no - # EXIF IFD, so this runs regardless of ``exif`` being present. - description = _read_photo_description(img, exif) - if description: - out["description"] = description - - if not exif: - return out - - gps = None - try: - gps = exif.get_ifd(_EXIF_TAG_GPS_IFD) or None - except Exception: - gps = None - if gps: - lat = _gps_to_decimal( - gps.get(_EXIF_GPS_LAT), gps.get(_EXIF_GPS_LAT_REF) - ) - lon = _gps_to_decimal( - gps.get(_EXIF_GPS_LON), gps.get(_EXIF_GPS_LON_REF) - ) - if lat is not None and lon is not None: - # Null Island guard: GPS chips and some editors stamp - # ``(0, 0)`` when the fix is invalid. Treat that as no - # location rather than dropping every such photo onto - # the equator off the African coast. - if abs(lat) < 1e-6 and abs(lon) < 1e-6: - return out - out["latitude"] = lat - out["longitude"] = lon + _read_exif_from_image(img, out) except Exception as err: _LOGGER.debug("EXIF: failed to read %s: %s", path, err) return out +def _read_exif_from_image(img: Any, out: dict[str, Any]) -> None: + """Fill ``out`` with capture date / description / GPS from an open image. + + Shared by ``_read_local_exif`` (opens from a filesystem path) and + ``_read_exif_from_bytes`` (opens from downloaded bytes, e.g. the + Nextcloud provider) - both hand this an already-``Image.open``'d image + plus a dict pre-seeded with a fallback ``captured_at``. + """ + exif = img.getexif() + + if exif: + dt_raw = exif.get(_EXIF_TAG_DATETIME_ORIGINAL) or exif.get( + _EXIF_TAG_DATETIME + ) + offset_raw = exif.get(_EXIF_TAG_OFFSET_TIME_ORIGINAL) + parsed = _parse_exif_datetime(dt_raw, offset_raw) + if parsed is not None: + out["captured_at"] = parsed + + # Description can come from IPTC / XMP even when the file has no EXIF + # IFD, so this runs regardless of ``exif`` being present. + description = _read_photo_description(img, exif) + if description: + out["description"] = description + + if not exif: + return + + gps = None + try: + gps = exif.get_ifd(_EXIF_TAG_GPS_IFD) or None + except Exception: + gps = None + if gps: + lat = _gps_to_decimal(gps.get(_EXIF_GPS_LAT), gps.get(_EXIF_GPS_LAT_REF)) + lon = _gps_to_decimal(gps.get(_EXIF_GPS_LON), gps.get(_EXIF_GPS_LON_REF)) + if lat is not None and lon is not None: + # Null Island guard: GPS chips and some editors stamp ``(0, 0)`` + # when the fix is invalid. Treat that as no location rather than + # dropping every such photo onto the equator off the African coast. + if abs(lat) < 1e-6 and abs(lon) < 1e-6: + return + out["latitude"] = lat + out["longitude"] = lon + + +def _read_exif_from_bytes( + data: bytes, mtime_fallback_ms: int | None +) -> dict[str, Any]: + """Read EXIF metadata from already-downloaded image bytes. + + Same return shape as ``_read_local_exif``, for providers (Nextcloud) + whose files live on a remote server rather than the local filesystem - + the caller downloads the file once for enrichment, regardless of which + quality is used for display. ``mtime_fallback_ms`` takes the place of + the filesystem mtime fallback (e.g. the WebDAV ``Last-Modified`` date). + """ + out: dict[str, Any] = {} + if isinstance(mtime_fallback_ms, int): + out["captured_at"] = mtime_fallback_ms + + try: + from PIL import Image + except Exception: # pragma: no cover - Pillow ships with HA core + return out + + try: + import io + + with Image.open(io.BytesIO(data)) as img: + _read_exif_from_image(img, out) + except Exception as err: + _LOGGER.debug("EXIF: failed to read image bytes: %s", err) + + return out + + def _format_nominatim_location(payload: dict[str, Any]) -> str | None: """Turn a Nominatim reverse-geocode response into a short label. @@ -951,6 +1006,8 @@ class AlbumCoordinator(DataUpdateCoordinator): data = await self._update_icloud() elif self.provider == PROVIDER_SYNOLOGY: data = await self._update_synology() + elif self.provider == PROVIDER_NEXTCLOUD: + data = await self._update_nextcloud() else: raise UpdateFailed(f"Unsupported provider: {self.provider}") except UpdateFailed: @@ -964,7 +1021,7 @@ class AlbumCoordinator(DataUpdateCoordinator): raise items = data.get("items") or [] - if self.provider in (PROVIDER_LOCAL_FOLDER, PROVIDER_IMMICH) and items: + if self.provider in (PROVIDER_LOCAL_FOLDER, PROVIDER_IMMICH, PROVIDER_NEXTCLOUD) and items: # Carry forward EXIF/geocode metadata for items we've already # scanned this session; new items get filled in by the # background worker below. @@ -1541,6 +1598,9 @@ class AlbumCoordinator(DataUpdateCoordinator): device_id = self.entry.data.get(CONF_SYNOLOGY_DEVICE_ID) space = self.entry.data.get(CONF_SYNOLOGY_SPACE, SYNOLOGY_SPACE_PERSONAL) album_id = self.entry.data.get(CONF_SYNOLOGY_ALBUM_ID) + passphrase = self.entry.data.get(CONF_SYNOLOGY_PASSPHRASE) + favorite_only = bool(self.entry.data.get(CONF_SYNOLOGY_FAVORITE)) + selection_raw = self.entry.data.get(CONF_SYNOLOGY_SELECTION) size = self.entry.data.get( CONF_SYNOLOGY_IMAGE_SIZE, DEFAULT_SYNOLOGY_IMAGE_SIZE ) @@ -1557,7 +1617,23 @@ class AlbumCoordinator(DataUpdateCoordinator): ) try: await client.async_login() - photos = await client.async_collect_assets(album_id or None) + if selection_raw: + # Composite selection (albums + people + places + tags + + # subjects + favorites), merged client-side. + try: + selection = json.loads(selection_raw) + except (TypeError, ValueError): + selection = {} + photos = await client.async_collect_composite(selection) + else: + # Legacy single-source entries (favorites / one album / all). + photos = await client.async_collect_assets( + album_id or None, + passphrase=passphrase or None, + favorite_only=favorite_only, + ) + except syn_api.SynologyPermissionError as err: + raise UpdateFailed(str(err)) from err except Exception as err: raise UpdateFailed(f"Error querying Synology Photos: {err}") from err @@ -1577,10 +1653,19 @@ class AlbumCoordinator(DataUpdateCoordinator): continue unit_id, cache_key = ref meta = syn_api.parse_photo_meta(p) + # Items pulled from a shared-with-me album carry their own + # passphrase (composite path); fall back to the single-album + # passphrase for legacy entries. + item_pp = p.get("_passphrase") or passphrase or None items.append( MediaItem( url=syn_api.build_thumbnail_url( - client.base_url, unit_id, cache_key, size, space + client.base_url, + unit_id, + cache_key, + size, + space, + passphrase=item_pp, ), width=meta.get("width"), height=meta.get("height"), @@ -1605,6 +1690,143 @@ class AlbumCoordinator(DataUpdateCoordinator): "items": items, } + async def _update_nextcloud(self) -> dict[str, Any]: + """List photos from an authenticated Nextcloud WebDAV folder. + + The PROPFIND listing carries filename/size/content-type/mtime but no + EXIF, so capture date, GPS and description are filled in afterwards by + the background enrichment worker (one original-file download per photo - + Nextcloud has no metadata-only endpoint the way Immich does). The app + password is sent server-side only via the coordinator's image headers. + """ + from . import nextcloud as nc_api + + url = self.entry.data.get(CONF_NEXTCLOUD_URL) + username = self.entry.data.get(CONF_NEXTCLOUD_USERNAME) + password = self.entry.data.get(CONF_NEXTCLOUD_PASSWORD) + folder = self.entry.data.get(CONF_NEXTCLOUD_FOLDER) or "" + recursive = bool(self.entry.data.get(CONF_NEXTCLOUD_RECURSIVE, False)) + size = self.entry.data.get( + CONF_NEXTCLOUD_IMAGE_SIZE, DEFAULT_NEXTCLOUD_IMAGE_SIZE + ) + if not url or not username or not password: + raise UpdateFailed("Nextcloud provider is missing URL or credentials") + + client = nc_api.NextcloudClient(self.hass, url, username, password, folder) + try: + photos = await client.async_list_photos(recursive=recursive) + except Exception as err: + raise UpdateFailed(f"Error listing Nextcloud folder: {err}") from err + + if not photos: + raise UpdateFailed("No images found in the Nextcloud folder") + + # The camera fetches image bytes server-side with this Basic-auth + # header, so the app password never appears in the browser URL. + self.image_request_headers = dict(client.image_headers) + + items: list[MediaItem] = [] + for p in photos: + href = p.get("href") + if not href: + continue + if size != NEXTCLOUD_IMAGE_ORIGINAL and p.get("file_id"): + display_url = nc_api.build_preview_url( + client.base_url, p["file_id"], NEXTCLOUD_PREVIEW_PX + ) + else: + display_url = href + items.append( + MediaItem( + url=display_url, + width=None, + height=None, + mime_type=p.get("content_type"), + filename=p.get("filename"), + uploaded_at=p.get("mtime_ms"), + byte_size=p.get("size"), + source_id=p.get("file_id") or href, + ) + ) + + return { + "title": self.entry.title, + "items": items, + } + + async def _enrich_nextcloud_item(self, item: MediaItem) -> None: + """Download one Nextcloud photo's original bytes and read its EXIF. + + Nextcloud's WebDAV folder has no metadata-only endpoint (unlike + Immich's per-asset detail call), so enrichment costs one full-file + download per photo regardless of the display quality configured. + """ + from . import nextcloud as nc_api + from urllib.parse import quote + + url = self.entry.data.get(CONF_NEXTCLOUD_URL) + username = self.entry.data.get(CONF_NEXTCLOUD_USERNAME) + password = self.entry.data.get(CONF_NEXTCLOUD_PASSWORD) + folder = self.entry.data.get(CONF_NEXTCLOUD_FOLDER) or "" + if not username or not password or not url: + item.exif_scanned = True + return + + # Reconstruct the original-file URL: for preview items the display url + # is the preview endpoint, so fall back to the folder href by filename. + original_url = None + if isinstance(item.url, str) and "/remote.php/dav/files/" in item.url: + original_url = item.url + elif item.filename: + client = nc_api.NextcloudClient(self.hass, url, username, password, folder) + original_url = client.dav_root + quote(item.filename) + if not original_url: + item.exif_scanned = True + return + + headers = { + "Authorization": nc_api.basic_auth_header(username, password) + } + session = async_get_clientsession(self.hass) + try: + async with async_timeout.timeout(30): + async with session.get(original_url, headers=headers) as resp: + resp.raise_for_status() + chunks: list[bytes] = [] + total = 0 + async for chunk in resp.content.iter_chunked(64 * 1024): + total += len(chunk) + if total > _NEXTCLOUD_ENRICH_MAX_BYTES: + _LOGGER.debug( + "Nextcloud: %s exceeded %d byte enrichment cap; skipping", + item.filename, _NEXTCLOUD_ENRICH_MAX_BYTES, + ) + item.exif_scanned = True + return + chunks.append(chunk) + data = b"".join(chunks) + except asyncio.CancelledError: + raise + except Exception as err: # noqa: BLE001 + _LOGGER.debug( + "Nextcloud: failed to download %s for enrichment: %s", + item.filename, err, + ) + item.exif_scanned = True + return + + info = await self.hass.async_add_executor_job( + _read_exif_from_bytes, data, item.uploaded_at + ) + if "captured_at" in info: + item.captured_at = info["captured_at"] + if "description" in info: + item.description = info["description"] + if "latitude" in info and "longitude" in info: + item.latitude = info["latitude"] + item.longitude = info["longitude"] + item.exif_scanned = True + async def _enrich_immich_item(self, item: MediaItem) -> None: """Fetch one Immich asset's detail and fill location/description.""" from . import immich as immich_api @@ -1677,6 +1899,24 @@ class AlbumCoordinator(DataUpdateCoordinator): self.async_set_updated_data(data) continue + if self.provider == PROVIDER_NEXTCLOUD: + try: + await self._enrich_nextcloud_item(item) + except asyncio.CancelledError: + raise + except Exception as err: # noqa: BLE001 + _LOGGER.debug("Nextcloud enrich error: %s", err) + item.exif_scanned = True + scanned_since_save += 1 + self._enrich_progress["exif_done"] = ( + self._enrich_progress.get("exif_done", 0) + 1 + ) + if scanned_since_save >= _EXIF_BATCH_SAVE: + scanned_since_save = 0 + await self._save_cached_items(data) + self.async_set_updated_data(data) + continue + url = item.url if not url.startswith("file://"): item.exif_scanned = True diff --git a/custom_components/album_slideshow/manifest.json b/custom_components/album_slideshow/manifest.json index e3d5ebf..5a5b013 100644 --- a/custom_components/album_slideshow/manifest.json +++ b/custom_components/album_slideshow/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/eyalgal/album_slideshow/issues", "requirements": ["Pillow"], - "version": "1.5.0" + "version": "1.6.0" } diff --git a/custom_components/album_slideshow/strings.json b/custom_components/album_slideshow/strings.json index 3f42f14..cf34134 100644 --- a/custom_components/album_slideshow/strings.json +++ b/custom_components/album_slideshow/strings.json @@ -87,7 +87,7 @@ }, "synology": { "title": "Synology Photos", - "description": "Connect to the Photos package on your Synology NAS. Enter the DSM address (e.g. http://192.168.1.10:5000 or your HTTPS/QuickConnect URL) and an account. If the account has two-factor authentication, also enter a current 6-digit code once; a trusted-device token is then stored so future refreshes do not need a code. Tip: use a dedicated read-only Photos account rather than an admin login.", + "description": "Connect to the Photos package on your Synology NAS. Enter the DSM address (e.g. http://192.168.1.10:5000 or your HTTPS/QuickConnect URL) and an account. If the account has two-factor authentication, also enter a current 6-digit code once; a trusted-device token is then stored so future refreshes do not need a code. Choose Personal for your own photos and albums (this is also where albums shared with you appear); choose Shared Space only if your NAS has the shared team library enabled. Tip: use a dedicated Photos account rather than an admin login.", "data": { "synology_url": "DSM URL", "synology_username": "Username", @@ -98,12 +98,30 @@ }, "synology_select": { "title": "Synology source", - "description": "Pick an album or show the whole library, and choose the image quality.", + "description": "Choose what to show. Tick any mix of favorites, albums, people, places, tags and subjects; they are combined into one slideshow. Leave everything empty to show all photos in this space.", "data": { "album_name": "Album name", - "album": "Album", + "favorites": "Favorites", + "albums": "Albums", + "people": "People", + "places": "Places", + "tags": "Tags", + "subjects": "Subjects", "synology_image_size": "Image quality" } + }, + "nextcloud": { + "title": "Nextcloud folder", + "description": "Connect to a folder in your Nextcloud files over WebDAV. Enter the server address (e.g. http://192.168.1.10, or your Nextcloud domain over HTTPS), your username, and an app password (create one under Settings > Security > Devices and sessions - not your main login password). Point it at a folder path (e.g. Photos/Family), or leave the folder blank for your whole files root. Turn on recursive to include subfolders.", + "data": { + "album_name": "Album name", + "nextcloud_url": "Server URL", + "nextcloud_username": "Username", + "nextcloud_password": "App password", + "nextcloud_folder": "Folder path (optional)", + "nextcloud_recursive": "Include subfolders", + "nextcloud_image_size": "Image quality" + } } }, "error": { @@ -122,13 +140,15 @@ "invalid_icloud_url": "That does not look like an iCloud Shared Album link.", "icloud_cannot_connect": "Could not reach that album. Check the link is a current public share.", "synology_cannot_connect": "Could not connect to Synology. Check the URL, username and password.", - "synology_otp_required": "This account needs a two-factor code. Enter a current 6-digit code to continue." + "synology_otp_required": "This account needs a two-factor code. Enter a current 6-digit code to continue.", + "synology_shared_unavailable": "Could not access the Shared Space. Enable Shared Space in Synology Photos and make sure this account has access to it, or choose Personal instead.", + "nextcloud_cannot_connect": "Could not connect to Nextcloud. Check the URL, username, app password and folder path." } }, "options": { "step": { "init": { - "title": "Local Folder options", + "title": "Location & privacy options", "description": "Reverse geocoding sends your photos' EXIF GPS coordinates to the public OpenStreetMap Nominatim service to look up a human-readable place name. Coordinates are rounded to ~100 m before lookup and cached on disk. Turn this off to keep coordinates entirely local; the latitude and longitude attributes still work either way.", "data": { "reverse_geocode": "Reverse-geocode EXIF GPS coordinates via OpenStreetMap" diff --git a/custom_components/album_slideshow/translations/en.json b/custom_components/album_slideshow/translations/en.json index 3f42f14..cf34134 100644 --- a/custom_components/album_slideshow/translations/en.json +++ b/custom_components/album_slideshow/translations/en.json @@ -87,7 +87,7 @@ }, "synology": { "title": "Synology Photos", - "description": "Connect to the Photos package on your Synology NAS. Enter the DSM address (e.g. http://192.168.1.10:5000 or your HTTPS/QuickConnect URL) and an account. If the account has two-factor authentication, also enter a current 6-digit code once; a trusted-device token is then stored so future refreshes do not need a code. Tip: use a dedicated read-only Photos account rather than an admin login.", + "description": "Connect to the Photos package on your Synology NAS. Enter the DSM address (e.g. http://192.168.1.10:5000 or your HTTPS/QuickConnect URL) and an account. If the account has two-factor authentication, also enter a current 6-digit code once; a trusted-device token is then stored so future refreshes do not need a code. Choose Personal for your own photos and albums (this is also where albums shared with you appear); choose Shared Space only if your NAS has the shared team library enabled. Tip: use a dedicated Photos account rather than an admin login.", "data": { "synology_url": "DSM URL", "synology_username": "Username", @@ -98,12 +98,30 @@ }, "synology_select": { "title": "Synology source", - "description": "Pick an album or show the whole library, and choose the image quality.", + "description": "Choose what to show. Tick any mix of favorites, albums, people, places, tags and subjects; they are combined into one slideshow. Leave everything empty to show all photos in this space.", "data": { "album_name": "Album name", - "album": "Album", + "favorites": "Favorites", + "albums": "Albums", + "people": "People", + "places": "Places", + "tags": "Tags", + "subjects": "Subjects", "synology_image_size": "Image quality" } + }, + "nextcloud": { + "title": "Nextcloud folder", + "description": "Connect to a folder in your Nextcloud files over WebDAV. Enter the server address (e.g. http://192.168.1.10, or your Nextcloud domain over HTTPS), your username, and an app password (create one under Settings > Security > Devices and sessions - not your main login password). Point it at a folder path (e.g. Photos/Family), or leave the folder blank for your whole files root. Turn on recursive to include subfolders.", + "data": { + "album_name": "Album name", + "nextcloud_url": "Server URL", + "nextcloud_username": "Username", + "nextcloud_password": "App password", + "nextcloud_folder": "Folder path (optional)", + "nextcloud_recursive": "Include subfolders", + "nextcloud_image_size": "Image quality" + } } }, "error": { @@ -122,13 +140,15 @@ "invalid_icloud_url": "That does not look like an iCloud Shared Album link.", "icloud_cannot_connect": "Could not reach that album. Check the link is a current public share.", "synology_cannot_connect": "Could not connect to Synology. Check the URL, username and password.", - "synology_otp_required": "This account needs a two-factor code. Enter a current 6-digit code to continue." + "synology_otp_required": "This account needs a two-factor code. Enter a current 6-digit code to continue.", + "synology_shared_unavailable": "Could not access the Shared Space. Enable Shared Space in Synology Photos and make sure this account has access to it, or choose Personal instead.", + "nextcloud_cannot_connect": "Could not connect to Nextcloud. Check the URL, username, app password and folder path." } }, "options": { "step": { "init": { - "title": "Local Folder options", + "title": "Location & privacy options", "description": "Reverse geocoding sends your photos' EXIF GPS coordinates to the public OpenStreetMap Nominatim service to look up a human-readable place name. Coordinates are rounded to ~100 m before lookup and cached on disk. Turn this off to keep coordinates entirely local; the latitude and longitude attributes still work either way.", "data": { "reverse_geocode": "Reverse-geocode EXIF GPS coordinates via OpenStreetMap" diff --git a/custom_components/album_slideshow/www/album-slideshow-card.js b/custom_components/album_slideshow/www/album-slideshow-card.js index b2f5000..fd8dc47 100644 --- a/custom_components/album_slideshow/www/album-slideshow-card.js +++ b/custom_components/album_slideshow/www/album-slideshow-card.js @@ -26,7 +26,7 @@ * tap_action: none # none | more-info */ -const VERSION = "1.5.0"; +const VERSION = "1.6.0"; const ANIMATED_TRANSITIONS = [ "fade", diff --git a/custom_components/midea_ac_lan/__init__.py b/custom_components/midea_ac_lan/__init__.py index d3b3b54..9ed3e3c 100644 --- a/custom_components/midea_ac_lan/__init__.py +++ b/custom_components/midea_ac_lan/__init__.py @@ -32,13 +32,16 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType from midealocal.device import DeviceType, MideaDevice, ProtocolVersion from midealocal.devices import device_selector +from midealocal.discover import discover from .const import ( ALL_PLATFORM, CONF_ACCOUNT, CONF_KEY, + CONF_MAC, CONF_MODEL, CONF_REFRESH_INTERVAL, + CONF_SN, CONF_SUBTYPE, DEVICES, DOMAIN, @@ -76,7 +79,7 @@ async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> Non dev.set_refresh_interval(refresh_interval) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: ARG001 +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # ruff:ignore[unused-function-argument] """Set up midea_lan component when load this integration. Returns @@ -91,13 +94,18 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: "dict", device_entities["entities"], ).items(): + attribute_key = ( + attribute_name + if isinstance(attribute_name, str) + else attribute_name.value + ) if ( attribute.get("type") in EXTRA_SWITCH - and attribute_name.value not in attributes + and attribute_key not in attributes ): - attributes.append(attribute_name.value) + attributes.append(attribute_key) - def service_set_attribute(service: Any) -> None: # noqa: ANN401 + def service_set_attribute(service: Any) -> None: # ruff:ignore[any-type] """Set service attribute func.""" device_id: int = service.data["device_id"] attr = service.data["attribute"] @@ -126,7 +134,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: attr, ) - def service_send_command(service: Any) -> None: # noqa: ANN401 + def service_send_command(service: Any) -> None: # ruff:ignore[any-type] """Send command to service func.""" device_id = service.data.get("device_id") cmd_type = service.data.get("cmd_type") @@ -204,6 +212,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b subtype = config_entry.data.get(CONF_SUBTYPE, 0) protocol: ProtocolVersion = ProtocolVersion(config_entry.data[CONF_PROTOCOL]) customize: str = config_entry.options.get(CONF_CUSTOMIZE, "") + mac: str | None = config_entry.data.get(CONF_MAC) + serial_number: str | None = config_entry.data.get(CONF_SN) if protocol == ProtocolVersion.V3 and (key == "" or token == ""): _LOGGER.error("For V3 devices, the key and the token is required") return False @@ -223,6 +233,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b model, subtype, customize, + mac, + serial_number, ) # hass core version < 2024.3 else: @@ -238,6 +250,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b model=model, subtype=subtype, customize=customize, + mac=mac, + serial_number=serial_number, ) if device: if refresh_interval is not None: @@ -306,6 +320,80 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> _LOGGER.debug("Migration to configuration version 2 successful") + # 2.1 -> 2.2: backfill mac address and serial number for existing devices + entry_version_2 = 2 + if config_entry.version == entry_version_2 and config_entry.minor_version == 1: + _LOGGER.debug("Migrating configuration from version 2.1") + + if await _async_backfill_mac_and_sn(hass, config_entry): + if (MAJOR_VERSION, MINOR_VERSION) >= (2024, 3): + hass.config_entries.async_update_entry(config_entry, minor_version=2) + else: + config_entry.minor_version = 2 + hass.config_entries.async_update_entry(config_entry) + + _LOGGER.debug("Migration to configuration version 2.2 successful") + else: + _LOGGER.debug( + "Device %s did not respond to discovery;" + " mac/serial number migration will be retried on next start", + config_entry.data.get(CONF_DEVICE_ID), + ) + + return True + + +async def _async_backfill_mac_and_sn( + hass: HomeAssistant, + config_entry: ConfigEntry, +) -> bool: + """Best-effort discovery to backfill mac address and serial number. + + Returns + ------- + True if the entry already had the data, has no IP/device id to look up, + or was successfully updated. False if the device did not answer + discovery and the migration should be retried on a later start. + + """ + if config_entry.data.get(CONF_TYPE) == CONF_ACCOUNT: + return True + if config_entry.data.get(CONF_MAC) or config_entry.data.get(CONF_SN): + return True + # Honor an IP override set via the options flow (e.g. after a DHCP change), + # mirroring async_setup_entry; the data IP may be stale. + ip_address = config_entry.options.get(CONF_IP_ADDRESS) + if ip_address is None: + ip_address = config_entry.data.get(CONF_IP_ADDRESS) + device_id = config_entry.data.get(CONF_DEVICE_ID) + if ip_address is None or device_id is None: + return True + try: + found_devices = await hass.async_add_executor_job( + lambda: discover(ip_address=ip_address), + ) + except Exception: + # Best-effort migration: any discovery failure (socket error, malformed + # reply from a legacy device, parse error) must not break entry setup. + # Retry on the next start instead. + _LOGGER.debug( + "Discovery for device %s failed; mac/serial number migration" + " will be retried on next start", + device_id, + exc_info=True, + ) + return False + device = found_devices.get(int(device_id)) + if device is None: + return False + hass.config_entries.async_update_entry( + config_entry, + data={ + **config_entry.data, + CONF_MAC: device.get(CONF_MAC), + CONF_SN: device.get(CONF_SN), + }, + ) return True diff --git a/custom_components/midea_ac_lan/binary_sensor.py b/custom_components/midea_ac_lan/binary_sensor.py index f956d4d..3eb8314 100644 --- a/custom_components/midea_ac_lan/binary_sensor.py +++ b/custom_components/midea_ac_lan/binary_sensor.py @@ -30,9 +30,15 @@ async def async_setup_entry( "dict", MIDEA_DEVICES[device.device_type]["entities"], ).items(): - if config["type"] == Platform.BINARY_SENSOR and entity_key in extra_sensors: - sensor = MideaBinarySensor(device, entity_key) - binary_sensors.append(sensor) + if config["type"] != Platform.BINARY_SENSOR or entity_key not in extra_sensors: + continue + required_attribute = config.get("required_attribute") + if ( + required_attribute is not None + and required_attribute not in device.attributes + ): + continue + binary_sensors.append(MideaBinarySensor(device, entity_key)) async_add_entities(binary_sensors) @@ -41,10 +47,10 @@ class MideaBinarySensor(MideaEntity, BinarySensorEntity): @property def device_class(self) -> BinarySensorDeviceClass | None: - """Return device class.""" + """Device class of the binary sensor.""" return cast("BinarySensorDeviceClass", self._config.get("device_class")) @property def is_on(self) -> bool: - """Return true if sensor state is on.""" + """Whether the binary sensor is on.""" return cast("bool", self._device.get_attribute(self._entity_key)) diff --git a/custom_components/midea_ac_lan/climate.py b/custom_components/midea_ac_lan/climate.py index 6024ba9..db11bc3 100644 --- a/custom_components/midea_ac_lan/climate.py +++ b/custom_components/midea_ac_lan/climate.py @@ -1,5 +1,6 @@ """Midea Climate entries.""" +import json import logging from typing import Any, ClassVar, cast @@ -27,6 +28,7 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_TEMPERATURE, + CONF_CUSTOMIZE, CONF_DEVICE_ID, CONF_SWITCHES, MAJOR_VERSION, @@ -179,15 +181,15 @@ class MideaClimate(MideaEntity, ClimateEntity): """Midea Climate extra state attributes.""" return cast("dict", self._device.attributes) - def turn_on(self, **kwargs: Any) -> None: # noqa: ANN401, ARG002 + def turn_on(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument] """Midea Climate turn on.""" self._device.set_attribute(attr="power", value=True) - def turn_off(self, **kwargs: Any) -> None: # noqa: ANN401, ARG002 + def turn_off(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument] """Midea Climate turn off.""" self._device.set_attribute(attr="power", value=False) - def set_temperature(self, **kwargs: Any) -> None: # noqa: ANN401 + def set_temperature(self, **kwargs: Any) -> None: # ruff:ignore[any-type] """Midea Climate set temperature.""" if ATTR_TEMPERATURE not in kwargs: return @@ -241,7 +243,7 @@ class MideaClimate(MideaEntity, ClimateEntity): elif old_mode == PRESET_BOOST: self._device.set_attribute(attr="boost_mode", value=False) - def update_state(self, status: Any) -> None: # noqa: ANN401, ARG002 + def update_state(self, status: Any) -> None: # ruff:ignore[any-type, unused-method-argument] """Midea Climate update state.""" if not self.hass: _LOGGER.warning( @@ -250,7 +252,7 @@ class MideaClimate(MideaEntity, ClimateEntity): type(self), ) return - self.schedule_update_ha_state() + self.schedule_update_if_running() class MideaACClimate(MideaClimate): @@ -266,7 +268,9 @@ class MideaACClimate(MideaClimate): ) -> None: """Midea AC Climate entity init.""" super().__init__(device, entity_key) - self._attr_hvac_modes = [ + # fixed positional map: device "mode" int -> HVACMode. DO NOT reorder, + # the device mode value indexes into this list. + self._mode_index = [ HVACMode.OFF, HVACMode.AUTO, HVACMode.COOL, @@ -274,6 +278,14 @@ class MideaACClimate(MideaClimate): HVACMode.HEAT, HVACMode.FAN_ONLY, ] + self._attr_hvac_modes = list(self._mode_index) + # customize overrides parsed once; B5 capabilities read dynamically + # (they arrive after the first refresh, so hvac_modes/supported_features + # are computed lazily — priority: customize > B5 > defaults) + self._customize_swing: bool | None = None + self._customize_hvac_modes: list[HVACMode] | None = None + self._customize_preset_modes: list[str] | None = None + self._parse_capability_customize(config_entry) self._fan_speeds: dict[str, int] = { FAN_SILENT: 20, FAN_LOW: 40, @@ -304,6 +316,176 @@ class MideaACClimate(MideaClimate): and "indoor_humidity" in config_entry.options["sensors"] ) + def _parse_capability_customize(self, config_entry: ConfigEntry) -> None: + """Parse the swing / hvac_modes customize overrides (highest priority). + + ``{"swing": false, "hvac_modes": ["off", "cool", "dry", "fan_only"]}`` + """ + customize = config_entry.options.get(CONF_CUSTOMIZE, "") + if not customize: + return + try: + params = json.loads(customize) + except (ValueError, TypeError): + return + if not isinstance(params, dict): + return + if "swing" in params: + self._customize_swing = bool(params["swing"]) + modes = params.get("hvac_modes") + if isinstance(modes, list): + valid = {mode.value: mode for mode in self._mode_index} + wanted: list[HVACMode] = [] + for name in modes: + hvac = valid.get(str(name)) + if hvac is not None and hvac not in wanted: + wanted.append(hvac) + if HVACMode.OFF not in wanted: + wanted.insert(0, HVACMode.OFF) + if any(hvac != HVACMode.OFF for hvac in wanted): + self._customize_hvac_modes = wanted + presets = params.get("preset_modes") + if isinstance(presets, list): + valid_presets = { + PRESET_NONE, + PRESET_COMFORT, + PRESET_ECO, + PRESET_BOOST, + PRESET_SLEEP, + PRESET_AWAY, + } + wanted_p: list[str] = [] + for name in presets: + preset = str(name) + if preset in valid_presets and preset not in wanted_p: + wanted_p.append(preset) + if PRESET_NONE not in wanted_p: + wanted_p.insert(0, PRESET_NONE) + # honor even an empty / none-only list (user wants no presets) + self._customize_preset_modes = wanted_p + + def _capability_swing(self) -> bool: + """Whether swing is available (customize > B5 capability > default). + + Returns: + ``True`` when the swing control should be exposed. + + """ + if self._customize_swing is not None: + return self._customize_swing + caps = getattr(self._device, "capabilities", {}) + if "swing_vertical" in caps or "swing_horizontal" in caps: + return bool(caps.get("swing_vertical") or caps.get("swing_horizontal")) + return True + + @property + def hvac_modes(self) -> list[HVACMode]: + """hvac_modes: customize > B5 capabilities > default full set. + + Read dynamically so capabilities decoded after the first refresh are + reflected (they are not yet available when the entity is created). + """ + if self._customize_hvac_modes is not None: + return self._customize_hvac_modes + caps = getattr(self._device, "capabilities", {}) + if not caps: + return list(self._mode_index) + modes = [HVACMode.OFF] + if caps.get("auto_mode"): + modes.append(HVACMode.AUTO) + if caps.get("cool_mode"): + modes.append(HVACMode.COOL) + if caps.get("dry_mode"): + modes.append(HVACMode.DRY) + if caps.get("heat_mode"): + modes.append(HVACMode.HEAT) + # fan-only is always available on AC devices + modes.append(HVACMode.FAN_ONLY) + return modes + + @property + def preset_modes(self) -> list[str]: + """preset_modes: customize > B5 capabilities > default full set. + + B5 only declares eco and turbo (and heat implies away). comfort and + sleep have no capability flag, so they are kept unless overridden via + the customize ``preset_modes`` option. + """ + all_presets = [ + PRESET_NONE, + PRESET_COMFORT, + PRESET_ECO, + PRESET_BOOST, + PRESET_SLEEP, + PRESET_AWAY, + ] + if self._customize_preset_modes is not None: + return self._customize_preset_modes + caps = getattr(self._device, "capabilities", {}) + if not caps: + return all_presets + keep = { + PRESET_NONE: True, + PRESET_COMFORT: True, + PRESET_ECO: bool(caps.get("eco")), + PRESET_BOOST: bool(caps.get("turbo_cool") or caps.get("turbo_heat")), + PRESET_SLEEP: True, + PRESET_AWAY: bool(caps.get("heat_mode")), + } + return [preset for preset in all_presets if keep[preset]] + + @property + def supported_features(self) -> ClimateEntityFeature: + """Midea AC Climate supported features.""" + features = super().supported_features + if not self._capability_swing(): + features &= ~ClimateEntityFeature.SWING_MODE + # drop the preset control entirely when no real preset is available + if not any(preset != PRESET_NONE for preset in self.preset_modes): + features &= ~ClimateEntityFeature.PRESET_MODE + return features + + @property + def hvac_mode(self) -> HVACMode: + """Midea AC Climate hvac mode (device mode int -> fixed map).""" + if self._device.get_attribute("power"): + mode = cast("int", self._device.get_attribute("mode")) + return self._mode_index[mode] + return HVACMode.OFF + + def set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Midea AC Climate set hvac mode (via the fixed map index).""" + if hvac_mode == HVACMode.OFF: + self.turn_off() + else: + self._device.set_attribute( + attr="mode", + value=self._mode_index.index(hvac_mode), + ) + + def set_temperature(self, **kwargs: Any) -> None: # ruff:ignore[any-type] + """Midea AC Climate set temperature (raw mode via the fixed map). + + ``hvac_modes`` is a filtered subset here, so the raw protocol mode must + come from the fixed ``_mode_index`` and not from the displayed list. + """ + if ATTR_TEMPERATURE not in kwargs: + return + temperature = float(int((float(kwargs[ATTR_TEMPERATURE]) * 2) + 0.5)) / 2 + hvac_mode = kwargs.get(ATTR_HVAC_MODE) + if hvac_mode == HVACMode.OFF: + self.turn_off() + else: + try: + mode = self._mode_index.index(hvac_mode.lower()) if hvac_mode else None + self._device.set_target_temperature( + target_temperature=temperature, + mode=mode, + zone=None, + ) + except ValueError: + _LOGGER.exception("Error setting temperature with: %s", kwargs) + @property def fan_mode(self) -> str: """Midea AC Climate fan mode.""" @@ -320,6 +502,28 @@ class MideaACClimate(MideaClimate): return str(FAN_LOW) return str(FAN_SILENT) + @property + def fan_modes(self) -> list[str] | None: + """fan_modes restricted to the speeds the device reports (B5). + + Falls back to the full set when no capability is reported. + """ + caps = getattr(self._device, "capabilities", {}) + if not caps: + return list(self._fan_speeds.keys()) + cap_by_fan = { + FAN_SILENT: "fan_silent", + FAN_LOW: "fan_low", + FAN_MEDIUM: "fan_medium", + FAN_HIGH: "fan_high", + FAN_FULL_SPEED: "fan_custom", + FAN_AUTO: "fan_auto", + } + modes = [ + name for name in self._fan_speeds if caps.get(cap_by_fan.get(name, "")) + ] + return modes or list(self._fan_speeds.keys()) + @property def target_temperature_step(self) -> float: """Midea AC Climate target temperature step.""" @@ -337,7 +541,7 @@ class MideaACClimate(MideaClimate): @property def current_humidity(self) -> float | None: - """Return the current indoor humidity, or None if unavailable.""" + """Current indoor humidity, or None if unavailable.""" # fix error humidity, disable indoor_humidity in web UI # https://github.com/wuwentao/midea_ac_lan/pull/641 if not self._indoor_humidity_enabled: @@ -357,6 +561,22 @@ class MideaACClimate(MideaClimate): self._device.get_attribute(ACAttributes.outdoor_temperature), ) + @property + def min_temp(self) -> float: + """Midea AC Climate min temperature (from device capability).""" + value = self._device.get_attribute(ACAttributes.min_temperature) + if value is None: + return TEMPERATURE_MIN + return cast("float", value) + + @property + def max_temp(self) -> float: + """Midea AC Climate max temperature (from device capability).""" + value = self._device.get_attribute(ACAttributes.max_temperature) + if value is None: + return TEMPERATURE_MAX + return cast("float", value) + def set_fan_mode(self, fan_mode: str) -> None: """Midea AC Climate set fan mode.""" fan_speed = self._fan_speeds.get(fan_mode) @@ -572,11 +792,11 @@ class MideaC3Climate(MideaClimate): self._temperature(False)[self._zone], ) - def turn_on(self, **kwargs: Any) -> None: # noqa: ANN401, ARG002 + def turn_on(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument] """Midea C3 Climate turn on.""" self._device.set_attribute(attr=self._power_attr, value=True) - def turn_off(self, **kwargs: Any) -> None: # noqa: ANN401, ARG002 + def turn_off(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument] """Midea C3 Climate turn off.""" self._device.set_attribute(attr=self._power_attr, value=False) @@ -605,7 +825,7 @@ class MideaC3Climate(MideaClimate): """Midea C3 Climate current temperature.""" return None - def set_temperature(self, **kwargs: Any) -> None: # noqa: ANN401 + def set_temperature(self, **kwargs: Any) -> None: # ruff:ignore[any-type] """Midea C3 Climate set temperature.""" if ATTR_TEMPERATURE not in kwargs: return @@ -681,7 +901,7 @@ class MideaFBClimate(MideaClimate): self._device.get_attribute(FBAttributes.current_temperature), ) - def set_temperature(self, **kwargs: Any) -> None: # noqa: ANN401 + def set_temperature(self, **kwargs: Any) -> None: # ruff:ignore[any-type] """Midea FB Climate set temperature.""" if ATTR_TEMPERATURE not in kwargs: return diff --git a/custom_components/midea_ac_lan/config_flow.py b/custom_components/midea_ac_lan/config_flow.py index 456f641..b5a5a8a 100644 --- a/custom_components/midea_ac_lan/config_flow.py +++ b/custom_components/midea_ac_lan/config_flow.py @@ -70,13 +70,17 @@ else: from .const import ( CONF_ACCOUNT, CONF_KEY, + CONF_MAC, CONF_MODEL, CONF_REFRESH_INTERVAL, CONF_SERVER, + CONF_SN, CONF_SUBTYPE, + DEVICES, DOMAIN, EXTRA_CONTROL, EXTRA_SENSOR, + supports_model, ) from .midea_devices import MIDEA_DEVICES @@ -105,7 +109,7 @@ class MideaLanConfigFlow(ConfigFlow, domain=DOMAIN): # type: ignore[call-arg] """ VERSION = 2 - MINOR_VERSION = 1 + MINOR_VERSION = 2 def __init__(self) -> None: """MideaLanConfigFlow class.""" @@ -140,7 +144,7 @@ class MideaLanConfigFlow(ConfigFlow, domain=DOMAIN): # type: ignore[call-arg] record_file = storage_path.joinpath(f"{data[CONF_DEVICE_ID]!s}.json") save_json(str(record_file), data) - def _load_device_config(self, device_id: str) -> Any: # noqa: ANN401 + def _load_device_config(self, device_id: str) -> Any: # ruff:ignore[any-type] """Load device config from json file with device id. Returns @@ -798,6 +802,8 @@ class MideaLanConfigFlow(ConfigFlow, domain=DOMAIN): # type: ignore[call-arg] CONF_SUBTYPE: user_input[CONF_SUBTYPE], CONF_TOKEN: user_input[CONF_TOKEN], CONF_KEY: user_input[CONF_KEY], + CONF_MAC: device.get(CONF_MAC), + CONF_SN: device.get(CONF_SN), } # save device json config when adding new device self._save_device_config(data) @@ -937,13 +943,38 @@ class MideaLanOptionsFlowHandler(OptionsFlow): return self.async_create_entry(title="", data=user_input) sensors = {} switches = {} + device_id = self._config_entry.data.get(CONF_DEVICE_ID) + device = ( + self.hass.data.get(DOMAIN, {}).get(DEVICES, {}).get(device_id) + if device_id is not None + else None + ) + selected_attributes = set( + self._config_entry.options.get(CONF_SENSORS, []), + ) | set(self._config_entry.options.get(CONF_SWITCHES, [])) for attribute, attribute_config in cast( "dict", MIDEA_DEVICES[cast("int", self._device_type)]["entities"], ).items(): + if not supports_model( + self._config_entry.data.get(CONF_MODEL), + attribute_config, + ): + continue attribute_name = ( attribute if isinstance(attribute, str) else attribute.value ) + required_attribute = attribute_config.get("required_attribute") + if ( + required_attribute is not None + and ( + device is None + or required_attribute not in device.attributes + or device.get_attribute(required_attribute) is None + ) + and attribute_name not in selected_attributes + ): + continue if attribute_config.get("type") in EXTRA_SENSOR: sensors[attribute_name] = attribute_config.get("name") elif attribute_config.get( diff --git a/custom_components/midea_ac_lan/const.py b/custom_components/midea_ac_lan/const.py index 9a21b78..2299e29 100644 --- a/custom_components/midea_ac_lan/const.py +++ b/custom_components/midea_ac_lan/const.py @@ -1,6 +1,7 @@ """Const for Midea Lan.""" from enum import IntEnum +from typing import Any, cast from homeassistant.const import Platform @@ -14,10 +15,19 @@ CONF_SUBTYPE = "subtype" CONF_ACCOUNT = "account" CONF_SERVER = "server" CONF_REFRESH_INTERVAL = "refresh_interval" +CONF_MAC = "mac" +CONF_SN = "sn" EXTRA_SENSOR = [Platform.SENSOR, Platform.BINARY_SENSOR] -EXTRA_SWITCH = [Platform.SWITCH, Platform.LOCK, Platform.SELECT, Platform.NUMBER] +EXTRA_SWITCH = [ + Platform.SWITCH, + Platform.LOCK, + Platform.SELECT, + Platform.NUMBER, + Platform.TIME, +] EXTRA_CONTROL = [ + Platform.BUTTON, Platform.CLIMATE, Platform.WATER_HEATER, Platform.FAN, @@ -28,6 +38,18 @@ EXTRA_CONTROL = [ ALL_PLATFORM = EXTRA_SENSOR + EXTRA_CONTROL +def supports_model(model: object, config: dict[str, Any]) -> bool: + """Return if the entity config applies to the device model. + + Returns + ------- + True if the entity is available for the device model. + + """ + models = config.get("models") + return not models or str(model) in cast("list[str]", models) + + class FanSpeed(IntEnum): """FanSpeed reference values.""" diff --git a/custom_components/midea_ac_lan/diagnostics.py b/custom_components/midea_ac_lan/diagnostics.py index 3ecd6f3..3cf1151 100644 --- a/custom_components/midea_ac_lan/diagnostics.py +++ b/custom_components/midea_ac_lan/diagnostics.py @@ -19,7 +19,7 @@ TO_REDACT = {CONF_TOKEN, CONF_KEY} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, # noqa: ARG001 + hass: HomeAssistant, # ruff:ignore[unused-function-argument] entry: ConfigEntry, ) -> dict[str, Any]: """Return diagnostics for a config entry. diff --git a/custom_components/midea_ac_lan/fan.py b/custom_components/midea_ac_lan/fan.py index 5f6bde3..d059d5c 100644 --- a/custom_components/midea_ac_lan/fan.py +++ b/custom_components/midea_ac_lan/fan.py @@ -23,7 +23,13 @@ from midealocal.devices.x40 import DeviceAttributes as X40Attributes from midealocal.devices.x40 import MideaX40Device from .const import DEVICES, DOMAIN -from .midea_devices import MIDEA_DEVICES +from .midea_devices import ( + FRESH_AIR_EXHAUST, + FRESH_AIR_EXHAUST_MODE, + FRESH_AIR_EXHAUST_POWER, + FRESH_AIR_EXHAUST_SPEED, + MIDEA_DEVICES, +) from .midea_entity import MideaEntity _LOGGER = logging.getLogger(__name__) @@ -45,6 +51,12 @@ async def async_setup_entry( "dict", MIDEA_DEVICES[device.device_type]["entities"], ).items(): + required_attribute = config.get("required_attribute") + if ( + required_attribute is not None + and required_attribute not in device.attributes + ): + continue if config["type"] == Platform.FAN and ( config.get("default") or entity_key in extra_switches ): @@ -100,7 +112,7 @@ class MideaFan(MideaEntity, FanEntity): """Midea Fan fan speed.""" return cast("int", self._device.get_attribute("fan_speed")) - def turn_off(self, **kwargs: Any) -> None: # noqa: ANN401, ARG002 + def turn_off(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument] """Midea Fan turn off.""" self._device.set_attribute(attr="power", value=False) @@ -117,7 +129,7 @@ class MideaFan(MideaEntity, FanEntity): """Midea Fan percentage.""" if not self.fan_speed: return None - return int(round(self.fan_speed * self.percentage_step)) # noqa: RUF046 + return int(round(self.fan_speed * self.percentage_step)) # ruff:ignore[unnecessary-cast-to-int] def set_percentage(self, percentage: int) -> None: """Midea Fan set percentage.""" @@ -131,7 +143,7 @@ class MideaFan(MideaEntity, FanEntity): else: await self.hass.async_add_executor_job(self.set_percentage, percentage) - def update_state(self, status: Any) -> None: # noqa: ANN401, ARG002 + def update_state(self, status: Any) -> None: # ruff:ignore[any-type, unused-method-argument] """Midea Fan update state.""" if not self.hass: _LOGGER.warning( @@ -140,7 +152,7 @@ class MideaFan(MideaEntity, FanEntity): type(self), ) return - self.schedule_update_ha_state() + self.schedule_update_if_running() class MideaFAFan(MideaFan): @@ -163,7 +175,7 @@ class MideaFAFan(MideaFan): self, percentage: int | None = None, preset_mode: str | None = None, - **kwargs: Any, # noqa: ANN401, ARG002 + **kwargs: Any, # ruff:ignore[any-type, unused-method-argument] ) -> None: """Midea FA Fan turn on.""" fan_speed = int(percentage / self.percentage_step + 0.5) if percentage else None @@ -189,7 +201,7 @@ class MideaB6Fan(MideaFan): self, percentage: int | None = None, preset_mode: str | None = None, - **kwargs: Any, # noqa: ANN401, ARG002 + **kwargs: Any, # ruff:ignore[any-type, unused-method-argument] ) -> None: """Midea B6 Fan turn on.""" fan_speed = int(percentage / self.percentage_step + 0.5) if percentage else None @@ -204,6 +216,7 @@ class MideaACFreshAirFan(MideaFan): def __init__(self, device: MideaACDevice, entity_key: str) -> None: """Midea AC Fresh Air Fan entity init.""" super().__init__(device, entity_key) + self._is_exhaust = entity_key == FRESH_AIR_EXHAUST self._attr_supported_features = ( FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE @@ -214,47 +227,89 @@ class MideaACFreshAirFan(MideaFan): @property def preset_modes(self) -> list[str] | None: """Midea AC Fan preset modes.""" + if self._is_exhaust: + return cast( + "list[str]", + getattr(self._device, "fresh_air_exhaust_fan_speeds", []), + ) return cast("list", self._device.fresh_air_fan_speeds) @property def is_on(self) -> bool: """Midea AC Fan is on.""" - return cast("bool", self._device.get_attribute(ACAttributes.fresh_air_power)) + attribute = ( + FRESH_AIR_EXHAUST_POWER + if self._is_exhaust + else ACAttributes.fresh_air_power + ) + return cast("bool", self._device.get_attribute(attribute)) @property def fan_speed(self) -> int: """Midea AC Fan fan speed.""" - return cast("int", self._device.get_attribute(ACAttributes.fresh_air_fan_speed)) + attribute = ( + FRESH_AIR_EXHAUST_SPEED + if self._is_exhaust + else ACAttributes.fresh_air_fan_speed + ) + return cast("int", self._device.get_attribute(attribute)) def turn_on( self, - percentage: int | None = None, # noqa: ARG002 - preset_mode: str | None = None, # noqa: ARG002 - **kwargs: Any, # noqa: ANN401, ARG002 + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, # ruff:ignore[any-type, unused-method-argument] ) -> None: """Midea AC Fan tun on.""" - self._device.set_attribute(attr=ACAttributes.fresh_air_power, value=True) + if preset_mode is not None: + self.set_preset_mode(preset_mode) + return + if percentage is not None: + self.set_percentage(percentage) + return + attribute = ( + FRESH_AIR_EXHAUST_POWER + if self._is_exhaust + else ACAttributes.fresh_air_power + ) + self._device.set_attribute(attr=attribute, value=True) - def turn_off(self, **kwargs: Any) -> None: # noqa: ANN401, ARG002 + def turn_off(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument] """Midea AC Fan turn off.""" - self._device.set_attribute(attr=ACAttributes.fresh_air_power, value=False) + attribute = ( + FRESH_AIR_EXHAUST_POWER + if self._is_exhaust + else ACAttributes.fresh_air_power + ) + self._device.set_attribute(attr=attribute, value=False) def set_percentage(self, percentage: int) -> None: """Midea AC Fan set percentage.""" fan_speed = int(percentage / self.percentage_step + 0.5) + attribute = ( + FRESH_AIR_EXHAUST_SPEED + if self._is_exhaust + else ACAttributes.fresh_air_fan_speed + ) self._device.set_attribute( - attr=ACAttributes.fresh_air_fan_speed, + attr=attribute, value=fan_speed, ) def set_preset_mode(self, preset_mode: str) -> None: """Midea AC Fan set preset mode.""" - self._device.set_attribute(attr=ACAttributes.fresh_air_mode, value=preset_mode) + attribute = ( + FRESH_AIR_EXHAUST_MODE if self._is_exhaust else ACAttributes.fresh_air_mode + ) + self._device.set_attribute(attr=attribute, value=preset_mode) @property def preset_mode(self) -> str | None: """Midea AC Fan preset mode.""" - return cast("str", self._device.get_attribute(attr=ACAttributes.fresh_air_mode)) + attribute = ( + FRESH_AIR_EXHAUST_MODE if self._is_exhaust else ACAttributes.fresh_air_mode + ) + return cast("str", self._device.get_attribute(attr=attribute)) class MideaCEFan(MideaFan): @@ -274,9 +329,9 @@ class MideaCEFan(MideaFan): def turn_on( self, - percentage: int | None = None, # noqa: ARG002 - preset_mode: str | None = None, # noqa: ARG002 - **kwargs: Any, # noqa: ANN401, ARG002 + percentage: int | None = None, # ruff:ignore[unused-method-argument] + preset_mode: str | None = None, # ruff:ignore[unused-method-argument] + **kwargs: Any, # ruff:ignore[any-type, unused-method-argument] ) -> None: """Midea CE Fan turn on.""" self._device.set_attribute(attr=CEAttributes.power, value=True) @@ -308,13 +363,13 @@ class MideaX40Fan(MideaFan): def turn_on( self, - percentage: int | None = None, # noqa: ARG002 - preset_mode: str | None = None, # noqa: ARG002 - **kwargs: Any, # noqa: ANN401, ARG002 + percentage: int | None = None, # ruff:ignore[unused-method-argument] + preset_mode: str | None = None, # ruff:ignore[unused-method-argument] + **kwargs: Any, # ruff:ignore[any-type, unused-method-argument] ) -> None: """Midea X40 Fan turn on.""" self._device.set_attribute(attr=X40Attributes.fan_speed, value=1) - def turn_off(self, **kwargs: Any) -> None: # noqa: ANN401, ARG002 + def turn_off(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument] """Midea X40 Fan turn off.""" self._device.set_attribute(attr=X40Attributes.fan_speed, value=0) diff --git a/custom_components/midea_ac_lan/humidifier.py b/custom_components/midea_ac_lan/humidifier.py index e12cc35..5326d4d 100644 --- a/custom_components/midea_ac_lan/humidifier.py +++ b/custom_components/midea_ac_lan/humidifier.py @@ -92,15 +92,15 @@ class MideaHumidifier(MideaEntity, HumidifierEntity): """Midea Humidifier is on.""" return cast("bool", self._device.get_attribute(attr="power")) - def turn_on(self, **kwargs: Any) -> None: # noqa: ANN401, ARG002 + def turn_on(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument] """Midea Humidifier turn on.""" self._device.set_attribute(attr="power", value=True) - def turn_off(self, **kwargs: Any) -> None: # noqa: ANN401, ARG002 + def turn_off(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument] """Midea Humidifier turn off.""" self._device.set_attribute(attr="power", value=False) - def update_state(self, status: Any) -> None: # noqa: ANN401, ARG002 + def update_state(self, status: Any) -> None: # ruff:ignore[any-type, unused-method-argument] """Midea Humidifier update state.""" if not self.hass: _LOGGER.warning( @@ -109,7 +109,7 @@ class MideaHumidifier(MideaEntity, HumidifierEntity): type(self), ) return - self.schedule_update_ha_state() + self.schedule_update_if_running() class MideaA1Humidifier(MideaHumidifier): diff --git a/custom_components/midea_ac_lan/light.py b/custom_components/midea_ac_lan/light.py index 00f27a6..e8eed2d 100644 --- a/custom_components/midea_ac_lan/light.py +++ b/custom_components/midea_ac_lan/light.py @@ -153,7 +153,7 @@ class MideaLight(MideaEntity, LightEntity): """Midea Light effect.""" return cast("str", self._device.get_attribute(X13Attributes.effect)) - def turn_on(self, **kwargs: Any) -> None: # noqa: ANN401 + def turn_on(self, **kwargs: Any) -> None: # ruff:ignore[any-type] """Midea Light turn on.""" if not self.is_on: self._device.set_attribute(attr=X13Attributes.power, value=True) @@ -168,11 +168,11 @@ class MideaLight(MideaEntity, LightEntity): if key == ATTR_EFFECT: self._device.set_attribute(attr=X13Attributes.effect, value=value) - def turn_off(self, **kwargs: Any) -> None: # noqa: ANN401, ARG002 + def turn_off(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument] """Midea Light turn off.""" self._device.set_attribute(attr=X13Attributes.power, value=False) - def update_state(self, status: Any) -> None: # noqa: ANN401,ARG002 + def update_state(self, status: Any) -> None: # ruff:ignore[any-type, unused-method-argument] """Midea Light update state.""" if not self.hass: _LOGGER.warning( @@ -181,4 +181,4 @@ class MideaLight(MideaEntity, LightEntity): type(self), ) return - self.schedule_update_ha_state() + self.schedule_update_if_running() diff --git a/custom_components/midea_ac_lan/lock.py b/custom_components/midea_ac_lan/lock.py index e66f974..49518ff 100644 --- a/custom_components/midea_ac_lan/lock.py +++ b/custom_components/midea_ac_lan/lock.py @@ -38,17 +38,17 @@ class MideaLock(MideaEntity, LockEntity): @property def is_locked(self) -> bool: - """Return true if state is locked.""" + """Whether the lock is locked.""" return cast("bool", self._device.get_attribute(self._entity_key)) - def lock(self, **kwargs: Any) -> None: # noqa: ANN401, ARG002 + def lock(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument] """Lock the lock.""" self._device.set_attribute(attr=self._entity_key, value=True) - def unlock(self, **kwargs: Any) -> None: # noqa: ANN401, ARG002 + def unlock(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument] """Unlock the lock.""" self._device.set_attribute(attr=self._entity_key, value=False) - def open(self, **kwargs: Any) -> None: # noqa: ANN401, ARG002 + def open(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument] """Open the lock.""" self.unlock() diff --git a/custom_components/midea_ac_lan/manifest.json b/custom_components/midea_ac_lan/manifest.json index 52f6ee4..5a373e7 100644 --- a/custom_components/midea_ac_lan/manifest.json +++ b/custom_components/midea_ac_lan/manifest.json @@ -9,6 +9,6 @@ "iot_class": "local_push", "issue_tracker": "https://github.com/wuwentao/midea_ac_lan/issues", "loggers": ["midealocal"], - "requirements": ["midea-local==6.8.0"], - "version": "0.6.12" + "requirements": ["midea-local==6.11.0"], + "version": "0.7.0" } diff --git a/custom_components/midea_ac_lan/midea_devices.py b/custom_components/midea_ac_lan/midea_devices.py index 170c43b..4d5f53c 100644 --- a/custom_components/midea_ac_lan/midea_devices.py +++ b/custom_components/midea_ac_lan/midea_devices.py @@ -8,8 +8,12 @@ from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, + REVOLUTIONS_PER_MINUTE, Platform, + UnitOfElectricCurrent, + UnitOfElectricPotential, UnitOfEnergy, + UnitOfFrequency, UnitOfPower, UnitOfTemperature, UnitOfTime, @@ -50,6 +54,11 @@ from midealocal.devices.x26 import DeviceAttributes as X26Attributes from midealocal.devices.x34 import DeviceAttributes as X34Attributes from midealocal.devices.x40 import DeviceAttributes as X40Attributes +FRESH_AIR_EXHAUST = "fresh_air_exhaust" +FRESH_AIR_EXHAUST_MODE = "fresh_air_exhaust_mode" +FRESH_AIR_EXHAUST_POWER = "fresh_air_exhaust_power" +FRESH_AIR_EXHAUST_SPEED = "fresh_air_exhaust_speed" + """ Entity Naming Rule: @@ -384,6 +393,29 @@ MIDEA_DEVICES: dict[int, dict[str, dict[str, Any] | str]] = { "name": "Fresh Air", "icon": "mdi:fan", }, + ACAttributes.fresh_air_mode: { + "type": Platform.SELECT, + "required_attribute": ACAttributes.fresh_air_mode, + "translation_key": "fresh_air_mode", + "name": "Fresh Air Speed", + "icon": "mdi:fan-chevron-up", + "options": "fresh_air_fan_speeds", + }, + FRESH_AIR_EXHAUST: { + "type": Platform.FAN, + "translation_key": "fresh_air_exhaust", + "name": "Fresh Air Exhaust", + "icon": "mdi:fan-reverse", + "required_attribute": FRESH_AIR_EXHAUST_POWER, + }, + FRESH_AIR_EXHAUST_MODE: { + "type": Platform.SELECT, + "translation_key": "fresh_air_exhaust_mode", + "name": "Fresh Air Exhaust Speed", + "icon": "mdi:fan-chevron-down", + "options": "fresh_air_exhaust_fan_speeds", + "required_attribute": FRESH_AIR_EXHAUST_POWER, + }, ACAttributes.aux_heating: { "type": Platform.SWITCH, "translation_key": "aux_heating", @@ -578,6 +610,125 @@ MIDEA_DEVICES: dict[int, dict[str, dict[str, Any] | str]] = { "name": "Error Code", "icon": "mdi:alert-box", }, + # group 1: compressor and refrigerant circuit + ACAttributes.compressor_frequency: { + "type": Platform.SENSOR, + "required_attribute": ACAttributes.compressor_frequency, + "translation_key": "compressor_frequency", + "name": "Compressor Frequency", + "device_class": SensorDeviceClass.FREQUENCY, + "unit": UnitOfFrequency.HERTZ, + "state_class": SensorStateClass.MEASUREMENT, + }, + ACAttributes.target_compressor_frequency: { + "type": Platform.SENSOR, + "required_attribute": ACAttributes.target_compressor_frequency, + "translation_key": "target_compressor_frequency", + "name": "Target Compressor Frequency", + "device_class": SensorDeviceClass.FREQUENCY, + "unit": UnitOfFrequency.HERTZ, + "state_class": SensorStateClass.MEASUREMENT, + }, + ACAttributes.compressor_current: { + "type": Platform.SENSOR, + "required_attribute": ACAttributes.compressor_current, + "translation_key": "compressor_current", + "name": "Compressor Current", + "device_class": SensorDeviceClass.CURRENT, + "unit": UnitOfElectricCurrent.AMPERE, + "state_class": SensorStateClass.MEASUREMENT, + }, + ACAttributes.compressor_voltage: { + "type": Platform.SENSOR, + "required_attribute": ACAttributes.compressor_voltage, + "translation_key": "compressor_voltage", + "name": "Compressor Voltage", + "device_class": SensorDeviceClass.VOLTAGE, + "unit": UnitOfElectricPotential.VOLT, + "state_class": SensorStateClass.MEASUREMENT, + }, + ACAttributes.indoor_coil_temperature: { + "type": Platform.SENSOR, + "required_attribute": ACAttributes.indoor_coil_temperature, + "translation_key": "indoor_coil_temperature", + "name": "Indoor Coil Temperature (T1)", + "device_class": SensorDeviceClass.TEMPERATURE, + "unit": UnitOfTemperature.CELSIUS, + "state_class": SensorStateClass.MEASUREMENT, + }, + ACAttributes.evaporator_temperature: { + "type": Platform.SENSOR, + "required_attribute": ACAttributes.evaporator_temperature, + "translation_key": "evaporator_temperature", + "name": "Evaporator Temperature (T2)", + "device_class": SensorDeviceClass.TEMPERATURE, + "unit": UnitOfTemperature.CELSIUS, + "state_class": SensorStateClass.MEASUREMENT, + }, + ACAttributes.condenser_temperature: { + "type": Platform.SENSOR, + "required_attribute": ACAttributes.condenser_temperature, + "translation_key": "condenser_temperature", + "name": "Condenser Temperature (T3)", + "device_class": SensorDeviceClass.TEMPERATURE, + "unit": UnitOfTemperature.CELSIUS, + "state_class": SensorStateClass.MEASUREMENT, + }, + ACAttributes.outdoor_ambient_temperature: { + "type": Platform.SENSOR, + "required_attribute": ACAttributes.outdoor_ambient_temperature, + "translation_key": "outdoor_ambient_temperature", + "name": "Outdoor Ambient Temperature (T4)", + "device_class": SensorDeviceClass.TEMPERATURE, + "unit": UnitOfTemperature.CELSIUS, + "state_class": SensorStateClass.MEASUREMENT, + }, + ACAttributes.discharge_pipe_temperature: { + "type": Platform.SENSOR, + "required_attribute": ACAttributes.discharge_pipe_temperature, + "translation_key": "discharge_pipe_temperature", + "name": "Discharge Pipe Temperature (TP)", + "device_class": SensorDeviceClass.TEMPERATURE, + "unit": UnitOfTemperature.CELSIUS, + "state_class": SensorStateClass.MEASUREMENT, + }, + # group 2: indoor fan and condensate pump + ACAttributes.indoor_fan_speed: { + "type": Platform.SENSOR, + "required_attribute": ACAttributes.indoor_fan_speed, + "translation_key": "indoor_fan_speed", + "name": "Indoor Fan Speed", + "icon": "mdi:fan", + "unit": REVOLUTIONS_PER_MINUTE, + "state_class": SensorStateClass.MEASUREMENT, + }, + ACAttributes.target_indoor_fan_speed: { + "type": Platform.SENSOR, + "required_attribute": ACAttributes.target_indoor_fan_speed, + "translation_key": "target_indoor_fan_speed", + "name": "Target Indoor Fan Speed", + "icon": "mdi:fan-clock", + "unit": REVOLUTIONS_PER_MINUTE, + "state_class": SensorStateClass.MEASUREMENT, + }, + ACAttributes.water_pump_running: { + "type": Platform.BINARY_SENSOR, + "required_attribute": ACAttributes.water_pump_running, + "translation_key": "water_pump_running", + "name": "Water Pump Running", + "icon": "mdi:water-pump", + "device_class": BinarySensorDeviceClass.RUNNING, + }, + # group 7: real time compressor power + ACAttributes.compressor_power: { + "type": Platform.SENSOR, + "required_attribute": ACAttributes.compressor_power, + "translation_key": "compressor_power", + "name": "Compressor Power", + "device_class": SensorDeviceClass.POWER, + "unit": UnitOfPower.WATT, + "state_class": SensorStateClass.MEASUREMENT, + }, ACAttributes.wind_lr_angle: { "type": Platform.SELECT, "translation_key": "wind_lr_angle", @@ -592,6 +743,13 @@ MIDEA_DEVICES: dict[int, dict[str, dict[str, Any] | str]] = { "options": "wind_ud_angles", "icon": "mdi:pan-vertical", }, + ACAttributes.rate_select: { + "type": Platform.SELECT, + "translation_key": "rate_select", + "name": "Power Rate Limit", + "options": "rate_selects", + "icon": "mdi:lightning-bolt-circle", + }, ACAttributes.fan_speed: { "type": Platform.NUMBER, "translation_key": "fan_speed_percent", @@ -2432,12 +2590,82 @@ MIDEA_DEVICES: dict[int, dict[str, dict[str, Any] | str]] = { "name": "Storage", "icon": "mdi:repeat-variant", }, + "start": { + "type": Platform.BUTTON, + "translation_key": "start", + "name": "Start", + "icon": "mdi:play", + "set_message": "e1_start", + "available_power_attribute": E1Attributes.power, + "models": ["7600024L"], + "default": True, + }, E1Attributes.mode: { "type": Platform.SENSOR, "translation_key": "mode", "name": "Working Mode", "icon": "mdi:dishwasher", }, + "mode_select": { + "type": Platform.SELECT, + "attribute": E1Attributes.mode, + "translation_key": "wash_mode", + "name": "Wash Mode", + "options_dict": "modes", + "options_codes_by_model": { + "7600024L": [13, 4, 8, 6, 2, 11, 10], + }, + "set_message": "e1_work_mode", + "available_power_attribute": E1Attributes.power, + "icon": "mdi:dishwasher", + "default": True, + }, + "estimated_energy_consumption": { + "type": Platform.SENSOR, + "translation_key": "estimated_energy_consumption", + "name": "Estimated Energy Consumption", + "icon": "mdi:lightning-bolt", + "device_class": SensorDeviceClass.ENERGY, + "unit": UnitOfEnergy.KILO_WATT_HOUR, + "state_class": SensorStateClass.TOTAL_INCREASING, + "estimate": { + "kind": "energy", + "values": { + "Germ": 0.765, + "ECO Wash": 0.99, + "Strong Wash": 1.28, + "Hour Wash": 0.91, + "Soak Wash": 0.02, + "Self Clean": 1.524, + "Fruit Wash": 1.625, + }, + }, + "models": ["7600024L"], + "default": True, + }, + "estimated_water_consumption": { + "type": Platform.SENSOR, + "translation_key": "estimated_water_consumption", + "name": "Estimated Water Consumption", + "icon": "mdi:water", + "device_class": SensorDeviceClass.WATER, + "unit": UnitOfVolume.LITERS, + "state_class": SensorStateClass.TOTAL_INCREASING, + "estimate": { + "kind": "water", + "values": { + "Germ": 9.9, + "ECO Wash": 10.4, + "Strong Wash": 13.9, + "Hour Wash": 10.4, + "Soak Wash": 3.4, + "Self Clean": 10.3, + "Fruit Wash": 13.3, + }, + }, + "models": ["7600024L"], + "default": True, + }, E1Attributes.error_code: { "type": Platform.SENSOR, "translation_key": "error_code", @@ -2536,6 +2764,18 @@ MIDEA_DEVICES: dict[int, dict[str, dict[str, Any] | str]] = { "name": "Whole Tank Heating", "icon": "mdi:restore", }, + E2Attributes.sterilization: { + "type": Platform.SWITCH, + "translation_key": "sterilization", + "name": "Sterilization", + "icon": "mdi:bacteria", + }, + E2Attributes.memory: { + "type": Platform.SWITCH, + "translation_key": "memory", + "name": "Memo U", + "icon": "mdi:brain", + }, }, }, 0xE3: { @@ -2951,6 +3191,176 @@ MIDEA_DEVICES: dict[int, dict[str, dict[str, Any] | str]] = { "unit": UnitOfVolume.LITERS, "state_class": SensorStateClass.TOTAL_INCREASING, }, + # Soft water machine (water softener) entities + EDAttributes.soften: { + "type": Platform.SWITCH, + "translation_key": "soften", + "name": "Softening", + "icon": "mdi:water-outline", + }, + EDAttributes.cl_sterilization: { + "type": Platform.SWITCH, + "translation_key": "cl_sterilization", + "name": "CL Sterilization", + "icon": "mdi:bacteria", + }, + EDAttributes.leak_water_protection: { + "type": Platform.SWITCH, + "translation_key": "leak_water_protection", + "name": "Leak Water Protection", + "icon": "mdi:water-alert", + }, + EDAttributes.water_way: { + "type": Platform.SWITCH, + "translation_key": "water_way", + "name": "Water Way", + "icon": "mdi:pipe", + }, + EDAttributes.regeneration: { + "type": Platform.SWITCH, + "translation_key": "regeneration", + "name": "Regeneration", + "icon": "mdi:refresh", + }, + EDAttributes.velocity: { + "type": Platform.SENSOR, + "translation_key": "velocity", + "name": "Velocity", + "icon": "mdi:speedometer", + "state_class": SensorStateClass.MEASUREMENT, + }, + EDAttributes.soft_available: { + "type": Platform.SENSOR, + "translation_key": "soft_available", + "name": "Soft Water Available", + "icon": "mdi:water-check", + "unit": UnitOfVolume.LITERS, + "state_class": SensorStateClass.MEASUREMENT, + }, + EDAttributes.left_salt: { + "type": Platform.SENSOR, + "translation_key": "left_salt", + "name": "Left Salt", + "icon": "mdi:shaker", + "unit": PERCENTAGE, + "state_class": SensorStateClass.MEASUREMENT, + }, + EDAttributes.remaining_days: { + "type": Platform.SENSOR, + "translation_key": "remaining_days", + "name": "Remaining Days", + "icon": "mdi:calendar-clock", + "unit": UnitOfTime.DAYS, + "state_class": SensorStateClass.MEASUREMENT, + }, + EDAttributes.water_hardness: { + "type": Platform.NUMBER, + "translation_key": "water_hardness", + "name": "Water Hardness", + "icon": "mdi:water", + "min": 0, + "max": 65535, + "step": 1, + }, + EDAttributes.flushing_days: { + "type": Platform.NUMBER, + "translation_key": "flushing_days", + "name": "Flushing Days", + "icon": "mdi:water", + "unit": UnitOfTime.DAYS, + "min": 0, + "max": 99, + "step": 1, + }, + EDAttributes.timing_regeneration_hour: { + "type": Platform.TIME, + "translation_key": "timing_regeneration", + "name": "Timing Regeneration", + "icon": "mdi:clock-outline", + }, + EDAttributes.regeneration_left_seconds: { + "type": Platform.SENSOR, + "translation_key": "regeneration_left_seconds", + "name": "Regeneration Left Seconds", + "icon": "mdi:timer", + "unit": UnitOfTime.SECONDS, + "state_class": SensorStateClass.MEASUREMENT, + }, + EDAttributes.use_days: { + "type": Platform.SENSOR, + "translation_key": "use_days", + "name": "Use Days", + "icon": "mdi:calendar", + "unit": UnitOfTime.DAYS, + "state_class": SensorStateClass.MEASUREMENT, + }, + EDAttributes.salt_setting: { + "type": Platform.SENSOR, + "translation_key": "salt_setting", + "name": "Salt Setting", + "icon": "mdi:shaker-outline", + "state_class": SensorStateClass.MEASUREMENT, + }, + EDAttributes.water_consumption_big: { + "type": Platform.SENSOR, + "translation_key": "water_consumption_big", + "name": "Water Consumption", + "icon": "mdi:water-pump", + "unit": UnitOfVolume.LITERS, + "state_class": SensorStateClass.TOTAL_INCREASING, + "suggested_display_precision": 2, + }, + EDAttributes.water_consumption_average: { + "type": Platform.SENSOR, + "translation_key": "water_consumption_average", + "name": "Water Consumption Average", + "icon": "mdi:water", + "unit": UnitOfVolume.LITERS, + "state_class": SensorStateClass.MEASUREMENT, + }, + EDAttributes.leak_water_protection_value: { + "type": Platform.NUMBER, + "translation_key": "leak_water_protection_value", + "name": "Leak Water Protection Value", + "icon": "mdi:water-alert-outline", + "unit": UnitOfVolume.LITERS, + "min": 0, + "max": 2550, + "step": 50, + }, + EDAttributes.leak_water: { + "type": Platform.BINARY_SENSOR, + "translation_key": "leak_water", + "name": "Leak Water", + "icon": "mdi:water-alert", + "device_class": BinarySensorDeviceClass.PROBLEM, + }, + EDAttributes.rsj_stand_by: { + "type": Platform.BINARY_SENSOR, + "translation_key": "rsj_stand_by", + "name": "Stand By", + "icon": "mdi:power-standby", + }, + EDAttributes.error: { + "type": Platform.SENSOR, + "translation_key": "error", + "name": "Error", + "icon": "mdi:alert-circle", + "device_class": SensorDeviceClass.ENUM, + # Soft water machine (deviceKind=9/10, subtype 703) error codes + # Source: weex.js error dictionary "a" + "options": { + 0: "no_error", + 1: "e1_position_not_found", + 2: "e2_photo_sensor_no_signal", + 3: "e3_motor_not_running", + 4: "e4_wrong_position", + 225: "e1_motor_fault", + 229: "e5_communication_fault", + 230: "e6_salt_sensor_fault", + 231: "e7_chlorine_sterilization_fault", + }, + }, }, }, 0xFA: { diff --git a/custom_components/midea_ac_lan/midea_entity.py b/custom_components/midea_ac_lan/midea_entity.py index 2b5c645..f717fcb 100644 --- a/custom_components/midea_ac_lan/midea_entity.py +++ b/custom_components/midea_ac_lan/midea_entity.py @@ -12,6 +12,7 @@ else: from homeassistant.helpers.entity import ( # type: ignore[attr-defined] DeviceInfo, ) +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac from homeassistant.helpers.entity import Entity from midealocal.device import MideaDevice @@ -27,14 +28,18 @@ class MideaEntity(Entity): def __init__(self, device: MideaDevice, entity_key: str) -> None: """Initialize Midea base entity.""" self._device = device - self._device.register_update(self.update_state) self._config = cast( "dict", MIDEA_DEVICES[self._device.device_type]["entities"], )[entity_key] self._entity_key = entity_key self._unique_id = f"{DOMAIN}.{self._device.device_id}_{entity_key}" - self.entity_id = self._unique_id + # Build entity_id with the correct platform domain (sensor.*, switch.*, …) + # instead of the integration domain. Keeps the legacy "_" + # object_id so existing entity_ids are unchanged, while fixing the HA + # wrong-domain deprecation (breaks in HA 2027.5.0). + ha_domain = self._config["type"] + self.entity_id = f"{ha_domain}.{self._device.device_id}_{entity_key}" self._device_name = self._device.name # HA language setting: @@ -43,7 +48,7 @@ class MideaEntity(Entity): # Entity name translation based on hass.config.language # add language in /config/configuration.yaml will disable web UI setting # homeassistant: - # language: zh-Hans # noqa: ERA001 + # language: zh-Hans # ruff:ignore[commented-out-code] # Translating the name and attributes of entities: # https://developers.home-assistant.io/blog/2023/03/27/entity_name_translations/#translating-entity-name @@ -84,13 +89,13 @@ class MideaEntity(Entity): @property def device(self) -> MideaDevice: - """Return device structure.""" + """Underlying Midea device instance.""" return self._device @property def device_info(self) -> DeviceInfo: - """Return device info.""" - return { + """Device registry info for the entity.""" + info: DeviceInfo = { "manufacturer": "Midea", "model": f"{MIDEA_DEVICES[self._device.device_type]['name']} " f"{self._device.model}" @@ -98,32 +103,100 @@ class MideaEntity(Entity): "identifiers": {(DOMAIN, str(self._device.device_id))}, "name": self._device_name, } + if self._device.mac: + info["connections"] = { + (CONNECTION_NETWORK_MAC, format_mac(self._device.mac)), + } + if self._device.serial_number: + info["serial_number"] = self._device.serial_number + return info @property def unique_id(self) -> str: - """Return entity unique id.""" + """Unique id of the entity.""" return self._unique_id @property def should_poll(self) -> bool: - """Return true is integration should poll.""" + """Whether the integration should poll for updates.""" return False @property def available(self) -> bool: - """Return entity availability.""" + """Whether the entity is available.""" return bool(self._device.available) @property def icon(self) -> str: - """Return entity icon.""" + """Icon for the entity.""" return cast("str", self._config.get("icon")) + async def async_added_to_hass(self) -> None: + """Subscribe to device updates once the entity is added to HA. + + Registering the callback here (rather than in ``__init__``) ensures an + entity that is constructed but never added to HA never receives updates, + which avoids the recurring "HASS is None" warnings. + """ + await super().async_added_to_hass() + self._device.register_update(self.update_state) + + async def async_will_remove_from_hass(self) -> None: + """Unsubscribe from device updates when the entity is removed from HA.""" + await super().async_will_remove_from_hass() + self._device.unregister_update(self.update_state) + @callback - def update_state(self, status: Any) -> None: # noqa: ANN401 + def schedule_update_if_running(self) -> None: + """Schedule a state write unless HA is shutting down. + + Shared by the base ``update_state`` and by the per-device-type + subclasses that override it (climate, fan, light, water_heater, + humidifier), so the shutdown guard lives in exactly one place and the + main control entities are protected too (issues #798 and #809). + + The caller is responsible for the ``self.hass is None`` guard. + + Raises: + RuntimeError: If ``schedule_update_ha_state()`` fails for a reason + other than the event loop being closed during shutdown. + + """ + if self.hass.is_stopping: + _LOGGER.debug( + "MideaEntity update_state for %s [%s]: HASS is stopping", + self.name, + type(self), + ) + return + + # A device background thread can still deliver an update after the HA + # event loop has been closed during shutdown. Scheduling a state write + # then raises RuntimeError because the loop is already closed (issues + # #798 and #809). The is_stopping guard above covers the common case, + # but a race remains between that check and the schedule call, so also + # guard against the closed loop and swallow the residual RuntimeError. + if self.hass.loop.is_closed(): + return + try: + self.schedule_update_ha_state() + except RuntimeError: + # Only swallow the shutdown race; re-raise any unrelated RuntimeError. + if not self.hass.loop.is_closed(): + raise + _LOGGER.debug( + "Ignoring update for %s: event loop closed during shutdown", + self.name, + ) + + @callback + def update_state(self, status: Any) -> None: # ruff:ignore[any-type] """Update entity state.""" if not self.hass: - _LOGGER.warning( + # Defensive guard for the is_stopping access below. Since the update + # callback is now registered in async_added_to_hass (#869), hass is + # always set on the normal path, so this is debug (like is_stopping). + _LOGGER.debug( "MideaEntity update_state for %s [%s] with status %s: HASS is None", self.name, type(self), @@ -131,14 +204,7 @@ class MideaEntity(Entity): ) return - if self.hass.is_stopping: - _LOGGER.debug( - "MideaEntity update_state for %s [%s] with status %s: HASS is stopping", - self.name, - type(self), - status, - ) + if self._entity_key not in status and "available" not in status: return - if self._entity_key in status or "available" in status: - self.schedule_update_ha_state() + self.schedule_update_if_running() diff --git a/custom_components/midea_ac_lan/number.py b/custom_components/midea_ac_lan/number.py index 9a02165..b2bc8a6 100644 --- a/custom_components/midea_ac_lan/number.py +++ b/custom_components/midea_ac_lan/number.py @@ -46,7 +46,7 @@ class MideaNumber(MideaEntity, NumberEntity): @property def native_min_value(self) -> float: - """Return minimum value.""" + """Minimum value allowed.""" return cast( "float", ( @@ -62,7 +62,7 @@ class MideaNumber(MideaEntity, NumberEntity): @property def native_max_value(self) -> float: - """Return maximum value.""" + """Maximum value allowed.""" return cast( "float", ( @@ -78,7 +78,7 @@ class MideaNumber(MideaEntity, NumberEntity): @property def native_step(self) -> float: - """Return step value.""" + """Step value between allowed values.""" return cast( "float", ( @@ -94,9 +94,9 @@ class MideaNumber(MideaEntity, NumberEntity): @property def native_value(self) -> float: - """Return value.""" + """Native value of the entity.""" return cast("float", self._device.get_attribute(self._entity_key)) - def set_native_value(self, value: Any) -> None: # noqa: ANN401 + def set_native_value(self, value: Any) -> None: # ruff:ignore[any-type] """Set value.""" self._device.set_attribute(self._entity_key, value) diff --git a/custom_components/midea_ac_lan/select.py b/custom_components/midea_ac_lan/select.py index 99a7136..ed41e26 100644 --- a/custom_components/midea_ac_lan/select.py +++ b/custom_components/midea_ac_lan/select.py @@ -1,18 +1,21 @@ """Select for Midea Lan.""" -from typing import cast +from typing import TYPE_CHECKING, Any, cast from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID, CONF_SWITCHES, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from midealocal.device import MideaDevice -from .const import DEVICES, DOMAIN +from .const import DEVICES, DOMAIN, supports_model from .midea_devices import MIDEA_DEVICES from .midea_entity import MideaEntity +if TYPE_CHECKING: + from midealocal.devices.e1 import MideaE1Device + async def async_setup_entry( hass: HomeAssistant, @@ -28,9 +31,20 @@ async def async_setup_entry( "dict", MIDEA_DEVICES[device.device_type]["entities"], ).items(): - if config["type"] == Platform.SELECT and entity_key in extra_switches: - dev = MideaSelect(device, entity_key) - selects.append(dev) + if ( + config["type"] != Platform.SELECT + or not supports_model(device.model, config) + or (not config.get("default") and entity_key not in extra_switches) + ): + continue + required_attribute = config.get("required_attribute") + if ( + required_attribute is not None + and required_attribute not in device.attributes + ): + continue + dev = MideaSelect(device, entity_key) + selects.append(dev) async_add_entities(selects) @@ -40,18 +54,85 @@ class MideaSelect(MideaEntity, SelectEntity): def __init__(self, device: MideaDevice, entity_key: str) -> None: """Midea select init.""" super().__init__(device, entity_key) + self._attribute_key = self._config.get("attribute", entity_key) self._options_name = self._config.get("options") + self._options_dict_name = self._config.get("options_dict") @property def options(self) -> list[str]: - """Return entity options.""" + """Available options for the entity.""" + if self._options_dict_name: + options = self._get_options_dict() + codes_by_model = self._config.get("options_codes_by_model", {}) + codes = codes_by_model.get(str(self._device.model)) or self._config.get( + "options_codes", + ) + if codes: + return [options[code] for code in codes if code in options] + return list(options.values()) return cast("list", getattr(self._device, self._options_name)) @property - def current_option(self) -> str: - """Return entity current option.""" - return cast("str", self._device.get_attribute(self._entity_key)) + def current_option(self) -> str | None: + """Currently selected option.""" + option = cast("str | None", self._device.get_attribute(self._attribute_key)) + return option if option in self.options else None + + @property + def available(self) -> bool: + """Whether the entity is available.""" + if not super().available: + return False + power_attribute = self._config.get("available_power_attribute") + return not power_attribute or bool(self._device.get_attribute(power_attribute)) def select_option(self, option: str) -> None: """Select entity option.""" - self._device.set_attribute(self._entity_key, option) + if self._config.get("set_message") == "e1_work_mode": + self._select_e1_work_mode(option) + return + self._device.set_attribute(self._attribute_key, option) + + def _get_options_dict(self) -> dict[int, str]: + """Return option dict from the backing midea-local device. + + Returns + ------- + Option labels keyed by the raw mode code. + + """ + return cast("dict[int, str]", getattr(self._device, self._options_dict_name)) + + def _select_e1_work_mode(self, option: str) -> None: + """Set dishwasher work mode via midea-local's public E1 API. + + Raises + ------ + ValueError + If the requested option is not a supported work mode. + + """ + mode = self._get_dict_key_by_value(self._get_options_dict(), option) + if mode is None: + raise ValueError(f"Unsupported dishwasher mode: {option}") + cast("MideaE1Device", self._device).set_work_mode(mode) + + @callback + def update_state(self, status: Any) -> None: # ruff:ignore[any-type] + """Update entity state.""" + super().update_state(status) + power_attribute = self._config.get("available_power_attribute") + if ( + power_attribute + and self.hass + and not self.hass.is_stopping + and (self._attribute_key in status or power_attribute in status) + ): + self.schedule_update_ha_state() + + @staticmethod + def _get_dict_key_by_value(source: dict[int, str], value: str) -> int | None: + for key, item in source.items(): + if item == value: + return key + return None diff --git a/custom_components/midea_ac_lan/sensor.py b/custom_components/midea_ac_lan/sensor.py index 4d1db3f..9648590 100644 --- a/custom_components/midea_ac_lan/sensor.py +++ b/custom_components/midea_ac_lan/sensor.py @@ -11,9 +11,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID, CONF_SENSORS, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import StateType +from midealocal.device import MideaDevice -from .const import DEVICES, DOMAIN +from .const import DEVICES, DOMAIN, supports_model from .midea_devices import MIDEA_DEVICES from .midea_entity import MideaEntity @@ -32,9 +34,24 @@ async def async_setup_entry( "dict", MIDEA_DEVICES[device.device_type]["entities"], ).items(): - if config["type"] == Platform.SENSOR and entity_key in extra_sensors: - sensor = MideaSensor(device, entity_key) - sensors.append(sensor) + if ( + config["type"] != Platform.SENSOR + or not supports_model(device.model, config) + or (not config.get("default") and entity_key not in extra_sensors) + ): + continue + required_attribute = config.get("required_attribute") + if ( + required_attribute is not None + and required_attribute not in device.attributes + ): + continue + sensor = ( + MideaEstimatedUsageSensor(device, entity_key) + if config.get("estimate") + else MideaSensor(device, entity_key) + ) + sensors.append(sensor) async_add_entities(sensors) @@ -43,25 +60,134 @@ class MideaSensor(MideaEntity, SensorEntity): @property def native_value(self) -> StateType: - """Return entity value.""" - return cast("StateType", self._device.get_attribute(self._entity_key)) + """Native value of the sensor.""" + value = self._device.get_attribute(self._entity_key) + # If an options mapping exists, translate the raw value to its enum key. + options = self._config.get("options") + if options is not None and isinstance(value, int): + # For an enum sensor an unmapped value is not a valid state, so + # report it as unknown (None) instead of leaking the raw int. + return cast("StateType", options.get(value)) + return cast("StateType", value) @property def device_class(self) -> SensorDeviceClass: - """Return device class.""" + """Device class of the sensor.""" return cast("SensorDeviceClass", self._config.get("device_class")) @property def state_class(self) -> SensorStateClass | None: - """Return state state.""" + """State class of the sensor.""" return cast("SensorStateClass | None", self._config.get("state_class")) + @property + def options(self) -> list[str] | None: + """List of possible states for an enum sensor.""" + if self.device_class != SensorDeviceClass.ENUM: + return None + options = self._config.get("options") + return list(options.values()) if options else None + @property def native_unit_of_measurement(self) -> str | None: - """Return unit of measurement.""" + """Unit of measurement for the sensor.""" return cast("str | None", self._config.get("unit")) + @property + def suggested_display_precision(self) -> int | None: + """Suggested number of decimal digits for display.""" + return cast("int | None", self._config.get("suggested_display_precision")) + @property def capability_attributes(self) -> dict[str, Any] | None: - """Return capabilities.""" + """Capability attributes of the sensor.""" + if self.options is not None: + return {"options": self.options} return {"state_class": self.state_class} if self.state_class else {} + + +class MideaEstimatedUsageSensor(MideaSensor, RestoreEntity): + """Represent estimated dishwasher usage accumulated per run. + + The dishwasher does not report actual energy/water usage, so this sensor + accumulates a fixed per-mode estimate (from the product manual) once each + time a wash run *completes*. Completion is detected by the device + ``progress`` attribute reaching ``"Complete"``; a cancelled or errored run + never reaches that state and is therefore not counted. + """ + + _COMPLETE_PROGRESS = "Complete" + _RUNNING_STATUS = "Running" + + def __init__(self, device: MideaDevice, entity_key: str) -> None: + """Initialize estimated usage sensor.""" + super().__init__(device, entity_key) + self._native_value: float = 0.0 + self._last_progress: str | None = None + self._running_mode: str | None = None + + async def async_added_to_hass(self) -> None: + """Register for device updates and restore the accumulated value.""" + await super().async_added_to_hass() + if last_state := await self.async_get_last_state(): + try: + self._native_value = float(last_state.state) + except (TypeError, ValueError): + self._native_value = 0.0 + self._last_progress = cast( + "str | None", + self._device.get_attribute("progress"), + ) + if self._device.get_attribute("status") == self._RUNNING_STATUS: + self._running_mode = cast("str | None", self._device.get_attribute("mode")) + + @property + def native_value(self) -> StateType: + """Accumulated estimated usage.""" + return round(self._native_value, 3) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Estimate metadata for the sensor.""" + estimate = cast("dict[str, Any]", self._config["estimate"]) + return { + "estimate_source": "fixed_per_wash_mode", + "known_modes": list(cast("dict[str, float]", estimate["values"]).keys()), + "last_progress": self._last_progress, + "running_mode": self._running_mode, + } + + def update_state(self, status: Any) -> None: # ruff:ignore[any-type] + """Accumulate the estimate once when a dishwasher run completes.""" + current_status = self._device.get_attribute("status") + current_progress = cast( + "str | None", + self._device.get_attribute("progress"), + ) + current_mode = cast("str | None", self._device.get_attribute("mode")) + + # Remember the mode selected while the machine is actually running so we + # can still attribute usage after it stops reporting the mode on finish. + if current_status == self._RUNNING_STATUS: + self._running_mode = current_mode + + # Count once, on the edge into the "Complete" progress state. A cancel or + # error transition never reaches "Complete", so it is not counted. + if ( + current_progress == self._COMPLETE_PROGRESS + and self._last_progress != self._COMPLETE_PROGRESS + ): + mode = self._running_mode or current_mode + values = cast("dict[str, float]", self._config["estimate"]["values"]) + if mode in values: + self._native_value += values[mode] + self._running_mode = None + + self._last_progress = current_progress + super().update_state(status) + if ( + self.hass + and not self.hass.is_stopping + and ("progress" in status or "status" in status or "mode" in status) + ): + self.schedule_update_ha_state() diff --git a/custom_components/midea_ac_lan/switch.py b/custom_components/midea_ac_lan/switch.py index ef4b6a8..7ca8c8f 100644 --- a/custom_components/midea_ac_lan/switch.py +++ b/custom_components/midea_ac_lan/switch.py @@ -38,13 +38,13 @@ class MideaSwitch(MideaEntity, ToggleEntity): @property def is_on(self) -> bool: - """Return true if switch is on.""" + """Whether the switch is on.""" return cast("bool", self._device.get_attribute(self._entity_key)) - def turn_on(self, **kwargs: Any) -> None: # noqa: ANN401, ARG002 + def turn_on(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument] """Turn on switch.""" self._device.set_attribute(attr=self._entity_key, value=True) - def turn_off(self, **kwargs: Any) -> None: # noqa: ANN401, ARG002 + def turn_off(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument] """Turn off switch.""" self._device.set_attribute(attr=self._entity_key, value=False) diff --git a/custom_components/midea_ac_lan/translations/de.json b/custom_components/midea_ac_lan/translations/de.json index caf280f..119dc4f 100644 --- a/custom_components/midea_ac_lan/translations/de.json +++ b/custom_components/midea_ac_lan/translations/de.json @@ -93,6 +93,9 @@ "cooking": { "name": "Cooking" }, + "error_code": { + "name": "Fehlercode" + }, "filter_change_reminder": { "name": "Filter Change Reminder" }, @@ -126,6 +129,9 @@ "keep_warm": { "name": "Keep Warm" }, + "leak_water": { + "name": "Leckwasser" + }, "lid_status": { "name": "Lid Status" }, @@ -138,6 +144,21 @@ "middle_compartment_preheating": { "name": "Middle Compartment Preheating" }, + "top_elec_heat": { + "name": "Elektroheizung oben" + }, + "bottom_elec_heat": { + "name": "Elektroheizung unten" + }, + "mute_effect": { + "name": "Stummschaltung aktiv" + }, + "mute_status": { + "name": "Stummschaltungsstatus" + }, + "multi_terminal": { + "name": "Multi-Terminal" + }, "oilcup_full": { "name": "Oil-cup Full" }, @@ -153,12 +174,33 @@ "rinse_aid": { "name": "Rinse Aid Shortage" }, + "rsj_stand_by": { + "name": "Standby" + }, "salt": { "name": "Salt Shortage" }, "seat_status": { "name": "Seat Status" }, + "smart_grid": { + "name": "Smart Grid" + }, + "eco": { + "name": "ECO" + }, + "maintenance_reminder": { + "name": "Wartungserinnerung" + }, + "maintain_warn": { + "name": "Wartungswarnung" + }, + "order1_effect": { + "name": "Zeitplan 1 aktiv" + }, + "order2_effect": { + "name": "Zeitplan 2 aktiv" + }, "status_dhw": { "name": "DHW status" }, @@ -206,6 +248,20 @@ }, "zone2_water_temp_mode": { "name": "Zone2 Water-temperature Mode" + }, + "microcrystal_fresh": { + "name": "Mikrokristall-Frische" + }, + "electronic_smell": { + "name": "Desodorieren und Sterilisieren" + }, + "water_pump_running": { + "name": "Wasserpumpe läuft" + } + }, + "button": { + "start": { + "name": "Start" } }, "climate": { @@ -214,11 +270,24 @@ }, "climate_zone2": { "name": "Zone2 Thermostat" + }, + "climate_key": { + "state_attributes": { + "fan_mode": { + "state": { + "silent": "Leise", + "full": "Voll" + } + } + } } }, "fan": { "fresh_air": { "name": "Fresh Air" + }, + "fresh_air_exhaust": { + "name": "Frischluft-Abluft" } }, "lock": { @@ -238,6 +307,21 @@ }, "water_temp_level": { "name": "Water Temperature Level" + }, + "fan_speed_percent": { + "name": "Lüftergeschwindigkeit (Prozent)" + }, + "vacation_days": { + "name": "Urlaubstage" + }, + "leak_water_protection_value": { + "name": "Leckschutzwert" + }, + "flushing_days": { + "name": "Regenerationstage" + }, + "water_hardness": { + "name": "Wasserhärte" } }, "select": { @@ -250,6 +334,25 @@ "fan_speed": { "name": "Fan Speed" }, + "fresh_air_exhaust_mode": { + "name": "Frischluft-Abluftgeschwindigkeit", + "state": { + "off": "Aus", + "silent": "Niedrig", + "high": "Hoch", + "full": "Voll" + } + }, + "fresh_air_mode": { + "name": "Frischluftgeschwindigkeit", + "state": { + "off": "Aus", + "low": "Niedrig", + "medium": "Mittel", + "high": "Hoch", + "full": "Voll" + } + }, "mode": { "name": "Mode" }, @@ -259,6 +362,9 @@ "oscillation_mode": { "name": "Oscillation Mode" }, + "rate_select": { + "name": "Power Rate Limit" + }, "screen_display": { "name": "Screen Display" }, @@ -268,8 +374,38 @@ "tilting_angle": { "name": "Tilting Angle" }, + "wash_mode": { + "name": "Waschmodus" + }, "water_level_set": { "name": "Water Level Setting" + }, + "wind_lr_angle": { + "name": "Luftstrom horizontal", + "state": { + "off": "Aus", + "left": "Links", + "left-mid": "Links-Mitte", + "middle": "Mitte", + "right-mid": "Rechts-Mitte", + "right": "Rechts" + } + }, + "wind_ud_angle": { + "name": "Luftstrom vertikal", + "state": { + "off": "Aus", + "up": "Oben", + "up-mid": "Oben-Mitte", + "middle": "Mitte", + "down-mid": "Unten-Mitte", + "down": "Unten" + } + } + }, + "time": { + "timing_regeneration": { + "name": "Regenerationszeit" } }, "sensor": { @@ -309,15 +445,71 @@ "dehydration_time": { "name": "dehydration time" }, + "dehydration_time_value": { + "name": "Schleuderzeitwert" + }, + "temperature": { + "name": "Temperatur" + }, + "wash_time_value": { + "name": "Waschzeitwert" + }, + "stains": { + "name": "Flecken" + }, + "dirty_degree": { + "name": "Verschmutzungsgrad" + }, "detergent": { "name": "detergent" }, + "intensity": { + "name": "Intensität" + }, + "dryness_level": { + "name": "Trocknungsgrad" + }, + "dry_temperature": { + "name": "Trocknungstemperatur" + }, + "door_warn": { + "name": "Türwarnung" + }, + "ai_switch": { + "name": "KI-Schalter" + }, + "material": { + "name": "Material" + }, + "water_box": { + "name": "Wasserbehälter" + }, "energy_consumption": { "name": "Energy Consumption" }, + "error": { + "name": "Fehler", + "state": { + "no_error": "Kein Fehler", + "e1_position_not_found": "E1 Position nicht gefunden", + "e2_photo_sensor_no_signal": "E2 Fotosensor kein Signal", + "e3_motor_not_running": "E3 Motor läuft nicht", + "e4_wrong_position": "E4 Falsche Position", + "e1_motor_fault": "E1 Motorfehler", + "e5_communication_fault": "E5 Kommunikationsfehler", + "e6_salt_sensor_fault": "E6 Salzsensorfehler", + "e7_chlorine_sterilization_fault": "E7 Chlorsterilisationsfehler" + } + }, "error_code": { "name": "Error Code" }, + "estimated_energy_consumption": { + "name": "Geschätzter Energieverbrauch" + }, + "estimated_water_consumption": { + "name": "Geschätzter Wasserverbrauch" + }, "fan_level": { "name": "Fan level" }, @@ -381,6 +573,9 @@ "keep_warm_time": { "name": "Keep Warm Time" }, + "left_salt": { + "name": "Verbleibendes Salz" + }, "middle_compartment_remaining": { "name": "Middle Compartment Remaining" }, @@ -417,6 +612,12 @@ "refrigerator_setting_temp": { "name": "Refrigerator Setting Temperature" }, + "regeneration_left_seconds": { + "name": "Regeneration verbleibende Sekunden" + }, + "remaining_days": { + "name": "Verbleibende Tage" + }, "right_flex_zone_actual_temp": { "name": "Right Flex Zone Actual Temperature" }, @@ -429,12 +630,18 @@ "rinse_level": { "name": "rinse level" }, + "salt_setting": { + "name": "Salzeinstellung" + }, "seat_temperature": { "name": "Seat Temperature" }, "soak_time": { "name": "soak time" }, + "soft_available": { + "name": "Verfügbares Weichwasser" + }, "softener": { "name": "softener" }, @@ -492,14 +699,110 @@ "water_consumption": { "name": "Wasserverbrauch" }, + "water_consumption_average": { + "name": "Durchschnittlicher Wasserverbrauch" + }, + "water_consumption_big": { + "name": "Wasserverbrauch" + }, "water_temperature": { "name": "Water Temperature" }, "water_level": { "name": "Water Level" }, + "wind": { + "name": "Wind" + }, + "typeinfo": { + "name": "Typinformation" + }, + "use_days": { + "name": "Nutzungstage" + }, + "elec_heat": { + "name": "Elektroheizung" + }, + "water_pump": { + "name": "Wasserpumpe" + }, + "four_way": { + "name": "Vierwegeventil" + }, + "back_water": { + "name": "Rücklaufwasser" + }, + "sterilize": { + "name": "Sterilisieren" + }, + "disinfection_temperature": { + "name": "Desinfektionstemperatur" + }, + "max_temperature": { + "name": "Maximale Zieltemperatur" + }, + "vacation_start_year": { + "name": "Urlaubsbeginn Jahr" + }, + "vacation_start_month": { + "name": "Urlaubsbeginn Monat" + }, + "vacation_start_day": { + "name": "Urlaubsbeginn Tag" + }, + "auto_sterilize_week": { + "name": "Auto-Sterilisieren Woche" + }, + "auto_sterilize_hour": { + "name": "Auto-Sterilisieren Stunde" + }, + "auto_sterilize_minute": { + "name": "Auto-Sterilisieren Minute" + }, "working_time": { "name": "Working Time" + }, + "humidity": { + "name": "Luftfeuchtigkeit" + }, + "variable_mode": { + "name": "Variabler Modus" + }, + "compressor_frequency": { + "name": "Kompressorfrequenz" + }, + "target_compressor_frequency": { + "name": "Ziel-Kompressorfrequenz" + }, + "compressor_current": { + "name": "Kompressorstrom" + }, + "compressor_voltage": { + "name": "Kompressorspannung" + }, + "compressor_power": { + "name": "Kompressorleistung" + }, + "indoor_coil_temperature": { + "name": "Innenraum-Wärmetauschertemperatur (T1)" + }, + "evaporator_temperature": { + "name": "Verdampfertemperatur (T2)" + }, + "outdoor_ambient_temperature": { + "name": "Außenumgebungstemperatur (T4)" + }, + "discharge_pipe_temperature": { + "name": "Druckleitungstemperatur (TP)" + }, + "indoor_fan_speed": { + "name": "Innenlüftergeschwindigkeit" + }, + "target_indoor_fan_speed": { + "name": "Ziel-Innenlüftergeschwindigkeit" + }, + "velocity": { + "name": "Durchflussgeschwindigkeit" } }, "switch": { @@ -515,6 +818,12 @@ "breezeless": { "name": "Breezeless" }, + "child_lock": { + "name": "Kindersicherung" + }, + "cl_sterilization": { + "name": "CL-Sterilisation" + }, "comfort_mode": { "name": "Comfort Mode" }, @@ -524,12 +833,24 @@ "disinfect": { "name": "Disinfect" }, + "vacation_mode": { + "name": "Urlaubsmodus" + }, "dry": { "name": "Dry" }, + "cold_water_single": { + "name": "Kaltwasser einzeln" + }, + "cold_water_dot": { + "name": "Kaltwasser Punkt" + }, "eco_mode": { "name": "ECO Mode" }, + "memory": { + "name": "Memo U" + }, "fast_dhw": { "name": "Fast DHW" }, @@ -545,6 +866,9 @@ "indirect_wind": { "name": "Indirect Wind" }, + "leak_water_protection": { + "name": "Leckschutz" + }, "light": { "name": "Licht" }, @@ -575,9 +899,15 @@ "prompt_tone": { "name": "Prompt Tone" }, + "regeneration": { + "name": "Regeneration" + }, "water_pump": { "name": "Wasserpumpe" }, + "water_way": { + "name": "Wasserweg" + }, "screen_display": { "name": "Screen Display" }, @@ -593,6 +923,9 @@ "sleep_mode": { "name": "Sleep Mode" }, + "out_silent": { + "name": "Außengerät Leisemodus" + }, "smart_eye": { "name": "Smart Eye" }, @@ -602,12 +935,18 @@ "smelly_sensor": { "name": "Geruchssensor" }, + "soften": { + "name": "Enthärtung" + }, "standby": { "name": "Standby" }, "start": { "name": "Start" }, + "sterilization": { + "name": "Sterilisation" + }, "storage": { "name": "Storage" }, diff --git a/custom_components/midea_ac_lan/translations/en.json b/custom_components/midea_ac_lan/translations/en.json index 8751a3f..d33740d 100644 --- a/custom_components/midea_ac_lan/translations/en.json +++ b/custom_components/midea_ac_lan/translations/en.json @@ -93,6 +93,9 @@ "cooking": { "name": "Cooking" }, + "error_code": { + "name": "Error Code" + }, "filter_change_reminder": { "name": "Filter Change Reminder" }, @@ -126,6 +129,9 @@ "keep_warm": { "name": "Keep Warm" }, + "leak_water": { + "name": "Leak Water" + }, "lid_status": { "name": "Lid Status" }, @@ -168,6 +174,9 @@ "rinse_aid": { "name": "Rinse Aid Shortage" }, + "rsj_stand_by": { + "name": "Stand By" + }, "salt": { "name": "Salt Shortage" }, @@ -237,11 +246,22 @@ "zone2_room_temp_mode": { "name": "Zone2 Room-temperature Mode" }, + "zone2_water_temp_mode": { + "name": "Zone2 Water-temperature Mode" + }, "microcrystal_fresh": { "name": "Microcrystal Fresh" }, "electronic_smell": { "name": "Deodorizing sterilizing" + }, + "water_pump_running": { + "name": "Water Pump Running" + } + }, + "button": { + "start": { + "name": "Start" } }, "climate": { @@ -265,6 +285,9 @@ "fan": { "fresh_air": { "name": "Fresh Air" + }, + "fresh_air_exhaust": { + "name": "Fresh Air Exhaust" } }, "lock": { @@ -290,6 +313,15 @@ }, "vacation_days": { "name": "Vacation Days" + }, + "leak_water_protection_value": { + "name": "Leak Water Protection Value" + }, + "flushing_days": { + "name": "Regeneration Days" + }, + "water_hardness": { + "name": "Water Hardness" } }, "select": { @@ -302,6 +334,25 @@ "fan_speed": { "name": "Fan Speed" }, + "fresh_air_exhaust_mode": { + "name": "Fresh Air Exhaust Speed", + "state": { + "off": "Off", + "silent": "Low", + "high": "High", + "full": "Full" + } + }, + "fresh_air_mode": { + "name": "Fresh Air Speed", + "state": { + "off": "Off", + "low": "Low", + "medium": "Medium", + "high": "High", + "full": "Full" + } + }, "mode": { "name": "Mode" }, @@ -311,6 +362,9 @@ "oscillation_mode": { "name": "Oscillation Mode" }, + "rate_select": { + "name": "Power Rate Limit" + }, "screen_display": { "name": "Screen Display" }, @@ -320,6 +374,9 @@ "tilting_angle": { "name": "Tilting Angle" }, + "wash_mode": { + "name": "Wash Mode" + }, "water_level_set": { "name": "Water Level Setting" }, @@ -346,6 +403,11 @@ } } }, + "time": { + "timing_regeneration": { + "name": "Timing Regeneration" + } + }, "sensor": { "bathing_leaving_temperature": { "name": "Bathing Leaving Water Temperature" @@ -386,6 +448,9 @@ "dehydration_time_value": { "name": "Dehydration time value" }, + "temperature": { + "name": "Temperature" + }, "wash_time_value": { "name": "Wash time value" }, @@ -422,9 +487,29 @@ "energy_consumption": { "name": "Energy Consumption" }, + "error": { + "name": "Error", + "state": { + "no_error": "No Error", + "e1_position_not_found": "E1 Position not found", + "e2_photo_sensor_no_signal": "E2 Photo sensor no signal", + "e3_motor_not_running": "E3 Motor not running", + "e4_wrong_position": "E4 Wrong position", + "e1_motor_fault": "E1 Motor fault", + "e5_communication_fault": "E5 Communication fault", + "e6_salt_sensor_fault": "E6 Salt sensor fault", + "e7_chlorine_sterilization_fault": "E7 Chlorine sterilization fault" + } + }, "error_code": { "name": "Error Code" }, + "estimated_energy_consumption": { + "name": "Estimated Energy Consumption" + }, + "estimated_water_consumption": { + "name": "Estimated Water Consumption" + }, "fan_level": { "name": "Fan level" }, @@ -488,6 +573,9 @@ "keep_warm_time": { "name": "Keep Warm Time" }, + "left_salt": { + "name": "Left Salt" + }, "middle_compartment_remaining": { "name": "Middle Compartment Remaining" }, @@ -524,6 +612,12 @@ "refrigerator_setting_temp": { "name": "Refrigerator Setting Temperature" }, + "regeneration_left_seconds": { + "name": "Regeneration Left Seconds" + }, + "remaining_days": { + "name": "Remaining Days" + }, "right_flex_zone_actual_temp": { "name": "Right Flex Zone Actual Temperature" }, @@ -536,12 +630,18 @@ "rinse_level": { "name": "Rinse level" }, + "salt_setting": { + "name": "Salt Setting" + }, "seat_temperature": { "name": "Seat Temperature" }, "soak_time": { "name": "Soak time" }, + "soft_available": { + "name": "Soft Water Available" + }, "softener": { "name": "Softener" }, @@ -587,6 +687,9 @@ "tvoc": { "name": "TVOC" }, + "wash_level": { + "name": "Rinse count" + }, "wash_strength": { "name": "Wash strength" }, @@ -596,6 +699,12 @@ "water_consumption": { "name": "Water Consumption" }, + "water_consumption_average": { + "name": "Water Consumption Average" + }, + "water_consumption_big": { + "name": "Water Consumption" + }, "water_temperature": { "name": "Water Temperature" }, @@ -608,6 +717,9 @@ "typeinfo": { "name": "Type Info" }, + "use_days": { + "name": "Use Days" + }, "elec_heat": { "name": "Electric Heat" }, @@ -655,6 +767,42 @@ }, "variable_mode": { "name": "Variable Mode" + }, + "compressor_frequency": { + "name": "Compressor Frequency" + }, + "target_compressor_frequency": { + "name": "Target Compressor Frequency" + }, + "compressor_current": { + "name": "Compressor Current" + }, + "compressor_voltage": { + "name": "Compressor Voltage" + }, + "compressor_power": { + "name": "Compressor Power" + }, + "indoor_coil_temperature": { + "name": "Indoor Coil Temperature (T1)" + }, + "evaporator_temperature": { + "name": "Evaporator Temperature (T2)" + }, + "outdoor_ambient_temperature": { + "name": "Outdoor Ambient Temperature (T4)" + }, + "discharge_pipe_temperature": { + "name": "Discharge Pipe Temperature (TP)" + }, + "indoor_fan_speed": { + "name": "Indoor Fan Speed" + }, + "target_indoor_fan_speed": { + "name": "Target Indoor Fan Speed" + }, + "velocity": { + "name": "Velocity" } }, "switch": { @@ -670,6 +818,12 @@ "breezeless": { "name": "Breezeless" }, + "child_lock": { + "name": "Child Lock" + }, + "cl_sterilization": { + "name": "CL Sterilization" + }, "comfort_mode": { "name": "Comfort Mode" }, @@ -694,6 +848,9 @@ "eco_mode": { "name": "ECO Mode" }, + "memory": { + "name": "Memo U" + }, "fast_dhw": { "name": "Fast DHW" }, @@ -709,6 +866,9 @@ "indirect_wind": { "name": "Indirect Wind" }, + "leak_water_protection": { + "name": "Leak Water Protection" + }, "light": { "name": "Light" }, @@ -739,9 +899,15 @@ "prompt_tone": { "name": "Prompt Tone" }, + "regeneration": { + "name": "Regeneration" + }, "water_pump": { "name": "Water Pump" }, + "water_way": { + "name": "Water Way" + }, "screen_display": { "name": "Screen Display" }, @@ -769,12 +935,18 @@ "smelly_sensor": { "name": "Smelly Sensor" }, + "soften": { + "name": "Softening" + }, "standby": { "name": "Standby" }, "start": { "name": "Start" }, + "sterilization": { + "name": "Sterilization" + }, "storage": { "name": "Storage" }, diff --git a/custom_components/midea_ac_lan/translations/es.json b/custom_components/midea_ac_lan/translations/es.json index a8ef2cb..4759631 100644 --- a/custom_components/midea_ac_lan/translations/es.json +++ b/custom_components/midea_ac_lan/translations/es.json @@ -93,6 +93,9 @@ "cooking": { "name": "Cocinando" }, + "error_code": { + "name": "Código de error" + }, "filter_change_reminder": { "name": "Recordatorio de cambio de filtro" }, @@ -126,6 +129,9 @@ "keep_warm": { "name": "Mantener caliente" }, + "leak_water": { + "name": "Fuga de agua" + }, "lid_status": { "name": "Estado de la tapa" }, @@ -138,6 +144,21 @@ "middle_compartment_preheating": { "name": "Precalentamiento del compartimento medio" }, + "top_elec_heat": { + "name": "Calefacción eléctrica superior" + }, + "bottom_elec_heat": { + "name": "Calefacción eléctrica inferior" + }, + "mute_effect": { + "name": "Efecto de silencio" + }, + "mute_status": { + "name": "Estado de silencio" + }, + "multi_terminal": { + "name": "Terminal múltiple" + }, "oilcup_full": { "name": "Depósito de aceite lleno" }, @@ -153,12 +174,33 @@ "rinse_aid": { "name": "Escasez de abrillantador" }, + "rsj_stand_by": { + "name": "En espera" + }, "salt": { "name": "Escasez de sal" }, "seat_status": { "name": "Estado del asiento" }, + "smart_grid": { + "name": "Red inteligente" + }, + "eco": { + "name": "ECO" + }, + "maintenance_reminder": { + "name": "Recordatorio de mantenimiento" + }, + "maintain_warn": { + "name": "Advertencia de mantenimiento" + }, + "order1_effect": { + "name": "Programación 1 activa" + }, + "order2_effect": { + "name": "Programación 2 activa" + }, "status_dhw": { "name": "Estado ACS" }, @@ -204,11 +246,22 @@ "zone2_room_temp_mode": { "name": "Zona 2 Modo temperatura ambiente" }, + "zone2_water_temp_mode": { + "name": "Zona 2 Modo temperatura del agua" + }, "microcrystal_fresh": { "name": "Frescura microcristalina" }, "electronic_smell": { "name": "Esterilización desodorizante" + }, + "water_pump_running": { + "name": "Bomba de agua en funcionamiento" + } + }, + "button": { + "start": { + "name": "Iniciar" } }, "climate": { @@ -232,6 +285,9 @@ "fan": { "fresh_air": { "name": "Aire fresco" + }, + "fresh_air_exhaust": { + "name": "Extracción de aire fresco" } }, "lock": { @@ -254,6 +310,18 @@ }, "fan_speed_percent": { "name": "Porcentaje de velocidad del ventilador" + }, + "vacation_days": { + "name": "Días de vacaciones" + }, + "leak_water_protection_value": { + "name": "Valor de protección contra fugas" + }, + "flushing_days": { + "name": "Días de regeneración" + }, + "water_hardness": { + "name": "Dureza del agua" } }, "select": { @@ -266,6 +334,25 @@ "fan_speed": { "name": "Velocidad del ventilador" }, + "fresh_air_exhaust_mode": { + "name": "Velocidad de extracción de aire fresco", + "state": { + "off": "Apagado", + "silent": "Baja", + "high": "Alta", + "full": "Máximo" + } + }, + "fresh_air_mode": { + "name": "Velocidad de aire fresco", + "state": { + "off": "Apagado", + "low": "Baja", + "medium": "Media", + "high": "Alta", + "full": "Máximo" + } + }, "mode": { "name": "Modo" }, @@ -275,6 +362,9 @@ "oscillation_mode": { "name": "Modo de oscilación" }, + "rate_select": { + "name": "Límite de tasa de potencia" + }, "screen_display": { "name": "Visualización de pantalla" }, @@ -284,6 +374,9 @@ "tilting_angle": { "name": "Ángulo de inclinación" }, + "wash_mode": { + "name": "Modo de lavado" + }, "water_level_set": { "name": "Ajuste de nivel de agua" }, @@ -310,6 +403,11 @@ } } }, + "time": { + "timing_regeneration": { + "name": "Regeneración programada" + } + }, "sensor": { "bathing_leaving_temperature": { "name": "Temperatura de salida del agua de baño" @@ -350,6 +448,9 @@ "dehydration_time_value": { "name": "Valor del tiempo de centrifugado" }, + "temperature": { + "name": "Temperatura" + }, "wash_time_value": { "name": "Valor del tiempo de lavado" }, @@ -386,9 +487,29 @@ "energy_consumption": { "name": "Consumo de energía" }, + "error": { + "name": "Error", + "state": { + "no_error": "Sin error", + "e1_position_not_found": "E1 Posición no encontrada", + "e2_photo_sensor_no_signal": "E2 Sensor foto sin señal", + "e3_motor_not_running": "E3 Motor no funciona", + "e4_wrong_position": "E4 Posición incorrecta", + "e1_motor_fault": "E1 Fallo del motor", + "e5_communication_fault": "E5 Fallo de comunicación", + "e6_salt_sensor_fault": "E6 Fallo sensor de sal", + "e7_chlorine_sterilization_fault": "E7 Fallo esterilización cloro" + } + }, "error_code": { "name": "Código de error" }, + "estimated_energy_consumption": { + "name": "Consumo estimado de energía" + }, + "estimated_water_consumption": { + "name": "Consumo estimado de agua" + }, "fan_level": { "name": "Nivel del ventilador" }, @@ -452,6 +573,9 @@ "keep_warm_time": { "name": "Tiempo de mantener caliente" }, + "left_salt": { + "name": "Sal restante" + }, "middle_compartment_remaining": { "name": "Tiempo restante compartimento medio" }, @@ -488,6 +612,12 @@ "refrigerator_setting_temp": { "name": "Temperatura establecida del frigorífico" }, + "regeneration_left_seconds": { + "name": "Segundos restantes de regeneración" + }, + "remaining_days": { + "name": "Días restantes" + }, "right_flex_zone_actual_temp": { "name": "Temperatura real de zona flexible derecha" }, @@ -500,12 +630,18 @@ "rinse_level": { "name": "Nivel de aclarado" }, + "salt_setting": { + "name": "Configuración de sal" + }, "seat_temperature": { "name": "Temperatura del asiento" }, "soak_time": { "name": "Tiempo de remojo" }, + "soft_available": { + "name": "Agua suave disponible" + }, "softener": { "name": "Suavizante" }, @@ -551,6 +687,9 @@ "tvoc": { "name": "COVT" }, + "wash_level": { + "name": "Recuento de aclarados" + }, "wash_strength": { "name": "Potencia de lavado" }, @@ -560,12 +699,66 @@ "water_consumption": { "name": "Consumo de agua" }, + "water_consumption_average": { + "name": "Consumo promedio de agua" + }, + "water_consumption_big": { + "name": "Consumo de agua" + }, "water_temperature": { "name": "Temperatura del agua" }, "water_level": { "name": "Nivel de agua" }, + "wind": { + "name": "Viento" + }, + "typeinfo": { + "name": "Información de tipo" + }, + "use_days": { + "name": "Días de uso" + }, + "elec_heat": { + "name": "Calefacción eléctrica" + }, + "water_pump": { + "name": "Bomba de agua" + }, + "four_way": { + "name": "Válvula de cuatro vías" + }, + "back_water": { + "name": "Retorno de agua" + }, + "sterilize": { + "name": "Esterilizar" + }, + "disinfection_temperature": { + "name": "Temperatura de desinfección" + }, + "max_temperature": { + "name": "Temperatura objetivo máxima" + }, + "vacation_start_year": { + "name": "Año de inicio de vacaciones" + }, + "vacation_start_month": { + "name": "Mes de inicio de vacaciones" + }, + "vacation_start_day": { + "name": "Día de inicio de vacaciones" + }, + "auto_sterilize_week": { + "name": "Semana de esterilización automática" + }, + "auto_sterilize_hour": { + "name": "Hora de esterilización automática" + }, + "auto_sterilize_minute": { + "name": "Minuto de esterilización automática" + }, "working_time": { "name": "Tiempo de funcionamiento" }, @@ -574,6 +767,42 @@ }, "variable_mode": { "name": "Modo variable" + }, + "compressor_frequency": { + "name": "Frecuencia del compresor" + }, + "target_compressor_frequency": { + "name": "Frecuencia objetivo del compresor" + }, + "compressor_current": { + "name": "Corriente del compresor" + }, + "compressor_voltage": { + "name": "Voltaje del compresor" + }, + "compressor_power": { + "name": "Potencia del compresor" + }, + "indoor_coil_temperature": { + "name": "Temperatura del serpentín interior (T1)" + }, + "evaporator_temperature": { + "name": "Temperatura del evaporador (T2)" + }, + "outdoor_ambient_temperature": { + "name": "Temperatura ambiente exterior (T4)" + }, + "discharge_pipe_temperature": { + "name": "Temperatura del tubo de descarga (TP)" + }, + "indoor_fan_speed": { + "name": "Velocidad del ventilador interior" + }, + "target_indoor_fan_speed": { + "name": "Velocidad objetivo del ventilador interior" + }, + "velocity": { + "name": "Velocidad" } }, "switch": { @@ -589,6 +818,12 @@ "breezeless": { "name": "Sin corrientes" }, + "child_lock": { + "name": "Bloqueo infantil" + }, + "cl_sterilization": { + "name": "Esterilización CL" + }, "comfort_mode": { "name": "Modo confort" }, @@ -598,6 +833,9 @@ "disinfect": { "name": "Desinfectar" }, + "vacation_mode": { + "name": "Modo vacaciones" + }, "dry": { "name": "Secado" }, @@ -610,6 +848,9 @@ "eco_mode": { "name": "Modo ECO" }, + "memory": { + "name": "Memo U" + }, "fast_dhw": { "name": "ACS Rápido" }, @@ -625,6 +866,9 @@ "indirect_wind": { "name": "Viento indirecto" }, + "leak_water_protection": { + "name": "Protección contra fugas" + }, "light": { "name": "Luz" }, @@ -655,9 +899,15 @@ "prompt_tone": { "name": "Tono de aviso" }, + "regeneration": { + "name": "Regeneración" + }, "water_pump": { "name": "Bomba de agua" }, + "water_way": { + "name": "Vía de agua" + }, "screen_display": { "name": "Visualización de pantalla" }, @@ -673,6 +923,9 @@ "sleep_mode": { "name": "Modo sueño" }, + "out_silent": { + "name": "Modo silencioso exterior" + }, "smart_eye": { "name": "Ojo inteligente" }, @@ -682,12 +935,18 @@ "smelly_sensor": { "name": "Sensor de olores" }, + "soften": { + "name": "Suavizado" + }, "standby": { "name": "En espera" }, "start": { "name": "Iniciar" }, + "sterilization": { + "name": "Esterilización" + }, "storage": { "name": "Almacenamiento" }, diff --git a/custom_components/midea_ac_lan/translations/fr.json b/custom_components/midea_ac_lan/translations/fr.json index 94b2b23..381e96a 100644 --- a/custom_components/midea_ac_lan/translations/fr.json +++ b/custom_components/midea_ac_lan/translations/fr.json @@ -93,6 +93,9 @@ "cooking": { "name": "Cooking" }, + "error_code": { + "name": "Code d'erreur" + }, "filter_change_reminder": { "name": "Filter Change Reminder" }, @@ -126,6 +129,9 @@ "keep_warm": { "name": "Keep Warm" }, + "leak_water": { + "name": "Fuite d'eau" + }, "lid_status": { "name": "Lid Status" }, @@ -138,6 +144,21 @@ "middle_compartment_preheating": { "name": "Middle Compartment Preheating" }, + "top_elec_heat": { + "name": "Chauffage électrique supérieur" + }, + "bottom_elec_heat": { + "name": "Chauffage électrique inférieur" + }, + "mute_effect": { + "name": "Effet silencieux" + }, + "mute_status": { + "name": "État silencieux" + }, + "multi_terminal": { + "name": "Multi-terminal" + }, "oilcup_full": { "name": "Oil-cup Full" }, @@ -153,12 +174,33 @@ "rinse_aid": { "name": "Rinse Aid Shortage" }, + "rsj_stand_by": { + "name": "Veille" + }, "salt": { "name": "Salt Shortage" }, "seat_status": { "name": "Seat Status" }, + "smart_grid": { + "name": "Réseau intelligent" + }, + "eco": { + "name": "ECO" + }, + "maintenance_reminder": { + "name": "Rappel d'entretien" + }, + "maintain_warn": { + "name": "Avertissement d'entretien" + }, + "order1_effect": { + "name": "Programmation 1 active" + }, + "order2_effect": { + "name": "Programmation 2 active" + }, "status_dhw": { "name": "DHW status" }, @@ -206,6 +248,20 @@ }, "zone2_water_temp_mode": { "name": "Zone2 Water-temperature Mode" + }, + "microcrystal_fresh": { + "name": "Fraîcheur microcristal" + }, + "electronic_smell": { + "name": "Désodorisation stérilisation" + }, + "water_pump_running": { + "name": "Pompe à eau en marche" + } + }, + "button": { + "start": { + "name": "Démarrer" } }, "climate": { @@ -214,11 +270,24 @@ }, "climate_zone2": { "name": "Zone2 Thermostat" + }, + "climate_key": { + "state_attributes": { + "fan_mode": { + "state": { + "silent": "Silencieux", + "full": "Maximum" + } + } + } } }, "fan": { "fresh_air": { "name": "Fresh Air" + }, + "fresh_air_exhaust": { + "name": "Extraction d'air frais" } }, "lock": { @@ -238,6 +307,21 @@ }, "water_temp_level": { "name": "Water Temperature Level" + }, + "fan_speed_percent": { + "name": "Pourcentage de vitesse du ventilateur" + }, + "vacation_days": { + "name": "Jours de vacances" + }, + "leak_water_protection_value": { + "name": "Valeur de protection contre les fuites" + }, + "flushing_days": { + "name": "Jours de régénération" + }, + "water_hardness": { + "name": "Dureté de l'eau" } }, "select": { @@ -250,6 +334,25 @@ "fan_speed": { "name": "Fan Speed" }, + "fresh_air_exhaust_mode": { + "name": "Vitesse d'extraction d'air frais", + "state": { + "off": "Arrêt", + "silent": "Faible", + "high": "Élevée", + "full": "Maximum" + } + }, + "fresh_air_mode": { + "name": "Vitesse d'air frais", + "state": { + "off": "Arrêt", + "low": "Faible", + "medium": "Moyenne", + "high": "Élevée", + "full": "Maximum" + } + }, "mode": { "name": "Mode" }, @@ -259,6 +362,9 @@ "oscillation_mode": { "name": "Oscillation Mode" }, + "rate_select": { + "name": "Power Rate Limit" + }, "screen_display": { "name": "Screen Display" }, @@ -268,8 +374,38 @@ "tilting_angle": { "name": "Tilting Angle" }, + "wash_mode": { + "name": "Mode de lavage" + }, "water_level_set": { "name": "Water Level Setting" + }, + "wind_lr_angle": { + "name": "Flux d'air horizontal", + "state": { + "off": "Arrêt", + "left": "Gauche", + "left-mid": "Centre-gauche", + "middle": "Centre", + "right-mid": "Centre-droite", + "right": "Droite" + } + }, + "wind_ud_angle": { + "name": "Flux d'air vertical", + "state": { + "off": "Arrêt", + "up": "Haut", + "up-mid": "Centre-haut", + "middle": "Centre", + "down-mid": "Centre-bas", + "down": "Bas" + } + } + }, + "time": { + "timing_regeneration": { + "name": "Régénération programmée" } }, "sensor": { @@ -309,15 +445,71 @@ "dehydration_time": { "name": "dehydration time" }, + "dehydration_time_value": { + "name": "Valeur du temps d'essorage" + }, + "temperature": { + "name": "Température" + }, + "wash_time_value": { + "name": "Valeur du temps de lavage" + }, + "stains": { + "name": "Taches" + }, + "dirty_degree": { + "name": "Degré de saleté" + }, "detergent": { "name": "detergent" }, + "intensity": { + "name": "Intensité" + }, + "dryness_level": { + "name": "Niveau de séchage" + }, + "dry_temperature": { + "name": "Température de séchage" + }, + "door_warn": { + "name": "Alerte de porte" + }, + "ai_switch": { + "name": "Interrupteur IA" + }, + "material": { + "name": "Matériau" + }, + "water_box": { + "name": "Réservoir d'eau" + }, "energy_consumption": { "name": "Energy Consumption" }, + "error": { + "name": "Erreur", + "state": { + "no_error": "Aucune erreur", + "e1_position_not_found": "E1 Position introuvable", + "e2_photo_sensor_no_signal": "E2 Capteur photo sans signal", + "e3_motor_not_running": "E3 Moteur ne tourne pas", + "e4_wrong_position": "E4 Position incorrecte", + "e1_motor_fault": "E1 Panne moteur", + "e5_communication_fault": "E5 Panne de communication", + "e6_salt_sensor_fault": "E6 Panne capteur de sel", + "e7_chlorine_sterilization_fault": "E7 Panne stérilisation chlore" + } + }, "error_code": { "name": "Error Code" }, + "estimated_energy_consumption": { + "name": "Consommation d'énergie estimée" + }, + "estimated_water_consumption": { + "name": "Consommation d'eau estimée" + }, "fan_level": { "name": "Fan level" }, @@ -381,6 +573,9 @@ "keep_warm_time": { "name": "Keep Warm Time" }, + "left_salt": { + "name": "Sel restant" + }, "middle_compartment_remaining": { "name": "Middle Compartment Remaining" }, @@ -409,7 +604,7 @@ "name": "Realtime Power" }, "pmv": { - "name": "PMV" + "name": "Indice PMV" }, "refrigerator_actual_temp": { "name": "Refrigerator Actual Temperature" @@ -417,6 +612,12 @@ "refrigerator_setting_temp": { "name": "Refrigerator Setting Temperature" }, + "regeneration_left_seconds": { + "name": "Secondes restantes de régénération" + }, + "remaining_days": { + "name": "Jours restants" + }, "right_flex_zone_actual_temp": { "name": "Right Flex Zone Actual Temperature" }, @@ -429,12 +630,18 @@ "rinse_level": { "name": "rinse level" }, + "salt_setting": { + "name": "Réglage du sel" + }, "seat_temperature": { "name": "Seat Temperature" }, "soak_time": { "name": "soak time" }, + "soft_available": { + "name": "Eau douce disponible" + }, "softener": { "name": "softener" }, @@ -492,14 +699,110 @@ "water_consumption": { "name": "Water Consumption" }, + "water_consumption_average": { + "name": "Consommation moyenne d'eau" + }, + "water_consumption_big": { + "name": "Consommation d'eau" + }, "water_temperature": { "name": "Water Temperature" }, "water_level": { "name": "Water Level" }, + "wind": { + "name": "Vent" + }, + "typeinfo": { + "name": "Informations de type" + }, + "use_days": { + "name": "Jours d'utilisation" + }, + "elec_heat": { + "name": "Chauffage électrique" + }, + "water_pump": { + "name": "Pompe à eau" + }, + "four_way": { + "name": "Vanne quatre voies" + }, + "back_water": { + "name": "Retour d'eau" + }, + "sterilize": { + "name": "Stériliser" + }, + "disinfection_temperature": { + "name": "Température de désinfection" + }, + "max_temperature": { + "name": "Température cible maximale" + }, + "vacation_start_year": { + "name": "Année de début des vacances" + }, + "vacation_start_month": { + "name": "Mois de début des vacances" + }, + "vacation_start_day": { + "name": "Jour de début des vacances" + }, + "auto_sterilize_week": { + "name": "Semaine de stérilisation automatique" + }, + "auto_sterilize_hour": { + "name": "Heure de stérilisation automatique" + }, + "auto_sterilize_minute": { + "name": "Minute de stérilisation automatique" + }, "working_time": { "name": "Working Time" + }, + "humidity": { + "name": "Humidité" + }, + "variable_mode": { + "name": "Mode variable" + }, + "compressor_frequency": { + "name": "Fréquence du compresseur" + }, + "target_compressor_frequency": { + "name": "Fréquence cible du compresseur" + }, + "compressor_current": { + "name": "Courant du compresseur" + }, + "compressor_voltage": { + "name": "Tension du compresseur" + }, + "compressor_power": { + "name": "Puissance du compresseur" + }, + "indoor_coil_temperature": { + "name": "Température de la batterie intérieure (T1)" + }, + "evaporator_temperature": { + "name": "Température de l'évaporateur (T2)" + }, + "outdoor_ambient_temperature": { + "name": "Température ambiante extérieure (T4)" + }, + "discharge_pipe_temperature": { + "name": "Température du tuyau de refoulement (TP)" + }, + "indoor_fan_speed": { + "name": "Vitesse du ventilateur intérieur" + }, + "target_indoor_fan_speed": { + "name": "Vitesse cible du ventilateur intérieur" + }, + "velocity": { + "name": "Vitesse" } }, "switch": { @@ -515,6 +818,12 @@ "breezeless": { "name": "Breezeless" }, + "child_lock": { + "name": "Verrouillage enfant" + }, + "cl_sterilization": { + "name": "Stérilisation CL" + }, "comfort_mode": { "name": "Comfort Mode" }, @@ -524,12 +833,24 @@ "disinfect": { "name": "Disinfect" }, + "vacation_mode": { + "name": "Mode vacances" + }, "dry": { "name": "Dry" }, + "cold_water_single": { + "name": "Eau froide simple" + }, + "cold_water_dot": { + "name": "Point d'eau froide" + }, "eco_mode": { "name": "ECO Mode" }, + "memory": { + "name": "Mémo U" + }, "fast_dhw": { "name": "Fast DHW" }, @@ -545,6 +866,9 @@ "indirect_wind": { "name": "Indirect Wind" }, + "leak_water_protection": { + "name": "Protection contre les fuites" + }, "light": { "name": "Light" }, @@ -575,9 +899,15 @@ "prompt_tone": { "name": "Prompt Tone" }, + "regeneration": { + "name": "Régénération" + }, "water_pump": { "name": "Pompe à eau" }, + "water_way": { + "name": "Voie d'eau" + }, "screen_display": { "name": "Screen Display" }, @@ -593,6 +923,9 @@ "sleep_mode": { "name": "Sleep Mode" }, + "out_silent": { + "name": "Mode silencieux extérieur" + }, "smart_eye": { "name": "Smart Eye" }, @@ -602,17 +935,23 @@ "smelly_sensor": { "name": "Smelly Sensor" }, + "soften": { + "name": "Adoucissement" + }, "standby": { "name": "Standby" }, "start": { "name": "Start" }, + "sterilization": { + "name": "Stérilisation" + }, "storage": { "name": "Storage" }, "swing": { - "name": "swing" + "name": "Swing" }, "swing_horizontal": { "name": "Swing Horizontal" @@ -621,10 +960,10 @@ "name": "Swing Vertical" }, "self_clean": { - "name": "Self Clean" + "name": "Auto-nettoyage" }, "sound": { - "name": "Sound" + "name": "Son" }, "tbh": { "name": "TBH" diff --git a/custom_components/midea_ac_lan/translations/hu.json b/custom_components/midea_ac_lan/translations/hu.json index 404b158..9c6265e 100644 --- a/custom_components/midea_ac_lan/translations/hu.json +++ b/custom_components/midea_ac_lan/translations/hu.json @@ -93,6 +93,9 @@ "cooking": { "name": "Cooking" }, + "error_code": { + "name": "Hibakód" + }, "filter_change_reminder": { "name": "Filter Change Reminder" }, @@ -126,6 +129,9 @@ "keep_warm": { "name": "Keep Warm" }, + "leak_water": { + "name": "Vízszivárgás" + }, "lid_status": { "name": "Lid Status" }, @@ -138,6 +144,21 @@ "middle_compartment_preheating": { "name": "Middle Compartment Preheating" }, + "top_elec_heat": { + "name": "Felső elektromos fűtés" + }, + "bottom_elec_heat": { + "name": "Alsó elektromos fűtés" + }, + "mute_effect": { + "name": "Némítás hatása" + }, + "mute_status": { + "name": "Némítás állapota" + }, + "multi_terminal": { + "name": "Több terminál" + }, "oilcup_full": { "name": "Oil-cup Full" }, @@ -153,12 +174,33 @@ "rinse_aid": { "name": "Rinse Aid Shortage" }, + "rsj_stand_by": { + "name": "Készenlét" + }, "salt": { "name": "Salt Shortage" }, "seat_status": { "name": "Seat Status" }, + "smart_grid": { + "name": "Okos hálózat" + }, + "eco": { + "name": "ECO" + }, + "maintenance_reminder": { + "name": "Karbantartási emlékeztető" + }, + "maintain_warn": { + "name": "Karbantartási figyelmeztetés" + }, + "order1_effect": { + "name": "1. ütemezés aktív" + }, + "order2_effect": { + "name": "2. ütemezés aktív" + }, "status_dhw": { "name": "DHW status" }, @@ -206,6 +248,20 @@ }, "zone2_water_temp_mode": { "name": "Zone2 Water-temperature Mode" + }, + "microcrystal_fresh": { + "name": "Mikrokristályos frissítés" + }, + "electronic_smell": { + "name": "Szagtalanító sterilizálás" + }, + "water_pump_running": { + "name": "Vízszivattyú működik" + } + }, + "button": { + "start": { + "name": "Indítás" } }, "climate": { @@ -214,11 +270,24 @@ }, "climate_zone2": { "name": "Zone2 Thermostat" + }, + "climate_key": { + "state_attributes": { + "fan_mode": { + "state": { + "silent": "Csendes", + "full": "Teljes" + } + } + } } }, "fan": { "fresh_air": { "name": "Fresh Air" + }, + "fresh_air_exhaust": { + "name": "Frisslevegő elszívás" } }, "lock": { @@ -238,6 +307,21 @@ }, "water_temp_level": { "name": "Water Temperature Level" + }, + "fan_speed_percent": { + "name": "Ventilátor sebesség százalék" + }, + "vacation_days": { + "name": "Vakáció napok" + }, + "leak_water_protection_value": { + "name": "Vízszivárgás védelem értéke" + }, + "flushing_days": { + "name": "Regenerálási napok" + }, + "water_hardness": { + "name": "Víz keménység" } }, "select": { @@ -250,6 +334,25 @@ "fan_speed": { "name": "Fan Speed" }, + "fresh_air_exhaust_mode": { + "name": "Frisslevegő elszívás sebessége", + "state": { + "off": "Ki", + "silent": "Alacsony", + "high": "Magas", + "full": "Teljes" + } + }, + "fresh_air_mode": { + "name": "Frisslevegő sebessége", + "state": { + "off": "Ki", + "low": "Alacsony", + "medium": "Közepes", + "high": "Magas", + "full": "Teljes" + } + }, "mode": { "name": "Mode" }, @@ -259,6 +362,9 @@ "oscillation_mode": { "name": "Oscillation Mode" }, + "rate_select": { + "name": "Power Rate Limit" + }, "screen_display": { "name": "Screen Display" }, @@ -268,8 +374,38 @@ "tilting_angle": { "name": "Tilting Angle" }, + "wash_mode": { + "name": "Mosási mód" + }, "water_level_set": { "name": "Water Level Setting" + }, + "wind_lr_angle": { + "name": "Légáramlás vízszintes", + "state": { + "off": "Ki", + "left": "Bal", + "left-mid": "Bal-közép", + "middle": "Közép", + "right-mid": "Jobb-közép", + "right": "Jobb" + } + }, + "wind_ud_angle": { + "name": "Légáramlás függőleges", + "state": { + "off": "Ki", + "up": "Fel", + "up-mid": "Fel-közép", + "middle": "Közép", + "down-mid": "Le-közép", + "down": "Le" + } + } + }, + "time": { + "timing_regeneration": { + "name": "Időzített regenerálás" } }, "sensor": { @@ -309,15 +445,71 @@ "dehydration_time": { "name": "dehydration time" }, + "dehydration_time_value": { + "name": "Víztelenítési idő értéke" + }, + "temperature": { + "name": "Hőmérséklet" + }, + "wash_time_value": { + "name": "Mosási idő értéke" + }, + "stains": { + "name": "Foltok" + }, + "dirty_degree": { + "name": "Szennyezettség mértéke" + }, "detergent": { "name": "detergent" }, + "intensity": { + "name": "Intenzitás" + }, + "dryness_level": { + "name": "Szárazsági szint" + }, + "dry_temperature": { + "name": "Szárítási hőmérséklet" + }, + "door_warn": { + "name": "Ajtó figyelmeztetés" + }, + "ai_switch": { + "name": "AI kapcsoló" + }, + "material": { + "name": "Anyag" + }, + "water_box": { + "name": "Víztartály" + }, "energy_consumption": { "name": "Energy Consumption" }, + "error": { + "name": "Hiba", + "state": { + "no_error": "Nincs hiba", + "e1_position_not_found": "E1 Pozíció nem található", + "e2_photo_sensor_no_signal": "E2 Fotóérzékelő nincs jel", + "e3_motor_not_running": "E3 Motor nem fut", + "e4_wrong_position": "E4 Hibás pozíció", + "e1_motor_fault": "E1 Motor hiba", + "e5_communication_fault": "E5 Kommunikációs hiba", + "e6_salt_sensor_fault": "E6 Sóérzékelő hiba", + "e7_chlorine_sterilization_fault": "E7 Klór sterilizációs hiba" + } + }, "error_code": { "name": "Error Code" }, + "estimated_energy_consumption": { + "name": "Becsült energiafogyasztás" + }, + "estimated_water_consumption": { + "name": "Becsült vízfogyasztás" + }, "fan_level": { "name": "Fan level" }, @@ -381,6 +573,9 @@ "keep_warm_time": { "name": "Keep Warm Time" }, + "left_salt": { + "name": "Maradék só" + }, "middle_compartment_remaining": { "name": "Middle Compartment Remaining" }, @@ -417,6 +612,12 @@ "refrigerator_setting_temp": { "name": "Refrigerator Setting Temperature" }, + "regeneration_left_seconds": { + "name": "Regenerálás hátralévő másodperc" + }, + "remaining_days": { + "name": "Hátralévő napok" + }, "right_flex_zone_actual_temp": { "name": "Right Flex Zone Actual Temperature" }, @@ -429,12 +630,18 @@ "rinse_level": { "name": "rinse level" }, + "salt_setting": { + "name": "Só beállítása" + }, "seat_temperature": { "name": "Seat Temperature" }, "soak_time": { "name": "soak time" }, + "soft_available": { + "name": "Elérhető lágy víz" + }, "softener": { "name": "softener" }, @@ -492,14 +699,110 @@ "water_consumption": { "name": "Water Consumption" }, + "water_consumption_average": { + "name": "Átlagos vízfogyasztás" + }, + "water_consumption_big": { + "name": "Vízfogyasztás" + }, "water_temperature": { "name": "Water Temperature" }, "water_level": { "name": "Water Level" }, + "wind": { + "name": "Szél" + }, + "typeinfo": { + "name": "Típusinformáció" + }, + "use_days": { + "name": "Használati napok" + }, + "elec_heat": { + "name": "Elektromos fűtés" + }, + "water_pump": { + "name": "Vízszivattyú" + }, + "four_way": { + "name": "Négyjáratú szelep" + }, + "back_water": { + "name": "Visszatérő víz" + }, + "sterilize": { + "name": "Sterilizálás" + }, + "disinfection_temperature": { + "name": "Fertőtlenítési hőmérséklet" + }, + "max_temperature": { + "name": "Maximális cél hőmérséklet" + }, + "vacation_start_year": { + "name": "Vakáció kezdő év" + }, + "vacation_start_month": { + "name": "Vakáció kezdő hónap" + }, + "vacation_start_day": { + "name": "Vakáció kezdő nap" + }, + "auto_sterilize_week": { + "name": "Automatikus sterilizálás hét" + }, + "auto_sterilize_hour": { + "name": "Automatikus sterilizálás óra" + }, + "auto_sterilize_minute": { + "name": "Automatikus sterilizálás perc" + }, "working_time": { "name": "Working Time" + }, + "humidity": { + "name": "Páratartalom" + }, + "variable_mode": { + "name": "Változó mód" + }, + "compressor_frequency": { + "name": "Kompresszor frekvencia" + }, + "target_compressor_frequency": { + "name": "Cél kompresszor frekvencia" + }, + "compressor_current": { + "name": "Kompresszor áramerősség" + }, + "compressor_voltage": { + "name": "Kompresszor feszültség" + }, + "compressor_power": { + "name": "Kompresszor teljesítmény" + }, + "indoor_coil_temperature": { + "name": "Beltéri hőcserélő hőmérséklet (T1)" + }, + "evaporator_temperature": { + "name": "Párologtató hőmérséklet (T2)" + }, + "outdoor_ambient_temperature": { + "name": "Kültéri környezeti hőmérséklet (T4)" + }, + "discharge_pipe_temperature": { + "name": "Nyomócső hőmérséklet (TP)" + }, + "indoor_fan_speed": { + "name": "Beltéri ventilátor sebesség" + }, + "target_indoor_fan_speed": { + "name": "Cél beltéri ventilátor sebesség" + }, + "velocity": { + "name": "Sebesség" } }, "switch": { @@ -515,6 +818,12 @@ "breezeless": { "name": "Breezeless" }, + "child_lock": { + "name": "Gyermekzár" + }, + "cl_sterilization": { + "name": "CL sterilizálás" + }, "comfort_mode": { "name": "Comfort Mode" }, @@ -524,12 +833,24 @@ "disinfect": { "name": "Disinfect" }, + "vacation_mode": { + "name": "Vakáció mód" + }, "dry": { "name": "Dry" }, + "cold_water_single": { + "name": "Hideg víz egyszeri" + }, + "cold_water_dot": { + "name": "Hideg víz pont" + }, "eco_mode": { "name": "ECO Mode" }, + "memory": { + "name": "Memo U" + }, "fast_dhw": { "name": "Fast DHW" }, @@ -545,6 +866,9 @@ "indirect_wind": { "name": "Indirect Wind" }, + "leak_water_protection": { + "name": "Védelem a vízszivárgás ellen" + }, "light": { "name": "Light" }, @@ -575,9 +899,15 @@ "prompt_tone": { "name": "Prompt Tone" }, + "regeneration": { + "name": "Regenerálás" + }, "water_pump": { "name": "Vízszivattyú" }, + "water_way": { + "name": "Vízi út" + }, "screen_display": { "name": "Screen Display" }, @@ -593,6 +923,9 @@ "sleep_mode": { "name": "Sleep Mode" }, + "out_silent": { + "name": "Kültéri csendes mód" + }, "smart_eye": { "name": "Smart Eye" }, @@ -602,12 +935,18 @@ "smelly_sensor": { "name": "Smelly Sensor" }, + "soften": { + "name": "Lágyítás" + }, "standby": { "name": "Standby" }, "start": { "name": "Start" }, + "sterilization": { + "name": "Sterilizálás" + }, "storage": { "name": "Storage" }, diff --git a/custom_components/midea_ac_lan/translations/ru.json b/custom_components/midea_ac_lan/translations/ru.json index 846f7d6..309a974 100644 --- a/custom_components/midea_ac_lan/translations/ru.json +++ b/custom_components/midea_ac_lan/translations/ru.json @@ -93,6 +93,9 @@ "cooking": { "name": "Приготовление" }, + "error_code": { + "name": "Код ошибки" + }, "filter_change_reminder": { "name": "Требуется замена фильтра" }, @@ -126,6 +129,9 @@ "keep_warm": { "name": "Поддержание температуры" }, + "leak_water": { + "name": "Протечка воды" + }, "lid_status": { "name": "Крышка" }, @@ -138,6 +144,21 @@ "middle_compartment_preheating": { "name": "Middle Compartment Preheating" }, + "top_elec_heat": { + "name": "Верхний электронагрев" + }, + "bottom_elec_heat": { + "name": "Нижний электронагрев" + }, + "mute_effect": { + "name": "Эффект тихого режима" + }, + "mute_status": { + "name": "Статус тихого режима" + }, + "multi_terminal": { + "name": "Мультитерминал" + }, "oilcup_full": { "name": "Oil-cup Full" }, @@ -153,12 +174,33 @@ "rinse_aid": { "name": "Нехватка ополаскивателя" }, + "rsj_stand_by": { + "name": "Ожидание" + }, "salt": { "name": "Соль" }, "seat_status": { "name": "Сиденье" }, + "smart_grid": { + "name": "Умная сеть" + }, + "eco": { + "name": "ECO" + }, + "maintenance_reminder": { + "name": "Напоминание об обслуживании" + }, + "maintain_warn": { + "name": "Предупреждение об обслуживании" + }, + "order1_effect": { + "name": "Расписание 1 активно" + }, + "order2_effect": { + "name": "Расписание 2 активно" + }, "status_dhw": { "name": "DHW status" }, @@ -206,6 +248,20 @@ }, "zone2_water_temp_mode": { "name": "Zone2 Water-temperature Mode" + }, + "microcrystal_fresh": { + "name": "Микрокристаллическая свежесть" + }, + "electronic_smell": { + "name": "Дезодорация и стерилизация" + }, + "water_pump_running": { + "name": "Работа водяного насоса" + } + }, + "button": { + "start": { + "name": "Старт" } }, "climate": { @@ -214,11 +270,24 @@ }, "climate_zone2": { "name": "Zone2 Thermostat" + }, + "climate_key": { + "state_attributes": { + "fan_mode": { + "state": { + "silent": "Тихий", + "full": "Полная" + } + } + } } }, "fan": { "fresh_air": { "name": "Fresh Air" + }, + "fresh_air_exhaust": { + "name": "Вытяжка свежего воздуха" } }, "lock": { @@ -238,6 +307,21 @@ }, "water_temp_level": { "name": "Water Temperature Level" + }, + "fan_speed_percent": { + "name": "Скорость вентилятора (%)" + }, + "vacation_days": { + "name": "Дни отпуска" + }, + "leak_water_protection_value": { + "name": "Значение защиты от протечек" + }, + "flushing_days": { + "name": "Дни регенерации" + }, + "water_hardness": { + "name": "Жёсткость воды" } }, "select": { @@ -250,6 +334,25 @@ "fan_speed": { "name": "Скорость" }, + "fresh_air_exhaust_mode": { + "name": "Скорость вытяжки свежего воздуха", + "state": { + "off": "Выкл", + "silent": "Низкая", + "high": "Высокая", + "full": "Максимальная" + } + }, + "fresh_air_mode": { + "name": "Скорость свежего воздуха", + "state": { + "off": "Выкл", + "low": "Низкая", + "medium": "Средняя", + "high": "Высокая", + "full": "Максимальная" + } + }, "mode": { "name": "Режим" }, @@ -259,6 +362,9 @@ "oscillation_mode": { "name": "Oscillation Mode" }, + "rate_select": { + "name": "Ограничение мощности" + }, "screen_display": { "name": "Screen Display" }, @@ -268,8 +374,38 @@ "tilting_angle": { "name": "Угол поворота" }, + "wash_mode": { + "name": "Режим мойки" + }, "water_level_set": { "name": "Water Level Setting" + }, + "wind_lr_angle": { + "name": "Горизонтальный поток воздуха", + "state": { + "off": "Выкл", + "left": "Влево", + "left-mid": "Влево-центр", + "middle": "Центр", + "right-mid": "Вправо-центр", + "right": "Вправо" + } + }, + "wind_ud_angle": { + "name": "Вертикальный поток воздуха", + "state": { + "off": "Выкл", + "up": "Вверх", + "up-mid": "Вверх-центр", + "middle": "Центр", + "down-mid": "Вниз-центр", + "down": "Вниз" + } + } + }, + "time": { + "timing_regeneration": { + "name": "Регенерация по таймеру" } }, "sensor": { @@ -312,6 +448,9 @@ "dehydration_time_value": { "name": "Dehydration time value" }, + "temperature": { + "name": "Температура" + }, "wash_time_value": { "name": "Wash time value" }, @@ -348,9 +487,29 @@ "energy_consumption": { "name": "Энергопотребление" }, + "error": { + "name": "Ошибка", + "state": { + "no_error": "Нет ошибок", + "e1_position_not_found": "E1 Позиция не найдена", + "e2_photo_sensor_no_signal": "E2 Датчик не даёт сигнал", + "e3_motor_not_running": "E3 Двигатель не работает", + "e4_wrong_position": "E4 Неправильная позиция", + "e1_motor_fault": "E1 Неисправность двигателя", + "e5_communication_fault": "E5 Ошибка связи", + "e6_salt_sensor_fault": "E6 Неисправность датчика соли", + "e7_chlorine_sterilization_fault": "E7 Неисправность хлорной стерилизации" + } + }, "error_code": { "name": "Код ошибки" }, + "estimated_energy_consumption": { + "name": "Расчётное энергопотребление" + }, + "estimated_water_consumption": { + "name": "Расчётный расход воды" + }, "fan_level": { "name": "Fan level" }, @@ -414,6 +573,9 @@ "keep_warm_time": { "name": "Keep Warm Time" }, + "left_salt": { + "name": "Остаток соли" + }, "middle_compartment_remaining": { "name": "Middle Compartment Remaining" }, @@ -450,6 +612,12 @@ "refrigerator_setting_temp": { "name": "Refrigerator Setting Temperature" }, + "regeneration_left_seconds": { + "name": "Оставшиеся секунды регенерации" + }, + "remaining_days": { + "name": "Оставшиеся дни" + }, "right_flex_zone_actual_temp": { "name": "Right Flex Zone Actual Temperature" }, @@ -462,12 +630,18 @@ "rinse_level": { "name": "Уровень ополаскивателя" }, + "salt_setting": { + "name": "Настройка соли" + }, "seat_temperature": { "name": "Температура сиденья" }, "soak_time": { "name": "Время замачивания" }, + "soft_available": { + "name": "Доступная мягкая вода" + }, "softener": { "name": "Умягчитель" }, @@ -513,6 +687,9 @@ "tvoc": { "name": "TVOC" }, + "wash_level": { + "name": "Количество полосканий" + }, "wash_strength": { "name": "Wash strength" }, @@ -522,14 +699,110 @@ "water_consumption": { "name": "Water Consumption" }, + "water_consumption_average": { + "name": "Средний расход воды" + }, + "water_consumption_big": { + "name": "Потребление воды" + }, "water_temperature": { "name": "Температура воды" }, "water_level": { "name": "Уровень воды" }, + "wind": { + "name": "Поток воздуха" + }, + "typeinfo": { + "name": "Информация о типе" + }, + "use_days": { + "name": "Дни использования" + }, + "elec_heat": { + "name": "Электронагрев" + }, + "water_pump": { + "name": "Водяной насос" + }, + "four_way": { + "name": "Четырёхходовой клапан" + }, + "back_water": { + "name": "Обратная вода" + }, + "sterilize": { + "name": "Стерилизация" + }, + "disinfection_temperature": { + "name": "Температура дезинфекции" + }, + "max_temperature": { + "name": "Максимальная целевая температура" + }, + "vacation_start_year": { + "name": "Год начала отпуска" + }, + "vacation_start_month": { + "name": "Месяц начала отпуска" + }, + "vacation_start_day": { + "name": "День начала отпуска" + }, + "auto_sterilize_week": { + "name": "День недели автостерилизации" + }, + "auto_sterilize_hour": { + "name": "Час автостерилизации" + }, + "auto_sterilize_minute": { + "name": "Минута автостерилизации" + }, "working_time": { "name": "Время работы" + }, + "humidity": { + "name": "Влажность" + }, + "variable_mode": { + "name": "Переменный режим" + }, + "compressor_frequency": { + "name": "Частота компрессора" + }, + "target_compressor_frequency": { + "name": "Целевая частота компрессора" + }, + "compressor_current": { + "name": "Ток компрессора" + }, + "compressor_voltage": { + "name": "Напряжение компрессора" + }, + "compressor_power": { + "name": "Мощность компрессора" + }, + "indoor_coil_temperature": { + "name": "Температура внутреннего теплообменника (T1)" + }, + "evaporator_temperature": { + "name": "Температура испарителя (T2)" + }, + "outdoor_ambient_temperature": { + "name": "Наружная температура окружающей среды (T4)" + }, + "discharge_pipe_temperature": { + "name": "Температура нагнетательной трубы (TP)" + }, + "indoor_fan_speed": { + "name": "Скорость внутреннего вентилятора" + }, + "target_indoor_fan_speed": { + "name": "Целевая скорость внутреннего вентилятора" + }, + "velocity": { + "name": "Скорость" } }, "switch": { @@ -545,6 +818,12 @@ "breezeless": { "name": "Breezeless" }, + "child_lock": { + "name": "Блокировка от детей" + }, + "cl_sterilization": { + "name": "CL-стерилизация" + }, "comfort_mode": { "name": "Comfort Mode" }, @@ -554,12 +833,24 @@ "disinfect": { "name": "Дезинфекция" }, + "vacation_mode": { + "name": "Режим отпуска" + }, "dry": { "name": "Dry" }, + "cold_water_single": { + "name": "Холодная вода (однократно)" + }, + "cold_water_dot": { + "name": "Холодная вода (порционно)" + }, "eco_mode": { "name": "Эко-режим" }, + "memory": { + "name": "Memo U" + }, "fast_dhw": { "name": "Fast DHW" }, @@ -575,6 +866,9 @@ "indirect_wind": { "name": "Indirect Wind" }, + "leak_water_protection": { + "name": "Защита от протечек" + }, "light": { "name": "Свет" }, @@ -605,9 +899,15 @@ "prompt_tone": { "name": "Prompt Tone" }, + "regeneration": { + "name": "Регенерация" + }, "water_pump": { "name": "Водяной насос" }, + "water_way": { + "name": "Водный путь" + }, "screen_display": { "name": "Screen Display" }, @@ -623,6 +923,9 @@ "sleep_mode": { "name": "Sleep Mode" }, + "out_silent": { + "name": "Тихий режим наружного блока" + }, "smart_eye": { "name": "Smart Eye" }, @@ -632,12 +935,18 @@ "smelly_sensor": { "name": "Smelly Sensor" }, + "soften": { + "name": "Умягчение" + }, "standby": { "name": "Ожидание" }, "start": { "name": "Старт" }, + "sterilization": { + "name": "Стерилизация" + }, "storage": { "name": "Storage" }, diff --git a/custom_components/midea_ac_lan/translations/sk.json b/custom_components/midea_ac_lan/translations/sk.json index 066adcc..8b074a9 100644 --- a/custom_components/midea_ac_lan/translations/sk.json +++ b/custom_components/midea_ac_lan/translations/sk.json @@ -93,6 +93,9 @@ "cooking": { "name": "Cooking" }, + "error_code": { + "name": "Kód chyby" + }, "filter_change_reminder": { "name": "Filter Change Reminder" }, @@ -126,6 +129,9 @@ "keep_warm": { "name": "Keep Warm" }, + "leak_water": { + "name": "Únik vody" + }, "lid_status": { "name": "Lid Status" }, @@ -138,6 +144,21 @@ "middle_compartment_preheating": { "name": "Middle Compartment Preheating" }, + "top_elec_heat": { + "name": "Horné elektrické vykurovanie" + }, + "bottom_elec_heat": { + "name": "Dolné elektrické vykurovanie" + }, + "mute_effect": { + "name": "Účinok stíšenia" + }, + "mute_status": { + "name": "Stav stíšenia" + }, + "multi_terminal": { + "name": "Viacero terminálov" + }, "oilcup_full": { "name": "Oil-cup Full" }, @@ -153,12 +174,33 @@ "rinse_aid": { "name": "Rinse Aid Shortage" }, + "rsj_stand_by": { + "name": "Pohotovostný režim" + }, "salt": { "name": "Salt Shortage" }, "seat_status": { "name": "Seat Status" }, + "smart_grid": { + "name": "Inteligentná sieť" + }, + "eco": { + "name": "ECO" + }, + "maintenance_reminder": { + "name": "Pripomienka údržby" + }, + "maintain_warn": { + "name": "Upozornenie na údržbu" + }, + "order1_effect": { + "name": "Plán 1 aktívny" + }, + "order2_effect": { + "name": "Plán 2 aktívny" + }, "status_dhw": { "name": "DHW status" }, @@ -206,6 +248,20 @@ }, "zone2_water_temp_mode": { "name": "Zone2 Water-temperature Mode" + }, + "microcrystal_fresh": { + "name": "Mikrokryštalická sviežosť" + }, + "electronic_smell": { + "name": "Dezodorácia a sterilizácia" + }, + "water_pump_running": { + "name": "Vodné čerpadlo beží" + } + }, + "button": { + "start": { + "name": "Štart" } }, "climate": { @@ -214,11 +270,24 @@ }, "climate_zone2": { "name": "Zone2 Thermostat" + }, + "climate_key": { + "state_attributes": { + "fan_mode": { + "state": { + "silent": "Tichý", + "full": "Plný" + } + } + } } }, "fan": { "fresh_air": { "name": "Fresh Air" + }, + "fresh_air_exhaust": { + "name": "Odvod čerstvého vzduchu" } }, "lock": { @@ -238,6 +307,21 @@ }, "water_temp_level": { "name": "Water Temperature Level" + }, + "fan_speed_percent": { + "name": "Percento rýchlosti ventilátora" + }, + "vacation_days": { + "name": "Dni dovolenky" + }, + "leak_water_protection_value": { + "name": "Hodnota ochrany proti úniku vody" + }, + "flushing_days": { + "name": "Dni regenerácie" + }, + "water_hardness": { + "name": "Tvrdosť vody" } }, "select": { @@ -250,6 +334,25 @@ "fan_speed": { "name": "Fan Speed" }, + "fresh_air_exhaust_mode": { + "name": "Rýchlosť odvodu čerstvého vzduchu", + "state": { + "off": "Vypnuté", + "silent": "Nízka", + "high": "Vysoká", + "full": "Plná" + } + }, + "fresh_air_mode": { + "name": "Rýchlosť čerstvého vzduchu", + "state": { + "off": "Vypnuté", + "low": "Nízka", + "medium": "Stredná", + "high": "Vysoká", + "full": "Plná" + } + }, "mode": { "name": "Mode" }, @@ -259,6 +362,9 @@ "oscillation_mode": { "name": "Oscillation Mode" }, + "rate_select": { + "name": "Power Rate Limit" + }, "screen_display": { "name": "Screen Display" }, @@ -268,8 +374,38 @@ "tilting_angle": { "name": "Tilting Angle" }, + "wash_mode": { + "name": "Režim prania" + }, "water_level_set": { "name": "Water Level Setting" + }, + "wind_lr_angle": { + "name": "Vodorovné prúdenie vzduchu", + "state": { + "off": "Vypnuté", + "left": "Vľavo", + "left-mid": "Vľavo-stred", + "middle": "Stred", + "right-mid": "Vpravo-stred", + "right": "Vpravo" + } + }, + "wind_ud_angle": { + "name": "Zvislé prúdenie vzduchu", + "state": { + "off": "Vypnuté", + "up": "Hore", + "up-mid": "Hore-stred", + "middle": "Stred", + "down-mid": "Dole-stred", + "down": "Dole" + } + } + }, + "time": { + "timing_regeneration": { + "name": "Časovaná regenerácia" } }, "sensor": { @@ -309,15 +445,71 @@ "dehydration_time": { "name": "dehydration time" }, + "dehydration_time_value": { + "name": "Hodnota času odstreďovania" + }, + "temperature": { + "name": "Teplota" + }, + "wash_time_value": { + "name": "Hodnota času prania" + }, + "stains": { + "name": "Škvrny" + }, + "dirty_degree": { + "name": "Stupeň znečistenia" + }, "detergent": { "name": "detergent" }, + "intensity": { + "name": "Intenzita" + }, + "dryness_level": { + "name": "Úroveň suchosti" + }, + "dry_temperature": { + "name": "Teplota sušenia" + }, + "door_warn": { + "name": "Upozornenie na dvere" + }, + "ai_switch": { + "name": "AI prepínač" + }, + "material": { + "name": "Materiál" + }, + "water_box": { + "name": "Nádržka na vodu" + }, "energy_consumption": { "name": "Energy Consumption" }, + "error": { + "name": "Chyba", + "state": { + "no_error": "Bez chyby", + "e1_position_not_found": "E1 Pozícia nenájdená", + "e2_photo_sensor_no_signal": "E2 Fotosenzor bez signálu", + "e3_motor_not_running": "E3 Motor nebeží", + "e4_wrong_position": "E4 Nesprávna pozícia", + "e1_motor_fault": "E1 Porucha motora", + "e5_communication_fault": "E5 Porucha komunikácie", + "e6_salt_sensor_fault": "E6 Porucha senzora soli", + "e7_chlorine_sterilization_fault": "E7 Porucha chlórovej sterilizácie" + } + }, "error_code": { "name": "Error Code" }, + "estimated_energy_consumption": { + "name": "Odhadovaná spotreba energie" + }, + "estimated_water_consumption": { + "name": "Odhadovaná spotreba vody" + }, "fan_level": { "name": "Fan level" }, @@ -381,6 +573,9 @@ "keep_warm_time": { "name": "Keep Warm Time" }, + "left_salt": { + "name": "Zvyšná soľ" + }, "middle_compartment_remaining": { "name": "Middle Compartment Remaining" }, @@ -417,6 +612,12 @@ "refrigerator_setting_temp": { "name": "Refrigerator Setting Temperature" }, + "regeneration_left_seconds": { + "name": "Zostávajúce sekundy regenerácie" + }, + "remaining_days": { + "name": "Zostávajúce dni" + }, "right_flex_zone_actual_temp": { "name": "Right Flex Zone Actual Temperature" }, @@ -429,12 +630,18 @@ "rinse_level": { "name": "rinse level" }, + "salt_setting": { + "name": "Nastavenie soli" + }, "seat_temperature": { "name": "Seat Temperature" }, "soak_time": { "name": "soak time" }, + "soft_available": { + "name": "Dostupná mäkká voda" + }, "softener": { "name": "softener" }, @@ -492,14 +699,110 @@ "water_consumption": { "name": "Water Consumption" }, + "water_consumption_average": { + "name": "Priemerná spotreba vody" + }, + "water_consumption_big": { + "name": "Spotreba vody" + }, "water_temperature": { "name": "Water Temperature" }, "water_level": { "name": "Water Level" }, + "wind": { + "name": "Vietor" + }, + "typeinfo": { + "name": "Informácie o type" + }, + "use_days": { + "name": "Dni používania" + }, + "elec_heat": { + "name": "Elektrické vykurovanie" + }, + "water_pump": { + "name": "Vodné čerpadlo" + }, + "four_way": { + "name": "Štvorcestný ventil" + }, + "back_water": { + "name": "Spätná voda" + }, + "sterilize": { + "name": "Sterilizácia" + }, + "disinfection_temperature": { + "name": "Teplota dezinfekcie" + }, + "max_temperature": { + "name": "Maximálna cieľová teplota" + }, + "vacation_start_year": { + "name": "Rok začiatku dovolenky" + }, + "vacation_start_month": { + "name": "Mesiac začiatku dovolenky" + }, + "vacation_start_day": { + "name": "Deň začiatku dovolenky" + }, + "auto_sterilize_week": { + "name": "Týždeň automatickej sterilizácie" + }, + "auto_sterilize_hour": { + "name": "Hodina automatickej sterilizácie" + }, + "auto_sterilize_minute": { + "name": "Minúta automatickej sterilizácie" + }, "working_time": { "name": "Working Time" + }, + "humidity": { + "name": "Vlhkosť" + }, + "variable_mode": { + "name": "Variabilný režim" + }, + "compressor_frequency": { + "name": "Frekvencia kompresora" + }, + "target_compressor_frequency": { + "name": "Cieľová frekvencia kompresora" + }, + "compressor_current": { + "name": "Prúd kompresora" + }, + "compressor_voltage": { + "name": "Napätie kompresora" + }, + "compressor_power": { + "name": "Výkon kompresora" + }, + "indoor_coil_temperature": { + "name": "Teplota vnútorného výmenníka (T1)" + }, + "evaporator_temperature": { + "name": "Teplota výparníka (T2)" + }, + "outdoor_ambient_temperature": { + "name": "Vonkajšia teplota okolia (T4)" + }, + "discharge_pipe_temperature": { + "name": "Teplota výtlačného potrubia (TP)" + }, + "indoor_fan_speed": { + "name": "Rýchlosť vnútorného ventilátora" + }, + "target_indoor_fan_speed": { + "name": "Cieľová rýchlosť vnútorného ventilátora" + }, + "velocity": { + "name": "Rýchlosť" } }, "switch": { @@ -515,6 +818,12 @@ "breezeless": { "name": "Breezeless" }, + "child_lock": { + "name": "Detský zámok" + }, + "cl_sterilization": { + "name": "CL sterilizácia" + }, "comfort_mode": { "name": "Comfort Mode" }, @@ -524,12 +833,24 @@ "disinfect": { "name": "Disinfect" }, + "vacation_mode": { + "name": "Režim dovolenky" + }, "dry": { "name": "Dry" }, + "cold_water_single": { + "name": "Studená voda jednorazovo" + }, + "cold_water_dot": { + "name": "Studená voda bodovo" + }, "eco_mode": { "name": "ECO Mode" }, + "memory": { + "name": "Memo U" + }, "fast_dhw": { "name": "Fast DHW" }, @@ -545,6 +866,9 @@ "indirect_wind": { "name": "Indirect Wind" }, + "leak_water_protection": { + "name": "Ochrana proti úniku vody" + }, "light": { "name": "Light" }, @@ -575,9 +899,15 @@ "prompt_tone": { "name": "Prompt Tone" }, + "regeneration": { + "name": "Regenerácia" + }, "water_pump": { "name": "Vodné čerpadlo" }, + "water_way": { + "name": "Vodná cesta" + }, "screen_display": { "name": "Screen Display" }, @@ -593,6 +923,9 @@ "sleep_mode": { "name": "Sleep Mode" }, + "out_silent": { + "name": "Vonkajší tichý režim" + }, "smart_eye": { "name": "Smart Eye" }, @@ -602,12 +935,18 @@ "smelly_sensor": { "name": "Smelly Sensor" }, + "soften": { + "name": "Zmäkčovanie" + }, "standby": { "name": "Standby" }, "start": { "name": "Start" }, + "sterilization": { + "name": "Sterilizácia" + }, "storage": { "name": "Storage" }, diff --git a/custom_components/midea_ac_lan/translations/zh-Hans.json b/custom_components/midea_ac_lan/translations/zh-Hans.json index 8743f2f..1087a0c 100644 --- a/custom_components/midea_ac_lan/translations/zh-Hans.json +++ b/custom_components/midea_ac_lan/translations/zh-Hans.json @@ -93,6 +93,9 @@ "cooking": { "name": "烹饪中" }, + "error_code": { + "name": "错误代码" + }, "filter_change_reminder": { "name": "滤芯更换提醒" }, @@ -126,6 +129,9 @@ "keep_warm": { "name": "保温" }, + "leak_water": { + "name": "漏水报警" + }, "lid_status": { "name": "盖子状态" }, @@ -138,6 +144,21 @@ "middle_compartment_preheating": { "name": "中层预热" }, + "top_elec_heat": { + "name": "顶部电加热" + }, + "bottom_elec_heat": { + "name": "底部电加热" + }, + "mute_effect": { + "name": "静音效果" + }, + "mute_status": { + "name": "静音状态" + }, + "multi_terminal": { + "name": "多终端" + }, "oilcup_full": { "name": "油杯满提示" }, @@ -153,12 +174,33 @@ "rinse_aid": { "name": "漂洗剂不足" }, + "rsj_stand_by": { + "name": "待机状态" + }, "salt": { "name": "软水盐不足" }, "seat_status": { "name": "入座状态" }, + "smart_grid": { + "name": "智能电网" + }, + "eco": { + "name": "ECO" + }, + "maintenance_reminder": { + "name": "维护提醒" + }, + "maintain_warn": { + "name": "维护警告" + }, + "order1_effect": { + "name": "定时1生效" + }, + "order2_effect": { + "name": "定时2生效" + }, "status_dhw": { "name": "DHW状态" }, @@ -212,6 +254,14 @@ }, "electronic_smell": { "name": "净味杀菌" + }, + "water_pump_running": { + "name": "水泵运行" + } + }, + "button": { + "start": { + "name": "启动" } }, "climate": { @@ -235,6 +285,9 @@ "fan": { "fresh_air": { "name": "新风" + }, + "fresh_air_exhaust": { + "name": "排风" } }, "lock": { @@ -257,6 +310,18 @@ }, "fan_speed_percent": { "name": "风速百分比" + }, + "vacation_days": { + "name": "假期天数" + }, + "leak_water_protection_value": { + "name": "漏水保护阈值" + }, + "flushing_days": { + "name": "再生天数" + }, + "water_hardness": { + "name": "进水硬度" } }, "select": { @@ -269,6 +334,25 @@ "fan_speed": { "name": "风速设定" }, + "fresh_air_exhaust_mode": { + "name": "排风风速", + "state": { + "off": "关闭", + "silent": "低速", + "high": "高速", + "full": "强劲" + } + }, + "fresh_air_mode": { + "name": "新风风速", + "state": { + "off": "关闭", + "low": "低速", + "medium": "中速", + "high": "高速", + "full": "强劲" + } + }, "mode": { "name": "模式" }, @@ -278,6 +362,9 @@ "oscillation_mode": { "name": "水平摆头模式" }, + "rate_select": { + "name": "功率限制" + }, "screen_display": { "name": "屏幕显示" }, @@ -287,6 +374,9 @@ "tilting_angle": { "name": "垂直摆头角度" }, + "wash_mode": { + "name": "洗涤模式" + }, "water_level_set": { "name": "水位设定" }, @@ -313,6 +403,11 @@ } } }, + "time": { + "timing_regeneration": { + "name": "定时再生时间" + } + }, "sensor": { "bathing_leaving_temperature": { "name": "淋浴出水温度" @@ -353,6 +448,9 @@ "dehydration_time_value": { "name": "脱水时间值" }, + "temperature": { + "name": "目标温度" + }, "wash_time_value": { "name": "洗涤时间值" }, @@ -362,6 +460,9 @@ "dirty_degree": { "name": "脏度" }, + "detergent": { + "name": "洗涤剂" + }, "intensity": { "name": "强度" }, @@ -386,9 +487,29 @@ "energy_consumption": { "name": "能耗" }, + "error": { + "name": "错误", + "state": { + "no_error": "无故障", + "e1_position_not_found": "E1 一直找不到工作位", + "e2_photo_sensor_no_signal": "E2 光感没有发出信号", + "e3_motor_not_running": "E3 电机不转", + "e4_wrong_position": "E4 工作位不正确", + "e1_motor_fault": "E1 电机故障", + "e5_communication_fault": "E5 通信故障", + "e6_salt_sensor_fault": "E6 盐位传感器故障", + "e7_chlorine_sterilization_fault": "E7 余氯杀菌故障" + } + }, "error_code": { "name": "错误码" }, + "estimated_energy_consumption": { + "name": "预估能耗" + }, + "estimated_water_consumption": { + "name": "预估水耗" + }, "fan_level": { "name": "当前风扇档位" }, @@ -452,6 +573,9 @@ "keep_warm_time": { "name": "保温时间" }, + "left_salt": { + "name": "剩余盐量" + }, "middle_compartment_remaining": { "name": "中层剩余时间" }, @@ -488,6 +612,12 @@ "refrigerator_setting_temp": { "name": "冷藏室设置温度" }, + "regeneration_left_seconds": { + "name": "再生剩余秒数" + }, + "remaining_days": { + "name": "剩余天数" + }, "right_flex_zone_actual_temp": { "name": "右变温区实际温度" }, @@ -500,12 +630,18 @@ "rinse_level": { "name": "漂洗档位" }, + "salt_setting": { + "name": "盐量设置" + }, "seat_temperature": { "name": "当前座温" }, "soak_time": { "name": "浸泡时间" }, + "soft_available": { + "name": "软化水可用量" + }, "softener": { "name": "柔顺剂" }, @@ -560,17 +696,68 @@ "wash_time": { "name": "洗涤时间档位" }, - "detergent": { - "name": "洗涤剂" - }, "water_consumption": { "name": "总耗水量" }, + "water_consumption_average": { + "name": "平均耗水量" + }, + "water_consumption_big": { + "name": "总耗水量" + }, + "water_temperature": { + "name": "水温" + }, "water_level": { "name": "水位" }, - "water_temperature": { - "name": "水温" + "wind": { + "name": "风" + }, + "typeinfo": { + "name": "类型信息" + }, + "use_days": { + "name": "使用天数" + }, + "elec_heat": { + "name": "电加热" + }, + "water_pump": { + "name": "水泵" + }, + "four_way": { + "name": "四通阀" + }, + "back_water": { + "name": "回水" + }, + "sterilize": { + "name": "杀菌" + }, + "disinfection_temperature": { + "name": "消毒温度" + }, + "max_temperature": { + "name": "最高目标温度" + }, + "vacation_start_year": { + "name": "假期开始年" + }, + "vacation_start_month": { + "name": "假期开始月" + }, + "vacation_start_day": { + "name": "假期开始日" + }, + "auto_sterilize_week": { + "name": "自动杀菌星期" + }, + "auto_sterilize_hour": { + "name": "自动杀菌小时" + }, + "auto_sterilize_minute": { + "name": "自动杀菌分钟" }, "working_time": { "name": "工作时间" @@ -580,6 +767,42 @@ }, "variable_mode": { "name": "变温区模式" + }, + "compressor_frequency": { + "name": "压缩机频率" + }, + "target_compressor_frequency": { + "name": "目标压缩机频率" + }, + "compressor_current": { + "name": "压缩机电流" + }, + "compressor_voltage": { + "name": "压缩机电压" + }, + "compressor_power": { + "name": "压缩机功率" + }, + "indoor_coil_temperature": { + "name": "室内盘管温度 (T1)" + }, + "evaporator_temperature": { + "name": "蒸发器温度 (T2)" + }, + "outdoor_ambient_temperature": { + "name": "室外环境温度 (T4)" + }, + "discharge_pipe_temperature": { + "name": "排气管温度 (TP)" + }, + "indoor_fan_speed": { + "name": "室内风机转速" + }, + "target_indoor_fan_speed": { + "name": "目标室内风机转速" + }, + "velocity": { + "name": "流速" } }, "switch": { @@ -595,27 +818,39 @@ "breezeless": { "name": "无风感" }, + "child_lock": { + "name": "童锁" + }, + "cl_sterilization": { + "name": "CL 杀菌" + }, "comfort_mode": { "name": "舒省模式" }, - "cold_water_single": { - "name": "单次零冷水" - }, - "cold_water_dot": { - "name": "点动零冷水" - }, "dhw_power": { "name": "生活热水电源开关" }, "disinfect": { "name": "消毒" }, + "vacation_mode": { + "name": "假期模式" + }, "dry": { "name": "干燥" }, + "cold_water_single": { + "name": "单次零冷水" + }, + "cold_water_dot": { + "name": "点动零冷水" + }, "eco_mode": { "name": "ECO模式" }, + "memory": { + "name": "记忆" + }, "fast_dhw": { "name": "快速生活热水" }, @@ -626,12 +861,14 @@ "name": "防霜冻" }, "heating_power": { - "name": "加热", - "_comments": "E2 加热功率, E6 加热开关" + "name": "加热" }, "indirect_wind": { "name": "防直吹" }, + "leak_water_protection": { + "name": "漏水保护" + }, "light": { "name": "灯光" }, @@ -662,9 +899,15 @@ "prompt_tone": { "name": "提示音" }, + "regeneration": { + "name": "再生" + }, "water_pump": { "name": "水泵" }, + "water_way": { + "name": "水路" + }, "screen_display": { "name": "屏幕显示" }, @@ -692,12 +935,18 @@ "smelly_sensor": { "name": "异味感应" }, + "soften": { + "name": "软化功能" + }, "standby": { "name": "待机" }, "start": { "name": "启动" }, + "sterilization": { + "name": "杀菌" + }, "storage": { "name": "保管开关" }, diff --git a/custom_components/midea_ac_lan/water_heater.py b/custom_components/midea_ac_lan/water_heater.py index 38fd760..4247295 100644 --- a/custom_components/midea_ac_lan/water_heater.py +++ b/custom_components/midea_ac_lan/water_heater.py @@ -151,7 +151,7 @@ class MideaWaterHeater(MideaEntity, WaterHeaterEntity): """Midea Water Heater target temperature.""" return cast("float", self._device.get_attribute("target_temperature")) - def set_temperature(self, **kwargs: Any) -> None: # noqa: ANN401 + def set_temperature(self, **kwargs: Any) -> None: # ruff:ignore[any-type] """Midea Water Heater set temperature.""" if ATTR_TEMPERATURE not in kwargs: return @@ -170,23 +170,23 @@ class MideaWaterHeater(MideaEntity, WaterHeaterEntity): return None return cast("list", self._device.preset_modes) - def turn_on(self, **kwargs: Any) -> None: # noqa: ANN401, ARG002 + def turn_on(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument] """Midea Water Heater turn on.""" self._device.set_attribute(attr="power", value=True) - def turn_off(self, **kwargs: Any) -> None: # noqa: ANN401, ARG002 + def turn_off(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument] """Midea Water Heater turn off.""" self._device.set_attribute(attr="power", value=False) - async def async_turn_on(self, **kwargs: Any) -> None: # noqa: ANN401 + async def async_turn_on(self, **kwargs: Any) -> None: # ruff:ignore[any-type] """Midea Water Heater async turn on.""" await self.hass.async_add_executor_job(ft.partial(self.turn_on, **kwargs)) - async def async_turn_off(self, **kwargs: Any) -> None: # noqa: ANN401 + async def async_turn_off(self, **kwargs: Any) -> None: # ruff:ignore[any-type] """Midea Water Heater async off.""" await self.hass.async_add_executor_job(ft.partial(self.turn_off, **kwargs)) - def update_state(self, status: Any) -> None: # noqa: ANN401, ARG002 + def update_state(self, status: Any) -> None: # ruff:ignore[any-type, unused-method-argument] """Midea Water Heater update state.""" if not self.hass: _LOGGER.warning( @@ -195,7 +195,7 @@ class MideaWaterHeater(MideaEntity, WaterHeaterEntity): type(self), ) return - self.schedule_update_ha_state() + self.schedule_update_if_running() class MideaE2WaterHeater(MideaWaterHeater): @@ -300,7 +300,7 @@ class MideaC3WaterHeater(MideaWaterHeater): """Midea C3 Water Heater target temperature.""" return cast("float", self._device.get_attribute(C3Attributes.dhw_target_temp)) - def set_temperature(self, **kwargs: Any) -> None: # noqa: ANN401 + def set_temperature(self, **kwargs: Any) -> None: # ruff:ignore[any-type] """Midea C3 Water Heater set temperature.""" if ATTR_TEMPERATURE not in kwargs: return @@ -317,11 +317,11 @@ class MideaC3WaterHeater(MideaWaterHeater): """Midea C3 Water Heater max temperature.""" return cast("float", self._device.get_attribute(C3Attributes.dhw_temp_max)) - def turn_on(self, **kwargs: Any) -> None: # noqa: ANN401, ARG002 + def turn_on(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument] """Midea C3 Water Heater turn on.""" self._device.set_attribute(attr=C3Attributes.dhw_power, value=True) - def turn_off(self, **kwargs: Any) -> None: # noqa: ANN401, ARG002 + def turn_off(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument] """Midea C3 Water Heater turn off.""" self._device.set_attribute(attr=C3Attributes.dhw_power, value=False) @@ -387,7 +387,7 @@ class MideaE6WaterHeater(MideaWaterHeater): """Midea E6 Water Heater target temperature.""" return cast("float", self._device.get_attribute(self._target_temperature_attr)) - def set_temperature(self, **kwargs: Any) -> None: # noqa: ANN401 + def set_temperature(self, **kwargs: Any) -> None: # ruff:ignore[any-type] """Midea E6 Water Heater set temperature.""" if ATTR_TEMPERATURE not in kwargs: return @@ -399,7 +399,7 @@ class MideaE6WaterHeater(MideaWaterHeater): """Midea E6 Water Heater min temperature.""" min_temperature = cast( "list[str]", - self._device.get_attribute(E6Attributes.min_temperature), + self._device.get_attribute(E6Attributes.temperature_min), ) return cast( "float", @@ -411,18 +411,18 @@ class MideaE6WaterHeater(MideaWaterHeater): """Midea E6 Water Heater max temperature.""" max_temperature = cast( "list[str]", - self._device.get_attribute(E6Attributes.max_temperature), + self._device.get_attribute(E6Attributes.temperature_max), ) return cast( "float", max_temperature[self._use], ) - def turn_on(self, **kwargs: Any) -> None: # noqa: ANN401, ARG002 + def turn_on(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument] """Midea E6 Water Heater turn on.""" self._device.set_attribute(attr=self._power_attr, value=True) - def turn_off(self, **kwargs: Any) -> None: # noqa: ANN401, ARG002 + def turn_off(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument] """Midea E6 Water Heater turn off.""" self._device.set_attribute(attr=self._power_attr, value=False)