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 = `