From 971f740fc5e89b9c0db7f5555b2e520c39f5d790 Mon Sep 17 00:00:00 2001 From: Dereck Date: Fri, 20 Feb 2026 19:46:12 -0500 Subject: [PATCH] feat: add expandable online nodes list to dashboard card The online/total nodes count is now clickable. Clicking it expands a list showing each online node name with a relative last-heard time (e.g. "14 min ago"). Reads the online_nodes attribute from the Meshtastic integration's Online Nodes sensor. Requires meshtastic/home-assistant with online_nodes attribute support. --- src/meshtastic-card.js | 64 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/src/meshtastic-card.js b/src/meshtastic-card.js index b7ec35f..a09a5fa 100644 --- a/src/meshtastic-card.js +++ b/src/meshtastic-card.js @@ -9,9 +9,15 @@ class MeshtasticCard extends LitElement { return { hass: {}, config: {}, + _nodesExpanded: { type: Boolean }, }; } + constructor() { + super(); + this._nodesExpanded = false; + } + static getConfigForm() { return { schema: [ @@ -62,6 +68,35 @@ class MeshtasticCard extends LitElement { return `${d > 0 ? d + 'd ' : ''}${h > 0 ? h + 'h ' : ''}${m}m`; } + _formatRelativeTime(utcString) { + const match = utcString.match(/(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2}) UTC/); + if (!match) return utcString; + const then = Date.UTC(match[1], match[2] - 1, match[3], match[4], match[5], match[6]); + const diffSec = Math.floor((Date.now() - then) / 1000); + if (diffSec < 60) return "just now"; + const m = Math.floor(diffSec / 60); + if (m < 60) return `${m} min ago`; + const h = Math.floor(m / 60); + const rm = m % 60; + if (h < 24) return rm > 0 ? `${h}h ${rm}min ago` : `${h}h ago`; + const d = Math.floor(h / 24); + return `${d}d ${h % 24}h ago`; + } + + _parseOnlineNodes(stateObj) { + const list = stateObj?.attributes?.online_nodes; + if (!Array.isArray(list)) return []; + return list.map(entry => { + const match = entry.match(/^(.+?) \(last heard: (.+)\)$/); + if (!match) return { name: entry, ago: "" }; + return { name: match[1], ago: this._formatRelativeTime(match[2]) }; + }); + } + + _toggleNodes() { + this._nodesExpanded = !this._nodesExpanded; + } + _renderBar(label, stateObj, icon, color, showPower = false, isPowered = false) { const val = parseFloat(stateObj?.state) || 0; return html` @@ -117,9 +152,27 @@ class MeshtasticCard extends LitElement {
${voltage?.state}V
-
${this._getState("nodes_online")?.state}/${this._getState("nodes_total")?.state} Nodes
+
+ + ${this._getState("nodes_online")?.state}/${this._getState("nodes_total")?.state} Nodes + +
+ ${this._nodesExpanded ? html` +
+ ${this._parseOnlineNodes(this._getState("nodes_online")).map(node => html` +
+ ${node.name} + ${node.ago} +
+ `)} + ${this._parseOnlineNodes(this._getState("nodes_online")).length === 0 ? html` +
No online nodes
+ ` : ""} +
+ ` : ""} +
NETWORK TRAFFIC
@@ -162,6 +215,15 @@ class MeshtasticCard extends LitElement { .secondary-stats { display: flex; justify-content: space-around; font-size: 0.85em; padding: 8px 0; border-top: 1px solid var(--divider-color); } .sec-item { display: flex; align-items: center; gap: 4px; } .sec-item ha-icon { --mdc-icon-size: 16px; color: var(--secondary-text-color); } + .nodes-toggle { cursor: pointer; user-select: none; } + .nodes-toggle:hover { opacity: 0.7; } + .chevron { --mdc-icon-size: 14px; margin-left: 2px; } + + .nodes-list { background: var(--secondary-background-color); border-radius: 8px; padding: 8px 10px; margin-top: 8px; display: flex; flex-direction: column; gap: 4px; } + .node-row { display: flex; justify-content: space-between; align-items: center; font-size: 0.78em; padding: 3px 0; border-bottom: 1px solid var(--divider-color); } + .node-row:last-child { border-bottom: none; } + .node-row-name { font-weight: 500; } + .node-row-ago { opacity: 0.5; font-size: 0.9em; } .traffic-section { background: var(--secondary-background-color); padding: 10px; border-radius: 8px; margin-top: 8px; } .traffic-header { font-size: 0.65em; font-weight: bold; letter-spacing: 1px; margin-bottom: 8px; opacity: 0.5; }