From 13ce4d48f376a1ae454e223ec81ef892cd93769b Mon Sep 17 00:00:00 2001 From: Gangoke Date: Thu, 21 May 2026 00:31:36 -1000 Subject: [PATCH] ACE2 Support + more --- README.md | 120 ++--- custom_components/kobrax_lan/__init__.py | 18 + custom_components/kobrax_lan/api.py | 71 +++ custom_components/kobrax_lan/binary_sensor.py | 2 + .../kobrax_lan/brand/dark_icon.png | Bin 0 -> 10046 bytes .../kobrax_lan/brand/dark_logo.png | Bin 0 -> 10046 bytes custom_components/kobrax_lan/brand/icon.png | Bin 0 -> 10046 bytes custom_components/kobrax_lan/brand/logo.png | Bin 0 -> 10046 bytes custom_components/kobrax_lan/button.py | 115 ++++- custom_components/kobrax_lan/const.py | 3 + custom_components/kobrax_lan/coordinator.py | 69 ++- custom_components/kobrax_lan/manifest.json | 3 +- custom_components/kobrax_lan/number.py | 93 ++++ custom_components/kobrax_lan/sensor.py | 439 ++++++++++++++++-- custom_components/kobrax_lan/switch.py | 50 ++ hacs.json | 2 +- 16 files changed, 869 insertions(+), 116 deletions(-) create mode 100644 custom_components/kobrax_lan/brand/dark_icon.png create mode 100644 custom_components/kobrax_lan/brand/dark_logo.png create mode 100644 custom_components/kobrax_lan/brand/icon.png create mode 100644 custom_components/kobrax_lan/brand/logo.png create mode 100644 custom_components/kobrax_lan/number.py create mode 100644 custom_components/kobrax_lan/switch.py diff --git a/README.md b/README.md index fff24f0..36e0118 100644 --- a/README.md +++ b/README.md @@ -1,101 +1,61 @@ -# Kobra X Home Assistant Component +# Kobra X LAN for Home Assistant -Home Assistant HACS integration for controlling and monitoring an Anycubic Kobra X through KX-Bridge. +Home Assistant integration for monitoring and controlling an Anycubic Kobra X through KX-Bridge. This project was coded with AI assistance and should be reviewed before use in production. Architecture: -- printer <-> [KX-Bridge-Release](https://gitea.it-drui.de/viewit/KX-Bridge-Release) <-> this integration <-> Home Assistant +- printer <-> [KX-Bridge](https://gitea.it-drui.de/viewit/KX-Bridge-Release) <-> this integration <-> Home Assistant -## Features +## Requirements -- Auto-discovered status from KX-Bridge `/api/state` -- Core printer sensors (state, temperatures, progress, file, layer/time data) -- Light control -- Print speed mode selection -- Printer action buttons (pause, resume, cancel, connect, disconnect) -- Camera stream entity using the printer RTSP URL from KX-Bridge, with bridge MJPEG proxy fallback -- Camera snapshot fallback using `/api/camera/snapshot` -- G-code thumbnail image entity from the active print job +- Running and reachable [KX-Bridge-Release](https://gitea.it-drui.de/viewit/KX-Bridge-Release) +- Bridge endpoint accessible from Home Assistant at `http://:7125` -## Available Entities +## Installation -### Binary Sensors +### Option 1: HACS -- `Online` -- `Printing` -- `Light State` - -### Sensors - -- `State` -- `Print State` -- `Progress` -- `Hotend Temperature` -- `Target Hotend Temperature` -- `Bed Temperature` -- `Target Bed Temperature` -- `Filename` -- `Current Layer` -- `Total Layers` -- `Remaining Time` -- `Print Duration` -- `Filament Slot 1 Color` / `Filament Slot 1 Type` -- `Filament Slot 2 Color` / `Filament Slot 2 Type` -- `Filament Slot 3 Color` / `Filament Slot 3 Type` -- `Filament Slot 4 Color` / `Filament Slot 4 Type` - -The filament slot entities are created from the AMS slot data reported by KX-Bridge. If the bridge does not report a slot count, the integration falls back to 4 slots. - -### Buttons - -- `Pause Print` -- `Resume Print` -- `Cancel Print` -- `Connect Bridge` -- `Disconnect Bridge` - -### Select - -- `Print Speed` - -### Light - -- `Light` - -### Camera - -- `Camera` - -### Image - -- `GCode Thumbnail` - -## Prerequisites - -1. [KX-Bridge-Release](https://gitea.it-drui.de/viewit/KX-Bridge-Release) must be running and reachable from Home Assistant. -2. Verify [KX-Bridge-Release](https://gitea.it-drui.de/viewit/KX-Bridge-Release) is accessible at `http://:7125`. - -## Installation (HACS) - -1. Add this repository as a custom repository in HACS with category `Integration`. - - ```https://github.com/gangoke/kobrax-lan-hass-component``` - -2. Install the integration. +1. In HACS, add this repository as a custom repository (category: Integration): + `https://github.com/gangoke/kobrax-lan-hass-component` +2. Install Kobra X LAN from HACS. 3. Restart Home Assistant. -4. Add integration `Kobra X LAN` from Settings -> Devices & Services. +4. Go to Settings -> Devices & Services -> Add Integration. +5. Search for Kobra X LAN. + +### Option 2: Manual (local custom_components) + +1. Copy the `kobrax_lan` folder into your Home Assistant `custom_components` directory: + `/custom_components/kobrax_lan` +3. Restart Home Assistant. +4. Add Kobra X LAN from Settings -> Devices & Services. ## Configuration The config flow asks for: - Host: KX-Bridge host and port (example: `192.168.1.50:7125`) -- Printer name: Friendly display name +- Printer name: Friendly display name in Home Assistant + +## Entity Overview + +| Platform | Key Entities | +| --- | --- | +| Binary Sensor | Online, Printing, Light State | +| Sensor | State, Print State, Progress, Temperatures, Filename, Layer/Time metrics, Skip-object counts, ACE status, AMS/ACE slot material | +| Button | Pause Print, Resume Print, Cancel Print, Connect Bridge, Disconnect Bridge, Refresh Skip State, ACE Dryer Start, ACE Dryer Stop | +| Switch | ACE auto-fill switches per detected ACE unit | +| Number | ACE dry target temperature, ACE dry duration | +| Select | Print speed | +| Light | Printer light | +| Camera | Printer camera | +| Image | G-code thumbnail | + +Slot entities are generated dynamically from KX-Bridge AMS/ACE slot data (including larger ACE topologies). ## Notes -- This integration talks to KX-Bridge HTTP endpoints and does not connect directly to the printer. -- Keep KX-Bridge and Home Assistant on the same trusted network. -- Native WebRTC is not implemented in this integration. If you want WebRTC in Home Assistant, point `go2rtc` or a WebRTC-capable HA add-on at the camera entity's RTSP source. +- This integration communicates with KX-Bridge HTTP endpoints and does not connect directly to the printer. +- Keep KX-Bridge and Home Assistant on a trusted local network. +- Native WebRTC is not implemented. For WebRTC in Home Assistant, point `go2rtc` (or another WebRTC-capable add-on) to the camera RTSP source. diff --git a/custom_components/kobrax_lan/__init__.py b/custom_components/kobrax_lan/__init__.py index 0bc3a52..5e35448 100644 --- a/custom_components/kobrax_lan/__init__.py +++ b/custom_components/kobrax_lan/__init__.py @@ -34,6 +34,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "coordinator": coordinator, "api": api, "entry": entry, + "ace_dry_config": { + 0: { + "target_temp": int((coordinator.data or {}).get("ace_drying", {}).get("target_temp", 45) or 45), + "duration": int((coordinator.data or {}).get("ace_drying", {}).get("duration", 240) or 240), + }, + 1: { + "target_temp": 45, + "duration": 240, + }, + 2: { + "target_temp": 45, + "duration": 240, + }, + 3: { + "target_temp": 45, + "duration": 240, + }, + }, } await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/custom_components/kobrax_lan/api.py b/custom_components/kobrax_lan/api.py index 34c007b..ffcf6e1 100644 --- a/custom_components/kobrax_lan/api.py +++ b/custom_components/kobrax_lan/api.py @@ -49,6 +49,62 @@ class KobraXApiClient: return result raise KobraXApiError("Unexpected response for /kx/files") + async def async_get_file_objects(self, file_id: str) -> dict[str, Any]: + data = await self._get_json(f"/kx/files/{file_id}/objects") + result = data.get("result", {}) + if isinstance(result, dict): + return result + raise KobraXApiError("Unexpected response for /kx/files/{id}/objects") + + async def async_skip_objects(self, names: list[str]) -> dict[str, Any]: + data = await self._post_json("/kx/skip", {"names": names}) + result = data.get("result") + if result is None: + raise KobraXApiError("Unexpected response for /kx/skip") + return data + + async def async_skip_query(self) -> dict[str, Any]: + data = await self._post_json("/kx/skip/query", {}) + result = data.get("result", {}) + if isinstance(result, dict): + return result + raise KobraXApiError("Unexpected response for /kx/skip/query") + + async def async_get_skip_state(self) -> dict[str, Any]: + data = await self._get_json("/kx/skip/state") + result = data.get("result", {}) + if isinstance(result, dict): + return result + raise KobraXApiError("Unexpected response for /kx/skip/state") + + async def async_set_ace_auto_feed(self, ace_id: int, on: bool) -> dict[str, Any]: + data = await self._post_json("/api/ace/auto_feed", {"ace_id": ace_id, "on": on}) + result = data.get("result") + if result is None: + raise KobraXApiError("Unexpected response for /api/ace/auto_feed") + return data + + async def async_set_ace_dry( + self, + action: str, + target_temp: int | None = None, + duration: int | None = None, + ace_id: int | None = None, + ) -> dict[str, Any]: + payload: dict[str, Any] = {"action": action} + if target_temp is not None: + payload["target_temp"] = int(target_temp) + if duration is not None: + payload["duration"] = int(duration) + if ace_id is not None: + payload["ace_id"] = int(ace_id) + + data = await self._post_json("/api/ace/dry", payload) + result = data.get("result") + if result is None: + raise KobraXApiError("Unexpected response for /api/ace/dry") + return data + async def async_pause_print(self) -> None: await self._post_json("/printer/print/pause", {}) @@ -79,9 +135,24 @@ class KobraXApiClient: async def async_disconnect(self) -> None: await self._post_json("/api/disconnect", {}) + async def async_restart_bridge(self) -> None: + await self._post_json("/api/restart", {}) + async def async_start_camera(self) -> None: await self._post_json("/api/camera/start", {}) + async def async_stop_camera(self) -> None: + await self._post_json("/api/camera/stop", {}) + + async def async_check_updates(self) -> dict[str, Any]: + return await self._get_json("/api/update/check") + + async def async_apply_update(self, tag: str, download_url: str) -> dict[str, Any]: + return await self._post_json( + "/api/update/apply", + {"tag": tag, "download_url": download_url}, + ) + async def async_get_camera_url(self) -> str | None: data = await self._get_json("/api/camera") url = data.get("url") diff --git a/custom_components/kobrax_lan/binary_sensor.py b/custom_components/kobrax_lan/binary_sensor.py index 04e133f..7124a8e 100644 --- a/custom_components/kobrax_lan/binary_sensor.py +++ b/custom_components/kobrax_lan/binary_sensor.py @@ -7,6 +7,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) +from homeassistant.helpers.entity import EntityCategory from .const import DOMAIN from .entity import KobraXEntity @@ -23,6 +24,7 @@ BINARY_SENSORS: tuple[KobraXBinaryDescription, ...] = ( name="Online", value_key="kobra_state", device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, ), KobraXBinaryDescription( key="printing", diff --git a/custom_components/kobrax_lan/brand/dark_icon.png b/custom_components/kobrax_lan/brand/dark_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..0c16a9a3f449853d2a971d258cb4cf980b8cdc2c GIT binary patch literal 10046 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 Z@nh{x<7$&^=E-MoH`H#bmMU9^{T~p3{4W3i literal 0 HcmV?d00001 diff --git a/custom_components/kobrax_lan/brand/dark_logo.png b/custom_components/kobrax_lan/brand/dark_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..0c16a9a3f449853d2a971d258cb4cf980b8cdc2c GIT binary patch literal 10046 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 Z@nh{x<7$&^=E-MoH`H#bmMU9^{T~p3{4W3i literal 0 HcmV?d00001 diff --git a/custom_components/kobrax_lan/brand/icon.png b/custom_components/kobrax_lan/brand/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..0c16a9a3f449853d2a971d258cb4cf980b8cdc2c GIT binary patch literal 10046 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 Z@nh{x<7$&^=E-MoH`H#bmMU9^{T~p3{4W3i literal 0 HcmV?d00001 diff --git a/custom_components/kobrax_lan/brand/logo.png b/custom_components/kobrax_lan/brand/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..0c16a9a3f449853d2a971d258cb4cf980b8cdc2c GIT binary patch literal 10046 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 Z@nh{x<7$&^=E-MoH`H#bmMU9^{T~p3{4W3i literal 0 HcmV?d00001 diff --git a/custom_components/kobrax_lan/button.py b/custom_components/kobrax_lan/button.py index ac7b7a2..c2481bf 100644 --- a/custom_components/kobrax_lan/button.py +++ b/custom_components/kobrax_lan/button.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity import EntityCategory from .api import KobraXApiError from .const import DOMAIN @@ -13,6 +14,7 @@ from .entity import KobraXEntity @dataclass(frozen=True, kw_only=True) class KobraXButtonDescription(ButtonEntityDescription): action: str + ace_id: int | None = None # None for non-ACE buttons, 0-3 for ACE buttons BUTTONS: tuple[KobraXButtonDescription, ...] = ( @@ -39,6 +41,7 @@ BUTTONS: tuple[KobraXButtonDescription, ...] = ( name="Connect Bridge", icon="mdi:lan-connect", action="connect", + entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, ), KobraXButtonDescription( @@ -46,11 +49,37 @@ BUTTONS: tuple[KobraXButtonDescription, ...] = ( name="Disconnect Bridge", icon="mdi:lan-disconnect", action="disconnect", + entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, ), +# ENABLE ME WHEN API ENDPOINT IS ADDED +# KobraXButtonDescription( +# key="restart_bridge", +# name="Restart (KX-Bridge)", +# icon="mdi:restart", +# action="restart", +# entity_category=EntityCategory.CONFIG, +# ), + KobraXButtonDescription( + key="refresh_skip_state", + name="Refresh Skip State", + icon="mdi:refresh", + action="skip_query", + entity_registry_enabled_default=False, + ), + KobraXButtonDescription( + key="apply_update", + name="Apply Update (KX-Bridge)", + icon="mdi:download-circle-outline", + action="apply_update", + entity_category=EntityCategory.CONFIG, + ), ) +# ACE dryer buttons are now dynamically created in async_setup_entry + + class KobraXActionButton(KobraXEntity, ButtonEntity): entity_description: KobraXButtonDescription @@ -58,6 +87,25 @@ class KobraXActionButton(KobraXEntity, ButtonEntity): super().__init__(coordinator, entry, description.key, description.name) self.entity_description = description + @property + def available(self) -> bool: + if self.entity_description.action != "apply_update": + return super().available + + update_info = self.state_data.get("update_info") + if not isinstance(update_info, dict): + return False + + current = str(update_info.get("current") or "").strip() + latest = str(update_info.get("latest") or "").strip() + if current and latest and current == latest: + return False + + if update_info.get("update_available") is False: + return False + + return super().available + async def async_press(self) -> None: api = self.hass.data[DOMAIN][self._entry.entry_id]["api"] try: @@ -71,6 +119,55 @@ class KobraXActionButton(KobraXEntity, ButtonEntity): await api.async_connect() elif self.entity_description.action == "disconnect": await api.async_disconnect() + elif self.entity_description.action == "restart": + await api.async_restart_bridge() + elif self.entity_description.action == "skip_query": + await api.async_skip_query() + elif self.entity_description.action == "apply_update": + await self.coordinator.async_apply_update() + await self.coordinator.async_request_refresh() + except KobraXApiError as err: + raise ServiceValidationError(str(err)) from err + except Exception as err: + raise ServiceValidationError(str(err)) from err + + +class KobraXAceDryButton(KobraXEntity, ButtonEntity): + def __init__( + self, + coordinator, + entry, + ace_id: int, + action: str, # "dry_start" or "dry_stop" + ) -> None: + if action == "dry_start": + unique_key = f"ace_{ace_id}_dry_start" + name = f"ACE {ace_id + 1} Dryer Start" + icon = "mdi:tumble-dryer" + else: # dry_stop + unique_key = f"ace_{ace_id}_dry_stop" + name = f"ACE {ace_id + 1} Dryer Stop" + icon = "mdi:tumble-dryer-off" + + super().__init__(coordinator, entry, unique_key, name) + self._ace_id = ace_id + self._action = action + self._attr_icon = icon + + async def async_press(self) -> None: + api = self.hass.data[DOMAIN][self._entry.entry_id]["api"] + try: + if self._action == "dry_start": + cfg = self.hass.data[DOMAIN][self._entry.entry_id]["ace_dry_config"] + ace_cfg = cfg.get(self._ace_id) or {} + await api.async_set_ace_dry( + "start", + target_temp=int(ace_cfg.get("target_temp", 45)), + duration=int(ace_cfg.get("duration", 240)), + ace_id=self._ace_id, + ) + elif self._action == "dry_stop": + await api.async_set_ace_dry("stop", ace_id=self._ace_id) await self.coordinator.async_request_refresh() except KobraXApiError as err: raise ServiceValidationError(str(err)) from err @@ -78,9 +175,15 @@ class KobraXActionButton(KobraXEntity, ButtonEntity): async def async_setup_entry(hass, entry, async_add_entities): coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"] - async_add_entities( - [ - KobraXActionButton(coordinator, entry, description) - for description in BUTTONS - ] - ) + + entities = [ + KobraXActionButton(coordinator, entry, description) + for description in BUTTONS + ] + + # Pre-create all 8 ACE dryer buttons (start + stop for each of 4 ACE units) + for ace_id in range(4): + entities.append(KobraXAceDryButton(coordinator, entry, ace_id, "dry_start")) + entities.append(KobraXAceDryButton(coordinator, entry, ace_id, "dry_stop")) + + async_add_entities(entities) diff --git a/custom_components/kobrax_lan/const.py b/custom_components/kobrax_lan/const.py index bc9f441..2f6af12 100644 --- a/custom_components/kobrax_lan/const.py +++ b/custom_components/kobrax_lan/const.py @@ -11,6 +11,7 @@ DEFAULT_HOST = "localhost:7125" DEFAULT_PRINTER_NAME = "Anycubic Kobra X" UPDATE_INTERVAL = timedelta(seconds=5) +UPDATE_CHECK_INTERVAL = timedelta(hours=1) PLATFORMS = [ "sensor", @@ -18,6 +19,8 @@ PLATFORMS = [ "light", "select", "button", + "switch", + "number", "camera", "image", ] diff --git a/custom_components/kobrax_lan/coordinator.py b/custom_components/kobrax_lan/coordinator.py index e290299..7e8ca16 100644 --- a/custom_components/kobrax_lan/coordinator.py +++ b/custom_components/kobrax_lan/coordinator.py @@ -1,12 +1,13 @@ from __future__ import annotations +import time from typing import Any from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .api import KobraXApiClient, KobraXApiError -from .const import DOMAIN, UPDATE_INTERVAL +from .const import DOMAIN, UPDATE_CHECK_INTERVAL, UPDATE_INTERVAL class KobraXCoordinator(DataUpdateCoordinator[dict[str, Any]]): @@ -18,9 +19,73 @@ class KobraXCoordinator(DataUpdateCoordinator[dict[str, Any]]): update_interval=UPDATE_INTERVAL, ) self.api = api + self._update_info: dict[str, Any] = {} + self._restart_supported: bool | None = None + self._next_update_check_monotonic = 0.0 async def _async_update_data(self) -> dict[str, Any]: try: - return await self.api.async_get_state() + # Probe restart endpoint once if not checked + if self._restart_supported is None: + try: + await self.api.async_restart_bridge() + self._restart_supported = True + except Exception as err: + # Only disable if 404/501, otherwise treat as available + msg = str(err) + if "404" in msg or "501" in msg: + self._restart_supported = False + else: + self._restart_supported = True + state = await self.api.async_get_state() + + now = time.monotonic() + if now >= self._next_update_check_monotonic: + try: + self._update_info = await self.api.async_check_updates() + except KobraXApiError: + # Keep integration polling resilient if update service is temporarily unavailable. + pass + self._next_update_check_monotonic = now + UPDATE_CHECK_INTERVAL.total_seconds() + + if self._update_info: + state["update_info"] = self._update_info + try: + skip_state = await self.api.async_get_skip_state() + state["skip_state"] = skip_state + except KobraXApiError: + # Skip endpoints are only available on newer bridge versions. + pass + return state except KobraXApiError as err: raise UpdateFailed(str(err)) from err + + async def async_check_updates(self) -> dict[str, Any]: + try: + update_info = await self.api.async_check_updates() + except KobraXApiError as err: + raise UpdateFailed(str(err)) from err + + self._update_info = update_info + merged = dict(self.data or {}) + merged["update_info"] = update_info + self.async_set_updated_data(merged) + return update_info + + async def async_apply_update(self) -> dict[str, Any]: + update_info = self._update_info or await self.async_check_updates() + tag = str(update_info.get("tag") or "").strip() + download_url = str(update_info.get("download_url") or "").strip() + if not tag or not download_url: + raise UpdateFailed("Missing tag or download URL from update check") + + try: + result = await self.api.async_apply_update(tag=tag, download_url=download_url) + except KobraXApiError as err: + raise UpdateFailed(str(err)) from err + + self._update_info = {**update_info, "last_apply_result": result} + merged = dict(self.data or {}) + merged["update_info"] = self._update_info + self.async_set_updated_data(merged) + return result diff --git a/custom_components/kobrax_lan/manifest.json b/custom_components/kobrax_lan/manifest.json index 565d523..2f98f74 100644 --- a/custom_components/kobrax_lan/manifest.json +++ b/custom_components/kobrax_lan/manifest.json @@ -5,8 +5,9 @@ "@Gangoke" ], "config_flow": true, + "homeassistant": "2026.3.0", "documentation": "https://github.com/gangoke/kobrax-lan-hass-component", "iot_class": "local_polling", "issue_tracker": "https://github.com/gangoke/kobrax-lan-hass-component/issues", - "version": "0.1.0" + "version": "0.2.0" } diff --git a/custom_components/kobrax_lan/number.py b/custom_components/kobrax_lan/number.py new file mode 100644 index 0000000..b0522c7 --- /dev/null +++ b/custom_components/kobrax_lan/number.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from typing import Any + +from homeassistant.components.number import NumberEntity + +from .const import DOMAIN +from .entity import KobraXEntity + + +def _minutes_to_hhmmss(minutes: int | float) -> str: + """Convert minutes to HH:mm:ss format.""" + total_seconds = int(minutes * 60) + hours = total_seconds // 3600 + remaining_seconds = total_seconds % 3600 + mins = remaining_seconds // 60 + secs = remaining_seconds % 60 + return f"{hours:02d}:{mins:02d}:{secs:02d}" + + +class KobraXAceDryConfigNumber(KobraXEntity, NumberEntity): + def __init__( + self, + coordinator, + entry, + ace_id: int, + config_type: str, # "target_temp" or "duration" + ) -> None: + if config_type == "target_temp": + unique_key = f"ace_{ace_id}_dry_target_temp" + name = f"ACE {ace_id + 1} Dryer Target Temperature" + min_val, max_val, step_val = 30, 80, 1 + unit = "°C" + icon = "mdi:thermometer" + else: # duration + unique_key = f"ace_{ace_id}_dry_duration" + name = f"ACE {ace_id + 1} Dryer Duration" + min_val, max_val, step_val = 10, 1440, 1 + unit = "min" + icon = "mdi:timer-cog-outline" + + super().__init__(coordinator, entry, unique_key, name) + self._ace_id = ace_id + self._config_type = config_type + self._attr_native_min_value = min_val + self._attr_native_max_value = max_val + self._attr_native_step = step_val + self._attr_native_unit_of_measurement = unit + self._attr_mode = "box" + self._attr_icon = icon + + @property + def native_value(self) -> float: + cfg = self.hass.data[DOMAIN][self._entry.entry_id]["ace_dry_config"] + ace_cfg = cfg.get(self._ace_id) or {} + key = "target_temp" if self._config_type == "target_temp" else "duration" + if self._config_type == "target_temp": + return float(ace_cfg.get(key, 45)) + else: + return float(ace_cfg.get(key, 240)) + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + if self._config_type != "duration": + return None + cfg = self.hass.data[DOMAIN][self._entry.entry_id]["ace_dry_config"] + ace_cfg = cfg.get(self._ace_id) or {} + duration_minutes = ace_cfg.get("duration", 240) + return {"formatted_duration": _minutes_to_hhmmss(duration_minutes)} + + async def async_set_native_value(self, value: float) -> None: + cfg = self.hass.data[DOMAIN][self._entry.entry_id]["ace_dry_config"] + if self._ace_id not in cfg: + cfg[self._ace_id] = {} + key = "target_temp" if self._config_type == "target_temp" else "duration" + cfg[self._ace_id][key] = int(round(value)) + self.async_write_ha_state() + + +async def async_setup_entry(hass, entry, async_add_entities): + coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"] + + # Initialize ace_dry_config structure if not present + if "ace_dry_config" not in hass.data[DOMAIN][entry.entry_id]: + hass.data[DOMAIN][entry.entry_id]["ace_dry_config"] = {} + + # Pre-create all 8 numbers (target_temp + duration for each of 4 ACE units) + entities = [] + for ace_id in range(4): + entities.append(KobraXAceDryConfigNumber(coordinator, entry, ace_id, "target_temp")) + entities.append(KobraXAceDryConfigNumber(coordinator, entry, ace_id, "duration")) + + async_add_entities(entities) diff --git a/custom_components/kobrax_lan/sensor.py b/custom_components/kobrax_lan/sensor.py index 7822db5..e620525 100644 --- a/custom_components/kobrax_lan/sensor.py +++ b/custom_components/kobrax_lan/sensor.py @@ -2,14 +2,45 @@ from __future__ import annotations from dataclasses import dataclass from typing import Any +from urllib.parse import quote from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorEntityDescription from homeassistant.const import PERCENTAGE, UnitOfTemperature +from homeassistant.core import callback +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_registry import RegistryEntryDisabler from .const import DOMAIN from .entity import KobraXEntity +MAX_FILAMENT_SLOTS = 19 +TOOLHEAD_SLOT_LIMIT = 4 +ACE_DIRECT_SLOT_LIMIT = 4 +MAX_ACE_UNITS = 4 + + +def _detected_slot_limit(state_data: dict[str, Any]) -> int: + mode = str(state_data.get("filament_mode") or "toolhead").lower() + if mode == "toolhead": + return TOOLHEAD_SLOT_LIMIT + if mode == "ace_direct": + return ACE_DIRECT_SLOT_LIMIT + + slots = state_data.get("ams_slots") or [] + if isinstance(slots, list) and slots: + return min(len(slots), MAX_FILAMENT_SLOTS) + return MAX_FILAMENT_SLOTS + + +def _detected_ace_unit_count(state_data: dict[str, Any]) -> int: + units = state_data.get("ace_units") or [] + if isinstance(units, list): + return min(len(units), MAX_ACE_UNITS) + return 0 + + @dataclass(frozen=True, kw_only=True) class KobraXSensorDescription(SensorEntityDescription): value_key: str @@ -97,6 +128,42 @@ SENSORS: tuple[KobraXSensorDescription, ...] = ( value_key="print_duration", icon="mdi:timer-outline", ), + KobraXSensorDescription( + key="skip_object_count", + name="Skip Object Count", + value_key="skip_object_count", + icon="mdi:vector-polygon", + ), + KobraXSensorDescription( + key="skipped_object_count", + name="Skipped Object Count", + value_key="skipped_object_count", + icon="mdi:content-cut", + ), + KobraXSensorDescription( + key="filament_mode", + name="Filament Mode", + value_key="filament_mode", + icon="mdi:shape-outline", + ), + KobraXSensorDescription( + key="ace_unit_count", + name="ACE Unit Count", + value_key="ace_unit_count", + icon="mdi:package-variant", + ), + KobraXSensorDescription( + key="bridge_version", + name="Bridge Version", + value_key="version", + icon="mdi:source-branch", + ), + KobraXSensorDescription( + key="latest_available_version", + name="Latest Available Version", + value_key="latest_available_version", + icon="mdi:cloud-download-outline", + ), ) @@ -106,31 +173,183 @@ class KobraXSensor(KobraXEntity, SensorEntity): def __init__(self, coordinator, entry, description: KobraXSensorDescription) -> None: super().__init__(coordinator, entry, description.key, description.name) self.entity_description = description + if description.value_key in ("version", "latest_available_version"): + self._attr_entity_category = EntityCategory.DIAGNOSTIC + + @staticmethod + def _seconds_to_hhmmss(seconds: int) -> str: + hours = seconds // 3600 + remaining_seconds = seconds % 3600 + minutes = remaining_seconds // 60 + secs = remaining_seconds % 60 + return f"{hours:02d}:{minutes:02d}:{secs:02d}" @property def native_value(self) -> Any: + if self.entity_description.value_key == "filament_mode": + return self.state_data.get("filament_mode") + if self.entity_description.value_key == "ace_unit_count": + units = self.state_data.get("ace_units") + return len(units) if isinstance(units, list) else 0 + if self.entity_description.value_key == "latest_available_version": + update_info = self.state_data.get("update_info") or {} + if isinstance(update_info, dict): + return update_info.get("latest") + return None + if self.entity_description.value_key == "skip_object_count": + skip_state = self.state_data.get("skip_state") or {} + objects = skip_state.get("objects") if isinstance(skip_state, dict) else [] + return len(objects) if isinstance(objects, list) else 0 + if self.entity_description.value_key == "skipped_object_count": + skip_state = self.state_data.get("skip_state") or {} + skipped = skip_state.get("skipped") if isinstance(skip_state, dict) else [] + return len(skipped) if isinstance(skipped, list) else 0 + value = self.state_data.get(self.entity_description.value_key) if self.entity_description.value_key == "progress" and value is not None: return round(float(value) * 100, 1) + if self.entity_description.value_key in ("remain_time", "print_duration") and value is not None: + try: + return self._seconds_to_hhmmss(int(float(value))) + except (TypeError, ValueError): + return None return value + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + if self.entity_description.value_key == "latest_available_version": + update_info = self.state_data.get("update_info") + if isinstance(update_info, dict): + return { + "current": update_info.get("current"), + "tag": update_info.get("tag"), + "update_available": update_info.get("update_available"), + "download_url": update_info.get("download_url"), + } + return None + + if self.entity_description.value_key not in ("skip_object_count", "skipped_object_count"): + return None + + skip_state = self.state_data.get("skip_state") + if not isinstance(skip_state, dict): + return None + + objects = skip_state.get("objects") + skipped = skip_state.get("skipped") + return { + "objects": objects if isinstance(objects, list) else [], + "skipped": skipped if isinstance(skipped, list) else [], + "filename": skip_state.get("filename"), + "ts": skip_state.get("ts"), + } + + +class KobraXAceDryerSensor(KobraXEntity, SensorEntity): + """Per-ACE-unit dryer sensor.""" + + def __init__( + self, + coordinator, + entry, + ace_id: int, + sensor_type: str, # "status", "humidity", "current_temp", "target_temp", "remaining_time" + ) -> None: + if sensor_type == "status": + unique_key = f"ace_{ace_id}_dryer_status" + name = f"ACE {ace_id + 1} Dryer Status" + icon = "mdi:tumble-dryer" + elif sensor_type == "humidity": + unique_key = f"ace_{ace_id}_dryer_humidity" + name = f"ACE {ace_id + 1} Dryer Humidity" + icon = "mdi:water-percent" + elif sensor_type == "current_temp": + unique_key = f"ace_{ace_id}_dryer_current_temp" + name = f"ACE {ace_id + 1} Dryer Current Temperature" + icon = None + elif sensor_type == "target_temp": + unique_key = f"ace_{ace_id}_dryer_target_temp" + name = f"ACE {ace_id + 1} Dryer Target Temperature" + icon = None + else: # remaining_time + unique_key = f"ace_{ace_id}_dryer_remaining_time" + name = f"ACE {ace_id + 1} Dryer Remaining Time" + icon = "mdi:timer-sand" + + super().__init__(coordinator, entry, unique_key, name) + self._ace_id = ace_id + self._sensor_type = sensor_type + if icon: + self._attr_icon = icon + + if sensor_type == "humidity": + self._attr_native_unit_of_measurement = PERCENTAGE + elif sensor_type in ("current_temp", "target_temp"): + self._attr_device_class = SensorDeviceClass.TEMPERATURE + self._attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS + self._attr_suggested_unit_of_measurement = UnitOfTemperature.CELSIUS + elif sensor_type == "remaining_time": + pass # HH:mm:ss format, no unit needed + + @staticmethod + def _minutes_to_hhmmss(minutes: int) -> str: + """Convert minutes to HH:mm:ss format.""" + total_seconds = minutes * 60 + hours = total_seconds // 3600 + remaining_seconds = total_seconds % 3600 + mins = remaining_seconds // 60 + secs = remaining_seconds % 60 + return f"{hours:02d}:{mins:02d}:{secs:02d}" + + @property + def native_value(self) -> Any: + drying = self.state_data.get("ace_drying") or {} + + # Handle per-unit structure: ace_drying[ace_id] or global structure + if isinstance(drying, dict): + # Try per-unit key first + unit_data = drying.get(self._ace_id) + if isinstance(unit_data, dict): + drying = unit_data + elif self._ace_id == 0 and not unit_data: + # Fall back to global structure for unit 0 + pass + else: + # No data for this unit + return None + else: + return None + + if self._sensor_type == "status": + status = int(drying.get("status", 0)) if drying.get("status") is not None else 0 + return "running" if status else "idle" + elif self._sensor_type == "humidity": + humidity = drying.get("humidity") + return round(float(humidity), 1) if humidity is not None else None + elif self._sensor_type == "current_temp": + current_temp = drying.get("current_temp") + return round(float(current_temp), 1) if current_temp is not None else None + elif self._sensor_type == "target_temp": + target_temp = drying.get("target_temp") + return float(target_temp) if target_temp is not None else None + elif self._sensor_type == "remaining_time": + remain = drying.get("remain_time") + if remain is not None: + try: + minutes = int(remain) + return self._minutes_to_hhmmss(minutes) + except (TypeError, ValueError): + return None + return None + + return None + class KobraXFilamentSlotSensor(KobraXEntity, SensorEntity): - def __init__(self, coordinator, entry, slot_index: int, field: str) -> None: - name_suffix = { - "color": f"Filament Slot {slot_index + 1} Color", - "type": f"Filament Slot {slot_index + 1} Type", - }[field] - super().__init__(coordinator, entry, f"filament_slot_{slot_index + 1}_{field}", name_suffix) + def __init__(self, coordinator, entry, slot_index: int) -> None: + super().__init__(coordinator, entry, f"slot_{slot_index + 1}", f"Slot {slot_index + 1}") self._slot_index = slot_index - self._field = field - - if field == "color": - self._attr_icon = "mdi:palette" - elif field == "type": - self._attr_icon = "mdi:label" - else: - self._attr_icon = "mdi:numeric" + self._attr_icon = "mdi:circle" def _slot(self) -> dict[str, Any]: slots = self.state_data.get("ams_slots") or [] @@ -139,6 +358,9 @@ class KobraXFilamentSlotSensor(KobraXEntity, SensorEntity): slot = slots[self._slot_index] return slot if isinstance(slot, dict) else {} + def _slot_limit_for_mode(self) -> int: + return _detected_slot_limit(self.state_data) + @staticmethod def _to_color_hex(color: Any) -> str | None: if isinstance(color, list) and len(color) >= 3: @@ -154,30 +376,132 @@ class KobraXFilamentSlotSensor(KobraXEntity, SensorEntity): @property def available(self) -> bool: - return bool(self._slot()) and super().available + return self._slot_index < self._slot_limit_for_mode() and bool(self._slot()) and super().available @property def native_value(self) -> Any: + slot = self._slot() + if not slot: + return "EMPTY" + status = slot.get("status") + if status is not None: + try: + if int(status) != 5: + return "EMPTY" + except (TypeError, ValueError): + return "EMPTY" + material = slot.get("type") + material_str = str(material).upper() if material else "EMPTY" + color_hex = self._to_color_hex(slot.get("color")) + if color_hex and material_str != "EMPTY": + return f"{material_str} ({color_hex})" + return material_str + + @property + def icon_color(self) -> str | None: slot = self._slot() if not slot: return None - if self._field == "color": - return self._to_color_hex(slot.get("color")) - if self._field == "type": - material = slot.get("type") - return str(material).upper() if material else None - return None + return self._to_color_hex(slot.get("color")) + + @property + def entity_picture(self) -> str | None: + """Return a colored circle picture as a frontend fallback when icon tinting is ignored.""" + slot = self._slot() + empty_svg = ( + "" + "" + ) + if not slot: + return f"data:image/svg+xml;utf8,{quote(empty_svg)}" + + status = slot.get("status") + if status is not None: + try: + if int(status) != 5: + return f"data:image/svg+xml;utf8,{quote(empty_svg)}" + except (TypeError, ValueError): + return f"data:image/svg+xml;utf8,{quote(empty_svg)}" + + color_hex = self._to_color_hex(slot.get("color")) + if not color_hex: + return f"data:image/svg+xml;utf8,{quote(empty_svg)}" + + svg = ( + "" + "" + ) + return f"data:image/svg+xml;utf8,{quote(svg)}" + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + slot = self._slot() + if not slot: + return None + return { + "color_hex": self._to_color_hex(slot.get("color")), + "status": slot.get("status"), + "box_id": slot.get("box_id"), + "global_index": slot.get("global_index"), + "activity": slot.get("activity"), + } async def async_setup_entry(hass, entry, async_add_entities): coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"] - slots = coordinator.data.get("ams_slots") if coordinator.data else [] - slot_count = len(slots) if isinstance(slots, list) and len(slots) > 0 else 4 filament_entities: list[SensorEntity] = [] - for slot_index in range(slot_count): - for field in ("color", "type"): - filament_entities.append(KobraXFilamentSlotSensor(coordinator, entry, slot_index, field)) + for slot_index in range(MAX_FILAMENT_SLOTS): + filament_entities.append(KobraXFilamentSlotSensor(coordinator, entry, slot_index)) + + ace_dryer_entities: list[SensorEntity] = [] + for ace_id in range(MAX_ACE_UNITS): + for sensor_type in ("status", "humidity", "current_temp", "target_temp", "remaining_time"): + ace_dryer_entities.append(KobraXAceDryerSensor(coordinator, entry, ace_id, sensor_type)) + + entity_registry = er.async_get(hass) + + # Remove legacy single-unit ACE dryer sensors that were replaced by per-unit entities. + legacy_ace_sensor_keys = ( + "ace_dryer_status", + "ace_dryer_humidity", + "ace_dryer_current_temp", + "ace_dryer_target_temp", + "ace_dryer_remaining_time", + ) + for key in legacy_ace_sensor_keys: + legacy_unique_id = f"{entry.entry_id}_{key}" + legacy_entity_id = entity_registry.async_get_entity_id("sensor", DOMAIN, legacy_unique_id) + if legacy_entity_id: + entity_registry.async_remove(legacy_entity_id) + + @callback + def _sync_slot_registry_state() -> None: + state_data = coordinator.data or {} + enabled_slots = _detected_slot_limit(state_data) + + for slot_index in range(MAX_FILAMENT_SLOTS): + unique_id = f"{entry.entry_id}_slot_{slot_index + 1}" + entity_id = entity_registry.async_get_entity_id("sensor", DOMAIN, unique_id) + if not entity_id: + continue + + reg_entry = entity_registry.async_get(entity_id) + if reg_entry is None: + continue + + should_enable = slot_index < enabled_slots + if should_enable: + if reg_entry.disabled_by == RegistryEntryDisabler.INTEGRATION: + entity_registry.async_update_entity(entity_id, disabled_by=None) + else: + if reg_entry.disabled_by is None: + entity_registry.async_update_entity( + entity_id, + disabled_by=RegistryEntryDisabler.INTEGRATION, + ) async_add_entities( [ @@ -185,4 +509,67 @@ async def async_setup_entry(hass, entry, async_add_entities): for description in SENSORS ] + filament_entities + + ace_dryer_entities ) + + @callback + def _sync_ace_registry_state() -> None: + state_data = coordinator.data or {} + enabled_ace_units = _detected_ace_unit_count(state_data) + + # Define all ACE entity patterns: (platform, unique_id_pattern_parts) + ace_entities: list[tuple[str, str]] = [] + + for ace_index in range(MAX_ACE_UNITS): + # Switch entities + ace_entities.append(("switch", f"{entry.entry_id}_ace_{ace_index}_auto_feed")) + + # Number entities (temp + duration) + ace_entities.append(("number", f"{entry.entry_id}_ace_{ace_index}_dry_target_temp")) + ace_entities.append(("number", f"{entry.entry_id}_ace_{ace_index}_dry_duration")) + + # Button entities (start + stop) + ace_entities.append(("button", f"{entry.entry_id}_ace_{ace_index}_dry_start")) + ace_entities.append(("button", f"{entry.entry_id}_ace_{ace_index}_dry_stop")) + + # Sensor entities (status, humidity, current_temp, target_temp, remaining_time) + ace_entities.append(("sensor", f"{entry.entry_id}_ace_{ace_index}_dryer_status")) + ace_entities.append(("sensor", f"{entry.entry_id}_ace_{ace_index}_dryer_humidity")) + ace_entities.append(("sensor", f"{entry.entry_id}_ace_{ace_index}_dryer_current_temp")) + ace_entities.append(("sensor", f"{entry.entry_id}_ace_{ace_index}_dryer_target_temp")) + ace_entities.append(("sensor", f"{entry.entry_id}_ace_{ace_index}_dryer_remaining_time")) + + for platform, unique_id in ace_entities: + entity_id = entity_registry.async_get_entity_id(platform, DOMAIN, unique_id) + if not entity_id: + continue + + reg_entry = entity_registry.async_get(entity_id) + if reg_entry is None: + continue + + # Extract ace_index from unique_id + parts = unique_id.split("_") + if len(parts) >= 3: + try: + ace_index = int(parts[2]) + except (ValueError, IndexError): + continue + else: + continue + + should_enable = ace_index < enabled_ace_units + if should_enable: + if reg_entry.disabled_by == RegistryEntryDisabler.INTEGRATION: + entity_registry.async_update_entity(entity_id, disabled_by=None) + else: + if reg_entry.disabled_by is None: + entity_registry.async_update_entity( + entity_id, + disabled_by=RegistryEntryDisabler.INTEGRATION, + ) + + entry.async_on_unload(coordinator.async_add_listener(_sync_slot_registry_state)) + entry.async_on_unload(coordinator.async_add_listener(_sync_ace_registry_state)) + _sync_slot_registry_state() + _sync_ace_registry_state() diff --git a/custom_components/kobrax_lan/switch.py b/custom_components/kobrax_lan/switch.py new file mode 100644 index 0000000..76db9ba --- /dev/null +++ b/custom_components/kobrax_lan/switch.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from homeassistant.components.switch import SwitchEntity +from homeassistant.exceptions import ServiceValidationError + +from .api import KobraXApiError +from .const import DOMAIN +from .entity import KobraXEntity + + +class KobraXAceAutoFeedSwitch(KobraXEntity, SwitchEntity): + def __init__(self, coordinator, entry, ace_id: int) -> None: + super().__init__(coordinator, entry, f"ace_{ace_id}_auto_feed", f"ACE {ace_id + 1} Auto Fill") + self._ace_id = ace_id + self._attr_icon = "mdi:autorenew" + + @property + def is_on(self) -> bool: + auto_feed = self.state_data.get("ace_auto_feed") or {} + if not isinstance(auto_feed, dict): + return False + value = auto_feed.get(self._ace_id) + if value is None: + value = auto_feed.get(str(self._ace_id)) + return bool(value) + + async def async_turn_on(self, **kwargs) -> None: + api = self.hass.data[DOMAIN][self._entry.entry_id]["api"] + try: + await api.async_set_ace_auto_feed(self._ace_id, True) + await self.coordinator.async_request_refresh() + except KobraXApiError as err: + raise ServiceValidationError(str(err)) from err + + async def async_turn_off(self, **kwargs) -> None: + api = self.hass.data[DOMAIN][self._entry.entry_id]["api"] + try: + await api.async_set_ace_auto_feed(self._ace_id, False) + await self.coordinator.async_request_refresh() + except KobraXApiError as err: + raise ServiceValidationError(str(err)) from err + + +async def async_setup_entry(hass, entry, async_add_entities): + coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"] + + # Pre-create switches for all 4 possible ACE units + async_add_entities( + [KobraXAceAutoFeedSwitch(coordinator, entry, ace_id) for ace_id in range(4)] + ) diff --git a/hacs.json b/hacs.json index d4672ac..fe5e0ef 100644 --- a/hacs.json +++ b/hacs.json @@ -4,7 +4,7 @@ "domains": [ "kobrax_lan" ], - "homeassistant": "2024.6.0", + "homeassistant": "2026.3.0", "iot_class": "local_polling", "render_readme": true }