diff --git a/custom_components/ha_creality_ws/manifest.json b/custom_components/ha_creality_ws/manifest.json index 87b25cb3..632a18d9 100644 --- a/custom_components/ha_creality_ws/manifest.json +++ b/custom_components/ha_creality_ws/manifest.json @@ -1,27 +1,27 @@ -{ - "domain": "ha_creality_ws", - "name": "Creality WebSocket Integration", - "after_dependencies": ["go2rtc"], - "codeowners": ["@3dg1luk43"], - "config_flow": true, - "dependencies": ["lovelace", "http", "frontend", "persistent_notification"], - "documentation": "https://github.com/3dg1luk43/ha_creality_ws", - "iot_class": "local_push", - "issue_tracker": "https://github.com/3dg1luk43/ha_creality_ws/issues", - "loggers": ["custom_components.ha_creality_ws"], - "requirements": [ - "websockets>=10.4", - "go2rtc-client>=0.1.0" - ], - "version": "0.9.7", - "zeroconf": [ - { "type": "_http._tcp.local.", "name": "*creality*" }, - { "type": "_workstation._tcp.local.", "name": "*creality*" }, - { "type": "_http._tcp.local.", "name": "*k1*" }, - { "type": "_workstation._tcp.local.", "name": "*k1*" }, - { "type": "_http._tcp.local.", "name": "*k2*" }, - { "type": "_workstation._tcp.local.", "name": "*k2*" } - ] -} - - +{ + "domain": "ha_creality_ws", + "name": "Creality WebSocket Integration", + "after_dependencies": ["go2rtc"], + "codeowners": ["@3dg1luk43"], + "config_flow": true, + "dependencies": ["lovelace", "http", "frontend", "persistent_notification"], + "documentation": "https://github.com/3dg1luk43/ha_creality_ws", + "iot_class": "local_push", + "issue_tracker": "https://github.com/3dg1luk43/ha_creality_ws/issues", + "loggers": ["custom_components.ha_creality_ws"], + "requirements": [ + "websockets>=10.4", + "go2rtc-client>=0.1.0" + ], + "version": "0.9.8", + "zeroconf": [ + { "type": "_http._tcp.local.", "name": "*creality*" }, + { "type": "_workstation._tcp.local.", "name": "*creality*" }, + { "type": "_http._tcp.local.", "name": "*k1*" }, + { "type": "_workstation._tcp.local.", "name": "*k1*" }, + { "type": "_http._tcp.local.", "name": "*k2*" }, + { "type": "_workstation._tcp.local.", "name": "*k2*" } + ] +} + + diff --git a/custom_components/ha_creality_ws/services.yaml b/custom_components/ha_creality_ws/services.yaml index 8b582e5b..0dfe9128 100644 --- a/custom_components/ha_creality_ws/services.yaml +++ b/custom_components/ha_creality_ws/services.yaml @@ -13,3 +13,82 @@ request_cfs_info: device: integration: ha_creality_ws multiple: true + +set_cfs_material: + fields: + device_id: + required: true + selector: + device: + integration: ha_creality_ws + multiple: true + box_id: + required: true + default: 1 + selector: + number: + # No upper bound: box_id is whatever the printer reports, and an + # external unit can present a high id (7 has been observed). Capping it + # at 4 made those slots unwritable. The printer rejects an unknown id. + min: 0 + mode: box + slot_id: + required: true + default: 0 + selector: + number: + min: 0 + max: 3 + mode: box + type: + required: true + example: "PLA" + selector: + text: + name: + required: false + example: "Hyper PLA" + selector: + text: + vendor: + required: false + example: "Creality" + selector: + text: + # A plain hex string, not the color_rgb selector: that selector hands back + # [r, g, b], which the printer does not understand. + color: + required: false + example: "#06c84f" + selector: + text: + min_temp: + required: false + selector: + number: + min: 150 + max: 300 + step: 1 + unit_of_measurement: "°C" + mode: box + max_temp: + required: false + selector: + number: + min: 150 + max: 350 + step: 1 + unit_of_measurement: "°C" + mode: box + pressure: + required: false + selector: + number: + min: 0.0 + max: 1.0 + step: 0.01 + mode: box + rfid: + required: false + selector: + text: diff --git a/custom_components/ha_creality_ws/strings.json b/custom_components/ha_creality_ws/strings.json index badbe9f4..1ed7dc1b 100644 --- a/custom_components/ha_creality_ws/strings.json +++ b/custom_components/ha_creality_ws/strings.json @@ -21,42 +21,143 @@ "not_K": "Not a K-series printer" } }, + "common": { + "title_fallback": "Creality Printer", + "channel_live": "3D Print", + "channel_finished": "3D Print Finished", + "channel_alerts": "3D Print Alerts", + "channel_soon": "3D Print Finishing Soon", + "action_pause": "Pause", + "action_resume": "Resume", + "action_stop": "Stop", + "action_dismiss": "Hide", + "status_starting": "Starting", + "status_paused": "Paused", + "status_finishing": "Finishing", + "body_progress": "{progress}%", + "body_paused": "Paused at {progress}%", + "body_paused_unknown": "Paused", + "body_layer": "{layer}/{total_layers}", + "body_time_left": "{duration}", + "body_fallback": "Printing", + "duration_hours_minutes": "{hours}h {minutes}m", + "duration_minutes": "{minutes}m", + "duration_seconds": "{seconds}s", + "filament_length": "{metres} m", + "completed": "{device} finished.", + "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." + }, + "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." + } + }, + "selector": { + "camera_mode": { + "options": { + "auto": "Auto (detect by model)", + "mjpeg": "MJPEG (K1 family)", + "webrtc": "WebRTC via go2rtc (K2 family)", + "webrtc_direct": "WebRTC direct, no go2rtc (alternative)", + "custom": "Custom camera URL (MJPEG / RTSP)" + } + } + }, "options": { "step": { "init": { "title": "Printer Configuration", - "description": "Choose a settings group to edit. Each group opens its own page so its options always load fresh.", + "description": "Choose a settings group to edit. Each group opens its own page, and pressing Submit on that page saves it and applies it right away. There is nothing to confirm afterwards: close this dialog whenever you are finished.", "menu_options": { "camera": "Camera", "notifications": "Notifications", "power": "Power switch", - "connection": "Connection & performance", - "save": "Save and apply" + "connection": "Connection & performance" } }, "camera": { "title": "Camera", - "description": "{camera_help}", + "description": "Camera streaming mode. Auto detects from the printer model. WebRTC via go2rtc is the default for K2-family printers; WebRTC direct is an alternative that signals the printer itself (no go2rtc) - try it if the default does not work, e.g. on newer K1C firmware. Custom lets you point at any http(s) MJPEG/snapshot URL or an rtsp:// stream (served via go2rtc). Leave the go2rtc RTSP port at 0 unless HLS / camera.record needs a non-default port. Submit saves this page and returns to the menu.", "data": { "camera_mode": "Camera Streaming Mode", "go2rtc_url": "External go2rtc Host/URL (if needed)", "go2rtc_port": "External go2rtc Port", + "go2rtc_rtsp_port": "go2rtc RTSP Port (0 = auto: 18554 built-in, 8554 external)", "custom_camera_url": "Custom Camera URL (http(s) MJPEG/snapshot or rtsp://)" } }, "notifications": { "title": "Notifications", + "description": "Choose where notifications go, then what you want to be told about. Pressing Submit saves this page and applies it right away.", "data": { - "notify_device": "Notification Device", - "notify_completed": "Notify when Completed", - "notify_error": "Notify on Error", - "notify_minutes_to_end": "Notify before Completion", - "minutes_to_end_value": "Minutes before Completion" + "notify_targets": "Send notifications to" + }, + "data_description": { + "notify_targets": "Pick one or more. Only Home Assistant companion app targets can show the live print card, the G-code preview or the buttons; any other notify platform receives the title and message only." + }, + "sections": { + "events": { + "name": "What to notify me about", + "description": "The live print card needs Home Assistant 2026.7 or newer plus iOS 17.2+ or Android 16+, and iOS ends it after 8 hours on very long prints.", + "data": { + "notify_live": "Live print card", + "notify_completed": "When a print ends", + "notify_error": "Errors and filament runout", + "notify_minutes_to_end": "Shortly before a print ends", + "minutes_to_end_value": "How long before" + }, + "data_description": { + "notify_live": "One notification for the whole print that updates in place, with a progress bar and a countdown that ticks on the phone.", + "notify_completed": "Covers both endings: a print that finished, and one that was stopped, whether from the printer, the Creality app or Home Assistant.", + "notify_error": "A printer error, or a spool that has run out.", + "notify_minutes_to_end": "A reminder to go and watch the last layers, on its own so the live card cannot replace it." + } + }, + "extras": { + "name": "Buttons, pictures and tapping", + "description": "All of these need a companion app target, and are ignored by every other notify platform.", + "data": { + "notify_actions": "Pause, Resume and Stop buttons", + "notify_preview_image": "G-code preview as the icon", + "notify_camera_snapshot": "Camera snapshot when a print ends", + "notify_tap_path": "Dashboard path to open on tap" + }, + "data_description": { + "notify_actions": "Stop asks for device authentication, so a mis-tap on a lock screen cannot end a long print.", + "notify_preview_image": "Skipped when the printer has no preview to serve, rather than sending an empty grey box.", + "notify_camera_snapshot": "What the bed actually looks like. Skipped on printers whose camera cannot take a still.", + "notify_tap_path": "For example /lovelace/printer. Left empty, an Android tap opens the printer camera and an iOS tap opens the app; this is the only way to make an iOS tap go anywhere specific." + } + }, + "text": { + "name": "Custom text", + "description": "Write your own wording, or leave a field empty to keep the built-in, translated text. A placeholder the printer has not reported renders as nothing, so wrap anything optional in square brackets and the whole bracketed part disappears while its value is unknown, as in {example}. The brackets themselves are the syntax and are not shown; double them ([[ and ]]) for a bracket you want to keep. The title is always the printer name: Android fixes a live card's title when the card starts and cannot change it afterwards.", + "data": { + "notify_template_live": "Live print card", + "notify_template_completed": "Print finished", + "notify_template_stopped": "Print stopped", + "notify_template_soon": "Shortly before the end", + "notify_template_error": "Printer error", + "notify_template_runout": "Filament runout" + }, + "data_description": { + "notify_template_live": "Placeholders: {fields_live}", + "notify_template_completed": "Placeholders: {fields_completed}", + "notify_template_stopped": "Placeholders: {fields_stopped}. Fewer than the others: the printer has already reset the progress, the job clock and the layer by the time a stop can be seen, so only what survives it is offered.", + "notify_template_soon": "Placeholders: {fields_finishing_soon}", + "notify_template_error": "Placeholders: {fields_error}", + "notify_template_runout": "Placeholders: {fields_filament_runout}" + } + } } }, "power": { "title": "Power switch", - "description": "{power_help}", + "description": "Optional power switch entity ID (e.g. switch.smart_plug_name) to enable accurate 'Off' state detection.", "data": { "power_switch_enabled": "Enable Power Switch Detection", "power_switch": "Power Switch Entity" @@ -71,7 +172,8 @@ } }, "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." + "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." } }, "entity": { @@ -87,7 +189,6 @@ "position_z": { "name": "Position Z" }, "feedrate_pct": { "name": "Print Speed" }, "flowrate_pct": { "name": "Flow Rate" }, - "system": { "name": "System" }, "model_info": { "name": "Model" }, "filament_status": { "name": "Filament Status", @@ -113,6 +214,9 @@ } }, "used_material_length": { "name": "Used Material Length" }, + "expected_material_length": { "name": "Expected Material Length" }, + "expected_material_weight": { "name": "Expected Material Weight" }, + "filament_consumption": { "name": "Filament Consumption" }, "print_job_time": { "name": "Print Job Time" }, "print_left_time": { "name": "Print Time Left" }, "real_time_flow": { "name": "Real-Time Flow" }, @@ -150,10 +254,7 @@ "print_tuning_pct": { "name": "Print Tuning" }, "nozzle_target": { "name": "Nozzle Target" }, "bed_target": { "name": "Bed Target" }, - "chamber_target": { "name": "Chamber Target" }, - "model_fan_pct": { "name": "Model Fan" }, - "case_fan_pct": { "name": "Case Fan" }, - "side_fan_pct": { "name": "Side Fan" } + "chamber_target": { "name": "Chamber Target" } }, "fan": { "model_fan": { "name": "Model Fan" }, @@ -163,9 +264,6 @@ "light": { "light": { "name": "Light" } }, - "switch": { - "light": { "name": "Light" } - }, "camera": { "printer_camera": { "name": "Printer Camera" } }, @@ -193,6 +291,56 @@ "description": "The printer(s) to request info from. If omitted, all connected printers will be queried." } } + }, + "set_cfs_material": { + "name": "Set CFS Material", + "description": "Write filament settings to one CFS slot. Only the fields you supply are changed; leave a field out to keep the value the printer already has. Material type is required and cannot be blank, and a color, if you give one, must be six hex digits.", + "fields": { + "device_id": { + "name": "Device", + "description": "The printer(s) to write to." + }, + "box_id": { + "name": "Box ID", + "description": "The box id as the printer reports it. See the box_id attribute on the slot's sensor; it is usually 1 for the first CFS unit." + }, + "slot_id": { + "name": "Slot ID", + "description": "Slot number within the box, starting at 0." + }, + "type": { + "name": "Material Type", + "description": "Filament type, for example PLA, PETG, ABS or TPU." + }, + "name": { + "name": "Material Name", + "description": "Product name of the filament, for example Hyper PLA." + }, + "vendor": { + "name": "Vendor", + "description": "Manufacturer or brand name." + }, + "color": { + "name": "Color", + "description": "Color as six hex digits, for example #06c84f. For a multi-color spool, leave this field out rather than sending an empty value; the other fields still write." + }, + "min_temp": { + "name": "Minimum Temperature", + "description": "Lowest printing temperature, in °C." + }, + "max_temp": { + "name": "Maximum Temperature", + "description": "Highest printing temperature, in °C. Must not be below the minimum." + }, + "pressure": { + "name": "Pressure Advance", + "description": "Pressure advance value, between 0 and 1." + }, + "rfid": { + "name": "RFID", + "description": "The printer's material id. Leaving it empty omits the field, so the existing tag association is preserved." + } + } } } } diff --git a/custom_components/ha_creality_ws/translations/en.json b/custom_components/ha_creality_ws/translations/en.json index badbe9f4..1ed7dc1b 100644 --- a/custom_components/ha_creality_ws/translations/en.json +++ b/custom_components/ha_creality_ws/translations/en.json @@ -21,42 +21,143 @@ "not_K": "Not a K-series printer" } }, + "common": { + "title_fallback": "Creality Printer", + "channel_live": "3D Print", + "channel_finished": "3D Print Finished", + "channel_alerts": "3D Print Alerts", + "channel_soon": "3D Print Finishing Soon", + "action_pause": "Pause", + "action_resume": "Resume", + "action_stop": "Stop", + "action_dismiss": "Hide", + "status_starting": "Starting", + "status_paused": "Paused", + "status_finishing": "Finishing", + "body_progress": "{progress}%", + "body_paused": "Paused at {progress}%", + "body_paused_unknown": "Paused", + "body_layer": "{layer}/{total_layers}", + "body_time_left": "{duration}", + "body_fallback": "Printing", + "duration_hours_minutes": "{hours}h {minutes}m", + "duration_minutes": "{minutes}m", + "duration_seconds": "{seconds}s", + "filament_length": "{metres} m", + "completed": "{device} finished.", + "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." + }, + "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." + } + }, + "selector": { + "camera_mode": { + "options": { + "auto": "Auto (detect by model)", + "mjpeg": "MJPEG (K1 family)", + "webrtc": "WebRTC via go2rtc (K2 family)", + "webrtc_direct": "WebRTC direct, no go2rtc (alternative)", + "custom": "Custom camera URL (MJPEG / RTSP)" + } + } + }, "options": { "step": { "init": { "title": "Printer Configuration", - "description": "Choose a settings group to edit. Each group opens its own page so its options always load fresh.", + "description": "Choose a settings group to edit. Each group opens its own page, and pressing Submit on that page saves it and applies it right away. There is nothing to confirm afterwards: close this dialog whenever you are finished.", "menu_options": { "camera": "Camera", "notifications": "Notifications", "power": "Power switch", - "connection": "Connection & performance", - "save": "Save and apply" + "connection": "Connection & performance" } }, "camera": { "title": "Camera", - "description": "{camera_help}", + "description": "Camera streaming mode. Auto detects from the printer model. WebRTC via go2rtc is the default for K2-family printers; WebRTC direct is an alternative that signals the printer itself (no go2rtc) - try it if the default does not work, e.g. on newer K1C firmware. Custom lets you point at any http(s) MJPEG/snapshot URL or an rtsp:// stream (served via go2rtc). Leave the go2rtc RTSP port at 0 unless HLS / camera.record needs a non-default port. Submit saves this page and returns to the menu.", "data": { "camera_mode": "Camera Streaming Mode", "go2rtc_url": "External go2rtc Host/URL (if needed)", "go2rtc_port": "External go2rtc Port", + "go2rtc_rtsp_port": "go2rtc RTSP Port (0 = auto: 18554 built-in, 8554 external)", "custom_camera_url": "Custom Camera URL (http(s) MJPEG/snapshot or rtsp://)" } }, "notifications": { "title": "Notifications", + "description": "Choose where notifications go, then what you want to be told about. Pressing Submit saves this page and applies it right away.", "data": { - "notify_device": "Notification Device", - "notify_completed": "Notify when Completed", - "notify_error": "Notify on Error", - "notify_minutes_to_end": "Notify before Completion", - "minutes_to_end_value": "Minutes before Completion" + "notify_targets": "Send notifications to" + }, + "data_description": { + "notify_targets": "Pick one or more. Only Home Assistant companion app targets can show the live print card, the G-code preview or the buttons; any other notify platform receives the title and message only." + }, + "sections": { + "events": { + "name": "What to notify me about", + "description": "The live print card needs Home Assistant 2026.7 or newer plus iOS 17.2+ or Android 16+, and iOS ends it after 8 hours on very long prints.", + "data": { + "notify_live": "Live print card", + "notify_completed": "When a print ends", + "notify_error": "Errors and filament runout", + "notify_minutes_to_end": "Shortly before a print ends", + "minutes_to_end_value": "How long before" + }, + "data_description": { + "notify_live": "One notification for the whole print that updates in place, with a progress bar and a countdown that ticks on the phone.", + "notify_completed": "Covers both endings: a print that finished, and one that was stopped, whether from the printer, the Creality app or Home Assistant.", + "notify_error": "A printer error, or a spool that has run out.", + "notify_minutes_to_end": "A reminder to go and watch the last layers, on its own so the live card cannot replace it." + } + }, + "extras": { + "name": "Buttons, pictures and tapping", + "description": "All of these need a companion app target, and are ignored by every other notify platform.", + "data": { + "notify_actions": "Pause, Resume and Stop buttons", + "notify_preview_image": "G-code preview as the icon", + "notify_camera_snapshot": "Camera snapshot when a print ends", + "notify_tap_path": "Dashboard path to open on tap" + }, + "data_description": { + "notify_actions": "Stop asks for device authentication, so a mis-tap on a lock screen cannot end a long print.", + "notify_preview_image": "Skipped when the printer has no preview to serve, rather than sending an empty grey box.", + "notify_camera_snapshot": "What the bed actually looks like. Skipped on printers whose camera cannot take a still.", + "notify_tap_path": "For example /lovelace/printer. Left empty, an Android tap opens the printer camera and an iOS tap opens the app; this is the only way to make an iOS tap go anywhere specific." + } + }, + "text": { + "name": "Custom text", + "description": "Write your own wording, or leave a field empty to keep the built-in, translated text. A placeholder the printer has not reported renders as nothing, so wrap anything optional in square brackets and the whole bracketed part disappears while its value is unknown, as in {example}. The brackets themselves are the syntax and are not shown; double them ([[ and ]]) for a bracket you want to keep. The title is always the printer name: Android fixes a live card's title when the card starts and cannot change it afterwards.", + "data": { + "notify_template_live": "Live print card", + "notify_template_completed": "Print finished", + "notify_template_stopped": "Print stopped", + "notify_template_soon": "Shortly before the end", + "notify_template_error": "Printer error", + "notify_template_runout": "Filament runout" + }, + "data_description": { + "notify_template_live": "Placeholders: {fields_live}", + "notify_template_completed": "Placeholders: {fields_completed}", + "notify_template_stopped": "Placeholders: {fields_stopped}. Fewer than the others: the printer has already reset the progress, the job clock and the layer by the time a stop can be seen, so only what survives it is offered.", + "notify_template_soon": "Placeholders: {fields_finishing_soon}", + "notify_template_error": "Placeholders: {fields_error}", + "notify_template_runout": "Placeholders: {fields_filament_runout}" + } + } } }, "power": { "title": "Power switch", - "description": "{power_help}", + "description": "Optional power switch entity ID (e.g. switch.smart_plug_name) to enable accurate 'Off' state detection.", "data": { "power_switch_enabled": "Enable Power Switch Detection", "power_switch": "Power Switch Entity" @@ -71,7 +172,8 @@ } }, "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." + "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." } }, "entity": { @@ -87,7 +189,6 @@ "position_z": { "name": "Position Z" }, "feedrate_pct": { "name": "Print Speed" }, "flowrate_pct": { "name": "Flow Rate" }, - "system": { "name": "System" }, "model_info": { "name": "Model" }, "filament_status": { "name": "Filament Status", @@ -113,6 +214,9 @@ } }, "used_material_length": { "name": "Used Material Length" }, + "expected_material_length": { "name": "Expected Material Length" }, + "expected_material_weight": { "name": "Expected Material Weight" }, + "filament_consumption": { "name": "Filament Consumption" }, "print_job_time": { "name": "Print Job Time" }, "print_left_time": { "name": "Print Time Left" }, "real_time_flow": { "name": "Real-Time Flow" }, @@ -150,10 +254,7 @@ "print_tuning_pct": { "name": "Print Tuning" }, "nozzle_target": { "name": "Nozzle Target" }, "bed_target": { "name": "Bed Target" }, - "chamber_target": { "name": "Chamber Target" }, - "model_fan_pct": { "name": "Model Fan" }, - "case_fan_pct": { "name": "Case Fan" }, - "side_fan_pct": { "name": "Side Fan" } + "chamber_target": { "name": "Chamber Target" } }, "fan": { "model_fan": { "name": "Model Fan" }, @@ -163,9 +264,6 @@ "light": { "light": { "name": "Light" } }, - "switch": { - "light": { "name": "Light" } - }, "camera": { "printer_camera": { "name": "Printer Camera" } }, @@ -193,6 +291,56 @@ "description": "The printer(s) to request info from. If omitted, all connected printers will be queried." } } + }, + "set_cfs_material": { + "name": "Set CFS Material", + "description": "Write filament settings to one CFS slot. Only the fields you supply are changed; leave a field out to keep the value the printer already has. Material type is required and cannot be blank, and a color, if you give one, must be six hex digits.", + "fields": { + "device_id": { + "name": "Device", + "description": "The printer(s) to write to." + }, + "box_id": { + "name": "Box ID", + "description": "The box id as the printer reports it. See the box_id attribute on the slot's sensor; it is usually 1 for the first CFS unit." + }, + "slot_id": { + "name": "Slot ID", + "description": "Slot number within the box, starting at 0." + }, + "type": { + "name": "Material Type", + "description": "Filament type, for example PLA, PETG, ABS or TPU." + }, + "name": { + "name": "Material Name", + "description": "Product name of the filament, for example Hyper PLA." + }, + "vendor": { + "name": "Vendor", + "description": "Manufacturer or brand name." + }, + "color": { + "name": "Color", + "description": "Color as six hex digits, for example #06c84f. For a multi-color spool, leave this field out rather than sending an empty value; the other fields still write." + }, + "min_temp": { + "name": "Minimum Temperature", + "description": "Lowest printing temperature, in °C." + }, + "max_temp": { + "name": "Maximum Temperature", + "description": "Highest printing temperature, in °C. Must not be below the minimum." + }, + "pressure": { + "name": "Pressure Advance", + "description": "Pressure advance value, between 0 and 1." + }, + "rfid": { + "name": "RFID", + "description": "The printer's material id. Leaving it empty omits the field, so the existing tag association is preserved." + } + } } } } diff --git a/custom_components/ha_creality_ws/translations/es.json b/custom_components/ha_creality_ws/translations/es.json index f60c84d4..aa4780d2 100644 --- a/custom_components/ha_creality_ws/translations/es.json +++ b/custom_components/ha_creality_ws/translations/es.json @@ -21,42 +21,143 @@ "not_K": "No es una impresora de la serie K" } }, + "common": { + "title_fallback": "Impresora Creality", + "channel_live": "Impresión 3D", + "channel_finished": "Impresión 3D finalizada", + "channel_alerts": "Alertas de impresión 3D", + "channel_soon": "Impresión 3D a punto de finalizar", + "action_pause": "Pausar", + "action_resume": "Reanudar", + "action_stop": "Detener", + "action_dismiss": "Ocultar", + "status_starting": "Iniciando", + "status_paused": "En pausa", + "status_finishing": "Finalizando", + "body_progress": "{progress}%", + "body_paused": "En pausa al {progress}%", + "body_paused_unknown": "En pausa", + "body_layer": "{layer}/{total_layers}", + "body_time_left": "{duration}", + "body_fallback": "Imprimiendo", + "duration_hours_minutes": "{hours} h {minutes} min", + "duration_minutes": "{minutes} min", + "duration_seconds": "{seconds} s", + "filament_length": "{metres} m", + "completed": "{device} ha terminado.", + "stopped": "{device} se ha detenido al {progress}%", + "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." + }, + "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." + } + }, + "selector": { + "camera_mode": { + "options": { + "auto": "Auto (detectar por modelo)", + "mjpeg": "MJPEG (familia K1)", + "webrtc": "WebRTC a través de go2rtc (familia K2)", + "webrtc_direct": "WebRTC directo, sin go2rtc (alternativa)", + "custom": "URL de cámara personalizada (MJPEG / RTSP)" + } + } + }, "options": { "step": { "init": { "title": "Configuración de la impresora", - "description": "Elige un grupo de ajustes para editar. Cada grupo se abre en su propia página, por lo que sus opciones siempre se cargan actualizadas.", + "description": "Elige un grupo de ajustes para editar. Cada grupo se abre en su propia página, y al enviar esa página se guarda y se aplica de inmediato. No hay nada que confirmar después: cierra este diálogo cuando hayas terminado.", "menu_options": { "camera": "Cámara", "notifications": "Notificaciones", "power": "Interruptor de encendido", - "connection": "Conexión y rendimiento", - "save": "Guardar y aplicar" + "connection": "Conexión y rendimiento" } }, "camera": { "title": "Cámara", - "description": "{camera_help}", + "description": "Modo de transmisión de la cámara. «Auto» lo detecta a partir del modelo de impresora. WebRTC a través de go2rtc es la opción predeterminada en las impresoras de la familia K2; WebRTC directo es una alternativa que hace la señalización contra la propia impresora (sin go2rtc): pruébala si la predeterminada no funciona, por ejemplo en firmware reciente de la K1C. El modo personalizado permite apuntar a cualquier URL http(s) MJPEG/snapshot o a un flujo rtsp:// (servido a través de go2rtc). Deja el puerto RTSP de go2rtc en 0 salvo que HLS o camera.record necesiten un puerto distinto del predeterminado. Al enviar el formulario se guarda esta página y vuelves al menú.", "data": { "camera_mode": "Modo de transmisión de la cámara", "go2rtc_url": "Servidor o dirección externa de go2rtc (si es necesario)", "go2rtc_port": "Puerto externo de go2rtc", + "go2rtc_rtsp_port": "Puerto RTSP de go2rtc (0 = auto: 18554 integrado, 8554 externo)", "custom_camera_url": "URL de cámara personalizada (MJPEG/snapshot http(s) o rtsp://)" } }, "notifications": { "title": "Notificaciones", + "description": "Elige a dónde van las notificaciones y, después, de qué quieres que te avisen. Al enviar el formulario se guarda esta página y se aplica de inmediato.", "data": { - "notify_device": "Dispositivo de notificación", - "notify_completed": "Notificar al finalizar", - "notify_error": "Notificar en caso de error", - "notify_minutes_to_end": "Notificar antes de finalizar", - "minutes_to_end_value": "Minutos antes de finalizar" + "notify_targets": "Destinos de notificación" + }, + "data_description": { + "notify_targets": "Elige uno o varios. Solo los destinos de la aplicación complementaria de Home Assistant pueden mostrar la tarjeta de impresión en vivo, la vista previa del G-code o los botones; el resto de plataformas de notificación reciben solo el título y el mensaje." + }, + "sections": { + "events": { + "name": "De qué quiero que me avisen", + "description": "La tarjeta de impresión en vivo requiere Home Assistant 2026.7 o posterior, además de iOS 17.2+ o Android 16+; en impresiones muy largas, iOS la cierra al cabo de 8 horas.", + "data": { + "notify_live": "Tarjeta de impresión en vivo", + "notify_completed": "Cuando termina una impresión", + "notify_error": "Errores y filamento agotado", + "notify_minutes_to_end": "Poco antes de que termine una impresión", + "minutes_to_end_value": "Con cuánta antelación" + }, + "data_description": { + "notify_live": "Una sola notificación para toda la impresión que se actualiza en su sitio, con una barra de progreso y una cuenta atrás que avanza en el teléfono.", + "notify_completed": "Cubre los dos finales: una impresión que ha terminado y otra que se ha detenido, ya sea desde la impresora, la aplicación de Creality o Home Assistant.", + "notify_error": "Un error de la impresora o una bobina que se ha agotado.", + "notify_minutes_to_end": "Un aviso para ir a ver las últimas capas, por separado para que la tarjeta en vivo no pueda sustituirlo." + } + }, + "extras": { + "name": "Botones, imágenes y pulsaciones", + "description": "Todo esto necesita un destino de la aplicación complementaria y el resto de plataformas de notificación lo ignoran.", + "data": { + "notify_actions": "Botones Pausar, Reanudar y Detener", + "notify_preview_image": "Vista previa del G-code como icono", + "notify_camera_snapshot": "Captura de la cámara al terminar la impresión", + "notify_tap_path": "Ruta del panel que se abre al pulsar" + }, + "data_description": { + "notify_actions": "«Detener» pide autenticación en el dispositivo, así que una pulsación accidental en la pantalla de bloqueo no puede acabar con una impresión larga.", + "notify_preview_image": "Se omite cuando la impresora no tiene ninguna vista previa que ofrecer, en lugar de enviar un recuadro gris vacío.", + "notify_camera_snapshot": "El aspecto real de la cama. Se omite en impresoras cuya cámara no puede tomar una imagen fija.", + "notify_tap_path": "P. ej. /lovelace/printer. Si se deja vacía, en Android la pulsación abre la cámara de la impresora y en iOS abre la aplicación; es la única forma de que en iOS la pulsación lleve a una vista concreta." + } + }, + "text": { + "name": "Texto personalizado", + "description": "Escribe tu propio texto, o deja un campo vacío para conservar el texto integrado y ya traducido. Un marcador de posición cuyo valor no haya comunicado la impresora no muestra nada, así que encierra entre corchetes todo lo que sea opcional: la parte entre corchetes desaparece por completo mientras su valor sea desconocido, como en {example}. Los corchetes en sí son la sintaxis y no se muestran; duplícalos ([[ y ]]) cuando quieras conservar un corchete literal. El título es siempre el nombre de la impresora: en Android el título de la tarjeta en vivo se fija al crearse la tarjeta y ya no puede cambiarse.", + "data": { + "notify_template_live": "Tarjeta de impresión en vivo", + "notify_template_completed": "Impresión terminada", + "notify_template_stopped": "Impresión detenida", + "notify_template_soon": "Poco antes del final", + "notify_template_error": "Error de la impresora", + "notify_template_runout": "Filamento agotado" + }, + "data_description": { + "notify_template_live": "Marcadores de posición: {fields_live}", + "notify_template_completed": "Marcadores de posición: {fields_completed}", + "notify_template_stopped": "Marcadores de posición: {fields_stopped}. Menos que en los demás: cuando se detecta una detención, la impresora ya ha reiniciado el progreso, el cronómetro del trabajo y la capa, así que solo se ofrece lo que sobrevive a ello.", + "notify_template_soon": "Marcadores de posición: {fields_finishing_soon}", + "notify_template_error": "Marcadores de posición: {fields_error}", + "notify_template_runout": "Marcadores de posición: {fields_filament_runout}" + } + } } }, "power": { "title": "Interruptor de encendido", - "description": "{power_help}", + "description": "ID de entidad opcional de un interruptor de encendido (p. ej. switch.smart_plug_name) para detectar con precisión el estado «Apagada».", "data": { "power_switch_enabled": "Activar la detección del interruptor de encendido", "power_switch": "Entidad del interruptor de encendido" @@ -71,7 +172,8 @@ } }, "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." + "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." } }, "entity": { @@ -87,7 +189,6 @@ "position_z": { "name": "Posición Z" }, "feedrate_pct": { "name": "Velocidad de impresión" }, "flowrate_pct": { "name": "Tasa de flujo" }, - "system": { "name": "Sistema" }, "model_info": { "name": "Modelo" }, "filament_status": { "name": "Estado del filamento", @@ -113,6 +214,9 @@ } }, "used_material_length": { "name": "Material usado" }, + "expected_material_length": { "name": "Material estimado" }, + "expected_material_weight": { "name": "Peso estimado del material" }, + "filament_consumption": { "name": "Consumo de filamento" }, "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" }, @@ -132,9 +236,9 @@ "active_filament_slot": { "name": "Ranura de filamento activa" }, "cfs_box_temp": { "name": "Temperatura de la caja CFS {box_id}" }, "cfs_box_humidity": { "name": "Humedad de la caja CFS {box_id}" }, - "cfs_slot_filament": { "name": "Filamento — caja CFS {box_id}, ranura {slot}" }, - "cfs_slot_color": { "name": "Color — caja CFS {box_id}, ranura {slot}" }, - "cfs_slot_percent": { "name": "Restante — caja CFS {box_id}, ranura {slot}" }, + "cfs_slot_filament": { "name": "Filamento (caja CFS {box_id}, ranura {slot})" }, + "cfs_slot_color": { "name": "Color (caja CFS {box_id}, ranura {slot})" }, + "cfs_slot_percent": { "name": "Restante (caja CFS {box_id}, ranura {slot})" }, "cfs_ext_filament": { "name": "Filamento externo CFS" }, "cfs_ext_color": { "name": "Color externo CFS" }, "cfs_ext_percent": { "name": "Restante externo CFS" } @@ -150,10 +254,7 @@ "print_tuning_pct": { "name": "Ajuste de impresión" }, "nozzle_target": { "name": "Objetivo de la boquilla" }, "bed_target": { "name": "Objetivo de la cama" }, - "chamber_target": { "name": "Objetivo de la cámara" }, - "model_fan_pct": { "name": "Ventilador del modelo" }, - "case_fan_pct": { "name": "Ventilador de la carcasa" }, - "side_fan_pct": { "name": "Ventilador lateral" } + "chamber_target": { "name": "Objetivo de la cámara" } }, "fan": { "model_fan": { "name": "Ventilador del modelo" }, @@ -163,9 +264,6 @@ "light": { "light": { "name": "Luz" } }, - "switch": { - "light": { "name": "Luz" } - }, "camera": { "printer_camera": { "name": "Cámara de la impresora" } }, @@ -193,6 +291,56 @@ "description": "La(s) impresora(s) a las que solicitar información. Si se omite, se consultarán todas las impresoras conectadas." } } + }, + "set_cfs_material": { + "name": "Establecer material CFS", + "description": "Escribe los ajustes de filamento en una ranura del CFS. Solo se cambian los campos que indiques; omite un campo para conservar el valor que ya tiene la impresora. El tipo de material es obligatorio y no puede ir vacío, y el color, si lo indicas, debe tener seis dígitos hexadecimales.", + "fields": { + "device_id": { + "name": "Dispositivo", + "description": "La(s) impresora(s) en las que escribir." + }, + "box_id": { + "name": "ID de caja", + "description": "El id de la caja tal como lo indica la impresora. Consulta el atributo box_id del sensor de la ranura; normalmente es 1 para la primera unidad CFS." + }, + "slot_id": { + "name": "ID de ranura", + "description": "Número de ranura dentro de la caja, empezando en 0." + }, + "type": { + "name": "Tipo de material", + "description": "Tipo de filamento, por ejemplo PLA, PETG, ABS o TPU." + }, + "name": { + "name": "Nombre del material", + "description": "Nombre comercial del filamento, por ejemplo Hyper PLA." + }, + "vendor": { + "name": "Fabricante", + "description": "Fabricante o marca." + }, + "color": { + "name": "Color", + "description": "Color en seis dígitos hexadecimales, por ejemplo #06c84f. Si la bobina es multicolor, omite este campo en lugar de enviarlo vacío; los demás campos sí se escriben." + }, + "min_temp": { + "name": "Temperatura mínima", + "description": "Temperatura mínima de impresión, en °C." + }, + "max_temp": { + "name": "Temperatura máxima", + "description": "Temperatura máxima de impresión, en °C. No puede ser inferior a la mínima." + }, + "pressure": { + "name": "Avance de presión", + "description": "Valor de avance de presión, entre 0 y 1." + }, + "rfid": { + "name": "RFID", + "description": "El id de material de la impresora. Si lo dejas vacío, el campo se omite y se conserva la asociación de etiqueta existente." + } + } } } } diff --git a/custom_components/ha_creality_ws/www/i18n/en.json b/custom_components/ha_creality_ws/www/i18n/en.json index fc0345a5..6a98a5e4 100644 --- a/custom_components/ha_creality_ws/www/i18n/en.json +++ b/custom_components/ha_creality_ws/www/i18n/en.json @@ -13,30 +13,51 @@ "editor_title": "Creality Printer Card Configuration", "tab_entities": "Entities", "tab_theme": "Theme", + "group_device": "Printer", + "group_entities": "Entities", "group_layout": "Layout & Icons", - "group_action_colors": "Action Colors", + "group_action_colors": "Action Button Colors", + "group_toggle_colors": "Toggle Button Colors", "group_status_area": "Status Area", "group_telemetry": "Telemetry", + "note_device": "Picking a device fills in the fields below that are still empty. Use the button to replace every field, including ones you set yourself. The power switch is never filled in, because the integration does not provide one.", + "note_clear_resets": "Use Reset to Defaults at the bottom to undo these.", + "note_auto_colors": "Turn Automatic off to choose a color yourself.", + "btn_refill_from_device": "Fill all fields from device", + "btn_reset": "Reset to Defaults", + "status_device_filled": "Filled {filled} of {total} fields.", + "status_device_empty": "No entities from this integration were found on that device.", "color_pause_bg": "Pause Button Background", "color_pause_icon": "Pause Button Icon", "color_resume_bg": "Resume Button Background", "color_resume_icon": "Resume Button Icon", "color_stop_bg": "Stop Button Background", "color_stop_icon": "Stop Button Icon", - "color_light_on_bg": "Light On Background", - "color_light_off_bg": "Light Off Background", + "color_light_on_bg": "Light Button Background (On)", + "color_light_off_bg": "Light Button Background (Off)", "color_light_icon_on": "Light Button Icon (On)", "color_light_icon_off": "Light Button Icon (Off)", - "color_custom_bg": "Custom Button Background", - "color_custom_icon": "Custom Button Icon", + "color_power_on_bg": "Power Button Background (On)", + "color_power_off_bg": "Power Button Background (Off)", + "color_power_icon_on": "Power Button Icon (On)", + "color_power_icon_off": "Power Button Icon (Off)", + "color_custom_bg": "Custom Button Background (On)", + "color_custom_icon": "Custom Button Icon (On)", + "color_custom_off_bg": "Custom Button Background (Off)", + "color_custom_icon_off": "Custom Button Icon (Off)", "color_status_icon": "Status Icon Color", "color_progress_ring": "Progress Ring Color", "color_status_bg": "Status Background", "color_telemetry_icon": "Telemetry Icon Color", "color_telemetry_text": "Telemetry Text Color", - "btn_save": "Save", - "btn_reset": "Reset to Defaults", - "color_picker_hint": "Click to open color picker", + "label_opacity": "Opacity", + "label_color_auto": "Automatic", + "helper_auto_status_icon": "Automatic: follows the print state, orange while paused, red on error, green when idle or finished.", + "helper_auto_progress_ring": "Automatic: follows the print state, matching the status icon.", + "helper_auto_status_bg": "Automatic: blends into the card background.", + "helper_auto_telemetry_icon": "Automatic: uses the theme's secondary text color.", + "helper_auto_telemetry_text": "Automatic: uses the theme's primary text color.", + "label_device": "Printer device", "label_name": "Printer Name", "label_camera": "Camera", "label_status": "Print Status Sensor", @@ -57,6 +78,13 @@ "label_custom_btn_icon": "Custom Button Icon", "label_custom_btn_hidden": "Hide Custom Button", "label_button_order": "Button Order (list)", + "label_hide_box_temp": "Hide Chamber Temperature", + "label_pause_btn_icon": "Pause Icon Override", + "label_resume_btn_icon": "Resume Icon Override", + "label_stop_btn_icon": "Stop Icon Override", + "label_light_btn_icon": "Light Icon Override", + "label_power_btn_icon": "Power Icon Override", + "helper_device": "Your printer, as set up by this integration", "helper_name": "Display name for the printer card", "helper_camera": "Camera entity for live video feed", "helper_status": "Sensor showing current print status", @@ -65,7 +93,7 @@ "helper_nozzle": "Sensor showing nozzle temperature", "helper_bed": "Sensor showing bed temperature", "helper_box": "Sensor showing chamber/enclosure temperature (optional)", - "helper_power": "Optional power switch entity for the printer (shows a Power button when set)", + "helper_power": "Your own smart plug for the printer, if you have one (shows a Power button when set). Not filled in from the device.", "helper_show_power_button": "Show the Power button when a power switch entity is configured", "helper_layer": "Sensor showing current print layer", "helper_total_layers": "Sensor showing total print layers", @@ -77,15 +105,7 @@ "helper_custom_btn_icon": "Icon for the custom button", "helper_custom_btn_hidden": "Hide the custom button", "helper_button_order": "List of buttons to show in order (pause, resume, stop, light, power, custom)", - "schema_button_order": "Button Order (pause, resume, stop, light, power, custom)", - "schema_hide_custom": "Hide Custom Button", - "schema_hide_box_temp": "Hide Chamber Temperature", - "schema_pause_icon": "Pause Icon Override", - "schema_resume_icon": "Resume Icon Override", - "schema_stop_icon": "Stop Icon Override", - "schema_light_icon": "Light Icon Override", - "schema_power_icon": "Power Icon Override", - "schema_custom_icon": "Custom Button Icon", + "helper_hide_box_temp": "Hide the chamber temperature pill even when a sensor is configured", "editor_error_title": "Editor Error", "editor_error_msg": "There was an error loading the visual editor. You can still edit your configuration using YAML.", "editor_error_prefix": "Error:" @@ -107,6 +127,43 @@ "schema_compact_view": "Compact View (Mini Mode)", "schema_show_type_in_mini": "Show Filament Type in Mini Mode", "tab_entities": "Entities", - "tab_theme": "Theme" + "tab_theme": "Theme", + "schema_view_mode": "Display Mode", + "view_mode_full": "Full", + "view_mode_compact": "Compact", + "toast_no_device": "Could not identify the printer for this card. Check the entities in the card configuration.", + "toast_multiple_devices": "This card mixes entities from more than one printer, so material editing is disabled.", + "toast_printer_busy": "Cannot edit material while the printer is busy", + "btn_edit": "Edit material", + "tooltip_edit_locked": "Editing is disabled while the printer is busy", + "tooltip_multicolour_readonly": "Multi-color spools cannot be edited", + "dialog_edit_title": "Edit material", + "dialog_edit_target": "Writing to box {box}, slot {slot}", + "label_material_type": "Material type", + "label_material_name": "Material name", + "label_material_vendor": "Vendor", + "label_material_color": "Color", + "label_material_min_temp": "Minimum temperature", + "label_material_max_temp": "Maximum temperature", + "label_material_pressure": "Pressure advance", + "btn_cancel": "Cancel", + "btn_save": "Save", + "btn_saving": "Saving…", + "toast_saved": "Material saved", + "toast_save_failed": "Could not save material: {error}", + "toast_temp_range_invalid": "Maximum temperature must not be below the minimum", + "toast_colour_invalid": "Color must be six hex digits, for example #06c84f", + "toast_type_required": "Material type is required", + "toast_external_not_supported": "This printer does not report a box id for the external spool, so it cannot be edited", + "warn_box_id_guessed": "The target box or slot was inferred from the card layout; check both match the printer before saving", + "label_color_presets": "Presets", + "label_preset_name": "Preset name", + "btn_save_preset": "Save preset", + "toast_preset_saved": "Preset “{name}” saved", + "toast_preset_deleted": "Preset “{name}” deleted", + "toast_preset_name_required": "Give the preset a name first", + "hint_delete_preset": "Dashed swatches are your own presets; right-click one to delete it.", + "view_mode_box": "Box (visual)", + "alt_cfs_box": "Creality CFS unit" } } diff --git a/custom_components/ha_creality_ws/www/i18n/es.json b/custom_components/ha_creality_ws/www/i18n/es.json index 0b885a3b..2df40271 100644 --- a/custom_components/ha_creality_ws/www/i18n/es.json +++ b/custom_components/ha_creality_ws/www/i18n/es.json @@ -10,33 +10,54 @@ "chip_light": "Luz", "chip_power": "Encendido", "chip_custom": "Acción personalizada", - "editor_title": "Configuración de la tarjeta Creality", + "editor_title": "Configuración de la tarjeta de impresora Creality", "tab_entities": "Entidades", "tab_theme": "Tema", + "group_device": "Impresora", + "group_entities": "Entidades", "group_layout": "Disposición e iconos", - "group_action_colors": "Colores de acción", + "group_action_colors": "Colores de los botones de acción", + "group_toggle_colors": "Colores de los botones de alternancia", "group_status_area": "Área de estado", "group_telemetry": "Telemetría", + "note_device": "Al elegir un dispositivo se rellenan los campos de abajo que aún están vacíos. Usa el botón para sustituir todos los campos, incluidos los que hayas establecido tú. El interruptor de encendido nunca se rellena, porque la integración no proporciona ninguno.", + "note_clear_resets": "Usa Restablecer valores predeterminados, al final del formulario, para deshacer estos cambios.", + "note_auto_colors": "Desactiva Automático para elegir tú mismo un color.", + "btn_refill_from_device": "Rellenar todos los campos desde el dispositivo", + "btn_reset": "Restablecer valores predeterminados", + "status_device_filled": "Se han rellenado {filled} de {total} campos.", + "status_device_empty": "No se han encontrado entidades de esta integración en ese dispositivo.", "color_pause_bg": "Fondo del botón Pausar", "color_pause_icon": "Icono del botón Pausar", "color_resume_bg": "Fondo del botón Reanudar", "color_resume_icon": "Icono del botón Reanudar", "color_stop_bg": "Fondo del botón Detener", "color_stop_icon": "Icono del botón Detener", - "color_light_on_bg": "Fondo de la luz encendida", - "color_light_off_bg": "Fondo de la luz apagada", - "color_light_icon_on": "Icono de la luz (encendida)", - "color_light_icon_off": "Icono de la luz (apagada)", - "color_custom_bg": "Fondo del botón personalizado", - "color_custom_icon": "Icono del botón personalizado", + "color_light_on_bg": "Fondo del botón de luz (encendida)", + "color_light_off_bg": "Fondo del botón de luz (apagada)", + "color_light_icon_on": "Icono del botón de luz (encendida)", + "color_light_icon_off": "Icono del botón de luz (apagada)", + "color_power_on_bg": "Fondo del botón de encendido (activado)", + "color_power_off_bg": "Fondo del botón de encendido (desactivado)", + "color_power_icon_on": "Icono del botón de encendido (activado)", + "color_power_icon_off": "Icono del botón de encendido (desactivado)", + "color_custom_bg": "Fondo del botón personalizado (activado)", + "color_custom_icon": "Icono del botón personalizado (activado)", + "color_custom_off_bg": "Fondo del botón personalizado (desactivado)", + "color_custom_icon_off": "Icono del botón personalizado (desactivado)", "color_status_icon": "Color del icono de estado", "color_progress_ring": "Color del anillo de progreso", - "color_status_bg": "Fondo de estado", + "color_status_bg": "Fondo del área de estado", "color_telemetry_icon": "Color del icono de telemetría", "color_telemetry_text": "Color del texto de telemetría", - "btn_save": "Guardar", - "btn_reset": "Restablecer valores predeterminados", - "color_picker_hint": "Haz clic para abrir el selector de color", + "label_opacity": "Opacidad", + "label_color_auto": "Automático", + "helper_auto_status_icon": "Automático: sigue el estado de impresión: naranja en pausa, rojo si hay error, verde en reposo o al terminar.", + "helper_auto_progress_ring": "Automático: sigue el estado de impresión, igual que el icono de estado.", + "helper_auto_status_bg": "Automático: se integra con el fondo de la tarjeta.", + "helper_auto_telemetry_icon": "Automático: usa el color de texto secundario del tema.", + "helper_auto_telemetry_text": "Automático: usa el color de texto principal del tema.", + "label_device": "Dispositivo de la impresora", "label_name": "Nombre de la impresora", "label_camera": "Cámara", "label_status": "Sensor de estado de impresión", @@ -57,6 +78,13 @@ "label_custom_btn_icon": "Icono del botón personalizado", "label_custom_btn_hidden": "Ocultar el botón personalizado", "label_button_order": "Orden de los botones (lista)", + "label_hide_box_temp": "Ocultar la temperatura de la cámara", + "label_pause_btn_icon": "Icono de pausa (sustitución)", + "label_resume_btn_icon": "Icono de reanudación (sustitución)", + "label_stop_btn_icon": "Icono de parada (sustitución)", + "label_light_btn_icon": "Icono de la luz (sustitución)", + "label_power_btn_icon": "Icono de encendido (sustitución)", + "helper_device": "Tu impresora, tal como la ha configurado esta integración", "helper_name": "Nombre de visualización de la tarjeta de la impresora", "helper_camera": "Entidad de cámara para vídeo en directo", "helper_status": "Sensor que muestra el estado de impresión actual", @@ -65,7 +93,7 @@ "helper_nozzle": "Sensor que muestra la temperatura de la boquilla", "helper_bed": "Sensor que muestra la temperatura de la cama", "helper_box": "Sensor que muestra la temperatura de la cámara/recinto (opcional)", - "helper_power": "Entidad del interruptor de encendido opcional (muestra un botón de encendido cuando se establece)", + "helper_power": "Tu propio enchufe inteligente para la impresora, si tienes uno (muestra un botón de encendido cuando se establece). No se rellena desde el dispositivo.", "helper_show_power_button": "Mostrar el botón de encendido cuando hay un interruptor configurado", "helper_layer": "Sensor que muestra la capa de impresión actual", "helper_total_layers": "Sensor que muestra el total de capas de impresión", @@ -77,15 +105,7 @@ "helper_custom_btn_icon": "Icono del botón personalizado", "helper_custom_btn_hidden": "Ocultar el botón personalizado", "helper_button_order": "Lista de botones en orden (pause, resume, stop, light, power, custom)", - "schema_button_order": "Orden de los botones (pause, resume, stop, light, power, custom)", - "schema_hide_custom": "Ocultar el botón personalizado", - "schema_hide_box_temp": "Ocultar la temperatura de la cámara", - "schema_pause_icon": "Icono de pausa (sustitución)", - "schema_resume_icon": "Icono de reanudación (sustitución)", - "schema_stop_icon": "Icono de parada (sustitución)", - "schema_light_icon": "Icono de la luz (sustitución)", - "schema_power_icon": "Icono de encendido (sustitución)", - "schema_custom_icon": "Icono del botón personalizado", + "helper_hide_box_temp": "Ocultar el indicador de temperatura de la cámara aunque haya un sensor configurado", "editor_error_title": "Error del editor", "editor_error_msg": "Se ha producido un error al cargar el editor visual. Aún puedes editar la configuración en YAML.", "editor_error_prefix": "Error:" @@ -101,12 +121,49 @@ "label_external_percent": "Porcentaje externo", "label_box_temp": "Temperatura de la caja {box}", "label_box_humidity": "Humedad de la caja {box}", - "label_slot_filament": "Filamento — caja {box}, ranura {slot}", - "label_slot_color": "Color — caja {box}, ranura {slot}", - "label_slot_percent": "Restante — caja {box}, ranura {slot}", + "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" + "tab_theme": "Tema", + "schema_view_mode": "Modo de visualización", + "view_mode_full": "Completo", + "view_mode_compact": "Compacto", + "toast_no_device": "No se pudo identificar la impresora de esta tarjeta. Revisa las entidades en la configuración de la tarjeta.", + "toast_multiple_devices": "Esta tarjeta mezcla entidades de más de una impresora, por lo que la edición de material está desactivada.", + "toast_printer_busy": "No se puede editar el material mientras la impresora está ocupada", + "btn_edit": "Editar material", + "tooltip_edit_locked": "La edición está desactivada mientras la impresora está ocupada", + "tooltip_multicolour_readonly": "Las bobinas multicolor no se pueden editar", + "dialog_edit_title": "Editar material", + "dialog_edit_target": "Escribiendo en la caja {box}, ranura {slot}", + "label_material_type": "Tipo de material", + "label_material_name": "Nombre del material", + "label_material_vendor": "Fabricante", + "label_material_color": "Color", + "label_material_min_temp": "Temperatura mínima", + "label_material_max_temp": "Temperatura máxima", + "label_material_pressure": "Avance de presión", + "btn_cancel": "Cancelar", + "btn_save": "Guardar", + "btn_saving": "Guardando…", + "toast_saved": "Material guardado", + "toast_save_failed": "No se pudo guardar el material: {error}", + "toast_temp_range_invalid": "La temperatura máxima no puede ser inferior a la mínima", + "toast_colour_invalid": "El color debe tener seis dígitos hexadecimales, por ejemplo #06c84f", + "toast_type_required": "El tipo de material es obligatorio", + "toast_external_not_supported": "Esta impresora no informa un id de caja para la bobina externa, por lo que no se puede editar", + "warn_box_id_guessed": "La caja o la ranura de destino se han inferido del diseño de la tarjeta: comprueba que ambas coinciden con la impresora antes de guardar", + "label_color_presets": "Preajustes", + "label_preset_name": "Nombre del preajuste", + "btn_save_preset": "Guardar preajuste", + "toast_preset_saved": "Preajuste «{name}» guardado", + "toast_preset_deleted": "Preajuste «{name}» eliminado", + "toast_preset_name_required": "Primero ponle un nombre al preajuste", + "hint_delete_preset": "Los círculos discontinuos son tus preajustes: haz clic derecho en uno para eliminarlo.", + "view_mode_box": "Caja (visual)", + "alt_cfs_box": "Unidad CFS de Creality" } } diff --git a/custom_components/ha_creality_ws/www/k_cfs_card.js b/custom_components/ha_creality_ws/www/k_cfs_card.js index 0a79168d..37badb54 100644 --- a/custom_components/ha_creality_ws/www/k_cfs_card.js +++ b/custom_components/ha_creality_ws/www/k_cfs_card.js @@ -4,7 +4,8 @@ const EDITOR_TAG = "k-cfs-card-editor"; const mdi = (name) => `mdi:${name}`; -const I18N_URL_BASE = "/ha_creality_ws/i18n/"; +const ASSET_URL_BASE = "/ha_creality_ws/"; +const I18N_URL_BASE = `${ASSET_URL_BASE}i18n/`; const _i18nData = {}; const _i18nPromises = {}; function _loadI18n(lang) { @@ -45,6 +46,177 @@ function _requestI18n(instance, hass, onLoaded) { Promise.all([_loadI18n("en"), lang !== "en" ? _loadI18n(lang) : null]).then(onLoaded); } +// Creality's standard filament colours. Deliberately untranslated: the names are +// part of the product's identity, and twelve colour words per language is poor +// value. They surface as tooltips only. +const CREALITY_STANDARD_COLOURS = { + Black: "#000000", + White: "#ffffff", + Grey: "#9e9e9e", + Red: "#e53935", + Orange: "#fb8c00", + Yellow: "#fdd835", + Green: "#06c84f", + Cyan: "#00acc1", + Blue: "#1e88e5", + Purple: "#8e24aa", + Pink: "#ec407a", + Lime: "#9ccc65", +}; + +const PRESETS_STORAGE_KEY = "k-cfs-colour-presets"; + +/** + * Escape a value for interpolation into markup. + * + * Filament names, colours and entity ids reach the renderers straight from + * printer telemetry, and the renderers build strings that are assigned to + * innerHTML. A `"` or `<` in any of them would otherwise close the attribute or + * element it sits in and inject markup into the dashboard. + * @param {*} value + * @returns {string} + */ +function esc(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * Whether a value is a hex colour: 3 or 6 digits, `#` optional. + * @param {*} value + * @returns {boolean} + */ +function isHexColour(value) { + return /^#?(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(String(value ?? "").trim()); +} + +/** + * Expand a hex colour to the six-digit form, or "" if it is not one. + * + * The service only accepts six digits, so a preset stored as `#abc` would be + * selectable and then rejected on save. + * @param {*} value + * @returns {string} + */ +function toSixDigitHex(value) { + if (!isHexColour(value)) return ""; + const hex = String(value).trim().replace(/^#/, "").toLowerCase(); + return hex.length === 3 ? `#${hex.replace(/./g, (c) => c + c)}` : `#${hex}`; +} + +/** + * User-defined colour presets, persisted in localStorage. + * + * Deliberately browser-local rather than stored in the card config: presets are + * a personal palette, and writing them into the dashboard would make every + * change a config edit visible to every user of that dashboard. + */ +class ColourPresetsManager { + constructor() { + this.presets = ColourPresetsManager._load(); + } + + static _load() { + try { + const stored = localStorage.getItem(PRESETS_STORAGE_KEY); + const parsed = stored ? JSON.parse(stored) : {}; + // Guard against a hand-edited or corrupted value. + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; + } catch (_) { + return {}; + } + } + + _persist() { + try { + localStorage.setItem(PRESETS_STORAGE_KEY, JSON.stringify(this.presets)); + } catch (_) { + // Private browsing or a full quota. The presets stay in memory for this + // session; losing them is not worth interrupting a save for. + } + } + + /** @returns {boolean} whether the preset was stored */ + save(name, colour) { + const key = String(name || "").trim(); + // Validate the input, not the output: _sanitizeColor returns #cccccc both + // for "unparseable" and for the real grey #cccccc, so comparing against it + // would refuse to store that colour. + const value = toSixDigitHex(colour); + if (!key || !value) return false; + this.presets[key] = value; + this._persist(); + 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]; + this._persist(); + return true; + } + + /** Standard colours first, then the user's own. @returns {Array<[string, string]>} */ + entries() { + return [ + ...Object.entries(CREALITY_STANDARD_COLOURS), + ...Object.entries(this.presets), + ]; + } + + isCustom(name) { + return name in this.presets; + } +} + +/** + * The one presets store for this page. + * + * Per-card instances each cached localStorage in their own constructor, so + * saving or deleting in one card's dialog left every other card on the dashboard + * serving a stale copy until reload. Created lazily: rendering a card must not + * touch storage, only opening a dialog does. + * @returns {ColourPresetsManager} + */ +let _sharedPresets = null; +function sharedPresets() { + if (!_sharedPresets) _sharedPresets = new ColourPresetsManager(); + return _sharedPresets; +} + +// The values that mean "no reading", in one place. Home Assistant renders an +// entity with no value as "unknown" and an unavailable one as "unavailable", and +// the integration writes "-" for an empty CFS slot. Trimmed before comparing, so +// a padded " unknown " is caught everywhere rather than only in the edit form -- +// each caller keeps its own fallback, since the display wants "-" and the form +// wants "". +const SENTINEL_VALUES = new Set(["unknown", "unavailable", "-"]); +function isSentinel(value) { + if (value === undefined || value === null) return true; + const text = String(value).trim(); + return text === "" || SENTINEL_VALUES.has(text.toLowerCase()); +} + +const BUSY_PRINT_STATES = new Set([ + "printing", + "paused", + "processing", + "self-testing", +]); + const CFS_TRANSLATIONS = { en: { no_data: "No CFS data available", @@ -61,7 +233,43 @@ const CFS_TRANSLATIONS = { 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)", + label_color_presets: "Presets", + label_preset_name: "Preset name", + btn_save_preset: "Save preset", + toast_preset_saved: "Preset “{name}” saved", + toast_preset_deleted: "Preset “{name}” deleted", + toast_preset_name_required: "Give the preset a name first", + hint_delete_preset: "Dashed swatches are your own presets; right-click one to delete it.", + btn_edit: "Edit material", + tooltip_edit_locked: "Editing is disabled while the printer is busy", + tooltip_multicolour_readonly: "Multi-color spools cannot be edited", + dialog_edit_title: "Edit material", + dialog_edit_target: "Writing to box {box}, slot {slot}", + label_material_type: "Material type", + label_material_name: "Material name", + label_material_vendor: "Vendor", + label_material_color: "Color", + label_material_min_temp: "Minimum temperature", + label_material_max_temp: "Maximum temperature", + label_material_pressure: "Pressure advance", + btn_cancel: "Cancel", + btn_save: "Save", + btn_saving: "Saving…", + toast_saved: "Material saved", + toast_save_failed: "Could not save material: {error}", + toast_temp_range_invalid: "Maximum temperature must not be below the minimum", + toast_colour_invalid: "Color must be six hex digits, for example #06c84f", + toast_type_required: "Material type is required", + toast_external_not_supported: "This printer does not report a box id for the external spool, so it cannot be edited", + warn_box_id_guessed: "The target box or slot was inferred from the card layout; check both match the printer before saving", + toast_no_device: "Could not identify the printer for this card. Check the entities in the card configuration.", + toast_multiple_devices: "This card mixes entities from more than one printer, so material editing is disabled.", + toast_printer_busy: "Cannot edit material while the printer is busy", + schema_view_mode: "Display Mode", + view_mode_box: "Box (visual)", + alt_cfs_box: "Creality CFS unit", + view_mode_full: "Full", + view_mode_compact: "Compact", schema_show_type_in_mini: "Show Filament Type in Mini Mode", // Editor tab/header tab_entities: "Entities", @@ -75,11 +283,16 @@ class KCFSCard extends HTMLElement { this._selectedCFS = 0; // Track selected CFS tab in normal mode } - static _sanitizeColor(value) { - const raw = String(value || "").trim(); - if (!raw || ["unknown", "unavailable", "—"].includes(raw.toLowerCase())) { - return "#cccccc"; - } + /** The colour the printer reported, or null when it reported none usable. + * + * Split out from `_sanitizeColor` so that "no colour" and "grey" stay + * distinguishable. The display grey is a placeholder, and the edit dialog + * 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. + */ + static _parseColor(value) { + if (isSentinel(value)) return null; + const raw = String(value).trim(); const hex = raw.startsWith("#") ? raw.slice(1) : raw; if (hex.length === 6 && /^[0-9a-fA-F]+$/.test(hex)) { return `#${hex.toLowerCase()}`; @@ -90,22 +303,104 @@ class KCFSCard extends HTMLElement { if (hex.length === 7 && hex.startsWith("0") && /^[0-9a-fA-F]+$/.test(hex)) { return `#${hex.slice(1).toLowerCase()}`; } - return "#cccccc"; + return null; + } + + static _sanitizeColor(value) { + return KCFSCard._parseColor(value) ?? "#cccccc"; + } + + /** + * Bring a stored config up to date. + * + * compact_view was a boolean; view_mode is "full" | "compact" | "box". The + * legacy key is translated once and then dropped, so the first visual edit + * writes clean YAML back. + * @param {object} config + * @returns {object} + */ + static _migrateConfig(config) { + const cfg = { ...(config || {}) }; + if (cfg.compact_view !== undefined && cfg.view_mode === undefined) { + cfg.view_mode = cfg.compact_view ? "compact" : "full"; + } + delete cfg.compact_view; + return cfg; + } + + /** Map a view mode to its ha-card class. "full" is historically "normal-mode". */ + static _modeClass(viewMode) { + if (viewMode === "compact") return "compact-mode"; + if (viewMode === "box") return "box-mode"; + return "normal-mode"; + } + + /** + * True when the printer reports several colours for one spool, e.g. + * "#0ffa800,#0ff97e1". _sanitizeColor renders those as grey, and a single + * colour cannot represent them, so editing has to leave the value alone + * rather than flatten it. + * @param {*} value raw colour as reported + * @returns {boolean} + */ + static _isMultiColour(value) { + return /[,;]/.test(String(value || "")); + } + + /** + * Work out which box and slot the *printer* calls this one. + * + * The card's box0..box3 config keys are card positions, not printer ids -- the + * sensors key off the id in materialBoxs[]. Three tiers, most reliable first: + * 1. the box_id/slot_id attributes the sensors publish; + * 2. the default entity id, which encodes them as cfs_box_N_slot_M_*; + * 3. card position + 1, which is only right when the pickers were filled in + * order from box 1 -- flagged as a guess so the UI can say so. + * @returns {{boxId: number, slotId: number, guessed: boolean}} + */ + static _resolvePrinterTarget(filamentObj, entityId, cardBoxIndex, cardSlotIndex) { + const attrs = filamentObj?.attributes; + if (typeof attrs?.box_id === "number" && typeof attrs?.slot_id === "number") { + return { boxId: attrs.box_id, slotId: attrs.slot_id, guessed: false }; + } + + const match = /cfs_box_(\d+)_slot_(\d+)_/.exec(String(entityId || "")); + // Home Assistant builds the entity id from the entity *name*, and the name + // counts slots from 1 for the reader ("CFS Box 1 Slot 2") while the printer + // counts them from 0. Passing the captured number straight through + // addressed the slot after the one on screen, so a save overwrote the + // neighbouring spool. Only the unique id is 0-based, and that never reaches + // the frontend. + // + // Guessed, unlike the attributes above: an entity id is a renameable + // display artifact, so the warning invites the user to check the target + // before writing to the printer. A `_slot_0_` id cannot have come from that + // naming scheme at all, so it is left to the card-position fallback rather + // than decremented into -1. + if (match && Number(match[2]) >= 1) { + return { boxId: Number(match[1]), slotId: Number(match[2]) - 1, guessed: true }; + } + + return { boxId: cardBoxIndex + 1, slotId: cardSlotIndex, guessed: true }; } static _parsePercent(percentObj) { if (!percentObj) return null; const state = percentObj.state; - if (state === undefined || state === null) return null; - const s = String(state); - if (s === "unknown" || s === "unavailable") return null; - const n = Number(s); + // "-" is a sentinel here too: Number("-") is NaN, so it was already + // rejected, but going through the shared helper keeps the set in one place. + if (isSentinel(state)) return null; + const n = Number(String(state).trim()); if (Number.isNaN(n) || !Number.isFinite(n)) return null; return Math.max(0, Math.min(100, n)); } + // Returns one of four literals, never telemetry -- but the consumers run the + // result through _sanitizeColor anyway. HTML escaping does not neutralise a + // semicolon, so any value interpolated into a `style` attribute would parse a + // second declaration; the whitelist is what makes that unreachable. static _getHumidityColor(humidityStr) { - if (!humidityStr || humidityStr === "—") return '#64b5f6'; // default blue + if (!humidityStr || humidityStr === "-") return '#64b5f6'; // default blue const match = String(humidityStr).match(/(\d+\.?\d*)/); if (!match) return '#64b5f6'; @@ -119,7 +414,7 @@ class KCFSCard extends HTMLElement { static getStubConfig() { const cfg = { name: "CFS", - compact_view: false, + view_mode: "full", show_type_in_mini: false, external_filament: "", external_color: "", @@ -144,10 +439,23 @@ class KCFSCard extends HTMLElement { } setConfig(config) { - this._cfg = { ...KCFSCard.getStubConfig(), ...config }; + this._cfg = { ...KCFSCard.getStubConfig(), ...KCFSCard._migrateConfig(config) }; if (!this._root) { this._root = this.attachShadow({ mode: "open" }); } + // _render() wipes #content, so the gate must not then decide nothing changed + // and skip repopulating it -- that would leave the card blank after an edit. + this._snapshot = null; + // Different entities may mean a different printer. Bumping the generation + // invalidates a _resolveDeviceId() that is already awaiting callWS: it + // captured the previous config's entity ids before the await, and writing + // its answer back would target the old printer for the rest of the session. + this._deviceId = undefined; + this._deviceIdError = null; + this._deviceIdGeneration = (this._deviceIdGeneration || 0) + 1; + this._deviceIdPending = false; + this._deviceIdRetried = false; + this._statusEid = null; this._render(); } @@ -162,14 +470,66 @@ class KCFSCard extends HTMLElement { set hass(hass) { this._hass = hass; - _requestI18n(this, hass, () => { this._update(); }); - this._update(); + // Translated strings are not part of the snapshot, so when they arrive the + // gate has to be reset and the render forced. + _requestI18n(this, hass, () => { this._snapshot = null; this._update(); }); + // HA does not guarantee hass.entities is populated on the first assignment. + // A resolution that failed against an empty registry caches null, and null + // is not undefined, so nothing would ever retry it. Reopen the question once + // the registry actually knows about one of our entities -- but only once. + // A card that legitimately cannot resolve (entities spanning two printers, + // or an entity with no device at all) also sits at null with a populated + // registry, and retrying it on every telemetry frame flipped the edit + // buttons between pencil and lock for the whole session. + if ( + this._deviceId === null + && !this._deviceIdPending + && !this._deviceIdRetried + && this._registryHasEntities() + ) { + this._deviceIdRetried = true; + this._deviceId = undefined; + this._deviceIdError = null; + } + // _isPrinterBusy() is a sync cache read, so the device id has to be resolved + // out of band once; re-render when it lands so the lock icon settles. + if (this._deviceId === undefined && !this._deviceIdPending) { + this._deviceIdPending = true; + // .catch as well as .then: _resolveDeviceId can reject (a malformed config, + // a hass assigned before setConfig), and an unhandled rejection left + // _deviceIdPending true forever, so both gates here refused to retry and + // the card could never resolve while still rendering its buttons enabled. + // Generation-guarded out here too, not only inside _resolveDeviceId. + // `hass` can be assigned before setConfig, so the first call rejects on + // the missing config; setConfig then runs synchronously, bumps the + // generation and clears _deviceId, and the queued rejection would + // otherwise land afterwards and pin the *new* config to + // "toast_no_device" with its edit buttons locked. Clearing the pending + // flag is gated for the mirror-image reason: a stale run clearing it + // would let a second resolution start alongside the current one. + // Safe to skip, because setConfig has already reset it. + const generation = this._deviceIdGeneration || 0; + const isCurrent = () => (this._deviceIdGeneration || 0) === generation; + this._resolveDeviceId() + .catch((err) => { + console.warn("k-cfs-card: device resolution failed", err); + if (!isCurrent()) return; + this._deviceIdError = "toast_no_device"; + this._deviceId = null; + }) + .finally(() => { + if (!isCurrent()) return; + this._deviceIdPending = false; + this._updateIfChanged(); + }); + } + this._updateIfChanged(); } _render() { if (!this._root) return; - const isCompact = this._cfg.compact_view; + const modeClass = KCFSCard._modeClass(this._cfg.view_mode); const style = ` /* inherit HA fonts & typography */ @@ -469,6 +829,7 @@ class KCFSCard extends HTMLElement { } .spool-mini-wrapper { + position: relative; display: flex; flex-direction: column; align-items: center; @@ -515,6 +876,7 @@ class KCFSCard extends HTMLElement { } .external-normal { + position: relative; background: rgba(var(--rgb-primary-text-color), 0.03); border-radius: 16px; padding: 12px 16px; @@ -566,6 +928,7 @@ class KCFSCard extends HTMLElement { } .external-compact { + position: relative; display: flex; align-items: center; gap: 10px; @@ -598,6 +961,269 @@ class KCFSCard extends HTMLElement { color: var(--secondary-text-color); } + /* === BOX VIEW === */ + .box-view { + position: relative; + width: 100%; + margin: 4px 0; + } + .box-image { + display: block; + width: 100%; + height: auto; + } + /* Four evenly spaced bays across the unit's glass section. The geometry is + tied to cfs_box.webp, which is why the mode is gated on a 4-slot box. */ + .bays { + position: absolute; + /* Measured against cfs_box.webp: the enclosure interior spans 7.5%-92.5% + horizontally with its dividers at 29/50/71%, so four equal columns + land one per bay. The height stops at the glass floor so a spool does + not spill onto the drawer below. */ + top: 7%; + left: 7.5%; + width: 85%; + height: 45%; + display: grid; + grid-template-columns: repeat(4, 1fr); + } + .bay { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + } + .bay + .bay { + /* Theme variable rather than a hardcoded white, which inverted badly in + light themes. */ + border-left: 1px solid var(--divider-color); + } + .bay-spool { + width: 50%; + aspect-ratio: 1; + border-radius: 50%; + background: var(--spool-color); + box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.25) inset; + opacity: 0.85; + } + .bay-label { + display: flex; + flex-direction: column; + align-items: center; + margin-top: 4px; + padding: 1px 4px; + border-radius: 4px; + /* Legible over a photo in either theme. */ + background: var(--card-background-color); + color: var(--primary-text-color); + font-size: 10px; + line-height: 1.2; + opacity: 0.92; + } + .bay-type { font-weight: 500; } + .bay-pct { color: var(--secondary-text-color); } + + /* === EDIT AFFORDANCE === */ + .edit-btn, .edit-btn-mini { + position: absolute; + top: 4px; + left: 4px; + z-index: 3; + display: grid; + place-items: center; + padding: 0; + border: none; + border-radius: 50%; + background: var(--secondary-background-color); + color: var(--primary-text-color); + cursor: pointer; + opacity: 1; + transition: opacity 120ms ease-in-out; + } + .edit-btn { width: 26px; height: 26px; } + .edit-btn-mini { width: 18px; height: 18px; top: -2px; left: -2px; } + .edit-btn ha-icon { --mdc-icon-size: 16px; } + .edit-btn-mini ha-icon { --mdc-icon-size: 12px; } + .edit-btn[aria-disabled="true"], .edit-btn-mini[aria-disabled="true"] { + cursor: not-allowed; + opacity: 0.45; + } + /* Reveal on hover only where hovering exists. A wall tablet is the primary + HA surface, and there the button has to be visible without one. */ + @media (hover: hover) { + /* The [aria-disabled] selectors are repeated here on purpose: they are + more specific than a bare .edit-btn, and a media query adds no + specificity, so without them a locked button stays visible while the + editable ones hide. */ + .edit-btn, .edit-btn-mini, + .edit-btn[aria-disabled="true"], .edit-btn-mini[aria-disabled="true"] { opacity: 0; } + .spool-card:hover .edit-btn, + .spool-mini-wrapper:hover .edit-btn-mini, + .bay:hover .edit-btn-mini, + .external-normal:hover .edit-btn, + .external-compact:hover .edit-btn { opacity: 1; } + } + .edit-btn:focus-visible, .edit-btn-mini:focus-visible { + opacity: 1; + outline: 2px solid var(--primary-color); + outline-offset: 1px; + } + + /* === EDIT DIALOG === */ + .edit-overlay { + position: fixed; + inset: 0; + z-index: 100; + display: grid; + place-items: center; + padding: 16px; + background: rgba(0, 0, 0, 0.55); + } + .edit-dialog { + width: min(420px, 100%); + max-height: 85vh; + overflow-y: auto; + padding: 16px; + border-radius: var(--ha-card-border-radius, 12px); + background: var(--card-background-color, var(--ha-card-background)); + color: var(--primary-text-color); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + } + .edit-dialog h2 { + margin: 0 0 4px; + font-size: 18px; + font-weight: 500; + } + .edit-target { + margin-bottom: 12px; + font-size: 12px; + color: var(--secondary-text-color); + } + .edit-warning { + margin-bottom: 12px; + padding: 8px 10px; + border-radius: 6px; + background: var(--warning-color, #ffa726); + color: #000; + font-size: 12px; + } + .colour-row { margin: 12px 0 4px; } + .colour-row > label { + display: block; + margin-bottom: 6px; + font-size: 13px; + color: var(--secondary-text-color); + } + .colour-inputs { display: flex; gap: 8px; align-items: center; } + .colour-inputs input[type="color"] { + width: 44px; + height: 34px; + padding: 0; + border: 1px solid var(--divider-color); + border-radius: 6px; + background: none; + cursor: pointer; + } + .colour-inputs input[type="text"] { + flex: 1; + min-width: 0; + padding: 8px; + border: 1px solid var(--divider-color); + border-radius: 6px; + background: var(--secondary-background-color); + color: var(--primary-text-color); + font-family: monospace; + } + .colour-inputs input:disabled { opacity: 0.5; cursor: not-allowed; } + .presets { margin-top: 12px; } + .presets > label { + display: block; + margin-bottom: 6px; + font-size: 13px; + color: var(--secondary-text-color); + } + .swatches { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 8px; + } + .swatch { + width: 24px; + height: 24px; + padding: 0; + border: 1px solid var(--divider-color); + border-radius: 50%; + cursor: pointer; + } + .swatch.custom { border-style: dashed; } + .swatch:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 1px; + } + .preset-save { display: flex; gap: 8px; } + .preset-save input { + flex: 1; + min-width: 0; + padding: 8px; + border: 1px solid var(--divider-color); + border-radius: 6px; + background: var(--secondary-background-color); + color: var(--primary-text-color); + } + .preset-hint { + margin-top: 6px; + font-size: 11px; + color: var(--secondary-text-color); + } + + .dialog-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 20px; + } + .dialog-btn { + padding: 8px 16px; + border: none; + border-radius: 6px; + font-size: 14px; + cursor: pointer; + } + .dialog-btn.secondary { + background: var(--secondary-background-color); + color: var(--primary-text-color); + } + .dialog-btn.primary { + background: var(--primary-color); + color: var(--text-primary-color, #fff); + } + .dialog-btn[disabled] { opacity: 0.6; cursor: default; } + + /* === TOAST === */ + .cfs-toast { + position: absolute; + left: 50%; + bottom: 12px; + transform: translateX(-50%); + /* Above .edit-overlay (100). Both _showToast and _showEditDialog append + to the same root, so at 10 the overlay's rgba backdrop painted over + every toast raised from the dialog: preset saved, preset deleted, + name required and invalid colour were all invisible. */ + z-index: 110; + max-width: calc(100% - 24px); + padding: 8px 14px; + border-radius: 6px; + background: var(--primary-color); + color: var(--text-primary-color, #fff); + font-size: 13px; + line-height: 1.3; + text-align: center; + box-shadow: var(--ha-card-box-shadow, 0 2px 6px rgba(0, 0, 0, 0.3)); + pointer-events: none; + } + .no-data { text-align: center; color: var(--secondary-text-color); @@ -606,27 +1232,29 @@ class KCFSCard extends HTMLElement { `; this._root.innerHTML = ` - +
`; } - _update() { - if (!this._root || !this._hass) return; - - const contentContainer = this._root.getElementById("content"); - if (!contentContainer) return; - + /** + * Read every configured entity into one render-ready snapshot. + * + * This is the ONLY place that reads hass.states for slots. The render gate and + * all three view modes consume what it returns, so there is exactly one copy of + * the collection logic to keep correct. + * @returns {{boxes: object[], external: object|null}} + */ + _collectData() { const states = this._hass.states || {}; const gObj = (eid) => (eid ? states?.[eid] : undefined); const fmtState = (st) => { - if (!st) return "—"; + if (!st) return "-"; const v = st.state; - if (v === undefined || v === null) return "—"; - const s = String(v); - if (s === "unknown" || s === "unavailable") return "—"; + if (isSentinel(v)) return "-"; + const s = String(v).trim(); if (this._hass && typeof this._hass.formatEntityState === "function") { try { return this._hass.formatEntityState(st); } catch (_) { } } @@ -666,20 +1294,43 @@ class KCFSCard extends HTMLElement { const type = filamentObj?.attributes?.type; const selected = filamentObj?.attributes?.selected; const rawColor = colorObj?.state || filamentObj?.attributes?.color_hex; - const color = KCFSCard._sanitizeColor(rawColor); + const parsedColor = KCFSCard._parseColor(rawColor); + const color = parsedColor ?? "#cccccc"; const percent = KCFSCard._parsePercent(percentObj); const percentText = fmtState(percentObj); + const entityId = filamentEid || colorEid || percentEid; + const target = KCFSCard._resolvePrinterTarget( + filamentObj, entityId, boxId, slotId, + ); slots[slotId] = { id: slotId, boxId, - entity_id: filamentEid || colorEid || percentEid, + entity_id: entityId, name, type, selected, color, + // Whether `color` above is the printer's or the display placeholder. + // Editing must not write the placeholder back as a real colour. + colorIsKnown: parsedColor !== null, percent, percentText, + // Editing needs the printer's own ids and the current values to + // prefill from; see KCFSCard._resolvePrinterTarget. + printerBoxId: target.boxId, + printerSlotId: target.slotId, + targetIsGuessed: target.guessed, + vendor: filamentObj?.attributes?.vendor, + // `name` above is the sensor *state*, i.e. the composed "vendor name" + // label. Editing has to write back the bare material name, or the + // vendor ends up duplicated inside it. + materialName: filamentObj?.attributes?.name, + rfid: filamentObj?.attributes?.rfid, + minTemp: filamentObj?.attributes?.min_temp, + maxTemp: filamentObj?.attributes?.max_temp, + pressure: filamentObj?.attributes?.pressure, + isMultiColour: KCFSCard._isMultiColour(rawColor), }; } @@ -711,7 +1362,8 @@ class KCFSCard extends HTMLElement { const type = filamentObj?.attributes?.type; const selected = filamentObj?.attributes?.selected; const rawColor = colorObj?.state || filamentObj?.attributes?.color_hex; - const color = KCFSCard._sanitizeColor(rawColor); + const parsedColor = KCFSCard._parseColor(rawColor); + const color = parsedColor ?? "#cccccc"; const percent = KCFSCard._parsePercent(percentObj); const percentText = fmtState(percentObj); @@ -723,20 +1375,96 @@ class KCFSCard extends HTMLElement { type, selected, color, + // Same as a box slot: the edit dialog must not offer the display grey + // as though the printer had reported it. Omitting this left the + // external spool's colour field always empty. + colorIsKnown: parsedColor !== null, percent, percentText, + // The external box's id comes from the printer via the sensor; there is + // no card position to fall back on, so leave it null when unknown rather + // than guessing 0. + printerBoxId: filamentObj?.attributes?.box_id ?? null, + printerSlotId: filamentObj?.attributes?.slot_id ?? 0, + // Both ids, not just the box: `slot_id` falls back to 0 above, so a + // spool reporting a box but no slot would have been written to slot 0 + // with nothing on screen saying the slot had been assumed. + // + // Typed, not `=== undefined`: `sensor.py` publishes + // `data.get("id", self._slot_id)`, which is `None` when the printer + // reports `"id": null` -- and that arrives here as `null`, which `??` + // swallows into 0 while an `undefined` test says the id was known. + // The box-slot path already tests `typeof === "number"`. + targetIsGuessed: + typeof filamentObj?.attributes?.box_id !== "number" + || typeof filamentObj?.attributes?.slot_id !== "number", + vendor: filamentObj?.attributes?.vendor, + materialName: filamentObj?.attributes?.name, + rfid: filamentObj?.attributes?.rfid, + minTemp: filamentObj?.attributes?.min_temp, + maxTemp: filamentObj?.attributes?.max_temp, + pressure: filamentObj?.attributes?.pressure, + isMultiColour: KCFSCard._isMultiColour(rawColor), }; } - const boxValues = Object.values(boxes); - if (boxValues.length === 0 && !hasExternal) { - contentContainer.innerHTML = `
${this._t("no_data")}
`; + return { boxes: Object.values(boxes), external: externalData }; + } + + /** + * Cheap change detection over everything the renderers read. + * + * The snapshot is a fixed-shape tree of scalars built from object literals, so + * key order is deterministic and stringifying it is both correct and much + * cheaper than walking it recursively. Anything a renderer reads that is NOT + * in `data` has to be included here too, or the view goes stale. + * @param {object} data + * @returns {string} + */ + _fingerprint(data) { + return JSON.stringify([ + this._cfg.view_mode, + this._cfg.show_type_in_mini, + this._isPrinterBusy(), + // _renderEditButton reads this to pick the lock icon, the tooltip and the + // disabled attribute. Device resolution finishes asynchronously, so + // without it here the .then() below found an unchanged fingerprint and the + // buttons stayed enabled against a card that cannot resolve its printer. + this._deviceIdError, + this._selectedCFS, + data, + ]); + } + + /** Re-render only when something a renderer would show has actually changed. */ + _updateIfChanged() { + if (!this._root || !this._hass) return; + const data = this._collectData(); + const fingerprint = this._fingerprint(data); + if (fingerprint === this._snapshot) return; + this._snapshot = fingerprint; + this._update(data); + } + + _update(data) { + if (!this._root || !this._hass) return; + + const contentContainer = this._root.getElementById("content"); + if (!contentContainer) return; + + // Callers that bypass the gate (an i18n load, the unit selector) pass nothing. + const { boxes: boxValues, external: externalData } = data || this._collectData(); + + if (boxValues.length === 0 && !externalData) { + contentContainer.innerHTML = `
${esc(this._t("no_data"))}
`; return; } // Render based on mode - if (this._cfg.compact_view) { + if (this._cfg.view_mode === "compact") { contentContainer.innerHTML = this._renderCompactMode(boxValues, externalData); + } else if (this._cfg.view_mode === "box") { + contentContainer.innerHTML = this._renderBoxMode(boxValues, externalData); } else { contentContainer.innerHTML = this._renderNormalMode(boxValues, externalData); } @@ -744,10 +1472,75 @@ class KCFSCard extends HTMLElement { this._attachEventHandlers(); } + /** + * Photo-realistic view: the CFS unit with a spool overlay per bay. + * + * Only offered for a four-bay unit, because the overlay geometry is tied to + * this particular image. Anything else falls back to the full view rather than + * drawing spools in the wrong places. + * @param {object[]} boxes + * @param {object|null} external + * @returns {string} + */ + _renderBoxMode(boxes, external) { + const box = boxes[this._selectedCFS] || boxes[0]; + const slots = box?.slots || []; + const filled = slots.filter(Boolean); + + if (filled.length !== 4) { + return this._renderNormalMode(boxes, external); + } + + const unitSelector = boxes.length > 1 + ? ` +
+ ${boxes.map((b, idx) => ` + + `).join('')} +
+ ` + : ''; + + const bays = slots.map((slot) => { + if (!slot) return '
'; + const safeType = !isSentinel(slot.type) ? String(slot.type).trim() : "-"; + const hasFilament = safeType !== "-"; + const pct = hasFilament && slot.percent !== null ? Math.round(slot.percent) : 0; + return ` +
+ ${this._renderEditButton(slot, true)} +
+
+ ${esc(safeType)} + ${pct}% +
+
+ `; + }).join(''); + + const env = []; + if (box?.temp && box.temp !== "-") env.push(`${esc(box.temp)}`); + if (box?.humidity && box.humidity !== "-") { + env.push(`${esc(box.humidity)}`); + } + + return ` + ${unitSelector} +
+ ${esc(this._t( +
${bays}
+
+ ${env.length ? `
${env.join(' • ')}
` : ''} + ${this._renderExternalCompact(external)} + `; + } + _renderNormalMode(boxes, external) { // Ensure we have at least one box if (boxes.length === 0 && !external) { - return `
${this._t("no_data")}
`; + return `
${esc(this._t("no_data"))}
`; } // Unit selector (only if we have multiple boxes) @@ -757,7 +1550,7 @@ class KCFSCard extends HTMLElement {
${boxes.map((box, idx) => ` `).join('')}
@@ -767,18 +1560,18 @@ class KCFSCard extends HTMLElement { // Get the selected box const selectedBox = boxes[this._selectedCFS] || boxes[0]; if (!selectedBox && !external) { - return `
${this._t("no_data")}
`; + return `
${esc(this._t("no_data"))}
`; } // Header with environment info let envInfo = ''; if (selectedBox) { - const tempStr = selectedBox.temp !== "—" ? selectedBox.temp : ''; - const humStr = selectedBox.humidity !== "—" ? selectedBox.humidity : ''; + const tempStr = selectedBox.temp !== "-" ? selectedBox.temp : ''; + const humStr = selectedBox.humidity !== "-" ? selectedBox.humidity : ''; if (tempStr || humStr) { - const tempHtml = tempStr ? `${tempStr}` : ''; - const humHtml = humStr ? `${humStr}` : ''; + const tempHtml = tempStr ? `${esc(tempStr)}` : ''; + const humHtml = humStr ? `${esc(humStr)}` : ''; const separator = tempStr && humStr ? ' • ' : ''; envInfo = `
${tempHtml}${separator}${humHtml}
`; } @@ -787,7 +1580,7 @@ class KCFSCard extends HTMLElement { const header = `
-
${this._cfg.name || 'Creality CFS'}
+
${esc(this._cfg.name || 'Creality CFS')}
${envInfo}
@@ -806,23 +1599,24 @@ class KCFSCard extends HTMLElement { // External section let externalSection = ''; if (external) { - const safeType = external.type && !["unknown", "unavailable", "—", "-"].includes(String(external.type).toLowerCase()) ? external.type : "—"; - const safeName = external.name && !["unknown", "unavailable", "—", "-"].includes(String(external.name).toLowerCase()) ? external.name : "—"; - const hasFilament = safeType !== "—" && safeName !== "—"; + const safeType = !isSentinel(external.type) ? String(external.type).trim() : "-"; + const safeName = !isSentinel(external.name) ? String(external.name).trim() : "-"; + const hasFilament = safeType !== "-" && safeName !== "-"; const pct = hasFilament && external.percent !== null ? external.percent : 0; - const percentTextDisplay = hasFilament ? (external.percentText || '—') : '—'; - const displayName = hasFilament ? `${safeName} ${safeType}` : '—'; + const percentTextDisplay = hasFilament ? (external.percentText || '-') : '-'; + const displayName = hasFilament ? `${safeName} ${safeType}` : '-'; externalSection = `
-
-
${this._t("ext_label")}
+
+ ${this._renderEditButton(external)} +
${esc(this._t("ext_label"))}
-
${displayName}
+
${esc(displayName)}
-
+
-
${percentTextDisplay}
+
${esc(percentTextDisplay)}
`; @@ -833,7 +1627,7 @@ class KCFSCard extends HTMLElement { _renderCompactMode(boxes, external) { if (boxes.length === 0 && !external) { - return `
${this._t("no_data")}
`; + return `
${esc(this._t("no_data"))}
`; } // CFS rows @@ -846,47 +1640,56 @@ class KCFSCard extends HTMLElement { `; } - // External section - let externalSection = ''; - if (external) { - const safeType = external.type && !["unknown", "unavailable", "—", "-"].includes(String(external.type).toLowerCase()) ? external.type : "—"; - const safeName = external.name && !["unknown", "unavailable", "—", "-"].includes(String(external.name).toLowerCase()) ? external.name : "—"; - const hasFilament = safeType !== "—" && safeName !== "—"; - const percentTextDisplay = hasFilament ? (external.percentText || '—') : '—'; - const displayName = hasFilament ? `${safeName} ${safeType}` : '—'; - externalSection = ` -
-
-
${this._t("ext_label")}
-
-
${displayName}
-
${percentTextDisplay}
-
+ return `${cfsRows}${this._renderExternalCompact(external)}`; + } + + /** + * The external spool as a compact row, or "" when none is configured. + * + * Shared by compact and box mode: box mode used to return the box view alone, + * so a configured external spool -- and its edit button -- vanished from the + * card whenever the selected unit had four populated slots. + * @param {object|null} external + * @returns {string} + */ + _renderExternalCompact(external) { + if (!external) return ''; + const safeType = !isSentinel(external.type) ? String(external.type).trim() : "-"; + const safeName = !isSentinel(external.name) ? String(external.name).trim() : "-"; + const hasFilament = safeType !== "-" && safeName !== "-"; + const percentTextDisplay = hasFilament ? (external.percentText || '-') : '-'; + const displayName = hasFilament ? `${safeName} ${safeType}` : '-'; + return ` +
+
+ ${this._renderEditButton(external)} +
${esc(this._t("ext_label"))}
+
+
${esc(displayName)}
+
${esc(percentTextDisplay)}
- `; - } - - return `${cfsRows}${externalSection}`; +
+ `; } _renderCFSRow(box) { - const tempStr = box.temp !== "—" ? box.temp : ''; - const humStr = box.humidity !== "—" ? box.humidity : ''; + const tempStr = box.temp !== "-" ? box.temp : ''; + const humStr = box.humidity !== "-" ? box.humidity : ''; let envHtml = ''; if (tempStr || humStr) { envHtml = `
- ${tempStr ? `
${tempStr}
` : ''} - ${humStr ? `
${humStr}
` : ''} + ${tempStr ? `
${esc(tempStr)}
` : ''} + ${humStr ? `
${esc(humStr)}
` : ''}
`; } return `
-
${this._t("cfs_number_label", { number: box.id + 1 })}
+
${esc(this._t("cfs_number_label", { number: box.id + 1 }))}
${box.slots.map((slot) => this._renderSpoolMini(slot)).join('')}
@@ -895,6 +1698,41 @@ class KCFSCard extends HTMLElement { `; } + /** + * The edit button for a slot, or "" when the slot cannot be edited. + * + * Editing is refused for the external spool until the printer actually tells + * us its box id -- PR #75 sent a hardcoded 0, which was a guess. + * @param {object} slot from _collectData + * @param {boolean} mini smaller variant for the compact rows + * @returns {string} + */ + _renderEditButton(slot, mini = false) { + if (!slot || !slot.entity_id) return ""; + + const busy = this._isPrinterBusy(); + const unaddressable = slot.printerBoxId === null || slot.printerBoxId === undefined; + const disabled = busy || unaddressable || Boolean(this._deviceIdError); + const title = busy + ? this._t("tooltip_edit_locked") + : unaddressable + ? this._t("toast_external_not_supported") + : this._deviceIdError + ? this._t(this._deviceIdError) + : this._t("btn_edit"); + + return ` + + `; + } + _renderSpoolCard(slot) { if (!slot) { return `
`; @@ -902,29 +1740,30 @@ class KCFSCard extends HTMLElement { const isActive = slot.selected === 1 || slot.selected === true; const color = slot.color || '#cccccc'; - const safeType = slot.type && !["unknown", "unavailable", "—", "-"].includes(String(slot.type).toLowerCase()) ? slot.type : "—"; - const safeName = slot.name && !["unknown", "unavailable", "—", "-"].includes(String(slot.name).toLowerCase()) ? slot.name : "—"; + const safeType = !isSentinel(slot.type) ? String(slot.type).trim() : "-"; + const safeName = !isSentinel(slot.name) ? String(slot.name).trim() : "-"; - // If no filament (type is "—" or name is "—"), show 0% regardless of actual value - const hasFilament = safeType !== "—" && safeName !== "—"; + // If no filament (type is "-" or name is "-"), show 0% regardless of actual value + const hasFilament = safeType !== "-" && safeName !== "-"; const pct = hasFilament && slot.percent !== null ? slot.percent : 0; const pctDisplay = hasFilament && slot.percent !== null ? Math.round(slot.percent) : 0; - const percentTextDisplay = hasFilament ? (slot.percentText || '—') : '—'; + const percentTextDisplay = hasFilament ? (slot.percentText || '-') : '-'; const badge = isActive ? '
' : ''; return ` -
+
${badge} + ${this._renderEditButton(slot)}
-
+
${pctDisplay}% - ${safeType} + ${esc(safeType)}
-
${safeName}
-
${percentTextDisplay}
+
${esc(safeName)}
+
${esc(percentTextDisplay)}
`; } @@ -934,18 +1773,18 @@ class KCFSCard extends HTMLElement { if (!slot) { if (showType) { - return `
—
—
`; + return `
-
-
`; } - return `
—
`; + return `
-
`; } const isActive = slot.selected === 1 || slot.selected === true; const color = slot.color || '#cccccc'; - const safeType = slot.type && !["unknown", "unavailable", "—", "-"].includes(String(slot.type).toLowerCase()) ? slot.type : "—"; - const safeName = slot.name && !["unknown", "unavailable", "—", "-"].includes(String(slot.name).toLowerCase()) ? slot.name : null; + const safeType = !isSentinel(slot.type) ? String(slot.type).trim() : "-"; + const safeName = !isSentinel(slot.name) ? String(slot.name).trim() : null; - // If no filament (type is "—" or name is empty/dash), show 0% regardless of actual value - const hasFilament = safeType !== "—" && safeName !== null; + // If no filament (type is "-" or name is empty/dash), show 0% regardless of actual value + const hasFilament = safeType !== "-" && safeName !== null; const pct = hasFilament && slot.percent !== null ? slot.percent : 0; const pctDisplay = hasFilament && slot.percent !== null ? Math.round(slot.percent) : 0; @@ -953,24 +1792,615 @@ class KCFSCard extends HTMLElement { return `
+ style="--spool-color: ${esc(color)}; --spool-pct: ${Number(pct) || 0}%" + data-eid="${esc(slot.entity_id)}"> ${pctDisplay}
-
${safeType}
+
${esc(safeType)}
+ ${this._renderEditButton(slot, true)}
`; } return ` -
- ${pctDisplay} +
+
+ ${pctDisplay} +
+ ${this._renderEditButton(slot, true)}
`; } + /** + * The device id this card's entities belong to, or null. + * + * Fails closed on purpose. PR #75 fell back to "the first ha_creality_ws + * device in the registry", which in a two-printer setup wrote filament data to + * the wrong printer. A card whose entities span two printers is also refused + * 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. + * @returns {Promise} + */ + async _resolveDeviceId() { + if (this._deviceId !== undefined) return this._deviceId; + + const generation = this._deviceIdGeneration || 0; + const isStale = () => (this._deviceIdGeneration || 0) !== generation; + const entityIds = this._configuredEntityIds(); + const registry = this._hass?.entities || {}; + const devices = new Set(); + + const unresolved = []; + for (const eid of entityIds) { + const deviceId = registry[eid]?.device_id; + if (deviceId) devices.add(deviceId); + else unresolved.push(eid); + } + + // 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. + // + // Every unresolved entity is asked, not just enough to find one device. + // Accepting the first answer resolved a card spanning two printers to + // whichever replied first, so editing a slot on the other one wrote its box + // and slot ids to the wrong machine -- the exact failure the fail-closed + // behaviour below exists to prevent. + // + // Asked in parallel over the distinct ids: a fully configured card supplies + // up to 51, and serial round trips delayed every edit button by all of them. + let anyLookupFailed = false; + if (unresolved.length) { + const answers = await Promise.all([...new Set(unresolved)].map(async (eid) => { + try { + const entry = await this._hass.callWS({ + type: "config/entity_registry/get", + entity_id: eid, + }); + 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 + // 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 }; + } + })); + for (const answer of answers) { + if (answer.failed) anyLookupFailed = true; + else if (answer.deviceId) devices.add(answer.deviceId); + } + } + + // setConfig ran while we were awaiting callWS: this answer is for the + // previous config, so drop it rather than caching it. + if (isStale()) return this._deviceId ?? null; + + // Fail closed when an entity's device could not be determined at all: this + // card may well span two printers and we simply cannot see it. + if (devices.size !== 1 || anyLookupFailed) { + this._deviceIdError = devices.size > 1 ? "toast_multiple_devices" : "toast_no_device"; + this._deviceId = null; + return null; + } + + this._deviceIdError = null; + [this._deviceId] = [...devices]; + return this._deviceId; + } + + /** Whether hass.entities knows about any entity this card is configured with. */ + _registryHasEntities() { + const registry = this._hass?.entities; + if (!registry) return false; + return this._configuredEntityIds().some((eid) => registry[eid]); + } + + /** Every non-empty entity picker value in the card's config. */ + _configuredEntityIds() { + const ids = []; + const push = (value) => { if (value) ids.push(value); }; + + for (let box = 0; box < 4; box += 1) { + push(this._cfg[`box${box}_temp`]); + push(this._cfg[`box${box}_humidity`]); + for (let slot = 0; slot < 4; slot += 1) { + push(this._cfg[`box${box}_slot${slot}_filament`]); + push(this._cfg[`box${box}_slot${slot}_color`]); + push(this._cfg[`box${box}_slot${slot}_percent`]); + } + } + push(this._cfg.external_filament); + push(this._cfg.external_color); + push(this._cfg.external_percent); + return ids; + } + + /** + * The print-status entity for *this* card's printer. + * + * PR #75 scanned all of hass.states for any id containing "_print_status", so + * printer B printing locked printer A's card. PrintStatusSensor sets + * translation_key="print_status", which the registry exposes, making this both + * device-scoped and rename-proof. Memoised per device. + * @returns {string|null} + */ + _statusEntityId() { + const deviceId = this._deviceId; + if (!deviceId) return null; + if (this._statusEid?.deviceId === deviceId) return this._statusEid.entityId; + + const registry = this._hass?.entities || {}; + let found = null; + for (const eid in registry) { + const entry = registry[eid]; + if ( + entry.device_id === deviceId + && entry.platform === "ha_creality_ws" + && entry.translation_key === "print_status" + ) { + found = eid; + break; + } + } + // Only a hit is memoized: late discovery can add the print_status entity + // after this first lookup, and caching the miss would leave _isPrinterBusy() + // permanently false -- so the lock affordance would never appear. + if (found) this._statusEid = { deviceId, entityId: found }; + return found; + } + + /** + * Whether this card's printer is mid-job. + * + * An unresolvable state counts as not busy: the bug being fixed was false + * positives, and blocking edits whenever the registry is thin would make the + * feature unusable. The service re-checks server-side, so this is an + * affordance rather than the guarantee. + * @returns {boolean} + */ + _isPrinterBusy() { + const entityId = this._statusEntityId(); + if (!entityId) return false; + const state = this._hass?.states?.[entityId]?.state; + return state ? BUSY_PRINT_STATES.has(state) : false; + } + + /** Brief, non-blocking feedback. Replaces PR #75's alert() calls. */ + _showToast(message) { + if (!this._root) return; + + // Hold the reference rather than looking it back up by id -- one toast at a + // time, and no dependency on where in the tree it was attached. + if (this._toastEl?.remove) this._toastEl.remove(); + clearTimeout(this._toastTimer); + + const toast = document.createElement("div"); + toast.className = "cfs-toast"; + toast.textContent = message; + this._root.appendChild(toast); + this._toastEl = toast; + this._toastTimer = setTimeout(() => { + if (toast.remove) toast.remove(); + if (this._toastEl === toast) this._toastEl = null; + }, 4000); + } + + /** + * Find a collected slot by its filament entity id. + * @returns {object|null} + */ + _findSlot(entityId) { + const { boxes, external } = this._collectData(); + if (external?.entity_id === entityId) return external; + for (const box of boxes) { + for (const slot of box.slots) { + if (slot?.entity_id === entityId) return slot; + } + } + return null; + } + + /** + * Open the material editor for one slot. + * + * Rendered inside the shadow root, not document.body. PR #75 appended to + * document.body, which put the dialog outside the reach of the injected + * + this._root.innerHTML = ` +
-

${this._t("editor_title")}

+

-
${this._t("tab_entities")}
-
${this._t("tab_theme")}
+
+
- -
- -
- -
-
-
-
-
${this._t("group_layout")}
- -
-
-
${this._t("group_action_colors")}
-
-
-
${this._t("color_pause_bg")}
-
-
-
-
- - -
-
-
-
⏸
-
${this._t("color_pause_icon")}
-
-
-
-
- - -
-
-
-
-
${this._t("color_resume_bg")}
-
-
-
-
- - -
-
-
-
▶
-
${this._t("color_resume_icon")}
-
-
-
-
- - -
-
-
-
-
${this._t("color_stop_bg")}
-
-
-
-
- - -
-
-
-
⏹
-
${this._t("color_stop_icon")}
-
-
-
-
- - -
-
-
-
-
${this._t("color_light_on_bg")}
-
-
-
-
- - -
-
-
-
-
${this._t("color_light_off_bg")}
-
-
-
-
- - -
-
-
-
💡
-
${this._t("color_light_icon_on")}
-
-
-
-
- - -
-
-
-
💡
-
${this._t("color_light_icon_off")}
-
-
-
-
- - -
-
-
-
-
${this._t("color_custom_bg")}
-
-
-
-
- - -
-
-
-
★
-
${this._t("color_custom_icon")}
-
-
-
-
- - -
-
-
- -
-
${this._t("group_status_area")}
-
-
🖨
-
${this._t("color_status_icon")}
-
-
-
-
- - -
-
-
-
⭕
-
${this._t("color_progress_ring")}
-
-
-
-
- - -
-
-
-
🎯
-
${this._t("color_status_bg")}
-
-
-
-
- - -
-
-
- -
-
${this._t("group_telemetry")}
-
-
🌡
-
${this._t("color_telemetry_icon")}
-
-
-
-
- - -
-
-
-
📝
-
${this._t("color_telemetry_text")}
-
-
-
-
- - -
-
-
- - +
+
+
+
+ +
+ +
+
+
+ +
-
- `; - this._setupTabs(); - this._setupEntitiesForm(); - this._setupThemeEditor(); - - } catch (error) { - console.error('Error rendering K-Printer Card Editor:', error); - this._root.innerHTML = ` -
-

Editor Error

-

${this._t("editor_error_msg")}

-

${this._t("editor_error_prefix")} ${error.message}

+
+
+
+ +
+ ${colorGroups} +
- `; +
`; + + this._formEls = {}; + this._appliedSchema = {}; + this._appliedData = {}; + + this._bindForm("device-form", (value) => this._onDeviceChanged(value)); + this._bindForm("entities-form", (value) => this._onEntitiesChanged(value)); + this._bindForm("layout-form", (value) => this._onLayoutChanged(value)); + THEME_COLOR_GROUPS.forEach((group, index) => { + this._bindForm(`color-form-${index}`, (value) => this._onColorChanged(group, value)); + }); + + for (const tab of this._root.querySelectorAll(".tab")) { + tab.addEventListener("click", () => this._selectTab(tab.dataset.tab)); + } + this._root.getElementById("refill") + .addEventListener("click", () => this._applyDeviceFill(true)); + this._root.getElementById("reset-theme") + .addEventListener("click", () => this._resetTheme()); + } + + /** + * Wire one ha-form up. + * + * computeLabel and computeHelper are assigned unconditionally. ha-form leaves + * both undefined until someone sets them, so the `if (form.computeLabel)` + * guard this replaces was never true and every label in the editor rendered + * as its raw config key. + * @param {string} id + * @param {function(!Object)} onChange + */ + _bindForm(id, onChange) { + const form = this._root.getElementById(id); + // Seeded before anything else can render it. _refresh assigns the real + // schema on the very next statement, but a throw in between would + // otherwise leave a form that crashes rather than one that renders empty. + form.schema = []; + form.data = {}; + form.computeLabel = (schema) => this._label(schema); + form.computeHelper = (schema) => this._helper(schema); + form.addEventListener("value-changed", (ev) => { + ev.stopPropagation(); + const value = ev.detail?.value || {}; + // The form is now showing `value`, so record it as applied. Without this + // the echoed setConfig would see a difference against the last value we + // pushed and reassign `data` mid-edit, jogging the control under the + // user's finger. + this._appliedData[id] = JSON.stringify(value); + onChange(value); + }); + this._formEls[id] = form; + } + + _selectTab(name) { + for (const tab of this._root.querySelectorAll(".tab")) { + tab.classList.toggle("active", tab.dataset.tab === name); + } + for (const content of this._root.querySelectorAll(".tab-content")) { + content.classList.toggle("active", content.id === `${name}-tab`); } } - _setupTabs() { - const tabs = this._root.querySelectorAll('.tab'); - const contents = this._root.querySelectorAll('.tab-content'); - - tabs.forEach(tab => { - tab.addEventListener('click', () => { - tabs.forEach(t => t.classList.remove('active')); - contents.forEach(c => c.classList.remove('active')); - - tab.classList.add('active'); - const tabId = tab.dataset.tab + '-tab'; - this._root.getElementById(tabId).classList.add('active'); - }); - }); + _showBuildError(error) { + this._root.innerHTML = + '

'; + const [heading, message, detail] = this._root.querySelectorAll("h3, p"); + heading.textContent = this._t("editor_error_title"); + message.textContent = this._t("editor_error_msg"); + detail.textContent = `${this._t("editor_error_prefix")} ${error?.message || error}`; } - _setupEntitiesForm() { - this._entitiesForm = this._root.getElementById('entities-form'); - this._entitiesForm.hass = this._hass; + // Labels --------------------------------------------------------------- - // Helper text mapping for form fields - const helperText = { - "name": "Display name for the printer card", - "camera": "Camera entity for live video feed", - "status": "Sensor showing current print status", - "progress": "Sensor showing print progress (0-100%)", - "time_left": "Sensor showing remaining print time (seconds)", - "nozzle": "Sensor showing nozzle temperature", - "bed": "Sensor showing bed temperature", - "box": "Sensor showing chamber/enclosure temperature (optional)", - "power": "Optional power switch entity for the printer (shows a Power button when set)", - "show_power_button": "Show the Power button when a power switch entity is configured", - "layer": "Sensor showing current print layer", - "total_layers": "Sensor showing total print layers", - "light": "Switch entity for printer light control", - "pause_btn": "Button entity to pause printing", - "resume_btn": "Button entity to resume printing", - "stop_btn": "Button entity to stop printing", - "custom_btn": "Any entity to trigger (Button, Script, Switch, etc.)", - "custom_btn_icon": "Icon for the custom button", - "custom_btn_hidden": "Hide the custom button", - "button_order": "List of buttons to show in order (pause, resume, stop, light, power, custom)" - }; - - this._entitiesForm.schema = [ - { name: "name", selector: { text: {} } }, - { name: "camera", selector: { entity: { domain: "camera" } } }, - { name: "status", selector: { entity: { domain: "sensor" } } }, - { name: "progress", selector: { entity: { domain: "sensor" } } }, - { name: "time_left", selector: { entity: { domain: "sensor" } } }, - { name: "nozzle", selector: { entity: { domain: "sensor" } } }, - { name: "bed", selector: { entity: { domain: "sensor" } } }, - { name: "box", selector: { entity: { domain: "sensor" } } }, - { name: "power", selector: { entity: { domain: ["switch", "input_boolean"] } } }, - { name: "show_power_button", selector: { boolean: {} } }, - { name: "layer", selector: { entity: { domain: "sensor" } } }, - { name: "total_layers", selector: { entity: { domain: "sensor" } } }, - { name: "light", selector: { entity: { domain: ["switch", "light"] } } }, - { name: "pause_btn", selector: { entity: { domain: "button" } } }, - { name: "resume_btn", selector: { entity: { domain: "button" } } }, - { name: "stop_btn", selector: { entity: { domain: "button" } } }, - { name: "custom_btn", selector: { entity: {} } }, - ]; - - // Label text mapping for form fields - const labelText = { - "name": this._t("label_name"), - "camera": this._t("label_camera"), - "status": this._t("label_status"), - "progress": this._t("label_progress"), - "time_left": this._t("label_time_left"), - "nozzle": this._t("label_nozzle"), - "bed": this._t("label_bed"), - "box": this._t("label_box"), - "power": this._t("label_power"), - "show_power_button": this._t("label_show_power_button"), - "layer": this._t("label_layer"), - "total_layers": this._t("label_total_layers"), - "light": this._t("label_light"), - "pause_btn": this._t("label_pause_btn"), - "resume_btn": this._t("label_resume_btn"), - "stop_btn": this._t("label_stop_btn"), - "custom_btn": this._t("label_custom_btn"), - "custom_btn_icon": this._t("label_custom_btn_icon"), - "custom_btn_hidden": this._t("label_custom_btn_hidden"), - "button_order": this._t("label_button_order"), - }; - - // Add label computation using computeLabel if supported - if (this._entitiesForm.computeLabel) { - const originalComputeLabel = this._entitiesForm.computeLabel.bind(this._entitiesForm); - this._entitiesForm.computeLabel = (schema) => { - return labelText[schema.name] || originalComputeLabel(schema); - }; - } - - // Add helper text using computeHelper if supported - if (this._entitiesForm.computeHelper) { - const originalComputeHelper = this._entitiesForm.computeHelper.bind(this._entitiesForm); - this._entitiesForm.computeHelper = (schema) => { - return helperText[schema.name] || originalComputeHelper(schema); - }; - } - - this._entitiesForm.data = this._cfg; - - this._entitiesForm.addEventListener("value-changed", (ev) => { - const val = ev.detail?.value || {}; - this._cfg = { ...this._cfg, ...val }; - this._dispatchConfigChange(); - }); + _label(schema) { + const name = schema?.name || ""; + if (!name) return ""; + if (name.endsWith(OPACITY_SUFFIX)) return this._t("label_opacity"); + if (name.endsWith(AUTO_SUFFIX)) return this._t("label_color_auto"); + const key = THEME_COLOR_FIELDS.has(name) ? `color_${name}` : `label_${name}`; + return this._tOr(key, humanizeName(name)); } - _setupThemeEditor() { + _helper(schema) { + const name = schema?.name || ""; + if (!name || name.endsWith(OPACITY_SUFFIX)) return ""; + // The explanation belongs on the switch, which is the control that acts on + // it; the colour picker below only appears once automatic is off. + if (name.endsWith(AUTO_SUFFIX)) { + return this._tOr(`helper_auto_${name.slice(0, -AUTO_SUFFIX.length)}`, ""); + } + if (THEME_COLOR_FIELDS.has(name)) return ""; + return this._tOr(`helper_${name}`, ""); + } - // Setup generic config form (Layout & Icons) - this._themeSettingsForm = this._root.getElementById('theme-settings-form'); - this._themeSettingsForm.hass = this._hass; + // Schemas -------------------------------------------------------------- - const themeSettingsSchema = [ - { name: "button_order", selector: { text: {} }, label: this._t("schema_button_order") }, - { name: "custom_btn_hidden", selector: { boolean: {} }, label: this._t("schema_hide_custom") }, - { name: "hide_box_temp", selector: { boolean: {} }, label: this._t("schema_hide_box_temp") }, - { name: "pause_btn_icon", selector: { icon: {} }, label: this._t("schema_pause_icon") }, - { name: "resume_btn_icon", selector: { icon: {} }, label: this._t("schema_resume_icon") }, - { name: "stop_btn_icon", selector: { icon: {} }, label: this._t("schema_stop_icon") }, - { name: "light_btn_icon", selector: { icon: {} }, label: this._t("schema_light_icon") }, - { name: "power_btn_icon", selector: { icon: {} }, label: this._t("schema_power_icon") }, - { name: "custom_btn_icon", selector: { icon: {} }, label: this._t("schema_custom_icon") }, - ]; + // Refresh -------------------------------------------------------------- - // Polyfill label if needed or rely on 'label' property in schema if HA supports it (HA Form supports 'label' in schema usually? No, it uses computeLabel) - // Let's use computeLabel for this form too. - this._themeSettingsForm.schema = themeSettingsSchema; - - // Prepare data for the form. button_order needs to be stringified if it's an array, or handled as list. - // Text input expects string. - const prepareFormData = (cfg) => { - return { - ...cfg, - button_order: Array.isArray(cfg.button_order) ? cfg.button_order.join(', ') : cfg.button_order - }; - }; - - this._themeSettingsForm.data = prepareFormData(this._cfg); - - this._themeSettingsForm.addEventListener("value-changed", (ev) => { - const val = ev.detail?.value || {}; - - // Post-process button_order back to array - if (val.button_order && typeof val.button_order === 'string') { - val.button_order = val.button_order.split(',').map(s => s.trim()).filter(s => s); - } - - this._cfg = { ...this._cfg, ...val }; - this._dispatchConfigChange(); - }); - - // Setup clickable elements to toggle inline color pickers - const clickableElements = this._root.querySelectorAll('.clickable-element'); - clickableElements.forEach(element => { - element.addEventListener('click', () => { - const themeKey = element.dataset.theme; - this._toggleColorPicker(themeKey); - }); - }); - - // Setup color picker interactions - this._setupColorPickerInteractions(); - - // Setup reset button - this._root.getElementById('reset-theme').addEventListener('click', () => { - const defaultConfig = KPrinterCard.getStubConfig(); - // Reset Theme - this._cfg.theme = { ...defaultConfig.theme }; - - // Reset Layout & Icons - ['button_order', 'custom_btn_hidden', 'pause_btn_icon', 'resume_btn_icon', 'stop_btn_icon', 'light_btn_icon', 'power_btn_icon', 'custom_btn_icon'].forEach(k => { - this._cfg[k] = defaultConfig[k]; - }); - - // Clear saved theme from storage - const cardId = generateCardId(this._cfg); + _refresh() { + // The shell is built here, not in connectedCallback, and only once there is + // a config: Home Assistant attaches the editor to the DOM before it calls + // setConfig (hui-element-editor assigns _configElement, which renders, and + // only then calls setConfig). An ha-form reads `schema` unguarded in its + // own render(), so attaching one before the schema exists throws out of + // Lit's update and takes the whole editor down. + if (!this._root || !this._cfg) return; + if (!this._formEls) { try { - const themes = JSON.parse(localStorage.getItem(THEME_STORAGE_KEY) || "{}"); - delete themes[cardId]; - localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(themes)); - } catch (e) { - console.warn("Failed to clear theme from localStorage:", e); + this._build(); + } catch (error) { + console.error("Error building K-Printer Card editor:", error); + this._showBuildError(error); + return; } + } - this._updateThemeControls(); + // A refresh runs from the hass setter, which Home Assistant calls out of + // hui-element-editor's own Lit update. Throwing from there breaks that + // update rather than just this editor, so a failure is reported in place + // instead of being allowed to escape. + try { + this._refreshForms(); + } catch (error) { + // Dump what the instance actually looked like: the one failure of this + // kind reported so far was a method missing from an instance while + // present on its prototype, and that is only diagnosable after the fact. + console.error("Error refreshing K-Printer Card editor:", error, { + constructor: this.constructor?.name, + own: Object.getOwnPropertyNames(this).join(" "), + proto: Object.getOwnPropertyNames(Object.getPrototypeOf(this) || {}).join(" "), + }); + this._showBuildError(error); + this._formEls = null; + } + } - // Update config inputs as well - if (this._themeSettingsForm) { - this._themeSettingsForm.data = prepareFormData(this._cfg); - } + _refreshForms() { - this._dispatchConfigChange(); + const text = { + "editor-title": this._t("editor_title"), + "tab-entities": this._t("tab_entities"), + "tab-theme": this._t("tab_theme"), + "group-device": this._t("group_device"), + "note-device": this._t("note_device"), + "group-entities": this._t("group_entities"), + "group-layout": this._t("group_layout"), + refill: this._t("btn_refill_from_device"), + "reset-theme": this._t("btn_reset"), + }; + for (const [id, value] of Object.entries(text)) { + const el = this._root.getElementById(id); + if (el) el.textContent = value; + } + this._root.getElementById("refill").disabled = !this._cfg.device; + + 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)); + + THEME_COLOR_GROUPS.forEach((group, index) => { + this._root.getElementById(`group-color-${index}`).textContent = this._t(group.title); + this._root.getElementById(`note-color-${index}`).textContent = this._t(group.note); + this._applyForm(`color-form-${index}`, colorSchema(this._cfg, group), colorData(this._cfg, group)); }); } - _toggleColorPicker(themeKey) { - const picker = this._root.getElementById(`color-picker-${themeKey}`); - if (!picker) return; + /** + * Push schema and data at a form, skipping assignments that change nothing. + * + * Lovelace answers every config-changed by calling setConfig again, so a + * refresh runs on every keystroke and every drag of the opacity slider. + * Reassigning identical data would make the control fight whatever the user + * is doing to it. + */ + _applyForm(id, schema, data) { + const form = this._formEls[id]; + if (!form) return; + if (this._hass) form.hass = this._hass; - // If this picker is already active, close it - if (picker.classList.contains('active')) { - picker.classList.remove('active'); + const schemaJson = JSON.stringify(schema); + if (this._appliedSchema[id] !== schemaJson) { + this._appliedSchema[id] = schemaJson; + form.schema = schema; + } + const dataJson = JSON.stringify(data); + if (this._appliedData[id] !== dataJson) { + this._appliedData[id] = dataJson; + form.data = data; + } + } + + // Changes -------------------------------------------------------------- + + _onEntitiesChanged(value) { + this._cfg = { ...this._cfg, ...value }; + this._dispatchConfigChange(); + } + + _onLayoutChanged(value) { + const next = { ...value }; + if (typeof next.button_order === "string") { + next.button_order = next.button_order.split(",").map((s) => s.trim()).filter(Boolean); + } + this._cfg = { ...this._cfg, ...next }; + this._dispatchConfigChange(); + } + + _onColorChanged(group, value) { + const defaults = KPrinterCard.getStubConfig().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) { + if (field.auto) { + // Absent means the switch was not rendered, which only happens while + // the field is already automatic. + if (value[`${field.key}${AUTO_SUFFIX}`] !== false) { + theme[field.key] = "auto"; + continue; + } + if (wasAuto.get(field.key) && !Array.isArray(value[field.key])) { + // Automatic was just switched off and there is no colour yet. Seed a + // plausible one rather than leaving the picker on black, which looks + // like a bug on every theme. + theme[field.key] = field.seed; + continue; + } + } + const rgb = value[field.key]; + if (!Array.isArray(rgb) || rgb.length < 3) { + // Nothing the picker can express: a var() or a named colour written by + // hand, which reaches the form as no value at all. Every field in the + // group is rewritten on any change here, so leaving it alone is what + // keeps an edit to one colour from eating another. + continue; + } + const alpha = field.alpha + ? clamp(Number(value[`${field.key}${OPACITY_SUFFIX}`] ?? 100), 0, 100) / 100 + : 1; + theme[field.key] = formatColor(rgb, alpha); + } + this._cfg = { ...this._cfg, theme }; + // Switching a field to or from automatic adds or removes its colour rows, + // so the form has to be rebuilt now rather than waiting for the debounced + // config-changed to come back round as a setConfig. Every other edit + // leaves the shape alone and is left for the control itself to show. + if (group.fields.some((field) => isAutoColor(this._cfg, field) !== wasAuto.get(field.key))) { + this._refresh(); + } + this._dispatchConfigChange(); + } + + _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; replacing a field the user + // already chose is what the button next to it is for. + this._applyDeviceFill(false); return; } - - // Hide all other color pickers - const allPickers = this._root.querySelectorAll('.color-picker-inline'); - allPickers.forEach(p => { - p.classList.remove('active'); - }); - - // Show the clicked color picker - picker.classList.add('active'); + this._setRefillStatus(""); + this._refresh(); + this._dispatchConfigChange(); } - _setupColorPickerInteractions() { - // Setup color preview clicks to open native color picker - const colorPreviews = this._root.querySelectorAll('.color-preview'); - colorPreviews.forEach(preview => { - preview.addEventListener('click', () => { - const picker = preview.closest('.color-picker-inline'); - if (picker) { - const textInput = picker.querySelector('.color-text'); - if (textInput) { - // Create a visible color input that stays in the editor - const input = document.createElement('input'); - input.type = 'color'; - input.value = textInput.value === 'auto' ? '#000000' : textInput.value; + // Device prefill ------------------------------------------------------- - // Style the input to be visible and positioned within the picker - input.style.position = 'absolute'; - input.style.left = '0'; - input.style.top = '0'; - input.style.width = '100%'; - input.style.height = '100%'; - input.style.opacity = '0'; - input.style.cursor = 'pointer'; - input.style.zIndex = '10'; - - // Add the input to the picker container - picker.style.position = 'relative'; - picker.appendChild(input); - - // Focus and click the input - input.focus(); - input.click(); - - // Handle color change - input.addEventListener('change', () => { - const newColor = input.value; - textInput.value = newColor; - preview.style.background = newColor; - - // Remove the input after color selection - if (picker.contains(input)) { - picker.removeChild(input); - } - }); - - // Handle escape key - const handleKeyDown = (e) => { - if (e.key === 'Escape') { - if (picker.contains(input)) { - picker.removeChild(input); - } - document.removeEventListener('keydown', handleKeyDown); - } - }; - document.addEventListener('keydown', handleKeyDown); - - // Handle clicks outside the picker - const handleClickOutside = (e) => { - if (!picker.contains(e.target)) { - if (picker.contains(input)) { - picker.removeChild(input); - } - document.removeEventListener('click', handleClickOutside); - } - }; - - // Add click outside handler after a delay - setTimeout(() => { - document.addEventListener('click', handleClickOutside); - }, 100); - } - } - }); - }); - - // Setup text input changes - const colorTexts = this._root.querySelectorAll('.color-text'); - colorTexts.forEach(textInput => { - textInput.addEventListener('input', () => { - const value = textInput.value; - if (/^#[0-9A-Fa-f]{6}$/.test(value)) { - const picker = textInput.closest('.color-picker-inline'); - if (picker) { - const preview = picker.querySelector('.color-preview'); - if (preview) { - preview.style.background = value; - } - } - } - }); - }); - - // Setup save buttons - const saveButtons = this._root.querySelectorAll('.save-color-btn'); - saveButtons.forEach(button => { - button.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - - const themeKey = button.dataset.theme; - const picker = button.closest('.color-picker-inline'); - if (picker) { - const textInput = picker.querySelector('.color-text'); - if (textInput) { - let newValue; - const inputValue = textInput.value; - - // Handle special cases for auto-supported fields - if ((themeKey === 'status_icon' || themeKey === 'progress_ring' || - themeKey === 'status_bg' || themeKey === 'telemetry_icon' || - themeKey === 'telemetry_text') && inputValue === 'auto') { - newValue = 'auto'; - } else if (/^#[0-9A-Fa-f]{6}$/.test(inputValue)) { - newValue = hexToRgba(inputValue, 0.9); - } else { - // Keep existing value if input is invalid - newValue = this._cfg.theme[themeKey]; - } - - - this._cfg.theme = { ...this._cfg.theme, [themeKey]: newValue }; - - // Save to storage - const cardId = generateCardId(this._cfg); - saveThemeToStorage(cardId, this._cfg.theme); - - this._updateThemeControls(); - - // Hide the color picker first - picker.classList.remove('active'); - - // Dispatch config change after a small delay to prevent tab switching - setTimeout(() => { - this._dispatchConfigChange(); - }, 100); - } - } - }); - }); + /** + * Entity ids the chosen device can supply, as a config patch. + * @param {boolean} overwrite Replace fields that already hold a value. + * @return {!Object} + */ + _prefillFromDevice(overwrite) { + const deviceId = this._cfg.device; + const patch = {}; + for (const [role, entityId] of Object.entries(entitiesForDevice(this._hass, deviceId))) { + if (overwrite || !this._cfg[role]) patch[role] = entityId; + } + 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 -- + // otherwise the field the user most expects to be filled never would be. + const nameUnset = !this._cfg.name || this._cfg.name === DEFAULT_CARD_NAME; + if (deviceName && (overwrite || nameUnset)) patch.name = deviceName; + return patch; } + _applyDeviceFill(overwrite) { + // The card name is filled too but is not counted: it is the one field + // right above the button, so "13 of 13" reads as a claim about the entity + // list rather than an off-by-one. + const roles = Object.keys(DEVICE_ROLE_ENTITIES); + const available = Object.keys(entitiesForDevice(this._hass, this._cfg.device)).length; + const patch = this._prefillFromDevice(overwrite); + const filled = roles.filter((role) => role in patch).length; + this._cfg = { ...this._cfg, ...patch }; + this._setRefillStatus(available + ? this._t("status_device_filled", { filled, total: roles.length }) + : this._t("status_device_empty")); + this._refresh(); + this._dispatchConfigChange(); + } - _updateThemeControls() { - // Update all preview elements with new colors - const clickableElements = this._root.querySelectorAll('.clickable-element'); - clickableElements.forEach(element => { - const themeKey = element.dataset.theme; - const preview = element.querySelector('.element-preview'); - const currentValue = this._cfg.theme[themeKey] || ''; + _setRefillStatus(text) { + const el = this._root?.getElementById("refill-status"); + if (el) el.textContent = text; + } - if (themeKey === 'status_icon' || themeKey === 'progress_ring') { - preview.style.background = currentValue === 'auto' ? 'var(--primary-color)' : currentValue; - } else { - preview.style.background = currentValue; - } + // Reset ---------------------------------------------------------------- - if (['pause_icon', 'resume_icon', 'stop_icon', 'light_icon_on', 'light_icon_off', 'custom_icon'].includes(themeKey)) { - preview.style.color = currentValue; - } - }); + _resetTheme() { + const defaults = KPrinterCard.getStubConfig(); + const cfg = { ...this._cfg, theme: { ...defaults.theme } }; + for (const key of LAYOUT_RESET_KEYS) cfg[key] = defaults[key]; - // Update color picker previews and text inputs - const colorPickers = this._root.querySelectorAll('.color-picker-inline'); - colorPickers.forEach(picker => { - const themeKey = picker.id.replace('color-picker-', ''); - const themeValue = this._cfg.theme[themeKey]; - const preview = picker.querySelector('.color-preview'); - const textInput = picker.querySelector('.color-text'); + // Clear the localStorage copy too: setConfig falls back to it for a config + // that carries no theme of its own, so leaving it behind would resurrect + // the old colours on the next load. + try { + const themes = JSON.parse(localStorage.getItem(THEME_STORAGE_KEY) || "{}"); + delete themes[generateCardId(cfg)]; + localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(themes)); + } catch (err) { + console.warn("Failed to clear theme from localStorage:", err); + } - if (preview && textInput) { - if (themeKey === 'status_icon' || themeKey === 'progress_ring' || - themeKey === 'status_bg' || themeKey === 'telemetry_icon' || - themeKey === 'telemetry_text') { - if (themeValue === 'auto') { - // Show appropriate theme color for auto values - if (themeKey === 'status_bg') { - preview.style.background = 'var(--card-background-color)'; - } else if (themeKey === 'telemetry_icon') { - preview.style.background = 'var(--secondary-text-color)'; - } else if (themeKey === 'telemetry_text') { - preview.style.background = 'var(--primary-text-color)'; - } else { - preview.style.background = '#000000'; - } - textInput.value = 'auto'; - } else { - preview.style.background = rgbaToHex(themeValue); - textInput.value = rgbaToHex(themeValue); - } - } else { - preview.style.background = rgbaToHex(themeValue); - textInput.value = rgbaToHex(themeValue); - } - } - }); + this._cfg = cfg; + this._refresh(); + this._dispatchConfigChange(); } _dispatchConfigChange() { clearTimeout(this._debounceTimer); this._debounceTimer = setTimeout(() => { - // Preserve current tab state - const activeTab = this._root.querySelector('.tab.active'); - const activeTabId = activeTab ? activeTab.dataset.tab : 'entities'; - - this.dispatchEvent(new CustomEvent("config-changed", { detail: { config: this._cfg } })); - - // Restore tab state after a brief delay - setTimeout(() => { - this._restoreTabState(activeTabId); - }, 50); + this._debounceTimer = null; + this._emitConfig(); }, 120); } - _restoreTabState(activeTabId) { - // Switch to the preserved tab - const tabs = this._root.querySelectorAll('.tab'); - const tabContents = this._root.querySelectorAll('.tab-content'); - - tabs.forEach(tab => { - tab.classList.remove('active'); - if (tab.dataset.tab === activeTabId) { - tab.classList.add('active'); - } - }); - - tabContents.forEach(content => { - content.classList.remove('active'); - if (content.id === `${activeTabId}-tab`) { - content.classList.add('active'); - } - }); + _emitConfig() { + // The theme is persisted here rather than at each change: an opacity slider + // emits on every pointer move, and each one would otherwise be a + // 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 }, + bubbles: true, + composed: true, + })); } } -customElements.define(EDITOR_TAG, KPrinterCardEditor); +defineOnce(EDITOR_TAG, KPrinterCardEditor); try { window.customCards = window.customCards || []; diff --git a/zigbee2mqtt/state.json b/zigbee2mqtt/state.json index f0fcc74d..a6ef3081 100644 --- a/zigbee2mqtt/state.json +++ b/zigbee2mqtt/state.json @@ -4,10 +4,10 @@ "state": "ON", "led_brightness": 100, "countdown_to_turn_off": 0, - "voltage": 121, + "voltage": 121.5, "countdown_to_turn_on": 0, "ac_frequency": 60, - "power_factor": 0.14, + "power_factor": 0.13, "update": { "state": "idle", "installed_version": 268513381, @@ -27,8 +27,8 @@ "led_brightness": 100, "countdown_to_turn_off": 0, "countdown_to_turn_on": 0, - "power": 1.9, - "current": 0.09, + "power": 2.2, + "current": 0.1, "energy": 48.6, "power_factor": 0.18, "update": { @@ -45,10 +45,10 @@ "state": "ON", "led_brightness": 100, "countdown_to_turn_off": 0, - "voltage": 120.6, + "voltage": 121.1, "countdown_to_turn_on": 0, - "energy": 92.46, - "power_factor": 0.2, + "energy": 92.47, + "power_factor": 0.89, "ac_frequency": 60, "update": { "state": "idle", @@ -58,8 +58,8 @@ "latest_release_notes": null }, "linkquality": 120, - "power": 0.2, - "current": 0.01, + "power": 83.9, + "current": 0.79, "power_on_behavior": "on" }, "0xb40e060fffe031e3": { @@ -74,13 +74,13 @@ "led_brightness": 100, "countdown_to_turn_off": 0, "countdown_to_turn_on": 0, - "voltage": 120.3, + "voltage": 120, "state": "ON", "ac_frequency": 60, - "energy": 189.5, - "power": 0.8, - "current": 0.02, - "power_factor": 0.67, + "energy": 189.54, + "power": 100.3, + "current": 1.27, + "power_factor": 0.94, "update": { "state": "idle", "installed_version": 268513381, @@ -95,13 +95,13 @@ "led_brightness": 100, "countdown_to_turn_off": 0, "countdown_to_turn_on": 0, - "voltage": 120.3, - "energy": 94.06, + "voltage": 120.9, + "energy": 94.07, "state": "ON", - "power": 25.6, - "current": 0.34, + "power": 24.6, + "current": 0.26, "ac_frequency": 60, - "power_factor": 0.75, + "power_factor": 0.74, "update": { "state": "idle", "installed_version": 268513381, @@ -114,12 +114,12 @@ }, "0xffffb40e060895b3": { "state": "ON", - "voltage": 120.9, + "voltage": 121.9, "ac_frequency": 60, "energy": 12.59, "current": 0.01, "power": 0.1, - "power_factor": 0.2, + "power_factor": 0.11, "linkquality": 87, "update": { "state": "idle", @@ -136,7 +136,7 @@ "0xffffb40e0608864e": { "led_brightness": 100, "countdown_to_turn_off": 0, - "voltage": 121.3, + "voltage": 121.7, "energy": 29.66, "countdown_to_turn_on": 0, "state": "ON", @@ -184,7 +184,7 @@ "linkquality": 91 }, "0xffffb40e0606234b": { - "contact": false, + "contact": true, "battery_low": false, "battery": 100, "linkquality": 91,