New apps Added

This commit is contained in:
2026-07-08 10:43:39 -04:00
parent 3b1f4bbd75
commit fefc2c8b5c
1114 changed files with 406637 additions and 154 deletions
@@ -0,0 +1,348 @@
/** Maintenance Supporter — dashboard-strategy registration shim.
*
* WHY THIS FILE EXISTS
* --------------------
* HA serves custom frontend modules via `frontend_extra_module_url`, which the
* frontend loads as fire-and-forget `import("<url>")` calls in index.html — no
* ordering, no await, no completion guarantee. For a `custom:` dashboard
* strategy, HA's renderer then only waits ~5 s via
* `customElements.whenDefined("ll-strategy-dashboard-…")` for the element to
* appear (unlike built-in strategies, which HA imports and awaits itself).
*
* On systems with many heavy HACS frontend plugins (card-mod, layout-card, …)
* our full strategy bundle (~14 KB + a transitive graph) can lose that race:
* the module is downloaded but its top-level `customElements.define` hasn't
* finished executing within the 5 s window → "Timeout waiting for strategy
* element ll-strategy-dashboard-maintenance-supporter to be registered".
* Reproduced ~3040 % of cold loads on a plugin-heavy live system.
*
* THE FIX
* -------
* This shim is the `extra_module_url` instead of the full bundle. It has ZERO
* static imports and does ONE thing synchronously: define a tiny wrapper
* element for the dashboard strategy (and push its picker entry). Parsing a
* few hundred bytes with no import graph reaches `define()` in well under a
* microtask, so it wins HA's 5 s race essentially every time. The heavy
* bundle is `import()`-ed lazily on first `generate()` / `getConfigElement()`
* — exactly how HA's own built-in strategies defer their code.
*
* The heavy bundle keeps registering the editor, the section strategies, the
* `ll-custom` tap handler, and the section picker entries at its own top level
* (all of which only matter once a dashboard is actually being generated).
*/
const STRATEGY_TYPE = "maintenance-supporter";
const STRATEGY_TAG = `ll-strategy-dashboard-${STRATEGY_TYPE}`;
const EDITOR_TAG = "hui-maintenance-supporter-strategy-editor";
// Absolute URL of the full strategy bundle (served at STRATEGY_URL by the
// integration). The shim's only dependency, loaded on demand.
const BUNDLE_URL =
"/maintenance_supporter_strategy/maintenance-dashboard-strategy.js";
let _bundle: Promise<unknown> | null = null;
function loadBundle(): Promise<unknown> {
if (!_bundle) _bundle = import(/* @vite-ignore */ BUNDLE_URL);
return _bundle;
}
// The real dashboard class, exported by the heavy bundle once it loads. We
// resolve it from the registered editor's sibling export at call time.
type DashboardStrategyClass = {
generate(config: unknown, hass: unknown): Promise<unknown>;
};
async function realDashboard(): Promise<DashboardStrategyClass> {
const mod = (await loadBundle()) as {
MaintenanceDashboardStrategy?: DashboardStrategyClass;
};
if (!mod.MaintenanceDashboardStrategy) {
throw new Error(
"[maintenance-supporter] strategy bundle loaded but did not export " +
"MaintenanceDashboardStrategy",
);
}
return mod.MaintenanceDashboardStrategy;
}
class MaintenanceDashboardStrategyShim extends HTMLElement {
static getCreateSuggestions(_hass: unknown) {
return { title: "Maintenance Supporter", icon: "mdi:wrench-clock" };
}
static async getConfigElement(): Promise<HTMLElement> {
// Loading the bundle defines the editor element as a side effect.
await loadBundle();
return document.createElement(EDITOR_TAG);
}
static async generate(config: unknown, hass: unknown): Promise<unknown> {
const real = await realDashboard();
return real.generate(config, hass);
}
}
function defineShim(): void {
// Attempt unconditionally: customElements.get() is unreliable across HA's
// scoped registries (it can read true while the active dashboard registry has
// no registration), so we always try and swallow the "already defined" throw.
// On a fresh (cache-busted) re-import this re-registers on the now-active
// registry, which is what the self-heal relies on.
try {
customElements.define(STRATEGY_TAG, MaintenanceDashboardStrategyShim);
} catch {
/* already defined in this registry — fine */
}
}
defineShim();
// Picker discovery (HA 2026.5+ reads window.customStrategies when the
// "Add Dashboard" dialog opens). Idempotent — the heavy bundle pushes the
// same entry too once it loads; whichever runs first wins, the other skips.
const w = window as unknown as {
customStrategies?: Array<{
type: string;
strategyType: string;
name: string;
description?: string;
documentationURL?: string;
}>;
};
w.customStrategies = w.customStrategies || [];
if (
!w.customStrategies.some(
(s) => s.type === STRATEGY_TYPE && s.strategyType === "dashboard",
)
) {
w.customStrategies.push({
type: STRATEGY_TYPE,
strategyType: "dashboard",
name: "Maintenance Supporter",
description:
"Auto-generated dashboard. Group views by area, status, floor, or due date — picked from the strategy editor or YAML.",
documentationURL:
"https://github.com/iluebbe/maintenance_supporter#dashboard-strategy",
});
}
// ── Self-heal ───────────────────────────────────────────────────────────────
// Root cause (diagnosed live): HA's frontend uses a SCOPED custom-element
// registry that is swapped in during boot. Our element, defined at module load,
// can end up registered on a registry that the dashboard renderer no longer
// uses — so `customElements.get(STRATEGY_TAG)` reads false in the active
// registry and HA's 5 s whenDefined times out (console-only error, empty view).
//
// Verified recovery (9/9 on a live plugin-heavy system): RE-DEFINE on the
// now-current registry, then bounce the view (navigate away + back) so HA's
// <hui-panel-lovelace> reconnects and re-runs _fetchConfig — generate() then
// finds the element and the real dashboard renders.
//
// Fully defensive: only acts on a Lovelace path when our element is missing
// from the active registry; on a healthy load it never fires.
(() => {
const heal = window as unknown as { __msStrategyHealActive?: boolean };
if (heal.__msStrategyHealActive) return; // one heal loop per page session
heal.__msStrategyHealActive = true;
const NON_LOVELACE =
/^\/(auth|config|developer-tools|profile|hassio|history|logbook|map|media-browser|energy|todo|calendar)\b/;
// NOTE: customElements.get(STRATEGY_TAG) is NOT a reliable health signal —
// HA's scoped registry makes it read `true` here even when the dashboard's
// renderer (a different registry) failed. So we gate on the RENDERED OUTCOME:
// a strategy dashboard that failed shows an essentially empty view.
const ERROR_RE =
/Timeout waiting for strategy element ll-strategy-(dashboard-)?maintenance-supporter/i;
// The dashboard/view strategy type our generated dashboards carry.
const OUR_STRATEGY = `custom:${STRATEGY_TYPE}`;
// Deep querySelector through open shadow roots (HA nests the panel deeply).
function findDeep(tag: string): Element | null {
const stack: Array<Element | ShadowRoot> = [document.documentElement];
let scanned = 0;
while (stack.length && scanned < 9000) {
const node = stack.pop();
scanned++;
if (!node) continue;
const el = node as Element & { shadowRoot?: ShadowRoot | null };
if (el.nodeType === 1 && el.tagName && el.tagName.toLowerCase() === tag) {
return el;
}
if (el.shadowRoot) stack.push(el.shadowRoot);
const kids = (node as ParentNode).children;
if (kids) for (const k of Array.from(kids)) stack.push(k);
}
return null;
}
// The view config for the path we are on (by view `path`, else numeric index,
// else the first view).
function currentViewCfg(
cfg: { views?: Array<Record<string, unknown>> },
): Record<string, unknown> | null {
const views = cfg?.views;
if (!Array.isArray(views) || !views.length) return null;
const seg = window.location.pathname.split("/").filter(Boolean).pop() || "";
const byPath = views.find((v) => (v as { path?: string })?.path === seg);
if (byPath) return byPath;
const idx = Number(seg);
if (Number.isInteger(idx) && views[idx]) return views[idx];
return views[0];
}
// True ONLY when the active dashboard/view is generated by our strategy.
// Reads the Lovelace config off <ha-panel-lovelace> (both the processed
// `config` and the original `rawConfig`, at dashboard level and current-view
// level). This scopes the self-heal so it never touches a dashboard that is
// not ours — the fix for issue #68, where the blank-view heuristic bounced
// unrelated dashboards that happened to have < 3 cards. Defensive: any read
// failure → false (never heal a view we cannot confirm is ours).
function isOurStrategyView(): boolean {
try {
const panel = findDeep("ha-panel-lovelace") as unknown as {
lovelace?: {
config?: Record<string, unknown>;
rawConfig?: Record<string, unknown>;
};
} | null;
const lov = panel?.lovelace;
if (!lov) return false;
const stratType = (s: unknown): string | undefined =>
(s as { type?: string } | undefined)?.type;
for (const cfg of [lov.config, lov.rawConfig]) {
if (!cfg) continue;
if (stratType((cfg as { strategy?: unknown }).strategy) === OUR_STRATEGY) {
return true;
}
const v = currentViewCfg(
cfg as { views?: Array<Record<string, unknown>> },
);
if (v && stratType((v as { strategy?: unknown }).strategy) === OUR_STRATEGY) {
return true;
}
}
return false;
} catch {
return false;
}
}
// Heuristic for "we are on a broken strategy view": the lovelace view exists
// but is essentially empty (HA rendered the failed strategy as a blank view),
// OR HA surfaced our strategy error card. Walks light + shadow DOM.
function viewIsBroken(): boolean {
let hasView = false;
let cards = 0;
let ourError = false;
let hasEmptyState = false;
const stack: Array<Element | ShadowRoot> = [document.documentElement];
let scanned = 0;
while (stack.length && scanned < 9000) {
const node = stack.pop();
scanned++;
if (!node) continue;
const el = node as Element & { shadowRoot?: ShadowRoot | null };
if (el.nodeType === 1 && el.tagName) {
const tag = el.tagName.toLowerCase();
if (tag === "hui-view" || tag === "hui-sections-view") hasView = true;
if (tag === "ha-card" || tag === "hui-card") cards++;
// The zero-objects onboarding view renders exactly ONE hui-empty-state-
// card. That is a SUCCESSFUL strategy render, but it trips the "< 3
// cards" blank-view heuristic below — issue #69, where the empty state
// was mistaken for a broken render and the page reloaded every ~5 s.
if (tag === "hui-empty-state-card") hasEmptyState = true;
if (tag === "hui-error-card" && ERROR_RE.test(el.textContent || "")) {
ourError = true;
}
}
if (el.shadowRoot) stack.push(el.shadowRoot);
const kids = (node as ParentNode).children;
if (kids) for (const k of Array.from(kids)) stack.push(k);
}
// ourError (HA's error card naming OUR strategy element) is unambiguous —
// act on it directly. The "blank view" signal (< 3 cards) is ambiguous on
// its own (a small or still-loading dashboard looks identical), so gate it
// on the active dashboard actually being our strategy. Without that gate the
// self-heal bounced unrelated dashboards with few cards (issue #68).
if (ourError) return true;
// A rendered empty-state card means generate() ran and produced our
// onboarding view — that is healthy by definition, so never bounce it. A
// genuine strategy timeout shows hui-error-card or zero cards, never this
// card (issue #69).
if (hasEmptyState) return false;
return hasView && cards < 3 && isOurStrategyView();
}
// URL of THIS module, so recovery can force a fresh re-import (a cache-busted
// import re-evaluates the module and re-runs customElements.define against the
// registry that is active NOW — the boot-time define may have landed on a
// scoped registry HA has since discarded, and a plain defineShim() call is a
// no-op because customElements.get() reads stale-true across scoped registries).
const SHIM_URL = "/maintenance_supporter_strategy_shim.js";
let bounces = 0;
let lastBounce = 0;
function recover(): void {
const now = Date.now();
if (now - lastBounce < 5000 || bounces >= 3) return; // never thrash / loop
lastBounce = now;
bounces += 1;
// 1) Force a fresh re-import so define() re-runs on the now-active registry.
// (Verified live: this is what makes the element resolvable to HA's
// dashboard renderer; a guarded re-define is not enough.)
void import(/* @vite-ignore */ `${SHIM_URL}?heal=${now}`)
.catch(() => undefined)
.finally(() => {
// 2) Bounce so HA re-fetches the strategy config; generate() now
// resolves the element and the real dashboard renders.
const back = window.location.pathname + window.location.search;
history.pushState(null, "", "/lovelace");
window.dispatchEvent(new CustomEvent("location-changed"));
window.setTimeout(() => {
history.pushState(null, "", back);
window.dispatchEvent(new CustomEvent("location-changed"));
}, 200);
});
}
function watch(): void {
if (NON_LOVELACE.test(window.location.pathname)) return;
let ticks = 0;
const startedAt = Date.now();
const id = window.setInterval(() => {
ticks++;
try {
// Give HA past its ~5 s strategy timeout before judging the outcome.
if (Date.now() - startedAt < 6000) return;
if (NON_LOVELACE.test(window.location.pathname)) {
window.clearInterval(id);
return;
}
if (viewIsBroken()) {
recover();
} else {
window.clearInterval(id); // healthy / recovered
}
if (ticks >= 30) window.clearInterval(id); // ~15 s safety cap
} catch {
window.clearInterval(id);
}
}, 500);
}
try {
if (document.readyState === "loading") {
window.addEventListener("DOMContentLoaded", watch);
} else {
watch();
}
window.addEventListener("location-changed", () => {
if (!NON_LOVELACE.test(window.location.pathname)) watch();
});
} catch {
/* best-effort; never block registration */
}
})();
export {};