diff --git a/README.md b/README.md index 7cd257c..ed5d4ca 100644 --- a/README.md +++ b/README.md @@ -214,8 +214,9 @@ History begins when the sensors are installed; no InfluxDB data is imported. already-installed ApexCharts Card for graphs and Home Assistant's authenticated history WebSocket API for estimates. The host's existing `/dashboard-bcachefs/0` dashboard includes this card with its Reconcile and Copy GC switches integrated -above the history controls. The embedded native Home Assistant tiles preserve -live state, unavailable handling, and the entity details dialog. +in the same row as the history controls. Compact buttons show On/Off text and +a colored background for On. They use confirmed entity state, disable themselves +when unavailable, and report service errors without changing their displayed state. - One selector applies 3h, 6h, 12h, 24h, 2d or 7d to both graphs and calculations. - Another selector chooses `target_data` or `erasure_code_data` for the estimate. diff --git a/homeassistant/bcachefs-reconcile-card.mjs b/homeassistant/bcachefs-reconcile-card.mjs index 30da705..c66275a 100644 --- a/homeassistant/bcachefs-reconcile-card.mjs +++ b/homeassistant/bcachefs-reconcile-card.mjs @@ -49,23 +49,29 @@ class BcachefsReconcileCard extends (globalThis.HTMLElement || class {}) { this._field = 'target_data'; this._request = 0; this._graphs = []; - this._switches = []; + this._pendingSwitches = new Set(); } setConfig(config) { this._config = {entity_prefix: 'sensor.bcachefs_reconcile_', ...config}; this._hours = RANGES.some(([hours]) => hours === config.hours) ? config.hours : this._hours; this.shadowRoot.innerHTML = `

Pending reconcile work

-
-
+ + + +
Remaining data—
Average net progress—
Estimated time remaining—
@@ -78,14 +84,18 @@ class BcachefsReconcileCard extends (globalThis.HTMLElement || class {}) { this.shadowRoot.querySelector('#field').value = this._field; select.addEventListener('change', () => {this._hours = Number(select.value); this._buildGraphs(); this._load();}); this.shadowRoot.querySelector('#field').addEventListener('change', event => {this._field = event.target.value; this._load();}); - this._buildSwitches(); + this.shadowRoot.querySelectorAll('.switch-button').forEach(button => { + button.addEventListener('click', () => this._toggleSwitch(button.dataset.entity)); + }); + this._updateSwitches(); this._buildGraphs(); if (this._hass) this._load(); } set hass(hass) { const first = !this._hass; this._hass = hass; - [...this._graphs, ...this._switches].forEach(card => {card.hass = hass;}); + this._graphs.forEach(card => {card.hass = hass;}); + this._updateSwitches(); const entity = hass.states[this._entity()]; if (entity && !Number.isFinite(Number(entity.state))) { ++this._request; @@ -98,24 +108,35 @@ class BcachefsReconcileCard extends (globalThis.HTMLElement || class {}) { if (this._hass && this._config) this._load(); } disconnectedCallback() { clearInterval(this._timer); ++this._request; } - getCardSize() {return 16;} + getCardSize() {return 14;} getGridOptions() {return {columns: 'full', rows: 'auto'};} _entity() {return `${this._config?.entity_prefix || 'sensor.bcachefs_reconcile_'}${this._field}`;} - async _buildSwitches() { - const generation = this._switchGeneration = (this._switchGeneration || 0) + 1; - const helpers = await window.loadCardHelpers(); - if (generation !== this._switchGeneration) return; - this._switches = [ - ['switch.bcachefs_reconcile_enabled', 'Reconcile', 'mdi:sync'], - ['switch.bcachefs_copygc_enabled', 'Copy GC', 'mdi:recycle'], - ].map(([entity, name, icon]) => helpers.createCardElement({ - type: 'tile', entity, name, icon, - tap_action: {action: 'more-info'}, - icon_tap_action: {action: 'toggle'}, - features: [{type: 'toggle'}], features_position: 'inline', - })); - this._switches.forEach(card => {if (this._hass) card.hass = this._hass;}); - this.shadowRoot.querySelector('.switches').replaceChildren(...this._switches); + _updateSwitches() { + this.shadowRoot.querySelectorAll('.switch-button').forEach(button => { + const state = this._hass?.states[button.dataset.entity]?.state; + const available = state === 'on' || state === 'off'; + const pending = this._pendingSwitches.has(button.dataset.entity); + button.setAttribute('aria-pressed', String(state === 'on')); + button.setAttribute('aria-busy', String(pending)); + button.disabled = !available || pending; + button.textContent = `${button.dataset.name} · ${pending ? 'Updating…' : !available ? 'Unavailable' : state === 'on' ? 'On' : 'Off'}`; + button.title = available ? `Turn ${state === 'on' ? 'off' : 'on'} ${button.dataset.name}` : `${button.dataset.name} is unavailable`; + }); + } + async _toggleSwitch(entity) { + const state = this._hass?.states[entity]?.state; + if (!['on', 'off'].includes(state) || this._pendingSwitches.has(entity)) return; + this._pendingSwitches.add(entity); + this.shadowRoot.querySelector('#control-error').textContent = ''; + this._updateSwitches(); + try { + await this._hass.callService('switch', state === 'on' ? 'turn_off' : 'turn_on', {entity_id: entity}); + } catch (error) { + this.shadowRoot.querySelector('#control-error').textContent = `Could not change switch: ${error.message || error}`; + } finally { + this._pendingSwitches.delete(entity); + this._updateSwitches(); + } } async _buildGraphs() { const generation = this._graphGeneration = (this._graphGeneration || 0) + 1;