2706 lines
93 KiB
JavaScript
2706 lines
93 KiB
JavaScript
|
|
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, """)
|
|
.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 = `
|
|
<ha-card class="${modeClass}">
|
|
<style>${style}</style>
|
|
<div id="content"></div>
|
|
</ha-card>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* 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 = `<div class="no-data">${esc(this._t("no_data"))}</div>`;
|
|
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
|
|
? `
|
|
<div class="unit-selector">
|
|
${boxes.map((b, idx) => `
|
|
<button class="unit-btn ${idx === this._selectedCFS ? 'active' : ''}" data-cfs="${idx}">
|
|
${esc(this._t("cfs_number_label", { number: b.id + 1 }))}
|
|
</button>
|
|
`).join('')}
|
|
</div>
|
|
`
|
|
: '';
|
|
|
|
const bays = slots.map((slot) => {
|
|
if (!slot) return '<div class="bay"></div>';
|
|
const safeType = !isSentinel(slot.type) ? String(slot.type).trim() : "-";
|
|
const hasFilament = safeType !== "-";
|
|
const pct = hasFilament && slot.percent !== null ? Math.round(slot.percent) : 0;
|
|
return `
|
|
<div class="bay" data-eid="${esc(slot.entity_id)}">
|
|
${this._renderEditButton(slot, true)}
|
|
<div class="bay-spool" style="--spool-color: ${esc(slot.color || '#cccccc')}"></div>
|
|
<div class="bay-label">
|
|
<span class="bay-type">${esc(safeType)}</span>
|
|
<span class="bay-pct">${pct}%</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
const env = [];
|
|
if (box?.temp && box.temp !== "-") env.push(`<span class="env-temp">${esc(box.temp)}</span>`);
|
|
if (box?.humidity && box.humidity !== "-") {
|
|
env.push(`<span class="env-hum" style="color: ${KCFSCard._sanitizeColor(box.humidityColor)}">${esc(box.humidity)}</span>`);
|
|
}
|
|
|
|
return `
|
|
${unitSelector}
|
|
<div class="box-view">
|
|
<img class="box-image" src="${ASSET_URL_BASE}cfs_box.webp" alt="${esc(this._t("alt_cfs_box"))}" />
|
|
<div class="bays">${bays}</div>
|
|
</div>
|
|
${env.length ? `<div class="env-info">${env.join(' <span style="color: var(--divider-color)">•</span> ')}</div>` : ''}
|
|
${this._renderExternalCompact(external)}
|
|
`;
|
|
}
|
|
|
|
_renderNormalMode(boxes, external) {
|
|
// Ensure we have at least one box
|
|
if (boxes.length === 0 && !external) {
|
|
return `<div class="no-data">${esc(this._t("no_data"))}</div>`;
|
|
}
|
|
|
|
// Unit selector (only if we have multiple boxes)
|
|
let unitSelector = '';
|
|
if (boxes.length > 1) {
|
|
unitSelector = `
|
|
<div class="unit-selector">
|
|
${boxes.map((box, idx) => `
|
|
<button class="unit-btn ${idx === this._selectedCFS ? 'active' : ''}" data-cfs="${idx}">
|
|
${esc(this._t("cfs_number_label", { number: box.id + 1 }))}
|
|
</button>
|
|
`).join('')}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Get the selected box
|
|
const selectedBox = boxes[this._selectedCFS] || boxes[0];
|
|
if (!selectedBox && !external) {
|
|
return `<div class="no-data">${esc(this._t("no_data"))}</div>`;
|
|
}
|
|
|
|
// 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 ? `<span class="env-temp">${esc(tempStr)}</span>` : '';
|
|
const humHtml = humStr ? `<span class="env-hum" style="color: ${KCFSCard._sanitizeColor(selectedBox.humidityColor)}">${esc(humStr)}</span>` : '';
|
|
const separator = tempStr && humStr ? ' <span style="color: var(--divider-color)">•</span> ' : '';
|
|
envInfo = `<div class="env-info">${tempHtml}${separator}${humHtml}</div>`;
|
|
}
|
|
}
|
|
|
|
const header = `
|
|
<div class="header">
|
|
<div class="title-section">
|
|
<div class="title">${esc(this._cfg.name || 'Creality CFS')}</div>
|
|
</div>
|
|
${envInfo}
|
|
</div>
|
|
`;
|
|
|
|
// Spool grid
|
|
let spoolGrid = '';
|
|
if (selectedBox) {
|
|
spoolGrid = `
|
|
<div class="spool-grid">
|
|
${selectedBox.slots.map((slot) => this._renderSpoolCard(slot)).join('')}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// 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 = `
|
|
<div class="external-section">
|
|
<div class="external-normal" data-eid="${esc(external.entity_id)}">
|
|
${this._renderEditButton(external)}
|
|
<div class="ext-icon">${esc(this._t("ext_label"))}</div>
|
|
<div class="ext-info">
|
|
<div class="ext-name">${esc(displayName)}</div>
|
|
<div class="ext-bar">
|
|
<div class="ext-fill" style="width: ${Number(pct) || 0}%"></div>
|
|
</div>
|
|
</div>
|
|
<div class="ext-percent">${esc(percentTextDisplay)}</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
return `${unitSelector}${header}${spoolGrid}${externalSection}`;
|
|
}
|
|
|
|
_renderCompactMode(boxes, external) {
|
|
if (boxes.length === 0 && !external) {
|
|
return `<div class="no-data">${esc(this._t("no_data"))}</div>`;
|
|
}
|
|
|
|
// CFS rows
|
|
let cfsRows = '';
|
|
if (boxes.length > 0) {
|
|
cfsRows = `
|
|
<div class="cfs-rows">
|
|
${boxes.map((box) => this._renderCFSRow(box)).join('')}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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 `
|
|
<div class="external-section">
|
|
<div class="external-compact" data-eid="${esc(external.entity_id)}">
|
|
${this._renderEditButton(external)}
|
|
<div class="ext-dot">${esc(this._t("ext_label"))}</div>
|
|
<div class="ext-compact-info">
|
|
<div>${esc(displayName)}</div>
|
|
<div>${esc(percentTextDisplay)}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
_renderCFSRow(box) {
|
|
const tempStr = box.temp !== "-" ? box.temp : '';
|
|
const humStr = box.humidity !== "-" ? box.humidity : '';
|
|
|
|
let envHtml = '';
|
|
if (tempStr || humStr) {
|
|
envHtml = `
|
|
<div class="env-mini">
|
|
${tempStr ? `<div class="temp">${esc(tempStr)}</div>` : ''}
|
|
${humStr ? `<div class="hum" style="color: ${KCFSCard._sanitizeColor(box.humidityColor)}">${esc(humStr)}</div>` : ''}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
return `
|
|
<div class="cfs-row">
|
|
<div class="cfs-label">${esc(this._t("cfs_number_label", { number: box.id + 1 }))}</div>
|
|
<div class="spools-inline">
|
|
${box.slots.map((slot) => this._renderSpoolMini(slot)).join('')}
|
|
</div>
|
|
${envHtml}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* 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 `
|
|
<button type="button"
|
|
class="${mini ? "edit-btn-mini" : "edit-btn"}"
|
|
data-edit="${esc(slot.entity_id)}"
|
|
title="${esc(title)}"
|
|
aria-label="${esc(title)}"
|
|
${disabled ? 'aria-disabled="true"' : ""}>
|
|
<ha-icon icon="${disabled ? mdi("lock") : mdi("pencil")}"></ha-icon>
|
|
</button>
|
|
`;
|
|
}
|
|
|
|
_renderSpoolCard(slot) {
|
|
if (!slot) {
|
|
return `<div class="spool-card"></div>`;
|
|
}
|
|
|
|
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 ? '<div class="status-badge"></div>' : '';
|
|
|
|
return `
|
|
<div class="spool-card ${isActive ? 'active' : ''}" data-eid="${esc(slot.entity_id)}">
|
|
${badge}
|
|
${this._renderEditButton(slot)}
|
|
<div class="ring-container">
|
|
<div class="ring-outer" style="--spool-color: ${esc(color)}; --spool-pct: ${Number(pct) || 0}%"></div>
|
|
<div class="ring-inner">
|
|
<span class="spool-pct">${pctDisplay}%</span>
|
|
<span class="spool-label">${esc(safeType)}</span>
|
|
</div>
|
|
</div>
|
|
<div class="material-name">${esc(safeName)}</div>
|
|
<div class="color-name">${esc(percentTextDisplay)}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
_renderSpoolMini(slot) {
|
|
const showType = this._cfg.show_type_in_mini;
|
|
|
|
if (!slot) {
|
|
if (showType) {
|
|
return `<div class="spool-mini-wrapper"><div class="spool-mini" style="--spool-color: #333; --spool-pct: 0%"><span>-</span></div><div class="spool-mini-type">-</div></div>`;
|
|
}
|
|
return `<div class="spool-mini" style="--spool-color: #333; --spool-pct: 0%"><span>-</span></div>`;
|
|
}
|
|
|
|
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 `
|
|
<div class="spool-mini-wrapper">
|
|
<div class="spool-mini ${isActive ? 'active' : ''}"
|
|
style="--spool-color: ${esc(color)}; --spool-pct: ${Number(pct) || 0}%"
|
|
data-eid="${esc(slot.entity_id)}">
|
|
<span>${pctDisplay}</span>
|
|
</div>
|
|
<div class="spool-mini-type">${esc(safeType)}</div>
|
|
${this._renderEditButton(slot, true)}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
return `
|
|
<div class="spool-mini-wrapper">
|
|
<div class="spool-mini ${isActive ? 'active' : ''}"
|
|
style="--spool-color: ${esc(color)}; --spool-pct: ${Number(pct) || 0}%"
|
|
data-eid="${esc(slot.entity_id)}">
|
|
<span>${pctDisplay}</span>
|
|
</div>
|
|
${this._renderEditButton(slot, true)}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* 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<string|null>}
|
|
*/
|
|
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
|
|
* <style> -- so its 120 lines of dialog CSS never applied and everything was
|
|
* styled with ~90 inline assignments instead. :host and ha-card already force
|
|
* overflow:visible and contain:none, so a fixed overlay still escapes the card.
|
|
*/
|
|
_showEditDialog(entityId) {
|
|
if (!this._hass || !this._root) return;
|
|
|
|
const slot = this._findSlot(entityId);
|
|
if (!slot) return;
|
|
|
|
// Re-check: the affordance may have been rendered before the print started.
|
|
if (this._isPrinterBusy()) {
|
|
this._showToast(this._t("toast_printer_busy"));
|
|
return;
|
|
}
|
|
if (this._deviceIdError) {
|
|
this._showToast(this._t(this._deviceIdError));
|
|
return;
|
|
}
|
|
if (slot.printerBoxId === null || slot.printerBoxId === undefined) {
|
|
this._showToast(this._t("toast_external_not_supported"));
|
|
return;
|
|
}
|
|
|
|
const overlay = document.createElement("div");
|
|
overlay.className = "edit-overlay";
|
|
const dialog = document.createElement("div");
|
|
dialog.className = "edit-dialog";
|
|
// Modal semantics: without these the overlay is an anonymous <div> that a
|
|
// screen reader cannot announce and a keyboard user cannot leave, since
|
|
// click-outside was the only dismissal besides the Cancel button.
|
|
dialog.setAttribute("role", "dialog");
|
|
dialog.setAttribute("aria-modal", "true");
|
|
dialog.setAttribute("aria-label", this._t("dialog_edit_title"));
|
|
dialog.tabIndex = -1;
|
|
overlay.appendChild(dialog);
|
|
|
|
const close = () => {
|
|
overlay.removeEventListener("keydown", onKeyDown);
|
|
if (overlay.remove) overlay.remove();
|
|
};
|
|
const onKeyDown = (ev) => {
|
|
if (ev.key === "Escape" || ev.key === "Esc") {
|
|
ev.stopPropagation();
|
|
close();
|
|
}
|
|
};
|
|
overlay.addEventListener("keydown", onKeyDown);
|
|
overlay.addEventListener("click", (ev) => { if (ev.target === overlay) close(); });
|
|
|
|
dialog.appendChild(this._renderEditForm(slot, close));
|
|
this._root.appendChild(overlay);
|
|
// Focus the dialog itself rather than the first field: ha-form upgrades
|
|
// asynchronously, so its inputs may not exist yet.
|
|
if (dialog.focus) dialog.focus();
|
|
}
|
|
|
|
/**
|
|
* Build the dialog body.
|
|
*
|
|
* The six text/number fields go through ha-form, which the editor already uses:
|
|
* it gives HA theming, accessibility and sensible mobile keyboards for free,
|
|
* and replaces most of PR #75's hand-rolled inputs. Colour stays hand-built
|
|
* because HA has no hex-colour selector (color_rgb returns [r, g, b]).
|
|
* @param {object} slot
|
|
* @param {Function} close
|
|
* @returns {object} the dialog content element
|
|
*/
|
|
_renderEditForm(slot, close) {
|
|
const container = document.createElement("div");
|
|
|
|
const heading = document.createElement("h2");
|
|
heading.textContent = this._t("dialog_edit_title");
|
|
container.appendChild(heading);
|
|
|
|
// Always show which slot is about to be written -- a mis-mapped card is then
|
|
// visible before saving rather than after.
|
|
const target = document.createElement("div");
|
|
target.className = "edit-target";
|
|
target.textContent = this._t("dialog_edit_target", {
|
|
box: slot.printerBoxId,
|
|
slot: slot.printerSlotId,
|
|
});
|
|
container.appendChild(target);
|
|
|
|
if (slot.targetIsGuessed) {
|
|
const warn = document.createElement("div");
|
|
warn.className = "edit-warning";
|
|
warn.textContent = this._t("warn_box_id_guessed");
|
|
container.appendChild(warn);
|
|
}
|
|
|
|
// `slot.name` is a sensor *state*, so an entity with no value reads back as
|
|
// the literal "unknown" -- and the type field is the only required one, so
|
|
// filling that in and saving an otherwise untouched form wrote "unknown"
|
|
// to the spool as its material name. Same sentinel set the display path
|
|
// uses, rather than the "-" check this had.
|
|
const clean = (value) => (isSentinel(value) ? "" : String(value).trim());
|
|
|
|
const values = {
|
|
type: clean(slot.type),
|
|
// The bare `name` attribute only. `slot.name` is the sensor *state*, which
|
|
// `format_filament_label` composes from vendor plus name -- or vendor plus
|
|
// *type* when the printer reports no name at all, e.g. "Creality PLA". As
|
|
// a fallback it prefilled that composite as the material name and
|
|
// `_saveMaterial` wrote it back, putting the vendor inside the name, which
|
|
// is the duplication issue #115 removed from the display path.
|
|
name: clean(slot.materialName),
|
|
vendor: clean(slot.vendor),
|
|
min_temp: slot.minTemp ?? undefined,
|
|
max_temp: slot.maxTemp ?? undefined,
|
|
pressure: slot.pressure ?? undefined,
|
|
};
|
|
|
|
const form = document.createElement("ha-form");
|
|
form.hass = this._hass;
|
|
form.data = values;
|
|
// Bounds mirror services.yaml and the service schema so all three agree.
|
|
form.schema = [
|
|
{ name: "type", selector: { text: {} } },
|
|
{ name: "name", selector: { text: {} } },
|
|
{ name: "vendor", selector: { text: {} } },
|
|
{ name: "min_temp", selector: { number: { min: 150, max: 300, step: 1, unit_of_measurement: "°C", mode: "box" } } },
|
|
{ name: "max_temp", selector: { number: { min: 150, max: 350, step: 1, unit_of_measurement: "°C", mode: "box" } } },
|
|
{ name: "pressure", selector: { number: { min: 0, max: 1, step: 0.01, mode: "box" } } },
|
|
];
|
|
form.computeLabel = (s) => this._t(`label_material_${s.name}`);
|
|
form.addEventListener("value-changed", (ev) => {
|
|
Object.assign(values, ev.detail.value);
|
|
});
|
|
container.appendChild(form);
|
|
|
|
// ---- colour -----------------------------------------------------------
|
|
const colourRow = document.createElement("div");
|
|
colourRow.className = "colour-row";
|
|
const colourLabel = document.createElement("label");
|
|
colourLabel.textContent = this._t("label_material_color");
|
|
colourRow.appendChild(colourLabel);
|
|
|
|
const colourInputs = document.createElement("div");
|
|
colourInputs.className = "colour-inputs";
|
|
// _sanitizeColor preserves a three-digit colour, but input[type=color]
|
|
// rejects #abc (falling back to #000000, so the swatch stops matching the
|
|
// text field) and _saveMaterial demands six digits -- it would refuse a
|
|
// colour the printer itself reported.
|
|
// Only a colour the printer actually reported. `slot.color` falls back to
|
|
// the display grey, and prefilling that sent `#cccccc` to the printer for
|
|
// anyone who opened the dialog to change something else and pressed Save.
|
|
// `_saveMaterial` already omits an empty colour from the payload.
|
|
const prefill = slot.colorIsKnown ? toSixDigitHex(slot.color) || "" : "";
|
|
const picker = document.createElement("input");
|
|
picker.type = "color";
|
|
picker.value = prefill || "#cccccc";
|
|
const hex = document.createElement("input");
|
|
hex.type = "text";
|
|
hex.value = slot.isMultiColour ? "" : prefill;
|
|
hex.placeholder = "#rrggbb";
|
|
|
|
if (slot.isMultiColour) {
|
|
// A two-colour spool cannot be expressed as one value, so editing the
|
|
// colour at all would quietly discard half of it.
|
|
picker.disabled = true;
|
|
hex.disabled = true;
|
|
hex.placeholder = this._t("tooltip_multicolour_readonly");
|
|
colourRow.title = this._t("tooltip_multicolour_readonly");
|
|
} else {
|
|
picker.addEventListener("input", () => { hex.value = picker.value; });
|
|
hex.addEventListener("input", () => {
|
|
if (/^#[0-9a-fA-F]{6}$/.test(hex.value)) picker.value = hex.value;
|
|
});
|
|
}
|
|
|
|
colourInputs.appendChild(picker);
|
|
colourInputs.appendChild(hex);
|
|
colourRow.appendChild(colourInputs);
|
|
|
|
if (!slot.isMultiColour) {
|
|
colourRow.appendChild(this._renderPresets(picker, hex));
|
|
}
|
|
|
|
container.appendChild(colourRow);
|
|
|
|
// ---- actions ----------------------------------------------------------
|
|
const actions = document.createElement("div");
|
|
actions.className = "dialog-actions";
|
|
|
|
const cancel = document.createElement("button");
|
|
cancel.type = "button";
|
|
cancel.className = "dialog-btn secondary";
|
|
cancel.textContent = this._t("btn_cancel");
|
|
cancel.addEventListener("click", close);
|
|
actions.appendChild(cancel);
|
|
|
|
const save = document.createElement("button");
|
|
save.type = "button";
|
|
save.className = "dialog-btn primary";
|
|
save.textContent = this._t("btn_save");
|
|
save.addEventListener("click", async () => {
|
|
save.disabled = true;
|
|
save.textContent = this._t("btn_saving");
|
|
const ok = await this._saveMaterial(slot, {
|
|
...values,
|
|
color: slot.isMultiColour ? undefined : hex.value.trim(),
|
|
});
|
|
if (ok) {
|
|
close();
|
|
} else {
|
|
save.disabled = false;
|
|
save.textContent = this._t("btn_save");
|
|
}
|
|
});
|
|
actions.appendChild(save);
|
|
container.appendChild(actions);
|
|
|
|
return container;
|
|
}
|
|
|
|
/**
|
|
* Swatch row plus preset management, wired to the colour inputs.
|
|
*
|
|
* Rebuilt in place when presets change rather than by reopening the dialog:
|
|
* PR #75 tore the whole dialog down and reopened it on a timer, which threw
|
|
* away whatever the user had already typed into the other fields.
|
|
* @param {object} picker the <input type="color">
|
|
* @param {object} hex the hex text input
|
|
* @returns {object} the presets section element
|
|
*/
|
|
_renderPresets(picker, hex) {
|
|
// Constructed on first use, so a dashboard full of cards does not all touch
|
|
// localStorage just to render.
|
|
this._presets = sharedPresets();
|
|
|
|
const section = document.createElement("div");
|
|
section.className = "presets";
|
|
|
|
const label = document.createElement("label");
|
|
label.textContent = this._t("label_color_presets");
|
|
section.appendChild(label);
|
|
|
|
const swatches = document.createElement("div");
|
|
swatches.className = "swatches";
|
|
section.appendChild(swatches);
|
|
|
|
const apply = (colour) => {
|
|
picker.value = colour;
|
|
hex.value = colour;
|
|
};
|
|
|
|
const rebuild = () => {
|
|
// Scoped to this row, not the document: PR #75 used a document-wide query
|
|
// that also reset swatches in a second card's open dialog.
|
|
swatches.innerHTML = "";
|
|
|
|
for (const [name, colour] of this._presets.entries()) {
|
|
const swatch = document.createElement("button");
|
|
swatch.type = "button";
|
|
swatch.className = "swatch";
|
|
swatch.title = name;
|
|
swatch.setAttribute("aria-label", name);
|
|
swatch.style.background = colour;
|
|
swatch.addEventListener("click", () => apply(colour));
|
|
|
|
if (this._presets.isCustom(name)) {
|
|
swatch.classList?.add?.("custom");
|
|
// Long-press-free management: a modifier click removes a custom preset,
|
|
// which keeps the row compact without a second list.
|
|
swatch.addEventListener("contextmenu", (ev) => {
|
|
ev.preventDefault?.();
|
|
if (this._presets.remove(name)) {
|
|
this._showToast(this._t("toast_preset_deleted", { name }));
|
|
rebuild();
|
|
}
|
|
});
|
|
}
|
|
swatches.appendChild(swatch);
|
|
}
|
|
};
|
|
rebuild();
|
|
|
|
const saveRow = document.createElement("div");
|
|
saveRow.className = "preset-save";
|
|
const nameInput = document.createElement("input");
|
|
nameInput.type = "text";
|
|
nameInput.placeholder = this._t("label_preset_name");
|
|
const saveBtn = document.createElement("button");
|
|
saveBtn.type = "button";
|
|
saveBtn.className = "dialog-btn secondary";
|
|
saveBtn.textContent = this._t("btn_save_preset");
|
|
saveBtn.addEventListener("click", () => {
|
|
const name = nameInput.value.trim();
|
|
if (!name) {
|
|
this._showToast(this._t("toast_preset_name_required"));
|
|
return;
|
|
}
|
|
if (this._presets.save(name, hex.value)) {
|
|
nameInput.value = "";
|
|
this._showToast(this._t("toast_preset_saved", { name }));
|
|
rebuild();
|
|
} else {
|
|
this._showToast(this._t("toast_colour_invalid"));
|
|
}
|
|
});
|
|
saveRow.appendChild(nameInput);
|
|
saveRow.appendChild(saveBtn);
|
|
section.appendChild(saveRow);
|
|
|
|
const hint = document.createElement("div");
|
|
hint.className = "preset-hint";
|
|
hint.textContent = this._t("hint_delete_preset");
|
|
section.appendChild(hint);
|
|
|
|
return section;
|
|
}
|
|
|
|
/**
|
|
* Send one slot's edits to the service.
|
|
* @returns {Promise<boolean>} whether the write was accepted
|
|
*/
|
|
async _saveMaterial(slot, formData) {
|
|
const deviceId = await this._resolveDeviceId();
|
|
if (!deviceId) {
|
|
this._showToast(this._t(this._deviceIdError || "toast_no_device"));
|
|
return false;
|
|
}
|
|
|
|
const type = String(formData.type || "").trim();
|
|
if (!type) {
|
|
this._showToast(this._t("toast_type_required"));
|
|
return false;
|
|
}
|
|
|
|
// Number.isFinite, not `||`: `0` is a legitimate pressure and a truthiness
|
|
// check would silently replace it with the default.
|
|
const num = (value) => {
|
|
const n = Number(value);
|
|
return Number.isFinite(n) ? n : undefined;
|
|
};
|
|
const minTemp = num(formData.min_temp);
|
|
const maxTemp = num(formData.max_temp);
|
|
if (minTemp !== undefined && maxTemp !== undefined && maxTemp < minTemp) {
|
|
// Surfaced rather than clamped: someone who typed these wants to know.
|
|
this._showToast(this._t("toast_temp_range_invalid"));
|
|
return false;
|
|
}
|
|
|
|
const colour = String(formData.color || "").trim();
|
|
if (colour && !/^#?[0-9a-fA-F]{6}$/.test(colour)) {
|
|
this._showToast(this._t("toast_colour_invalid"));
|
|
return false;
|
|
}
|
|
|
|
const payload = {
|
|
device_id: deviceId,
|
|
box_id: slot.printerBoxId,
|
|
slot_id: slot.printerSlotId,
|
|
type,
|
|
};
|
|
if (formData.name && String(formData.name).trim()) payload.name = String(formData.name).trim();
|
|
if (formData.vendor && String(formData.vendor).trim()) payload.vendor = String(formData.vendor).trim();
|
|
if (colour) payload.color = colour.startsWith("#") ? colour : `#${colour}`;
|
|
if (minTemp !== undefined) payload.min_temp = minTemp;
|
|
if (maxTemp !== undefined) payload.max_temp = maxTemp;
|
|
const pressure = num(formData.pressure);
|
|
if (pressure !== undefined) payload.pressure = pressure;
|
|
// Pass the printer's existing tag id straight back; omitting the key would
|
|
// be fine too, but sending "" would erase it.
|
|
if (slot.rfid) payload.rfid = slot.rfid;
|
|
|
|
try {
|
|
await this._hass.callService("ha_creality_ws", "set_cfs_material", payload);
|
|
} catch (err) {
|
|
this._showToast(this._t("toast_save_failed", { error: err?.message || err }));
|
|
return false;
|
|
}
|
|
|
|
this._showToast(this._t("toast_saved"));
|
|
// Ask the printer to re-report so the card shows what actually landed.
|
|
try {
|
|
await this._hass.callService("ha_creality_ws", "request_cfs_info", {
|
|
device_id: deviceId,
|
|
});
|
|
} catch (_) {
|
|
// The write succeeded; a failed refresh only delays the display.
|
|
}
|
|
return true;
|
|
}
|
|
|
|
_attachEventHandlers() {
|
|
// Unit selector buttons
|
|
this._root.querySelectorAll('.unit-btn').forEach(btn => {
|
|
btn.onclick = () => {
|
|
const cfsIdx = parseInt(btn.dataset.cfs, 10);
|
|
if (!isNaN(cfsIdx)) {
|
|
this._selectedCFS = cfsIdx;
|
|
// _selectedCFS is in the fingerprint, so go through the gate and keep
|
|
// the snapshot consistent with what is on screen.
|
|
this._updateIfChanged();
|
|
}
|
|
};
|
|
});
|
|
|
|
// Edit buttons. Bound before the more-info handler below, and stopping
|
|
// propagation so clicking edit does not also open the entity dialog.
|
|
this._root.querySelectorAll('.edit-btn, .edit-btn-mini').forEach(btn => {
|
|
btn.onclick = (ev) => {
|
|
ev.stopPropagation();
|
|
// aria-disabled rather than the disabled property: the point of letting
|
|
// the click through is to say why editing is blocked. _showEditDialog
|
|
// re-checks all three conditions, so this is an explanation, not the
|
|
// guard.
|
|
if (btn.getAttribute("aria-disabled") === "true") {
|
|
this._showToast(btn.title);
|
|
return;
|
|
}
|
|
this._showEditDialog(btn.dataset.edit);
|
|
};
|
|
});
|
|
|
|
// Spool cards and mini spools - show more info
|
|
this._root.querySelectorAll('.spool-card, .spool-mini, .spool-mini-wrapper .spool-mini, .bay, .external-normal, .external-compact').forEach(el => {
|
|
const eid = el.dataset.eid;
|
|
if (!eid) return;
|
|
|
|
el.onclick = () => {
|
|
this.dispatchEvent(new CustomEvent("hass-more-info", {
|
|
detail: { entityId: eid },
|
|
bubbles: true,
|
|
composed: true,
|
|
}));
|
|
};
|
|
});
|
|
}
|
|
|
|
getCardSize() {
|
|
// Return a dynamic size based on compact mode and number of boxes
|
|
if (this._cfg?.view_mode === "compact") {
|
|
// Count configured boxes
|
|
let boxCount = 0;
|
|
for (let box = 0; box < 4; box++) {
|
|
const hasBox = this._cfg[`box${box}_temp`] || this._cfg[`box${box}_humidity`] ||
|
|
[0, 1, 2, 3].some(s => this._cfg[`box${box}_slot${s}_filament`] || this._cfg[`box${box}_slot${s}_color`] || this._cfg[`box${box}_slot${s}_percent`]);
|
|
if (hasBox) boxCount++;
|
|
}
|
|
// Check for external filament
|
|
const hasExternal = this._cfg.external_filament || this._cfg.external_color || this._cfg.external_percent;
|
|
const externalRows = hasExternal ? 1 : 0;
|
|
|
|
// Add extra space when more than 2 rows
|
|
const extraPadding = (boxCount + externalRows) > 2 ? 1 : 0;
|
|
|
|
return Math.max(1, boxCount + externalRows + extraPadding);
|
|
}
|
|
return 5;
|
|
}
|
|
|
|
getLayoutOptions() {
|
|
// Count configured boxes for dynamic sizing
|
|
let boxCount = 0;
|
|
if (this._cfg) {
|
|
for (let box = 0; box < 4; box++) {
|
|
const hasBox = this._cfg[`box${box}_temp`] || this._cfg[`box${box}_humidity`] ||
|
|
[0, 1, 2, 3].some(s => this._cfg[`box${box}_slot${s}_filament`] || this._cfg[`box${box}_slot${s}_color`] || this._cfg[`box${box}_slot${s}_percent`]);
|
|
if (hasBox) boxCount++;
|
|
}
|
|
}
|
|
|
|
// Check for external filament
|
|
const hasExternal = this._cfg?.external_filament || this._cfg?.external_color || this._cfg?.external_percent;
|
|
const externalRows = hasExternal ? 1 : 0;
|
|
|
|
// Add extra space when more than 2 rows
|
|
const totalRows = boxCount + externalRows;
|
|
const extraPadding = totalRows > 2 ? 1 : 0;
|
|
|
|
const minRows = this._cfg?.view_mode === "compact" ? Math.max(1, totalRows + extraPadding) : 5;
|
|
|
|
return {
|
|
grid_rows: minRows,
|
|
grid_min_rows: minRows,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Register a custom element at most once.
|
|
*
|
|
* A dashboard can end up importing this module twice -- two Lovelace resource
|
|
* entries, or a page that was open across a Home Assistant restart picking up
|
|
* the new `?v=` alongside the copy it already had. A bare define() throws on
|
|
* the second pass, which aborts the rest of that module: the tag then keeps
|
|
* whichever class won the race while the functions around it come from the
|
|
* other copy, and the mismatch shows up as methods that exist in the source
|
|
* but not on the instance.
|
|
* @param {string} tag
|
|
* @param {!Function} cls
|
|
*/
|
|
function defineOnce(tag, cls) {
|
|
if (customElements.get(tag)) return;
|
|
try {
|
|
customElements.define(tag, cls);
|
|
} catch (err) {
|
|
console.error(`ha_creality_ws: could not define <${tag}>`, err);
|
|
}
|
|
}
|
|
|
|
defineOnce(CARD_TAG, KCFSCard);
|
|
|
|
class KCFSCardEditor extends HTMLElement {
|
|
// i18n helpers -------------------------------------------------------
|
|
_resolveLanguage() {
|
|
return _resolveLang(this._hass);
|
|
}
|
|
_t(key, vars) {
|
|
return _translate(this._hass, "cfs_card", CFS_TRANSLATIONS, key, vars);
|
|
}
|
|
// ---------------------------------------------------------------------
|
|
|
|
set hass(hass) {
|
|
this._hass = hass;
|
|
if (this._form) this._form.hass = hass;
|
|
_requestI18n(this, hass, () => { if (this._root) this._render(); });
|
|
}
|
|
|
|
setConfig(config) {
|
|
this._cfg = { ...KCFSCard.getStubConfig(), ...KCFSCard._migrateConfig(config) };
|
|
this._render();
|
|
}
|
|
|
|
connectedCallback() {
|
|
this._render();
|
|
}
|
|
|
|
_render() {
|
|
if (!this._root) {
|
|
this._root = this.attachShadow({ mode: "open" });
|
|
}
|
|
|
|
const style = `
|
|
.editor-container { padding: 16px; }
|
|
.tabs { display: flex; border-bottom: 1px solid var(--divider-color); margin-bottom: 16px; }
|
|
.tab { padding: 8px 16px; cursor: pointer; border-bottom: 2px solid transparent; }
|
|
.tab.active { border-bottom-color: var(--primary-color); color: var(--primary-color); }
|
|
.tab-content { display: none; }
|
|
.tab-content.active { display: block; }
|
|
.input-helper { font-size: 0.9em; color: var(--secondary-text-color); margin-top: 4px; padding: 0 8px; }
|
|
`;
|
|
|
|
this._root.innerHTML = `
|
|
<style>${style}</style>
|
|
<div class="editor-container">
|
|
<div class="tabs">
|
|
<div class="tab active" data-tab="entities">${this._t("tab_entities")}</div>
|
|
<div class="tab" data-tab="theme">${this._t("tab_theme")}</div>
|
|
</div>
|
|
<div class="tab-content active" id="entities-tab">
|
|
<ha-form id="form"></ha-form>
|
|
</div>
|
|
<div class="tab-content" id="theme-tab">
|
|
<ha-form id="theme-form"></ha-form>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
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)"
|
|
});
|