187 files

This commit is contained in:
Home Assistant Version Control committed 2026-10-09 11:57:31 +00:00
1 parent 28fa34d78a
commit 1e1b12d52b
187 files changed
+40899 -32654

No files matched your search

@@ -6,14 +6,15 @@
"config_flow": true,
"dependencies": ["lovelace", "http", "frontend", "persistent_notification"],
"documentation": "https://github.com/3dg1luk43/ha_creality_ws",
"integration_type": "device",
"iot_class": "local_push",
"issue_tracker": "https://github.com/3dg1luk43/ha_creality_ws/issues",
"loggers": ["custom_components.ha_creality_ws"],
"loggers": ["websockets", "go2rtc_client"],
"requirements": [
"websockets>=10.4",
"go2rtc-client>=0.1.0"
],
"version": "0.9.8",
"version": "0.9.9",
"zeroconf": [
{ "type": "_http._tcp.local.", "name": "*creality*" },
{ "type": "_workstation._tcp.local.", "name": "*creality*" },
+91 -11
View File
@@ -1,5 +1,6 @@
{
"config": {
"flow_title": "{name}",
"step": {
"user": {
"title": "Connect to Creality Printer",
@@ -8,12 +9,14 @@
"host": "IP Address",
"name": "Friendly Name"
}
},
"zeroconf_confirm": {
"title": "Add discovered printer",
"description": "Add the Creality printer {name} at {host}?"
}
},
"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."
"cannot_connect": "Failed to connect to printer. Ensure IP is correct and printer is ON."
},
"abort": {
"already_configured": "Device is already configured",
@@ -34,6 +37,7 @@
"status_starting": "Starting",
"status_paused": "Paused",
"status_finishing": "Finishing",
"status_printing": "Printing",
"body_progress": "{progress}%",
"body_paused": "Paused at {progress}%",
"body_paused_unknown": "Paused",
@@ -48,12 +52,60 @@
"stopped": "{device} stopped at {progress}%",
"completed_detailed": "{device} finished. {filament} consumed.",
"error": "{device} error - {key} [{code}]",
"filament_runout": "{device} filament ran out. Print is {state}. Change the filament.",
"finishing_soon": "{device} will finish in {minutes} minutes."
"filament_runout": "{device} filament ran out. Print status: {state}. Change the filament.",
"finishing_soon": "{device} will finish in {minutes} min.",
"cfs_info_title": "CFS information request",
"cfs_info_failed": "Could not reach {printers}.",
"cfs_material_failed_title": "CFS material not changed",
"cfs_material_failed": "{printer} is not connected, so box {box} slot {slot} was not changed.",
"diagnostic_title": "Creality diagnostic data",
"diagnostic_collected": "Diagnostic data was collected (printers: {printers}). It is in this action's response and in the Home Assistant log."
},
"exceptions": {
"unsupported_ha_version": {
"message": "This version of the Creality WebSocket integration needs Home Assistant {minimum} or newer, but this system is running {running}. Update Home Assistant, or install integration version 0.9.7 instead."
},
"cfs_material_write_failed": {
"message": "The CFS material was not changed on {printers}: the printer is not connected."
},
"cfs_material_needs_device": {
"message": "Choose the printer to change. This action only writes to the printers you select, never to all of them."
},
"no_printer_matched": {
"message": "None of the selected devices is a Creality printer set up in this integration."
},
"cfs_material_printer_busy": {
"message": "{printer} is in the middle of a print, so its CFS material was not changed. Try again once the print is over."
},
"material_type_empty": {
"message": "The material type must not be empty."
},
"material_not_a_number": {
"message": "{field} must be a number, not {value}."
},
"material_temp_order": {
"message": "The maximum temperature ({high}) must not be below the minimum temperature ({low})."
},
"material_pressure_range": {
"message": "The pressure advance must be between 0 and 1, not {value}."
},
"material_colour_list": {
"message": "The colour must be a hex value such as #ff8800, not a list of RGB values."
},
"material_colour_multi": {
"message": "{value} holds several colours, and a slot can only store one."
},
"material_colour_invalid": {
"message": "The colour must be six hex digits such as #ff8800, not {value}."
},
"printer_not_connected": {
"message": "The printer is not connected, so the command was not sent. Check that it is switched on and reachable, then try again."
}
},
"issues": {
"missing_power_switch": {
"title": "Power switch for {printer} not found",
"description": "The power switch set for {printer}, `{entity_id}`, does not exist, so it was renamed or removed. The printer is connected to as if no power switch were set. To use one again, choose it under Settings > Devices & services > Creality WebSocket Integration > Configure > Power switch."
}
},
"selector": {
@@ -173,7 +225,9 @@
},
"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.",
"unknown_placeholder": "That is not a placeholder this integration can fill. Check the list in the description above, and mind the spelling."
"unknown_placeholder": "That is not a placeholder this integration can fill. Check the list in the description above, and mind the spelling.",
"cannot_connect": "Failed to connect to the printer at this address. Check the IP address and that the printer is switched on.",
"host_in_use": "Another printer in this integration is already set up at this address."
}
},
"entity": {
@@ -189,6 +243,7 @@
"position_z": { "name": "Position Z" },
"feedrate_pct": { "name": "Print Speed" },
"flowrate_pct": { "name": "Flow Rate" },
"real_time_speed": { "name": "Real-Time Speed" },
"model_info": { "name": "Model" },
"filament_status": {
"name": "Filament Status",
@@ -220,7 +275,10 @@
"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" },
"current_object": {
"name": "Current Object",
"state": { "not_printing": "Not printing" }
},
"object_count": { "name": "Object Count" },
"print_control": {
"name": "Print Control",
@@ -233,7 +291,28 @@
"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" },
"active_filament_slot": {
"name": "Active Filament Slot",
"state": {
"external": "External",
"box_1_slot_1": "Box 1 Slot 1",
"box_1_slot_2": "Box 1 Slot 2",
"box_1_slot_3": "Box 1 Slot 3",
"box_1_slot_4": "Box 1 Slot 4",
"box_2_slot_1": "Box 2 Slot 1",
"box_2_slot_2": "Box 2 Slot 2",
"box_2_slot_3": "Box 2 Slot 3",
"box_2_slot_4": "Box 2 Slot 4",
"box_3_slot_1": "Box 3 Slot 1",
"box_3_slot_2": "Box 3 Slot 2",
"box_3_slot_3": "Box 3 Slot 3",
"box_3_slot_4": "Box 3 Slot 4",
"box_4_slot_1": "Box 4 Slot 1",
"box_4_slot_2": "Box 4 Slot 2",
"box_4_slot_3": "Box 4 Slot 3",
"box_4_slot_4": "Box 4 Slot 4"
}
},
"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" },
@@ -251,7 +330,8 @@
"reconnect": { "name": "Reconnect" }
},
"number": {
"print_tuning_pct": { "name": "Print Tuning" },
"print_tuning_pct": { "name": "Speed Factor" },
"flow_rate_pct": { "name": "Flow Factor" },
"nozzle_target": { "name": "Nozzle Target" },
"bed_target": { "name": "Bed Target" },
"chamber_target": { "name": "Chamber Target" }
@@ -274,11 +354,11 @@
"services": {
"diagnostic_dump": {
"name": "Diagnostic Dump",
"description": "Dump all WebSocket telemetry data from connected Creality printers to JSON format for troubleshooting and analysis.",
"description": "Collect diagnostics for every Creality printer and return them as the response, for troubleshooting. Also written to the log.",
"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)"
"description": "Show printer addresses and network names, notify targets and access tokens instead of hiding them. Leave this off for anything you share publicly."
}
}
},
@@ -1,5 +1,6 @@
{
"config": {
"flow_title": "{name}",
"step": {
"user": {
"title": "Connect to Creality Printer",
@@ -8,12 +9,14 @@
"host": "IP Address",
"name": "Friendly Name"
}
},
"zeroconf_confirm": {
"title": "Add discovered printer",
"description": "Add the Creality printer {name} at {host}?"
}
},
"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."
"cannot_connect": "Failed to connect to printer. Ensure IP is correct and printer is ON."
},
"abort": {
"already_configured": "Device is already configured",
@@ -34,6 +37,7 @@
"status_starting": "Starting",
"status_paused": "Paused",
"status_finishing": "Finishing",
"status_printing": "Printing",
"body_progress": "{progress}%",
"body_paused": "Paused at {progress}%",
"body_paused_unknown": "Paused",
@@ -48,12 +52,60 @@
"stopped": "{device} stopped at {progress}%",
"completed_detailed": "{device} finished. {filament} consumed.",
"error": "{device} error - {key} [{code}]",
"filament_runout": "{device} filament ran out. Print is {state}. Change the filament.",
"finishing_soon": "{device} will finish in {minutes} minutes."
"filament_runout": "{device} filament ran out. Print status: {state}. Change the filament.",
"finishing_soon": "{device} will finish in {minutes} min.",
"cfs_info_title": "CFS information request",
"cfs_info_failed": "Could not reach {printers}.",
"cfs_material_failed_title": "CFS material not changed",
"cfs_material_failed": "{printer} is not connected, so box {box} slot {slot} was not changed.",
"diagnostic_title": "Creality diagnostic data",
"diagnostic_collected": "Diagnostic data was collected (printers: {printers}). It is in this action's response and in the Home Assistant log."
},
"exceptions": {
"unsupported_ha_version": {
"message": "This version of the Creality WebSocket integration needs Home Assistant {minimum} or newer, but this system is running {running}. Update Home Assistant, or install integration version 0.9.7 instead."
},
"cfs_material_write_failed": {
"message": "The CFS material was not changed on {printers}: the printer is not connected."
},
"cfs_material_needs_device": {
"message": "Choose the printer to change. This action only writes to the printers you select, never to all of them."
},
"no_printer_matched": {
"message": "None of the selected devices is a Creality printer set up in this integration."
},
"cfs_material_printer_busy": {
"message": "{printer} is in the middle of a print, so its CFS material was not changed. Try again once the print is over."
},
"material_type_empty": {
"message": "The material type must not be empty."
},
"material_not_a_number": {
"message": "{field} must be a number, not {value}."
},
"material_temp_order": {
"message": "The maximum temperature ({high}) must not be below the minimum temperature ({low})."
},
"material_pressure_range": {
"message": "The pressure advance must be between 0 and 1, not {value}."
},
"material_colour_list": {
"message": "The colour must be a hex value such as #ff8800, not a list of RGB values."
},
"material_colour_multi": {
"message": "{value} holds several colours, and a slot can only store one."
},
"material_colour_invalid": {
"message": "The colour must be six hex digits such as #ff8800, not {value}."
},
"printer_not_connected": {
"message": "The printer is not connected, so the command was not sent. Check that it is switched on and reachable, then try again."
}
},
"issues": {
"missing_power_switch": {
"title": "Power switch for {printer} not found",
"description": "The power switch set for {printer}, `{entity_id}`, does not exist, so it was renamed or removed. The printer is connected to as if no power switch were set. To use one again, choose it under Settings > Devices & services > Creality WebSocket Integration > Configure > Power switch."
}
},
"selector": {
@@ -173,7 +225,9 @@
},
"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.",
"unknown_placeholder": "That is not a placeholder this integration can fill. Check the list in the description above, and mind the spelling."
"unknown_placeholder": "That is not a placeholder this integration can fill. Check the list in the description above, and mind the spelling.",
"cannot_connect": "Failed to connect to the printer at this address. Check the IP address and that the printer is switched on.",
"host_in_use": "Another printer in this integration is already set up at this address."
}
},
"entity": {
@@ -189,6 +243,7 @@
"position_z": { "name": "Position Z" },
"feedrate_pct": { "name": "Print Speed" },
"flowrate_pct": { "name": "Flow Rate" },
"real_time_speed": { "name": "Real-Time Speed" },
"model_info": { "name": "Model" },
"filament_status": {
"name": "Filament Status",
@@ -220,7 +275,10 @@
"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" },
"current_object": {
"name": "Current Object",
"state": { "not_printing": "Not printing" }
},
"object_count": { "name": "Object Count" },
"print_control": {
"name": "Print Control",
@@ -233,7 +291,28 @@
"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" },
"active_filament_slot": {
"name": "Active Filament Slot",
"state": {
"external": "External",
"box_1_slot_1": "Box 1 Slot 1",
"box_1_slot_2": "Box 1 Slot 2",
"box_1_slot_3": "Box 1 Slot 3",
"box_1_slot_4": "Box 1 Slot 4",
"box_2_slot_1": "Box 2 Slot 1",
"box_2_slot_2": "Box 2 Slot 2",
"box_2_slot_3": "Box 2 Slot 3",
"box_2_slot_4": "Box 2 Slot 4",
"box_3_slot_1": "Box 3 Slot 1",
"box_3_slot_2": "Box 3 Slot 2",
"box_3_slot_3": "Box 3 Slot 3",
"box_3_slot_4": "Box 3 Slot 4",
"box_4_slot_1": "Box 4 Slot 1",
"box_4_slot_2": "Box 4 Slot 2",
"box_4_slot_3": "Box 4 Slot 3",
"box_4_slot_4": "Box 4 Slot 4"
}
},
"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" },
@@ -251,7 +330,8 @@
"reconnect": { "name": "Reconnect" }
},
"number": {
"print_tuning_pct": { "name": "Print Tuning" },
"print_tuning_pct": { "name": "Speed Factor" },
"flow_rate_pct": { "name": "Flow Factor" },
"nozzle_target": { "name": "Nozzle Target" },
"bed_target": { "name": "Bed Target" },
"chamber_target": { "name": "Chamber Target" }
@@ -274,11 +354,11 @@
"services": {
"diagnostic_dump": {
"name": "Diagnostic Dump",
"description": "Dump all WebSocket telemetry data from connected Creality printers to JSON format for troubleshooting and analysis.",
"description": "Collect diagnostics for every Creality printer and return them as the response, for troubleshooting. Also written to the log.",
"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)"
"description": "Show printer addresses and network names, notify targets and access tokens instead of hiding them. Leave this off for anything you share publicly."
}
}
},
@@ -1,5 +1,6 @@
{
"config": {
"flow_title": "{name}",
"step": {
"user": {
"title": "Conectar a la impresora Creality",
@@ -8,12 +9,14 @@
"host": "Dirección IP",
"name": "Nombre descriptivo"
}
},
"zeroconf_confirm": {
"title": "Añadir impresora descubierta",
"description": "¿Quieres añadir la impresora Creality {name} en {host}?"
}
},
"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."
"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."
},
"abort": {
"already_configured": "El dispositivo ya está configurado",
@@ -34,6 +37,7 @@
"status_starting": "Iniciando",
"status_paused": "En pausa",
"status_finishing": "Finalizando",
"status_printing": "Imprimiendo",
"body_progress": "{progress}%",
"body_paused": "En pausa al {progress}%",
"body_paused_unknown": "En pausa",
@@ -49,11 +53,59 @@
"completed_detailed": "{device} ha terminado. Filamento consumido: {filament}.",
"error": "Error de {device} - {key} [{code}]",
"filament_runout": "{device} se ha quedado sin filamento. Estado de la impresión: {state}. Cambia el filamento.",
"finishing_soon": "{device} terminará en {minutes} minutos."
"finishing_soon": "{device} terminará en {minutes} min.",
"cfs_info_title": "Solicitud de información del CFS",
"cfs_info_failed": "No se ha podido contactar con {printers}.",
"cfs_material_failed_title": "Material del CFS no cambiado",
"cfs_material_failed": "{printer} no está conectada, así que no se ha cambiado la ranura {slot} de la caja {box}.",
"diagnostic_title": "Datos de diagnóstico de Creality",
"diagnostic_collected": "Se han recopilado los datos de diagnóstico (impresoras: {printers}). Están en la respuesta de esta acción y en el registro de Home Assistant."
},
"exceptions": {
"unsupported_ha_version": {
"message": "Esta versión de la integración Creality WebSocket necesita Home Assistant {minimum} o posterior, pero este sistema ejecuta la versión {running}. Actualiza Home Assistant o instala en su lugar la versión 0.9.7 de la integración."
},
"cfs_material_write_failed": {
"message": "No se ha cambiado el material del CFS en {printers}: la impresora no está conectada."
},
"cfs_material_needs_device": {
"message": "Elige la impresora que quieres cambiar. Esta acción solo escribe en las impresoras que selecciones, nunca en todas."
},
"no_printer_matched": {
"message": "Ninguno de los dispositivos seleccionados es una impresora Creality configurada en esta integración."
},
"cfs_material_printer_busy": {
"message": "{printer} está en mitad de una impresión, así que no se ha cambiado su material del CFS. Vuelve a intentarlo cuando termine la impresión."
},
"material_type_empty": {
"message": "El tipo de material no puede estar vacío."
},
"material_not_a_number": {
"message": "{field} debe ser un número, no {value}."
},
"material_temp_order": {
"message": "La temperatura máxima ({high}) no puede ser inferior a la temperatura mínima ({low})."
},
"material_pressure_range": {
"message": "El avance de presión debe estar entre 0 y 1, no {value}."
},
"material_colour_list": {
"message": "El color debe ser un valor hexadecimal como #ff8800, no una lista de valores RGB."
},
"material_colour_multi": {
"message": "{value} contiene varios colores, y una ranura solo puede guardar uno."
},
"material_colour_invalid": {
"message": "El color debe tener seis dígitos hexadecimales, como #ff8800, no {value}."
},
"printer_not_connected": {
"message": "La impresora no está conectada, así que no se ha enviado el comando. Comprueba que esté encendida y que se pueda acceder a ella, y vuelve a intentarlo."
}
},
"issues": {
"missing_power_switch": {
"title": "No se encuentra el interruptor de encendido de {printer}",
"description": "El interruptor de encendido configurado para {printer}, `{entity_id}`, no existe, así que se ha cambiado de nombre o se ha eliminado. La integración se conecta a la impresora como si no se hubiera configurado ninguno. Para volver a usar uno, elígelo en Ajustes > Dispositivos y servicios > Creality WebSocket Integration > Configurar > Interruptor de encendido."
}
},
"selector": {
@@ -173,7 +225,9 @@
},
"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.",
"unknown_placeholder": "Ese no es un marcador de posición que esta integración pueda rellenar. Consulta la lista que aparece en la descripción de arriba y comprueba que esté bien escrito."
"unknown_placeholder": "Ese no es un marcador de posición que esta integración pueda rellenar. Consulta la lista que aparece en la descripción de arriba y comprueba que esté bien escrito.",
"cannot_connect": "No se ha podido conectar con la impresora en esta dirección. Comprueba la dirección IP y que la impresora esté encendida.",
"host_in_use": "Otra impresora de esta integración ya está configurada en esta dirección."
}
},
"entity": {
@@ -189,6 +243,7 @@
"position_z": { "name": "Posición Z" },
"feedrate_pct": { "name": "Velocidad de impresión" },
"flowrate_pct": { "name": "Tasa de flujo" },
"real_time_speed": { "name": "Velocidad en tiempo real" },
"model_info": { "name": "Modelo" },
"filament_status": {
"name": "Estado del filamento",
@@ -220,7 +275,10 @@
"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" },
"current_object": {
"name": "Objeto actual",
"state": { "not_printing": "Sin impresión" }
},
"object_count": { "name": "Cantidad de objetos" },
"print_control": {
"name": "Control de impresión",
@@ -233,7 +291,28 @@
"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" },
"active_filament_slot": {
"name": "Ranura de filamento activa",
"state": {
"external": "Externa",
"box_1_slot_1": "Caja 1, ranura 1",
"box_1_slot_2": "Caja 1, ranura 2",
"box_1_slot_3": "Caja 1, ranura 3",
"box_1_slot_4": "Caja 1, ranura 4",
"box_2_slot_1": "Caja 2, ranura 1",
"box_2_slot_2": "Caja 2, ranura 2",
"box_2_slot_3": "Caja 2, ranura 3",
"box_2_slot_4": "Caja 2, ranura 4",
"box_3_slot_1": "Caja 3, ranura 1",
"box_3_slot_2": "Caja 3, ranura 2",
"box_3_slot_3": "Caja 3, ranura 3",
"box_3_slot_4": "Caja 3, ranura 4",
"box_4_slot_1": "Caja 4, ranura 1",
"box_4_slot_2": "Caja 4, ranura 2",
"box_4_slot_3": "Caja 4, ranura 3",
"box_4_slot_4": "Caja 4, ranura 4"
}
},
"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})" },
@@ -251,7 +330,8 @@
"reconnect": { "name": "Reconectar" }
},
"number": {
"print_tuning_pct": { "name": "Ajuste de impresión" },
"print_tuning_pct": { "name": "Factor de velocidad" },
"flow_rate_pct": { "name": "Factor de flujo" },
"nozzle_target": { "name": "Objetivo de la boquilla" },
"bed_target": { "name": "Objetivo de la cama" },
"chamber_target": { "name": "Objetivo de la cámara" }
@@ -274,11 +354,11 @@
"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.",
"description": "Recopila los diagnósticos de todas las impresoras Creality y los devuelve como respuesta, para resolver problemas. También se escriben en el registro.",
"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)"
"description": "Muestra las direcciones y los nombres de red de las impresoras, los destinos de notificación y los tokens de acceso en lugar de ocultarlos. Déjalo desactivado para todo lo que compartas públicamente."
}
}
},
@@ -1,5 +1,8 @@
{
"printer_card": {
"default_name": "3D Printer",
"picker_name": "Creality Printer Card",
"picker_description": "Standalone card for Creality K-Series printers",
"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?",
@@ -77,6 +80,7 @@
"label_custom_btn": "Custom Action Entity",
"label_custom_btn_icon": "Custom Button Icon",
"label_custom_btn_hidden": "Hide Custom Button",
"label_hidden_buttons": "Hidden Buttons",
"label_button_order": "Button Order (list)",
"label_hide_box_temp": "Hide Chamber Temperature",
"label_pause_btn_icon": "Pause Icon Override",
@@ -104,6 +108,7 @@
"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_hidden_buttons": "Never shown on the card, whatever the printer is doing",
"helper_button_order": "List of buttons to show in order (pause, resume, stop, light, power, custom)",
"helper_hide_box_temp": "Hide the chamber temperature pill even when a sensor is configured",
"editor_error_title": "Editor Error",
@@ -111,9 +116,14 @@
"editor_error_prefix": "Error:"
},
"cfs_card": {
"picker_name": "Creality CFS Card",
"label_device": "Printer",
"btn_fill_from_device": "Fill all fields from the printer",
"status_device_filled": "Filled {filled} of {total} fields.",
"status_device_empty": "This printer has no CFS sensors yet.",
"picker_description": "A card to control the Creality Filament System (CFS)",
"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",
@@ -124,7 +134,6 @@
"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",
@@ -1,5 +1,8 @@
{
"printer_card": {
"default_name": "Impresora 3D",
"picker_name": "Tarjeta de impresora Creality",
"picker_description": "Tarjeta independiente para impresoras Creality de la serie K",
"status_unknown": "Desconocido",
"confirm_stop": "¿Seguro que deseas detener la impresión?",
"confirm_power_off": "¿Seguro que deseas apagar la impresora?",
@@ -77,6 +80,7 @@
"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_hidden_buttons": "Botones ocultos",
"label_button_order": "Orden de los botones (lista)",
"label_hide_box_temp": "Ocultar la temperatura de la cámara",
"label_pause_btn_icon": "Icono de pausa (sustitución)",
@@ -104,6 +108,7 @@
"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_hidden_buttons": "Nunca se muestran en la tarjeta, sea cual sea el estado de la impresora",
"helper_button_order": "Lista de botones en orden (pause, resume, stop, light, power, custom)",
"helper_hide_box_temp": "Ocultar el indicador de temperatura de la cámara aunque haya un sensor configurado",
"editor_error_title": "Error del editor",
@@ -111,9 +116,14 @@
"editor_error_prefix": "Error:"
},
"cfs_card": {
"picker_name": "Tarjeta CFS de Creality",
"picker_description": "Tarjeta para controlar el Creality Filament System (CFS)",
"label_device": "Impresora",
"btn_fill_from_device": "Rellenar todos los campos desde la impresora",
"status_device_filled": "Se han rellenado {filled} de {total} campos.",
"status_device_empty": "Esta impresora aún no tiene sensores del CFS.",
"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",
@@ -124,7 +134,6 @@
"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",
+436 -127
View File
@@ -6,20 +6,28 @@ const mdi = (name) => `mdi:${name}`;
const ASSET_URL_BASE = "/ha_creality_ws/";
const I18N_URL_BASE = `${ASSET_URL_BASE}i18n/`;
const _i18nData = {};
const _i18nPromises = {};
// One cache for both cards, on the page: each module used to fetch en.json for
// itself, and a language with no file (a 404) was asked for again by every new
// card. A 404 is now remembered; only a failed request is retried. `no-cache`
// revalidates, so an updated translation is not served stale (R45).
const _i18nShared = (globalThis.__haCrealityWsI18n = globalThis.__haCrealityWsI18n || { data: {}, promises: {} });
const _i18nData = _i18nShared.data;
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))
if (lang in _i18nData) return Promise.resolve(_i18nData[lang]);
const promises = _i18nShared.promises;
if (promises[lang]) return promises[lang];
promises[lang] = fetch(`${I18N_URL_BASE}${lang}.json`, { cache: "no-cache" })
.then((res) => {
if (res.ok) return res.json();
if (res.status === 404) return null;
throw new Error(`HTTP ${res.status}`);
})
.then((data) => {
if (data) _i18nData[lang] = data;
else _i18nPromises[lang] = null;
_i18nData[lang] = data;
return data;
})
.catch(() => { _i18nPromises[lang] = null; return null; });
return _i18nPromises[lang];
.catch(() => { promises[lang] = null; return null; });
return promises[lang];
}
function _resolveLang(hass) {
return hass?.locale?.language || hass?.language || "en";
@@ -33,9 +41,12 @@ function _translate(hass, section, fallbackDict, key, vars) {
: (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);
});
// One pass with a function: a replacement *string* expands `$&` and
// friends, so a preset named "Teal $& Co" toasted as "Teal {name} Co", and
// a value containing "{other}" was substituted again by a later key.
text = text.replace(/\{(\w+)\}/g, (match, name) => (
Object.prototype.hasOwnProperty.call(vars, name) ? String(vars[name]) : match
));
}
return text;
}
@@ -153,15 +164,6 @@ class ColourPresetsManager {
return true;
}
rename(from, to) {
const target = String(to || "").trim();
if (!target || !(from in this.presets) || target === from) return false;
this.presets[target] = this.presets[from];
delete this.presets[from];
this._persist();
return true;
}
remove(name) {
if (!(name in this.presets)) return false;
delete this.presets[name];
@@ -219,9 +221,14 @@ const BUSY_PRINT_STATES = new Set([
const CFS_TRANSLATIONS = {
en: {
picker_name: "Creality CFS Card",
label_device: "Printer",
btn_fill_from_device: "Fill all fields from the printer",
status_device_filled: "Filled {filled} of {total} fields.",
status_device_empty: "This printer has no CFS sensors yet.",
picker_description: "A card to control the Creality Filament System (CFS)",
no_data: "No CFS data available",
ext_label: "EXT",
cfs_label: "CFS",
cfs_number_label: "CFS {number}",
// Editor
label_card_title: "Card Title",
@@ -290,6 +297,19 @@ class KCFSCard extends HTMLElement {
* used to prefill it like a real value -- so changing only the material type
* and saving wrote `#cccccc` to the spool as though the printer had said so.
*/
/**
* The external spool's label: its name, plus the material type only when
* the name does not already say it. The filament sensor's state is already
* "Generic PLA", so appending the type read "Generic PLA PLA" (#115).
*/
static _nameWithType(name, type) {
const n = String(name ?? "").trim();
const t = String(type ?? "").trim();
if (!t || t === "-") return n;
const words = n.toLowerCase().split(/\s+/);
return words.includes(t.toLowerCase()) ? n : `${n} ${t}`;
}
static _parseColor(value) {
if (isSentinel(value)) return null;
const raw = String(value).trim();
@@ -411,7 +431,25 @@ class KCFSCard extends HTMLElement {
return '#f44336'; // Red (60-100%) - Critical
}
static getStubConfig() {
/**
* What a new card starts with: the first printer that has CFS sensors, else
* nothing. Only what differs from the defaults goes into the dashboard
* (R44); every default used to be written there.
*/
static getStubConfig(hass) {
const registry = hass?.entities || {};
const devices = [...new Set(Object.values(registry)
.filter((e) => e?.platform === "ha_creality_ws" && e.device_id)
.map((e) => e.device_id))];
for (const deviceId of devices) {
const found = cfsEntitiesForDevice(hass, deviceId);
if (Object.keys(found).length) return { device: deviceId, ...found };
}
return {};
}
/** Every option with its default value. */
static defaultConfig() {
const cfg = {
name: "CFS",
view_mode: "full",
@@ -439,7 +477,7 @@ class KCFSCard extends HTMLElement {
}
setConfig(config) {
this._cfg = { ...KCFSCard.getStubConfig(), ...KCFSCard._migrateConfig(config) };
this._cfg = { ...KCFSCard.defaultConfig(), ...KCFSCard._migrateConfig(config) };
if (!this._root) {
this._root = this.attachShadow({ mode: "open" });
}
@@ -460,9 +498,6 @@ class KCFSCard extends HTMLElement {
}
// i18n helpers -------------------------------------------------------
_resolveLanguage() {
return _resolveLang(this._hass);
}
_t(key, vars) {
return _translate(this._hass, "cfs_card", CFS_TRANSLATIONS, key, vars);
}
@@ -741,6 +776,10 @@ class KCFSCard extends HTMLElement {
50% { opacity: 0.7; transform: scale(1.1); }
}
@media (prefers-reduced-motion: reduce) {
.status-badge { animation: none; }
}
/* === COMPACT MODE === */
.compact-mode {
padding: 14px;
@@ -858,14 +897,16 @@ class KCFSCard extends HTMLElement {
gap: 2px;
}
.env-mini .temp {
color: #ffb74d;
/* The hue says warm, dry or damp; the theme's text colour carries the
contrast. The bare #ffb74d was 1.73:1 on a light theme (R42). */
.env-mini .temp, .env-temp {
color: color-mix(in srgb, #ffb74d 45%, var(--primary-text-color));
font-weight: 600;
}
.env-mini .hum {
.env-mini .hum, .env-hum {
color: color-mix(in srgb, var(--hum-color, #64b5f6) 45%, var(--primary-text-color));
font-weight: 600;
/* Cor aplicada dinamicamente via inline style */
}
/* === EXTERNAL SECTION === */
@@ -1079,7 +1120,17 @@ class KCFSCard extends HTMLElement {
place-items: center;
padding: 16px;
background: rgba(0, 0, 0, 0.55);
/* Undo the <dialog> defaults: the element is the full-screen backdrop. */
border: none;
margin: 0;
width: 100%;
height: 100%;
max-width: none;
max-height: none;
box-sizing: border-box;
color: inherit;
}
.edit-overlay::backdrop { background: transparent; }
.edit-dialog {
width: min(420px, 100%);
max-height: 85vh;
@@ -1293,8 +1344,14 @@ class KCFSCard extends HTMLElement {
const name = filamentObj?.state;
const type = filamentObj?.attributes?.type;
const selected = filamentObj?.attributes?.selected;
const rawColor = colorObj?.state || filamentObj?.attributes?.color_hex;
const parsedColor = KCFSCard._parseColor(rawColor);
// Parsed in turn, not `||`: "unknown"/"unavailable" are truthy, so a
// colour sensor reading either hid the slot's color_hex attribute and
// the spool went grey (R28).
const rawColor = isSentinel(colorObj?.state)
? filamentObj?.attributes?.color_hex
: colorObj.state;
const parsedColor = KCFSCard._parseColor(rawColor)
?? KCFSCard._parseColor(filamentObj?.attributes?.color_hex);
const color = parsedColor ?? "#cccccc";
const percent = KCFSCard._parsePercent(percentObj);
const percentText = fmtState(percentObj);
@@ -1361,8 +1418,11 @@ class KCFSCard extends HTMLElement {
const name = filamentObj?.state;
const type = filamentObj?.attributes?.type;
const selected = filamentObj?.attributes?.selected;
const rawColor = colorObj?.state || filamentObj?.attributes?.color_hex;
const parsedColor = KCFSCard._parseColor(rawColor);
const rawColor = isSentinel(colorObj?.state)
? filamentObj?.attributes?.color_hex
: colorObj.state;
const parsedColor = KCFSCard._parseColor(rawColor)
?? KCFSCard._parseColor(filamentObj?.attributes?.color_hex);
const color = parsedColor ?? "#cccccc";
const percent = KCFSCard._parsePercent(percentObj);
const percentText = fmtState(percentObj);
@@ -1523,7 +1583,7 @@ class KCFSCard extends HTMLElement {
const env = [];
if (box?.temp && box.temp !== "-") env.push(`<span class="env-temp">${esc(box.temp)}</span>`);
if (box?.humidity && box.humidity !== "-") {
env.push(`<span class="env-hum" style="color: ${KCFSCard._sanitizeColor(box.humidityColor)}">${esc(box.humidity)}</span>`);
env.push(`<span class="env-hum" style="--hum-color: ${KCFSCard._sanitizeColor(box.humidityColor)}">${esc(box.humidity)}</span>`);
}
return `
@@ -1571,7 +1631,7 @@ class KCFSCard extends HTMLElement {
if (tempStr || humStr) {
const tempHtml = tempStr ? `<span class="env-temp">${esc(tempStr)}</span>` : '';
const humHtml = humStr ? `<span class="env-hum" style="color: ${KCFSCard._sanitizeColor(selectedBox.humidityColor)}">${esc(humStr)}</span>` : '';
const humHtml = humStr ? `<span class="env-hum" style="--hum-color: ${KCFSCard._sanitizeColor(selectedBox.humidityColor)}">${esc(humStr)}</span>` : '';
const separator = tempStr && humStr ? ' <span style="color: var(--divider-color)">•</span> ' : '';
envInfo = `<div class="env-info">${tempHtml}${separator}${humHtml}</div>`;
}
@@ -1604,7 +1664,7 @@ class KCFSCard extends HTMLElement {
const hasFilament = safeType !== "-" && safeName !== "-";
const pct = hasFilament && external.percent !== null ? external.percent : 0;
const percentTextDisplay = hasFilament ? (external.percentText || '-') : '-';
const displayName = hasFilament ? `${safeName} ${safeType}` : '-';
const displayName = hasFilament ? KCFSCard._nameWithType(safeName, safeType) : '-';
externalSection = `
<div class="external-section">
<div class="external-normal" data-eid="${esc(external.entity_id)}">
@@ -1658,7 +1718,7 @@ class KCFSCard extends HTMLElement {
const safeName = !isSentinel(external.name) ? String(external.name).trim() : "-";
const hasFilament = safeType !== "-" && safeName !== "-";
const percentTextDisplay = hasFilament ? (external.percentText || '-') : '-';
const displayName = hasFilament ? `${safeName} ${safeType}` : '-';
const displayName = hasFilament ? KCFSCard._nameWithType(safeName, safeType) : '-';
return `
<div class="external-section">
<div class="external-compact" data-eid="${esc(external.entity_id)}">
@@ -1682,7 +1742,7 @@ class KCFSCard extends HTMLElement {
envHtml = `
<div class="env-mini">
${tempStr ? `<div class="temp">${esc(tempStr)}</div>` : ''}
${humStr ? `<div class="hum" style="color: ${KCFSCard._sanitizeColor(box.humidityColor)}">${esc(humStr)}</div>` : ''}
${humStr ? `<div class="hum" style="--hum-color: ${KCFSCard._sanitizeColor(box.humidityColor)}">${esc(humStr)}</div>` : ''}
</div>
`;
}
@@ -1823,8 +1883,7 @@ class KCFSCard extends HTMLElement {
* rather than silently resolved to one of them.
*
* Reads hass.entities (EntityRegistryDisplayEntry carries device_id, platform
* and translation_key), so no WebSocket round trip and no admin permission is
* needed -- unlike config/entity_registry/list.
* and translation_key), so no WebSocket round trip is needed.
* @returns {Promise<string|null>}
*/
async _resolveDeviceId() {
@@ -1844,9 +1903,8 @@ class KCFSCard extends HTMLElement {
}
// Anything hass.entities could not answer for is asked individually. It is
// not a version fallback: hass.entities can be *partially* populated, and it
// is absent entirely for a non-admin user, whose browser is not allowed
// config/entity_registry/get either.
// not a version fallback: hass.entities can be *partially* populated, for
// one while the frontend is still loading it.
//
// Every unresolved entity is asked, not just enough to find one device.
// Accepting the first answer resolved a card spanning two printers to
@@ -1866,9 +1924,9 @@ class KCFSCard extends HTMLElement {
});
return { deviceId: entry?.device_id || null, failed: false };
} catch (_) {
// config/entity_registry/get is admin-only, so for a non-admin
// dashboard user every one of these fails. Treating that as "this
// entity has no device" is what let a two-printer card resolve to
// A failed lookup (an entity missing from the registry, a dropped
// connection) is not "this entity has no device". Treating it so
// is what let a two-printer card resolve to
// whichever printer *was* in hass.entities, and _saveMaterial then
// sent the other printer's box and slot ids to it.
return { deviceId: null, failed: true };
@@ -1985,8 +2043,13 @@ class KCFSCard extends HTMLElement {
const toast = document.createElement("div");
toast.className = "cfs-toast";
// Announced by screen readers without taking focus (R42).
toast.setAttribute("role", "status");
toast.setAttribute("aria-live", "polite");
toast.textContent = message;
this._root.appendChild(toast);
// Inside the open edit dialog when there is one: it is in the top layer,
// and a toast on the card would be painted under it.
(this._dialogEl || this._root).appendChild(toast);
this._toastEl = toast;
this._toastTimer = setTimeout(() => {
if (toast.remove) toast.remove();
@@ -2038,7 +2101,11 @@ class KCFSCard extends HTMLElement {
return;
}
const overlay = document.createElement("div");
// A native <dialog> opened with showModal() renders in the top layer. The
// <div> overlay before it stayed inside this card's stacking context
// (`:host { z-index: 1 }`), so a second CFS card further down the page was
// painted over the bottom of the dialog (R46, seen in Chromium).
const overlay = document.createElement("dialog");
overlay.className = "edit-overlay";
const dialog = document.createElement("div");
dialog.className = "edit-dialog";
@@ -2049,11 +2116,16 @@ class KCFSCard extends HTMLElement {
dialog.setAttribute("aria-modal", "true");
dialog.setAttribute("aria-label", this._t("dialog_edit_title"));
dialog.tabIndex = -1;
overlay.appendChild(dialog);
// Back to whatever opened the dialog afterwards, so a keyboard user is not
// dropped at the top of the page (R42).
const opener = this._root.activeElement || null;
const close = () => {
overlay.removeEventListener("keydown", onKeyDown);
if (overlay.close && overlay.open) overlay.close();
if (overlay.remove) overlay.remove();
if (this._dialogEl === overlay) this._dialogEl = null;
if (opener && opener.focus) opener.focus();
};
const onKeyDown = (ev) => {
if (ev.key === "Escape" || ev.key === "Esc") {
@@ -2061,11 +2133,38 @@ class KCFSCard extends HTMLElement {
close();
}
};
// Tab stays inside the dialog: it is modal, and focus wandering to the
// card behind it left a keyboard user editing an invisible page (R42).
// Sentinels rather than counting fields, because ha-form keeps its inputs
// in its own shadow root where the dialog cannot see which one has focus.
const sentinel = (onFocus) => {
const el = document.createElement("span");
el.tabIndex = 0;
el.className = "focus-sentinel";
el.addEventListener("focus", onFocus);
return el;
};
const before = sentinel(() => {
const buttons = Array.from(dialog.querySelectorAll("button")).filter((btn) => !btn.disabled);
const last = buttons[buttons.length - 1];
if (last && last.focus) last.focus();
});
const after = sentinel(() => dialog.focus && dialog.focus());
overlay.appendChild(before);
overlay.appendChild(dialog);
overlay.appendChild(after);
overlay.addEventListener("keydown", onKeyDown);
// Escape on a modal <dialog> arrives as `cancel`; close it our way.
overlay.addEventListener("cancel", (ev) => {
ev.preventDefault?.();
close();
});
overlay.addEventListener("click", (ev) => { if (ev.target === overlay) close(); });
dialog.appendChild(this._renderEditForm(slot, close));
this._root.appendChild(overlay);
this._dialogEl = overlay;
if (overlay.showModal) overlay.showModal();
// Focus the dialog itself rather than the first field: ha-form upgrades
// asynchronously, so its inputs may not exist yet.
if (dialog.focus) dialog.focus();
@@ -2274,18 +2373,55 @@ class KCFSCard extends HTMLElement {
swatch.title = name;
swatch.setAttribute("aria-label", name);
swatch.style.background = colour;
swatch.addEventListener("click", () => apply(colour));
let longPressed = false;
swatch.addEventListener("click", () => {
// The click that ends a long press is not a pick.
if (longPressed) {
longPressed = false;
return;
}
apply(colour);
});
if (this._presets.isCustom(name)) {
swatch.classList?.add?.("custom");
// Long-press-free management: a modifier click removes a custom preset,
// which keeps the row compact without a second list.
swatch.addEventListener("contextmenu", (ev) => {
ev.preventDefault?.();
const removePreset = () => {
if (this._presets.remove(name)) {
this._showToast(this._t("toast_preset_deleted", { name }));
rebuild();
}
};
// A right-click removes a custom preset, which keeps the row compact
// without a second list.
swatch.addEventListener("contextmenu", (ev) => {
ev.preventDefault?.();
removePreset();
});
// iOS Safari fires no contextmenu on a long press, so a preset could
// not be removed there at all: a touch held for 600 ms does it, and
// Delete does it from the keyboard (R46).
let pressTimer = null;
const cancelPress = () => {
clearTimeout(pressTimer);
pressTimer = null;
};
swatch.addEventListener("pointerdown", (ev) => {
if (ev.pointerType === "mouse") return;
cancelPress();
pressTimer = setTimeout(() => {
pressTimer = null;
longPressed = true;
removePreset();
}, 600);
});
for (const type of ["pointerup", "pointercancel", "pointerleave"]) {
swatch.addEventListener(type, cancelPress);
}
swatch.addEventListener("keydown", (ev) => {
if (ev.key === "Delete" || ev.key === "Backspace") {
ev.preventDefault?.();
removePreset();
}
});
}
swatches.appendChild(swatch);
@@ -2469,30 +2605,20 @@ class KCFSCard extends HTMLElement {
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?.view_mode === "compact" ? Math.max(1, totalRows + extraPadding) : 5;
/**
* Sections-view sizing: let the grid size the cell to the card.
*
* `getLayoutOptions` (deprecated) reserved a fixed number of rows, five for
* the full view, and the card forces `height: auto` so its content is not
* clipped (#71) -- together that drew the card over whatever sat below it,
* by 176 px for a one-box CFS at phone width (R23). `rows: "auto"` makes the
* reserved height the card's own.
*/
getGridOptions() {
return {
grid_rows: minRows,
grid_min_rows: minRows,
columns: 12,
rows: "auto",
min_columns: 6,
};
}
}
@@ -2521,11 +2647,76 @@ function defineOnce(tag, cls) {
defineOnce(CARD_TAG, KCFSCard);
/**
* This integration's CFS sensors for one device, as a card config patch.
*
* CFS units take card positions 0-3 in the printer's box order; the external
* spool holder is box 0 on the printer and goes to the external fields. Box and
* slot come from the sensors' `box_id`/`slot_id` attributes, falling back to
* the default entity id ("..._cfs_box_1_slot_2_filament", slot 1-based) for a
* sensor with no reading yet. Without this, a CFS card meant filling up to 51
* entity pickers by hand (R43).
* @param {?Object} hass
* @param {string} deviceId
* @return {!Object<string, string>} Config key -> entity id.
*/
function cfsEntitiesForDevice(hass, deviceId) {
const registry = hass?.entities || {};
const states = hass?.states || {};
const patch = {};
if (!deviceId) return patch;
const slots = [];
const boxes = [];
for (const [entityId, entry] of Object.entries(registry)) {
if (!entry || entry.device_id !== deviceId) continue;
if (entry.platform && entry.platform !== "ha_creality_ws") continue;
const key = entry.translation_key || "";
const attrs = states[entityId]?.attributes || {};
let m = /^cfs_ext_(filament|color|percent)$/.exec(key);
if (m) {
patch[`external_${m[1]}`] = entityId;
continue;
}
m = /^cfs_slot_(filament|color|percent)$/.exec(key);
if (m) {
let box = attrs.box_id;
let slot = attrs.slot_id;
if (box === undefined || box === null || slot === undefined || slot === null) {
const id = /_cfs_box_(\d+)_slot_(\d+)_/.exec(entityId);
if (!id) continue;
box = Number(id[1]);
slot = Number(id[2]) - 1;
}
// Box 0 is the external spool holder; an install from before R20 still
// has "Box 0 Slot 1" sensors for it next to the External ones.
if (Number(box) >= 1) slots.push({ box: Number(box), slot: Number(slot), kind: m[1], entityId });
continue;
}
m = /^cfs_box_(temp|humidity)$/.exec(key);
if (m) {
let box = attrs.box_id;
if (box === undefined || box === null) {
const id = /_cfs_box_(\d+)_(temperature|humidity)/.exec(entityId);
if (!id) continue;
box = Number(id[1]);
}
if (Number(box) >= 1) boxes.push({ box: Number(box), kind: m[1], entityId });
}
}
const order = [...new Set([...slots, ...boxes].map((e) => e.box))].sort((a, b) => a - b).slice(0, 4);
for (const e of slots) {
const pos = order.indexOf(e.box);
if (pos >= 0 && e.slot >= 0 && e.slot < 4) patch[`box${pos}_slot${e.slot}_${e.kind}`] = e.entityId;
}
for (const e of boxes) {
const pos = order.indexOf(e.box);
if (pos >= 0) patch[`box${pos}_${e.kind}`] = e.entityId;
}
return patch;
}
class KCFSCardEditor extends HTMLElement {
// i18n helpers -------------------------------------------------------
_resolveLanguage() {
return _resolveLang(this._hass);
}
_t(key, vars) {
return _translate(this._hass, "cfs_card", CFS_TRANSLATIONS, key, vars);
}
@@ -2533,42 +2724,84 @@ class KCFSCardEditor extends HTMLElement {
set hass(hass) {
this._hass = hass;
if (this._form) this._form.hass = hass;
_requestI18n(this, hass, () => { if (this._root) this._render(); });
_requestI18n(this, hass, () => this._refresh(true));
this._refresh();
}
setConfig(config) {
this._cfg = { ...KCFSCard.getStubConfig(), ...KCFSCard._migrateConfig(config) };
this._render();
this._cfg = { ...KCFSCard.defaultConfig(), ...KCFSCard._migrateConfig(config) };
this._refresh();
}
connectedCallback() {
this._render();
this._refresh();
}
_render() {
/**
* Build once, then only update.
*
* Lovelace answers every config-changed with a fresh setConfig. Rebuilding
* the editor's DOM on each one, as this did, lost the focused field after
* every keystroke and jumped back to the first tab (R22). Data is only
* reassigned when it really differs from what the forms already show.
*/
_refresh(relabel = false) {
if (!this._cfg) return;
if (!this._root) {
this._root = this.attachShadow({ mode: "open" });
}
if (!this._form) {
this._build();
relabel = true;
}
this._form.hass = this._hass;
this._themeForm.hass = this._hass;
this._deviceForm.hass = this._hass;
if (relabel) this._applyLabels();
this._setFormData();
}
_setFormData() {
const key = JSON.stringify(this._cfg);
if (key === this._shownKey) return;
this._shownKey = key;
this._form.data = this._cfg;
this._themeForm.data = this._cfg;
this._deviceForm.data = { device: this._cfg.device || "" };
this._root.getElementById("refill").disabled = !this._cfg.device;
}
_build() {
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 { padding: 8px 16px; cursor: pointer; border: none; border-bottom: 2px solid transparent; background: none; color: inherit; font: inherit; }
.tab:focus-visible { outline: 2px solid var(--primary-color); outline-offset: -2px; }
.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; }
.device-row { display: flex; align-items: center; gap: 12px; margin: 4px 0 16px; flex-wrap: wrap; }
.ghost-btn { background: none; border: 1px solid var(--divider-color); border-radius: 6px; padding: 6px 12px;
color: var(--primary-color); font: inherit; cursor: pointer; }
.ghost-btn:disabled { color: var(--disabled-text-color); cursor: default; }
.ghost-btn:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; }
.status { color: var(--secondary-text-color); font-size: 0.9em; }
`;
this._root.innerHTML = `
<style>${style}</style>
<div class="editor-container">
<div class="tabs">
<div class="tab active" data-tab="entities">${this._t("tab_entities")}</div>
<div class="tab" data-tab="theme">${this._t("tab_theme")}</div>
<div class="tabs" role="tablist">
<button type="button" class="tab active" role="tab" aria-selected="true" data-tab="entities" id="tab-entities"></button>
<button type="button" class="tab" role="tab" aria-selected="false" data-tab="theme" id="tab-theme"></button>
</div>
<div class="tab-content active" id="entities-tab">
<ha-form id="device-form"></ha-form>
<div class="device-row">
<button type="button" class="ghost-btn" id="refill"></button>
<span class="status" id="refill-status" role="status"></span>
</div>
<ha-form id="form"></ha-form>
</div>
<div class="tab-content" id="theme-tab">
@@ -2578,18 +2811,82 @@ class KCFSCardEditor extends HTMLElement {
`;
this._setupTabs();
this._setupDeviceForm();
this._setupEntitiesForm();
this._setupThemeForm();
}
_setupDeviceForm() {
this._deviceForm = this._root.getElementById("device-form");
this._deviceForm.schema = [{
name: "device",
selector: { device: { filter: [{ integration: "ha_creality_ws" }] } },
}];
this._deviceForm.addEventListener("value-changed", (ev) => this._onDeviceChanged(ev.detail.value || {}));
this._root.getElementById("refill").addEventListener("click", () => this._applyDeviceFill(true));
}
_onDeviceChanged(value) {
const deviceId = value.device || "";
if (deviceId === (this._cfg.device || "")) return;
this._cfg = { ...this._cfg, device: deviceId };
if (deviceId) {
// Picking a device fills what is still blank; the button replaces too.
this._applyDeviceFill(false);
return;
}
this._setRefillStatus("");
this._setFormData();
this._dispatchConfigChange();
}
_applyDeviceFill(overwrite) {
const found = cfsEntitiesForDevice(this._hass, this._cfg.device);
const patch = {};
for (const [key, entityId] of Object.entries(found)) {
if (overwrite || !this._cfg[key]) patch[key] = entityId;
}
this._cfg = { ...this._cfg, ...patch };
const total = Object.keys(found).length;
this._setRefillStatus(total
? this._t("status_device_filled", { filled: Object.keys(patch).length, total })
: this._t("status_device_empty"));
this._setFormData();
this._dispatchConfigChange();
}
_setRefillStatus(text) {
const el = this._root?.getElementById("refill-status");
if (el) el.textContent = text;
}
/** Everything that reads a translation, so a late language load relabels. */
_applyLabels() {
this._root.getElementById("tab-entities").textContent = this._t("tab_entities");
this._root.getElementById("tab-theme").textContent = this._t("tab_theme");
this._root.getElementById("refill").textContent = this._t("btn_fill_from_device");
this._deviceForm.computeLabel = () => this._t("label_device");
// New function objects, so ha-form re-renders its labels.
this._form.computeLabel = (s) => this._entityLabel(s);
this._themeForm.schema = this._themeSchema();
this._themeForm.computeLabel = (s) => ({
view_mode: this._t("schema_view_mode"),
show_type_in_mini: this._t("schema_show_type_in_mini"),
}[s.name] || s.name);
}
_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"));
tabs.forEach((t) => {
t.classList.remove("active");
t.setAttribute("aria-selected", "false");
});
contents.forEach((c) => c.classList.remove("active"));
tab.classList.add("active");
tab.setAttribute("aria-selected", "true");
this._root.getElementById(`${tab.dataset.tab}-tab`).classList.add("active");
};
});
@@ -2597,8 +2894,6 @@ class KCFSCardEditor extends HTMLElement {
_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" } } },
@@ -2617,7 +2912,15 @@ class KCFSCardEditor extends HTMLElement {
}
this._form.schema = schema;
this._form.computeLabel = (s) => {
// Assigned unconditionally: ha-form leaves computeHelper undefined until
// someone sets it, so guarding on it meant this never ran. Harmless here
// only because ha-form's own default is "no helper" either way.
this._form.computeHelper = () => "";
this._form.addEventListener("value-changed", (ev) => this._edited(ev.detail.value));
}
_entityLabel(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");
@@ -2642,23 +2945,22 @@ class KCFSCardEditor extends HTMLElement {
}
return s.name;
};
// Assigned unconditionally: ha-form leaves computeHelper undefined until
// someone sets it, so guarding on it meant this never ran. Harmless here
// only because ha-form's own default is "no helper" either way.
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 = [
this._themeForm = this._root.getElementById("theme-form");
this._themeForm.addEventListener("value-changed", (ev) => this._edited(ev.detail.value));
}
/** One edit: show it, then tell Lovelace, whose echo is then a no-op. */
_edited(value) {
this._cfg = { ...this._cfg, ...value };
this._setFormData();
this._dispatchConfigChange();
}
_themeSchema() {
return [
{
name: "view_mode",
selector: {
@@ -2674,20 +2976,15 @@ class KCFSCardEditor extends HTMLElement {
},
{ name: "show_type_in_mini", selector: { boolean: {} } },
];
themeForm.computeLabel = (s) => ({
view_mode: this._t("schema_view_mode"),
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() {
// Only what differs from the defaults (R44): the card merges them back.
const defaults = KCFSCard.defaultConfig();
const config = Object.fromEntries(Object.entries(this._cfg)
.filter(([key, value]) => !(key in defaults) || value !== defaults[key]));
this.dispatchEvent(new CustomEvent("config-changed", {
detail: { config: this._cfg },
detail: { config },
bubbles: true,
composed: true,
}));
@@ -2697,9 +2994,21 @@ class KCFSCardEditor extends HTMLElement {
defineOnce(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)"
});
// Once per page, like the element itself: a second copy of this module (two
// resource entries with different ?v=) listed the card twice in the picker.
if (!window.customCards.some((card) => card.type === "k-cfs-card")) {
const pickerEntry = {
type: "k-cfs-card",
name: CFS_TRANSLATIONS.en.picker_name,
preview: true,
description: CFS_TRANSLATIONS.en.picker_description,
};
window.customCards.push(pickerEntry);
// The picker reads the entry when it opens, so the page's language can be
// applied once its strings arrive (R33).
const pageHass = document.querySelector?.("home-assistant")?.hass;
_requestI18n({}, pageHass, () => {
pickerEntry.name = _translate(pageHass, "cfs_card", CFS_TRANSLATIONS, "picker_name");
pickerEntry.description = _translate(pageHass, "cfs_card", CFS_TRANSLATIONS, "picker_description");
});
}
@@ -2,20 +2,28 @@ 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 = {};
// One cache for both cards, on the page: each module used to fetch en.json for
// itself, and a language with no file (a 404) was asked for again by every new
// card. A 404 is now remembered; only a failed request is retried. `no-cache`
// revalidates, so an updated translation is not served stale (R45).
const _i18nShared = (globalThis.__haCrealityWsI18n = globalThis.__haCrealityWsI18n || { data: {}, promises: {} });
const _i18nData = _i18nShared.data;
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))
if (lang in _i18nData) return Promise.resolve(_i18nData[lang]);
const promises = _i18nShared.promises;
if (promises[lang]) return promises[lang];
promises[lang] = fetch(`${I18N_URL_BASE}${lang}.json`, { cache: "no-cache" })
.then((res) => {
if (res.ok) return res.json();
if (res.status === 404) return null;
throw new Error(`HTTP ${res.status}`);
})
.then((data) => {
if (data) _i18nData[lang] = data;
else _i18nPromises[lang] = null;
_i18nData[lang] = data;
return data;
})
.catch(() => { _i18nPromises[lang] = null; return null; });
return _i18nPromises[lang];
.catch(() => { promises[lang] = null; return null; });
return promises[lang];
}
function _resolveLang(hass) {
return hass?.locale?.language || hass?.language || "en";
@@ -29,9 +37,12 @@ function _translate(hass, section, fallbackDict, key, vars) {
: (remoteEn && key in remoteEn) ? remoteEn[key]
: (fallbackDict[lang]?.[key] ?? fallbackDict[short]?.[key] ?? fallbackDict["en"]?.[key] ?? key);
if (vars) {
for (const [name, value] of Object.entries(vars)) {
text = text.replace(new RegExp(`\\{${name}\\}`, "g"), value);
}
// One pass with a function: a replacement *string* expands `$&` and
// friends, so a preset named "Teal $& Co" toasted as "Teal {name} Co", and
// a value containing "{other}" was substituted again by a later key.
text = text.replace(/\{(\w+)\}/g, (match, name) => (
Object.prototype.hasOwnProperty.call(vars, name) ? String(vars[name]) : match
));
}
return text;
}
@@ -48,6 +59,13 @@ function _requestI18n(instance, hass, onLoaded) {
// loop if size measurement on the fresh instance keeps producing the same delta.
const LL_REBUILD_MIN_INTERVAL_MS = 2000;
const _lastCardRebuildDispatch = new Map();
// The size each card last measured, by its full name/status key. Lovelace answers
// ll-rebuild by building a NEW element, which used to start at size 3, measure
// the same wrapped row again and fire again once the throttle cleared: a rebuild
// every two seconds on a narrow screen, with the reported size never sticking.
// A fresh element reads its predecessor's size from here, so it measures no
// change and stays put.
const _measuredCardSize = new Map();
// How much wider, in px, the telemetry row has to get before the units it
// dropped are worth retrying. Retrying at the width that rejected them would
@@ -57,7 +75,13 @@ const TELEMETRY_COMPACT_HYSTERESIS = 8;
const INTEGRATION_DOMAIN = "ha_creality_ws";
// The name a card carries until the user (or the device picker) names it.
const DEFAULT_CARD_NAME = "3D Printer";
// What the card picker used to write into every new card's YAML. Still read as
// "no name", so such a card shows the translated default and is renamed from
// its device like an unnamed one (R33).
const LEGACY_DEFAULT_CARD_NAME = "3D Printer";
function isUnnamed(name) {
return !name || name === LEGACY_DEFAULT_CARD_NAME;
}
/**
* Card roles the printer's own device can fill, keyed by the translation_key
@@ -249,9 +273,31 @@ function loadThemeFromStorage(cardId) {
* @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);
return cardIdForKey(cardKey(config));
}
/** The full identity a card's id is derived from: its name and status entity. */
function cardKey(config) {
return `${config.name || "printer"}-${config.status || "unknown"}`;
}
function cardIdForKey(key) {
// `btoa` takes Latin-1 only and throws on anything above U+00FF, so a card
// named "Tiskárna č.1" (or with an en dash, CJK or an emoji in its name)
// threw out of setConfig and became an error card, and in the editor the
// throw landed inside the debounce, so the edit was silently never saved.
// Latin-1 names keep the id they always had, so a theme stored under it is
// still found; anything else is encoded as UTF-8 bytes first.
let encoded;
try {
encoded = btoa(key);
} catch (_err) {
const bytes = new TextEncoder().encode(key);
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
encoded = btoa(binary);
}
return encoded.replace(/[^a-zA-Z0-9]/g, '').substring(0, 16);
}
// Home Assistant rewrites a DURATION sensor's state into whichever display unit
@@ -321,10 +367,68 @@ function computeColor(status) {
return "var(--secondary-text-color)";
}
/**
* `config` minus what equals its default, the theme likewise (R44).
* @param {!Object} config
* @param {!Object} defaults
* @return {!Object}
*/
function withoutDefaults(config, defaults) {
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const out = {};
for (const [key, value] of Object.entries(config)) {
if (key === "theme" && value && typeof value === "object") {
const theme = {};
for (const [k, v] of Object.entries(value)) {
if (!same(v, defaults.theme?.[k])) theme[k] = v;
}
if (Object.keys(theme).length) out.theme = theme;
} else if (!(key in defaults) || !same(value, defaults[key])) {
out[key] = value;
}
}
return out;
}
/** A theme value safe inside a <style> element, or "" when it is not (R46). */
function cssValue(value) {
const text = String(value ?? "");
return /^[#\w\s(),.%+\-]*$/.test(text) ? text : "";
}
/** Text safe inside a double-quoted HTML attribute. */
function attr(value) {
return String(value ?? "").replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// Entities the custom chip switches on and off rather than pressing (R46).
const CUSTOM_TOGGLE_DOMAINS = ["switch", "light", "input_boolean", "fan", "cover"];
/** A theme colour, or `fallback` when it is unset or "auto". */
function autoColour(value, fallback) {
return !value || value === "auto" ? fallback : value;
}
class KPrinterCard extends HTMLElement {
static getStubConfig() {
/**
* What a new card starts with: the first printer's entities when there is
* one, else nothing. Only what differs from the defaults goes into the
* dashboard; every default used to be written there and then outlived any
* later change to it (R44).
*/
static getStubConfig(hass) {
const registry = hass?.entities || {};
const deviceId = Object.values(registry).find((e) => e?.platform === INTEGRATION_DOMAIN && e.device_id)?.device_id;
if (!deviceId) return {};
const device = hass?.devices?.[deviceId];
const name = device?.name_by_user || device?.name || "";
return { ...(name ? { name } : {}), device: deviceId, ...entitiesForDevice(hass, deviceId) };
}
/** Every option with its default value. */
static defaultConfig() {
return {
name: DEFAULT_CARD_NAME,
name: "",
// Device the entity fields were filled from. Stored so the editor's
// "fill from device" button has something to re-read; the card itself
// resolves nothing from it, so a config written by hand needs no device.
@@ -349,7 +453,9 @@ class KPrinterCard extends HTMLElement {
resume_icon: "#fff",
stop_icon: "#fff",
light_icon_on: "#000",
light_icon_off: "#000",
// "auto": the theme's text colour. A fixed black was 2.26:1 against the
// off-state grey on a dark theme (R42).
light_icon_off: "auto",
// Status icon and progress circle
status_icon: "auto", // auto, or specific color
progress_ring: "auto", // auto, or specific color
@@ -363,18 +469,20 @@ class KPrinterCard extends HTMLElement {
// Off-state colours for the custom button, used when it drives a
// toggleable entity (switch/light/input_boolean).
custom_off_bg: "rgba(150,150,150,.35)",
custom_icon_off: "#000",
custom_icon_off: "auto",
// Power button. The chip CSS has always read these, but nothing ever
// set them, so the button was the one chip the theme could not reach.
power_on_bg: "rgba(76, 175, 80, .90)",
power_off_bg: "rgba(150,150,150,.35)",
power_icon_on: "#fff",
power_icon_off: "#000",
power_icon_off: "auto",
},
// Config for custom button
custom_btn: "",
custom_btn_icon: "", // Moved to theme editor but stored here
custom_btn_hidden: false,
// Buttons never shown, whatever the printer is doing (#37)
hidden_buttons: [],
// Order of buttons
button_order: ['pause', 'resume', 'stop', 'light', 'power', 'custom'],
// Icon overrides
@@ -431,14 +539,21 @@ class KPrinterCard extends HTMLElement {
}
setConfig(config) {
const defaultConfig = KPrinterCard.getStubConfig();
const defaultConfig = KPrinterCard.defaultConfig();
const migrated = KPrinterCard._migrateConfig(config);
this._cfg = { ...defaultConfig, ...migrated };
// A dashboard now holds only the colours that differ from the defaults.
this._cfg.theme = { ...defaultConfig.theme, ...(migrated.theme || {}) };
// Init optimistic state overrides map
if (!this._optimisticStates) this._optimisticStates = {};
// Generate card ID for theme persistence
this._cardId = generateCardId(this._cfg);
this._sizeKey = cardKey(this._cfg);
this._cardId = cardIdForKey(this._sizeKey);
// A rebuilt element starts from the size its predecessor measured.
this._cardSize = _measuredCardSize.get(this._sizeKey);
// A new config can name new entities: the next hass must update.
this._seenStates = null;
// Load saved theme if no theme is provided in config
if (!config?.theme) {
@@ -461,23 +576,9 @@ class KPrinterCard extends HTMLElement {
// 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);
}
@@ -486,28 +587,72 @@ class KPrinterCard extends HTMLElement {
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);
// Home Assistant assigns a new hass for every state change in the whole
// instance. This used to rebuild the shadow DOM each time (the theme only
// changes in setConfig, which renders anyway), re-parsing the stylesheet,
// recreating every icon and dropping keyboard focus, then update twice
// more. Now only a change to something this card shows updates it.
if (this._root && this._relevantChange(hass)) this._update();
}
/** Whether `hass` changed anything `_update` reads, recording what it saw. */
_relevantChange(hass) {
const ids = this._watchedEntityIds();
const prev = this._seenStates;
const next = {};
let changed = !prev;
for (const id of ids) {
const st = hass?.states?.[id];
next[id] = st;
if (prev && prev[id] !== st) changed = true;
}
// The language picks the card's strings; the formatter, the units.
const lang = _resolveLang(hass);
const fmt = typeof hass?.formatEntityState;
if (lang !== this._seenLang || fmt !== this._seenFormatter) changed = true;
this._seenLang = lang;
this._seenFormatter = fmt;
this._seenStates = next;
return changed;
}
/**
* Every entity id the config names, plus the switch/light twin that
* `_resolveEntityId` may substitute for it. Cached per config object.
*/
_watchedEntityIds() {
if (this._watchedFor === this._cfg && this._watched) return this._watched;
const ids = new Set();
for (const value of Object.values(this._cfg || {})) {
if (typeof value !== "string" || !/^[a-z0-9_]+\.[a-z0-9_]+$/.test(value)) continue;
ids.add(value);
const objectId = value.split(".")[1];
ids.add(`switch.${objectId}`);
ids.add(`light.${objectId}`);
}
this._watched = ids;
this._watchedFor = this._cfg;
return ids;
}
getCardSize() {
return this._cardSize ?? _measuredCardSize.get(this._sizeKey) ?? 3;
}
getCardSize() { return this._cardSize || 3; }
_render() {
if (!this._root) return;
// Ensure theme is always properly initialized
const defaultConfig = KPrinterCard.getStubConfig();
const defaultConfig = KPrinterCard.defaultConfig();
this._cfg.theme = { ...defaultConfig.theme, ...(this._cfg.theme || {}) };
// Apply theme variables to CSS custom properties
const theme = this._cfg.theme;
// Apply theme variables to CSS custom properties. Each value lands inside a
// <style> element, so anything that is not plainly a colour is dropped
// (and its default used): a value with `;`, `}` or `</style>` could
// otherwise rewrite the card's CSS or break out into markup (R46).
const theme = Object.fromEntries(
Object.entries(this._cfg.theme).map(([key, value]) => [key, cssValue(value)]),
);
// Theme CSS custom properties - embedded directly in CSS
const themeCSS = `
@@ -521,7 +666,7 @@ class KPrinterCard extends HTMLElement {
--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'};
--light-icon-off: ${autoColour(theme.light_icon_off, 'var(--primary-text-color)')};
--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)')};
@@ -529,11 +674,11 @@ class KPrinterCard extends HTMLElement {
--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'};
--custom-icon-off: ${autoColour(theme.custom_icon_off, 'var(--primary-text-color)')};
--power-on-bg: ${theme.power_on_bg || 'rgba(76, 175, 80, .90)'};
--power-off-bg: ${theme.power_off_bg || 'rgba(150,150,150,.35)'};
--power-icon-on: ${theme.power_icon_on || '#fff'};
--power-icon-off: ${theme.power_icon_off || '#000'};
--power-icon-off: ${autoColour(theme.power_icon_off, 'var(--primary-text-color)')};
}
`;
@@ -595,6 +740,11 @@ class KPrinterCard extends HTMLElement {
color:var(--chip-fg, var(--primary-text-color));
cursor:pointer; user-select:none; border:none; outline:none;
}
/* The outline is removed for mouse clicks only: a keyboard user has to
see where focus is (R42). */
.chip:focus-visible, .title.click:focus-visible {
outline: 2px solid var(--primary-color); outline-offset: 2px;
}
.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); }
@@ -737,7 +887,8 @@ class KPrinterCard extends HTMLElement {
// the stronger warning as much as a running print does.
const printing = ["printing", "paused", "processing"].includes(st);
const msg = printing ? this._t("confirm_power_off_printing") : this._t("confirm_power_off");
if (!confirm(msg)) return;
this._confirmedAction(msg, "homeassistant.turn_off", eid);
return;
}
this._toggleEntity(eid);
} else if (id === "light") {
@@ -748,18 +899,23 @@ class KPrinterCard extends HTMLElement {
} 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);
}
const eid = this._cfg.stop_btn;
const domain = (eid || "").split(".")[0];
const service = ["button", "input_button"].includes(domain) ? `${domain}.press` : "homeassistant.turn_on";
this._confirmedAction(this._t("confirm_stop"), service, eid);
} 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
// What a tap means for each kind of entity: on/off things toggle (the
// chip already shows fans and covers as on/off, but only ever turned
// them on), an automation runs rather than being enabled, and buttons,
// scripts and scenes are pressed or turned on (R46).
const eid = this._cfg.custom_btn;
const domain = eid ? (eid.split(".")[0] || "").toLowerCase() : "";
if (["switch", "light", "input_boolean"].includes(domain)) {
if (CUSTOM_TOGGLE_DOMAINS.includes(domain)) {
this._toggleEntity(eid);
} else if (domain === "automation") {
this._hass?.callService("automation", "trigger", { entity_id: eid });
} else {
this._pressButtonEntity(eid); // Fallback to press (works for button domain, or generic turn_on if mapped)
this._pressButtonEntity(eid);
}
}
});
@@ -783,7 +939,6 @@ class KPrinterCard extends HTMLElement {
}
disconnectedCallback() {
clearTimeout(this._initialUpdateTimer);
if (this._telemetryResizeObserver) {
if (this._telemetryObservedNode) {
this._telemetryResizeObserver.unobserve(this._telemetryObservedNode);
@@ -900,25 +1055,45 @@ class KPrinterCard extends HTMLElement {
const currentSize = this._cardSize ?? 3;
if (nextSize === currentSize) return;
const cardKey = this._cardId || CARD_TAG;
const throttleKey = this._sizeKey || CARD_TAG;
const now = Date.now();
const lastDispatch = _lastCardRebuildDispatch.get(cardKey) || 0;
const lastDispatch = _lastCardRebuildDispatch.get(throttleKey) || 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);
_lastCardRebuildDispatch.set(throttleKey, now);
this._cardSize = nextSize;
_measuredCardSize.set(this._sizeKey, 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.
/**
* Run `service` on `entityId` behind Home Assistant's own confirmation
* dialog. window.confirm() is switched off in some kiosk browsers and
* webviews, where it answers "no" without asking, so Stop and power-off
* silently did nothing there (R46).
*/
_confirmedAction(text, service, entityId) {
if (!entityId) return;
this.dispatchEvent(new CustomEvent("hass-action", {
bubbles: true,
composed: true,
detail: {
action: "tap",
config: {
tap_action: {
action: "perform-action",
perform_action: service,
target: { entity_id: entityId },
confirmation: { text },
},
},
},
}));
}
async _pressButtonEntity(eid) {
if (!this._hass || !eid) return;
@@ -926,7 +1101,7 @@ class KPrinterCard extends HTMLElement {
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
// Scripts and scenes run through turn_on.
await this._hass.callService("homeassistant", "turn_on", { entity_id: eid });
}
}
@@ -1014,7 +1189,7 @@ class KPrinterCard extends HTMLElement {
};
const fmtWithUnit = (eid) => fmtState(gObj(eid));
const name = this._cfg.name || DEFAULT_CARD_NAME;
const name = isUnnamed(this._cfg.name) ? this._t("default_name") : this._cfg.name;
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 = durationToSeconds(gObj(this._cfg.time_left));
@@ -1139,11 +1314,12 @@ class KPrinterCard extends HTMLElement {
if (!uniqueOrder.includes(k) && buttons[k]) uniqueOrder.push(k);
});
const hiddenByConfig = new Set(Array.isArray(this._cfg.hidden_buttons) ? this._cfg.hidden_buttons : []);
let chipsHtml = "";
uniqueOrder.forEach(key => {
const btn = buttons[key];
if (btn && !btn.hidden) {
chipsHtml += `<button class="chip ${btn.class}" id="${key}" title="${btn.title}"><ha-icon icon="${btn.icon}"></ha-icon></button>`;
if (btn && !btn.hidden && !hiddenByConfig.has(key)) {
chipsHtml += `<button class="chip ${btn.class}" id="${key}" title="${attr(btn.title)}" aria-label="${attr(btn.title)}"><ha-icon icon="${attr(btn.icon)}"></ha-icon></button>`;
}
});
@@ -1189,6 +1365,9 @@ class KPrinterCard extends HTMLElement {
}
const CARD_TRANSLATIONS = {
en: {
default_name: "3D Printer",
picker_name: "Creality Printer Card",
picker_description: "Standalone card for Creality K-Series printers",
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?",
@@ -1266,6 +1445,7 @@ const CARD_TRANSLATIONS = {
label_custom_btn: "Custom Action Entity",
label_custom_btn_icon: "Custom Button Icon",
label_custom_btn_hidden: "Hide Custom Button",
label_hidden_buttons: "Hidden Buttons",
label_button_order: "Button Order (list)",
label_hide_box_temp: "Hide Chamber Temperature",
label_pause_btn_icon: "Pause Icon Override",
@@ -1293,6 +1473,7 @@ const CARD_TRANSLATIONS = {
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_hidden_buttons: "Never shown on the card, whatever the printer is doing",
helper_button_order: "List of buttons to show in order (pause, resume, stop, light, power, custom)",
helper_hide_box_temp: "Hide the chamber temperature pill even when a sensor is configured",
editor_error_title: "Editor Error",
@@ -1323,7 +1504,6 @@ function defineOnce(tag, cls) {
}
}
defineOnce(CARD_TAG, KPrinterCard);
/**
* Colour controls in the theme tab, grouped the way they are rendered.
@@ -1355,15 +1535,15 @@ const THEME_COLOR_GROUPS = [
{ key: "light_on_bg", alpha: true },
{ key: "light_icon_on" },
{ key: "light_off_bg", alpha: true },
{ key: "light_icon_off" },
{ key: "light_icon_off", auto: true, seed: "#000000" },
{ key: "power_on_bg", alpha: true },
{ key: "power_icon_on" },
{ key: "power_off_bg", alpha: true },
{ key: "power_icon_off" },
{ key: "power_icon_off", auto: true, seed: "#000000" },
{ key: "custom_bg", alpha: true },
{ key: "custom_icon" },
{ key: "custom_off_bg", alpha: true },
{ key: "custom_icon_off" },
{ key: "custom_icon_off", auto: true, seed: "#000000" },
],
},
{
@@ -1405,7 +1585,7 @@ const AUTO_SUFFIX = "_auto";
/** Top-level config keys the theme tab owns, and so the reset button clears. */
const LAYOUT_RESET_KEYS = [
"button_order", "custom_btn_hidden", "hide_box_temp",
"button_order", "custom_btn_hidden", "hidden_buttons", "hide_box_temp",
"pause_btn_icon", "resume_btn_icon", "stop_btn_icon",
"light_btn_icon", "power_btn_icon", "custom_btn_icon",
];
@@ -1419,7 +1599,8 @@ const EDITOR_STYLE = `
.editor-container { padding: 16px; max-width: 1200px; margin: 0 auto; }
.editor-title { margin: 0 0 16px 0; font-size: 18px; color: var(--primary-text-color); }
.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 { padding: 8px 16px; cursor: pointer; border: none; border-bottom: 2px solid transparent; background: none; color: inherit; font: inherit; }
.tab:focus-visible { outline: 2px solid var(--primary-color); outline-offset: -2px; }
.tab.active { border-bottom-color: var(--primary-color); color: var(--primary-color); }
.tab-content { display: none; }
.tab-content.active { display: block; }
@@ -1493,10 +1674,23 @@ function entitiesSchema() {
];
}
function layoutSchema() {
/** The chips `hidden_buttons` can name, in their default order. */
const HIDEABLE_BUTTONS = ["pause", "resume", "stop", "light", "power", "custom"];
function layoutSchema(t = (key) => key) {
return [
{ name: "button_order", selector: { text: {} } },
{ name: "custom_btn_hidden", selector: { boolean: {} } },
{
name: "hidden_buttons",
selector: {
select: {
multiple: true,
mode: "list",
options: HIDEABLE_BUTTONS.map((key) => ({ value: key, label: t(`chip_${key}`) })),
},
},
},
{ name: "hide_box_temp", selector: { boolean: {} } },
{ name: "pause_btn_icon", selector: { icon: {} } },
{ name: "resume_btn_icon", selector: { icon: {} } },
@@ -1602,9 +1796,6 @@ function colorData(cfg, group) {
/* Visual editor: entity wiring on one tab, appearance on the other. */
class KPrinterCardEditor extends HTMLElement {
// i18n helpers -------------------------------------------------------
_resolveLanguage() {
return _resolveLang(this._hass);
}
_t(key, vars) {
return _translate(this._hass, "printer_card", CARD_TRANSLATIONS, key, vars);
}
@@ -1622,7 +1813,7 @@ class KPrinterCardEditor extends HTMLElement {
}
setConfig(config) {
const defaults = KPrinterCard.getStubConfig();
const defaults = KPrinterCard.defaultConfig();
this._cfg = { ...defaults, ...KPrinterCard._migrateConfig(config) };
this._cfg.theme = { ...defaults.theme, ...(this._cfg.theme || {}) };
this._refresh();
@@ -1665,9 +1856,9 @@ class KPrinterCardEditor extends HTMLElement {
<style>${EDITOR_STYLE}</style>
<div class="editor-container">
<h2 class="editor-title" id="editor-title"></h2>
<div class="tabs">
<div class="tab active" data-tab="entities" id="tab-entities"></div>
<div class="tab" data-tab="theme" id="tab-theme"></div>
<div class="tabs" role="tablist">
<button type="button" class="tab active" role="tab" aria-selected="true" data-tab="entities" id="tab-entities"></button>
<button type="button" class="tab" role="tab" aria-selected="false" data-tab="theme" id="tab-theme"></button>
</div>
<div class="tab-content active" id="entities-tab">
@@ -1751,6 +1942,7 @@ class KPrinterCardEditor extends HTMLElement {
_selectTab(name) {
for (const tab of this._root.querySelectorAll(".tab")) {
tab.classList.toggle("active", tab.dataset.tab === name);
tab.setAttribute("aria-selected", String(tab.dataset.tab === name));
}
for (const content of this._root.querySelectorAll(".tab-content")) {
content.classList.toggle("active", content.id === `${name}-tab`);
@@ -1852,7 +2044,7 @@ class KPrinterCardEditor extends HTMLElement {
this._applyForm("device-form", deviceSchema(), { device: this._cfg.device || "" });
this._applyForm("entities-form", entitiesSchema(), entitiesData(this._cfg));
this._applyForm("layout-form", layoutSchema(), layoutData(this._cfg));
this._applyForm("layout-form", layoutSchema((key) => this._t(key)), layoutData(this._cfg));
THEME_COLOR_GROUPS.forEach((group, index) => {
this._root.getElementById(`group-color-${index}`).textContent = this._t(group.title);
@@ -1903,7 +2095,7 @@ class KPrinterCardEditor extends HTMLElement {
}
_onColorChanged(group, value) {
const defaults = KPrinterCard.getStubConfig().theme;
const defaults = KPrinterCard.defaultConfig().theme;
const theme = { ...this._cfg.theme };
const wasAuto = new Map(group.fields.map((field) => [field.key, isAutoColor(this._cfg, field)]));
for (const field of group.fields) {
@@ -1976,9 +2168,9 @@ class KPrinterCardEditor extends HTMLElement {
}
const device = this._hass?.devices?.[deviceId];
const deviceName = device?.name_by_user || device?.name || "";
// Every card starts life named "3D Printer", so that counts as unset --
// Cards used to start life named "3D Printer", so that counts as unset --
// otherwise the field the user most expects to be filled never would be.
const nameUnset = !this._cfg.name || this._cfg.name === DEFAULT_CARD_NAME;
const nameUnset = isUnnamed(this._cfg.name);
if (deviceName && (overwrite || nameUnset)) patch.name = deviceName;
return patch;
}
@@ -2008,7 +2200,7 @@ class KPrinterCardEditor extends HTMLElement {
// Reset ----------------------------------------------------------------
_resetTheme() {
const defaults = KPrinterCard.getStubConfig();
const defaults = KPrinterCard.defaultConfig();
const cfg = { ...this._cfg, theme: { ...defaults.theme } };
for (const key of LAYOUT_RESET_KEYS) cfg[key] = defaults[key];
@@ -2042,20 +2234,36 @@ class KPrinterCardEditor extends HTMLElement {
// localStorage write. The card saves the same thing again from setConfig.
saveThemeToStorage(generateCardId(this._cfg), this._cfg.theme);
this.dispatchEvent(new CustomEvent("config-changed", {
detail: { config: this._cfg },
detail: { config: withoutDefaults(this._cfg, KPrinterCard.defaultConfig()) },
bubbles: true,
composed: true,
}));
}
}
// Defined last, once every module-level constant the classes read exists:
// defining the card upgrades elements already in the page on the spot, and
// THEME_COLOR_FIELDS (used by _migrateTheme) was declared after it (R46).
defineOnce(CARD_TAG, KPrinterCard);
defineOnce(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,
});
// Once per page, like the element itself: a second copy of this module (two
// resource entries with different ?v=) listed the card twice in the picker.
if (!window.customCards.some((card) => card.type === CARD_TAG)) {
const pickerEntry = {
type: CARD_TAG,
name: CARD_TRANSLATIONS.en.picker_name,
description: CARD_TRANSLATIONS.en.picker_description,
preview: true,
};
window.customCards.push(pickerEntry);
// The picker reads the entry when it opens, so the page's language can be
// applied once its strings arrive (R33).
const pageHass = document.querySelector?.("home-assistant")?.hass;
_requestI18n({}, pageHass, () => {
pickerEntry.name = _translate(pageHass, "printer_card", CARD_TRANSLATIONS, "picker_name");
pickerEntry.description = _translate(pageHass, "printer_card", CARD_TRANSLATIONS, "picker_description");
});
}
} catch (_) { }