/* * Carte OPNsense (custom:opnsense-card) pour Home Assistant, au style des cartes * MyElectricalData new (Carte Enedis Linky New) et des Cartes Serveur HA : * passerelles (état, latence, pertes, gigue), débits des interfaces, CPU, RAM, disque, * table d'états, tampons réseau, températures, baux DHCP, mise à jour d'OPNsense, * redémarrage et arrêt confirmés en deux clics. * * Données : l'intégration OPNsense de HACS (travisghansen/hass-opnsense), ou, sans intégration, * le package REST fourni dans le dépôt (packages/opnsense.yaml). Voir le README. */ (() => { const CARD_VERSION = "0.1.0"; /* ------------------------------------------------------------- affichage */ const fmt = (n, digits = 1) => n === null || n === undefined ? "—" : Number(n).toLocaleString("fr-FR", { minimumFractionDigits: digits, maximumFractionDigits: digits }); const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); /** Classe de thème de la carte : * v2 : style MyElectricalData, toujours bleu nuit ; * auto : style MyElectricalData, bleu nuit si Home Assistant est en mode sombre, clair sinon ; * ha : couleurs du thème Home Assistant (clair ou sombre). */ const themeClass = (theme, hass) => { const light = hass?.themes?.darkMode === false; if (theme === "ha") return `th-ha${light ? " light" : ""}`; if (theme === "auto") return `th-v2${light ? " light" : ""}`; return "th-v2"; }; const since = (iso) => { const t = new Date(iso).getTime(); if (!Number.isFinite(t)) return null; const m = Math.max(0, Math.floor((Date.now() - t) / 60000)); const d = Math.floor(m / 1440), h = Math.floor((m % 1440) / 60), mn = m % 60; return d ? `${d} j ${h} h` : h ? `${h} h ${String(mn).padStart(2, "0")} min` : `${mn} min`; }; const level = (v, warn, alert) => (v === null ? "" : v >= alert ? "b-alert" : v >= warn ? "b-warn" : "b-ok"); const SRV_STYLE = ``; const PVE_STYLE = ``; /* ------------------------------------------------------------------ style */ const STYLE = ``; /* ----------------------------------------------------------------- éditeur */ const THEME_SELECT = { name: "theme", selector: { select: { mode: "dropdown", options: [ { value: "v2", label: "Style MyElectricalData (bleu nuit)" }, { value: "auto", label: "Style MyElectricalData, clair ou sombre selon Home Assistant" }, { value: "ha", label: "Thème Home Assistant (clair ou sombre)" }, ], }, }, }; /** Éditeur visuel : formulaire Home Assistant construit à partir du schéma. */ function makeEditor(schema, labels, defaults) { const hasDays = schema.some((s) => s.name === "days"); return class extends HTMLElement { setConfig(config) { this._config = { ...config }; this._draw(); } set hass(hass) { this._hass = hass; if (this._form) this._form.hass = hass; else this._draw(); } _draw() { if (!this._hass || !this._config) return; if (!this._form) { this._form = document.createElement("ha-form"); this._form.computeLabel = (s) => labels[s.name] || s.name; this._form.addEventListener("value-changed", (ev) => { const v = { ...ev.detail.value }; if (hasDays && v.days !== undefined) v.days = Number(v.days); this._config = v; this.dispatchEvent(new CustomEvent("config-changed", { detail: { config: v }, bubbles: true, composed: true })); }); this.appendChild(this._form); } this._form.hass = this._hass; this._form.schema = schema; this._form.data = hasDays ? { ...defaults, ...this._config, days: String(this._config.days || defaults.days) } : { ...defaults, ...this._config }; } }; } /* ============================================================== OPNsense */ /* * Carte OPNsense (custom:opnsense-card) pour l'intégration hass-opnsense (HACS) : * état et mise à jour du pare-feu, ressources (CPU, RAM, disque, tables), températures, * passerelles (état, latence, pertes), débits des interfaces, baux DHCP, et redémarrage ou * arrêt confirmés en deux clics. */ const OPN_DEFAULTS = { title: "", icon: "mdi:wall-fire", icon_color: "", show_info: true, show_resources: true, show_gateways: true, show_interfaces: true, show_actions: true, interfaces: [], // interfaces affichées (vide = celles qui ont des débits) theme: "v2", }; /** Rôle d'une entité OPNsense d'après la fin de son nom (noms anglais fixés par l'intégration). */ const OPN_ROLES = [ ["cpu", /_cpu_usage$/], ["mem", /_memory_used_percentage$/], ["mem_used", /_memory_used$/], ["mem_total", /_memory_physmem$/], ["swap", /_memory_swap_used_percentage$/], ["mbuf", /_memory_buffers_used_percentage$/], ["pf", /_pf_state_table_used_percentage$/], ["load1", /_system_load_average_one_minute$/], ["boot", /_system_boottime$/], ["dhcp", /_dhcp_leases_all$/], ["certs", /_certificates$/], ["disk", /_filesystem_used_percentage(?:_root)?$/], ["ups", /_ups_status$/], ["ups_batt", /_ups_battery_charge$/], ]; /** État d'une passerelle (dpinger) et d'une interface, en français. */ const OPN_GW = { online: ["En ligne", "ok"], none: ["En ligne", "ok"], down: ["Hors ligne", "bad"], loss: ["Pertes", "warn"], delay: ["Latence", "warn"], delay_loss: ["Latence et pertes", "warn"], force_down: ["Désactivée", "off"], offline: ["Hors ligne", "bad"], latency: ["Latence", "warn"], packetloss: ["Pertes", "warn"] }; const opnGw = (s) => OPN_GW[String(s ?? "").toLowerCase().replace(/[+\s]+/g, "_")] || [s ? String(s) : "Inconnu", "off"]; /** Débit en bit/s d'un capteur, selon son unité (kB/s par défaut de l'intégration, ou converti par Home Assistant). */ const OPN_RATE = { "b/s": 8, "kb/s": 8e3, "kib/s": 8192, "mb/s": 8e6, "mib/s": 8388608, "gb/s": 8e9, "bit/s": 1, "kbit/s": 1e3, "mbit/s": 1e6, "gbit/s": 1e9 }; /** Ordre d'affichage : WAN d'abord, puis LAN, puis le reste par nom. */ const opnRank = (n) => (/^wan/i.test(n) ? 0 : /^lan/i.test(n) ? 1 : /wan/i.test(n) ? 0.5 : 2); const bitrate = (bps) => (bps === null ? "—" : bps >= 1e9 ? `${fmt(bps / 1e9, 2)} Gbit/s` : bps >= 1e6 ? `${fmt(bps / 1e6, bps < 1e7 ? 2 : 1)} Mbit/s` : bps >= 1e3 ? `${fmt(bps / 1e3, 0)} kbit/s` : `${fmt(bps, 0)} bit/s`); class OpnsenseCard extends HTMLElement { static getConfigElement() { return document.createElement("opnsense-card-editor"); } static getStubConfig() { return {}; } setConfig(config) { this._config = { ...OPN_DEFAULTS, ...(config || {}) }; this._map = null; this._sig = null; if (!this.shadowRoot) this.attachShadow({ mode: "open" }); if (!this._clicks) { this.shadowRoot.addEventListener("click", (this._clicks = (ev) => { const act = ev.target.closest?.("[data-act]"); if (act) { // Redémarrer, arrêter : premier clic arme, second clic dans les 5 s confirme (service de l'intégration) ev.stopPropagation(); const what = act.dataset.act; if (this._armed === what) { this._armed = null; if (this._map?.rest) this._hass?.callService("rest_command", `opnsense_${what === "reboot" ? "reboot" : "halt"}`, {}); else this._hass?.callService("opnsense", what === "reboot" ? "system_reboot" : "system_halt", this._map?.device ? { device_id: this._map.device } : {}); } else { this._armed = what; clearTimeout(this._armTimer); this._armTimer = setTimeout(() => { this._armed = null; this._sig = null; this._render(); }, 5000); } this._sig = null; this._render(); return; } const el = ev.target.closest?.("[data-entity]"); if (el?.dataset.entity) { const e = new Event("hass-more-info", { bubbles: true, composed: true }); e.detail = { entityId: el.dataset.entity }; this.dispatchEvent(e); } })); } this._render(); } set hass(hass) { if (this._hass?.entities !== hass.entities || this._hass?.devices !== hass.devices) this._map = null; this._hass = hass; this._render(); } getCardSize() { return 8; } _st(id) { return id ? this._hass?.states?.[id] : undefined; } _num(id) { const s = this._st(id); if (!s || s.state === "unavailable" || s.state === "unknown" || s.state === "") return null; const v = Number(s.state); return Number.isFinite(v) ? v : null; } /** Entités d'un pare-feu OPNsense (appareil choisi, sinon le premier) rangées par rôle, interfaces et passerelles. */ _resolve() { if (this._map) return this._map; const hass = this._hass, c = this._config, reg = hass.entities || {}; // entités restaurées (plus fournies par aucune intégration, ex. ancien package) : ignorées const live = (id) => !reg[id]?.hidden && !hass.states[id]?.attributes?.restored; let ids = Object.keys(hass.states).filter((id) => reg[id]?.platform === "opnsense" && live(id)); // Sans intégration : capteurs du package REST (packages/opnsense.yaml), reconnus à leur préfixe const rest = !ids.length; if (rest) { const pre = new RegExp(`^(sensor|binary_sensor|update)\\.${String(c.prefix || "opnsense").toLowerCase().replace(/[^a-z0-9_]/g, "")}_`); ids = Object.keys(hass.states).filter((id) => pre.test(id) && live(id)); } const device = rest ? null : c.device || reg[ids.find((id) => id.startsWith("sensor."))]?.device_id || null; const mine = ids.filter((id) => !device || reg[id]?.device_id === device); const m = { device, rest, roles: {}, ifaces: {}, gws: {}, temps: [], update: null, notices: null }; for (const id of mine) { const dom = id.split(".")[0]; if (dom === "update" || (dom === "binary_sensor" && /_firmware_updates?_available$/.test(id))) { m.update ||= id; continue; } if (dom === "binary_sensor" && /_pending_notices_present$/.test(id)) { m.notices = id; continue; } if (dom !== "sensor") continue; // package REST : toutes les passerelles et toutes les interfaces dans un capteur chacun if (/_gateways$/.test(id)) { m.gwAll = id; continue; } if (/_traffic$/.test(id)) { m.traffic = id; continue; } let x = /_interface_(.+?)_(status|traffic_in_rate|traffic_out_rate)$/.exec(id); if (x) { (m.ifaces[x[1]] ||= { key: x[1] })[x[2] === "status" ? "status" : x[2] === "traffic_in_rate" ? "in" : "out"] = id; continue; } x = /_gateway_(.+?)_(status|delay|loss|stddev|address)$/.exec(id); if (x) { (m.gws[x[1]] ||= { key: x[1] })[x[2]] = id; continue; } if (/_temp_/.test(id) && /°C|°F/.test(hass.states[id].attributes?.unit_of_measurement || "")) { m.temps.push(id); continue; } for (const [role, re] of OPN_ROLES) if (re.test(id)) { m.roles[role] ||= id; break; } } const dev = hass.devices?.[device]; m.name = c.title || dev?.name_by_user || dev?.name || "OPNsense"; m.version = dev?.sw_version || null; m.model = [dev?.manufacturer, dev?.model].filter(Boolean).join(" "); this._map = m; return m; } /** Nom lisible d'une interface ou d'une passerelle : nom de l'entité sans le préfixe de l'appareil. */ _label(id, re, fallback) { const fn = String(this._st(id)?.attributes?.friendly_name || ""); const x = re.exec(fn); return x ? x[1] : fallback.replace(/_/g, " ").toUpperCase(); } /** Passerelles du package REST : « nom,état,latence,pertes,gigue|… » (valeurs en ms et %). */ _gwPack(id) { const n = (v) => (v === "" || v === undefined || !Number.isFinite(Number(v)) ? null : Number(v)); return String(this._st(id)?.state || "").split("|").map((row) => row.split(",")).filter((f) => f.length >= 2 && f[0]) .map(([name, st, d, l, j]) => ({ key: name, status: id, name, state: opnGw(st), delay: n(d), loss: n(l), jitter: n(j), ip: null })) .sort((a, b) => opnRank(a.name) - opnRank(b.name) || a.name.localeCompare(b.name, "fr")); } /** Interfaces du package REST : « heure|nom,octets reçus,octets envoyés|… ». La carte calcule les * débits entre deux relevés (le précédent est repris de l'historique au chargement de la page). */ _trafPack(id) { const parse = (state) => { const [t, ...rows] = String(state || "").split("|"); const out = { t: Number(t), i: {} }; for (const row of rows) { const [name, rx, tx] = row.split(","); if (name) out.i[name] = [Number(rx), Number(tx)]; } return Number.isFinite(out.t) && out.t > 0 ? out : null; }; const cur = parse(this._st(id)?.state); if (!cur) return []; const tr = (this._tr ||= { last: null, rates: {} }); if (tr.last && cur.t > tr.last.t) { const dt = cur.t - tr.last.t; for (const [name, [rx, tx]] of Object.entries(cur.i)) { const p = tr.last.i[name]; // compteur remis à zéro (redémarrage) : pas de débit négatif if (p) tr.rates[name] = [Math.max(0, ((rx - p[0]) * 8) / dt), Math.max(0, ((tx - p[1]) * 8) / dt)]; } } if (!tr.last || cur.t > tr.last.t) tr.last = cur; if (!tr.seeded && this._hass?.callApi) { tr.seeded = true; const start = new Date(Date.now() - 10 * 60000).toISOString(); this._hass.callApi("GET", `history/period/${start}?filter_entity_id=${encodeURIComponent(id)}&minimal_response&no_attributes&significant_changes_only=0`) .then((res) => { const prev = (res?.[0] || []).map((h) => parse(h.state)).filter((x) => x && x.t < cur.t).pop(); if (!prev || Object.keys(tr.rates).length) return; tr.last = prev; this._trafPack(id); // recalcule avec le relevé précédent this._sig = null; this._render(); }) .catch(() => { /* sans historique : le débit s'affiche au relevé suivant */ }); } return Object.keys(cur.i).map((name) => ({ key: name, in: id, out: id, name, up: null, rin: tr.rates[name]?.[0] ?? null, rout: tr.rates[name]?.[1] ?? null })) .sort((a, b) => opnRank(a.name) - opnRank(b.name) || a.name.localeCompare(b.name, "fr")); } _bps(id) { const v = this._num(id); if (v === null) return null; const u = String(this._st(id)?.attributes?.unit_of_measurement || "kB/s").toLowerCase().replace(/\s/g, ""); return Math.max(0, v * (OPN_RATE[u] || 8e3)); // un compteur remis à zéro donne une dérivée négative } _render() { if (!this._config || !this._hass || !this.shadowRoot) return; const c = this._config, m = this._resolve(); const ids = [...Object.values(m.roles), ...Object.values(m.ifaces).flatMap((i) => Object.values(i)), ...Object.values(m.gws).flatMap((g) => Object.values(g)), ...m.temps, m.update, m.notices, m.gwAll, m.traffic]; const sig = `${c.theme}|${this._hass.themes?.darkMode}|${this._armed}|${Math.floor(Date.now() / 60000)}|` + ids.map((id) => this._st(id)?.state ?? "-").join("|"); if (sig === this._sig) return; this._sig = sig; const sec = (icon, t, body) => (body ? `