const CARD_TAG = "k-cfs-card"; const EDITOR_TAG = "k-cfs-card-editor"; const mdi = (name) => `mdi:${name}`; const ASSET_URL_BASE = "/ha_creality_ws/"; const I18N_URL_BASE = `${ASSET_URL_BASE}i18n/`; const _i18nData = {}; const _i18nPromises = {}; function _loadI18n(lang) { if (_i18nData[lang]) return Promise.resolve(_i18nData[lang]); if (_i18nPromises[lang]) return _i18nPromises[lang]; _i18nPromises[lang] = fetch(`${I18N_URL_BASE}${lang}.json`) .then((res) => (res.ok ? res.json() : null)) .then((data) => { if (data) _i18nData[lang] = data; else _i18nPromises[lang] = null; return data; }) .catch(() => { _i18nPromises[lang] = null; return null; }); return _i18nPromises[lang]; } function _resolveLang(hass) { return hass?.locale?.language || hass?.language || "en"; } function _translate(hass, section, fallbackDict, key, vars) { const lang = _resolveLang(hass); const short = lang.split("-")[0]; const remote = _i18nData[lang]?.[section] ?? _i18nData[short]?.[section]; const remoteEn = _i18nData["en"]?.[section]; let text = (remote && key in remote) ? remote[key] : (remoteEn && key in remoteEn) ? remoteEn[key] : (fallbackDict[lang]?.[key] ?? fallbackDict[short]?.[key] ?? fallbackDict["en"]?.[key] ?? key); if (vars) { Object.entries(vars).forEach(([k, v]) => { text = text.replace(new RegExp(`\\{${k}\\}`, "g"), v); }); } return text; } function _requestI18n(instance, hass, onLoaded) { if (instance._i18nRequested) return; instance._i18nRequested = true; const lang = _resolveLang(hass).split("-")[0]; Promise.all([_loadI18n("en"), lang !== "en" ? _loadI18n(lang) : null]).then(onLoaded); } // 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", ext_label: "EXT", cfs_label: "CFS", cfs_number_label: "CFS {number}", // Editor label_card_title: "Card Title", label_external_filament: "External Filament", label_external_color: "External Color", label_external_percent: "External Percent", label_box_temp: "Box {box} Temperature", label_box_humidity: "Box {box} Humidity", label_slot_filament: "Box {box} Slot {slot} Filament", label_slot_color: "Box {box} Slot {slot} Color", label_slot_percent: "Box {box} Slot {slot} Remaining Percent", 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", tab_theme: "Theme", }, }; class KCFSCard extends HTMLElement { constructor() { super(); this._selectedCFS = 0; // Track selected CFS tab in normal mode } /** 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()}`; } if (hex.length === 3 && /^[0-9a-fA-F]+$/.test(hex)) { return `#${hex.toLowerCase()}`; } if (hex.length === 7 && hex.startsWith("0") && /^[0-9a-fA-F]+$/.test(hex)) { return `#${hex.slice(1).toLowerCase()}`; } return 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; // "-" 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 const match = String(humidityStr).match(/(\d+\.?\d*)/); if (!match) return '#64b5f6'; const value = parseFloat(match[1]); if (value < 40) return '#4caf50'; // Green (0-39%) - Ideal if (value < 60) return '#ff9800'; // Orange (40-59%) - Attention return '#f44336'; // Red (60-100%) - Critical } static getStubConfig() { const cfg = { name: "CFS", view_mode: "full", show_type_in_mini: false, external_filament: "", external_color: "", external_percent: "", }; for (let box = 0; box < 4; box += 1) { cfg[`box${box}_temp`] = ""; cfg[`box${box}_humidity`] = ""; for (let slot = 0; slot < 4; slot += 1) { cfg[`box${box}_slot${slot}_filament`] = ""; cfg[`box${box}_slot${slot}_color`] = ""; cfg[`box${box}_slot${slot}_percent`] = ""; } } return cfg; } static getConfigElement() { return document.createElement(EDITOR_TAG); } setConfig(config) { this._cfg = { ...KCFSCard.getStubConfig(), ...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(); } // i18n helpers ------------------------------------------------------- _resolveLanguage() { return _resolveLang(this._hass); } _t(key, vars) { return _translate(this._hass, "cfs_card", CFS_TRANSLATIONS, key, vars); } // --------------------------------------------------------------------- set hass(hass) { this._hass = hass; // 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 modeClass = KCFSCard._modeClass(this._cfg.view_mode); const style = ` /* inherit HA fonts & typography */ :host { font: inherit; color: var(--primary-text-color); display: block !important; height: auto !important; contain: none !important; position: relative; z-index: 1; } /* unify horizontal padding so right edges line up */ :host { --row-xpad: 6px; } ha-card { overflow: visible !important; display: block !important; height: auto !important; min-height: auto !important; max-height: none !important; contain: none !important; position: relative; z-index: 1; } ha-card.compact-mode, ha-card.normal-mode { height: auto !important; overflow: visible !important; } #content { padding: 14px; } .card { border-radius: var(--ha-card-border-radius, 12px); background: var(--card-background-color); color: var(--primary-text-color); box-shadow: var(--ha-card-box-shadow, 0 2px 4px rgba(0,0,0,.2)); padding: 10px var(--row-xpad) 10px var(--row-xpad); display: flex; flex-direction: column; gap: 6px; } /* === NORMAL MODE === */ .normal-mode {} .header { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 12px; } .title-section { display: flex; flex-direction: column; gap: 4px; } .title { font-size: 20px; font-weight: 700; letter-spacing: -0.5px; } .subtitle { font-size: 12px; color: var(--secondary-text-color); font-weight: 500; } .env-info { font-size: 11px; background: rgba(var(--rgb-primary-text-color), 0.08); padding: 6px 10px; border-radius: 12px; color: var(--secondary-text-color); } .unit-selector { display: flex; gap: 8px; margin-bottom: 20px; background: rgba(var(--rgb-primary-text-color), 0.05); padding: 4px; border-radius: 14px; width: fit-content; } .unit-btn { padding: 6px 16px; border-radius: 10px; font-size: 12px; font-weight: 600; cursor: pointer; transition: all 0.3s ease; border: none; color: var(--secondary-text-color); background: transparent; } .unit-btn.active { background: rgba(var(--rgb-primary-text-color), 0.1); color: var(--primary-text-color); box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15); } .spool-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } .spool-card { background: rgba(var(--rgb-primary-text-color), 0.04); border-radius: var(--ha-card-border-radius, 18px); padding: 16px; display: flex; flex-direction: column; align-items: center; justify-content: center; position: relative; border: 1px solid transparent; transition: all 0.3s ease; cursor: pointer; } .spool-card:hover { background: rgba(var(--rgb-primary-text-color), 0.06); } .spool-card.active { background: rgba(var(--rgb-primary-text-color), 0.08); border: 1px solid rgba(var(--rgb-primary-color), 0.3); box-shadow: 0 0 20px rgba(var(--rgb-primary-color), 0.2); } .ring-container { width: 80px; height: 80px; border-radius: 50%; position: relative; display: flex; justify-content: center; align-items: center; margin-bottom: 10px; } .ring-outer { width: 100%; height: 100%; border-radius: 50%; position: absolute; background: conic-gradient( var(--spool-color) var(--spool-pct), rgba(var(--rgb-primary-text-color), 0.08) 0 ); mask: radial-gradient(circle closest-side at 50% 50%, transparent 33px, black 34px); -webkit-mask: radial-gradient(circle closest-side at 50% 50%, transparent 33px, black 34px); } .ring-inner { width: 66px; height: 66px; background: transparent; border-radius: 50%; position: relative; z-index: 2; display: flex; flex-direction: column; justify-content: center; align-items: center; } .spool-label { font-size: 10px; color: var(--secondary-text-color); text-transform: uppercase; } .spool-pct { font-size: 16px; font-weight: 700; } .material-name { font-size: 13px; font-weight: 600; text-align: center; } .color-name { font-size: 11px; color: var(--secondary-text-color); text-align: center; } .status-badge { position: absolute; top: 8px; right: 8px; width: 8px; height: 8px; background: var(--success-color, #4caf50); border-radius: 50%; box-shadow: 0 0 8px var(--success-color, #4caf50); animation: pulse-badge 2s infinite; } @keyframes pulse-badge { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.7; transform: scale(1.1); } } /* === COMPACT MODE === */ .compact-mode { padding: 14px; } .compact-mode .header { margin-bottom: 12px; } .cfs-rows { display: flex; flex-direction: column; gap: 10px; } .cfs-row { display: flex; align-items: center; gap: 12px; } .cfs-label { width: 48px; font-size: 11px; color: var(--secondary-text-color); font-weight: 600; } .spools-inline { display: flex; gap: 10px; flex: 1; } .spool-mini { width: 34px; height: 34px; border-radius: 50%; position: relative; display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: 600; cursor: pointer; transition: transform 0.2s; } .spool-mini:hover { transform: scale(1.05); } .spool-mini::before { content: ''; position: absolute; inset: 0; border-radius: 50%; background: conic-gradient( var(--spool-color) var(--spool-pct), rgba(var(--rgb-primary-text-color), 0.08) 0 ); mask: radial-gradient(circle closest-side at 50% 50%, transparent 12px, black 13px); -webkit-mask: radial-gradient(circle closest-side at 50% 50%, transparent 12px, black 13px); } .spool-mini::after { content: ''; position: absolute; inset: 4px; background: transparent; border-radius: 50%; z-index: 1; } .spool-mini span { position: relative; z-index: 2; } .spool-mini.active { box-shadow: 0 0 10px var(--spool-color); } .spool-mini.active::after { inset: 3px; } .spool-mini-wrapper { position: relative; display: flex; flex-direction: column; align-items: center; gap: 2px; } .spool-mini-type { font-size: 8px; color: var(--secondary-text-color); text-transform: uppercase; font-weight: 600; max-width: 40px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .env-mini { width: 56px; display: flex; flex-direction: column; align-items: flex-end; font-size: 10px; line-height: 1.4; gap: 2px; } .env-mini .temp { color: #ffb74d; font-weight: 600; } .env-mini .hum { font-weight: 600; /* Cor aplicada dinamicamente via inline style */ } /* === EXTERNAL SECTION === */ .external-section { margin-top: 16px; padding-top: 14px; border-top: 1px solid rgba(var(--rgb-primary-text-color), 0.08); } .external-normal { position: relative; background: rgba(var(--rgb-primary-text-color), 0.03); border-radius: 16px; padding: 12px 16px; display: flex; align-items: center; gap: 12px; } .ext-icon { width: 30px; height: 30px; background: var(--primary-color); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: bold; color: white; } .ext-info { flex-grow: 1; } .ext-name { font-size: 12px; font-weight: 600; } .ext-bar { height: 4px; background: rgba(var(--rgb-primary-text-color), 0.1); border-radius: 2px; margin-top: 6px; overflow: hidden; } .ext-fill { height: 100%; background: var(--primary-color); transition: width 0.3s ease; } .ext-percent { font-size: 12px; color: var(--secondary-text-color); font-weight: 600; } .external-compact { position: relative; display: flex; align-items: center; gap: 10px; } .ext-dot { width: 34px; height: 34px; border-radius: 50%; background: conic-gradient(var(--primary-color) 100%, rgba(var(--rgb-primary-text-color), 0.1) 0); display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: 600; color: white; } .ext-compact-info { flex: 1; } .ext-compact-info div:first-child { font-size: 12px; font-weight: 600; } .ext-compact-info div:last-child { font-size: 10px; color: var(--secondary-text-color); } /* === 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); padding: 20px; } `; this._root.innerHTML = `
`; } /** * 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 "-"; const v = st.state; if (isSentinel(v)) return "-"; const s = String(v).trim(); if (this._hass && typeof this._hass.formatEntityState === "function") { try { return this._hass.formatEntityState(st); } catch (_) { } } const unit = st.attributes?.unit_of_measurement; const n = Number(s); if (!Number.isNaN(n) && Number.isFinite(n)) { const dp = (typeof st.attributes?.display_precision === "number") ? st.attributes.display_precision : (typeof st.attributes?.suggested_display_precision === "number") ? st.attributes.suggested_display_precision : (unit && /°|c|f/i.test(unit)) ? 1 : 2; const out = n.toFixed(Math.max(0, Math.min(6, dp))); return unit ? `${out} ${unit}` : out; } return unit ? `${s} ${unit}` : s; }; // Collect box data const boxes = {}; for (let boxId = 0; boxId < 4; boxId += 1) { const tempEid = this._cfg[`box${boxId}_temp`]; const humidityEid = this._cfg[`box${boxId}_humidity`]; const slots = []; for (let slotId = 0; slotId < 4; slotId += 1) { const filamentEid = this._cfg[`box${boxId}_slot${slotId}_filament`]; const colorEid = this._cfg[`box${boxId}_slot${slotId}_color`]; const percentEid = this._cfg[`box${boxId}_slot${slotId}_percent`]; if (!filamentEid && !colorEid && !percentEid) { slots.push(null); continue; } const filamentObj = gObj(filamentEid); const colorObj = gObj(colorEid); const percentObj = gObj(percentEid); const name = filamentObj?.state; const type = filamentObj?.attributes?.type; const selected = filamentObj?.attributes?.selected; const rawColor = colorObj?.state || filamentObj?.attributes?.color_hex; const 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: 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), }; } if (tempEid || humidityEid || slots.some((slot) => slot)) { const humidityFormatted = fmtState(gObj(humidityEid)); boxes[boxId] = { id: boxId, temp: fmtState(gObj(tempEid)), humidity: humidityFormatted, humidityColor: KCFSCard._getHumidityColor(humidityFormatted), slots, }; } } // Collect external data const external = { filament: this._cfg.external_filament, color: this._cfg.external_color, percent: this._cfg.external_percent, }; const hasExternal = external.filament || external.color || external.percent; let externalData = null; if (hasExternal) { const filamentObj = gObj(external.filament); const colorObj = gObj(external.color); const percentObj = gObj(external.percent); const name = filamentObj?.state; const type = filamentObj?.attributes?.type; const selected = filamentObj?.attributes?.selected; const rawColor = colorObj?.state || filamentObj?.attributes?.color_hex; const parsedColor = KCFSCard._parseColor(rawColor); const color = parsedColor ?? "#cccccc"; const percent = KCFSCard._parsePercent(percentObj); const percentText = fmtState(percentObj); externalData = { id: 0, boxId: -1, entity_id: external.filament || external.color || external.percent, name, type, selected, color, // 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), }; } 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.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); } 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 `
${esc(this._t("no_data"))}
`; } // Unit selector (only if we have multiple boxes) let unitSelector = ''; if (boxes.length > 1) { unitSelector = `
${boxes.map((box, idx) => ` `).join('')}
`; } // Get the selected box const selectedBox = boxes[this._selectedCFS] || boxes[0]; if (!selectedBox && !external) { return `
${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 : ''; if (tempStr || humStr) { const tempHtml = tempStr ? `${esc(tempStr)}` : ''; const humHtml = humStr ? `${esc(humStr)}` : ''; const separator = tempStr && humStr ? ' • ' : ''; envInfo = `
${tempHtml}${separator}${humHtml}
`; } } const header = `
${esc(this._cfg.name || 'Creality CFS')}
${envInfo}
`; // Spool grid let spoolGrid = ''; if (selectedBox) { spoolGrid = `
${selectedBox.slots.map((slot) => this._renderSpoolCard(slot)).join('')}
`; } // External section let externalSection = ''; if (external) { const safeType = !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}` : '-'; externalSection = `
${this._renderEditButton(external)}
${esc(this._t("ext_label"))}
${esc(displayName)}
${esc(percentTextDisplay)}
`; } return `${unitSelector}${header}${spoolGrid}${externalSection}`; } _renderCompactMode(boxes, external) { if (boxes.length === 0 && !external) { return `
${esc(this._t("no_data"))}
`; } // CFS rows let cfsRows = ''; if (boxes.length > 0) { cfsRows = `
${boxes.map((box) => this._renderCFSRow(box)).join('')}
`; } 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)}
`; } _renderCFSRow(box) { const tempStr = box.temp !== "-" ? box.temp : ''; const humStr = box.humidity !== "-" ? box.humidity : ''; let envHtml = ''; if (tempStr || humStr) { envHtml = `
${tempStr ? `
${esc(tempStr)}
` : ''} ${humStr ? `
${esc(humStr)}
` : ''}
`; } return `
${esc(this._t("cfs_number_label", { number: box.id + 1 }))}
${box.slots.map((slot) => this._renderSpoolMini(slot)).join('')}
${envHtml}
`; } /** * 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 `
`; } const isActive = slot.selected === 1 || slot.selected === true; const color = slot.color || '#cccccc'; 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 !== "-"; const pct = hasFilament && slot.percent !== null ? slot.percent : 0; const pctDisplay = hasFilament && slot.percent !== null ? Math.round(slot.percent) : 0; const percentTextDisplay = hasFilament ? (slot.percentText || '-') : '-'; const badge = isActive ? '
' : ''; return `
${badge} ${this._renderEditButton(slot)}
${pctDisplay}% ${esc(safeType)}
${esc(safeName)}
${esc(percentTextDisplay)}
`; } _renderSpoolMini(slot) { const showType = this._cfg.show_type_in_mini; if (!slot) { if (showType) { return `
-
-
`; } return `
-
`; } const isActive = slot.selected === 1 || slot.selected === true; const color = slot.color || '#cccccc'; const safeType = !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; const pct = hasFilament && slot.percent !== null ? slot.percent : 0; const pctDisplay = hasFilament && slot.percent !== null ? Math.round(slot.percent) : 0; if (showType) { return `
${pctDisplay}
${esc(safeType)}
${this._renderEditButton(slot, true)}
`; } return `
${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._t("tab_entities")}
${this._t("tab_theme")}
`; this._setupTabs(); this._setupEntitiesForm(); this._setupThemeForm(); } _setupTabs() { const tabs = this._root.querySelectorAll(".tab"); const contents = this._root.querySelectorAll(".tab-content"); tabs.forEach((tab) => { tab.onclick = () => { tabs.forEach((t) => t.classList.remove("active")); contents.forEach((c) => c.classList.remove("active")); tab.classList.add("active"); this._root.getElementById(`${tab.dataset.tab}-tab`).classList.add("active"); }; }); } _setupEntitiesForm() { this._form = this._root.getElementById("form"); this._form.hass = this._hass; this._form.data = this._cfg; const schema = [ { name: "name", selector: { text: {} } }, { name: "external_filament", selector: { entity: { domain: "sensor" } } }, { name: "external_color", selector: { entity: { domain: "sensor" } } }, { name: "external_percent", selector: { entity: { domain: "sensor" } } }, ]; for (let box = 0; box < 4; box += 1) { schema.push({ name: `box${box}_temp`, selector: { entity: { domain: "sensor" } } }); schema.push({ name: `box${box}_humidity`, selector: { entity: { domain: "sensor" } } }); for (let slot = 0; slot < 4; slot += 1) { schema.push({ name: `box${box}_slot${slot}_filament`, selector: { entity: { domain: "sensor" } } }); schema.push({ name: `box${box}_slot${slot}_color`, selector: { entity: { domain: "sensor" } } }); schema.push({ name: `box${box}_slot${slot}_percent`, selector: { entity: { domain: "sensor" } } }); } } this._form.schema = schema; this._form.computeLabel = (s) => { if (s.name === "name") return this._t("label_card_title"); if (s.name === "external_filament") return this._t("label_external_filament"); if (s.name === "external_color") return this._t("label_external_color"); if (s.name === "external_percent") return this._t("label_external_percent"); const boxMatch = s.name.match(/^box(\d+)_(temp|humidity)$/); if (boxMatch) { const [, boxId, metric] = boxMatch; const key = metric === "temp" ? "label_box_temp" : "label_box_humidity"; return this._t(key, { box: Number(boxId) + 1 }); } const slotMatch = s.name.match(/^box(\d+)_slot(\d+)_(filament|color|percent)$/); if (slotMatch) { const [, boxId, slotId, metric] = slotMatch; const keyMap = { filament: "label_slot_filament", color: "label_slot_color", percent: "label_slot_percent", }; return this._t(keyMap[metric], { box: Number(boxId) + 1, slot: Number(slotId) + 1 }); } return s.name; }; // Assigned unconditionally: ha-form leaves computeHelper undefined until // someone sets it, so guarding on it meant this never ran. Harmless here // only because ha-form's own default is "no helper" either way. this._form.computeHelper = () => ""; this._form.addEventListener("value-changed", (ev) => { this._cfg = { ...this._cfg, ...ev.detail.value }; this._dispatchConfigChange(); }); } _setupThemeForm() { const themeForm = this._root.getElementById("theme-form"); themeForm.hass = this._hass; themeForm.data = this._cfg; themeForm.schema = [ { name: "view_mode", selector: { select: { mode: "dropdown", options: [ { value: "full", label: this._t("view_mode_full") }, { value: "compact", label: this._t("view_mode_compact") }, { value: "box", label: this._t("view_mode_box") }, ], }, }, }, { name: "show_type_in_mini", selector: { boolean: {} } }, ]; themeForm.computeLabel = (s) => ({ view_mode: this._t("schema_view_mode"), show_type_in_mini: this._t("schema_show_type_in_mini"), }[s.name] || s.name); themeForm.addEventListener("value-changed", (ev) => { this._cfg = { ...this._cfg, ...ev.detail.value }; this._dispatchConfigChange(); }); } _dispatchConfigChange() { this.dispatchEvent(new CustomEvent("config-changed", { detail: { config: this._cfg }, bubbles: true, composed: true, })); } } defineOnce(EDITOR_TAG, KCFSCardEditor); window.customCards = window.customCards || []; window.customCards.push({ type: "k-cfs-card", name: "Creality CFS Card", preview: true, description: "A card to control the Creality Filament System (CFS)" });