diff --git a/custom_components/ha_creality_ws/manifest.json b/custom_components/ha_creality_ws/manifest.json
new file mode 100644
index 00000000..87b25cb3
--- /dev/null
+++ b/custom_components/ha_creality_ws/manifest.json
@@ -0,0 +1,27 @@
+{
+ "domain": "ha_creality_ws",
+ "name": "Creality WebSocket Integration",
+ "after_dependencies": ["go2rtc"],
+ "codeowners": ["@3dg1luk43"],
+ "config_flow": true,
+ "dependencies": ["lovelace", "http", "frontend", "persistent_notification"],
+ "documentation": "https://github.com/3dg1luk43/ha_creality_ws",
+ "iot_class": "local_push",
+ "issue_tracker": "https://github.com/3dg1luk43/ha_creality_ws/issues",
+ "loggers": ["custom_components.ha_creality_ws"],
+ "requirements": [
+ "websockets>=10.4",
+ "go2rtc-client>=0.1.0"
+ ],
+ "version": "0.9.7",
+ "zeroconf": [
+ { "type": "_http._tcp.local.", "name": "*creality*" },
+ { "type": "_workstation._tcp.local.", "name": "*creality*" },
+ { "type": "_http._tcp.local.", "name": "*k1*" },
+ { "type": "_workstation._tcp.local.", "name": "*k1*" },
+ { "type": "_http._tcp.local.", "name": "*k2*" },
+ { "type": "_workstation._tcp.local.", "name": "*k2*" }
+ ]
+}
+
+
diff --git a/custom_components/ha_creality_ws/services.yaml b/custom_components/ha_creality_ws/services.yaml
new file mode 100644
index 00000000..8b582e5b
--- /dev/null
+++ b/custom_components/ha_creality_ws/services.yaml
@@ -0,0 +1,15 @@
+diagnostic_dump:
+ fields:
+ include_sensitive_data:
+ required: false
+ default: false
+ selector:
+ boolean:
+
+request_cfs_info:
+ fields:
+ device_id:
+ selector:
+ device:
+ integration: ha_creality_ws
+ multiple: true
diff --git a/custom_components/ha_creality_ws/strings.json b/custom_components/ha_creality_ws/strings.json
new file mode 100644
index 00000000..badbe9f4
--- /dev/null
+++ b/custom_components/ha_creality_ws/strings.json
@@ -0,0 +1,198 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "title": "Connect to Creality Printer",
+ "description": "Enter the IP address of your Creality K-series printer. The printer must be unlocked (root access not required) and on the same network.",
+ "data": {
+ "host": "IP Address",
+ "name": "Friendly Name"
+ }
+ }
+ },
+ "error": {
+ "cannot_connect": "Failed to connect to printer. Ensure IP is correct and printer is ON.",
+ "not_K": "Connection refused or not a supported Creality K-series printer.",
+ "already_configured": "This printer is already configured."
+ },
+ "abort": {
+ "already_configured": "Device is already configured",
+ "cannot_connect": "Failed to connect",
+ "not_K": "Not a K-series printer"
+ }
+ },
+ "options": {
+ "step": {
+ "init": {
+ "title": "Printer Configuration",
+ "description": "Choose a settings group to edit. Each group opens its own page so its options always load fresh.",
+ "menu_options": {
+ "camera": "Camera",
+ "notifications": "Notifications",
+ "power": "Power switch",
+ "connection": "Connection & performance",
+ "save": "Save and apply"
+ }
+ },
+ "camera": {
+ "title": "Camera",
+ "description": "{camera_help}",
+ "data": {
+ "camera_mode": "Camera Streaming Mode",
+ "go2rtc_url": "External go2rtc Host/URL (if needed)",
+ "go2rtc_port": "External go2rtc Port",
+ "custom_camera_url": "Custom Camera URL (http(s) MJPEG/snapshot or rtsp://)"
+ }
+ },
+ "notifications": {
+ "title": "Notifications",
+ "data": {
+ "notify_device": "Notification Device",
+ "notify_completed": "Notify when Completed",
+ "notify_error": "Notify on Error",
+ "notify_minutes_to_end": "Notify before Completion",
+ "minutes_to_end_value": "Minutes before Completion"
+ }
+ },
+ "power": {
+ "title": "Power switch",
+ "description": "{power_help}",
+ "data": {
+ "power_switch_enabled": "Enable Power Switch Detection",
+ "power_switch": "Power Switch Entity"
+ }
+ },
+ "connection": {
+ "title": "Connection & performance",
+ "data": {
+ "host": "Printer IP Address",
+ "polling_rate": "Polling Rate (seconds, 0 = real-time)"
+ }
+ }
+ },
+ "error": {
+ "invalid_camera_url": "Enter a full URL including the scheme, e.g. http://192.168.1.50:8080/?action=stream or rtsp://192.168.1.50:554/stream."
+ }
+ },
+ "entity": {
+ "sensor": {
+ "bed_temperature": { "name": "Bed Temperature" },
+ "chamber_temperature": { "name": "Chamber Temperature" },
+ "nozzle_temperature": { "name": "Nozzle Temperature" },
+ "print_progress": { "name": "Print Progress" },
+ "total_layers": { "name": "Total Layers" },
+ "current_layer": { "name": "Working Layer" },
+ "position_x": { "name": "Position X" },
+ "position_y": { "name": "Position Y" },
+ "position_z": { "name": "Position Z" },
+ "feedrate_pct": { "name": "Print Speed" },
+ "flowrate_pct": { "name": "Flow Rate" },
+ "system": { "name": "System" },
+ "model_info": { "name": "Model" },
+ "filament_status": {
+ "name": "Filament Status",
+ "state": {
+ "normal": "Normal",
+ "runout": "Filament Runout",
+ "unknown": "Unknown"
+ }
+ },
+ "print_status": {
+ "name": "Print Status",
+ "state": {
+ "off": "Off",
+ "unknown": "Unknown",
+ "error": "Error",
+ "self-testing": "Self-Testing",
+ "completed": "Completed",
+ "paused": "Paused",
+ "stopped": "Stopped",
+ "printing": "Printing",
+ "processing": "Processing",
+ "idle": "Idle"
+ }
+ },
+ "used_material_length": { "name": "Used Material Length" },
+ "print_job_time": { "name": "Print Job Time" },
+ "print_left_time": { "name": "Print Time Left" },
+ "real_time_flow": { "name": "Real-Time Flow" },
+ "current_object": { "name": "Current Object" },
+ "object_count": { "name": "Object Count" },
+ "print_control": {
+ "name": "Print Control",
+ "state": {
+ "queued": "Queued",
+ "ok": "OK",
+ "unknown": "Unknown"
+ }
+ },
+ "max_nozzle_temp": { "name": "Max Nozzle Temperature" },
+ "max_bed_temp": { "name": "Max Bed Temperature" },
+ "max_chamber_temp": { "name": "Max Chamber Temperature" },
+ "active_filament_slot": { "name": "Active Filament Slot" },
+ "cfs_box_temp": { "name": "CFS Box {box_id} Temperature" },
+ "cfs_box_humidity": { "name": "CFS Box {box_id} Humidity" },
+ "cfs_slot_filament": { "name": "CFS Box {box_id} Slot {slot} Filament" },
+ "cfs_slot_color": { "name": "CFS Box {box_id} Slot {slot} Color" },
+ "cfs_slot_percent": { "name": "CFS Box {box_id} Slot {slot} Remaining" },
+ "cfs_ext_filament": { "name": "CFS External Filament" },
+ "cfs_ext_color": { "name": "CFS External Color" },
+ "cfs_ext_percent": { "name": "CFS External Remaining" }
+ },
+ "button": {
+ "home_xyz": { "name": "Home (XY then Z)" },
+ "pause_print": { "name": "Pause Print" },
+ "resume_print": { "name": "Resume Print" },
+ "stop_print": { "name": "Stop Print" },
+ "reconnect": { "name": "Reconnect" }
+ },
+ "number": {
+ "print_tuning_pct": { "name": "Print Tuning" },
+ "nozzle_target": { "name": "Nozzle Target" },
+ "bed_target": { "name": "Bed Target" },
+ "chamber_target": { "name": "Chamber Target" },
+ "model_fan_pct": { "name": "Model Fan" },
+ "case_fan_pct": { "name": "Case Fan" },
+ "side_fan_pct": { "name": "Side Fan" }
+ },
+ "fan": {
+ "model_fan": { "name": "Model Fan" },
+ "case_fan": { "name": "Case Fan" },
+ "side_fan": { "name": "Side Fan" }
+ },
+ "light": {
+ "light": { "name": "Light" }
+ },
+ "switch": {
+ "light": { "name": "Light" }
+ },
+ "camera": {
+ "printer_camera": { "name": "Printer Camera" }
+ },
+ "image": {
+ "current_print_preview": { "name": "Current Print Preview" }
+ }
+ },
+ "services": {
+ "diagnostic_dump": {
+ "name": "Diagnostic Dump",
+ "description": "Dump all WebSocket telemetry data from connected Creality printers to JSON format for troubleshooting and analysis.",
+ "fields": {
+ "include_sensitive_data": {
+ "name": "Include Sensitive Data",
+ "description": "Whether to include potentially sensitive data in the diagnostic output (currently unused, reserved for future use)"
+ }
+ }
+ },
+ "request_cfs_info": {
+ "name": "Request CFS Info",
+ "description": "Manually request the printer to send the latest CFS (multifilament) information including temperatures, humidity, and filament details.",
+ "fields": {
+ "device_id": {
+ "name": "Device",
+ "description": "The printer(s) to request info from. If omitted, all connected printers will be queried."
+ }
+ }
+ }
+ }
+}
diff --git a/custom_components/ha_creality_ws/translations/en.json b/custom_components/ha_creality_ws/translations/en.json
new file mode 100644
index 00000000..badbe9f4
--- /dev/null
+++ b/custom_components/ha_creality_ws/translations/en.json
@@ -0,0 +1,198 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "title": "Connect to Creality Printer",
+ "description": "Enter the IP address of your Creality K-series printer. The printer must be unlocked (root access not required) and on the same network.",
+ "data": {
+ "host": "IP Address",
+ "name": "Friendly Name"
+ }
+ }
+ },
+ "error": {
+ "cannot_connect": "Failed to connect to printer. Ensure IP is correct and printer is ON.",
+ "not_K": "Connection refused or not a supported Creality K-series printer.",
+ "already_configured": "This printer is already configured."
+ },
+ "abort": {
+ "already_configured": "Device is already configured",
+ "cannot_connect": "Failed to connect",
+ "not_K": "Not a K-series printer"
+ }
+ },
+ "options": {
+ "step": {
+ "init": {
+ "title": "Printer Configuration",
+ "description": "Choose a settings group to edit. Each group opens its own page so its options always load fresh.",
+ "menu_options": {
+ "camera": "Camera",
+ "notifications": "Notifications",
+ "power": "Power switch",
+ "connection": "Connection & performance",
+ "save": "Save and apply"
+ }
+ },
+ "camera": {
+ "title": "Camera",
+ "description": "{camera_help}",
+ "data": {
+ "camera_mode": "Camera Streaming Mode",
+ "go2rtc_url": "External go2rtc Host/URL (if needed)",
+ "go2rtc_port": "External go2rtc Port",
+ "custom_camera_url": "Custom Camera URL (http(s) MJPEG/snapshot or rtsp://)"
+ }
+ },
+ "notifications": {
+ "title": "Notifications",
+ "data": {
+ "notify_device": "Notification Device",
+ "notify_completed": "Notify when Completed",
+ "notify_error": "Notify on Error",
+ "notify_minutes_to_end": "Notify before Completion",
+ "minutes_to_end_value": "Minutes before Completion"
+ }
+ },
+ "power": {
+ "title": "Power switch",
+ "description": "{power_help}",
+ "data": {
+ "power_switch_enabled": "Enable Power Switch Detection",
+ "power_switch": "Power Switch Entity"
+ }
+ },
+ "connection": {
+ "title": "Connection & performance",
+ "data": {
+ "host": "Printer IP Address",
+ "polling_rate": "Polling Rate (seconds, 0 = real-time)"
+ }
+ }
+ },
+ "error": {
+ "invalid_camera_url": "Enter a full URL including the scheme, e.g. http://192.168.1.50:8080/?action=stream or rtsp://192.168.1.50:554/stream."
+ }
+ },
+ "entity": {
+ "sensor": {
+ "bed_temperature": { "name": "Bed Temperature" },
+ "chamber_temperature": { "name": "Chamber Temperature" },
+ "nozzle_temperature": { "name": "Nozzle Temperature" },
+ "print_progress": { "name": "Print Progress" },
+ "total_layers": { "name": "Total Layers" },
+ "current_layer": { "name": "Working Layer" },
+ "position_x": { "name": "Position X" },
+ "position_y": { "name": "Position Y" },
+ "position_z": { "name": "Position Z" },
+ "feedrate_pct": { "name": "Print Speed" },
+ "flowrate_pct": { "name": "Flow Rate" },
+ "system": { "name": "System" },
+ "model_info": { "name": "Model" },
+ "filament_status": {
+ "name": "Filament Status",
+ "state": {
+ "normal": "Normal",
+ "runout": "Filament Runout",
+ "unknown": "Unknown"
+ }
+ },
+ "print_status": {
+ "name": "Print Status",
+ "state": {
+ "off": "Off",
+ "unknown": "Unknown",
+ "error": "Error",
+ "self-testing": "Self-Testing",
+ "completed": "Completed",
+ "paused": "Paused",
+ "stopped": "Stopped",
+ "printing": "Printing",
+ "processing": "Processing",
+ "idle": "Idle"
+ }
+ },
+ "used_material_length": { "name": "Used Material Length" },
+ "print_job_time": { "name": "Print Job Time" },
+ "print_left_time": { "name": "Print Time Left" },
+ "real_time_flow": { "name": "Real-Time Flow" },
+ "current_object": { "name": "Current Object" },
+ "object_count": { "name": "Object Count" },
+ "print_control": {
+ "name": "Print Control",
+ "state": {
+ "queued": "Queued",
+ "ok": "OK",
+ "unknown": "Unknown"
+ }
+ },
+ "max_nozzle_temp": { "name": "Max Nozzle Temperature" },
+ "max_bed_temp": { "name": "Max Bed Temperature" },
+ "max_chamber_temp": { "name": "Max Chamber Temperature" },
+ "active_filament_slot": { "name": "Active Filament Slot" },
+ "cfs_box_temp": { "name": "CFS Box {box_id} Temperature" },
+ "cfs_box_humidity": { "name": "CFS Box {box_id} Humidity" },
+ "cfs_slot_filament": { "name": "CFS Box {box_id} Slot {slot} Filament" },
+ "cfs_slot_color": { "name": "CFS Box {box_id} Slot {slot} Color" },
+ "cfs_slot_percent": { "name": "CFS Box {box_id} Slot {slot} Remaining" },
+ "cfs_ext_filament": { "name": "CFS External Filament" },
+ "cfs_ext_color": { "name": "CFS External Color" },
+ "cfs_ext_percent": { "name": "CFS External Remaining" }
+ },
+ "button": {
+ "home_xyz": { "name": "Home (XY then Z)" },
+ "pause_print": { "name": "Pause Print" },
+ "resume_print": { "name": "Resume Print" },
+ "stop_print": { "name": "Stop Print" },
+ "reconnect": { "name": "Reconnect" }
+ },
+ "number": {
+ "print_tuning_pct": { "name": "Print Tuning" },
+ "nozzle_target": { "name": "Nozzle Target" },
+ "bed_target": { "name": "Bed Target" },
+ "chamber_target": { "name": "Chamber Target" },
+ "model_fan_pct": { "name": "Model Fan" },
+ "case_fan_pct": { "name": "Case Fan" },
+ "side_fan_pct": { "name": "Side Fan" }
+ },
+ "fan": {
+ "model_fan": { "name": "Model Fan" },
+ "case_fan": { "name": "Case Fan" },
+ "side_fan": { "name": "Side Fan" }
+ },
+ "light": {
+ "light": { "name": "Light" }
+ },
+ "switch": {
+ "light": { "name": "Light" }
+ },
+ "camera": {
+ "printer_camera": { "name": "Printer Camera" }
+ },
+ "image": {
+ "current_print_preview": { "name": "Current Print Preview" }
+ }
+ },
+ "services": {
+ "diagnostic_dump": {
+ "name": "Diagnostic Dump",
+ "description": "Dump all WebSocket telemetry data from connected Creality printers to JSON format for troubleshooting and analysis.",
+ "fields": {
+ "include_sensitive_data": {
+ "name": "Include Sensitive Data",
+ "description": "Whether to include potentially sensitive data in the diagnostic output (currently unused, reserved for future use)"
+ }
+ }
+ },
+ "request_cfs_info": {
+ "name": "Request CFS Info",
+ "description": "Manually request the printer to send the latest CFS (multifilament) information including temperatures, humidity, and filament details.",
+ "fields": {
+ "device_id": {
+ "name": "Device",
+ "description": "The printer(s) to request info from. If omitted, all connected printers will be queried."
+ }
+ }
+ }
+ }
+}
diff --git a/custom_components/ha_creality_ws/translations/es.json b/custom_components/ha_creality_ws/translations/es.json
new file mode 100644
index 00000000..f60c84d4
--- /dev/null
+++ b/custom_components/ha_creality_ws/translations/es.json
@@ -0,0 +1,198 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "title": "Conectar a la impresora Creality",
+ "description": "Introduce la dirección IP de tu impresora Creality de la serie K. La impresora debe estar desbloqueada (no se requiere acceso de superusuario) y en la misma red.",
+ "data": {
+ "host": "Dirección IP",
+ "name": "Nombre descriptivo"
+ }
+ }
+ },
+ "error": {
+ "cannot_connect": "Error al conectar con la impresora. Asegúrate de que la dirección IP sea correcta y de que la impresora esté encendida.",
+ "not_K": "Conexión rechazada o no es una impresora Creality de la serie K compatible.",
+ "already_configured": "Esta impresora ya está configurada."
+ },
+ "abort": {
+ "already_configured": "El dispositivo ya está configurado",
+ "cannot_connect": "Error de conexión",
+ "not_K": "No es una impresora de la serie K"
+ }
+ },
+ "options": {
+ "step": {
+ "init": {
+ "title": "Configuración de la impresora",
+ "description": "Elige un grupo de ajustes para editar. Cada grupo se abre en su propia página, por lo que sus opciones siempre se cargan actualizadas.",
+ "menu_options": {
+ "camera": "Cámara",
+ "notifications": "Notificaciones",
+ "power": "Interruptor de encendido",
+ "connection": "Conexión y rendimiento",
+ "save": "Guardar y aplicar"
+ }
+ },
+ "camera": {
+ "title": "Cámara",
+ "description": "{camera_help}",
+ "data": {
+ "camera_mode": "Modo de transmisión de la cámara",
+ "go2rtc_url": "Servidor o dirección externa de go2rtc (si es necesario)",
+ "go2rtc_port": "Puerto externo de go2rtc",
+ "custom_camera_url": "URL de cámara personalizada (MJPEG/snapshot http(s) o rtsp://)"
+ }
+ },
+ "notifications": {
+ "title": "Notificaciones",
+ "data": {
+ "notify_device": "Dispositivo de notificación",
+ "notify_completed": "Notificar al finalizar",
+ "notify_error": "Notificar en caso de error",
+ "notify_minutes_to_end": "Notificar antes de finalizar",
+ "minutes_to_end_value": "Minutos antes de finalizar"
+ }
+ },
+ "power": {
+ "title": "Interruptor de encendido",
+ "description": "{power_help}",
+ "data": {
+ "power_switch_enabled": "Activar la detección del interruptor de encendido",
+ "power_switch": "Entidad del interruptor de encendido"
+ }
+ },
+ "connection": {
+ "title": "Conexión y rendimiento",
+ "data": {
+ "host": "Dirección IP de la impresora",
+ "polling_rate": "Frecuencia de actualización (segundos, 0 = tiempo real)"
+ }
+ }
+ },
+ "error": {
+ "invalid_camera_url": "Introduce una URL completa incluyendo el esquema, p. ej. http://192.168.1.50:8080/?action=stream o rtsp://192.168.1.50:554/stream."
+ }
+ },
+ "entity": {
+ "sensor": {
+ "bed_temperature": { "name": "Temperatura de la cama" },
+ "chamber_temperature": { "name": "Temperatura de la cámara" },
+ "nozzle_temperature": { "name": "Temperatura de la boquilla" },
+ "print_progress": { "name": "Progreso de impresión" },
+ "total_layers": { "name": "Capas totales" },
+ "current_layer": { "name": "Capa actual" },
+ "position_x": { "name": "Posición X" },
+ "position_y": { "name": "Posición Y" },
+ "position_z": { "name": "Posición Z" },
+ "feedrate_pct": { "name": "Velocidad de impresión" },
+ "flowrate_pct": { "name": "Tasa de flujo" },
+ "system": { "name": "Sistema" },
+ "model_info": { "name": "Modelo" },
+ "filament_status": {
+ "name": "Estado del filamento",
+ "state": {
+ "normal": "Normal",
+ "runout": "Filamento agotado",
+ "unknown": "Desconocido"
+ }
+ },
+ "print_status": {
+ "name": "Estado de impresión",
+ "state": {
+ "off": "Apagada",
+ "unknown": "Desconocido",
+ "error": "Error",
+ "self-testing": "Autocomprobación",
+ "completed": "Completada",
+ "paused": "En pausa",
+ "stopped": "Detenida",
+ "printing": "Imprimiendo",
+ "processing": "Procesando",
+ "idle": "En reposo"
+ }
+ },
+ "used_material_length": { "name": "Material usado" },
+ "print_job_time": { "name": "Tiempo de trabajo de impresión" },
+ "print_left_time": { "name": "Tiempo restante de impresión" },
+ "real_time_flow": { "name": "Flujo en tiempo real" },
+ "current_object": { "name": "Objeto actual" },
+ "object_count": { "name": "Cantidad de objetos" },
+ "print_control": {
+ "name": "Control de impresión",
+ "state": {
+ "queued": "En cola",
+ "ok": "OK",
+ "unknown": "Desconocido"
+ }
+ },
+ "max_nozzle_temp": { "name": "Temperatura máxima de la boquilla" },
+ "max_bed_temp": { "name": "Temperatura máxima de la cama" },
+ "max_chamber_temp": { "name": "Temperatura máxima de la cámara" },
+ "active_filament_slot": { "name": "Ranura de filamento activa" },
+ "cfs_box_temp": { "name": "Temperatura de la caja CFS {box_id}" },
+ "cfs_box_humidity": { "name": "Humedad de la caja CFS {box_id}" },
+ "cfs_slot_filament": { "name": "Filamento — caja CFS {box_id}, ranura {slot}" },
+ "cfs_slot_color": { "name": "Color — caja CFS {box_id}, ranura {slot}" },
+ "cfs_slot_percent": { "name": "Restante — caja CFS {box_id}, ranura {slot}" },
+ "cfs_ext_filament": { "name": "Filamento externo CFS" },
+ "cfs_ext_color": { "name": "Color externo CFS" },
+ "cfs_ext_percent": { "name": "Restante externo CFS" }
+ },
+ "button": {
+ "home_xyz": { "name": "Inicio (XY y luego Z)" },
+ "pause_print": { "name": "Pausar la impresión" },
+ "resume_print": { "name": "Reanudar la impresión" },
+ "stop_print": { "name": "Detener la impresión" },
+ "reconnect": { "name": "Reconectar" }
+ },
+ "number": {
+ "print_tuning_pct": { "name": "Ajuste de impresión" },
+ "nozzle_target": { "name": "Objetivo de la boquilla" },
+ "bed_target": { "name": "Objetivo de la cama" },
+ "chamber_target": { "name": "Objetivo de la cámara" },
+ "model_fan_pct": { "name": "Ventilador del modelo" },
+ "case_fan_pct": { "name": "Ventilador de la carcasa" },
+ "side_fan_pct": { "name": "Ventilador lateral" }
+ },
+ "fan": {
+ "model_fan": { "name": "Ventilador del modelo" },
+ "case_fan": { "name": "Ventilador de la carcasa" },
+ "side_fan": { "name": "Ventilador lateral" }
+ },
+ "light": {
+ "light": { "name": "Luz" }
+ },
+ "switch": {
+ "light": { "name": "Luz" }
+ },
+ "camera": {
+ "printer_camera": { "name": "Cámara de la impresora" }
+ },
+ "image": {
+ "current_print_preview": { "name": "Vista previa de la impresión actual" }
+ }
+ },
+ "services": {
+ "diagnostic_dump": {
+ "name": "Volcado de diagnóstico",
+ "description": "Vuelca todos los datos de telemetría WebSocket de las impresoras Creality conectadas en formato JSON para resolución de problemas y análisis.",
+ "fields": {
+ "include_sensitive_data": {
+ "name": "Incluir datos sensibles",
+ "description": "Si se deben incluir datos potencialmente sensibles en la salida de diagnóstico (actualmente sin uso, reservado para uso futuro)"
+ }
+ }
+ },
+ "request_cfs_info": {
+ "name": "Solicitar información del CFS",
+ "description": "Solicita manualmente a la impresora que envíe la última información del CFS (sistema multifilamento), incluyendo temperaturas, humedad y detalles del filamento.",
+ "fields": {
+ "device_id": {
+ "name": "Dispositivo",
+ "description": "La(s) impresora(s) a las que solicitar información. Si se omite, se consultarán todas las impresoras conectadas."
+ }
+ }
+ }
+ }
+}
diff --git a/custom_components/ha_creality_ws/www/i18n/en.json b/custom_components/ha_creality_ws/www/i18n/en.json
new file mode 100644
index 00000000..fc0345a5
--- /dev/null
+++ b/custom_components/ha_creality_ws/www/i18n/en.json
@@ -0,0 +1,112 @@
+{
+ "printer_card": {
+ "status_unknown": "Unknown",
+ "confirm_stop": "Are you sure you want to stop the print?",
+ "confirm_power_off": "Are you sure you want to power off the printer?",
+ "confirm_power_off_printing": "A print is in progress. Are you sure you want to power off the printer?",
+ "chip_pause": "Pause",
+ "chip_resume": "Resume",
+ "chip_stop": "Stop",
+ "chip_light": "Light",
+ "chip_power": "Power",
+ "chip_custom": "Custom Action",
+ "editor_title": "Creality Printer Card Configuration",
+ "tab_entities": "Entities",
+ "tab_theme": "Theme",
+ "group_layout": "Layout & Icons",
+ "group_action_colors": "Action Colors",
+ "group_status_area": "Status Area",
+ "group_telemetry": "Telemetry",
+ "color_pause_bg": "Pause Button Background",
+ "color_pause_icon": "Pause Button Icon",
+ "color_resume_bg": "Resume Button Background",
+ "color_resume_icon": "Resume Button Icon",
+ "color_stop_bg": "Stop Button Background",
+ "color_stop_icon": "Stop Button Icon",
+ "color_light_on_bg": "Light On Background",
+ "color_light_off_bg": "Light Off Background",
+ "color_light_icon_on": "Light Button Icon (On)",
+ "color_light_icon_off": "Light Button Icon (Off)",
+ "color_custom_bg": "Custom Button Background",
+ "color_custom_icon": "Custom Button Icon",
+ "color_status_icon": "Status Icon Color",
+ "color_progress_ring": "Progress Ring Color",
+ "color_status_bg": "Status Background",
+ "color_telemetry_icon": "Telemetry Icon Color",
+ "color_telemetry_text": "Telemetry Text Color",
+ "btn_save": "Save",
+ "btn_reset": "Reset to Defaults",
+ "color_picker_hint": "Click to open color picker",
+ "label_name": "Printer Name",
+ "label_camera": "Camera",
+ "label_status": "Print Status Sensor",
+ "label_progress": "Print Progress Sensor (%)",
+ "label_time_left": "Time Left Sensor",
+ "label_nozzle": "Nozzle Temperature Sensor",
+ "label_bed": "Bed Temperature Sensor",
+ "label_box": "Chamber Temperature Sensor",
+ "label_power": "Power Switch",
+ "label_show_power_button": "Show Power Button",
+ "label_layer": "Current Layer Sensor",
+ "label_total_layers": "Total Layers Sensor",
+ "label_light": "Light Switch",
+ "label_pause_btn": "Pause Button",
+ "label_resume_btn": "Resume Button",
+ "label_stop_btn": "Stop Button",
+ "label_custom_btn": "Custom Action Entity",
+ "label_custom_btn_icon": "Custom Button Icon",
+ "label_custom_btn_hidden": "Hide Custom Button",
+ "label_button_order": "Button Order (list)",
+ "helper_name": "Display name for the printer card",
+ "helper_camera": "Camera entity for live video feed",
+ "helper_status": "Sensor showing current print status",
+ "helper_progress": "Sensor showing print progress (0-100%)",
+ "helper_time_left": "Sensor showing remaining print time (seconds)",
+ "helper_nozzle": "Sensor showing nozzle temperature",
+ "helper_bed": "Sensor showing bed temperature",
+ "helper_box": "Sensor showing chamber/enclosure temperature (optional)",
+ "helper_power": "Optional power switch entity for the printer (shows a Power button when set)",
+ "helper_show_power_button": "Show the Power button when a power switch entity is configured",
+ "helper_layer": "Sensor showing current print layer",
+ "helper_total_layers": "Sensor showing total print layers",
+ "helper_light": "Switch entity for printer light control",
+ "helper_pause_btn": "Button entity to pause printing",
+ "helper_resume_btn": "Button entity to resume printing",
+ "helper_stop_btn": "Button entity to stop printing",
+ "helper_custom_btn": "Any entity to trigger (Button, Script, Switch, etc.)",
+ "helper_custom_btn_icon": "Icon for the custom button",
+ "helper_custom_btn_hidden": "Hide the custom button",
+ "helper_button_order": "List of buttons to show in order (pause, resume, stop, light, power, custom)",
+ "schema_button_order": "Button Order (pause, resume, stop, light, power, custom)",
+ "schema_hide_custom": "Hide Custom Button",
+ "schema_hide_box_temp": "Hide Chamber Temperature",
+ "schema_pause_icon": "Pause Icon Override",
+ "schema_resume_icon": "Resume Icon Override",
+ "schema_stop_icon": "Stop Icon Override",
+ "schema_light_icon": "Light Icon Override",
+ "schema_power_icon": "Power Icon Override",
+ "schema_custom_icon": "Custom Button Icon",
+ "editor_error_title": "Editor Error",
+ "editor_error_msg": "There was an error loading the visual editor. You can still edit your configuration using YAML.",
+ "editor_error_prefix": "Error:"
+ },
+ "cfs_card": {
+ "no_data": "No CFS data available",
+ "ext_label": "EXT",
+ "cfs_label": "CFS",
+ "cfs_number_label": "CFS {number}",
+ "label_card_title": "Card Title",
+ "label_external_filament": "External Filament",
+ "label_external_color": "External Color",
+ "label_external_percent": "External Percent",
+ "label_box_temp": "Box {box} Temperature",
+ "label_box_humidity": "Box {box} Humidity",
+ "label_slot_filament": "Box {box} Slot {slot} Filament",
+ "label_slot_color": "Box {box} Slot {slot} Color",
+ "label_slot_percent": "Box {box} Slot {slot} Remaining Percent",
+ "schema_compact_view": "Compact View (Mini Mode)",
+ "schema_show_type_in_mini": "Show Filament Type in Mini Mode",
+ "tab_entities": "Entities",
+ "tab_theme": "Theme"
+ }
+}
diff --git a/custom_components/ha_creality_ws/www/i18n/es.json b/custom_components/ha_creality_ws/www/i18n/es.json
new file mode 100644
index 00000000..0b885a3b
--- /dev/null
+++ b/custom_components/ha_creality_ws/www/i18n/es.json
@@ -0,0 +1,112 @@
+{
+ "printer_card": {
+ "status_unknown": "Desconocido",
+ "confirm_stop": "¿Seguro que deseas detener la impresión?",
+ "confirm_power_off": "¿Seguro que deseas apagar la impresora?",
+ "confirm_power_off_printing": "Hay una impresión en curso. ¿Seguro que deseas apagar la impresora?",
+ "chip_pause": "Pausar",
+ "chip_resume": "Reanudar",
+ "chip_stop": "Detener",
+ "chip_light": "Luz",
+ "chip_power": "Encendido",
+ "chip_custom": "Acción personalizada",
+ "editor_title": "Configuración de la tarjeta Creality",
+ "tab_entities": "Entidades",
+ "tab_theme": "Tema",
+ "group_layout": "Disposición e iconos",
+ "group_action_colors": "Colores de acción",
+ "group_status_area": "Área de estado",
+ "group_telemetry": "Telemetría",
+ "color_pause_bg": "Fondo del botón Pausar",
+ "color_pause_icon": "Icono del botón Pausar",
+ "color_resume_bg": "Fondo del botón Reanudar",
+ "color_resume_icon": "Icono del botón Reanudar",
+ "color_stop_bg": "Fondo del botón Detener",
+ "color_stop_icon": "Icono del botón Detener",
+ "color_light_on_bg": "Fondo de la luz encendida",
+ "color_light_off_bg": "Fondo de la luz apagada",
+ "color_light_icon_on": "Icono de la luz (encendida)",
+ "color_light_icon_off": "Icono de la luz (apagada)",
+ "color_custom_bg": "Fondo del botón personalizado",
+ "color_custom_icon": "Icono del botón personalizado",
+ "color_status_icon": "Color del icono de estado",
+ "color_progress_ring": "Color del anillo de progreso",
+ "color_status_bg": "Fondo de estado",
+ "color_telemetry_icon": "Color del icono de telemetría",
+ "color_telemetry_text": "Color del texto de telemetría",
+ "btn_save": "Guardar",
+ "btn_reset": "Restablecer valores predeterminados",
+ "color_picker_hint": "Haz clic para abrir el selector de color",
+ "label_name": "Nombre de la impresora",
+ "label_camera": "Cámara",
+ "label_status": "Sensor de estado de impresión",
+ "label_progress": "Sensor de progreso de impresión (%)",
+ "label_time_left": "Sensor de tiempo restante",
+ "label_nozzle": "Sensor de temperatura de la boquilla",
+ "label_bed": "Sensor de temperatura de la cama",
+ "label_box": "Sensor de temperatura de la cámara",
+ "label_power": "Interruptor de encendido",
+ "label_show_power_button": "Mostrar el botón de encendido",
+ "label_layer": "Sensor de capa actual",
+ "label_total_layers": "Sensor de capas totales",
+ "label_light": "Interruptor de la luz",
+ "label_pause_btn": "Botón de pausa",
+ "label_resume_btn": "Botón de reanudación",
+ "label_stop_btn": "Botón de parada",
+ "label_custom_btn": "Entidad de acción personalizada",
+ "label_custom_btn_icon": "Icono del botón personalizado",
+ "label_custom_btn_hidden": "Ocultar el botón personalizado",
+ "label_button_order": "Orden de los botones (lista)",
+ "helper_name": "Nombre de visualización de la tarjeta de la impresora",
+ "helper_camera": "Entidad de cámara para vídeo en directo",
+ "helper_status": "Sensor que muestra el estado de impresión actual",
+ "helper_progress": "Sensor que muestra el progreso de impresión (0-100 %)",
+ "helper_time_left": "Sensor que muestra el tiempo de impresión restante (segundos)",
+ "helper_nozzle": "Sensor que muestra la temperatura de la boquilla",
+ "helper_bed": "Sensor que muestra la temperatura de la cama",
+ "helper_box": "Sensor que muestra la temperatura de la cámara/recinto (opcional)",
+ "helper_power": "Entidad del interruptor de encendido opcional (muestra un botón de encendido cuando se establece)",
+ "helper_show_power_button": "Mostrar el botón de encendido cuando hay un interruptor configurado",
+ "helper_layer": "Sensor que muestra la capa de impresión actual",
+ "helper_total_layers": "Sensor que muestra el total de capas de impresión",
+ "helper_light": "Entidad de interruptor para el control de la luz de la impresora",
+ "helper_pause_btn": "Entidad de botón para pausar la impresión",
+ "helper_resume_btn": "Entidad de botón para reanudar la impresión",
+ "helper_stop_btn": "Entidad de botón para detener la impresión",
+ "helper_custom_btn": "Cualquier entidad a activar (botón, script, interruptor, etc.)",
+ "helper_custom_btn_icon": "Icono del botón personalizado",
+ "helper_custom_btn_hidden": "Ocultar el botón personalizado",
+ "helper_button_order": "Lista de botones en orden (pause, resume, stop, light, power, custom)",
+ "schema_button_order": "Orden de los botones (pause, resume, stop, light, power, custom)",
+ "schema_hide_custom": "Ocultar el botón personalizado",
+ "schema_hide_box_temp": "Ocultar la temperatura de la cámara",
+ "schema_pause_icon": "Icono de pausa (sustitución)",
+ "schema_resume_icon": "Icono de reanudación (sustitución)",
+ "schema_stop_icon": "Icono de parada (sustitución)",
+ "schema_light_icon": "Icono de la luz (sustitución)",
+ "schema_power_icon": "Icono de encendido (sustitución)",
+ "schema_custom_icon": "Icono del botón personalizado",
+ "editor_error_title": "Error del editor",
+ "editor_error_msg": "Se ha producido un error al cargar el editor visual. Aún puedes editar la configuración en YAML.",
+ "editor_error_prefix": "Error:"
+ },
+ "cfs_card": {
+ "no_data": "No hay datos del CFS disponibles",
+ "ext_label": "EXT",
+ "cfs_label": "CFS",
+ "cfs_number_label": "CFS {number}",
+ "label_card_title": "Título de la tarjeta",
+ "label_external_filament": "Filamento externo",
+ "label_external_color": "Color externo",
+ "label_external_percent": "Porcentaje externo",
+ "label_box_temp": "Temperatura de la caja {box}",
+ "label_box_humidity": "Humedad de la caja {box}",
+ "label_slot_filament": "Filamento — caja {box}, ranura {slot}",
+ "label_slot_color": "Color — caja {box}, ranura {slot}",
+ "label_slot_percent": "Restante — caja {box}, ranura {slot}",
+ "schema_compact_view": "Vista compacta (modo mini)",
+ "schema_show_type_in_mini": "Mostrar el tipo de filamento en modo mini",
+ "tab_entities": "Entidades",
+ "tab_theme": "Tema"
+ }
+}
diff --git a/custom_components/ha_creality_ws/www/k_cfs_card.js b/custom_components/ha_creality_ws/www/k_cfs_card.js
new file mode 100644
index 00000000..0a79168d
--- /dev/null
+++ b/custom_components/ha_creality_ws/www/k_cfs_card.js
@@ -0,0 +1,1221 @@
+
+const CARD_TAG = "k-cfs-card";
+const EDITOR_TAG = "k-cfs-card-editor";
+
+const mdi = (name) => `mdi:${name}`;
+
+const I18N_URL_BASE = "/ha_creality_ws/i18n/";
+const _i18nData = {};
+const _i18nPromises = {};
+function _loadI18n(lang) {
+ if (_i18nData[lang]) return Promise.resolve(_i18nData[lang]);
+ if (_i18nPromises[lang]) return _i18nPromises[lang];
+ _i18nPromises[lang] = fetch(`${I18N_URL_BASE}${lang}.json`)
+ .then((res) => (res.ok ? res.json() : null))
+ .then((data) => {
+ if (data) _i18nData[lang] = data;
+ else _i18nPromises[lang] = null;
+ return data;
+ })
+ .catch(() => { _i18nPromises[lang] = null; return null; });
+ return _i18nPromises[lang];
+}
+function _resolveLang(hass) {
+ return hass?.locale?.language || hass?.language || "en";
+}
+function _translate(hass, section, fallbackDict, key, vars) {
+ const lang = _resolveLang(hass);
+ const short = lang.split("-")[0];
+ const remote = _i18nData[lang]?.[section] ?? _i18nData[short]?.[section];
+ const remoteEn = _i18nData["en"]?.[section];
+ let text = (remote && key in remote) ? remote[key]
+ : (remoteEn && key in remoteEn) ? remoteEn[key]
+ : (fallbackDict[lang]?.[key] ?? fallbackDict[short]?.[key] ?? fallbackDict["en"]?.[key] ?? key);
+ if (vars) {
+ Object.entries(vars).forEach(([k, v]) => {
+ text = text.replace(new RegExp(`\\{${k}\\}`, "g"), v);
+ });
+ }
+ return text;
+}
+function _requestI18n(instance, hass, onLoaded) {
+ if (instance._i18nRequested) return;
+ instance._i18nRequested = true;
+ const lang = _resolveLang(hass).split("-")[0];
+ Promise.all([_loadI18n("en"), lang !== "en" ? _loadI18n(lang) : null]).then(onLoaded);
+}
+
+const CFS_TRANSLATIONS = {
+ en: {
+ no_data: "No CFS data available",
+ ext_label: "EXT",
+ cfs_label: "CFS",
+ cfs_number_label: "CFS {number}",
+ // Editor
+ label_card_title: "Card Title",
+ label_external_filament: "External Filament",
+ label_external_color: "External Color",
+ label_external_percent: "External Percent",
+ label_box_temp: "Box {box} Temperature",
+ label_box_humidity: "Box {box} Humidity",
+ label_slot_filament: "Box {box} Slot {slot} Filament",
+ label_slot_color: "Box {box} Slot {slot} Color",
+ label_slot_percent: "Box {box} Slot {slot} Remaining Percent",
+ schema_compact_view: "Compact View (Mini Mode)",
+ schema_show_type_in_mini: "Show Filament Type in Mini Mode",
+ // Editor tab/header
+ tab_entities: "Entities",
+ tab_theme: "Theme",
+ },
+};
+
+class KCFSCard extends HTMLElement {
+ constructor() {
+ super();
+ this._selectedCFS = 0; // Track selected CFS tab in normal mode
+ }
+
+ static _sanitizeColor(value) {
+ const raw = String(value || "").trim();
+ if (!raw || ["unknown", "unavailable", "—"].includes(raw.toLowerCase())) {
+ return "#cccccc";
+ }
+ const hex = raw.startsWith("#") ? raw.slice(1) : raw;
+ if (hex.length === 6 && /^[0-9a-fA-F]+$/.test(hex)) {
+ return `#${hex.toLowerCase()}`;
+ }
+ if (hex.length === 3 && /^[0-9a-fA-F]+$/.test(hex)) {
+ return `#${hex.toLowerCase()}`;
+ }
+ if (hex.length === 7 && hex.startsWith("0") && /^[0-9a-fA-F]+$/.test(hex)) {
+ return `#${hex.slice(1).toLowerCase()}`;
+ }
+ return "#cccccc";
+ }
+
+ static _parsePercent(percentObj) {
+ if (!percentObj) return null;
+ const state = percentObj.state;
+ if (state === undefined || state === null) return null;
+ const s = String(state);
+ if (s === "unknown" || s === "unavailable") return null;
+ const n = Number(s);
+ if (Number.isNaN(n) || !Number.isFinite(n)) return null;
+ return Math.max(0, Math.min(100, n));
+ }
+
+ static _getHumidityColor(humidityStr) {
+ if (!humidityStr || humidityStr === "—") return '#64b5f6'; // default blue
+
+ const match = String(humidityStr).match(/(\d+\.?\d*)/);
+ if (!match) return '#64b5f6';
+
+ const value = parseFloat(match[1]);
+ if (value < 40) return '#4caf50'; // Green (0-39%) - Ideal
+ if (value < 60) return '#ff9800'; // Orange (40-59%) - Attention
+ return '#f44336'; // Red (60-100%) - Critical
+ }
+
+ static getStubConfig() {
+ const cfg = {
+ name: "CFS",
+ compact_view: false,
+ show_type_in_mini: false,
+ external_filament: "",
+ external_color: "",
+ external_percent: "",
+ };
+
+ for (let box = 0; box < 4; box += 1) {
+ cfg[`box${box}_temp`] = "";
+ cfg[`box${box}_humidity`] = "";
+ for (let slot = 0; slot < 4; slot += 1) {
+ cfg[`box${box}_slot${slot}_filament`] = "";
+ cfg[`box${box}_slot${slot}_color`] = "";
+ cfg[`box${box}_slot${slot}_percent`] = "";
+ }
+ }
+
+ return cfg;
+ }
+
+ static getConfigElement() {
+ return document.createElement(EDITOR_TAG);
+ }
+
+ setConfig(config) {
+ this._cfg = { ...KCFSCard.getStubConfig(), ...config };
+ if (!this._root) {
+ this._root = this.attachShadow({ mode: "open" });
+ }
+ this._render();
+ }
+
+ // i18n helpers -------------------------------------------------------
+ _resolveLanguage() {
+ return _resolveLang(this._hass);
+ }
+ _t(key, vars) {
+ return _translate(this._hass, "cfs_card", CFS_TRANSLATIONS, key, vars);
+ }
+ // ---------------------------------------------------------------------
+
+ set hass(hass) {
+ this._hass = hass;
+ _requestI18n(this, hass, () => { this._update(); });
+ this._update();
+ }
+
+ _render() {
+ if (!this._root) return;
+
+ const isCompact = this._cfg.compact_view;
+
+ const style = `
+ /* inherit HA fonts & typography */
+ :host {
+ font: inherit;
+ color: var(--primary-text-color);
+ display: block !important;
+ height: auto !important;
+ contain: none !important;
+ position: relative;
+ z-index: 1;
+ }
+
+ /* unify horizontal padding so right edges line up */
+ :host { --row-xpad: 6px; }
+
+ ha-card {
+ overflow: visible !important;
+ display: block !important;
+ height: auto !important;
+ min-height: auto !important;
+ max-height: none !important;
+ contain: none !important;
+ position: relative;
+ z-index: 1;
+ }
+
+ ha-card.compact-mode,
+ ha-card.normal-mode {
+ height: auto !important;
+ overflow: visible !important;
+ }
+
+ #content {
+ padding: 14px;
+ }
+
+ .card {
+ border-radius: var(--ha-card-border-radius, 12px);
+ background: var(--card-background-color);
+ color: var(--primary-text-color);
+ box-shadow: var(--ha-card-box-shadow, 0 2px 4px rgba(0,0,0,.2));
+ padding: 10px var(--row-xpad) 10px var(--row-xpad);
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ }
+
+ /* === NORMAL MODE === */
+ .normal-mode {}
+
+ .header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-end;
+ margin-bottom: 12px;
+ }
+ .title-section {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ }
+ .title {
+ font-size: 20px;
+ font-weight: 700;
+ letter-spacing: -0.5px;
+ }
+ .subtitle {
+ font-size: 12px;
+ color: var(--secondary-text-color);
+ font-weight: 500;
+ }
+ .env-info {
+ font-size: 11px;
+ background: rgba(var(--rgb-primary-text-color), 0.08);
+ padding: 6px 10px;
+ border-radius: 12px;
+ color: var(--secondary-text-color);
+ }
+
+ .unit-selector {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 20px;
+ background: rgba(var(--rgb-primary-text-color), 0.05);
+ padding: 4px;
+ border-radius: 14px;
+ width: fit-content;
+ }
+ .unit-btn {
+ padding: 6px 16px;
+ border-radius: 10px;
+ font-size: 12px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ border: none;
+ color: var(--secondary-text-color);
+ background: transparent;
+ }
+ .unit-btn.active {
+ background: rgba(var(--rgb-primary-text-color), 0.1);
+ color: var(--primary-text-color);
+ box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
+ }
+
+ .spool-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+ }
+
+ .spool-card {
+ background: rgba(var(--rgb-primary-text-color), 0.04);
+ border-radius: var(--ha-card-border-radius, 18px);
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+ border: 1px solid transparent;
+ transition: all 0.3s ease;
+ cursor: pointer;
+ }
+
+ .spool-card:hover {
+ background: rgba(var(--rgb-primary-text-color), 0.06);
+ }
+
+ .spool-card.active {
+ background: rgba(var(--rgb-primary-text-color), 0.08);
+ border: 1px solid rgba(var(--rgb-primary-color), 0.3);
+ box-shadow: 0 0 20px rgba(var(--rgb-primary-color), 0.2);
+ }
+
+ .ring-container {
+ width: 80px;
+ height: 80px;
+ border-radius: 50%;
+ position: relative;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin-bottom: 10px;
+ }
+
+ .ring-outer {
+ width: 100%;
+ height: 100%;
+ border-radius: 50%;
+ position: absolute;
+ background: conic-gradient(
+ var(--spool-color) var(--spool-pct),
+ rgba(var(--rgb-primary-text-color), 0.08) 0
+ );
+ mask: radial-gradient(circle closest-side at 50% 50%, transparent 33px, black 34px);
+ -webkit-mask: radial-gradient(circle closest-side at 50% 50%, transparent 33px, black 34px);
+ }
+
+ .ring-inner {
+ width: 66px;
+ height: 66px;
+ background: transparent;
+ border-radius: 50%;
+ position: relative;
+ z-index: 2;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ }
+
+ .spool-label {
+ font-size: 10px;
+ color: var(--secondary-text-color);
+ text-transform: uppercase;
+ }
+ .spool-pct {
+ font-size: 16px;
+ font-weight: 700;
+ }
+
+ .material-name {
+ font-size: 13px;
+ font-weight: 600;
+ text-align: center;
+ }
+ .color-name {
+ font-size: 11px;
+ color: var(--secondary-text-color);
+ text-align: center;
+ }
+
+ .status-badge {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ width: 8px;
+ height: 8px;
+ background: var(--success-color, #4caf50);
+ border-radius: 50%;
+ box-shadow: 0 0 8px var(--success-color, #4caf50);
+ animation: pulse-badge 2s infinite;
+ }
+
+ @keyframes pulse-badge {
+ 0%, 100% { opacity: 1; transform: scale(1); }
+ 50% { opacity: 0.7; transform: scale(1.1); }
+ }
+
+ /* === COMPACT MODE === */
+ .compact-mode {
+ padding: 14px;
+ }
+
+ .compact-mode .header {
+ margin-bottom: 12px;
+ }
+
+ .cfs-rows {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ }
+
+ .cfs-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ }
+
+ .cfs-label {
+ width: 48px;
+ font-size: 11px;
+ color: var(--secondary-text-color);
+ font-weight: 600;
+ }
+
+ .spools-inline {
+ display: flex;
+ gap: 10px;
+ flex: 1;
+ }
+
+ .spool-mini {
+ width: 34px;
+ height: 34px;
+ border-radius: 50%;
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 10px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: transform 0.2s;
+ }
+
+ .spool-mini:hover {
+ transform: scale(1.05);
+ }
+
+ .spool-mini::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ border-radius: 50%;
+ background: conic-gradient(
+ var(--spool-color) var(--spool-pct),
+ rgba(var(--rgb-primary-text-color), 0.08) 0
+ );
+ mask: radial-gradient(circle closest-side at 50% 50%, transparent 12px, black 13px);
+ -webkit-mask: radial-gradient(circle closest-side at 50% 50%, transparent 12px, black 13px);
+ }
+
+ .spool-mini::after {
+ content: '';
+ position: absolute;
+ inset: 4px;
+ background: transparent;
+ border-radius: 50%;
+ z-index: 1;
+ }
+
+ .spool-mini span {
+ position: relative;
+ z-index: 2;
+ }
+
+ .spool-mini.active {
+ box-shadow: 0 0 10px var(--spool-color);
+ }
+
+ .spool-mini.active::after {
+ inset: 3px;
+ }
+
+ .spool-mini-wrapper {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 2px;
+ }
+
+ .spool-mini-type {
+ font-size: 8px;
+ color: var(--secondary-text-color);
+ text-transform: uppercase;
+ font-weight: 600;
+ max-width: 40px;
+ text-align: center;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .env-mini {
+ width: 56px;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ font-size: 10px;
+ line-height: 1.4;
+ gap: 2px;
+ }
+
+ .env-mini .temp {
+ color: #ffb74d;
+ font-weight: 600;
+ }
+
+ .env-mini .hum {
+ font-weight: 600;
+ /* Cor aplicada dinamicamente via inline style */
+ }
+
+ /* === EXTERNAL SECTION === */
+ .external-section {
+ margin-top: 16px;
+ padding-top: 14px;
+ border-top: 1px solid rgba(var(--rgb-primary-text-color), 0.08);
+ }
+
+ .external-normal {
+ background: rgba(var(--rgb-primary-text-color), 0.03);
+ border-radius: 16px;
+ padding: 12px 16px;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ }
+
+ .ext-icon {
+ width: 30px;
+ height: 30px;
+ background: var(--primary-color);
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 10px;
+ font-weight: bold;
+ color: white;
+ }
+
+ .ext-info {
+ flex-grow: 1;
+ }
+
+ .ext-name {
+ font-size: 12px;
+ font-weight: 600;
+ }
+
+ .ext-bar {
+ height: 4px;
+ background: rgba(var(--rgb-primary-text-color), 0.1);
+ border-radius: 2px;
+ margin-top: 6px;
+ overflow: hidden;
+ }
+
+ .ext-fill {
+ height: 100%;
+ background: var(--primary-color);
+ transition: width 0.3s ease;
+ }
+
+ .ext-percent {
+ font-size: 12px;
+ color: var(--secondary-text-color);
+ font-weight: 600;
+ }
+
+ .external-compact {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ }
+
+ .ext-dot {
+ width: 34px;
+ height: 34px;
+ border-radius: 50%;
+ background: conic-gradient(var(--primary-color) 100%, rgba(var(--rgb-primary-text-color), 0.1) 0);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 10px;
+ font-weight: 600;
+ color: white;
+ }
+
+ .ext-compact-info {
+ flex: 1;
+ }
+
+ .ext-compact-info div:first-child {
+ font-size: 12px;
+ font-weight: 600;
+ }
+
+ .ext-compact-info div:last-child {
+ font-size: 10px;
+ color: var(--secondary-text-color);
+ }
+
+ .no-data {
+ text-align: center;
+ color: var(--secondary-text-color);
+ padding: 20px;
+ }
+ `;
+
+ this._root.innerHTML = `
+
+
+
+
+ `;
+ }
+
+ _update() {
+ if (!this._root || !this._hass) return;
+
+ const contentContainer = this._root.getElementById("content");
+ if (!contentContainer) return;
+
+ const states = this._hass.states || {};
+ const gObj = (eid) => (eid ? states?.[eid] : undefined);
+ const fmtState = (st) => {
+ if (!st) return "—";
+ const v = st.state;
+ if (v === undefined || v === null) return "—";
+ const s = String(v);
+ if (s === "unknown" || s === "unavailable") return "—";
+ if (this._hass && typeof this._hass.formatEntityState === "function") {
+ try { return this._hass.formatEntityState(st); } catch (_) { }
+ }
+ const unit = st.attributes?.unit_of_measurement;
+ const n = Number(s);
+ if (!Number.isNaN(n) && Number.isFinite(n)) {
+ const dp = (typeof st.attributes?.display_precision === "number") ? st.attributes.display_precision
+ : (typeof st.attributes?.suggested_display_precision === "number") ? st.attributes.suggested_display_precision
+ : (unit && /°|c|f/i.test(unit)) ? 1
+ : 2;
+ const out = n.toFixed(Math.max(0, Math.min(6, dp)));
+ return unit ? `${out} ${unit}` : out;
+ }
+ return unit ? `${s} ${unit}` : s;
+ };
+
+ // Collect box data
+ const boxes = {};
+ for (let boxId = 0; boxId < 4; boxId += 1) {
+ const tempEid = this._cfg[`box${boxId}_temp`];
+ const humidityEid = this._cfg[`box${boxId}_humidity`];
+ const slots = [];
+
+ for (let slotId = 0; slotId < 4; slotId += 1) {
+ const filamentEid = this._cfg[`box${boxId}_slot${slotId}_filament`];
+ const colorEid = this._cfg[`box${boxId}_slot${slotId}_color`];
+ const percentEid = this._cfg[`box${boxId}_slot${slotId}_percent`];
+ if (!filamentEid && !colorEid && !percentEid) {
+ slots.push(null);
+ continue;
+ }
+
+ const filamentObj = gObj(filamentEid);
+ const colorObj = gObj(colorEid);
+ const percentObj = gObj(percentEid);
+ const name = filamentObj?.state;
+ const type = filamentObj?.attributes?.type;
+ const selected = filamentObj?.attributes?.selected;
+ const rawColor = colorObj?.state || filamentObj?.attributes?.color_hex;
+ const color = KCFSCard._sanitizeColor(rawColor);
+ const percent = KCFSCard._parsePercent(percentObj);
+ const percentText = fmtState(percentObj);
+
+ slots[slotId] = {
+ id: slotId,
+ boxId,
+ entity_id: filamentEid || colorEid || percentEid,
+ name,
+ type,
+ selected,
+ color,
+ percent,
+ percentText,
+ };
+ }
+
+ if (tempEid || humidityEid || slots.some((slot) => slot)) {
+ const humidityFormatted = fmtState(gObj(humidityEid));
+ boxes[boxId] = {
+ id: boxId,
+ temp: fmtState(gObj(tempEid)),
+ humidity: humidityFormatted,
+ humidityColor: KCFSCard._getHumidityColor(humidityFormatted),
+ slots,
+ };
+ }
+ }
+
+ // Collect external data
+ const external = {
+ filament: this._cfg.external_filament,
+ color: this._cfg.external_color,
+ percent: this._cfg.external_percent,
+ };
+ const hasExternal = external.filament || external.color || external.percent;
+ let externalData = null;
+ if (hasExternal) {
+ const filamentObj = gObj(external.filament);
+ const colorObj = gObj(external.color);
+ const percentObj = gObj(external.percent);
+ const name = filamentObj?.state;
+ const type = filamentObj?.attributes?.type;
+ const selected = filamentObj?.attributes?.selected;
+ const rawColor = colorObj?.state || filamentObj?.attributes?.color_hex;
+ const color = KCFSCard._sanitizeColor(rawColor);
+ const percent = KCFSCard._parsePercent(percentObj);
+ const percentText = fmtState(percentObj);
+
+ externalData = {
+ id: 0,
+ boxId: -1,
+ entity_id: external.filament || external.color || external.percent,
+ name,
+ type,
+ selected,
+ color,
+ percent,
+ percentText,
+ };
+ }
+
+ const boxValues = Object.values(boxes);
+ if (boxValues.length === 0 && !hasExternal) {
+ contentContainer.innerHTML = `
${this._t("no_data")}
`;
+ return;
+ }
+
+ // Render based on mode
+ if (this._cfg.compact_view) {
+ contentContainer.innerHTML = this._renderCompactMode(boxValues, externalData);
+ } else {
+ contentContainer.innerHTML = this._renderNormalMode(boxValues, externalData);
+ }
+
+ this._attachEventHandlers();
+ }
+
+ _renderNormalMode(boxes, external) {
+ // Ensure we have at least one box
+ if (boxes.length === 0 && !external) {
+ return `${this._t("no_data")}
`;
+ }
+
+ // Unit selector (only if we have multiple boxes)
+ let unitSelector = '';
+ if (boxes.length > 1) {
+ unitSelector = `
+
+ ${boxes.map((box, idx) => `
+
+ `).join('')}
+
+ `;
+ }
+
+ // Get the selected box
+ const selectedBox = boxes[this._selectedCFS] || boxes[0];
+ if (!selectedBox && !external) {
+ return `${this._t("no_data")}
`;
+ }
+
+ // Header with environment info
+ let envInfo = '';
+ if (selectedBox) {
+ const tempStr = selectedBox.temp !== "—" ? selectedBox.temp : '';
+ const humStr = selectedBox.humidity !== "—" ? selectedBox.humidity : '';
+
+ if (tempStr || humStr) {
+ const tempHtml = tempStr ? `${tempStr}` : '';
+ const humHtml = humStr ? `${humStr}` : '';
+ const separator = tempStr && humStr ? ' • ' : '';
+ envInfo = `${tempHtml}${separator}${humHtml}
`;
+ }
+ }
+
+ const header = `
+
+ `;
+
+ // Spool grid
+ let spoolGrid = '';
+ if (selectedBox) {
+ spoolGrid = `
+
+ ${selectedBox.slots.map((slot) => this._renderSpoolCard(slot)).join('')}
+
+ `;
+ }
+
+ // External section
+ let externalSection = '';
+ if (external) {
+ const safeType = external.type && !["unknown", "unavailable", "—", "-"].includes(String(external.type).toLowerCase()) ? external.type : "—";
+ const safeName = external.name && !["unknown", "unavailable", "—", "-"].includes(String(external.name).toLowerCase()) ? external.name : "—";
+ const hasFilament = safeType !== "—" && safeName !== "—";
+ const pct = hasFilament && external.percent !== null ? external.percent : 0;
+ const percentTextDisplay = hasFilament ? (external.percentText || '—') : '—';
+ const displayName = hasFilament ? `${safeName} ${safeType}` : '—';
+ externalSection = `
+
+
+
${this._t("ext_label")}
+
+
${percentTextDisplay}
+
+
+ `;
+ }
+
+ return `${unitSelector}${header}${spoolGrid}${externalSection}`;
+ }
+
+ _renderCompactMode(boxes, external) {
+ if (boxes.length === 0 && !external) {
+ return `${this._t("no_data")}
`;
+ }
+
+ // CFS rows
+ let cfsRows = '';
+ if (boxes.length > 0) {
+ cfsRows = `
+
+ ${boxes.map((box) => this._renderCFSRow(box)).join('')}
+
+ `;
+ }
+
+ // External section
+ let externalSection = '';
+ if (external) {
+ const safeType = external.type && !["unknown", "unavailable", "—", "-"].includes(String(external.type).toLowerCase()) ? external.type : "—";
+ const safeName = external.name && !["unknown", "unavailable", "—", "-"].includes(String(external.name).toLowerCase()) ? external.name : "—";
+ const hasFilament = safeType !== "—" && safeName !== "—";
+ const percentTextDisplay = hasFilament ? (external.percentText || '—') : '—';
+ const displayName = hasFilament ? `${safeName} ${safeType}` : '—';
+ externalSection = `
+
+
+
${this._t("ext_label")}
+
+
${displayName}
+
${percentTextDisplay}
+
+
+
+ `;
+ }
+
+ return `${cfsRows}${externalSection}`;
+ }
+
+ _renderCFSRow(box) {
+ const tempStr = box.temp !== "—" ? box.temp : '';
+ const humStr = box.humidity !== "—" ? box.humidity : '';
+
+ let envHtml = '';
+ if (tempStr || humStr) {
+ envHtml = `
+
+ ${tempStr ? `
${tempStr}
` : ''}
+ ${humStr ? `
${humStr}
` : ''}
+
+ `;
+ }
+
+ return `
+
+
${this._t("cfs_number_label", { number: box.id + 1 })}
+
+ ${box.slots.map((slot) => this._renderSpoolMini(slot)).join('')}
+
+ ${envHtml}
+
+ `;
+ }
+
+ _renderSpoolCard(slot) {
+ if (!slot) {
+ return ``;
+ }
+
+ const isActive = slot.selected === 1 || slot.selected === true;
+ const color = slot.color || '#cccccc';
+ const safeType = slot.type && !["unknown", "unavailable", "—", "-"].includes(String(slot.type).toLowerCase()) ? slot.type : "—";
+ const safeName = slot.name && !["unknown", "unavailable", "—", "-"].includes(String(slot.name).toLowerCase()) ? slot.name : "—";
+
+ // If no filament (type is "—" or name is "—"), show 0% regardless of actual value
+ const hasFilament = safeType !== "—" && safeName !== "—";
+ const pct = hasFilament && slot.percent !== null ? slot.percent : 0;
+ const pctDisplay = hasFilament && slot.percent !== null ? Math.round(slot.percent) : 0;
+ const percentTextDisplay = hasFilament ? (slot.percentText || '—') : '—';
+
+ const badge = isActive ? '' : '';
+
+ return `
+
+ ${badge}
+
+
+
+ ${pctDisplay}%
+ ${safeType}
+
+
+
${safeName}
+
${percentTextDisplay}
+
+ `;
+ }
+
+ _renderSpoolMini(slot) {
+ const showType = this._cfg.show_type_in_mini;
+
+ if (!slot) {
+ if (showType) {
+ return ``;
+ }
+ return `—
`;
+ }
+
+ const isActive = slot.selected === 1 || slot.selected === true;
+ const color = slot.color || '#cccccc';
+ const safeType = slot.type && !["unknown", "unavailable", "—", "-"].includes(String(slot.type).toLowerCase()) ? slot.type : "—";
+ const safeName = slot.name && !["unknown", "unavailable", "—", "-"].includes(String(slot.name).toLowerCase()) ? slot.name : null;
+
+ // If no filament (type is "—" or name is empty/dash), show 0% regardless of actual value
+ const hasFilament = safeType !== "—" && safeName !== null;
+ const pct = hasFilament && slot.percent !== null ? slot.percent : 0;
+ const pctDisplay = hasFilament && slot.percent !== null ? Math.round(slot.percent) : 0;
+
+ if (showType) {
+ return `
+
+
+ ${pctDisplay}
+
+
${safeType}
+
+ `;
+ }
+
+ return `
+
+ ${pctDisplay}
+
+ `;
+ }
+
+ _attachEventHandlers() {
+ // Unit selector buttons
+ this._root.querySelectorAll('.unit-btn').forEach(btn => {
+ btn.onclick = () => {
+ const cfsIdx = parseInt(btn.dataset.cfs, 10);
+ if (!isNaN(cfsIdx)) {
+ this._selectedCFS = cfsIdx;
+ this._update();
+ }
+ };
+ });
+
+ // Spool cards and mini spools - show more info
+ this._root.querySelectorAll('.spool-card, .spool-mini, .spool-mini-wrapper .spool-mini, .external-normal, .external-compact').forEach(el => {
+ const eid = el.dataset.eid;
+ if (!eid) return;
+
+ el.onclick = () => {
+ this.dispatchEvent(new CustomEvent("hass-more-info", {
+ detail: { entityId: eid },
+ bubbles: true,
+ composed: true,
+ }));
+ };
+ });
+ }
+
+ getCardSize() {
+ // Return a dynamic size based on compact mode and number of boxes
+ if (this._cfg?.compact_view) {
+ // Count configured boxes
+ let boxCount = 0;
+ for (let box = 0; box < 4; box++) {
+ const hasBox = this._cfg[`box${box}_temp`] || this._cfg[`box${box}_humidity`] ||
+ [0, 1, 2, 3].some(s => this._cfg[`box${box}_slot${s}_filament`] || this._cfg[`box${box}_slot${s}_color`] || this._cfg[`box${box}_slot${s}_percent`]);
+ if (hasBox) boxCount++;
+ }
+ // Check for external filament
+ const hasExternal = this._cfg.external_filament || this._cfg.external_color || this._cfg.external_percent;
+ const externalRows = hasExternal ? 1 : 0;
+
+ // Add extra space when more than 2 rows
+ const extraPadding = (boxCount + externalRows) > 2 ? 1 : 0;
+
+ return Math.max(1, boxCount + externalRows + extraPadding);
+ }
+ return 5;
+ }
+
+ getLayoutOptions() {
+ // Count configured boxes for dynamic sizing
+ let boxCount = 0;
+ if (this._cfg) {
+ for (let box = 0; box < 4; box++) {
+ const hasBox = this._cfg[`box${box}_temp`] || this._cfg[`box${box}_humidity`] ||
+ [0, 1, 2, 3].some(s => this._cfg[`box${box}_slot${s}_filament`] || this._cfg[`box${box}_slot${s}_color`] || this._cfg[`box${box}_slot${s}_percent`]);
+ if (hasBox) boxCount++;
+ }
+ }
+
+ // Check for external filament
+ const hasExternal = this._cfg?.external_filament || this._cfg?.external_color || this._cfg?.external_percent;
+ const externalRows = hasExternal ? 1 : 0;
+
+ // Add extra space when more than 2 rows
+ const totalRows = boxCount + externalRows;
+ const extraPadding = totalRows > 2 ? 1 : 0;
+
+ const minRows = this._cfg?.compact_view ? Math.max(1, totalRows + extraPadding) : 5;
+
+ return {
+ grid_rows: minRows,
+ grid_min_rows: minRows,
+ };
+ }
+}
+
+customElements.define(CARD_TAG, KCFSCard);
+
+class KCFSCardEditor extends HTMLElement {
+ // i18n helpers -------------------------------------------------------
+ _resolveLanguage() {
+ return _resolveLang(this._hass);
+ }
+ _t(key, vars) {
+ return _translate(this._hass, "cfs_card", CFS_TRANSLATIONS, key, vars);
+ }
+ // ---------------------------------------------------------------------
+
+ set hass(hass) {
+ this._hass = hass;
+ if (this._form) this._form.hass = hass;
+ _requestI18n(this, hass, () => { if (this._root) this._render(); });
+ }
+
+ setConfig(config) {
+ this._cfg = { ...KCFSCard.getStubConfig(), ...config };
+ this._render();
+ }
+
+ connectedCallback() {
+ this._render();
+ }
+
+ _render() {
+ if (!this._root) {
+ this._root = this.attachShadow({ mode: "open" });
+ }
+
+ const style = `
+ .editor-container { padding: 16px; }
+ .tabs { display: flex; border-bottom: 1px solid var(--divider-color); margin-bottom: 16px; }
+ .tab { padding: 8px 16px; cursor: pointer; border-bottom: 2px solid transparent; }
+ .tab.active { border-bottom-color: var(--primary-color); color: var(--primary-color); }
+ .tab-content { display: none; }
+ .tab-content.active { display: block; }
+ .input-helper { font-size: 0.9em; color: var(--secondary-text-color); margin-top: 4px; padding: 0 8px; }
+ `;
+
+ this._root.innerHTML = `
+
+
+
+
${this._t("tab_entities")}
+
${this._t("tab_theme")}
+
+
+
+
+
+
+
+
+ `;
+
+ this._setupTabs();
+ this._setupEntitiesForm();
+ this._setupThemeForm();
+ }
+
+ _setupTabs() {
+ const tabs = this._root.querySelectorAll(".tab");
+ const contents = this._root.querySelectorAll(".tab-content");
+ tabs.forEach((tab) => {
+ tab.onclick = () => {
+ tabs.forEach((t) => t.classList.remove("active"));
+ contents.forEach((c) => c.classList.remove("active"));
+ tab.classList.add("active");
+ this._root.getElementById(`${tab.dataset.tab}-tab`).classList.add("active");
+ };
+ });
+ }
+
+ _setupEntitiesForm() {
+ this._form = this._root.getElementById("form");
+ this._form.hass = this._hass;
+ this._form.data = this._cfg;
+ const schema = [
+ { name: "name", selector: { text: {} } },
+ { name: "external_filament", selector: { entity: { domain: "sensor" } } },
+ { name: "external_color", selector: { entity: { domain: "sensor" } } },
+ { name: "external_percent", selector: { entity: { domain: "sensor" } } },
+ ];
+
+ for (let box = 0; box < 4; box += 1) {
+ schema.push({ name: `box${box}_temp`, selector: { entity: { domain: "sensor" } } });
+ schema.push({ name: `box${box}_humidity`, selector: { entity: { domain: "sensor" } } });
+ for (let slot = 0; slot < 4; slot += 1) {
+ schema.push({ name: `box${box}_slot${slot}_filament`, selector: { entity: { domain: "sensor" } } });
+ schema.push({ name: `box${box}_slot${slot}_color`, selector: { entity: { domain: "sensor" } } });
+ schema.push({ name: `box${box}_slot${slot}_percent`, selector: { entity: { domain: "sensor" } } });
+ }
+ }
+
+ this._form.schema = schema;
+ this._form.computeLabel = (s) => {
+ if (s.name === "name") return this._t("label_card_title");
+ if (s.name === "external_filament") return this._t("label_external_filament");
+ if (s.name === "external_color") return this._t("label_external_color");
+ if (s.name === "external_percent") return this._t("label_external_percent");
+
+ const boxMatch = s.name.match(/^box(\d+)_(temp|humidity)$/);
+ if (boxMatch) {
+ const [, boxId, metric] = boxMatch;
+ const key = metric === "temp" ? "label_box_temp" : "label_box_humidity";
+ return this._t(key, { box: Number(boxId) + 1 });
+ }
+
+ const slotMatch = s.name.match(/^box(\d+)_slot(\d+)_(filament|color|percent)$/);
+ if (slotMatch) {
+ const [, boxId, slotId, metric] = slotMatch;
+ const keyMap = {
+ filament: "label_slot_filament",
+ color: "label_slot_color",
+ percent: "label_slot_percent",
+ };
+ return this._t(keyMap[metric], { box: Number(boxId) + 1, slot: Number(slotId) + 1 });
+ }
+
+ return s.name;
+ };
+ if (this._form.computeHelper) {
+ this._form.computeHelper = () => "";
+ }
+
+ this._form.addEventListener("value-changed", (ev) => {
+ this._cfg = { ...this._cfg, ...ev.detail.value };
+ this._dispatchConfigChange();
+ });
+ }
+
+ _setupThemeForm() {
+ const themeForm = this._root.getElementById("theme-form");
+ themeForm.hass = this._hass;
+ themeForm.data = this._cfg;
+ themeForm.schema = [
+ { name: "compact_view", selector: { boolean: {} } },
+ { name: "show_type_in_mini", selector: { boolean: {} } },
+ ];
+ themeForm.computeLabel = (s) => ({
+ compact_view: this._t("schema_compact_view"),
+ show_type_in_mini: this._t("schema_show_type_in_mini"),
+ }[s.name] || s.name);
+
+ themeForm.addEventListener("value-changed", (ev) => {
+ this._cfg = { ...this._cfg, ...ev.detail.value };
+ this._dispatchConfigChange();
+ });
+ }
+
+ _dispatchConfigChange() {
+ this.dispatchEvent(new CustomEvent("config-changed", {
+ detail: { config: this._cfg },
+ bubbles: true,
+ composed: true,
+ }));
+ }
+}
+
+customElements.define(EDITOR_TAG, KCFSCardEditor);
+
+window.customCards = window.customCards || [];
+window.customCards.push({
+ type: "k-cfs-card",
+ name: "Creality CFS Card",
+ preview: true,
+ description: "A card to control the Creality Filament System (CFS)"
+});
diff --git a/custom_components/ha_creality_ws/www/k_printer_card.js b/custom_components/ha_creality_ws/www/k_printer_card.js
new file mode 100644
index 00000000..5d5c562d
--- /dev/null
+++ b/custom_components/ha_creality_ws/www/k_printer_card.js
@@ -0,0 +1,1794 @@
+const CARD_TAG = "k-printer-card";
+const EDITOR_TAG = "k-printer-card-editor";
+
+const I18N_URL_BASE = "/ha_creality_ws/i18n/";
+const _i18nData = {};
+const _i18nPromises = {};
+function _loadI18n(lang) {
+ if (_i18nData[lang]) return Promise.resolve(_i18nData[lang]);
+ if (_i18nPromises[lang]) return _i18nPromises[lang];
+ _i18nPromises[lang] = fetch(`${I18N_URL_BASE}${lang}.json`)
+ .then((res) => (res.ok ? res.json() : null))
+ .then((data) => {
+ if (data) _i18nData[lang] = data;
+ else _i18nPromises[lang] = null;
+ return data;
+ })
+ .catch(() => { _i18nPromises[lang] = null; return null; });
+ return _i18nPromises[lang];
+}
+function _resolveLang(hass) {
+ return hass?.locale?.language || hass?.language || "en";
+}
+function _translate(hass, section, fallbackDict, key) {
+ const lang = _resolveLang(hass);
+ const short = lang.split("-")[0];
+ const remote = _i18nData[lang]?.[section] ?? _i18nData[short]?.[section];
+ if (remote && key in remote) return remote[key];
+ const remoteEn = _i18nData["en"]?.[section];
+ if (remoteEn && key in remoteEn) return remoteEn[key];
+ return fallbackDict[lang]?.[key] ?? fallbackDict[short]?.[key] ?? fallbackDict["en"]?.[key] ?? key;
+}
+function _requestI18n(instance, hass, onLoaded) {
+ if (instance._i18nRequested) return;
+ instance._i18nRequested = true;
+ const lang = _resolveLang(hass).split("-")[0];
+ Promise.all([_loadI18n("en"), lang !== "en" ? _loadI18n(lang) : null]).then(onLoaded);
+}
+
+// Per-card-id throttle for ll-rebuild dispatches. ll-rebuild may cause Lovelace to
+// recreate the element, in which case instance state (this._cardSize) is reset. A
+// module-level record survives recreation and prevents a measure→dispatch→recreate
+// loop if size measurement on the fresh instance keeps producing the same delta.
+const LL_REBUILD_MIN_INTERVAL_MS = 2000;
+const _lastCardRebuildDispatch = new Map();
+
+const clamp = (v, a, b) => Math.min(Math.max(v, a), b);
+const mdi = (name) => `mdi:${name}`;
+const normStr = (x) => String(x ?? "").toLowerCase();
+
+// Theme persistence utilities
+const THEME_STORAGE_KEY = "k-printer-card-themes";
+
+// Color conversion utilities
+function rgbaToHex(rgba) {
+ if (!rgba || rgba.startsWith('#')) return rgba;
+
+ // Handle rgba() format
+ const match = rgba.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*[\d.]+)?\)/);
+ if (match) {
+ const r = parseInt(match[1]);
+ const g = parseInt(match[2]);
+ const b = parseInt(match[3]);
+ return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
+ }
+
+ // Handle CSS variables
+ if (rgba.startsWith('var(')) {
+ return '#000000'; // fallback
+ }
+
+ return '#000000';
+}
+
+function hexToRgba(hex, alpha = 1) {
+ if (!hex || !hex.startsWith('#')) return hex;
+
+ const r = parseInt(hex.slice(1, 3), 16);
+ const g = parseInt(hex.slice(3, 5), 16);
+ const b = parseInt(hex.slice(5, 7), 16);
+
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
+}
+
+/**
+ * Save theme configuration to localStorage for persistence across card updates
+ * @param {string} cardId - Unique identifier for the card
+ * @param {Object} theme - Theme configuration object
+ */
+function saveThemeToStorage(cardId, theme) {
+ try {
+ const themes = JSON.parse(localStorage.getItem(THEME_STORAGE_KEY) || "{}");
+ themes[cardId] = theme;
+ localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(themes));
+ } catch (e) {
+ console.warn("Failed to save theme to localStorage:", e);
+ }
+}
+
+/**
+ * Load theme configuration from localStorage
+ * @param {string} cardId - Unique identifier for the card
+ * @returns {Object|null} Theme configuration or null if not found
+ */
+function loadThemeFromStorage(cardId) {
+ try {
+ const themes = JSON.parse(localStorage.getItem(THEME_STORAGE_KEY) || "{}");
+ return themes[cardId] || null;
+ } catch (e) {
+ console.warn("Failed to load theme from localStorage:", e);
+ return null;
+ }
+}
+
+/**
+ * Generate a unique card ID based on configuration
+ * @param {Object} config - Card configuration
+ * @returns {string} Unique card identifier
+ */
+function generateCardId(config) {
+ // Generate a unique ID based on the card configuration
+ const key = `${config.name || "printer"}-${config.status || "unknown"}`;
+ return btoa(key).replace(/[^a-zA-Z0-9]/g, '').substring(0, 16);
+}
+
+function fmtTimeLeft(seconds) {
+ // Floor to whole seconds so a fractional value (some firmwares report a float)
+ // renders as e.g. 2:25 instead of 2:25.6789 and doesn't reflow the row every poll.
+ const s = Math.floor(Number(seconds) || 0);
+ const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
+ if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
+ if (m > 0) return `${m}:${String(sec).padStart(2, "0")}`;
+ return `${sec}s`;
+}
+function computeIcon(status) {
+ const st = normStr(status);
+ if (["off", "unknown", "stopped"].includes(st)) return mdi("printer-3d-off");
+ if (["printing", "resuming", "pausing", "paused"].includes(st)) return mdi("printer-3d-nozzle");
+ if (st === "error") return mdi("close-octagon");
+ if (st === "self-testing") return mdi("cogs");
+ return mdi("printer-3d");
+}
+function computeColor(status) {
+ const st = normStr(status);
+ if (["off", "unknown", "stopped"].includes(st)) return "var(--secondary-text-color)";
+ if (["paused", "pausing"].includes(st)) return "#fc6d09";
+ if (st === "error") return "var(--error-color)";
+ if (["printing", "resuming", "processing"].includes(st)) return "var(--primary-color)";
+ if (["idle", "completed"].includes(st)) return "var(--success-color, #4caf50)";
+ if (st === "self-testing") return "var(--info-color, #2196f3)";
+ return "var(--secondary-text-color)";
+}
+
+class KPrinterCard extends HTMLElement {
+ static getStubConfig() {
+ return {
+ name: "3D Printer",
+ camera: "", status: "", progress: "", time_left: "",
+ nozzle: "", bed: "", box: "",
+ // Optional power switch entity and visibility flag for a Power button
+ power: "",
+ show_power_button: true,
+ layer: "", total_layers: "",
+ light: "", pause_btn: "", resume_btn: "", stop_btn: "",
+ // Theme customization options
+ theme: {
+ // Button backgrounds
+ pause_bg: "rgba(252, 109, 9, .90)",
+ resume_bg: "rgba(76, 175, 80, .90)",
+ stop_bg: "rgba(244, 67, 54, .95)",
+ light_on_bg: "rgba(255, 235, 59, .95)",
+ light_off_bg: "rgba(150,150,150,.35)",
+ // Button icon colors
+ pause_icon: "#fff",
+ resume_icon: "#fff",
+ stop_icon: "#fff",
+ light_icon_on: "#000",
+ light_icon_off: "#000",
+ // Status icon and progress circle
+ status_icon: "auto", // auto, or specific color
+ progress_ring: "auto", // auto, or specific color
+ status_bg: "auto", // auto (transparent), or specific color
+ // Telemetry colors
+ telemetry_icon: "auto", // auto (inherit theme), or specific color
+ telemetry_text: "auto", // auto (inherit theme), or specific color
+ // Custom button colors
+ custom_bg: "rgba(33, 150, 243, .90)",
+ custom_icon: "#fff",
+ },
+ // Config for custom button
+ custom_btn: "",
+ custom_btn_icon: "", // Moved to theme editor but stored here
+ custom_btn_hidden: false,
+ // Order of buttons
+ button_order: ['pause', 'resume', 'stop', 'light', 'power', 'custom'],
+ // Icon overrides
+ pause_btn_icon: "",
+ resume_btn_icon: "",
+ stop_btn_icon: "",
+ light_btn_icon: "",
+ power_btn_icon: "",
+
+ // Feature toggles
+ hide_box_temp: false
+ };
+ }
+ static getConfigElement() {
+ const editor = document.createElement(EDITOR_TAG);
+ return editor;
+ }
+
+ setConfig(config) {
+ const defaultConfig = KPrinterCard.getStubConfig();
+ this._cfg = { ...defaultConfig, ...(config || {}) };
+ // Init optimistic state overrides map
+ if (!this._optimisticStates) this._optimisticStates = {};
+
+ // Generate card ID for theme persistence
+ this._cardId = generateCardId(this._cfg);
+
+ // Load saved theme if no theme is provided in config
+ if (!config?.theme) {
+ const savedTheme = loadThemeFromStorage(this._cardId);
+ if (savedTheme) {
+ this._cfg.theme = { ...defaultConfig.theme, ...savedTheme };
+ }
+ } else {
+ // Deep merge theme configuration
+ this._cfg.theme = { ...defaultConfig.theme, ...config.theme };
+ // Save theme to storage
+ saveThemeToStorage(this._cardId, this._cfg.theme);
+ }
+
+ if (!this._root) {
+ this._root = this.attachShadow({ mode: "open" });
+ }
+
+ // Always re-render when config changes to apply new theme
+ this._render();
+
+ // Apply theme after render to ensure DOM is ready
+ this._applyTheme();
+ }
+ _applyTheme() {
+ if (!this._root || !this._cfg.theme) {
+ return;
+ }
+
+ // Re-render with updated CSS to apply theme changes
+ this._render();
+ }
+
+ // i18n helpers -------------------------------------------------------
+ _resolveLanguage() {
+ return _resolveLang(this._hass);
+ }
+ _t(key) {
+ return _translate(this._hass, "printer_card", CARD_TRANSLATIONS, key);
+ }
+ // ---------------------------------------------------------------------
+
+ set hass(hass) {
+ this._hass = hass;
+ _requestI18n(this, hass, () => { if (this._root) this._update(); });
+ if (this._root) {
+ // Apply theme first, then update
+ this._applyTheme();
+ this._update();
+ // Schedule a follow-up update shortly after initial attach to absorb entity states once Home Assistant populates them
+ clearTimeout(this._initialUpdateTimer);
+ this._initialUpdateTimer = setTimeout(() => {
+ try { this._update(); } catch (_) { }
+ }, 150);
+ }
+ }
+ getCardSize() { return this._cardSize || 3; }
+
+ _render() {
+ if (!this._root) return;
+
+ // Ensure theme is always properly initialized
+ const defaultConfig = KPrinterCard.getStubConfig();
+ this._cfg.theme = { ...defaultConfig.theme, ...(this._cfg.theme || {}) };
+
+ // Apply theme variables to CSS custom properties
+ const theme = this._cfg.theme;
+
+ // Theme CSS custom properties - embedded directly in CSS
+ const themeCSS = `
+ :host {
+ --pause-bg: ${theme.pause_bg || 'rgba(252, 109, 9, .90)'};
+ --resume-bg: ${theme.resume_bg || 'rgba(76, 175, 80, .90)'};
+ --stop-bg: ${theme.stop_bg || 'rgba(244, 67, 54, .95)'};
+ --light-on-bg: ${theme.light_on_bg || 'rgba(255, 235, 59, .95)'};
+ --light-off-bg: ${theme.light_off_bg || 'rgba(150,150,150,.35)'};
+ --pause-icon: ${theme.pause_icon || '#fff'};
+ --resume-icon: ${theme.resume_icon || '#fff'};
+ --stop-icon: ${theme.stop_icon || '#fff'};
+ --light-icon-on: ${theme.light_icon_on || '#000'};
+ --light-icon-off: ${theme.light_icon_off || '#000'};
+ --status-bg: ${theme.status_bg === 'auto' ? 'radial-gradient(var(--card-background-color) 62%, transparent 0)' : (theme.status_bg || 'radial-gradient(var(--card-background-color) 62%, transparent 0)')};
+ --telemetry-icon: ${theme.telemetry_icon === 'auto' ? 'var(--secondary-text-color)' : (theme.telemetry_icon || 'var(--secondary-text-color)')};
+ --telemetry-text: ${theme.telemetry_text === 'auto' ? 'var(--primary-text-color)' : (theme.telemetry_text || 'var(--primary-text-color)')};
+ --custom-bg: ${theme.custom_bg || 'rgba(33, 150, 243, .90)'};
+ --custom-on-bg: ${theme.custom_on_bg || theme.custom_bg || 'rgba(33, 150, 243, .90)'};
+ --custom-off-bg: ${theme.custom_off_bg || 'rgba(150,150,150,.35)'};
+ --custom-icon: ${theme.custom_icon || '#fff'};
+ --custom-icon-off: ${theme.custom_icon_off || '#000'};
+ }
+ `;
+
+
+ const style = `
+ /* Theme CSS custom properties */
+ ${themeCSS}
+
+ /* inherit HA fonts & typography */
+ :host { font: inherit; color: var(--primary-text-color); }
+
+ /* unify horizontal padding so right edges line up */
+ :host { --row-xpad: 6px; }
+
+ .card {
+ color: var(--primary-text-color);
+ padding: 10px var(--row-xpad) 10px var(--row-xpad);
+ display: grid;
+ grid-template-rows: auto auto;
+ gap: 6px;
+ }
+
+ /* top row */
+ .row-top {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ align-items: center;
+ gap: 8px;
+ padding: 0 var(--row-xpad);
+ }
+ .title { display:flex; align-items:center; gap:10px; min-height:44px; }
+
+ /* icon + progress ring */
+ .shape { position:relative; width:40px; height:40px; border-radius:50%;
+ display:grid; place-items:center;
+ background: var(--status-bg, radial-gradient(var(--card-background-color) 62%, transparent 0));
+ }
+ .ring { position:absolute; inset:0; border-radius:50%;
+ mask: radial-gradient(circle at 50% 50%, transparent 54%, black 55%);
+ -webkit-mask: radial-gradient(circle at 50% 50%, transparent 54%, black 55%);
+ background: conic-gradient(var(--ring-color, var(--primary-color)) var(--ring-pct,0%),
+ rgba(128,128,128,.25) var(--ring-pct,0%));
+ }
+ ha-icon { --mdc-icon-size:24px; width:24px; height:24px; color: var(--icon-color); }
+
+ .name { font-weight:600; font-size:.95rem; line-height:1.2; }
+ .secondary { color:var(--secondary-text-color); font-size:.8rem; }
+
+ /* action chips – align right edge to telemetry via the same side padding */
+ .chips {
+ display:flex; gap:8px; justify-content:flex-end; flex-wrap:wrap;
+ padding: 0 var(--row-xpad);
+ }
+ .chip {
+ display:inline-flex; align-items:center; justify-content:center;
+ gap:6px; min-width:38px; height:34px;
+ border-radius:18px; padding:0 10px;
+ font-size:.8rem; background:var(--chip-bg, rgba(128,128,128,.14));
+ color:var(--chip-fg, var(--primary-text-color));
+ cursor:pointer; user-select:none; border:none; outline:none;
+ }
+ .chip[hidden]{ display:none !important; }
+ .chip:active { transform: translateY(1px); }
+ .chip.danger { --chip-bg: var(--stop-bg, rgba(244, 67, 54, .95)); --chip-fg: var(--stop-icon, #fff); }
+ .chip.warn { --chip-bg: var(--pause-bg, rgba(252, 109, 9, .90)); --chip-fg: var(--pause-icon, #fff); }
+ .chip.ok { --chip-bg: var(--resume-bg, rgba(76, 175, 80, .90)); --chip-fg: var(--resume-icon, #fff); }
+ .chip.light-on { --chip-bg: var(--light-on-bg, rgba(255, 235, 59, .95)); --chip-fg: var(--light-icon-on, #000); }
+ .chip.light-off { --chip-bg: var(--light-off-bg, rgba(150,150,150,.35)); --chip-fg: var(--light-icon-off, #000); }
+ .chip.power-on { --chip-bg: var(--power-on-bg, rgba(76, 175, 80, .90)); --chip-fg: var(--power-icon-on, #fff); }
+ .chip.power-off { --chip-bg: var(--power-off-bg, rgba(150,150,150,.35)); --chip-fg: var(--power-icon-off, #000); }
+ .chip.unknown { --chip-bg: rgba(128,128,128,.14); --chip-fg: var(--primary-text-color); }
+ .chip.custom { --chip-bg: var(--custom-bg, rgba(33, 150, 243, .90)); --chip-fg: var(--custom-icon, #fff); }
+ .chip.custom-on { --chip-bg: var(--custom-on-bg, var(--custom-bg, rgba(33, 150, 243, .90))); --chip-fg: var(--custom-icon, #fff); }
+ .chip.custom-off{ --chip-bg: var(--custom-off-bg, rgba(150,150,150,.35)); --chip-fg: var(--custom-icon-off, #000); }
+ /* Order is handled by flex order style or DOM order */
+
+
+ /* telemetry row – single line, same right padding, tighter pills */
+ .telemetry {
+ display:flex;
+ gap:6px;
+ justify-content:center;
+ flex-wrap:wrap;
+ padding: 0 var(--row-xpad);
+ min-width:0;
+ }
+ .pill {
+ display:inline-flex; align-items:center; gap:6px;
+ padding:6px 10px; border-radius:14px;
+ background:rgba(127,127,127,.12);
+ font-size:.8rem; border:1px solid rgba(255,255,255,0.08);
+ white-space:nowrap; flex:0 0 auto;
+ color: var(--telemetry-text, var(--primary-text-color));
+ }
+ .pill ha-icon { --mdc-icon-size:16px; width:16px; height:16px; color: var(--telemetry-icon, var(--secondary-text-color)); }
+
+ .click { cursor:pointer; }
+
+ `;
+
+ this._root.innerHTML = `
+
+
+
+
+
+
+ `;
+
+ // events
+ const fireMoreInfo = (eid) => {
+ if (!eid) return;
+ this.dispatchEvent(new CustomEvent("hass-more-info", {
+ detail: { entityId: eid },
+ bubbles: true,
+ composed: true,
+ }));
+ };
+
+ this._root.getElementById("more")?.addEventListener("click", () => {
+ const eid = this._cfg.camera || this._cfg.status || this._cfg.progress;
+ fireMoreInfo(eid);
+ });
+ this._root.getElementById("more")?.addEventListener("keydown", (ev) => {
+ if (ev.key === "Enter" || ev.key === " ") {
+ ev.preventDefault();
+ const eid = this._cfg.camera || this._cfg.status || this._cfg.progress;
+ fireMoreInfo(eid);
+ }
+ });
+
+
+
+ // --- Chip/Button Event Delegation ---
+ const chipsContainer = this._root.getElementById("chips-container");
+ chipsContainer.addEventListener("click", (e) => {
+ const btn = e.target.closest("button.chip");
+ if (!btn) return;
+ const id = btn.id;
+
+ if (id === "power") {
+ const eid = this._resolveEntityId(this._cfg.power, ["switch"]);
+ // Confirm only when the press would turn the printer OFF (not when
+ // turning it on), with a stronger warning while a print is in progress
+ // so an accidental tap can't kill a running job.
+ if (this._hass?.states?.[eid]?.state === "on") {
+ const st = normStr(this._hass?.states?.[this._cfg.status]?.state);
+ const printing = ["printing", "resuming", "pausing", "paused"].includes(st);
+ const msg = printing ? this._t("confirm_power_off_printing") : this._t("confirm_power_off");
+ if (!confirm(msg)) return;
+ }
+ this._toggleEntity(eid);
+ } else if (id === "light") {
+ const eid = this._resolveEntityId(this._cfg.light, ["light", "switch"]);
+ this._toggleEntity(eid);
+ } else if (id === "pause") {
+ this._pressButtonEntity(this._cfg.pause_btn);
+ } else if (id === "resume") {
+ this._pressButtonEntity(this._cfg.resume_btn);
+ } else if (id === "stop") {
+ if (confirm(this._t("confirm_stop"))) {
+ this._pressButtonEntity(this._cfg.stop_btn);
+ }
+ } else if (id === "custom") {
+ // Custom button can be a button (press), script (turn_on/run), switch (toggle), automation (trigger), etc.
+ // For simplicity, treat as toggle if switch/light/input_boolean, else press/turn_on
+ const eid = this._cfg.custom_btn;
+ const domain = eid ? (eid.split(".")[0] || "").toLowerCase() : "";
+ if (["switch", "light", "input_boolean"].includes(domain)) {
+ this._toggleEntity(eid);
+ } else {
+ this._pressButtonEntity(eid); // Fallback to press (works for button domain, or generic turn_on if mapped)
+ }
+ }
+ });
+
+ this._update();
+ this._setupTelemetrySizeObserver();
+ }
+
+ connectedCallback() {
+ // The card may be re-attached without setConfig firing again (e.g. when Lovelace
+ // moves the element between containers). _render() — which normally wires up the
+ // telemetry observer — only runs from setConfig/hass paths, so reinstate the
+ // observer here whenever a previously-rendered card returns to the DOM.
+ if (this._root) {
+ this._setupTelemetrySizeObserver();
+ }
+ }
+
+ disconnectedCallback() {
+ clearTimeout(this._initialUpdateTimer);
+ if (this._telemetryResizeObserver) {
+ if (this._telemetryObservedNode) {
+ this._telemetryResizeObserver.unobserve(this._telemetryObservedNode);
+ this._telemetryObservedNode = null;
+ }
+ this._telemetryResizeObserver.disconnect();
+ this._telemetryResizeObserver = null;
+ }
+ if (this._telemetrySizeFrame) {
+ cancelAnimationFrame(this._telemetrySizeFrame);
+ this._telemetrySizeFrame = null;
+ }
+ }
+
+ _setupTelemetrySizeObserver() {
+ const telemetry = this._root?.querySelector(".telemetry");
+ if (!telemetry) return;
+
+ if (typeof ResizeObserver !== "function") {
+ this._scheduleTelemetrySizeUpdate();
+ return;
+ }
+
+ if (!this._telemetryResizeObserver) {
+ this._telemetryResizeObserver = new ResizeObserver(() => this._scheduleTelemetrySizeUpdate());
+ }
+
+ if (this._telemetryObservedNode && this._telemetryObservedNode !== telemetry) {
+ this._telemetryResizeObserver.unobserve(this._telemetryObservedNode);
+ }
+
+ if (this._telemetryObservedNode !== telemetry) {
+ this._telemetryResizeObserver.observe(telemetry);
+ this._telemetryObservedNode = telemetry;
+ }
+ this._scheduleTelemetrySizeUpdate();
+ }
+
+ _scheduleTelemetrySizeUpdate() {
+ if (this._telemetrySizeFrame) {
+ cancelAnimationFrame(this._telemetrySizeFrame);
+ }
+ this._telemetrySizeFrame = requestAnimationFrame(() => {
+ this._telemetrySizeFrame = null;
+ this._updateTelemetryCardSize();
+ });
+ }
+
+ _updateTelemetryCardSize() {
+ const telemetry = this._root?.querySelector(".telemetry");
+ if (!telemetry) return;
+
+ const lineTops = new Set(
+ Array.from(telemetry.children)
+ .filter((pill) => {
+ const style = getComputedStyle(pill);
+ return style.display !== "none" && style.visibility !== "hidden";
+ })
+ .map((pill) => Math.round(pill.offsetTop)),
+ );
+ const nextSize = Math.max(3, 2 + lineTops.size);
+ const currentSize = this._cardSize ?? 3;
+ if (nextSize === currentSize) return;
+
+ const cardKey = this._cardId || CARD_TAG;
+ const now = Date.now();
+ const lastDispatch = _lastCardRebuildDispatch.get(cardKey) || 0;
+ // Defer the _cardSize update until the throttle clears: otherwise a throttled
+ // call would record the new size locally without telling Lovelace, and the
+ // next measurement would short-circuit on the equality check above — leaving
+ // the rebuild permanently suppressed.
+ if (now - lastDispatch < LL_REBUILD_MIN_INTERVAL_MS) return;
+ _lastCardRebuildDispatch.set(cardKey, now);
+ this._cardSize = nextSize;
+
+ this.dispatchEvent(new CustomEvent("ll-rebuild", { bubbles: true, composed: true }));
+ }
+
+ // Re-implement _pressButtonEntity to be smarter about non-button domains if needed,
+ // but existing implementation calls button.press.
+ // For custom buttons (e.g. scripts), we might want to default to homeassistant.turn_on if button.press fails is overkill,
+ // but let's keep it simple: if it's a script/automation, button.press might not work.
+ // Let's refine _pressButtonEntity to handle more types or create a generic helper.
+
+ async _pressButtonEntity(eid) {
+ if (!this._hass || !eid) return;
+ const domain = eid.split(".")[0];
+ if (domain === "button" || domain === "input_button") {
+ await this._hass.callService(domain, "press", { entity_id: eid });
+ } else {
+ // Fallback for scripts, automations, scenes which act like "press" via turn_on
+ await this._hass.callService("homeassistant", "turn_on", { entity_id: eid });
+ }
+ }
+ async _toggleEntity(eid) {
+ if (!this._hass || !eid) return;
+ const st = this._hass.states[eid];
+ const domain = (eid.split(".")[0] || "").toLowerCase();
+ if (domain === "switch" || domain === "light") {
+ const next = st?.state === "on" ? "off" : "on";
+ // Only apply optimistic UI to the power switch; Light strictly reflects HA state
+ const resolvedPower = this._resolveEntityId(this._cfg.power, ["switch"]);
+ if (resolvedPower && eid === resolvedPower) {
+ const now = Date.now();
+ this._optimisticStates = this._optimisticStates || {};
+ this._optimisticStates[eid] = { state: next, until: now + 2000 };
+ this._update();
+ }
+ await this._hass.callService(domain, next === "on" ? "turn_on" : "turn_off", { entity_id: eid });
+ } else {
+ await this._hass.callService("homeassistant", "toggle", { entity_id: eid });
+ }
+ }
+
+ _resolveEntityId(eid, preferredDomains = []) {
+ if (!eid || !this._hass) return eid;
+ if (this._hass.states[eid]) return eid;
+ const parts = String(eid).split(".");
+ if (parts.length === 2) {
+ const id = parts[1];
+ for (const dom of preferredDomains) {
+ const candidate = `${dom}.${id}`;
+ if (this._hass.states[candidate]) return candidate;
+ }
+ } else if (parts.length === 1) {
+ const id = parts[0];
+ for (const dom of preferredDomains) {
+ const candidate = `${dom}.${id}`;
+ if (this._hass.states[candidate]) return candidate;
+ }
+ }
+ return eid;
+ }
+
+ _update() {
+ if (!this._root) return;
+ const now = Date.now();
+ // Purge expired optimistic entries
+ if (this._optimisticStates) {
+ for (const [key, val] of Object.entries(this._optimisticStates)) {
+ if (!val || val.until <= now) delete this._optimisticStates[key];
+ }
+ }
+ const g = (eid) => {
+ if (!eid) return undefined;
+ const ov = this._optimisticStates?.[eid];
+ if (ov && ov.until > now && (eid.startsWith("switch.") || eid.startsWith("light."))) {
+ return ov.state;
+ }
+ return this._hass?.states?.[eid]?.state;
+ };
+ const gObj = (eid) => this._hass?.states?.[eid];
+ const gNum = (eid) => Number(g(eid));
+ const fmtState = (st) => {
+ if (!st) return "—";
+ const v = st.state;
+ if (v === undefined || v === null) return "—";
+ const s = String(v);
+ if (s === "unknown" || s === "unavailable") return "—";
+ // Prefer HA's built-in formatter to honor per-entity precision and units
+ if (this._hass && typeof this._hass.formatEntityState === 'function') {
+ try { return this._hass.formatEntityState(st); } catch (_) { }
+ }
+ // Fallback: number-aware formatting with (suggested_)display_precision when present
+ const unit = st.attributes?.unit_of_measurement;
+ const n = Number(s);
+ if (!Number.isNaN(n) && Number.isFinite(n)) {
+ const dp = (typeof st.attributes?.display_precision === 'number') ? st.attributes.display_precision
+ : (typeof st.attributes?.suggested_display_precision === 'number') ? st.attributes.suggested_display_precision
+ : (unit && /°|c|f/i.test(unit)) ? 1
+ : 2;
+ const out = n.toFixed(Math.max(0, Math.min(6, dp)));
+ return unit ? `${out} ${unit}` : out;
+ }
+ return unit ? `${s} ${unit}` : s;
+ };
+ const fmtWithUnit = (eid) => fmtState(gObj(eid));
+
+ const name = this._cfg.name || "3D Printer";
+ const status = g(this._cfg.status) ?? "unknown";
+ const pct = clamp(Number.isFinite(gNum(this._cfg.progress)) ? gNum(this._cfg.progress) : 0, 0, 100);
+ const timeLeft = gNum(this._cfg.time_left) || 0;
+ const nozzleStr = fmtWithUnit(this._cfg.nozzle);
+ const bedStr = fmtWithUnit(this._cfg.bed);
+ const boxStr = fmtWithUnit(this._cfg.box);
+ const _rawLayer = g(this._cfg.layer);
+ const layer = (_rawLayer && _rawLayer !== "unavailable" && _rawLayer !== "unknown" ? _rawLayer : "") + "";
+ const _rawTotalLayers = g(this._cfg.total_layers);
+ const totalLayers = (_rawTotalLayers && _rawTotalLayers !== "unavailable" && _rawTotalLayers !== "unknown" ? _rawTotalLayers : "") + "";
+ const resolvedLight = this._resolveEntityId(this._cfg.light, ["light", "switch"]);
+ const lightState = this._hass?.states?.[resolvedLight]?.state;
+ const resolvedPower = this._resolveEntityId(this._cfg.power, ["switch"]);
+ const powerState = g(resolvedPower);
+
+ const st = normStr(status);
+ const isPrinting = ["printing", "resuming", "pausing"].includes(st);
+ const isPaused = st === "paused";
+ const showStop = isPrinting || isPaused || st === "self-testing";
+ // Show Light chip only when the light entity exists in HA state and power (if configured) is not OFF
+ const showLight = Boolean(resolvedLight && this._hass?.states?.[resolvedLight]) && !(resolvedPower && powerState === "off");
+ // Show the Power button whenever configured, independent of printer status
+ const showPower = Boolean(this._cfg.show_power_button) && Boolean(this._cfg.power);
+
+ // Title/status
+ this._root.getElementById("name").textContent = name;
+ const proper = (!status || status === "unavailable" || status === "unknown") ? this._t("status_unknown") : (fmtState(gObj(this._cfg.status)) || status[0].toUpperCase() + status.slice(1));
+ const sec = (isPrinting || isPaused) ? `${pct}% ${proper}` : proper;
+ this._root.getElementById("secondary").textContent = sec;
+
+ // Icon & ring
+ const iconEl = this._root.getElementById("icon");
+ iconEl.setAttribute("icon", computeIcon(status));
+ const theme = this._cfg.theme || {};
+ const iconColor = theme.status_icon === "auto" ? computeColor(status) : theme.status_icon;
+ iconEl.style.setProperty("--icon-color", iconColor);
+ const ring = this._root.getElementById("ring");
+ ring.style.setProperty("--ring-pct", isPrinting || isPaused ? `${pct}%` : "0%");
+ const ringColor = theme.progress_ring === "auto" ? computeColor(status) : theme.progress_ring;
+ ring.style.setProperty("--ring-color", ringColor);
+
+ const chipsContainer = this._root.getElementById("chips-container");
+
+ // Define button definitions
+ const buttons = {
+ pause: {
+ hidden: !isPrinting,
+ class: "warn",
+ icon: this._cfg.pause_btn_icon || "mdi:pause",
+ title: this._t("chip_pause")
+ },
+ resume: {
+ hidden: !isPaused,
+ class: "ok",
+ icon: this._cfg.resume_btn_icon || "mdi:play",
+ title: this._t("chip_resume")
+ },
+ stop: {
+ hidden: !showStop,
+ class: "danger",
+ icon: this._cfg.stop_btn_icon || "mdi:stop",
+ title: this._t("chip_stop")
+ },
+ light: {
+ hidden: !showLight,
+ class: lightState === "on" ? "light-on" : "light-off",
+ icon: this._cfg.light_btn_icon || "mdi:lightbulb",
+ title: this._t("chip_light")
+ },
+ power: {
+ hidden: !showPower,
+ class: "unknown", // Calculated below
+ icon: this._cfg.power_btn_icon || "mdi:power",
+ title: this._t("chip_power")
+ },
+ custom: {
+ hidden: Boolean(this._cfg.custom_btn_hidden) || !this._cfg.custom_btn,
+ class: "custom",
+ icon: this._cfg.custom_btn_icon || "mdi:gesture-tap",
+ title: this._t("chip_custom")
+ }
+ };
+
+ // Calculate Custom Button Class based on state
+ if (!buttons.custom.hidden) {
+ const customEid = this._cfg.custom_btn;
+ const dom = customEid ? customEid.split(".")[0] : "";
+ const state = g(customEid);
+ // Only use on/off styling for stateful domains
+ if (["switch", "light", "input_boolean", "fan", "cover", "binary_sensor"].includes(dom)) {
+ const isOn = state === "on" || state === "open"; // cover uses open/closed
+ buttons.custom.class = isOn ? "custom-on" : "custom-off";
+ }
+ }
+
+ // Calculate Power Class Logic
+ if (!buttons.power.hidden) {
+ this._lastPowerState = this._lastPowerState || null;
+ if (powerState === "on" || powerState === "off") {
+ this._lastPowerState = powerState;
+ }
+ const isOn = powerState === "on" || (!powerState && this._lastPowerState === "on");
+ const isOff = powerState === "off" || (!powerState && this._lastPowerState === "off");
+ buttons.power.class = isOn ? "power-on" : (isOff ? "power-off" : "unknown");
+ }
+
+ // Reconstruct DOM only if order or visibility needs update (or simple clear/redraw for robustness)
+ // To avoid losing focus or animation, we could diff, but for this card, clear/redraw is fast enough
+ // IF we don't do it every frame. However, we're in _update().
+ // Let's build the HTML string for buttons based on order.
+
+ const order = Array.isArray(this._cfg.button_order) ? this._cfg.button_order : ['pause', 'resume', 'stop', 'light', 'power', 'custom'];
+
+ // Filter duplicates and ensure valid keys
+ const uniqueOrder = [...new Set(order)].filter(k => buttons[k]);
+
+ // Add any missing standard buttons to the end if not present (backward compat)
+ ['pause', 'resume', 'stop', 'light', 'power'].forEach(k => {
+ if (!uniqueOrder.includes(k) && buttons[k]) uniqueOrder.push(k);
+ });
+
+ let chipsHtml = "";
+ uniqueOrder.forEach(key => {
+ const btn = buttons[key];
+ if (btn && !btn.hidden) {
+ chipsHtml += ``;
+ }
+ });
+
+ if (chipsContainer.innerHTML !== chipsHtml) {
+ chipsContainer.innerHTML = chipsHtml;
+ }
+
+ // Telemetry
+ this._root.getElementById("nozzle").textContent = nozzleStr;
+ this._root.getElementById("bed").textContent = bedStr;
+ this._root.getElementById("box").textContent = boxStr;
+ this._root.getElementById("time").textContent = fmtTimeLeft(timeLeft);
+ this._root.getElementById("layers").textContent = `${layer || "—"}/${totalLayers || "—"}`;
+
+ // Toggle Chamber Temp visibility.
+ // Hide when explicitly hidden, when no chamber entity is configured, or when
+ // the configured entity does not exist in HA (printers without a chamber,
+ // e.g. Ender 3 V3 KE) so we don't render a stray thermometer icon that
+ // offsets the adjacent telemetry. A configured-but-unavailable entity stays
+ // visible and shows "—", matching the nozzle/bed pills.
+ const boxPill = this._root.getElementById("box-pill");
+ if (boxPill) {
+ const boxConfigured = Boolean(this._cfg.box) && Boolean(this._hass?.states?.[this._cfg.box]);
+ boxPill.style.display = (this._cfg.hide_box_temp || !boxConfigured) ? "none" : "";
+ }
+ this._scheduleTelemetrySizeUpdate();
+ }
+}
+const CARD_TRANSLATIONS = {
+ en: {
+ status_unknown: "Unknown",
+ confirm_stop: "Are you sure you want to stop the print?",
+ confirm_power_off: "Are you sure you want to power off the printer?",
+ confirm_power_off_printing: "A print is in progress. Are you sure you want to power off the printer?",
+ chip_pause: "Pause",
+ chip_resume: "Resume",
+ chip_stop: "Stop",
+ chip_light: "Light",
+ chip_power: "Power",
+ chip_custom: "Custom Action",
+ editor_title: "Creality Printer Card Configuration",
+ tab_entities: "Entities",
+ tab_theme: "Theme",
+ group_layout: "Layout & Icons",
+ group_action_colors: "Action Colors",
+ group_status_area: "Status Area",
+ group_telemetry: "Telemetry",
+ color_pause_bg: "Pause Button Background",
+ color_pause_icon: "Pause Button Icon",
+ color_resume_bg: "Resume Button Background",
+ color_resume_icon: "Resume Button Icon",
+ color_stop_bg: "Stop Button Background",
+ color_stop_icon: "Stop Button Icon",
+ color_light_on_bg: "Light On Background",
+ color_light_off_bg: "Light Off Background",
+ color_light_icon_on: "Light Button Icon (On)",
+ color_light_icon_off: "Light Button Icon (Off)",
+ color_custom_bg: "Custom Button Background",
+ color_custom_icon: "Custom Button Icon",
+ color_status_icon: "Status Icon Color",
+ color_progress_ring: "Progress Ring Color",
+ color_status_bg: "Status Background",
+ color_telemetry_icon: "Telemetry Icon Color",
+ color_telemetry_text: "Telemetry Text Color",
+ btn_save: "Save",
+ btn_reset: "Reset to Defaults",
+ color_picker_hint: "Click to open color picker",
+ label_name: "Printer Name",
+ label_camera: "Camera",
+ label_status: "Print Status Sensor",
+ label_progress: "Print Progress Sensor (%)",
+ label_time_left: "Time Left Sensor",
+ label_nozzle: "Nozzle Temperature Sensor",
+ label_bed: "Bed Temperature Sensor",
+ label_box: "Chamber Temperature Sensor",
+ label_power: "Power Switch",
+ label_show_power_button: "Show Power Button",
+ label_layer: "Current Layer Sensor",
+ label_total_layers: "Total Layers Sensor",
+ label_light: "Light Switch",
+ label_pause_btn: "Pause Button",
+ label_resume_btn: "Resume Button",
+ label_stop_btn: "Stop Button",
+ label_custom_btn: "Custom Action Entity",
+ label_custom_btn_icon: "Custom Button Icon",
+ label_custom_btn_hidden: "Hide Custom Button",
+ label_button_order: "Button Order (list)",
+ helper_name: "Display name for the printer card",
+ helper_camera: "Camera entity for live video feed",
+ helper_status: "Sensor showing current print status",
+ helper_progress: "Sensor showing print progress (0-100%)",
+ helper_time_left: "Sensor showing remaining print time (seconds)",
+ helper_nozzle: "Sensor showing nozzle temperature",
+ helper_bed: "Sensor showing bed temperature",
+ helper_box: "Sensor showing chamber/enclosure temperature (optional)",
+ helper_power: "Optional power switch entity for the printer (shows a Power button when set)",
+ helper_show_power_button: "Show the Power button when a power switch entity is configured",
+ helper_layer: "Sensor showing current print layer",
+ helper_total_layers: "Sensor showing total print layers",
+ helper_light: "Switch entity for printer light control",
+ helper_pause_btn: "Button entity to pause printing",
+ helper_resume_btn: "Button entity to resume printing",
+ helper_stop_btn: "Button entity to stop printing",
+ helper_custom_btn: "Any entity to trigger (Button, Script, Switch, etc.)",
+ helper_custom_btn_icon: "Icon for the custom button",
+ helper_custom_btn_hidden: "Hide the custom button",
+ helper_button_order: "List of buttons to show in order (pause, resume, stop, light, power, custom)",
+ schema_button_order: "Button Order (pause, resume, stop, light, power, custom)",
+ schema_hide_custom: "Hide Custom Button",
+ schema_hide_box_temp: "Hide Chamber Temperature",
+ schema_pause_icon: "Pause Icon Override",
+ schema_resume_icon: "Resume Icon Override",
+ schema_stop_icon: "Stop Icon Override",
+ schema_light_icon: "Light Icon Override",
+ schema_power_icon: "Power Icon Override",
+ schema_custom_icon: "Custom Button Icon",
+ editor_error_title: "Editor Error",
+ editor_error_msg: "There was an error loading the visual editor. You can still edit your configuration using YAML.",
+ editor_error_prefix: "Error:",
+ },
+};
+
+customElements.define(CARD_TAG, KPrinterCard);
+
+/* Interactive theme editor */
+class KPrinterCardEditor extends HTMLElement {
+ // i18n helpers -------------------------------------------------------
+ _resolveLanguage() {
+ return _resolveLang(this._hass);
+ }
+ _t(key) {
+ return _translate(this._hass, "printer_card", CARD_TRANSLATIONS, key);
+ }
+ // ---------------------------------------------------------------------
+
+ set hass(hass) {
+ this._hass = hass;
+ if (this._entitiesForm) this._entitiesForm.hass = hass;
+ _requestI18n(this, hass, () => { if (this._root) this._render(); });
+ }
+ setConfig(config) {
+ const defaultConfig = KPrinterCard.getStubConfig();
+ this._cfg = { ...defaultConfig, ...(config || {}) };
+
+ // Always ensure theme is properly initialized
+ this._cfg.theme = { ...defaultConfig.theme, ...(this._cfg.theme || {}) };
+
+ if (this._root) {
+ this._render();
+ }
+ }
+ connectedCallback() { if (!this._root) { this._root = this.attachShadow({ mode: "open" }); this._render(); } }
+
+ _render() {
+ if (!this._root || !this._cfg) return;
+
+ try {
+
+ const style = `
+ :host { display: block; }
+ .editor-container { padding: 16px; max-width: 1200px; margin: 0 auto; }
+ .tabs { display: flex; border-bottom: 1px solid var(--divider-color); margin-bottom: 16px; }
+ .tab { padding: 8px 16px; cursor: pointer; border-bottom: 2px solid transparent; }
+ .tab.active { border-bottom-color: var(--primary-color); color: var(--primary-color); }
+ .tab-content { display: none; }
+ .tab-content.active { display: block; }
+
+ .theme-editor { display: block; }
+
+ .theme-controls {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ }
+
+ .control-group {
+ background: var(--card-background-color);
+ border: 1px solid var(--divider-color);
+ border-radius: 8px;
+ padding: 12px;
+ }
+
+ .group-title {
+ font-size: 14px;
+ font-weight: 600;
+ margin-bottom: 8px;
+ color: var(--primary-text-color);
+ }
+
+ .clickable-element {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ cursor: pointer;
+ margin: 4px 0;
+ transition: all 0.2s;
+ }
+
+ .clickable-element:hover {
+ border-color: var(--primary-color);
+ background: rgba(var(--rgb-primary-color), 0.1);
+ }
+
+ .element-preview {
+ width: 24px;
+ height: 24px;
+ border-radius: 4px;
+ border: 1px solid var(--divider-color);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 12px;
+ }
+
+ .element-label {
+ font-size: 12px;
+ color: var(--primary-text-color);
+ flex: 1;
+ }
+
+ .color-picker-inline {
+ background: var(--card-background-color, #ffffff);
+ border: 1px solid var(--divider-color, #ccc);
+ border-radius: 8px;
+ padding: 12px;
+ margin: 8px 0;
+ display: none;
+ position: relative;
+ z-index: 10;
+ }
+
+ .color-picker-inline.active {
+ display: block;
+ }
+
+
+ .reset-btn {
+ background: var(--error-color);
+ color: white;
+ border: none;
+ padding: 8px 16px;
+ border-radius: 4px;
+ cursor: pointer;
+ margin-top: 16px;
+ width: 100%;
+ }
+
+ .reset-btn:hover {
+ opacity: 0.8;
+ }
+
+ .input-row { margin-bottom: 12px; }
+ .input-label { display: block; font-size: 12px; font-weight: 500; margin-bottom: 4px; color: var(--primary-text-color); }
+ .text-input { width: 100%; padding: 8px; border: 1px solid var(--divider-color); border-radius: 4px; background: var(--card-background-color); color: var(--primary-text-color); box-sizing: border-box;}
+ .input-helper { font-size: 11px; color: var(--secondary-text-color); margin-top: 2px; }
+ .checkbox-row { display: flex; align-items: center; justify-content: space-between; }
+ `;
+
+ this._root.innerHTML = `
+
+
+
${this._t("editor_title")}
+
+
${this._t("tab_entities")}
+
${this._t("tab_theme")}
+
+
+
+
+
+
+
+
+
+
+
${this._t("group_layout")}
+
+
+
+
+
${this._t("group_action_colors")}
+
+
+
${this._t("color_pause_bg")}
+
+
+
+
+
+
+
+
+
+
⏸
+
${this._t("color_pause_icon")}
+
+
+
+
+
+
+
+
+
+
+
${this._t("color_resume_bg")}
+
+
+
+
+
+
+
+
+
+
▶
+
${this._t("color_resume_icon")}
+
+
+
+
+
+
+
+
+
+
+
${this._t("color_stop_bg")}
+
+
+
+
+
+
+
+
+
+
⏹
+
${this._t("color_stop_icon")}
+
+
+
+
+
+
+
+
+
+
+
${this._t("color_light_on_bg")}
+
+
+
+
+
+
+
+
+
+
+
${this._t("color_light_off_bg")}
+
+
+
+
+
+
+
+
+
+
💡
+
${this._t("color_light_icon_on")}
+
+
+
+
+
+
+
+
+
+
💡
+
${this._t("color_light_icon_off")}
+
+
+
+
+
+
+
+
+
+
+
${this._t("color_custom_bg")}
+
+
+
+
+
+
+
+
+
+
★
+
${this._t("color_custom_icon")}
+
+
+
+
+
+
+
+
+
+
+
+
${this._t("group_status_area")}
+
+
🖨
+
${this._t("color_status_icon")}
+
+
+
+
+
+
+
+
+
+
⭕
+
${this._t("color_progress_ring")}
+
+
+
+
+
+
+
+
+
+
🎯
+
${this._t("color_status_bg")}
+
+
+
+
+
+
+
+
+
+
+
+
${this._t("group_telemetry")}
+
+
🌡
+
${this._t("color_telemetry_icon")}
+
+
+
+
+
+
+
+
+
+
📝
+
${this._t("color_telemetry_text")}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ this._setupTabs();
+ this._setupEntitiesForm();
+ this._setupThemeEditor();
+
+ } catch (error) {
+ console.error('Error rendering K-Printer Card Editor:', error);
+ this._root.innerHTML = `
+
+
Editor Error
+
${this._t("editor_error_msg")}
+
${this._t("editor_error_prefix")} ${error.message}
+
+ `;
+ }
+ }
+
+ _setupTabs() {
+ const tabs = this._root.querySelectorAll('.tab');
+ const contents = this._root.querySelectorAll('.tab-content');
+
+ tabs.forEach(tab => {
+ tab.addEventListener('click', () => {
+ tabs.forEach(t => t.classList.remove('active'));
+ contents.forEach(c => c.classList.remove('active'));
+
+ tab.classList.add('active');
+ const tabId = tab.dataset.tab + '-tab';
+ this._root.getElementById(tabId).classList.add('active');
+ });
+ });
+ }
+
+ _setupEntitiesForm() {
+ this._entitiesForm = this._root.getElementById('entities-form');
+ this._entitiesForm.hass = this._hass;
+
+ // Helper text mapping for form fields
+ const helperText = {
+ "name": "Display name for the printer card",
+ "camera": "Camera entity for live video feed",
+ "status": "Sensor showing current print status",
+ "progress": "Sensor showing print progress (0-100%)",
+ "time_left": "Sensor showing remaining print time (seconds)",
+ "nozzle": "Sensor showing nozzle temperature",
+ "bed": "Sensor showing bed temperature",
+ "box": "Sensor showing chamber/enclosure temperature (optional)",
+ "power": "Optional power switch entity for the printer (shows a Power button when set)",
+ "show_power_button": "Show the Power button when a power switch entity is configured",
+ "layer": "Sensor showing current print layer",
+ "total_layers": "Sensor showing total print layers",
+ "light": "Switch entity for printer light control",
+ "pause_btn": "Button entity to pause printing",
+ "resume_btn": "Button entity to resume printing",
+ "stop_btn": "Button entity to stop printing",
+ "custom_btn": "Any entity to trigger (Button, Script, Switch, etc.)",
+ "custom_btn_icon": "Icon for the custom button",
+ "custom_btn_hidden": "Hide the custom button",
+ "button_order": "List of buttons to show in order (pause, resume, stop, light, power, custom)"
+ };
+
+ this._entitiesForm.schema = [
+ { name: "name", selector: { text: {} } },
+ { name: "camera", selector: { entity: { domain: "camera" } } },
+ { name: "status", selector: { entity: { domain: "sensor" } } },
+ { name: "progress", selector: { entity: { domain: "sensor" } } },
+ { name: "time_left", selector: { entity: { domain: "sensor" } } },
+ { name: "nozzle", selector: { entity: { domain: "sensor" } } },
+ { name: "bed", selector: { entity: { domain: "sensor" } } },
+ { name: "box", selector: { entity: { domain: "sensor" } } },
+ { name: "power", selector: { entity: { domain: ["switch", "input_boolean"] } } },
+ { name: "show_power_button", selector: { boolean: {} } },
+ { name: "layer", selector: { entity: { domain: "sensor" } } },
+ { name: "total_layers", selector: { entity: { domain: "sensor" } } },
+ { name: "light", selector: { entity: { domain: ["switch", "light"] } } },
+ { name: "pause_btn", selector: { entity: { domain: "button" } } },
+ { name: "resume_btn", selector: { entity: { domain: "button" } } },
+ { name: "stop_btn", selector: { entity: { domain: "button" } } },
+ { name: "custom_btn", selector: { entity: {} } },
+ ];
+
+ // Label text mapping for form fields
+ const labelText = {
+ "name": this._t("label_name"),
+ "camera": this._t("label_camera"),
+ "status": this._t("label_status"),
+ "progress": this._t("label_progress"),
+ "time_left": this._t("label_time_left"),
+ "nozzle": this._t("label_nozzle"),
+ "bed": this._t("label_bed"),
+ "box": this._t("label_box"),
+ "power": this._t("label_power"),
+ "show_power_button": this._t("label_show_power_button"),
+ "layer": this._t("label_layer"),
+ "total_layers": this._t("label_total_layers"),
+ "light": this._t("label_light"),
+ "pause_btn": this._t("label_pause_btn"),
+ "resume_btn": this._t("label_resume_btn"),
+ "stop_btn": this._t("label_stop_btn"),
+ "custom_btn": this._t("label_custom_btn"),
+ "custom_btn_icon": this._t("label_custom_btn_icon"),
+ "custom_btn_hidden": this._t("label_custom_btn_hidden"),
+ "button_order": this._t("label_button_order"),
+ };
+
+ // Add label computation using computeLabel if supported
+ if (this._entitiesForm.computeLabel) {
+ const originalComputeLabel = this._entitiesForm.computeLabel.bind(this._entitiesForm);
+ this._entitiesForm.computeLabel = (schema) => {
+ return labelText[schema.name] || originalComputeLabel(schema);
+ };
+ }
+
+ // Add helper text using computeHelper if supported
+ if (this._entitiesForm.computeHelper) {
+ const originalComputeHelper = this._entitiesForm.computeHelper.bind(this._entitiesForm);
+ this._entitiesForm.computeHelper = (schema) => {
+ return helperText[schema.name] || originalComputeHelper(schema);
+ };
+ }
+
+ this._entitiesForm.data = this._cfg;
+
+ this._entitiesForm.addEventListener("value-changed", (ev) => {
+ const val = ev.detail?.value || {};
+ this._cfg = { ...this._cfg, ...val };
+ this._dispatchConfigChange();
+ });
+ }
+
+ _setupThemeEditor() {
+
+ // Setup generic config form (Layout & Icons)
+ this._themeSettingsForm = this._root.getElementById('theme-settings-form');
+ this._themeSettingsForm.hass = this._hass;
+
+ const themeSettingsSchema = [
+ { name: "button_order", selector: { text: {} }, label: this._t("schema_button_order") },
+ { name: "custom_btn_hidden", selector: { boolean: {} }, label: this._t("schema_hide_custom") },
+ { name: "hide_box_temp", selector: { boolean: {} }, label: this._t("schema_hide_box_temp") },
+ { name: "pause_btn_icon", selector: { icon: {} }, label: this._t("schema_pause_icon") },
+ { name: "resume_btn_icon", selector: { icon: {} }, label: this._t("schema_resume_icon") },
+ { name: "stop_btn_icon", selector: { icon: {} }, label: this._t("schema_stop_icon") },
+ { name: "light_btn_icon", selector: { icon: {} }, label: this._t("schema_light_icon") },
+ { name: "power_btn_icon", selector: { icon: {} }, label: this._t("schema_power_icon") },
+ { name: "custom_btn_icon", selector: { icon: {} }, label: this._t("schema_custom_icon") },
+ ];
+
+ // Polyfill label if needed or rely on 'label' property in schema if HA supports it (HA Form supports 'label' in schema usually? No, it uses computeLabel)
+ // Let's use computeLabel for this form too.
+ this._themeSettingsForm.schema = themeSettingsSchema;
+
+ // Prepare data for the form. button_order needs to be stringified if it's an array, or handled as list.
+ // Text input expects string.
+ const prepareFormData = (cfg) => {
+ return {
+ ...cfg,
+ button_order: Array.isArray(cfg.button_order) ? cfg.button_order.join(', ') : cfg.button_order
+ };
+ };
+
+ this._themeSettingsForm.data = prepareFormData(this._cfg);
+
+ this._themeSettingsForm.addEventListener("value-changed", (ev) => {
+ const val = ev.detail?.value || {};
+
+ // Post-process button_order back to array
+ if (val.button_order && typeof val.button_order === 'string') {
+ val.button_order = val.button_order.split(',').map(s => s.trim()).filter(s => s);
+ }
+
+ this._cfg = { ...this._cfg, ...val };
+ this._dispatchConfigChange();
+ });
+
+ // Setup clickable elements to toggle inline color pickers
+ const clickableElements = this._root.querySelectorAll('.clickable-element');
+ clickableElements.forEach(element => {
+ element.addEventListener('click', () => {
+ const themeKey = element.dataset.theme;
+ this._toggleColorPicker(themeKey);
+ });
+ });
+
+ // Setup color picker interactions
+ this._setupColorPickerInteractions();
+
+ // Setup reset button
+ this._root.getElementById('reset-theme').addEventListener('click', () => {
+ const defaultConfig = KPrinterCard.getStubConfig();
+ // Reset Theme
+ this._cfg.theme = { ...defaultConfig.theme };
+
+ // Reset Layout & Icons
+ ['button_order', 'custom_btn_hidden', 'pause_btn_icon', 'resume_btn_icon', 'stop_btn_icon', 'light_btn_icon', 'power_btn_icon', 'custom_btn_icon'].forEach(k => {
+ this._cfg[k] = defaultConfig[k];
+ });
+
+ // Clear saved theme from storage
+ const cardId = generateCardId(this._cfg);
+ try {
+ const themes = JSON.parse(localStorage.getItem(THEME_STORAGE_KEY) || "{}");
+ delete themes[cardId];
+ localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(themes));
+ } catch (e) {
+ console.warn("Failed to clear theme from localStorage:", e);
+ }
+
+ this._updateThemeControls();
+
+ // Update config inputs as well
+ if (this._themeSettingsForm) {
+ this._themeSettingsForm.data = prepareFormData(this._cfg);
+ }
+
+ this._dispatchConfigChange();
+ });
+ }
+
+ _toggleColorPicker(themeKey) {
+ const picker = this._root.getElementById(`color-picker-${themeKey}`);
+ if (!picker) return;
+
+ // If this picker is already active, close it
+ if (picker.classList.contains('active')) {
+ picker.classList.remove('active');
+ return;
+ }
+
+ // Hide all other color pickers
+ const allPickers = this._root.querySelectorAll('.color-picker-inline');
+ allPickers.forEach(p => {
+ p.classList.remove('active');
+ });
+
+ // Show the clicked color picker
+ picker.classList.add('active');
+ }
+
+ _setupColorPickerInteractions() {
+ // Setup color preview clicks to open native color picker
+ const colorPreviews = this._root.querySelectorAll('.color-preview');
+ colorPreviews.forEach(preview => {
+ preview.addEventListener('click', () => {
+ const picker = preview.closest('.color-picker-inline');
+ if (picker) {
+ const textInput = picker.querySelector('.color-text');
+ if (textInput) {
+ // Create a visible color input that stays in the editor
+ const input = document.createElement('input');
+ input.type = 'color';
+ input.value = textInput.value === 'auto' ? '#000000' : textInput.value;
+
+ // Style the input to be visible and positioned within the picker
+ input.style.position = 'absolute';
+ input.style.left = '0';
+ input.style.top = '0';
+ input.style.width = '100%';
+ input.style.height = '100%';
+ input.style.opacity = '0';
+ input.style.cursor = 'pointer';
+ input.style.zIndex = '10';
+
+ // Add the input to the picker container
+ picker.style.position = 'relative';
+ picker.appendChild(input);
+
+ // Focus and click the input
+ input.focus();
+ input.click();
+
+ // Handle color change
+ input.addEventListener('change', () => {
+ const newColor = input.value;
+ textInput.value = newColor;
+ preview.style.background = newColor;
+
+ // Remove the input after color selection
+ if (picker.contains(input)) {
+ picker.removeChild(input);
+ }
+ });
+
+ // Handle escape key
+ const handleKeyDown = (e) => {
+ if (e.key === 'Escape') {
+ if (picker.contains(input)) {
+ picker.removeChild(input);
+ }
+ document.removeEventListener('keydown', handleKeyDown);
+ }
+ };
+ document.addEventListener('keydown', handleKeyDown);
+
+ // Handle clicks outside the picker
+ const handleClickOutside = (e) => {
+ if (!picker.contains(e.target)) {
+ if (picker.contains(input)) {
+ picker.removeChild(input);
+ }
+ document.removeEventListener('click', handleClickOutside);
+ }
+ };
+
+ // Add click outside handler after a delay
+ setTimeout(() => {
+ document.addEventListener('click', handleClickOutside);
+ }, 100);
+ }
+ }
+ });
+ });
+
+ // Setup text input changes
+ const colorTexts = this._root.querySelectorAll('.color-text');
+ colorTexts.forEach(textInput => {
+ textInput.addEventListener('input', () => {
+ const value = textInput.value;
+ if (/^#[0-9A-Fa-f]{6}$/.test(value)) {
+ const picker = textInput.closest('.color-picker-inline');
+ if (picker) {
+ const preview = picker.querySelector('.color-preview');
+ if (preview) {
+ preview.style.background = value;
+ }
+ }
+ }
+ });
+ });
+
+ // Setup save buttons
+ const saveButtons = this._root.querySelectorAll('.save-color-btn');
+ saveButtons.forEach(button => {
+ button.addEventListener('click', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const themeKey = button.dataset.theme;
+ const picker = button.closest('.color-picker-inline');
+ if (picker) {
+ const textInput = picker.querySelector('.color-text');
+ if (textInput) {
+ let newValue;
+ const inputValue = textInput.value;
+
+ // Handle special cases for auto-supported fields
+ if ((themeKey === 'status_icon' || themeKey === 'progress_ring' ||
+ themeKey === 'status_bg' || themeKey === 'telemetry_icon' ||
+ themeKey === 'telemetry_text') && inputValue === 'auto') {
+ newValue = 'auto';
+ } else if (/^#[0-9A-Fa-f]{6}$/.test(inputValue)) {
+ newValue = hexToRgba(inputValue, 0.9);
+ } else {
+ // Keep existing value if input is invalid
+ newValue = this._cfg.theme[themeKey];
+ }
+
+
+ this._cfg.theme = { ...this._cfg.theme, [themeKey]: newValue };
+
+ // Save to storage
+ const cardId = generateCardId(this._cfg);
+ saveThemeToStorage(cardId, this._cfg.theme);
+
+ this._updateThemeControls();
+
+ // Hide the color picker first
+ picker.classList.remove('active');
+
+ // Dispatch config change after a small delay to prevent tab switching
+ setTimeout(() => {
+ this._dispatchConfigChange();
+ }, 100);
+ }
+ }
+ });
+ });
+ }
+
+
+
+ _updateThemeControls() {
+ // Update all preview elements with new colors
+ const clickableElements = this._root.querySelectorAll('.clickable-element');
+ clickableElements.forEach(element => {
+ const themeKey = element.dataset.theme;
+ const preview = element.querySelector('.element-preview');
+ const currentValue = this._cfg.theme[themeKey] || '';
+
+ if (themeKey === 'status_icon' || themeKey === 'progress_ring') {
+ preview.style.background = currentValue === 'auto' ? 'var(--primary-color)' : currentValue;
+ } else {
+ preview.style.background = currentValue;
+ }
+
+ if (['pause_icon', 'resume_icon', 'stop_icon', 'light_icon_on', 'light_icon_off', 'custom_icon'].includes(themeKey)) {
+ preview.style.color = currentValue;
+ }
+ });
+
+ // Update color picker previews and text inputs
+ const colorPickers = this._root.querySelectorAll('.color-picker-inline');
+ colorPickers.forEach(picker => {
+ const themeKey = picker.id.replace('color-picker-', '');
+ const themeValue = this._cfg.theme[themeKey];
+ const preview = picker.querySelector('.color-preview');
+ const textInput = picker.querySelector('.color-text');
+
+ if (preview && textInput) {
+ if (themeKey === 'status_icon' || themeKey === 'progress_ring' ||
+ themeKey === 'status_bg' || themeKey === 'telemetry_icon' ||
+ themeKey === 'telemetry_text') {
+ if (themeValue === 'auto') {
+ // Show appropriate theme color for auto values
+ if (themeKey === 'status_bg') {
+ preview.style.background = 'var(--card-background-color)';
+ } else if (themeKey === 'telemetry_icon') {
+ preview.style.background = 'var(--secondary-text-color)';
+ } else if (themeKey === 'telemetry_text') {
+ preview.style.background = 'var(--primary-text-color)';
+ } else {
+ preview.style.background = '#000000';
+ }
+ textInput.value = 'auto';
+ } else {
+ preview.style.background = rgbaToHex(themeValue);
+ textInput.value = rgbaToHex(themeValue);
+ }
+ } else {
+ preview.style.background = rgbaToHex(themeValue);
+ textInput.value = rgbaToHex(themeValue);
+ }
+ }
+ });
+ }
+
+ _dispatchConfigChange() {
+ clearTimeout(this._debounceTimer);
+ this._debounceTimer = setTimeout(() => {
+ // Preserve current tab state
+ const activeTab = this._root.querySelector('.tab.active');
+ const activeTabId = activeTab ? activeTab.dataset.tab : 'entities';
+
+ this.dispatchEvent(new CustomEvent("config-changed", { detail: { config: this._cfg } }));
+
+ // Restore tab state after a brief delay
+ setTimeout(() => {
+ this._restoreTabState(activeTabId);
+ }, 50);
+ }, 120);
+ }
+
+ _restoreTabState(activeTabId) {
+ // Switch to the preserved tab
+ const tabs = this._root.querySelectorAll('.tab');
+ const tabContents = this._root.querySelectorAll('.tab-content');
+
+ tabs.forEach(tab => {
+ tab.classList.remove('active');
+ if (tab.dataset.tab === activeTabId) {
+ tab.classList.add('active');
+ }
+ });
+
+ tabContents.forEach(content => {
+ content.classList.remove('active');
+ if (content.id === `${activeTabId}-tab`) {
+ content.classList.add('active');
+ }
+ });
+ }
+}
+customElements.define(EDITOR_TAG, KPrinterCardEditor);
+
+try {
+ window.customCards = window.customCards || [];
+ window.customCards.push({
+ type: CARD_TAG,
+ name: "Creality Printer Card",
+ description: "Standalone card for Creality K-Series printers",
+ preview: true,
+ });
+} catch (_) { }
diff --git a/zigbee2mqtt/state.json b/zigbee2mqtt/state.json
index 1bdaf5d5..5e9f60fa 100644
--- a/zigbee2mqtt/state.json
+++ b/zigbee2mqtt/state.json
@@ -1,13 +1,13 @@
{
"0xffffb40e0607ace4": {
- "energy": 21.01,
+ "energy": 21.04,
"state": "ON",
"led_brightness": 100,
"countdown_to_turn_off": 0,
- "voltage": 120,
+ "voltage": 120.2,
"countdown_to_turn_on": 0,
"ac_frequency": 60,
- "power_factor": 0.11,
+ "power_factor": 0.59,
"update": {
"state": "idle",
"installed_version": 268513381,
@@ -15,14 +15,14 @@
"latest_source": "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/ThirdReality/SmartPlug_Zigbee_PROD_OTA_V101_1.01.01.ota",
"latest_release_notes": null
},
- "power": 0.5,
+ "power": 194.1,
"linkquality": 91,
- "current": 0.03,
+ "current": 2.77,
"power_on_behavior": "on"
},
"0xffffb40e0607af27": {
"state": "ON",
- "voltage": 118.8,
+ "voltage": 120.2,
"ac_frequency": 60,
"led_brightness": 100,
"countdown_to_turn_off": 0,
@@ -45,10 +45,10 @@
"state": "ON",
"led_brightness": 100,
"countdown_to_turn_off": 0,
- "voltage": 119.5,
+ "voltage": 120.1,
"countdown_to_turn_on": 0,
- "energy": 89.91,
- "power_factor": 0.31,
+ "energy": 89.96,
+ "power_factor": 0.2,
"ac_frequency": 60,
"update": {
"state": "idle",
@@ -58,13 +58,13 @@
"latest_release_notes": null
},
"linkquality": 120,
- "power": 0.2,
- "current": 0.01,
+ "power": 83.6,
+ "current": 0.79,
"power_on_behavior": "on"
},
"0xb40e060fffe031e3": {
"battery": 100,
- "contact": true,
+ "contact": false,
"tamper": false,
"battery_low": false,
"linkquality": 120,
@@ -74,13 +74,13 @@
"led_brightness": 100,
"countdown_to_turn_off": 0,
"countdown_to_turn_on": 0,
- "voltage": 118.9,
+ "voltage": 119.3,
"state": "ON",
"ac_frequency": 60,
- "energy": 183.6,
- "power": 0.8,
- "current": 0.05,
- "power_factor": 0.67,
+ "energy": 183.69,
+ "power": 107.4,
+ "current": 0.95,
+ "power_factor": 0.95,
"update": {
"state": "idle",
"installed_version": 268513381,
@@ -95,13 +95,13 @@
"led_brightness": 100,
"countdown_to_turn_off": 0,
"countdown_to_turn_on": 0,
- "voltage": 119.7,
- "energy": 91.05,
+ "voltage": 120,
+ "energy": 91.11,
"state": "ON",
- "power": 53.3,
- "current": 0.61,
+ "power": 52.2,
+ "current": 0.54,
"ac_frequency": 60,
- "power_factor": 0.9,
+ "power_factor": 0.76,
"update": {
"state": "idle",
"installed_version": 268513381,
@@ -114,7 +114,7 @@
},
"0xffffb40e060895b3": {
"state": "ON",
- "voltage": 120.1,
+ "voltage": 120.6,
"ac_frequency": 60,
"energy": 12.09,
"current": 0.01,
@@ -136,14 +136,14 @@
"0xffffb40e0608864e": {
"led_brightness": 100,
"countdown_to_turn_off": 0,
- "voltage": 120.6,
+ "voltage": 121,
"energy": 28.73,
"countdown_to_turn_on": 0,
"state": "ON",
- "current": 0.02,
+ "current": 0.13,
"ac_frequency": 60,
"power": 0.4,
- "power_factor": 0.15,
+ "power_factor": 0.14,
"update": {
"state": "idle",
"installed_version": 268513381,
@@ -171,7 +171,7 @@
},
"0xa4c1380d0679ffff": {
"battery": 100,
- "temperature": 26.3,
+ "temperature": 26.2,
"temperature_units": "celsius",
"temperature_calibration": 0,
"update": {
@@ -224,7 +224,7 @@
},
"0xb40e060fffe717c5": {
"battery": 100,
- "occupancy": false,
+ "occupancy": true,
"tamper": false,
"battery_low": false,
"linkquality": 116,