Files
HomeAssistantVS/custom_components/ha_creality_ws/www/k_cfs_card.js
T

1222 lines
37 KiB
JavaScript

const CARD_TAG = "k-cfs-card";
const EDITOR_TAG = "k-cfs-card-editor";
const mdi = (name) => `mdi:${name}`;
const I18N_URL_BASE = "/ha_creality_ws/i18n/";
const _i18nData = {};
const _i18nPromises = {};
function _loadI18n(lang) {
if (_i18nData[lang]) return Promise.resolve(_i18nData[lang]);
if (_i18nPromises[lang]) return _i18nPromises[lang];
_i18nPromises[lang] = fetch(`${I18N_URL_BASE}${lang}.json`)
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (data) _i18nData[lang] = data;
else _i18nPromises[lang] = null;
return data;
})
.catch(() => { _i18nPromises[lang] = null; return null; });
return _i18nPromises[lang];
}
function _resolveLang(hass) {
return hass?.locale?.language || hass?.language || "en";
}
function _translate(hass, section, fallbackDict, key, vars) {
const lang = _resolveLang(hass);
const short = lang.split("-")[0];
const remote = _i18nData[lang]?.[section] ?? _i18nData[short]?.[section];
const remoteEn = _i18nData["en"]?.[section];
let text = (remote && key in remote) ? remote[key]
: (remoteEn && key in remoteEn) ? remoteEn[key]
: (fallbackDict[lang]?.[key] ?? fallbackDict[short]?.[key] ?? fallbackDict["en"]?.[key] ?? key);
if (vars) {
Object.entries(vars).forEach(([k, v]) => {
text = text.replace(new RegExp(`\\{${k}\\}`, "g"), v);
});
}
return text;
}
function _requestI18n(instance, hass, onLoaded) {
if (instance._i18nRequested) return;
instance._i18nRequested = true;
const lang = _resolveLang(hass).split("-")[0];
Promise.all([_loadI18n("en"), lang !== "en" ? _loadI18n(lang) : null]).then(onLoaded);
}
const CFS_TRANSLATIONS = {
en: {
no_data: "No CFS data available",
ext_label: "EXT",
cfs_label: "CFS",
cfs_number_label: "CFS {number}",
// Editor
label_card_title: "Card Title",
label_external_filament: "External Filament",
label_external_color: "External Color",
label_external_percent: "External Percent",
label_box_temp: "Box {box} Temperature",
label_box_humidity: "Box {box} Humidity",
label_slot_filament: "Box {box} Slot {slot} Filament",
label_slot_color: "Box {box} Slot {slot} Color",
label_slot_percent: "Box {box} Slot {slot} Remaining Percent",
schema_compact_view: "Compact View (Mini Mode)",
schema_show_type_in_mini: "Show Filament Type in Mini Mode",
// Editor tab/header
tab_entities: "Entities",
tab_theme: "Theme",
},
};
class KCFSCard extends HTMLElement {
constructor() {
super();
this._selectedCFS = 0; // Track selected CFS tab in normal mode
}
static _sanitizeColor(value) {
const raw = String(value || "").trim();
if (!raw || ["unknown", "unavailable", "—"].includes(raw.toLowerCase())) {
return "#cccccc";
}
const hex = raw.startsWith("#") ? raw.slice(1) : raw;
if (hex.length === 6 && /^[0-9a-fA-F]+$/.test(hex)) {
return `#${hex.toLowerCase()}`;
}
if (hex.length === 3 && /^[0-9a-fA-F]+$/.test(hex)) {
return `#${hex.toLowerCase()}`;
}
if (hex.length === 7 && hex.startsWith("0") && /^[0-9a-fA-F]+$/.test(hex)) {
return `#${hex.slice(1).toLowerCase()}`;
}
return "#cccccc";
}
static _parsePercent(percentObj) {
if (!percentObj) return null;
const state = percentObj.state;
if (state === undefined || state === null) return null;
const s = String(state);
if (s === "unknown" || s === "unavailable") return null;
const n = Number(s);
if (Number.isNaN(n) || !Number.isFinite(n)) return null;
return Math.max(0, Math.min(100, n));
}
static _getHumidityColor(humidityStr) {
if (!humidityStr || humidityStr === "—") return '#64b5f6'; // default blue
const match = String(humidityStr).match(/(\d+\.?\d*)/);
if (!match) return '#64b5f6';
const value = parseFloat(match[1]);
if (value < 40) return '#4caf50'; // Green (0-39%) - Ideal
if (value < 60) return '#ff9800'; // Orange (40-59%) - Attention
return '#f44336'; // Red (60-100%) - Critical
}
static getStubConfig() {
const cfg = {
name: "CFS",
compact_view: false,
show_type_in_mini: false,
external_filament: "",
external_color: "",
external_percent: "",
};
for (let box = 0; box < 4; box += 1) {
cfg[`box${box}_temp`] = "";
cfg[`box${box}_humidity`] = "";
for (let slot = 0; slot < 4; slot += 1) {
cfg[`box${box}_slot${slot}_filament`] = "";
cfg[`box${box}_slot${slot}_color`] = "";
cfg[`box${box}_slot${slot}_percent`] = "";
}
}
return cfg;
}
static getConfigElement() {
return document.createElement(EDITOR_TAG);
}
setConfig(config) {
this._cfg = { ...KCFSCard.getStubConfig(), ...config };
if (!this._root) {
this._root = this.attachShadow({ mode: "open" });
}
this._render();
}
// i18n helpers -------------------------------------------------------
_resolveLanguage() {
return _resolveLang(this._hass);
}
_t(key, vars) {
return _translate(this._hass, "cfs_card", CFS_TRANSLATIONS, key, vars);
}
// ---------------------------------------------------------------------
set hass(hass) {
this._hass = hass;
_requestI18n(this, hass, () => { this._update(); });
this._update();
}
_render() {
if (!this._root) return;
const isCompact = this._cfg.compact_view;
const style = `
/* inherit HA fonts & typography */
:host {
font: inherit;
color: var(--primary-text-color);
display: block !important;
height: auto !important;
contain: none !important;
position: relative;
z-index: 1;
}
/* unify horizontal padding so right edges line up */
:host { --row-xpad: 6px; }
ha-card {
overflow: visible !important;
display: block !important;
height: auto !important;
min-height: auto !important;
max-height: none !important;
contain: none !important;
position: relative;
z-index: 1;
}
ha-card.compact-mode,
ha-card.normal-mode {
height: auto !important;
overflow: visible !important;
}
#content {
padding: 14px;
}
.card {
border-radius: var(--ha-card-border-radius, 12px);
background: var(--card-background-color);
color: var(--primary-text-color);
box-shadow: var(--ha-card-box-shadow, 0 2px 4px rgba(0,0,0,.2));
padding: 10px var(--row-xpad) 10px var(--row-xpad);
display: flex;
flex-direction: column;
gap: 6px;
}
/* === NORMAL MODE === */
.normal-mode {}
.header {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 12px;
}
.title-section {
display: flex;
flex-direction: column;
gap: 4px;
}
.title {
font-size: 20px;
font-weight: 700;
letter-spacing: -0.5px;
}
.subtitle {
font-size: 12px;
color: var(--secondary-text-color);
font-weight: 500;
}
.env-info {
font-size: 11px;
background: rgba(var(--rgb-primary-text-color), 0.08);
padding: 6px 10px;
border-radius: 12px;
color: var(--secondary-text-color);
}
.unit-selector {
display: flex;
gap: 8px;
margin-bottom: 20px;
background: rgba(var(--rgb-primary-text-color), 0.05);
padding: 4px;
border-radius: 14px;
width: fit-content;
}
.unit-btn {
padding: 6px 16px;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
border: none;
color: var(--secondary-text-color);
background: transparent;
}
.unit-btn.active {
background: rgba(var(--rgb-primary-text-color), 0.1);
color: var(--primary-text-color);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
}
.spool-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.spool-card {
background: rgba(var(--rgb-primary-text-color), 0.04);
border-radius: var(--ha-card-border-radius, 18px);
padding: 16px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
border: 1px solid transparent;
transition: all 0.3s ease;
cursor: pointer;
}
.spool-card:hover {
background: rgba(var(--rgb-primary-text-color), 0.06);
}
.spool-card.active {
background: rgba(var(--rgb-primary-text-color), 0.08);
border: 1px solid rgba(var(--rgb-primary-color), 0.3);
box-shadow: 0 0 20px rgba(var(--rgb-primary-color), 0.2);
}
.ring-container {
width: 80px;
height: 80px;
border-radius: 50%;
position: relative;
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 10px;
}
.ring-outer {
width: 100%;
height: 100%;
border-radius: 50%;
position: absolute;
background: conic-gradient(
var(--spool-color) var(--spool-pct),
rgba(var(--rgb-primary-text-color), 0.08) 0
);
mask: radial-gradient(circle closest-side at 50% 50%, transparent 33px, black 34px);
-webkit-mask: radial-gradient(circle closest-side at 50% 50%, transparent 33px, black 34px);
}
.ring-inner {
width: 66px;
height: 66px;
background: transparent;
border-radius: 50%;
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.spool-label {
font-size: 10px;
color: var(--secondary-text-color);
text-transform: uppercase;
}
.spool-pct {
font-size: 16px;
font-weight: 700;
}
.material-name {
font-size: 13px;
font-weight: 600;
text-align: center;
}
.color-name {
font-size: 11px;
color: var(--secondary-text-color);
text-align: center;
}
.status-badge {
position: absolute;
top: 8px;
right: 8px;
width: 8px;
height: 8px;
background: var(--success-color, #4caf50);
border-radius: 50%;
box-shadow: 0 0 8px var(--success-color, #4caf50);
animation: pulse-badge 2s infinite;
}
@keyframes pulse-badge {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.7; transform: scale(1.1); }
}
/* === COMPACT MODE === */
.compact-mode {
padding: 14px;
}
.compact-mode .header {
margin-bottom: 12px;
}
.cfs-rows {
display: flex;
flex-direction: column;
gap: 10px;
}
.cfs-row {
display: flex;
align-items: center;
gap: 12px;
}
.cfs-label {
width: 48px;
font-size: 11px;
color: var(--secondary-text-color);
font-weight: 600;
}
.spools-inline {
display: flex;
gap: 10px;
flex: 1;
}
.spool-mini {
width: 34px;
height: 34px;
border-radius: 50%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s;
}
.spool-mini:hover {
transform: scale(1.05);
}
.spool-mini::before {
content: '';
position: absolute;
inset: 0;
border-radius: 50%;
background: conic-gradient(
var(--spool-color) var(--spool-pct),
rgba(var(--rgb-primary-text-color), 0.08) 0
);
mask: radial-gradient(circle closest-side at 50% 50%, transparent 12px, black 13px);
-webkit-mask: radial-gradient(circle closest-side at 50% 50%, transparent 12px, black 13px);
}
.spool-mini::after {
content: '';
position: absolute;
inset: 4px;
background: transparent;
border-radius: 50%;
z-index: 1;
}
.spool-mini span {
position: relative;
z-index: 2;
}
.spool-mini.active {
box-shadow: 0 0 10px var(--spool-color);
}
.spool-mini.active::after {
inset: 3px;
}
.spool-mini-wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.spool-mini-type {
font-size: 8px;
color: var(--secondary-text-color);
text-transform: uppercase;
font-weight: 600;
max-width: 40px;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.env-mini {
width: 56px;
display: flex;
flex-direction: column;
align-items: flex-end;
font-size: 10px;
line-height: 1.4;
gap: 2px;
}
.env-mini .temp {
color: #ffb74d;
font-weight: 600;
}
.env-mini .hum {
font-weight: 600;
/* Cor aplicada dinamicamente via inline style */
}
/* === EXTERNAL SECTION === */
.external-section {
margin-top: 16px;
padding-top: 14px;
border-top: 1px solid rgba(var(--rgb-primary-text-color), 0.08);
}
.external-normal {
background: rgba(var(--rgb-primary-text-color), 0.03);
border-radius: 16px;
padding: 12px 16px;
display: flex;
align-items: center;
gap: 12px;
}
.ext-icon {
width: 30px;
height: 30px;
background: var(--primary-color);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: bold;
color: white;
}
.ext-info {
flex-grow: 1;
}
.ext-name {
font-size: 12px;
font-weight: 600;
}
.ext-bar {
height: 4px;
background: rgba(var(--rgb-primary-text-color), 0.1);
border-radius: 2px;
margin-top: 6px;
overflow: hidden;
}
.ext-fill {
height: 100%;
background: var(--primary-color);
transition: width 0.3s ease;
}
.ext-percent {
font-size: 12px;
color: var(--secondary-text-color);
font-weight: 600;
}
.external-compact {
display: flex;
align-items: center;
gap: 10px;
}
.ext-dot {
width: 34px;
height: 34px;
border-radius: 50%;
background: conic-gradient(var(--primary-color) 100%, rgba(var(--rgb-primary-text-color), 0.1) 0);
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 600;
color: white;
}
.ext-compact-info {
flex: 1;
}
.ext-compact-info div:first-child {
font-size: 12px;
font-weight: 600;
}
.ext-compact-info div:last-child {
font-size: 10px;
color: var(--secondary-text-color);
}
.no-data {
text-align: center;
color: var(--secondary-text-color);
padding: 20px;
}
`;
this._root.innerHTML = `
<ha-card class="${isCompact ? 'compact-mode' : 'normal-mode'}">
<style>${style}</style>
<div id="content"></div>
</ha-card>
`;
}
_update() {
if (!this._root || !this._hass) return;
const contentContainer = this._root.getElementById("content");
if (!contentContainer) return;
const states = this._hass.states || {};
const gObj = (eid) => (eid ? states?.[eid] : undefined);
const fmtState = (st) => {
if (!st) return "—";
const v = st.state;
if (v === undefined || v === null) return "—";
const s = String(v);
if (s === "unknown" || s === "unavailable") return "—";
if (this._hass && typeof this._hass.formatEntityState === "function") {
try { return this._hass.formatEntityState(st); } catch (_) { }
}
const unit = st.attributes?.unit_of_measurement;
const n = Number(s);
if (!Number.isNaN(n) && Number.isFinite(n)) {
const dp = (typeof st.attributes?.display_precision === "number") ? st.attributes.display_precision
: (typeof st.attributes?.suggested_display_precision === "number") ? st.attributes.suggested_display_precision
: (unit && /°|c|f/i.test(unit)) ? 1
: 2;
const out = n.toFixed(Math.max(0, Math.min(6, dp)));
return unit ? `${out} ${unit}` : out;
}
return unit ? `${s} ${unit}` : s;
};
// Collect box data
const boxes = {};
for (let boxId = 0; boxId < 4; boxId += 1) {
const tempEid = this._cfg[`box${boxId}_temp`];
const humidityEid = this._cfg[`box${boxId}_humidity`];
const slots = [];
for (let slotId = 0; slotId < 4; slotId += 1) {
const filamentEid = this._cfg[`box${boxId}_slot${slotId}_filament`];
const colorEid = this._cfg[`box${boxId}_slot${slotId}_color`];
const percentEid = this._cfg[`box${boxId}_slot${slotId}_percent`];
if (!filamentEid && !colorEid && !percentEid) {
slots.push(null);
continue;
}
const filamentObj = gObj(filamentEid);
const colorObj = gObj(colorEid);
const percentObj = gObj(percentEid);
const name = filamentObj?.state;
const type = filamentObj?.attributes?.type;
const selected = filamentObj?.attributes?.selected;
const rawColor = colorObj?.state || filamentObj?.attributes?.color_hex;
const color = KCFSCard._sanitizeColor(rawColor);
const percent = KCFSCard._parsePercent(percentObj);
const percentText = fmtState(percentObj);
slots[slotId] = {
id: slotId,
boxId,
entity_id: filamentEid || colorEid || percentEid,
name,
type,
selected,
color,
percent,
percentText,
};
}
if (tempEid || humidityEid || slots.some((slot) => slot)) {
const humidityFormatted = fmtState(gObj(humidityEid));
boxes[boxId] = {
id: boxId,
temp: fmtState(gObj(tempEid)),
humidity: humidityFormatted,
humidityColor: KCFSCard._getHumidityColor(humidityFormatted),
slots,
};
}
}
// Collect external data
const external = {
filament: this._cfg.external_filament,
color: this._cfg.external_color,
percent: this._cfg.external_percent,
};
const hasExternal = external.filament || external.color || external.percent;
let externalData = null;
if (hasExternal) {
const filamentObj = gObj(external.filament);
const colorObj = gObj(external.color);
const percentObj = gObj(external.percent);
const name = filamentObj?.state;
const type = filamentObj?.attributes?.type;
const selected = filamentObj?.attributes?.selected;
const rawColor = colorObj?.state || filamentObj?.attributes?.color_hex;
const color = KCFSCard._sanitizeColor(rawColor);
const percent = KCFSCard._parsePercent(percentObj);
const percentText = fmtState(percentObj);
externalData = {
id: 0,
boxId: -1,
entity_id: external.filament || external.color || external.percent,
name,
type,
selected,
color,
percent,
percentText,
};
}
const boxValues = Object.values(boxes);
if (boxValues.length === 0 && !hasExternal) {
contentContainer.innerHTML = `<div class="no-data">${this._t("no_data")}</div>`;
return;
}
// Render based on mode
if (this._cfg.compact_view) {
contentContainer.innerHTML = this._renderCompactMode(boxValues, externalData);
} else {
contentContainer.innerHTML = this._renderNormalMode(boxValues, externalData);
}
this._attachEventHandlers();
}
_renderNormalMode(boxes, external) {
// Ensure we have at least one box
if (boxes.length === 0 && !external) {
return `<div class="no-data">${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}">
${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">${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">${tempStr}</span>` : '';
const humHtml = humStr ? `<span class="env-hum" style="color: ${selectedBox.humidityColor}">${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">${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 = external.type && !["unknown", "unavailable", "—", "-"].includes(String(external.type).toLowerCase()) ? external.type : "—";
const safeName = external.name && !["unknown", "unavailable", "—", "-"].includes(String(external.name).toLowerCase()) ? external.name : "—";
const hasFilament = safeType !== "—" && safeName !== "—";
const pct = hasFilament && external.percent !== null ? external.percent : 0;
const percentTextDisplay = hasFilament ? (external.percentText || '—') : '—';
const displayName = hasFilament ? `${safeName} ${safeType}` : '—';
externalSection = `
<div class="external-section">
<div class="external-normal" data-eid="${external.entity_id}">
<div class="ext-icon">${this._t("ext_label")}</div>
<div class="ext-info">
<div class="ext-name">${displayName}</div>
<div class="ext-bar">
<div class="ext-fill" style="width: ${pct}%"></div>
</div>
</div>
<div class="ext-percent">${percentTextDisplay}</div>
</div>
</div>
`;
}
return `${unitSelector}${header}${spoolGrid}${externalSection}`;
}
_renderCompactMode(boxes, external) {
if (boxes.length === 0 && !external) {
return `<div class="no-data">${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>
`;
}
// External section
let externalSection = '';
if (external) {
const safeType = external.type && !["unknown", "unavailable", "—", "-"].includes(String(external.type).toLowerCase()) ? external.type : "—";
const safeName = external.name && !["unknown", "unavailable", "—", "-"].includes(String(external.name).toLowerCase()) ? external.name : "—";
const hasFilament = safeType !== "—" && safeName !== "—";
const percentTextDisplay = hasFilament ? (external.percentText || '—') : '—';
const displayName = hasFilament ? `${safeName} ${safeType}` : '—';
externalSection = `
<div class="external-section">
<div class="external-compact" data-eid="${external.entity_id}">
<div class="ext-dot">${this._t("ext_label")}</div>
<div class="ext-compact-info">
<div>${displayName}</div>
<div>${percentTextDisplay}</div>
</div>
</div>
</div>
`;
}
return `${cfsRows}${externalSection}`;
}
_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">${tempStr}</div>` : ''}
${humStr ? `<div class="hum" style="color: ${box.humidityColor}">${humStr}</div>` : ''}
</div>
`;
}
return `
<div class="cfs-row">
<div class="cfs-label">${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>
`;
}
_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 = slot.type && !["unknown", "unavailable", "—", "-"].includes(String(slot.type).toLowerCase()) ? slot.type : "—";
const safeName = slot.name && !["unknown", "unavailable", "—", "-"].includes(String(slot.name).toLowerCase()) ? slot.name : "—";
// If no filament (type is "—" or name is "—"), show 0% regardless of actual value
const hasFilament = safeType !== "—" && safeName !== "—";
const pct = hasFilament && slot.percent !== null ? slot.percent : 0;
const pctDisplay = hasFilament && slot.percent !== null ? Math.round(slot.percent) : 0;
const percentTextDisplay = hasFilament ? (slot.percentText || '—') : '—';
const badge = isActive ? '<div class="status-badge"></div>' : '';
return `
<div class="spool-card ${isActive ? 'active' : ''}" data-eid="${slot.entity_id}">
${badge}
<div class="ring-container">
<div class="ring-outer" style="--spool-color: ${color}; --spool-pct: ${pct}%"></div>
<div class="ring-inner">
<span class="spool-pct">${pctDisplay}%</span>
<span class="spool-label">${safeType}</span>
</div>
</div>
<div class="material-name">${safeName}</div>
<div class="color-name">${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 = slot.type && !["unknown", "unavailable", "—", "-"].includes(String(slot.type).toLowerCase()) ? slot.type : "—";
const safeName = slot.name && !["unknown", "unavailable", "—", "-"].includes(String(slot.name).toLowerCase()) ? slot.name : null;
// If no filament (type is "—" or name is empty/dash), show 0% regardless of actual value
const hasFilament = safeType !== "—" && safeName !== null;
const pct = hasFilament && slot.percent !== null ? slot.percent : 0;
const pctDisplay = hasFilament && slot.percent !== null ? Math.round(slot.percent) : 0;
if (showType) {
return `
<div class="spool-mini-wrapper">
<div class="spool-mini ${isActive ? 'active' : ''}"
style="--spool-color: ${color}; --spool-pct: ${pct}%"
data-eid="${slot.entity_id}">
<span>${pctDisplay}</span>
</div>
<div class="spool-mini-type">${safeType}</div>
</div>
`;
}
return `
<div class="spool-mini ${isActive ? 'active' : ''}"
style="--spool-color: ${color}; --spool-pct: ${pct}%"
data-eid="${slot.entity_id}">
<span>${pctDisplay}</span>
</div>
`;
}
_attachEventHandlers() {
// Unit selector buttons
this._root.querySelectorAll('.unit-btn').forEach(btn => {
btn.onclick = () => {
const cfsIdx = parseInt(btn.dataset.cfs, 10);
if (!isNaN(cfsIdx)) {
this._selectedCFS = cfsIdx;
this._update();
}
};
});
// Spool cards and mini spools - show more info
this._root.querySelectorAll('.spool-card, .spool-mini, .spool-mini-wrapper .spool-mini, .external-normal, .external-compact').forEach(el => {
const eid = el.dataset.eid;
if (!eid) return;
el.onclick = () => {
this.dispatchEvent(new CustomEvent("hass-more-info", {
detail: { entityId: eid },
bubbles: true,
composed: true,
}));
};
});
}
getCardSize() {
// Return a dynamic size based on compact mode and number of boxes
if (this._cfg?.compact_view) {
// Count configured boxes
let boxCount = 0;
for (let box = 0; box < 4; box++) {
const hasBox = this._cfg[`box${box}_temp`] || this._cfg[`box${box}_humidity`] ||
[0, 1, 2, 3].some(s => this._cfg[`box${box}_slot${s}_filament`] || this._cfg[`box${box}_slot${s}_color`] || this._cfg[`box${box}_slot${s}_percent`]);
if (hasBox) boxCount++;
}
// Check for external filament
const hasExternal = this._cfg.external_filament || this._cfg.external_color || this._cfg.external_percent;
const externalRows = hasExternal ? 1 : 0;
// Add extra space when more than 2 rows
const extraPadding = (boxCount + externalRows) > 2 ? 1 : 0;
return Math.max(1, boxCount + externalRows + extraPadding);
}
return 5;
}
getLayoutOptions() {
// Count configured boxes for dynamic sizing
let boxCount = 0;
if (this._cfg) {
for (let box = 0; box < 4; box++) {
const hasBox = this._cfg[`box${box}_temp`] || this._cfg[`box${box}_humidity`] ||
[0, 1, 2, 3].some(s => this._cfg[`box${box}_slot${s}_filament`] || this._cfg[`box${box}_slot${s}_color`] || this._cfg[`box${box}_slot${s}_percent`]);
if (hasBox) boxCount++;
}
}
// Check for external filament
const hasExternal = this._cfg?.external_filament || this._cfg?.external_color || this._cfg?.external_percent;
const externalRows = hasExternal ? 1 : 0;
// Add extra space when more than 2 rows
const totalRows = boxCount + externalRows;
const extraPadding = totalRows > 2 ? 1 : 0;
const minRows = this._cfg?.compact_view ? Math.max(1, totalRows + extraPadding) : 5;
return {
grid_rows: minRows,
grid_min_rows: minRows,
};
}
}
customElements.define(CARD_TAG, KCFSCard);
class KCFSCardEditor extends HTMLElement {
// i18n helpers -------------------------------------------------------
_resolveLanguage() {
return _resolveLang(this._hass);
}
_t(key, vars) {
return _translate(this._hass, "cfs_card", CFS_TRANSLATIONS, key, vars);
}
// ---------------------------------------------------------------------
set hass(hass) {
this._hass = hass;
if (this._form) this._form.hass = hass;
_requestI18n(this, hass, () => { if (this._root) this._render(); });
}
setConfig(config) {
this._cfg = { ...KCFSCard.getStubConfig(), ...config };
this._render();
}
connectedCallback() {
this._render();
}
_render() {
if (!this._root) {
this._root = this.attachShadow({ mode: "open" });
}
const style = `
.editor-container { padding: 16px; }
.tabs { display: flex; border-bottom: 1px solid var(--divider-color); margin-bottom: 16px; }
.tab { padding: 8px 16px; cursor: pointer; border-bottom: 2px solid transparent; }
.tab.active { border-bottom-color: var(--primary-color); color: var(--primary-color); }
.tab-content { display: none; }
.tab-content.active { display: block; }
.input-helper { font-size: 0.9em; color: var(--secondary-text-color); margin-top: 4px; padding: 0 8px; }
`;
this._root.innerHTML = `
<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;
};
if (this._form.computeHelper) {
this._form.computeHelper = () => "";
}
this._form.addEventListener("value-changed", (ev) => {
this._cfg = { ...this._cfg, ...ev.detail.value };
this._dispatchConfigChange();
});
}
_setupThemeForm() {
const themeForm = this._root.getElementById("theme-form");
themeForm.hass = this._hass;
themeForm.data = this._cfg;
themeForm.schema = [
{ name: "compact_view", selector: { boolean: {} } },
{ name: "show_type_in_mini", selector: { boolean: {} } },
];
themeForm.computeLabel = (s) => ({
compact_view: this._t("schema_compact_view"),
show_type_in_mini: this._t("schema_show_type_in_mini"),
}[s.name] || s.name);
themeForm.addEventListener("value-changed", (ev) => {
this._cfg = { ...this._cfg, ...ev.detail.value };
this._dispatchConfigChange();
});
}
_dispatchConfigChange() {
this.dispatchEvent(new CustomEvent("config-changed", {
detail: { config: this._cfg },
bubbles: true,
composed: true,
}));
}
}
customElements.define(EDITOR_TAG, KCFSCardEditor);
window.customCards = window.customCards || [];
window.customCards.push({
type: "k-cfs-card",
name: "Creality CFS Card",
preview: true,
description: "A card to control the Creality Filament System (CFS)"
});