@feezal/feezal-element 3.0.1 → 3.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/feezal-element.js CHANGED
@@ -60,6 +60,28 @@ export class FeezalElement extends LitElement {
60
60
  // bound to MQTT topics). Never reflected — the attribute is the
61
61
  // source of truth; effects only apply in the viewer.
62
62
  conditions: {type: String, attribute: 'conditions'},
63
+ // ── N31: availability — universal base-class machinery ───────────
64
+ // subscribe-availability accepts a single topic string (back-compat)
65
+ // OR a JSON array of topics / {topic, property} entries (the modern
66
+ // HA discovery form: bridge state + device availability). The base
67
+ // subscribes, tracks per-topic status and combines it per
68
+ // availability-mode into the reactive `_available` flag; rendering
69
+ // (badges, dimming) stays element business. Elements that want the
70
+ // fields in their inspector declare the attributes in their own
71
+ // descriptor — the machinery works regardless.
72
+ // Deliberately NOT reflected: Lit reflects constructor defaults on
73
+ // first update, which would stamp subscribe-availability="" /
74
+ // payload-available="online" / … onto EVERY element and serialize
75
+ // that junk into every saved dashboard (and it broke the U32
76
+ // create-component attribute table). The inspector and discovery
77
+ // write the attributes directly; attribute → property sync is all
78
+ // the machinery needs.
79
+ subscribeAvailability: {type: String, attribute: 'subscribe-availability'},
80
+ availabilityMode: {type: String, attribute: 'availability-mode'},
81
+ payloadAvailable: {type: String, attribute: 'payload-available'},
82
+ payloadUnavailable: {type: String, attribute: 'payload-unavailable'},
83
+ msgPropAvailability: {type: String, attribute: 'message-property-availability'},
84
+ _available: {state: true},
63
85
  };
64
86
 
65
87
  constructor() {
@@ -70,6 +92,15 @@ export class FeezalElement extends LitElement {
70
92
  this.dynamicSubscriptions = false;
71
93
  this.visible = false;
72
94
  this._conditions = new FeezalConditions(this);
95
+ // N31 availability defaults (payload values = HA defaults)
96
+ this.subscribeAvailability = '';
97
+ this.availabilityMode = 'all';
98
+ this.payloadAvailable = 'online';
99
+ this.payloadUnavailable = 'offline';
100
+ this.msgPropAvailability = '';
101
+ this._available = true;
102
+ this._availabilitySubs = [];
103
+ this._availabilityStatus = {};
73
104
  }
74
105
 
75
106
  connectedCallback() {
@@ -79,11 +110,17 @@ export class FeezalElement extends LitElement {
79
110
  this._subscribe();
80
111
  this._conditions.connect();
81
112
  }
113
+ // N31: availability is independent of the primary-subscription
114
+ // machinery (elements overriding _subscribe with an empty body still
115
+ // get it) and not editor-gated — it only feeds internal state, never
116
+ // writes serializable attributes in the editor.
117
+ this._subscribeAvailability();
82
118
  }
83
119
 
84
120
  disconnectedCallback() {
85
121
  super.disconnectedCallback();
86
122
  this._unsubscribe();
123
+ this._unsubscribeAvailability();
87
124
  this._conditions.disconnect();
88
125
  }
89
126
 
@@ -104,6 +141,98 @@ export class FeezalElement extends LitElement {
104
141
  if (changed.has('conditions') && (this.visible || !this.dynamicSubscriptions)) {
105
142
  this._conditions.connect();
106
143
  }
144
+
145
+ // N31: availability wiring changed at runtime (inspector edits on the
146
+ // live canvas) → rewire. Signature-guarded so the first update after
147
+ // connect is a no-op.
148
+ if (this.isConnected && this.__availSig !== undefined &&
149
+ this._availabilitySignature() !== this.__availSig) {
150
+ this._unsubscribeAvailability();
151
+ this._subscribeAvailability();
152
+ }
153
+ }
154
+
155
+ // ── N31: availability ────────────────────────────────────────────────────
156
+
157
+ _availabilitySignature() {
158
+ return [this.subscribeAvailability, this.availabilityMode,
159
+ this.payloadAvailable, this.payloadUnavailable, this.msgPropAvailability].join('|');
160
+ }
161
+
162
+ /** Parse subscribe-availability: '' → [], plain topic string → one entry,
163
+ * JSON array of strings / {topic, property} objects → many entries. */
164
+ _availabilityEntries() {
165
+ const raw = (this.subscribeAvailability || '').trim();
166
+ if (!raw) return [];
167
+ if (raw.startsWith('[')) {
168
+ try {
169
+ const arr = JSON.parse(raw);
170
+ return (Array.isArray(arr) ? arr : [])
171
+ .map(entry => (typeof entry === 'string' ? {topic: entry} : entry))
172
+ .filter(entry => entry && typeof entry.topic === 'string' && entry.topic);
173
+ } catch {
174
+ return [];
175
+ }
176
+ }
177
+ return [{topic: raw}];
178
+ }
179
+
180
+ _subscribeAvailability() {
181
+ this.__availSig = this._availabilitySignature();
182
+ this._availabilityStatus = {};
183
+ const entries = this._availabilityEntries();
184
+ if (entries.length === 0) {
185
+ this._setAvailable(true);
186
+ return;
187
+ }
188
+ entries.forEach((entry, index) => {
189
+ const key = index + ':' + entry.topic;
190
+ this._availabilityStatus[key] = null; // unknown until the first message
191
+ this._availabilitySubs.push(feezal.connection.sub(entry.topic, msg => {
192
+ let v = this.getProperty(msg, entry.property || this.msgPropAvailability || this.messageProperty);
193
+ // Unwrap JSON {"state": "online"} payloads (zigbee2mqtt availability)
194
+ if (typeof v === 'string') {
195
+ try {
196
+ const parsed = JSON.parse(v);
197
+ if (parsed && typeof parsed === 'object' && 'state' in parsed) v = parsed.state;
198
+ } catch { /* not JSON — use the raw string */ }
199
+ } else if (v && typeof v === 'object' && 'state' in v) {
200
+ v = v.state;
201
+ }
202
+ const s = String(v).toLowerCase();
203
+ this._availabilityStatus[key] = String(v) === this.payloadAvailable ||
204
+ (s !== String(this.payloadUnavailable).toLowerCase() &&
205
+ s !== 'offline' && s !== 'false' && s !== '0' && s !== 'unavailable');
206
+ this._recomputeAvailability();
207
+ }));
208
+ });
209
+ this._recomputeAvailability();
210
+ }
211
+
212
+ _unsubscribeAvailability() {
213
+ this._availabilitySubs.forEach(sub => feezal.connection.unsubscribe(sub));
214
+ this._availabilitySubs = [];
215
+ }
216
+
217
+ /** Combine per-topic status per availability-mode into `_available`.
218
+ * Topics without a message yet count as available (matches the previous
219
+ * per-element behaviour — an element never starts out badged). */
220
+ _recomputeAvailability() {
221
+ const statuses = Object.values(this._availabilityStatus);
222
+ const known = statuses.filter(v => v !== null);
223
+ this._setAvailable(this.availabilityMode === 'any'
224
+ ? (known.length === 0 || known.some(Boolean))
225
+ : statuses.every(v => v !== false));
226
+ }
227
+
228
+ _setAvailable(available) {
229
+ this._available = available;
230
+ // Styling hook for themes/zero-effort elements — viewer only: a
231
+ // reflected attribute in the editor would be serialized into the
232
+ // saved views.html.
233
+ if (!feezal.isEditor) {
234
+ this.toggleAttribute('unavailable', !available);
235
+ }
107
236
  }
108
237
 
109
238
  // ── Subscription helpers ─────────────────────────────────────────────────
@@ -262,5 +391,44 @@ export class FeezalElement extends LitElement {
262
391
  }
263
392
  }
264
393
 
394
+ // ── N31: shared availability badge (optional consistency helper) ─────────────
395
+ // Elements MAY compose these into their render/styles instead of hand-rolling
396
+ // a badge — the base class itself renders nothing. Themable via the
397
+ // --feezal-unavailable-* custom properties.
398
+ export const feezalAvailabilityStyles = css`
399
+ .feezal-unavail-badge {
400
+ position: absolute;
401
+ top: var(--feezal-unavailable-top, 4px);
402
+ right: var(--feezal-unavailable-right, 4px);
403
+ font-size: var(--feezal-unavailable-size, 14px);
404
+ line-height: 1;
405
+ color: var(--feezal-unavailable-color, var(--error-color, #b00020));
406
+ opacity: 0.85;
407
+ pointer-events: none;
408
+ z-index: 2;
409
+ }
410
+ `;
411
+
412
+ /** Badge template partial: renders nothing while available. */
413
+ export function availabilityBadge(available) {
414
+ return available ? '' : html`<span class="feezal-unavail-badge" title="Device unavailable">⚠</span>`;
415
+ }
416
+
417
+ /**
418
+ * E117: shared descriptor for the `publish-local` attribute — spread into an
419
+ * element's `feezal.attributes` right after `publish` so the label and help
420
+ * text can never drift between elements. The element declares
421
+ * `publishLocal: {type: Boolean, reflect: true, attribute: 'publish-local'}`
422
+ * and passes `{local: this.publishLocal}` as the pub() options; the
423
+ * connection then loops the message back page-locally instead of sending it
424
+ * to the broker (see FeezalConnection.pub).
425
+ */
426
+ export const publishLocalAttribute = {
427
+ name: 'publish-local',
428
+ type: 'boolean',
429
+ default: false,
430
+ help: 'Publish page-locally instead of to the broker: the payload reaches only subscribers in THIS browser tab (dialog triggers, view switches, wiring elements together). Nothing is sent over MQTT, nothing is retained, and it works while disconnected.'
431
+ };
432
+
265
433
  // Re-export so element files can do a single import.
266
434
  export {html, css};
@@ -141,7 +141,7 @@ class FeezalTopicInput extends LitElement {
141
141
  render() {
142
142
  return html`
143
143
  <div class="topic-wrap">
144
- <sl-input size="${this.size}" autocomplete="off"
144
+ <sl-input size="${this.size}" autocomplete="off" clearable
145
145
  label="${this.label || ''}"
146
146
  placeholder="${this.placeholder}"
147
147
  .value="${this.value ?? ''}"
package/package.json CHANGED
@@ -35,5 +35,5 @@
35
35
  "url": "git+https://github.com/feezal/feezal.git",
36
36
  "directory": "www/packages/@feezal/feezal-element"
37
37
  },
38
- "version": "3.0.1"
38
+ "version": "3.0.3"
39
39
  }