From 8a0ebd706962850611a22a6e009349476068ab65 Mon Sep 17 00:00:00 2001 From: Home Assistant Version Control Date: Thu, 24 Sep 2026 00:56:01 +0000 Subject: [PATCH] 10 files --- .../ha_creality_ws/manifest.json | 27 + .../ha_creality_ws/services.yaml | 15 + custom_components/ha_creality_ws/strings.json | 198 ++ .../ha_creality_ws/translations/en.json | 198 ++ .../ha_creality_ws/translations/es.json | 198 ++ .../ha_creality_ws/www/i18n/en.json | 112 + .../ha_creality_ws/www/i18n/es.json | 112 + .../ha_creality_ws/www/k_cfs_card.js | 1221 +++++++++++ .../ha_creality_ws/www/k_printer_card.js | 1794 +++++++++++++++++ zigbee2mqtt/state.json | 56 +- 10 files changed, 3903 insertions(+), 28 deletions(-) create mode 100644 custom_components/ha_creality_ws/manifest.json create mode 100644 custom_components/ha_creality_ws/services.yaml create mode 100644 custom_components/ha_creality_ws/strings.json create mode 100644 custom_components/ha_creality_ws/translations/en.json create mode 100644 custom_components/ha_creality_ws/translations/es.json create mode 100644 custom_components/ha_creality_ws/www/i18n/en.json create mode 100644 custom_components/ha_creality_ws/www/i18n/es.json create mode 100644 custom_components/ha_creality_ws/www/k_cfs_card.js create mode 100644 custom_components/ha_creality_ws/www/k_printer_card.js 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 = ` +
+
+
${this._cfg.name || 'Creality CFS'}
+
+ ${envInfo} +
+ `; + + // 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")}
+
+
${displayName}
+
+
+
+
+
${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,