diff --git a/.gitignore b/.gitignore index b525567..e573c95 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules/ # Build outputs dist/ *.js.map +custom_components/bom_local/www/*.js.map # Environment files .env diff --git a/custom_components/bom_local/__init__.py b/custom_components/bom_local/__init__.py new file mode 100644 index 0000000..5e6ad35 --- /dev/null +++ b/custom_components/bom_local/__init__.py @@ -0,0 +1,73 @@ +"""The BOM Local Radar integration.""" +import logging +import voluptuous as vol + +from homeassistant.core import HomeAssistant +from homeassistant.components.http import StaticPathConfig +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN, CONF_SERVICE_URL, DEFAULT_SERVICE_URL +from .views import BomLocalProxyView +from .websocket import async_setup_websocket + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_SERVICE_URL, default=DEFAULT_SERVICE_URL): cv.string, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the BOM Local Radar component from YAML.""" + if DOMAIN not in config: + return True + + conf = config[DOMAIN] + service_url = conf.get(CONF_SERVICE_URL) + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN]["service_url"] = service_url + + await _async_setup_integration(hass, service_url) + + return True + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up BOM Local Radar from a config entry.""" + service_url = entry.data.get(CONF_SERVICE_URL) + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN]["service_url"] = service_url + + await _async_setup_integration(hass, service_url) + + return True + +async def _async_setup_integration(hass: HomeAssistant, service_url: str): + """Common setup logic for YAML and Config Entry.""" + # Register the proxy view + hass.http.register_view(BomLocalProxyView(service_url)) + + # Register the websocket commands + async_setup_websocket(hass, service_url) + + # Register the card as a resource automatically + # Note: In a real HACS install, the path would be /local/community/bom-local-card/bom-local-card.js + # but since this is an integration, it's /local/community/bom_local/bom-local-card.js + await hass.http.async_register_static_paths([ + StaticPathConfig( + "/local/community/bom_local/bom-local-card.js", + hass.config.path("custom_components/bom_local/www/bom-local-card.js"), + True + ) + ]) + + _LOGGER.info("BOM Local Radar integration setup completed with service URL: %s", service_url) + diff --git a/custom_components/bom_local/__pycache__/__init__.cpython-313.pyc b/custom_components/bom_local/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..3a7e88f Binary files /dev/null and b/custom_components/bom_local/__pycache__/__init__.cpython-313.pyc differ diff --git a/custom_components/bom_local/__pycache__/config_flow.cpython-313.pyc b/custom_components/bom_local/__pycache__/config_flow.cpython-313.pyc new file mode 100644 index 0000000..b019a4a Binary files /dev/null and b/custom_components/bom_local/__pycache__/config_flow.cpython-313.pyc differ diff --git a/custom_components/bom_local/__pycache__/const.cpython-313.pyc b/custom_components/bom_local/__pycache__/const.cpython-313.pyc new file mode 100644 index 0000000..04a4e56 Binary files /dev/null and b/custom_components/bom_local/__pycache__/const.cpython-313.pyc differ diff --git a/custom_components/bom_local/__pycache__/views.cpython-313.pyc b/custom_components/bom_local/__pycache__/views.cpython-313.pyc new file mode 100644 index 0000000..32047f3 Binary files /dev/null and b/custom_components/bom_local/__pycache__/views.cpython-313.pyc differ diff --git a/custom_components/bom_local/__pycache__/websocket.cpython-313.pyc b/custom_components/bom_local/__pycache__/websocket.cpython-313.pyc new file mode 100644 index 0000000..bfb6730 Binary files /dev/null and b/custom_components/bom_local/__pycache__/websocket.cpython-313.pyc differ diff --git a/custom_components/bom_local/config_flow.py b/custom_components/bom_local/config_flow.py new file mode 100644 index 0000000..a938970 --- /dev/null +++ b/custom_components/bom_local/config_flow.py @@ -0,0 +1,58 @@ +import logging +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.core import callback +from homeassistant.const import CONF_URL +import homeassistant.helpers.config_validation as cv + +from .const import DOMAIN, CONF_SERVICE_URL, DEFAULT_SERVICE_URL + +_LOGGER = logging.getLogger(__name__) + +class BomLocalConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for BOM Local Radar.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + # We could validate the URL here by trying a fetch + return self.async_create_entry(title="BOM Local Radar", data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({ + vol.Required(CONF_SERVICE_URL, default=DEFAULT_SERVICE_URL): str, + }), + errors=errors, + ) + + @staticmethod + @callback + def async_get_options_flow(config_entry): + return BomLocalOptionsFlow(config_entry) + +class BomLocalOptionsFlow(config_entries.OptionsFlow): + """Handle options flow.""" + + def __init__(self, config_entry): + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema({ + vol.Required( + CONF_SERVICE_URL, + default=self.config_entry.data.get(CONF_SERVICE_URL, DEFAULT_SERVICE_URL), + ): str, + }), + ) + diff --git a/custom_components/bom_local/const.py b/custom_components/bom_local/const.py new file mode 100644 index 0000000..4c1765f --- /dev/null +++ b/custom_components/bom_local/const.py @@ -0,0 +1,6 @@ +"""Constants for the BOM Local Radar integration.""" + +DOMAIN = "bom_local" +CONF_SERVICE_URL = "service_url" +DEFAULT_SERVICE_URL = "http://localhost:8082" + diff --git a/custom_components/bom_local/manifest.json b/custom_components/bom_local/manifest.json new file mode 100644 index 0000000..fae947a --- /dev/null +++ b/custom_components/bom_local/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "bom_local", + "name": "BOM Local Radar", + "codeowners": ["@alexhopeoconnor"], + "config_flow": true, + "iot_class": "local_polling", + "version": "0.2.0", + "dependencies": ["http", "websocket_api"], + "documentation": "https://github.com/alexhopeoconnor/bom-local-card", + "issue_tracker": "https://github.com/alexhopeoconnor/bom-local-card/issues" +} + diff --git a/custom_components/bom_local/strings.json b/custom_components/bom_local/strings.json new file mode 100644 index 0000000..8933077 --- /dev/null +++ b/custom_components/bom_local/strings.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "title": "BOM Local Radar Configuration", + "description": "Enter the URL of your BOM Local Radar service (e.g., http://192.168.1.50:8082).", + "data": { + "service_url": "Service URL" + } + } + }, + "error": { + "cannot_connect": "Failed to connect to the BOM Local Radar service", + "invalid_url": "Please enter a valid URL" + }, + "abort": { + "already_configured": "This service is already configured" + } + }, + "options": { + "step": { + "init": { + "title": "BOM Local Radar Options", + "data": { + "service_url": "Service URL" + } + } + } + } +} + diff --git a/custom_components/bom_local/translations/en.json b/custom_components/bom_local/translations/en.json new file mode 100644 index 0000000..8933077 --- /dev/null +++ b/custom_components/bom_local/translations/en.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "title": "BOM Local Radar Configuration", + "description": "Enter the URL of your BOM Local Radar service (e.g., http://192.168.1.50:8082).", + "data": { + "service_url": "Service URL" + } + } + }, + "error": { + "cannot_connect": "Failed to connect to the BOM Local Radar service", + "invalid_url": "Please enter a valid URL" + }, + "abort": { + "already_configured": "This service is already configured" + } + }, + "options": { + "step": { + "init": { + "title": "BOM Local Radar Options", + "data": { + "service_url": "Service URL" + } + } + } + } +} + diff --git a/custom_components/bom_local/views.py b/custom_components/bom_local/views.py new file mode 100644 index 0000000..c1c5216 --- /dev/null +++ b/custom_components/bom_local/views.py @@ -0,0 +1,42 @@ +import aiohttp +from homeassistant.components.http import HomeAssistantView +from homeassistant.core import HomeAssistant +from .const import DOMAIN + +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +class BomLocalProxyView(HomeAssistantView): + """Proxy view for BOM local service images.""" + url = "/api/bom_local/proxy/{path:.*}" + name = "api:bom_local:proxy" + requires_auth = False + + def __init__(self, service_url: str): + self._service_url = service_url.rstrip('/') + + async def get(self, request, path): + """Fetch image from local service and stream it back.""" + # Reconstruct query parameters (important for historical data) + query_string = request.query_string + target_url = f"{self._service_url}/{path}" + if query_string: + target_url = f"{target_url}?{query_string}" + + hass = request.app["hass"] + session = async_get_clientsession(hass) + try: + async with session.get(target_url, timeout=15) as response: + if response.status != 200: + return web.Response(status=response.status) + + content = await response.read() + return web.Response( + body=content, + content_type=response.content_type, + headers={"Cache-Control": "max-age=3600"} + ) + except Exception: + return web.Response(status=502) + +from aiohttp import web + diff --git a/custom_components/bom_local/websocket.py b/custom_components/bom_local/websocket.py new file mode 100644 index 0000000..a73d84c --- /dev/null +++ b/custom_components/bom_local/websocket.py @@ -0,0 +1,59 @@ +import voluptuous as vol +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant, callback +import aiohttp +import logging + +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +@callback +def async_setup_websocket(hass: HomeAssistant, service_url: str): + """Set up websocket commands.""" + websocket_api.async_register_command( + hass, websocket_get_radar_data + ) + +@websocket_api.websocket_command({ + vol.Required("type"): "bom_local/get_radar_data", + vol.Required("suburb"): str, + vol.Required("state"): str, + vol.Optional("timespan"): str, + vol.Optional("startTime"): str, + vol.Optional("endTime"): str, +}) +@websocket_api.async_response +async def websocket_get_radar_data(hass: HomeAssistant, connection, msg): + """Handle getting radar data.""" + service_url = hass.data[DOMAIN]["service_url"].rstrip('/') + suburb = msg["suburb"] + state = msg["state"] + + # Construct target URL for the local service + if "startTime" in msg and "endTime" in msg: + # Historical/Timeseries + path = f"api/radar/{suburb}/{state}/timeseries?startTime={msg['startTime']}&endTime={msg['endTime']}" + else: + # Latest + path = f"api/radar/{suburb}/{state}" + + target_url = f"{service_url}/{path}" + + session = async_get_clientsession(hass) + try: + async with session.get(target_url, timeout=10) as response: + # Even if status is not 200 (e.g. 404), the service returns helpful JSON + # about cache status that the card needs. + data = await response.json() + + if response.status == 200: + connection.send_result(msg["id"], data) + else: + # Send the data back but as an error result so the card can parse it + connection.send_result(msg["id"], data) + except Exception as err: + _LOGGER.error("Error fetching radar data from %s: %s", target_url, err) + connection.send_error(msg["id"], "connection_error", str(err)) + diff --git a/custom_components/bom_local/www/bom-local-card.js b/custom_components/bom_local/www/bom-local-card.js new file mode 100644 index 0000000..faf3d11 --- /dev/null +++ b/custom_components/bom_local/www/bom-local-card.js @@ -0,0 +1,3445 @@ +/****************************************************************************** +Copyright (c) Microsoft Corporation. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. +***************************************************************************** */ + +function __decorate(decorators, target, key, desc) { + var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; + if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); + else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; + return c > 3 && r && Object.defineProperty(target, key, r), r; +} + +typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) { + var e = new Error(message); + return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e; +}; + +/** + * @license + * Copyright 2019 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const t$3=globalThis,e$2=t$3.ShadowRoot&&(void 0===t$3.ShadyCSS||t$3.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,s$2=Symbol(),o$4=new WeakMap;class n$3{constructor(t,e,o){if(this._$cssResult$=!0,o!==s$2)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t,this.t=e;}get styleSheet(){let t=this.o;const s=this.t;if(e$2&&void 0===t){const e=void 0!==s&&1===s.length;e&&(t=o$4.get(s)),void 0===t&&((this.o=t=new CSSStyleSheet).replaceSync(this.cssText),e&&o$4.set(s,t));}return t}toString(){return this.cssText}}const r$5=t=>new n$3("string"==typeof t?t:t+"",void 0,s$2),i$3=(t,...e)=>{const o=1===t.length?t[0]:e.reduce(((e,s,o)=>e+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(s)+t[o+1]),t[0]);return new n$3(o,t,s$2)},S$1=(s,o)=>{if(e$2)s.adoptedStyleSheets=o.map((t=>t instanceof CSSStyleSheet?t:t.styleSheet));else for(const e of o){const o=document.createElement("style"),n=t$3.litNonce;void 0!==n&&o.setAttribute("nonce",n),o.textContent=e.cssText,s.appendChild(o);}},c$2=e$2?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let e="";for(const s of t.cssRules)e+=s.cssText;return r$5(e)})(t):t; + +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */const{is:i$2,defineProperty:e$1,getOwnPropertyDescriptor:h$1,getOwnPropertyNames:r$4,getOwnPropertySymbols:o$3,getPrototypeOf:n$2}=Object,a$1=globalThis,c$1=a$1.trustedTypes,l$1=c$1?c$1.emptyScript:"",p$1=a$1.reactiveElementPolyfillSupport,d$1=(t,s)=>t,u$1={toAttribute(t,s){switch(s){case Boolean:t=t?l$1:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t);}return t},fromAttribute(t,s){let i=t;switch(s){case Boolean:i=null!==t;break;case Number:i=null===t?null:Number(t);break;case Object:case Array:try{i=JSON.parse(t);}catch(t){i=null;}}return i}},f$1=(t,s)=>!i$2(t,s),b={attribute:!0,type:String,converter:u$1,reflect:!1,useDefault:!1,hasChanged:f$1};Symbol.metadata??=Symbol("metadata"),a$1.litPropertyMetadata??=new WeakMap;class y$1 extends HTMLElement{static addInitializer(t){this._$Ei(),(this.l??=[]).push(t);}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(t,s=b){if(s.state&&(s.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(t)&&((s=Object.create(s)).wrapped=!0),this.elementProperties.set(t,s),!s.noAccessor){const i=Symbol(),h=this.getPropertyDescriptor(t,i,s);void 0!==h&&e$1(this.prototype,t,h);}}static getPropertyDescriptor(t,s,i){const{get:e,set:r}=h$1(this.prototype,t)??{get(){return this[s]},set(t){this[s]=t;}};return {get:e,set(s){const h=e?.call(this);r?.call(this,s),this.requestUpdate(t,h,i);},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??b}static _$Ei(){if(this.hasOwnProperty(d$1("elementProperties")))return;const t=n$2(this);t.finalize(),void 0!==t.l&&(this.l=[...t.l]),this.elementProperties=new Map(t.elementProperties);}static finalize(){if(this.hasOwnProperty(d$1("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(d$1("properties"))){const t=this.properties,s=[...r$4(t),...o$3(t)];for(const i of s)this.createProperty(i,t[i]);}const t=this[Symbol.metadata];if(null!==t){const s=litPropertyMetadata.get(t);if(void 0!==s)for(const[t,i]of s)this.elementProperties.set(t,i);}this._$Eh=new Map;for(const[t,s]of this.elementProperties){const i=this._$Eu(t,s);void 0!==i&&this._$Eh.set(i,t);}this.elementStyles=this.finalizeStyles(this.styles);}static finalizeStyles(s){const i=[];if(Array.isArray(s)){const e=new Set(s.flat(1/0).reverse());for(const s of e)i.unshift(c$2(s));}else void 0!==s&&i.push(c$2(s));return i}static _$Eu(t,s){const i=s.attribute;return !1===i?void 0:"string"==typeof i?i:"string"==typeof t?t.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev();}_$Ev(){this._$ES=new Promise((t=>this.enableUpdating=t)),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach((t=>t(this)));}addController(t){(this._$EO??=new Set).add(t),void 0!==this.renderRoot&&this.isConnected&&t.hostConnected?.();}removeController(t){this._$EO?.delete(t);}_$E_(){const t=new Map,s=this.constructor.elementProperties;for(const i of s.keys())this.hasOwnProperty(i)&&(t.set(i,this[i]),delete this[i]);t.size>0&&(this._$Ep=t);}createRenderRoot(){const t=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return S$1(t,this.constructor.elementStyles),t}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach((t=>t.hostConnected?.()));}enableUpdating(t){}disconnectedCallback(){this._$EO?.forEach((t=>t.hostDisconnected?.()));}attributeChangedCallback(t,s,i){this._$AK(t,i);}_$ET(t,s){const i=this.constructor.elementProperties.get(t),e=this.constructor._$Eu(t,i);if(void 0!==e&&!0===i.reflect){const h=(void 0!==i.converter?.toAttribute?i.converter:u$1).toAttribute(s,i.type);this._$Em=t,null==h?this.removeAttribute(e):this.setAttribute(e,h),this._$Em=null;}}_$AK(t,s){const i=this.constructor,e=i._$Eh.get(t);if(void 0!==e&&this._$Em!==e){const t=i.getPropertyOptions(e),h="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:u$1;this._$Em=e;const r=h.fromAttribute(s,t.type);this[e]=r??this._$Ej?.get(e)??r,this._$Em=null;}}requestUpdate(t,s,i){if(void 0!==t){const e=this.constructor,h=this[t];if(i??=e.getPropertyOptions(t),!((i.hasChanged??f$1)(h,s)||i.useDefault&&i.reflect&&h===this._$Ej?.get(t)&&!this.hasAttribute(e._$Eu(t,i))))return;this.C(t,s,i);}!1===this.isUpdatePending&&(this._$ES=this._$EP());}C(t,s,{useDefault:i,reflect:e,wrapped:h},r){i&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,r??s??this[t]),!0!==h||void 0!==r)||(this._$AL.has(t)||(this.hasUpdated||i||(s=void 0),this._$AL.set(t,s)),!0===e&&this._$Em!==t&&(this._$Eq??=new Set).add(t));}async _$EP(){this.isUpdatePending=!0;try{await this._$ES;}catch(t){Promise.reject(t);}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[t,s]of this._$Ep)this[t]=s;this._$Ep=void 0;}const t=this.constructor.elementProperties;if(t.size>0)for(const[s,i]of t){const{wrapped:t}=i,e=this[s];!0!==t||this._$AL.has(s)||void 0===e||this.C(s,void 0,i,e);}}let t=!1;const s=this._$AL;try{t=this.shouldUpdate(s),t?(this.willUpdate(s),this._$EO?.forEach((t=>t.hostUpdate?.())),this.update(s)):this._$EM();}catch(s){throw t=!1,this._$EM(),s}t&&this._$AE(s);}willUpdate(t){}_$AE(t){this._$EO?.forEach((t=>t.hostUpdated?.())),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t);}_$EM(){this._$AL=new Map,this.isUpdatePending=!1;}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(t){return !0}update(t){this._$Eq&&=this._$Eq.forEach((t=>this._$ET(t,this[t]))),this._$EM();}updated(t){}firstUpdated(t){}}y$1.elementStyles=[],y$1.shadowRootOptions={mode:"open"},y$1[d$1("elementProperties")]=new Map,y$1[d$1("finalized")]=new Map,p$1?.({ReactiveElement:y$1}),(a$1.reactiveElementVersions??=[]).push("2.1.1"); + +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const t$2=globalThis,i$1=t$2.trustedTypes,s$1=i$1?i$1.createPolicy("lit-html",{createHTML:t=>t}):void 0,e="$lit$",h=`lit$${Math.random().toFixed(9).slice(2)}$`,o$2="?"+h,n$1=`<${o$2}>`,r$3=document,l=()=>r$3.createComment(""),c=t=>null===t||"object"!=typeof t&&"function"!=typeof t,a=Array.isArray,u=t=>a(t)||"function"==typeof t?.[Symbol.iterator],d="[ \t\n\f\r]",f=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,v=/-->/g,_=/>/g,m=RegExp(`>|${d}(?:([^\\s"'>=/]+)(${d}*=${d}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),p=/'/g,g=/"/g,$=/^(?:script|style|textarea|title)$/i,y=t=>(i,...s)=>({_$litType$:t,strings:i,values:s}),x=y(1),T=Symbol.for("lit-noChange"),E=Symbol.for("lit-nothing"),A=new WeakMap,C=r$3.createTreeWalker(r$3,129);function P(t,i){if(!a(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==s$1?s$1.createHTML(i):i}const V=(t,i)=>{const s=t.length-1,o=[];let r,l=2===i?"":3===i?"":"",c=f;for(let i=0;i"===u[0]?(c=r??f,d=-1):void 0===u[1]?d=-2:(d=c.lastIndex-u[2].length,a=u[1],c=void 0===u[3]?m:'"'===u[3]?g:p):c===g||c===p?c=m:c===v||c===_?c=f:(c=m,r=void 0);const x=c===m&&t[i+1].startsWith("/>")?" ":"";l+=c===f?s+n$1:d>=0?(o.push(a),s.slice(0,d)+e+s.slice(d)+h+x):s+h+(-2===d?i:x);}return [P(t,l+(t[s]||"")+(2===i?"":3===i?"":"")),o]};class N{constructor({strings:t,_$litType$:s},n){let r;this.parts=[];let c=0,a=0;const u=t.length-1,d=this.parts,[f,v]=V(t,s);if(this.el=N.createElement(f,n),C.currentNode=this.el.content,2===s||3===s){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes);}for(;null!==(r=C.nextNode())&&d.length0){r.textContent=i$1?i$1.emptyScript:"";for(let i=0;i2||""!==s[0]||""!==s[1]?(this._$AH=Array(s.length-1).fill(new String),this.strings=s):this._$AH=E;}_$AI(t,i=this,s,e){const h=this.strings;let o=!1;if(void 0===h)t=S(this,t,i,0),o=!c(t)||t!==this._$AH&&t!==T,o&&(this._$AH=t);else {const e=t;let n,r;for(t=h[0],n=0;n{const e=s?.renderBefore??i;let h=e._$litPart$;if(void 0===h){const t=s?.renderBefore??null;e._$litPart$=h=new R(i.insertBefore(l(),t),t,void 0,s??{});}return h._$AI(t),h}; + +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */const s=globalThis;class i extends y$1{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0;}createRenderRoot(){const t=super.createRenderRoot();return this.renderOptions.renderBefore??=t.firstChild,t}update(t){const r=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(t),this._$Do=B(r,this.renderRoot,this.renderOptions);}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0);}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1);}render(){return T}}i._$litElement$=!0,i["finalized"]=!0,s.litElementHydrateSupport?.({LitElement:i});const o$1=s.litElementPolyfillSupport;o$1?.({LitElement:i});(s.litElementVersions??=[]).push("4.2.1"); + +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const t$1=t=>(e,o)=>{void 0!==o?o.addInitializer((()=>{customElements.define(t,e);})):customElements.define(t,e);}; + +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */const o={attribute:!0,type:String,converter:u$1,reflect:!1,hasChanged:f$1},r$2=(t=o,e,r)=>{const{kind:n,metadata:i}=r;let s=globalThis.litPropertyMetadata.get(i);if(void 0===s&&globalThis.litPropertyMetadata.set(i,s=new Map),"setter"===n&&((t=Object.create(t)).wrapped=!0),s.set(r.name,t),"accessor"===n){const{name:o}=r;return {set(r){const n=e.get.call(this);e.set.call(this,r),this.requestUpdate(o,n,t);},init(e){return void 0!==e&&this.C(o,void 0,t,e),e}}}if("setter"===n){const{name:o}=r;return function(r){const n=this[o];e.call(this,r),this.requestUpdate(o,n,t);}}throw Error("Unsupported decorator location: "+n)};function n(t){return (e,o)=>"object"==typeof o?r$2(t,e,o):((t,e,o)=>{const r=e.hasOwnProperty(o);return e.constructor.createProperty(o,t),r?Object.getOwnPropertyDescriptor(e,o):void 0})(t,e,o)} + +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */function r$1(r){return n({...r,state:!0,attribute:!1})} + +var t,r;!function(e){e.language="language",e.system="system",e.comma_decimal="comma_decimal",e.decimal_comma="decimal_comma",e.space_comma="space_comma",e.none="none";}(t||(t={})),function(e){e.language="language",e.system="system",e.am_pm="12",e.twenty_four="24";}(r||(r={}));var ne=function(e,t,r,n){n=n||{},r=null==r?{}:r;var i=new Event(t,{bubbles:void 0===n.bubbles||n.bubbles,cancelable:Boolean(n.cancelable),composed:void 0===n.composed||n.composed});return i.detail=r,e.dispatchEvent(i),i}; + +// Australian states for dropdown +const AUSTRALIAN_STATES = [ + { value: 'ACT', label: 'Australian Capital Territory' }, + { value: 'NSW', label: 'New South Wales' }, + { value: 'NT', label: 'Northern Territory' }, + { value: 'QLD', label: 'Queensland' }, + { value: 'SA', label: 'South Australia' }, + { value: 'TAS', label: 'Tasmania' }, + { value: 'VIC', label: 'Victoria' }, + { value: 'WA', label: 'Western Australia' }, +]; +let BomLocalRadarCardEditor = class BomLocalRadarCardEditor extends i { + constructor() { + super(...arguments); + this._config = this._mergeWithDefaults(); + this._metadataExpanded = false; + this._controlsExpanded = false; + } + _mergeWithDefaults(config = {}) { + const defaults = { + service_url: 'http://localhost:8082', + timespan: 'latest', + frame_interval: 2.0, + refresh_interval: 30, + auto_play: true, + show_card_title: true, + show_metadata: true, + show_controls: true, + image_zoom: 1.0, + image_fit: 'contain', + }; + return Object.assign(Object.assign(Object.assign({}, defaults), config), { type: 'custom:bom-local-card' }); + } + setConfig(config) { + this._config = this._mergeWithDefaults(config); + this.requestUpdate(); + } + render() { + var _a, _b, _c, _d, _e, _f, _g; + if (!this.hass) + return x ``; + return x ` +
+
+

Location Configuration

+ this._updateConfig('suburb', e.target.value)} + required + > +
+ + +
+
+ +
+

Display

+
+ + { + const checked = e.target.checked; + this._updateConfig('show_card_title', checked); + }} + > +
+ ${this._config.show_card_title !== false ? x ` + this._updateConfig('card_title', e.target.value)} + helper="Leave empty to use default" + > + ` : ''} + + + +
+
+
+ + { + const checked = e.target.checked; + this._updateControlsToggle(checked); + }} + > +
+ ${this._getControlsEnabled() ? x ` + { this._controlsExpanded = !this._controlsExpanded; }} + > + + + ` : ''} +
+ ${this._getControlsEnabled() && this._controlsExpanded ? x ` +
+
+ + this._updateControlsConfig('show_play_pause', e.target.checked)} + > +
+
+ + this._updateControlsConfig('show_prev_next', e.target.checked)} + > +
+
+ + this._updateControlsConfig('show_slider', e.target.checked)} + > +
+
+ + this._updateControlsConfig('show_nav_buttons', e.target.checked)} + > +
+
+ + this._updateControlsConfig('show_frame_info', e.target.checked)} + > +
+
+ + +
+ ${this._getControlsConfig('position') === 'overlay' ? x ` +
+ + +
+ this._updateConfig('controls_overlay_opacity', parseFloat(e.target.value))} + > + ${(() => { + const conflict = this._checkOverlayConflict(); + return conflict.hasConflict ? x ` +
+ ${conflict.message} +
+ ` : ''; + })()} + ` : ''} +
+ ` : ''} +
+ + this._updateConfig('image_zoom', parseFloat(e.target.value))} + helper="Zoom level: 0.5 = 50%, 1.0 = 100%, 2.0 = 200%" + > +
+ + +
+
+ +
+

Slideshow

+
+ + +
+ ${this._cacheRange && this._cacheRange.totalCacheFolders > 0 ? x ` +
+
+ Available cache: ${this._formatCacheRange()} +
+
+ ` : this._cacheRangeError ? x ` +
+
⚠️ ${this._cacheRangeError}
+
+ ` : ''} + ${this._getRangeWarning(this._config.timespan || 'latest') ? x ` +
+
+ ⚠️ Selected timespan may not have enough data. Available: ${this._formatCacheRange()} +
+
+ ` : ''} + ${this._config.timespan === 'custom' ? x ` + this._updateConfig('custom_start_time', e.target.value)} + helper="e.g., 2025-01-15T10:00:00Z" + > + this._updateConfig('custom_end_time', e.target.value)} + helper="e.g., 2025-01-15T18:00:00Z" + > + ${this._validateCustomRange() ? x ` +
+
+ ⚠️ ${this._validateCustomRange()} +
+
+ ` : ''} + ` : ''} + this._updateConfig('frame_interval', parseFloat(e.target.value))} + > +
+ + { + const checked = e.target.checked; + this._updateConfig('auto_play', checked); + }} + > +
+
+ +
+

Auto Refresh

+ this._updateConfig('refresh_interval', parseInt(e.target.value))} + > +
+
+ `; + } + _updateConfig(key, value) { + const config = Object.assign(Object.assign({}, this._config), { [key]: value }); + this._config = config; + ne(this, 'config-changed', { config }); + } + // Metadata configuration helpers + _getMetadataEnabled() { + const config = this._config.show_metadata; + if (config === undefined || config === true) + return true; + if (typeof config === 'boolean') + return config; + return true; // Object means enabled with custom config + } + _updateMetadataToggle(enabled) { + if (enabled) { + // If enabling, check if we have existing config or create default + if (typeof this._config.show_metadata === 'object') { + // Keep existing config + return; + } + // Create default config object + this._updateConfig('show_metadata', {}); + } + else { + // Disable metadata + this._updateConfig('show_metadata', false); + } + } + _getMetadataConfig(key) { + const config = this._config.show_metadata; + if (typeof config === 'boolean') { + // For boolean config, return default values for position/style, true for others + if (key === 'position') + return 'above'; + if (key === 'style') + return 'cards'; + return config; + } + if (typeof config === 'object') { + const value = config[key]; + // For position/style, return the value or undefined (default) + if (key === 'position' || key === 'style') { + return value; + } + // For boolean fields, default to true if not explicitly false + return value !== false; + } + // Default values + if (key === 'position') + return 'above'; + if (key === 'style') + return 'cards'; + return true; + } + _updateMetadataConfig(key, value) { + let config; + if (typeof this._config.show_metadata === 'object') { + config = Object.assign({}, this._config.show_metadata); + } + else { + config = {}; + } + // Handle undefined to remove the key (revert to default) + if (value === undefined) { + delete config[key]; + } + else { + // Type-safe assignment based on key + if (key === 'position' || key === 'style') { + config[key] = value; + } + else { + config[key] = value; + } + } + // If config is empty, set to true (default) + if (Object.keys(config).length === 0) { + this._updateConfig('show_metadata', true); + } + else { + this._updateConfig('show_metadata', config); + } + } + // Controls configuration helpers + _getControlsEnabled() { + const config = this._config.show_controls; + if (config === undefined || config === true) + return true; + if (typeof config === 'boolean') + return config; + return true; // Object means enabled with custom config + } + _updateControlsToggle(enabled) { + if (enabled) { + // If enabling, check if we have existing config or create default + if (typeof this._config.show_controls === 'object') { + // Keep existing config + return; + } + // Create default config object + this._updateConfig('show_controls', {}); + } + else { + // Disable controls + this._updateConfig('show_controls', false); + } + } + _getControlsConfig(key) { + const config = this._config.show_controls; + if (typeof config === 'boolean') { + // For boolean config, return default values for position, true for others + if (key === 'position') + return 'below'; + return config; + } + if (typeof config === 'object') { + const value = config[key]; + // For position, return the value or undefined (default) + if (key === 'position') { + return value; + } + // For boolean fields, default to true if not explicitly false + return value !== false; + } + // Default values + if (key === 'position') + return 'below'; + return true; + } + _updateControlsConfig(key, value) { + let config; + if (typeof this._config.show_controls === 'object') { + config = Object.assign({}, this._config.show_controls); + } + else { + config = {}; + } + // Handle undefined to remove the key (revert to default) + if (value === undefined) { + delete config[key]; + } + else { + // Type-safe assignment based on key + if (key === 'position') { + config[key] = value; + } + else { + config[key] = value; + } + } + // If config is empty, set to true (default) + if (Object.keys(config).length === 0) { + this._updateConfig('show_controls', true); + } + else { + this._updateConfig('show_controls', config); + } + } + /** + * Check if metadata and controls would overlap when both are overlays + */ + _checkOverlayConflict() { + var _a, _b, _c; + const metadataConfig = this._getMetadataConfig('position'); + const controlsConfig = this._getControlsConfig('position'); + // Both must be overlays to have a conflict + if (metadataConfig !== 'overlay' || controlsConfig !== 'overlay') { + return { hasConflict: false, message: '' }; + } + const metadataPos = (_a = this._config.metadata_overlay_position) !== null && _a !== void 0 ? _a : 'top'; + const controlsPos = (_b = this._config.controls_overlay_position) !== null && _b !== void 0 ? _b : 'bottom'; + // Check for conflicts + if (metadataPos === controlsPos) { + return { + hasConflict: true, + message: `⚠️ Warning: Both metadata and controls are overlayed at the same position (${metadataPos}). They will overlap.` + }; + } + // Check for adjacent positions that might conflict + const conflicts = { + 'top': ['top', 'center'], + 'bottom': ['bottom', 'center'], + 'left': ['left', 'center'], + 'right': ['right', 'center'], + 'center': ['top', 'bottom', 'left', 'right', 'center'] + }; + if ((_c = conflicts[metadataPos]) === null || _c === void 0 ? void 0 : _c.includes(controlsPos)) { + return { + hasConflict: true, + message: `⚠️ Warning: Metadata (${metadataPos}) and controls (${controlsPos}) may overlap. Consider using different positions.` + }; + } + return { hasConflict: false, message: '' }; + } + _getRangeWarning(timespan) { + if (!this._cacheRange || this._cacheRange.totalCacheFolders === 0 || timespan === 'latest' || timespan === 'custom') { + return false; + } + const hours = parseInt(timespan.replace('h', '')) || 0; + const requestedMinutes = hours * 60; + const availableMinutes = this._cacheRange.timeSpanMinutes || 0; + return requestedMinutes > availableMinutes; + } + _formatCacheRange() { + var _a, _b, _c, _d, _e, _f; + if (!this._cacheRange || this._cacheRange.totalCacheFolders === 0) { + return 'No cache available'; + } + const oldest = (_a = this._cacheRange.oldestCache) === null || _a === void 0 ? void 0 : _a.cacheTimestamp; + const newest = (_b = this._cacheRange.newestCache) === null || _b === void 0 ? void 0 : _b.cacheTimestamp; + const timeSpan = this._cacheRange.timeSpanMinutes; + if (oldest && newest) { + const oldestDate = new Date(oldest); + const newestDate = new Date(newest); + // Use local timezone for display + const locale = ((_d = (_c = this.hass) === null || _c === void 0 ? void 0 : _c.locale) === null || _d === void 0 ? void 0 : _d.language) || 'en-AU'; + const timeZone = ((_f = (_e = this.hass) === null || _e === void 0 ? void 0 : _e.config) === null || _f === void 0 ? void 0 : _f.time_zone) || Intl.DateTimeFormat().resolvedOptions().timeZone; + const oldestStr = oldestDate.toLocaleString(locale, { + timeZone: timeZone, + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + const newestStr = newestDate.toLocaleString(locale, { + timeZone: timeZone, + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + if (timeSpan) { + const hours = Math.floor(timeSpan / 60); + const minutes = timeSpan % 60; + return `${oldestStr} to ${newestStr} (${hours}h ${minutes}m, ${this._cacheRange.totalCacheFolders} folders)`; + } + return `${oldestStr} to ${newestStr} (${this._cacheRange.totalCacheFolders} folders)`; + } + return `${this._cacheRange.totalCacheFolders} cache folders available`; + } + _validateCustomRange() { + var _a, _b, _c, _d, _e, _f, _g, _h; + // Only validate if timespan is custom + if (this._config.timespan !== 'custom') { + return null; + } + if (!this._config.custom_start_time || !this._config.custom_end_time) { + return null; // No validation needed if fields are empty + } + try { + const startTime = new Date(this._config.custom_start_time); + const endTime = new Date(this._config.custom_end_time); + if (isNaN(startTime.getTime()) || isNaN(endTime.getTime())) { + return 'Invalid date format. Use ISO 8601 format (e.g., 2025-01-15T10:00:00Z)'; + } + if (startTime >= endTime) { + return 'Start time must be before end time'; + } + if (this._cacheRange && this._cacheRange.oldestCache && this._cacheRange.newestCache) { + try { + const oldestCache = new Date(this._cacheRange.oldestCache.cacheTimestamp); + const newestCache = new Date(this._cacheRange.newestCache.cacheTimestamp); + if (isNaN(oldestCache.getTime()) || isNaN(newestCache.getTime())) { + return null; // Can't validate if cache dates are invalid + } + if (startTime < oldestCache) { + const locale = ((_b = (_a = this.hass) === null || _a === void 0 ? void 0 : _a.locale) === null || _b === void 0 ? void 0 : _b.language) || 'en-AU'; + const timeZone = ((_d = (_c = this.hass) === null || _c === void 0 ? void 0 : _c.config) === null || _d === void 0 ? void 0 : _d.time_zone) || Intl.DateTimeFormat().resolvedOptions().timeZone; + const oldestStr = oldestCache.toLocaleString(locale, { + timeZone: timeZone, + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + return `Start time is before available cache (${oldestStr})`; + } + if (endTime > newestCache) { + const locale = ((_f = (_e = this.hass) === null || _e === void 0 ? void 0 : _e.locale) === null || _f === void 0 ? void 0 : _f.language) || 'en-AU'; + const timeZone = ((_h = (_g = this.hass) === null || _g === void 0 ? void 0 : _g.config) === null || _h === void 0 ? void 0 : _h.time_zone) || Intl.DateTimeFormat().resolvedOptions().timeZone; + const newestStr = newestCache.toLocaleString(locale, { + timeZone: timeZone, + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + return `End time is after available cache (${newestStr})`; + } + } + catch (err) { + // Ignore cache date parsing errors + return null; + } + } + return null; // Valid + } + catch (err) { + return 'Invalid date format'; + } + } +}; +BomLocalRadarCardEditor.styles = i$3 ` + .editor { + padding: 16px; + display: flex; + flex-direction: column; + gap: 24px; + } + + .section { + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px; + background: var(--card-background-color, #ffffff); + border-radius: 8px; + border: 1px solid var(--divider-color, #e0e0e0); + } + + .section h3 { + margin: 0; + font-weight: 600; + font-size: 1.1em; + color: var(--primary-text-color, #212121); + padding-bottom: 8px; + border-bottom: 2px solid var(--divider-color, #e0e0e0); + } + + .section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + padding: 8px 0; + } + + .metadata-section, + .controls-section { + margin-top: 0; + padding: 12px; + background: var(--secondary-background-color, #fafafa); + border-radius: 8px; + border: 1px solid var(--divider-color, #e0e0e0); + } + + .metadata-options, + .controls-options { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--divider-color, #e0e0e0); + } + + ha-textfield { + display: block; + width: 100%; + margin-bottom: 0; + } + + .select-wrapper { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 16px; + } + + .select-label { + font-size: 0.875rem; + font-weight: 500; + color: var(--primary-text-color, #212121); + } + + .native-select { + width: 100%; + padding: 12px; + border: 1px solid var(--divider-color, #e0e0e0); + border-radius: 4px; + background: var(--card-background-color, #ffffff); + color: var(--primary-text-color, #212121); + font-size: 1rem; + font-family: inherit; + cursor: pointer; + transition: border-color 0.2s; + } + + .native-select:hover { + border-color: var(--primary-color, #03a9f4); + } + + .native-select:focus { + outline: none; + border-color: var(--primary-color, #03a9f4); + box-shadow: 0 0 0 2px rgba(3, 169, 244, 0.2); + } + + .switch-wrapper { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 0; + margin-bottom: 0; + } + + .switch-label { + font-size: 0.875rem; + font-weight: 500; + color: var(--primary-text-color, #212121); + flex: 1; + cursor: pointer; + } + + ha-switch { + margin-left: 16px; + flex-shrink: 0; + } + + ha-icon-button { + --mdc-icon-button-size: 32px; + } + + .cache-range-info { + margin-top: 8px; + padding: 8px 12px; + background: var(--info-color, rgba(33, 150, 243, 0.1)); + border-left: 3px solid var(--info-color, #2196f3); + border-radius: 4px; + } + + .cache-range-info .info-text { + font-size: 0.875em; + color: var(--primary-text-color, #212121); + } + + .range-warning, + .cache-range-warning { + margin-top: 8px; + padding: 8px 12px; + background: var(--warning-color, rgba(255, 152, 0, 0.1)); + border-left: 3px solid var(--warning-color, #ff9800); + border-radius: 4px; + } + + .range-warning .warning-text, + .cache-range-warning .warning-text { + font-size: 0.875em; + color: var(--primary-text-color, #212121); + } + `; +__decorate([ + n({ attribute: false }) +], BomLocalRadarCardEditor.prototype, "hass", void 0); +__decorate([ + r$1() +], BomLocalRadarCardEditor.prototype, "_config", void 0); +__decorate([ + r$1() +], BomLocalRadarCardEditor.prototype, "_metadataExpanded", void 0); +__decorate([ + r$1() +], BomLocalRadarCardEditor.prototype, "_controlsExpanded", void 0); +__decorate([ + r$1() +], BomLocalRadarCardEditor.prototype, "_cacheRange", void 0); +__decorate([ + r$1() +], BomLocalRadarCardEditor.prototype, "_cacheRangeError", void 0); +BomLocalRadarCardEditor = __decorate([ + t$1('bom-local-card-editor') +], BomLocalRadarCardEditor); + +const CARD_VERSION = '0.2.0'; +const DEFAULT_FRAME_INTERVAL = 2.0; // seconds +const DEFAULT_RESTART_DELAY = 2000; // ms (pause before looping) +const DEFAULT_REFRESH_INTERVAL = 30; // seconds + +/** + * Parses API error response and creates ErrorState + * Consolidates duplicated error handling logic from fetchRadarData and fetchHistoricalRadar + */ +function parseApiError(response, errorData, parsedJson, options) { + // Extract error information (support both camelCase and PascalCase) + const errorCode = errorData.errorCode || errorData.ErrorCode; + const message = errorData.message || errorData.Message || response.statusText || 'An error occurred'; + const errorType = errorData.errorType || errorData.ErrorType; + const details = errorData.details || errorData.Details || {}; + const suggestions = errorData.suggestions || errorData.Suggestions || {}; + // Build enhanced error message + let enhancedMessage = message; + // Enhance message for cache not found scenarios + if (errorCode === 'CACHE_NOT_FOUND' || (response.status === 404 && !errorCode)) { + if (!enhancedMessage.includes('cache') && !enhancedMessage.includes('Cache')) { + enhancedMessage = 'No cached data found for this location. Cache update has been triggered in background. Please retry in a few moments.'; + } + } + // Handle previous update failure + if (details.previousUpdateFailed) { + enhancedMessage = `${enhancedMessage}\n\nPrevious update failed: ${details.previousError || 'Unknown error'}`; + if (details.previousErrorCode) { + enhancedMessage += ` (${details.previousErrorCode})`; + } + } + // Handle time range errors with available/requested ranges + if (errorCode === 'TIME_RANGE_ERROR' && details.availableRange) { + const available = details.availableRange; + const requested = details.requestedRange; + enhancedMessage += `\n\nAvailable data: ${available.oldest || 'N/A'} to ${available.newest || 'N/A'}`; + if (available.totalCacheFolders) { + enhancedMessage += ` (${available.totalCacheFolders} cache folders)`; + } + if (requested) { + enhancedMessage += `\nRequested: ${requested.start || 'N/A'} to ${requested.end || 'N/A'}`; + } + if (details.requestedHours) { + enhancedMessage += `\nRequested range: ${details.requestedHours} hours (max: ${details.maxHours || 'N/A'} hours)`; + } + // Add service suggestion if available + if (suggestions.suggestion) { + enhancedMessage += `\n\n💡 ${suggestions.suggestion}`; + } + } + // Determine retry behavior + const retryAfter = suggestions.retryAfter || + details.retryAfter || + options.defaultRetryAfter || 30; + const action = suggestions.action || 'retry_after_seconds'; + const shouldAutoRetry = action !== 'manual_refresh_recommended' && + action !== 'check_network_and_retry' && + action !== 'adjust_time_range'; + // Map error type + const mappedType = mapErrorType(errorType || errorCode || (response.status === 404 ? 'CACHE_NOT_FOUND' : 'unknown')); + // Determine retryable status - for historical endpoint, also allow 400 + const isHistoricalEndpoint = response.url.includes('/timeseries'); + const retryable = response.status === 404 || (isHistoricalEndpoint && response.status === 400); + return { + message: enhancedMessage, + type: mappedType, + retryable: retryable, + retryAction: options.retryAction, + retryAfter: shouldAutoRetry ? retryAfter : undefined, + errorCode: errorCode || (response.status === 404 ? 'CACHE_NOT_FOUND' : `HTTP_${response.status}`), + details: Object.assign(Object.assign({}, details), { action: action, refreshEndpoint: suggestions.refreshEndpoint, statusEndpoint: suggestions.statusEndpoint, suggestedRange: suggestions.suggestedRange }), + }; +} +/** + * Maps API error type to card error type + */ +function mapErrorType(apiErrorType) { + if (!apiErrorType) + return 'unknown'; + const type = apiErrorType.toLowerCase(); + if (type.includes('cache') || type === 'cache_not_found' || type === 'cacheerror') + return 'cache'; + if (type.includes('validation') || type === 'validation_error' || type === 'validationerror') + return 'validation'; + if (type.includes('network') || type.includes('fetch')) + return 'network'; + if (type.includes('notfound') || type === 'not_found' || type === 'notfounderror') + return 'cache'; + return 'unknown'; +} + +/** + * Resolves a URL through the Home Assistant integration proxy if it's a relative path. + * The integration provides a proxy at /api/bom_local/proxy/ + */ +function resolveImageUrl(url, serviceUrl) { + if (!url) + return url; + // If URL is already absolute (starts with http:// or https://), use as-is + if (url.startsWith('http://') || url.startsWith('https://')) { + return url; + } + // If we are using the HA integration proxy (standard behavior now) + const proxyPrefix = '/api/bom_local/proxy'; + // Remove leading slash if present to avoid double slashes + const path = url.startsWith('/') ? url : `/${url}`; + return `${proxyPrefix}${path}`; +} + +class RadarApiService { + /** + * Fetches latest radar frames via HA WebSocket API + */ + async fetchLatestFrames(options) { + const { hass, suburb, state, onError } = options; + try { + const data = await hass.callWS({ + type: 'bom_local/get_radar_data', + suburb, + state, + }); + // Handle service-level errors (e.g. CACHE_NOT_FOUND) + if (data.errorCode || data.ErrorCode || data.error) { + const error = parseApiError({ ok: false, status: 404, statusText: 'Not Found', url: '/api/radar' }, data, true, { + retryAction: () => this.fetchLatestFrames(options), + defaultRetryAfter: 30, + }); + onError(error); + return null; + } + // Validate response has frames + if (!data.frames || data.frames.length === 0) { + throw new Error('No frames available in response'); + } + // Resolve relative image URLs through HA proxy + data.frames.forEach((frame) => { + if (frame.imageUrl) { + frame.imageUrl = resolveImageUrl(frame.imageUrl); + } + }); + return data; + } + catch (err) { + this.handleFetchError(err, options); + return null; + } + } + /** + * Fetches historical radar data via HA WebSocket API + */ + async fetchHistoricalFrames(options) { + var _a; + const { hass, suburb, state, timespan, customStartTime, customEndTime, onError } = options; + try { + let startTime = null; + let endTime = new Date().toISOString(); + if (timespan === 'custom') { + if (customStartTime) + startTime = new Date(customStartTime).toISOString(); + if (customEndTime) + endTime = new Date(customEndTime).toISOString(); + } + else if (timespan) { + const hours = parseInt(timespan.replace('h', '')) || 1; + startTime = new Date(Date.now() - (hours * 60 * 60 * 1000)).toISOString(); + } + if (!startTime) { + throw new Error('Invalid timespan configuration'); + } + const data = await hass.callWS({ + type: 'bom_local/get_radar_data', + suburb, + state, + startTime, + endTime, + }); + // Handle service-level errors (e.g. TIME_RANGE_ERROR or CACHE_NOT_FOUND) + if (data.errorCode || data.ErrorCode || data.error) { + const error = parseApiError({ ok: false, status: 404, statusText: 'Not Found', url: '/timeseries' }, data, true, { + retryAction: () => this.fetchHistoricalFrames(options), + defaultRetryAfter: 30, + }); + onError(error); + return null; + } + if (!data.cacheFolders || data.cacheFolders.length === 0) { + throw new Error('No historical data found for the specified time range.'); + } + // Flatten all frames from all cache folders + const allFrames = []; + data.cacheFolders.forEach(cacheFolder => { + cacheFolder.frames.forEach(frame => { + frame.cacheTimestamp = cacheFolder.cacheTimestamp; + frame.observationTime = cacheFolder.observationTime; + frame.cacheFolderName = cacheFolder.cacheFolderName; + if (frame.imageUrl) { + frame.imageUrl = resolveImageUrl(frame.imageUrl); + } + if (!frame.absoluteObservationTime && frame.observationTime && frame.minutesAgo !== undefined) { + const obsTime = new Date(frame.observationTime); + frame.absoluteObservationTime = new Date(obsTime.getTime() - (frame.minutesAgo * 60 * 1000)).toISOString(); + } + allFrames.push(frame); + }); + }); + // Re-index frames sequentially + allFrames.sort((a, b) => new Date(a.absoluteObservationTime).getTime() - new Date(b.absoluteObservationTime).getTime()); + allFrames.forEach((frame, idx) => { + frame.sequentialIndex = idx; + }); + // Fetch latest metadata for display + let metadata = {}; + try { + metadata = await hass.callWS({ + type: 'bom_local/get_radar_data', + suburb, + state + }); + } + catch (err) { + console.debug('Could not fetch metadata:', err); + } + const newestCacheFolder = data.cacheFolders[data.cacheFolders.length - 1]; + const radarResponse = { + frames: allFrames, + lastUpdated: endTime, + observationTime: metadata.observationTime || (newestCacheFolder === null || newestCacheFolder === void 0 ? void 0 : newestCacheFolder.observationTime) || endTime, + forecastTime: endTime, + weatherStation: metadata.weatherStation, + distance: metadata.distance, + cacheIsValid: (_a = metadata.cacheIsValid) !== null && _a !== void 0 ? _a : true, + cacheExpiresAt: metadata.cacheExpiresAt || endTime, + isUpdating: metadata.isUpdating || false, + nextUpdateTime: metadata.nextUpdateTime || endTime, + }; + return radarResponse; + } + catch (err) { + this.handleFetchError(err, options); + return null; + } + } + /** + * Handles fetch errors and categorizes them appropriately + */ + handleFetchError(err, options) { + const message = (err === null || err === void 0 ? void 0 : err.message) || (typeof err === 'string' ? err : 'Unknown error occurred'); + options.onError({ + message: message, + type: 'unknown', + retryable: true, + retryAction: () => this.fetchLatestFrames(options), + }); + } +} + +/** + * Base card styles including CSS variables and root layout + */ +const cardStyles = i$3 ` + :host { + --bom-primary-color: var(--primary-color, #667eea); + --bom-secondary-color: var(--secondary-text-color, #666); + --bom-card-background: var(--card-background-color, white); + --bom-text-color: var(--primary-text-color, #333); + --bom-border-color: var(--divider-color, #e0e0e0); + } + + #root { + width: 100%; + position: relative; + } +`; + +/** + * Styles for radar image container and loading states + */ +const imageStyles = i$3 ` + /* Radar Image Container */ + .radar-image-container { + position: relative; + width: 100%; + background: #000; + border-radius: 8px; + overflow: hidden; + margin-bottom: 15px; + aspect-ratio: 16/9; + min-height: 200px; /* Smaller on mobile */ + display: flex; + align-items: center; + justify-content: center; + } + + @media (min-width: 480px) { + .radar-image-container { + min-height: 300px; + } + } + + @media (min-width: 768px) { + .radar-image-container { + min-height: 400px; + } + } + + .radar-image { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: contain; + display: block; + image-rendering: crisp-edges; + transition: transform 0.3s ease; + } + + .radar-image-contain { + width: 100%; + height: 100%; + object-fit: contain; + } + + .radar-image-cover { + width: 100%; + height: 100%; + object-fit: cover; + } + + .radar-image-fill { + width: 100%; + height: 100%; + object-fit: fill; + } + + .loading { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: white; + font-size: 1.2em; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + } + + .loading-message { + font-size: 0.9em; + opacity: 0.9; + } +`; + +/** + * Styles for frame slider, navigation buttons, and play controls + */ +const controlsStyles = i$3 ` + .frame-slider-container { + width: 100%; + padding: 15px; + background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%); + border-radius: 12px; + border: 2px solid #e0e0e0; + box-shadow: 0 4px 16px rgba(0,0,0,0.08); + margin-bottom: 15px; + box-sizing: border-box; + } + + .frame-slider-wrapper { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; + flex-wrap: wrap; + } + + .frame-nav-btn { + padding: 10px 16px; + border: 2px solid var(--primary-color, #667eea); + background: white; + color: var(--primary-color, #667eea); + border-radius: 10px; + cursor: pointer; + font-weight: 700; + font-size: 0.9em; + transition: all 0.2s; + min-width: 50px; + display: flex; + align-items: center; + justify-content: center; + } + + .frame-nav-btn:hover:not(:disabled) { + background: var(--primary-color, #667eea); + color: white; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); + } + + .frame-nav-btn:disabled { + opacity: 0.4; + cursor: not-allowed; + background: #f0f0f0; + border-color: #d0d0d0; + color: #999; + } + + .frame-slider { + flex: 1; + min-width: 120px; + height: 10px; + border-radius: 5px; + background: #e0e0e0; + outline: none; + -webkit-appearance: none; + cursor: pointer; + order: 3; + } + + .frame-slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 24px; + height: 24px; + border-radius: 50%; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + cursor: pointer; + box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4); + border: 3px solid white; + transition: all 0.2s; + margin-top: -7px; /* Center the 24px thumb on the 10px track: (24-10)/2 = 7 */ + } + + .frame-slider::-webkit-slider-thumb:hover { + transform: scale(1.15); + box-shadow: 0 4px 16px rgba(102, 126, 234, 0.6); + } + + .frame-slider::-webkit-slider-runnable-track { + height: 10px; + border-radius: 5px; + background: linear-gradient(to right, #667eea 0%, #764ba2 100%); + } + + .frame-slider::-moz-range-thumb { + width: 24px; + height: 24px; + border-radius: 50%; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + cursor: pointer; + border: 3px solid white; + box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4); + } + + .frame-slider::-moz-range-track { + height: 10px; + border-radius: 5px; + background: #e0e0e0; + } + + .frame-slider::-moz-range-progress { + height: 10px; + border-radius: 5px; + background: linear-gradient(to right, #667eea 0%, #764ba2 100%); + } + + .play-controls { + display: flex; + gap: 8px; + justify-content: center; + align-items: center; + margin-bottom: 12px; + flex-wrap: wrap; + } + + .play-btn { + padding: 10px 16px; + border: 2px solid var(--primary-color, #667eea); + background: var(--primary-color, #667eea); + color: white; + border-radius: 10px; + cursor: pointer; + font-weight: 700; + font-size: 0.9em; + transition: all 0.2s; + min-height: 44px; + display: flex; + align-items: center; + justify-content: center; + } + + .play-btn:hover:not(:disabled) { + background: var(--primary-color-dark, #5568d3); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); + } + + .play-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .frame-info { + text-align: center; + color: var(--secondary-text-color, rgba(0, 0, 0, 0.6)); + font-size: 0.85em; + margin-top: 6px; + margin-bottom: 4px; + padding: 6px 12px; + background: var(--card-background-color, #fff); + border-radius: 4px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 12px; + flex-wrap: wrap; + line-height: 1.4; + } + + .frame-index { + font-weight: 600; + color: var(--primary-text-color, rgba(0, 0, 0, 0.87)); + white-space: nowrap; + } + + .frame-time { + white-space: nowrap; + color: var(--secondary-text-color, rgba(0, 0, 0, 0.6)); + } + + .observation-time { + white-space: nowrap; + color: var(--secondary-text-color, rgba(0, 0, 0, 0.6)); + } + + .frame-progress { + white-space: nowrap; + color: var(--secondary-text-color, rgba(0, 0, 0, 0.6)); + font-size: 0.9em; + } + + .frame-nav-buttons { + display: flex; + gap: 8px; + justify-content: center; + margin-bottom: 12px; + flex-wrap: wrap; + } + + .controls-section { + margin-top: 15px; + } + + /* Responsive adjustments */ + @media (min-width: 480px) { + .frame-slider-wrapper { + flex-wrap: nowrap; + } + .frame-slider { + order: 0; + } + } +`; + +/** + * Styles for metadata display sections + */ +const metadataStyles = i$3 ` + /* Metadata Section */ + .metadata-section { + margin-bottom: 15px; + } + + .metadata-section.metadata-cards { + display: grid; + grid-template-columns: 1fr; /* Single column on mobile */ + gap: 12px; + } + + @media (min-width: 480px) { + .metadata-section.metadata-cards { + grid-template-columns: repeat(2, 1fr); + } + } + + @media (min-width: 768px) { + .metadata-section.metadata-cards { + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 15px; + } + } + + .metadata-section.metadata-cards .metadata-item { + background: var(--card-background-color, #ffffff); + border: 1px solid var(--divider-color, rgba(0, 0, 0, 0.12)); + border-radius: 8px; + padding: 12px 16px; + display: flex; + flex-direction: column; + gap: 4px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + transition: box-shadow 0.2s ease, transform 0.2s ease; + } + + .metadata-section.metadata-cards .metadata-item:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); + transform: translateY(-1px); + } + + .metadata-section.metadata-cards .metadata-label { + font-size: 0.75em; + text-transform: uppercase; + letter-spacing: 0.5px; + opacity: 0.7; + margin-bottom: 2px; + } + + .metadata-section.metadata-cards .metadata-value { + font-size: 1.1em; + font-weight: 600; + } + + .metadata-section.metadata-compact { + display: flex; + flex-wrap: wrap; + gap: 12px; + font-size: 0.9em; + } + + .metadata-section.metadata-compact .metadata-item { + background: var(--secondary-background-color, #f5f5f5); + border-radius: 6px; + padding: 8px 12px; + display: inline-flex; + align-items: center; + gap: 8px; + } + + .metadata-section.metadata-minimal { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 0.85em; + } + + .metadata-section.metadata-minimal .metadata-item { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; + border-bottom: 1px solid var(--divider-color, rgba(0, 0, 0, 0.08)); + } + + .metadata-section.metadata-minimal .metadata-item:last-child { + border-bottom: none; + } + + .metadata-item { + display: flex; + align-items: center; + gap: 6px; + } + + .metadata-label { + color: var(--bom-secondary-color); + font-weight: 600; + font-size: 0.85em; + } + + .metadata-value { + color: var(--bom-text-color); + font-weight: 600; + } + + .metadata-relative { + color: var(--bom-secondary-color); + font-size: 0.85em; + } + + .info-section { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 15px; + margin-bottom: 15px; + } + + .info-card { + background: var(--bom-card-background); + border-radius: 8px; + padding: 15px; + border-left: 4px solid var(--bom-primary-color); + } + + .info-card h3 { + font-size: 0.8em; + text-transform: uppercase; + color: var(--bom-secondary-color); + margin-bottom: 6px; + letter-spacing: 0.5px; + } + + .info-card .value { + font-size: 1.2em; + font-weight: 600; + color: var(--bom-text-color); + } +`; + +/** + * Styles for overlay controls and metadata overlays + */ +const overlayStyles = i$3 ` + /* Overlay Controls */ + .controls-overlay { + position: absolute; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(8px); + border-radius: 8px; + padding: 12px; + z-index: 10; + transition: opacity 0.3s ease; + display: flex; + flex-direction: column; + gap: 8px; + min-width: 200px; + } + + .controls-overlay.overlay-top { + top: 12px; + left: 50%; + transform: translateX(-50%); + } + + .controls-overlay.overlay-bottom { + bottom: 12px; + left: 50%; + transform: translateX(-50%); + } + + .controls-overlay.overlay-left { + left: 12px; + top: 50%; + transform: translateY(-50%); + } + + .controls-overlay.overlay-right { + right: 12px; + top: 50%; + transform: translateY(-50%); + } + + .controls-overlay.overlay-center { + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + + /* Metadata Overlay */ + .metadata-overlay { + position: absolute; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(6px); + border-radius: 6px; + padding: 8px 12px; + z-index: 5; + font-size: 0.85em; + color: white; + display: flex; + flex-direction: column; + gap: 4px; + } + + .metadata-overlay .metadata-item { + color: white; + } + + .metadata-overlay .metadata-label { + color: rgba(255, 255, 255, 0.8); + } + + .metadata-overlay.overlay-top { + top: 12px; + left: 12px; + right: auto; + } + + .metadata-overlay.overlay-bottom { + bottom: 12px; + left: 12px; + right: auto; + } + + .metadata-overlay.overlay-left { + left: 12px; + top: 50%; + transform: translateY(-50%); + } + + .metadata-overlay.overlay-right { + right: 12px; + top: 50%; + transform: translateY(-50%); + } + + .metadata-overlay.overlay-center { + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } +`; + +/** + * Styles for error display components + */ +const errorStyles = i$3 ` + /* Error Display */ + .error-container { + padding: 16px; + } + + .error-card { + background: var(--card-background-color, #ffffff); + border-radius: 12px; + border-left: 4px solid var(--error-color); + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); + overflow: hidden; + border: 1px solid var(--divider-color, rgba(0, 0, 0, 0.1)); + transition: opacity 0.3s ease, transform 0.3s ease; + } + + .error-header { + display: flex; + align-items: center; + gap: 12px; + padding: 18px 20px; + background: var(--secondary-background-color, #fafafa); + border-bottom: 1px solid var(--divider-color, rgba(0, 0, 0, 0.1)); + } + + .error-icon { + color: var(--error-color); + width: 24px; + height: 24px; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + } + + .error-title { + font-size: 1.1em; + font-weight: 600; + color: var(--primary-text-color, #212121); + flex: 1; + } + + .error-content { + padding: 20px; + display: flex; + flex-direction: column; + gap: 16px; + } + + .error-message { + font-weight: 500; + line-height: 1.7; + display: flex; + flex-direction: column; + gap: 6px; + color: var(--primary-text-color, #212121); + font-size: 1.05em; + margin-bottom: 4px; + } + + .error-line { + min-height: 1.2em; + } + + .error-line-spacer { + height: 0.5em; + } + + .error-code { + font-size: 0.85em; + font-family: monospace; + color: var(--secondary-text-color, #666); + background: var(--secondary-background-color, #f5f5f5); + padding: 6px 12px; + border-radius: 6px; + display: inline-flex; + align-items: center; + gap: 6px; + width: fit-content; + border: 1px solid var(--divider-color, rgba(0, 0, 0, 0.1)); + } + + .code-icon { + width: 16px; + height: 16px; + opacity: 0.7; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + + .error-retry-info { + font-size: 0.9em; + color: var(--primary-text-color, #212121); + padding: 10px 14px; + background: linear-gradient(135deg, + rgba(33, 150, 243, 0.08) 0%, + rgba(33, 150, 243, 0.04) 100%); + border-radius: 8px; + border-left: 3px solid var(--primary-color, #2196f3); + display: flex; + align-items: center; + gap: 8px; + margin-top: 4px; + } + + .error-retry-info::before { + content: '⏱'; + font-size: 1.1em; + } + + .error-details { + margin-top: 8px; + padding-top: 16px; + border-top: 1px solid var(--divider-color, rgba(0, 0, 0, 0.12)); + display: flex; + flex-direction: column; + gap: 10px; + } + + .error-detail-item { + font-size: 0.9em; + line-height: 1.6; + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 12px; + background: var(--secondary-background-color, #fafafa); + border-radius: 8px; + transition: background 0.2s ease; + } + + .error-detail-item:hover { + background: var(--secondary-background-color, #f0f0f0); + } + + .detail-icon { + color: var(--error-color); + width: 18px; + height: 18px; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + margin-top: 2px; + } + + .detail-content { + flex: 1; + color: var(--primary-text-color, #212121); + } + + .detail-content strong { + font-weight: 600; + color: var(--primary-text-color, #212121); + } + + .detail-content code { + font-family: monospace; + font-size: 0.9em; + background: var(--secondary-background-color, rgba(0, 0, 0, 0.05)); + padding: 4px 8px; + border-radius: 4px; + border: 1px solid var(--divider-color, rgba(0, 0, 0, 0.15)); + color: var(--primary-text-color, #212121); + display: inline-block; + word-break: break-all; + } + + .error-detail-item.suggested-range { + background: linear-gradient(135deg, + rgba(255, 193, 7, 0.1) 0%, + rgba(255, 193, 7, 0.05) 100%); + border-left: 3px solid var(--warning-color, #ffc107); + } + + .suggestion-hint { + font-size: 0.85em; + color: var(--secondary-text-color, #666); + margin-top: 4px; + font-style: italic; + } + + .error-actions { + margin-top: 16px; + display: flex; + gap: 12px; + justify-content: flex-start; + } + + .retry-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px 24px; + min-height: 44px; + background: var(--error-color, #f44336); + color: white !important; + border: 2px solid var(--error-color, #f44336); + border-radius: 8px; + font-size: 0.95em; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35), 0 2px 6px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.2); + font-family: inherit; + -webkit-appearance: none; + appearance: none; + user-select: none; + position: relative; + z-index: 1; + filter: brightness(1.05); + } + + .retry-button::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: 6px; + background: linear-gradient(to bottom, rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0)); + pointer-events: none; + z-index: -1; + } + + .retry-button:hover { + opacity: 0.9; + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); + filter: brightness(0.95); + } + + .retry-button:active { + transform: translateY(0); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + } + + .retry-button:focus { + outline: 2px solid var(--error-color); + outline-offset: 2px; + } + + .retry-button:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + } + + .retry-icon { + width: 18px; + height: 18px; + color: white; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + vertical-align: middle; + } + + .retry-button span { + color: white; + } +`; + +/** + * Formats timestamp for display + */ +function formatTimestamp(isoString, locale = 'en-AU', timeZone) { + if (!isoString) + return '-'; + const date = new Date(isoString); + const tz = timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone; + return date.toLocaleString(locale, { + timeZone: tz, + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} +/** + * Relative time (e.g., "5 min ago", "2 hours ago") + */ +function formatRelativeTime(isoString) { + if (!isoString) + return '-'; + const date = new Date(isoString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + if (diffMins < 1) + return 'Just now'; + if (diffMins < 60) + return `${diffMins} min ago`; + if (diffHours < 24) + return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`; + return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`; +} + +let BomErrorDisplay = class BomErrorDisplay extends i { + constructor() { + super(...arguments); + this.locale = 'en-AU'; + } + _getErrorIcon() { + if (!this.error) + return 'mdi:alert'; + switch (this.error.type) { + case 'cache': + return 'mdi:database-alert'; + case 'validation': + return 'mdi:alert-circle'; + case 'network': + return 'mdi:wifi-off'; + default: + return 'mdi:alert'; + } + } + _getErrorColor() { + if (!this.error) + return '#f44336'; + switch (this.error.type) { + case 'cache': + return '#ff9800'; + case 'validation': + return '#f44336'; + case 'network': + return '#2196f3'; + default: + return '#f44336'; + } + } + _getErrorTitle() { + if (!this.error) + return 'Error'; + switch (this.error.type) { + case 'cache': + return 'Cache Error'; + case 'validation': + return 'Validation Error'; + case 'network': + return 'Network Error'; + default: + return 'Error'; + } + } + render() { + var _a, _b, _c, _d, _e, _f; + if (!this.error) { + return x ``; + } + const action = (_a = this.error.details) === null || _a === void 0 ? void 0 : _a.action; + const isManualRefreshRecommended = action === 'manual_refresh_recommended'; + const refreshEndpoint = (_b = this.error.details) === null || _b === void 0 ? void 0 : _b.refreshEndpoint; + const retryInfo = this.error.retryAfter + ? x `
Auto-retrying in ${this.error.retryAfter} seconds...
` + : isManualRefreshRecommended + ? x `
Manual refresh recommended. Previous update failed.
` + : ''; + // Format error message with line breaks (preserve empty lines) + const errorLines = this.error.message.split('\n'); + const errorMessage = errorLines.map((line, index) => line.trim() + ? x `
${line}
` + : x `
`); + // Show additional details if available + const showDetails = this.error.details && (this.error.details.previousUpdateFailed || + this.error.details.availableRange || + this.error.details.requestedRange || + this.error.details.suggestedRange || + refreshEndpoint); + // Determine icon and color based on error type + const errorIcon = this._getErrorIcon(); + const errorColor = this._getErrorColor(); + return x ` +
+
+
+ +
${this._getErrorTitle()}
+
+
+
${errorMessage}
+ ${this.error.errorCode ? x ` +
+ + Error Code: ${this.error.errorCode} +
+ ` : ''} + ${showDetails ? x ` +
+ ${((_c = this.error.details) === null || _c === void 0 ? void 0 : _c.previousUpdateFailed) ? x ` +
+ +
+ Previous Update Failed: ${this.error.details.previousError || 'Unknown error'} + ${this.error.details.previousErrorCode ? x ` (${this.error.details.previousErrorCode})` : ''} +
+
+ ` : ''} + ${((_d = this.error.details) === null || _d === void 0 ? void 0 : _d.availableRange) ? x ` +
+ +
+ Available Data Range: ${this.error.details.availableRange.oldest ? formatTimestamp(this.error.details.availableRange.oldest, this.locale, this.timeZone) : 'N/A'} to ${this.error.details.availableRange.newest ? formatTimestamp(this.error.details.availableRange.newest, this.locale, this.timeZone) : 'N/A'} + ${this.error.details.availableRange.totalCacheFolders ? x ` (${this.error.details.availableRange.totalCacheFolders} folders)` : ''} +
+
+ ` : ''} + ${((_e = this.error.details) === null || _e === void 0 ? void 0 : _e.requestedRange) ? x ` +
+ +
+ Requested Range: ${this.error.details.requestedRange.start ? formatTimestamp(this.error.details.requestedRange.start, this.locale, this.timeZone) : 'N/A'} to ${this.error.details.requestedRange.end ? formatTimestamp(this.error.details.requestedRange.end, this.locale, this.timeZone) : 'N/A'} +
+
+ ` : ''} + ${((_f = this.error.details) === null || _f === void 0 ? void 0 : _f.suggestedRange) ? x ` +
+ +
+ Suggested Range: ${this.error.details.suggestedRange.start ? formatTimestamp(this.error.details.suggestedRange.start, this.locale, this.timeZone) : 'N/A'} to ${this.error.details.suggestedRange.end ? formatTimestamp(this.error.details.suggestedRange.end, this.locale, this.timeZone) : 'N/A'} +
Try using this time range instead
+
+
+ ` : ''} + ${refreshEndpoint ? x ` +
+ +
+ Refresh Endpoint: ${refreshEndpoint} +
+
+ ` : ''} +
+ ` : ''} + ${retryInfo} + ${this.error.retryable && this.onRetry ? x ` +
+ +
+ ` : ''} +
+
+
+ `; + } +}; +BomErrorDisplay.styles = [errorStyles]; +__decorate([ + n({ attribute: false }) +], BomErrorDisplay.prototype, "error", void 0); +__decorate([ + n({ attribute: false }) +], BomErrorDisplay.prototype, "locale", void 0); +__decorate([ + n({ attribute: false }) +], BomErrorDisplay.prototype, "timeZone", void 0); +__decorate([ + n({ attribute: false }) +], BomErrorDisplay.prototype, "onRetry", void 0); +BomErrorDisplay = __decorate([ + t$1('bom-error-display') +], BomErrorDisplay); + +let BomMetadataSection = class BomMetadataSection extends i { + constructor() { + super(...arguments); + this.position = 'above'; + this.locale = 'en-AU'; + } + _getDisplayConfig() { + if (!this.config || (typeof this.config === 'boolean' && !this.config)) { + return null; + } + return typeof this.config === 'object' ? this.config : {}; + } + render() { + var _a, _b; + const displayConfig = this._getDisplayConfig(); + if (!displayConfig) { + return x ``; + } + const configPosition = (_a = displayConfig.position) !== null && _a !== void 0 ? _a : 'above'; + // Only render if position matches + if (configPosition !== this.position) { + return x ``; + } + // If overlay, render differently + if (this.position === 'overlay') { + return this._renderOverlay(); + } + // Default style is 'cards' if not specified + const style = (_b = displayConfig.style) !== null && _b !== void 0 ? _b : 'cards'; + return x ` + + `; + } + _renderOverlay() { + var _a, _b; + const displayConfig = this._getDisplayConfig(); + if (!displayConfig) { + return x ``; + } + const opacity = (_a = this.overlayOpacity) !== null && _a !== void 0 ? _a : 0.85; + const position = (_b = this.overlayPosition) !== null && _b !== void 0 ? _b : 'top'; + return x ` + + `; + } + _renderCacheStatus() { + if (!this.radarData) + return x ``; + return x ` + + `; + } + _renderObservationTime() { + var _a; + if (!((_a = this.radarData) === null || _a === void 0 ? void 0 : _a.observationTime)) + return x ``; + return x ` + + `; + } + _renderForecastTime() { + var _a; + if (!((_a = this.radarData) === null || _a === void 0 ? void 0 : _a.forecastTime)) + return x ``; + return x ` + + `; + } + _renderWeatherStation() { + var _a; + if (!((_a = this.radarData) === null || _a === void 0 ? void 0 : _a.weatherStation)) + return x ``; + return x ` + + `; + } + _renderDistance() { + var _a; + if (!((_a = this.radarData) === null || _a === void 0 ? void 0 : _a.distance)) + return x ``; + return x ` + + `; + } + _renderNextUpdate() { + var _a; + if (!((_a = this.radarData) === null || _a === void 0 ? void 0 : _a.nextUpdateTime)) + return x ``; + return x ` + + `; + } +}; +BomMetadataSection.styles = [metadataStyles, overlayStyles]; +__decorate([ + n({ attribute: false }) +], BomMetadataSection.prototype, "radarData", void 0); +__decorate([ + n({ attribute: false }) +], BomMetadataSection.prototype, "config", void 0); +__decorate([ + n({ attribute: false }) +], BomMetadataSection.prototype, "position", void 0); +__decorate([ + n({ attribute: false }) +], BomMetadataSection.prototype, "locale", void 0); +__decorate([ + n({ attribute: false }) +], BomMetadataSection.prototype, "timeZone", void 0); +__decorate([ + n({ attribute: false }) +], BomMetadataSection.prototype, "overlayOpacity", void 0); +__decorate([ + n({ attribute: false }) +], BomMetadataSection.prototype, "overlayPosition", void 0); +BomMetadataSection = __decorate([ + t$1('bom-metadata-section') +], BomMetadataSection); + +let BomControlsSection = class BomControlsSection extends i { + constructor() { + super(...arguments); + this.frames = []; + this.currentFrameIndex = 0; + this.isPlaying = false; + this.isExtendedMode = false; + this.locale = 'en-AU'; + } + _getDisplayConfig() { + if (!this.config || (typeof this.config === 'boolean' && !this.config)) { + return null; + } + return typeof this.config === 'object' ? this.config : {}; + } + render() { + var _a; + const displayConfig = this._getDisplayConfig(); + if (!displayConfig) { + return x ``; + } + const configPosition = (_a = displayConfig.position) !== null && _a !== void 0 ? _a : 'below'; + // Only render if position matches (or if no position filter specified) + if (this.position !== undefined && configPosition !== this.position) { + return x ``; + } + // If overlay, render differently + if (configPosition === 'overlay') { + return this._renderOverlay(displayConfig); + } + return x ` +
+ ${displayConfig.show_slider !== false ? this._renderFrameSlider() : ''} + ${displayConfig.show_nav_buttons !== false ? this._renderNavButtons() : ''} + ${displayConfig.show_play_pause !== false ? this._renderPlayPause() : ''} + ${displayConfig.show_prev_next !== false ? this._renderPrevNext() : ''} + ${displayConfig.show_frame_info !== false ? this._renderFrameInfo() : ''} +
+ `; + } + _renderOverlay(displayConfig) { + var _a, _b; + const opacity = (_a = this.overlayOpacity) !== null && _a !== void 0 ? _a : 0.9; + const position = (_b = this.overlayPosition) !== null && _b !== void 0 ? _b : 'bottom'; + return x ` +
+ ${displayConfig.show_slider !== false ? this._renderFrameSlider() : ''} + ${displayConfig.show_play_pause !== false ? this._renderPlayPause() : ''} + ${displayConfig.show_prev_next !== false ? this._renderPrevNext() : ''} + ${displayConfig.show_frame_info !== false ? this._renderFrameInfo() : ''} +
+ `; + } + _renderFrameSlider() { + if (this.frames.length === 0) + return x ``; + return x ` +
+
+ +
+
+ `; + } + _renderNavButtons() { + if (this.frames.length === 0) + return x ``; + // Calculate dynamic skip amount based on total frames (1/3 of total, min 1, max 50) + const skipAmount = this.isExtendedMode + ? Math.max(1, Math.min(50, Math.round(this.frames.length / 3))) + : 0; + return x ` +
+ ${this.isExtendedMode ? x ` + + + + + ` : x ` + + + `} +
+ `; + } + _renderPlayPause() { + return x ` +
+ +
+ `; + } + _renderPrevNext() { + return x ` +
+ + +
+ `; + } + _renderFrameInfo() { + var _a, _b; + const currentFrame = this.frames[this.currentFrameIndex]; + if (!currentFrame) + return x ``; + const showFrameTimes = this.showFrameTimes !== false; + // Format frame info + let frameInfoText = ''; + if (this.isExtendedMode && currentFrame.absoluteObservationTime) { + frameInfoText = `Frame ${((_a = currentFrame.sequentialIndex) !== null && _a !== void 0 ? _a : this.currentFrameIndex) + 1} of ${this.frames.length}`; + } + else { + frameInfoText = `Frame ${currentFrame.frameIndex + 1} of ${this.frames.length}`; + } + const progress = this.frames.length > 0 + ? Math.round(((this.currentFrameIndex + 1) / this.frames.length) * 100) + : 0; + return x ` +
+ ${frameInfoText} + ${showFrameTimes && currentFrame.absoluteObservationTime ? x ` + + ${formatTimestamp(currentFrame.absoluteObservationTime, this.locale, this.timeZone)} + + ` : showFrameTimes && currentFrame.minutesAgo !== undefined ? x ` + + ${currentFrame.minutesAgo} min ago + + ` : ''} + ${((_b = this.radarData) === null || _b === void 0 ? void 0 : _b.observationTime) && showFrameTimes ? x ` + + Obs: ${formatTimestamp(this.radarData.observationTime, this.locale, this.timeZone)} + + ` : ''} + ${progress}% +
+ `; + } +}; +BomControlsSection.styles = [controlsStyles, overlayStyles]; +__decorate([ + n({ attribute: false }) +], BomControlsSection.prototype, "frames", void 0); +__decorate([ + n({ attribute: false }) +], BomControlsSection.prototype, "currentFrameIndex", void 0); +__decorate([ + n({ attribute: false }) +], BomControlsSection.prototype, "isPlaying", void 0); +__decorate([ + n({ attribute: false }) +], BomControlsSection.prototype, "isExtendedMode", void 0); +__decorate([ + n({ attribute: false }) +], BomControlsSection.prototype, "radarData", void 0); +__decorate([ + n({ attribute: false }) +], BomControlsSection.prototype, "config", void 0); +__decorate([ + n({ attribute: false }) +], BomControlsSection.prototype, "position", void 0); +__decorate([ + n({ attribute: false }) +], BomControlsSection.prototype, "locale", void 0); +__decorate([ + n({ attribute: false }) +], BomControlsSection.prototype, "timeZone", void 0); +__decorate([ + n({ attribute: false }) +], BomControlsSection.prototype, "overlayOpacity", void 0); +__decorate([ + n({ attribute: false }) +], BomControlsSection.prototype, "overlayPosition", void 0); +__decorate([ + n({ attribute: false }) +], BomControlsSection.prototype, "showFrameTimes", void 0); +__decorate([ + n({ attribute: false }) +], BomControlsSection.prototype, "onFrameChange", void 0); +__decorate([ + n({ attribute: false }) +], BomControlsSection.prototype, "onPrevious", void 0); +__decorate([ + n({ attribute: false }) +], BomControlsSection.prototype, "onNext", void 0); +__decorate([ + n({ attribute: false }) +], BomControlsSection.prototype, "onJumpFrame", void 0); +__decorate([ + n({ attribute: false }) +], BomControlsSection.prototype, "onToggleAnimation", void 0); +BomControlsSection = __decorate([ + t$1('bom-controls-section') +], BomControlsSection); + +var _a; +console.info(`%c BOM-LOCAL-RADAR-CARD \n%c Version ${CARD_VERSION} `, 'color: orange; font-weight: bold; background: black', 'color: white; font-weight: bold; background: dimgray'); +window.customCards = (_a = window.customCards) !== null && _a !== void 0 ? _a : []; +window.customCards.push({ + type: 'bom-local-card', + name: 'BOM Local Card', + description: 'A rain radar card using the local BOM service', +}); +let BomLocalRadarCard = class BomLocalRadarCard extends i { + constructor() { + super(...arguments); + this.currentFrameIndex = 0; + this.isLoading = false; + this.isPlaying = false; + this.frames = []; + this.isExtendedMode = false; + this.preloadedImages = []; + this.apiService = new RadarApiService(); + /** + * Handle keyboard navigation + */ + this._handleKeyDown = (e) => { + var _a; + // Only handle if card is focused or contains focused element + if (!((_a = this.shadowRoot) === null || _a === void 0 ? void 0 : _a.activeElement) && document.activeElement !== this) { + return; + } + switch (e.key) { + case 'ArrowLeft': + e.preventDefault(); + this.previousFrame(); + break; + case 'ArrowRight': + e.preventDefault(); + this.nextFrame(); + break; + case ' ': + e.preventDefault(); + this.toggleAnimation(); + break; + case 'Home': + e.preventDefault(); + this.showFrame(0); + break; + case 'End': + e.preventDefault(); + this.showFrame(this.frames.length - 1); + break; + } + }; + } + static async getConfigElement() { + return document.createElement('bom-local-card-editor'); + } + static getStubConfig() { + return { + type: 'custom:bom-local-card', + suburb: 'Pomona', + state: 'QLD', + }; + } + setConfig(config) { + if (!config.suburb || !config.state) { + throw new Error('suburb and state are required'); + } + this._config = config; + } + /** + * Dynamic card size based on configuration + * Returns height in units (1 unit = 50px) + */ + getCardSize() { + let size = 4; // Base size for image (200px) + // Add space for metadata if shown above/below + const metadataConfig = this._getMetadataConfig(); + if (metadataConfig && typeof metadataConfig !== 'boolean') { + if (metadataConfig.position !== 'overlay') { + size += 1; // +50px for metadata section + } + } + else if (metadataConfig === true) { + size += 1; + } + // Add space for controls if shown below (not overlay) + const controlsConfig = this._getControlsConfig(); + if (controlsConfig && typeof controlsConfig !== 'boolean') { + if (controlsConfig.position !== 'overlay') { + size += 2; // +100px for controls + } + } + else if (controlsConfig === true) { + // Default position is 'below', so add space + size += 2; + } + return size; + } + /** + * Define grid options for HA's sections view + * This allows the card to integrate with HA's grid system + */ + getGridOptions() { + // Calculate based on whether controls are visible + const hasControls = this._shouldShowControls(); + const hasMetadata = this._shouldShowMetadata(); + // Base size: 6 columns (half width), 2 rows + // Adjust based on content + const baseRows = 2; + const additionalRows = (hasControls ? 1 : 0) + (hasMetadata ? 0.5 : 0); + return { + columns: 6, // Default: half width + rows: baseRows + additionalRows, + min_columns: 3, // Minimum: quarter width + min_rows: 2, // Minimum: always show image + max_columns: 12, // Can span full width + max_rows: 8, // Can be tall for detailed view + }; + } + /** + * Fetches radar data from the local service via HA integration + * Supports both latest frames and historical timeseries + */ + async fetchRadarData() { + const suburb = encodeURIComponent(this._config.suburb); + const state = encodeURIComponent(this._config.state); + const timespan = this._config.timespan || 'latest'; + this.isLoading = true; + const options = { + hass: this.hass, + suburb, + state, + timespan: timespan !== 'latest' ? timespan : undefined, + customStartTime: this._config.custom_start_time, + customEndTime: this._config.custom_end_time, + onError: (error) => { + this.error = error; + this.isLoading = false; + // Auto-retry if applicable + if (error.retryable && error.retryAfter) { + if (this.retryTimer) { + window.clearTimeout(this.retryTimer); + } + this.retryTimer = window.setTimeout(() => { + this.retryTimer = undefined; + this.fetchRadarData(); + }, error.retryAfter * 1000); + } + }, + }; + let data = null; + if (timespan !== 'latest') { + data = await this.apiService.fetchHistoricalFrames(options); + } + else { + data = await this.apiService.fetchLatestFrames(options); + } + if (data) { + this.error = undefined; + this.radarData = data; + this.frames = data.frames.sort((a, b) => a.frameIndex - b.frameIndex); + this.isExtendedMode = timespan !== 'latest'; + this.isLoading = false; + this.preloadImages(this.frames); + if (this._config.auto_play !== false && !this.isPlaying) { + this.startAnimation(); + } + } + return data; + } + /** + * Gets the current frame image URL + */ + getCurrentFrameUrl() { + if (!this.frames || this.frames.length === 0) { + return null; + } + const frame = this.frames[this.currentFrameIndex]; + return (frame === null || frame === void 0 ? void 0 : frame.imageUrl) || null; + } + /** + * Helper to get metadata config + */ + _getMetadataConfig() { + const showMetadata = this._config.show_metadata; + if (showMetadata === undefined) { + return true; // Default: show all metadata + } + return showMetadata; + } + /** + * Helper to get controls config + */ + _getControlsConfig() { + const showControls = this._config.show_controls; + if (showControls === undefined) { + return true; // Default: show all controls + } + return showControls; + } + /** + * Check if metadata should be shown + */ + _shouldShowMetadata() { + const config = this._getMetadataConfig(); + return config !== false; + } + /** + * Check if controls should be shown + */ + _shouldShowControls() { + const config = this._getControlsConfig(); + return config !== false; + } + /** + * Get locale for formatting + */ + _getLocale() { + var _a, _b; + return this._config.locale || ((_b = (_a = this.hass) === null || _a === void 0 ? void 0 : _a.locale) === null || _b === void 0 ? void 0 : _b.language) || 'en-AU'; + } + /** + * Check if frame should be preloaded (only nearby frames) + */ + _shouldPreloadFrame(index) { + const currentIndex = this.currentFrameIndex; + // Preload current, next, and previous frames + return Math.abs(index - currentIndex) <= 1; + } + /** + * Preloads nearby frame images to prevent jiggle when switching + * Cleans up old preloaded images to prevent memory leaks + * Only preloads frames near the current frame for performance + */ + preloadImages(frames) { + // Clean up old preloaded images + this.preloadedImages.forEach(img => { + img.src = ''; + img.onload = null; + img.onerror = null; + }); + this.preloadedImages = []; + // Only preload nearby frames + frames.forEach((frame, index) => { + if (this._shouldPreloadFrame(index)) { + const img = new Image(); + img.src = frame.imageUrl; + this.preloadedImages.push(img); + } + }); + } + /** + * Debounce helper + */ + _debounce(func, wait) { + let timeout; + return (...args) => { + window.clearTimeout(timeout); + timeout = window.setTimeout(() => func(...args), wait); + }; + } + /** + * Starts the frame animation loop using requestAnimationFrame + * Ensures only one animation timer is active at a time + */ + startAnimation() { + // Always stop existing animation first to prevent accumulation + this.stopAnimation(); + if (this.frames.length === 0) { + return; + } + this.isPlaying = true; + const frameInterval = (this._config.frame_interval || DEFAULT_FRAME_INTERVAL) * 1000; + const restartDelay = DEFAULT_RESTART_DELAY; + const maxFrame = this.frames.length - 1; + let lastFrameTime = performance.now(); + const animate = (currentTime) => { + // Check if we're still playing (might have been stopped) + if (!this.isPlaying) { + return; + } + // Check if animation was cleared (component disconnected) + if (!this.animationTimer) { + return; + } + const elapsed = currentTime - lastFrameTime; + if (this.currentFrameIndex >= maxFrame) { + // Check if we've waited long enough before restarting + if (elapsed >= restartDelay) { + this.currentFrameIndex = 0; + this.requestUpdate(); + lastFrameTime = currentTime; + } + } + else { + // Check if it's time to advance to next frame + if (elapsed >= frameInterval) { + this.currentFrameIndex++; + this.requestUpdate(); + lastFrameTime = currentTime; + } + } + // Continue animation + this.animationTimer = requestAnimationFrame(animate); + }; + // Start animation + this.animationTimer = requestAnimationFrame(animate); + } + /** + * Stops the animation + */ + stopAnimation() { + if (this.animationTimer) { + cancelAnimationFrame(this.animationTimer); + this.animationTimer = undefined; + } + this.isPlaying = false; + } + /** + * Toggles play/pause + */ + toggleAnimation() { + if (this.isPlaying) { + this.stopAnimation(); + } + else { + this.startAnimation(); + } + } + /** + * Shows a specific frame + */ + showFrame(index) { + if (index < 0 || index >= this.frames.length) { + return; + } + this.currentFrameIndex = index; + this.requestUpdate(); + } + /** + * Navigate to previous frame + */ + previousFrame() { + this.stopAnimation(); + const newIndex = this.currentFrameIndex > 0 ? this.currentFrameIndex - 1 : this.frames.length - 1; + this.showFrame(newIndex); + } + /** + * Navigate to next frame + */ + nextFrame() { + this.stopAnimation(); + const newIndex = (this.currentFrameIndex + 1) % this.frames.length; + this.showFrame(newIndex); + } + /** + * Jump forward/backward by N frames + */ + jumpFrame(offset) { + const newIndex = Math.max(0, Math.min(this.frames.length - 1, this.currentFrameIndex + offset)); + this.showFrame(newIndex); + } + /** + * Auto-refresh data + */ + startAutoRefresh() { + if (this.refreshTimer) { + window.clearInterval(this.refreshTimer); + } + const refreshInterval = (this._config.refresh_interval || DEFAULT_REFRESH_INTERVAL) * 1000; + this.refreshTimer = window.setInterval(() => { + this.fetchRadarData(); + }, refreshInterval); + } + /** + * Stops auto-refresh + */ + stopAutoRefresh() { + if (this.refreshTimer) { + window.clearInterval(this.refreshTimer); + this.refreshTimer = undefined; + } + } + async firstUpdated() { + await this.fetchRadarData(); + this.startAutoRefresh(); + } + connectedCallback() { + super.connectedCallback(); + this.addEventListener('keydown', this._handleKeyDown); + } + disconnectedCallback() { + super.disconnectedCallback(); + this.removeEventListener('keydown', this._handleKeyDown); + this.stopAnimation(); + this.stopAutoRefresh(); + // Clean up retry timer + if (this.retryTimer) { + window.clearTimeout(this.retryTimer); + this.retryTimer = undefined; + } + // Clean up preloaded images + this.preloadedImages.forEach(img => { + img.src = ''; + img.onload = null; + img.onerror = null; + }); + this.preloadedImages = []; + } + updated(changedProperties) { + super.updated(changedProperties); + // Handle config changes + if (changedProperties.has('_config')) { + const oldConfig = changedProperties.get('_config'); + if (oldConfig && this._config) { + // If location or timespan changed, refetch data + if (oldConfig.suburb !== this._config.suburb || + oldConfig.state !== this._config.state || + oldConfig.timespan !== this._config.timespan || + oldConfig.custom_start_time !== this._config.custom_start_time || + oldConfig.custom_end_time !== this._config.custom_end_time) { + this.fetchRadarData(); + } + // If refresh interval changed, restart auto-refresh + if (oldConfig.refresh_interval !== this._config.refresh_interval) { + this.startAutoRefresh(); + } + // If frame interval changed and playing, restart animation + if (oldConfig.frame_interval !== this._config.frame_interval && this.isPlaying) { + this.stopAnimation(); + this.startAnimation(); + } + } + } + } + /** + * Render metadata section + */ + _renderMetadata(position) { + var _a, _b, _c; + const config = this._getMetadataConfig(); + if (!config || (typeof config === 'boolean' && !config)) { + return ''; + } + const displayConfig = typeof config === 'object' ? config : {}; + // Default position is 'above' if not specified + const configPosition = (_a = displayConfig.position) !== null && _a !== void 0 ? _a : 'above'; + // Only render if position matches + if (configPosition !== position) { + return ''; + } + return x ` + + `; + } + /** + * Render radar image with zoom and overlay support + */ + _renderRadarImage() { + const zoom = this._config.image_zoom || 1.0; + const fit = this._config.image_fit || 'contain'; + const controlsConfig = this._getControlsConfig(); + const controlsPosition = typeof controlsConfig === 'object' && controlsConfig.position === 'overlay'; + const metadataConfig = this._getMetadataConfig(); + const metadataPosition = typeof metadataConfig === 'object' && metadataConfig.position === 'overlay'; + const currentFrameUrl = this.getCurrentFrameUrl(); + return x ` +
+ ${this.isLoading + ? x ` +
+ +
Loading radar data...
+
+ ` + : currentFrameUrl + ? x ` + Radar frame ${this.currentFrameIndex} + ${controlsPosition ? this._renderControls('overlay') : ''} + ${metadataPosition ? this._renderMetadata('overlay') : ''} + ` + : ''} +
+ `; + } + /** + * Render controls section + */ + _renderControls(position) { + var _a, _b; + const config = this._getControlsConfig(); + if (!config || (typeof config === 'boolean' && !config)) { + return x ``; + } + const metadataConfig = this._getMetadataConfig(); + const showFrameTimes = metadataConfig && typeof metadataConfig === 'object' && metadataConfig.show_frame_times !== false; + return x ` + this.showFrame(index)} + .onPrevious=${() => this.previousFrame()} + .onNext=${() => this.nextFrame()} + .onJumpFrame=${(offset) => this.jumpFrame(offset)} + .onToggleAnimation=${() => this.toggleAnimation()} + > + `; + } + render() { + var _a, _b; + if (!this._config) { + return x `Configuration error`; + } + // Use HA card header if title is configured + const cardTitle = this._config.show_card_title !== false && this._config.card_title + ? this._config.card_title + : undefined; + // Render error state + if (this.error) { + return x ` + + { + var _a; + if ((_a = this.error) === null || _a === void 0 ? void 0 : _a.retryAction) { + this.error.retryAction(); + } + }} + > + + `; + } + return x ` + +
+ ${this._renderMetadata('above')} + ${this._renderControls('above')} + ${this._renderRadarImage()} + ${this._renderMetadata('below')} + ${this._renderControls('below')} +
+
+ `; + } + /** + * Get CSS classes for root element + */ + _getRootClasses() { + const classes = []; + const controlsConfig = this._getControlsConfig(); + const controlsPosition = typeof controlsConfig === 'object' && controlsConfig.position === 'overlay'; + const metadataConfig = this._getMetadataConfig(); + const metadataPosition = typeof metadataConfig === 'object' && metadataConfig.position === 'overlay'; + if (controlsPosition || metadataPosition) { + classes.push('overlay-enabled'); + } + return classes.join(' '); + } +}; +BomLocalRadarCard.styles = [ + cardStyles, + imageStyles, + controlsStyles, + metadataStyles, + overlayStyles, + errorStyles, +]; +__decorate([ + n({ attribute: false }) +], BomLocalRadarCard.prototype, "hass", void 0); +__decorate([ + n({ attribute: false }) +], BomLocalRadarCard.prototype, "_config", void 0); +__decorate([ + n({ attribute: false }) +], BomLocalRadarCard.prototype, "editMode", void 0); +__decorate([ + r$1() +], BomLocalRadarCard.prototype, "radarData", void 0); +__decorate([ + r$1() +], BomLocalRadarCard.prototype, "currentFrameIndex", void 0); +__decorate([ + r$1() +], BomLocalRadarCard.prototype, "isLoading", void 0); +__decorate([ + r$1() +], BomLocalRadarCard.prototype, "error", void 0); +__decorate([ + r$1() +], BomLocalRadarCard.prototype, "animationTimer", void 0); +__decorate([ + r$1() +], BomLocalRadarCard.prototype, "refreshTimer", void 0); +__decorate([ + r$1() +], BomLocalRadarCard.prototype, "retryTimer", void 0); +__decorate([ + r$1() +], BomLocalRadarCard.prototype, "isPlaying", void 0); +__decorate([ + r$1() +], BomLocalRadarCard.prototype, "frames", void 0); +__decorate([ + r$1() +], BomLocalRadarCard.prototype, "isExtendedMode", void 0); +BomLocalRadarCard = __decorate([ + t$1('bom-local-card') +], BomLocalRadarCard); +if (!customElements.get('bom-local-card')) { + customElements.define('bom-local-card', BomLocalRadarCard); +} + +export { BomLocalRadarCard }; +//# sourceMappingURL=bom-local-card.js.map diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 4ee9644..c4ffc07 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -40,6 +40,8 @@ services: volumes: # Mount HA config directory - ./test-ha/config:/config + # Mount the integration + - ./custom_components/bom_local:/config/custom_components/bom_local environment: - TZ=Australia/Brisbane restart: unless-stopped diff --git a/hacs.json b/hacs.json index ecd523b..71bdb5a 100644 --- a/hacs.json +++ b/hacs.json @@ -1,14 +1,6 @@ { - "name": "BOM Local Radar Card", - "render_readme": true, - "filename": "bom-local-card.js", - "homeassistant": "2024.1.0" + "name": "BOM Local Radar", + "content_type": "integration", + "domains": ["bom_local"], + "render_readme": true } - - - - - - - - diff --git a/package.json b/package.json index 896afe7..574360c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bom-local-card", - "version": "0.1.4", + "version": "0.2.0", "description": "Home Assistant custom card for displaying Australian Bureau of Meteorology (BOM) rain radar data with animated slideshow and historical data support", "module": "bom-local-card.js", "keywords": [ diff --git a/rollup.config.js b/rollup.config.js index 651298f..9fed80e 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -8,7 +8,7 @@ const isProd = process.env.NODE_ENV === 'production'; export default { input: 'src/bom-local-card.ts', output: { - file: 'dist/bom-local-card.js', + file: 'custom_components/bom_local/www/bom-local-card.js', format: 'es', sourcemap: !isProd, }, diff --git a/run.sh b/run.sh index b646d54..516d345 100755 --- a/run.sh +++ b/run.sh @@ -101,18 +101,24 @@ clean_test() { echo " ✅ Cleared .storage directory" fi - # Clean up www directory files (card file) using Docker if needed - if [ -f "test-ha/config/www/bom-local-card.js" ]; then - echo " Removing card file..." - CURRENT_UID=$(id -u) - CURRENT_GID=$(id -g) - if ! rm -f test-ha/config/www/bom-local-card.js 2>/dev/null; then - docker run --rm \ - -v "$(pwd)/test-ha/config/www:/www:rw" \ - -u root \ - alpine:latest \ - sh -c "rm -f /www/bom-local-card.js" 2>/dev/null || true - fi + # Clean up www directory files (wiping all old card versions) + echo " Cleaning www directory..." + if [ -d "test-ha/config/www" ]; then + docker run --rm \ + -v "$(pwd)/test-ha/config/www:/www:rw" \ + -u root \ + alpine:latest \ + sh -c "rm -rf /www/*" 2>/dev/null || true + fi + + # Clean up custom_components directory (wiping all old integration versions) + echo " Cleaning custom_components..." + if [ -d "test-ha/config/custom_components" ]; then + docker run --rm \ + -v "$(pwd)/test-ha/config/custom_components:/components:rw" \ + -u root \ + alpine:latest \ + sh -c "rm -rf /components/*" 2>/dev/null || true fi # Clean up cache directory (persisted between redeploys, but removed on full clean) diff --git a/scripts/build-docker.sh b/scripts/build-docker.sh index e17aa12..8b2e72d 100755 --- a/scripts/build-docker.sh +++ b/scripts/build-docker.sh @@ -17,11 +17,12 @@ if ! command -v docker &> /dev/null; then exit 1 fi -# Clean dist directory to ensure fresh build -if [ -d "dist" ]; then - rm -rf dist/* -fi -mkdir -p dist +# New build path for integration +CARD_BUILD_DIR="custom_components/bom_local/www" +CARD_BUILD_PATH="$CARD_BUILD_DIR/bom-local-card.js" + +# Ensure build directory exists +mkdir -p "$CARD_BUILD_DIR" # Build the image echo "📦 Building Docker image..." @@ -45,15 +46,16 @@ if [ -n "$NODE_ENV" ]; then ENV_ARGS="-e NODE_ENV=$NODE_ENV" fi -# Run the container (using volume for dist to get the output) -docker run --rm $ENV_ARGS -v "$(pwd)/dist:/build/dist" bom-local-card-builder +# Run the container (using volume for the integration www folder to get the output) +# We map the local integration www folder to the expected path in the container +docker run --rm $ENV_ARGS -v "$(pwd)/$CARD_BUILD_DIR:/build/$CARD_BUILD_DIR" bom-local-card-builder # Check if build succeeded -if [ ! -f "dist/bom-local-card.js" ]; then - echo "❌ Error: Build failed - dist/bom-local-card.js not found" +if [ ! -f "$CARD_BUILD_PATH" ]; then + echo "❌ Error: Build failed - $CARD_BUILD_PATH not found" exit 1 fi -FILE_SIZE=$(stat -f%z "dist/bom-local-card.js" 2>/dev/null || stat -c%s "dist/bom-local-card.js" 2>/dev/null || echo "0") +FILE_SIZE=$(stat -f%z "$CARD_BUILD_PATH" 2>/dev/null || stat -c%s "$CARD_BUILD_PATH" 2>/dev/null || echo "0") echo "✅ Build complete (${FILE_SIZE} bytes)" diff --git a/scripts/test.sh b/scripts/test.sh index 8bf2811..6fe6896 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -168,13 +168,15 @@ build_with_docker() { } # Build card +CARD_BUILD_PATH="custom_components/bom_local/www/bom-local-card.js" + if [ "$SKIP_BUILD" = "1" ]; then echo "⏭️ Skipping build (--skip-build)" - if [ ! -f "dist/bom-local-card.js" ]; then - echo "❌ Error: dist/bom-local-card.js not found and --skip-build was used" + if [ ! -f "$CARD_BUILD_PATH" ]; then + echo "❌ Error: $CARD_BUILD_PATH not found and --skip-build was used" exit 1 fi -elif [ "$FORCE_BUILD" = "1" ] || [ ! -f "dist/bom-local-card.js" ]; then +elif [ "$FORCE_BUILD" = "1" ] || [ ! -f "$CARD_BUILD_PATH" ]; then # If FORCE_BUILD is set, also force Docker rebuild if [ "$FORCE_BUILD" = "1" ] && [ "$USE_DOCKER_BUILD" = "1" ]; then export FORCE_REBUILD=1 @@ -192,13 +194,13 @@ elif [ "$FORCE_BUILD" = "1" ] || [ ! -f "dist/bom-local-card.js" ]; then fi fi - if [ ! -f "dist/bom-local-card.js" ]; then - echo "❌ Error: dist/bom-local-card.js not found after build" + if [ ! -f "$CARD_BUILD_PATH" ]; then + echo "❌ Error: $CARD_BUILD_PATH not found after build" exit 1 fi # Verify build quality - FILE_SIZE=$(stat -f%z "dist/bom-local-card.js" 2>/dev/null || stat -c%s "dist/bom-local-card.js" 2>/dev/null || echo "0") + FILE_SIZE=$(stat -f%z "$CARD_BUILD_PATH" 2>/dev/null || stat -c%s "$CARD_BUILD_PATH" 2>/dev/null || echo "0") if [ "$FILE_SIZE" -lt 50000 ]; then echo "⚠️ Warning: Built file is suspiciously small (${FILE_SIZE} bytes)" echo " This might indicate dependencies weren't bundled correctly" @@ -206,15 +208,15 @@ elif [ "$FORCE_BUILD" = "1" ] || [ ! -f "dist/bom-local-card.js" ]; then fi # Check for external imports (should not exist if dependencies are bundled) - if grep -q "from ['\"]lit['\"]" dist/bom-local-card.js 2>/dev/null || \ - grep -q "from ['\"]custom-card-helpers['\"]" dist/bom-local-card.js 2>/dev/null; then + if grep -q "from ['\"]lit['\"]" "$CARD_BUILD_PATH" 2>/dev/null || \ + grep -q "from ['\"]custom-card-helpers['\"]" "$CARD_BUILD_PATH" 2>/dev/null; then echo "⚠️ Warning: Found external import statements in built file" echo " Dependencies may not be bundled. Check rollup.config.js" fi echo "✅ Build complete (${FILE_SIZE} bytes)" else - echo "✅ Using existing dist/bom-local-card.js" + echo "✅ Using existing $CARD_BUILD_PATH" echo " (Use --force-build to rebuild, --docker-build or --npm-build to specify method)" fi @@ -246,42 +248,8 @@ fi if [ "$CONTAINERS_RUNNING" = "1" ]; then echo "🔄 Updating card in running test environment (preserving HA state)..." - # Copy card file - echo "📋 Updating card file..." - mkdir -p test-ha/config/www - - # Copy file using Docker (containers run as root, so we use root to write) - docker run --rm \ - -v "$(pwd)/dist/bom-local-card.js:/source/bom-local-card.js:ro" \ - -v "$(pwd)/test-ha/config/www:/dest:rw" \ - -u root \ - alpine:latest \ - sh -c "cp /source/bom-local-card.js /dest/bom-local-card.js && chmod 644 /dest/bom-local-card.js" 2>/dev/null || { - echo "❌ Error: Failed to copy card file" - exit 1 - } - echo " ✅ Card file copied successfully" - - # Verify copy succeeded - if [ ! -f "test-ha/config/www/bom-local-card.js" ]; then - echo "❌ Error: Failed to update card file" - exit 1 - fi - - # Verify file sizes match - SOURCE_SIZE=$(stat -f%z "dist/bom-local-card.js" 2>/dev/null || stat -c%s "dist/bom-local-card.js" 2>/dev/null) - DEST_SIZE=$(stat -f%z "test-ha/config/www/bom-local-card.js" 2>/dev/null || stat -c%s "test-ha/config/www/bom-local-card.js" 2>/dev/null) - if [ "$SOURCE_SIZE" != "$DEST_SIZE" ]; then - echo "❌ Error: File sizes don't match after copy" - exit 1 - fi - - # Verify file contains expected content (basic sanity check) - if ! grep -q "resolveImageUrl\|BOM-LOCAL-RADAR-CARD" test-ha/config/www/bom-local-card.js 2>/dev/null; then - echo " ⚠️ Warning: Card file may not be valid (missing expected content)" - fi - - echo "✅ Card file updated (${SOURCE_SIZE} bytes)" + # Copy card file - no longer needed as integration is mounted + echo "📋 Integration is mounted directly, no file copy needed." # Copy configuration files to HA config directory (for dashboard changes) echo "📋 Updating HA configuration files..." @@ -409,69 +377,8 @@ mkdir -p test-ha/config/www mkdir -p test-ha/cache # Ensure directories exist (Docker will create files as current user due to user: setting in compose) -# Copy built card file to www directory (for /local/ access in HA) -echo "📋 Copying card file to www directory..." -mkdir -p test-ha/config/www - -# Check if update is needed by comparing file sizes and content -NEEDS_UPDATE=1 -if [ -f "test-ha/config/www/bom-local-card.js" ]; then - SOURCE_SIZE=$(stat -f%z "dist/bom-local-card.js" 2>/dev/null || stat -c%s "dist/bom-local-card.js" 2>/dev/null) - DEST_SIZE=$(stat -f%z "test-ha/config/www/bom-local-card.js" 2>/dev/null || stat -c%s "test-ha/config/www/bom-local-card.js" 2>/dev/null) - - # Check if sizes match and if the file contains expected content (resolveImageUrl function) - if [ "$SOURCE_SIZE" = "$DEST_SIZE" ]; then - # Check if file contains key function to verify it's the latest version - if grep -q "resolveImageUrl" test-ha/config/www/bom-local-card.js 2>/dev/null || \ - grep -q "Resolve relative image URLs" test-ha/config/www/bom-local-card.js 2>/dev/null; then - # File exists, sizes match, and contains expected content - may not need update - # But we'll update anyway to be safe, or check modification time - SOURCE_MTIME=$(stat -f%m "dist/bom-local-card.js" 2>/dev/null || stat -c%Y "dist/bom-local-card.js" 2>/dev/null) - DEST_MTIME=$(stat -f%m "test-ha/config/www/bom-local-card.js" 2>/dev/null || stat -c%Y "test-ha/config/www/bom-local-card.js" 2>/dev/null) - if [ "$SOURCE_MTIME" -le "$DEST_MTIME" ]; then - NEEDS_UPDATE=0 - fi - fi - fi -fi - -if [ "$NEEDS_UPDATE" = "1" ]; then - # Copy file using Docker (containers run as root, so we use root to write) - echo " Copying file using Docker..." - docker run --rm \ - -v "$(pwd)/dist/bom-local-card.js:/source/bom-local-card.js:ro" \ - -v "$(pwd)/test-ha/config/www:/dest:rw" \ - -u root \ - alpine:latest \ - sh -c "cp /source/bom-local-card.js /dest/bom-local-card.js && chmod 644 /dest/bom-local-card.js" 2>/dev/null || { - echo "❌ Error: Failed to copy card file" - exit 1 - } - echo " ✅ Card file copied successfully" - - # Verify copy succeeded - if [ ! -f "test-ha/config/www/bom-local-card.js" ]; then - echo "❌ Error: Failed to copy card file to www directory" - exit 1 - fi - - # Verify file sizes match - SOURCE_SIZE=$(stat -f%z "dist/bom-local-card.js" 2>/dev/null || stat -c%s "dist/bom-local-card.js" 2>/dev/null) - DEST_SIZE=$(stat -f%z "test-ha/config/www/bom-local-card.js" 2>/dev/null || stat -c%s "test-ha/config/www/bom-local-card.js" 2>/dev/null) - if [ "$SOURCE_SIZE" != "$DEST_SIZE" ]; then - echo "❌ Error: File sizes don't match after copy" - exit 1 - fi - - # Verify file contains expected content (basic sanity check) - if ! grep -q "resolveImageUrl\|BOM-LOCAL-RADAR-CARD" test-ha/config/www/bom-local-card.js 2>/dev/null; then - echo " ⚠️ Warning: Card file may not be valid (missing expected content)" - fi - - echo "✅ Card file copied successfully" -else - echo "✅ Card file is up to date (skipping copy)" -fi +# Integration is mounted directly, skipping redundant copy +echo "📋 Integration is mounted directly." # Copy configuration files to HA config directory echo "📋 Copying HA configuration files..." diff --git a/src/bom-local-card.ts b/src/bom-local-card.ts index d413d76..1936017 100644 --- a/src/bom-local-card.ts +++ b/src/bom-local-card.ts @@ -79,7 +79,6 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard { type: 'custom:bom-local-card', suburb: 'Pomona', state: 'QLD', - service_url: 'http://localhost:8082', }; } @@ -146,11 +145,10 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard { } /** - * Fetches radar data from the local service + * Fetches radar data from the local service via HA integration * Supports both latest frames and historical timeseries */ private async fetchRadarData(): Promise { - const serviceUrl = this._config.service_url || DEFAULT_SERVICE_URL; const suburb = encodeURIComponent(this._config.suburb); const state = encodeURIComponent(this._config.state); const timespan = this._config.timespan || 'latest'; @@ -158,7 +156,7 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard { this.isLoading = true; const options = { - serviceUrl, + hass: this.hass, suburb, state, timespan: timespan !== 'latest' ? timespan : undefined, @@ -531,10 +529,6 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard { oldConfig.custom_end_time !== this._config.custom_end_time) { this.fetchRadarData(); } - // If service URL changed, refetch - if (oldConfig.service_url !== this._config.service_url) { - this.fetchRadarData(); - } // If refresh interval changed, restart auto-refresh if (oldConfig.refresh_interval !== this._config.refresh_interval) { this.startAutoRefresh(); diff --git a/src/const.ts b/src/const.ts index cbab09a..7255c50 100644 --- a/src/const.ts +++ b/src/const.ts @@ -1,4 +1,4 @@ -export const CARD_VERSION = '0.1.1'; +export const CARD_VERSION = '0.2.0'; export const DEFAULT_SERVICE_URL = 'http://localhost:8082'; export const DEFAULT_FRAME_INTERVAL = 2.0; // seconds export const DEFAULT_RESTART_DELAY = 2000; // ms (pause before looping) diff --git a/src/editor.ts b/src/editor.ts index 645ff14..8ed5866 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -70,13 +70,7 @@ export class BomLocalRadarCardEditor extends LitElement implements LovelaceCardE return html`
-

Service Configuration

- this._updateConfig('service_url', (e.target as HTMLInputElement).value)} - helper="Base URL of bom-local-service (e.g., http://localhost:8082)" - > +

Location Configuration

{ - const { serviceUrl, suburb, state, onError } = options; - const url = `${serviceUrl}/api/radar/${suburb}/${state}`; + const { hass, suburb, state, onError } = options; try { - const response = await fetch(url, { - method: 'GET', - headers: { 'Accept': 'application/json' }, + const data = await hass.callWS({ + type: 'bom_local/get_radar_data', + suburb, + state, }); - if (!response.ok) { - const { errorData, parsedJson } = await parseErrorResponse(response); - const error = parseApiError(response, errorData, parsedJson, { - retryAction: () => this.fetchLatestFrames(options), - defaultRetryAfter: 30, - }); + // Handle service-level errors (e.g. CACHE_NOT_FOUND) + if (data.errorCode || data.ErrorCode || data.error) { + const error = parseApiError( + { ok: false, status: 404, statusText: 'Not Found', url: '/api/radar' } as Response, + data, + true, + { + retryAction: () => this.fetchLatestFrames(options), + defaultRetryAfter: 30, + } + ); onError(error); return null; } - const data: any = await response.json(); - - // Handle legacy error format (shouldn't happen with structured errors, but handle for compatibility) - if (data.error) { - onError({ - message: data.error || 'Service returned an error', - type: 'cache', - retryable: true, - retryAction: () => this.fetchLatestFrames(options), - retryAfter: data.retryAfter || 30, - }); - return null; - } - // Validate response has frames if (!data.frames || data.frames.length === 0) { throw new Error('No frames available in response'); } - // Resolve relative image URLs against service URL - // Service returns relative URLs like "/api/radar/.../frame/..." - // which need to be absolute for browser to fetch from correct origin + // Resolve relative image URLs through HA proxy data.frames.forEach((frame: RadarFrame) => { if (frame.imageUrl) { - frame.imageUrl = resolveImageUrl(frame.imageUrl, serviceUrl); + frame.imageUrl = resolveImageUrl(frame.imageUrl); } }); return data; - } catch (err) { + } catch (err: any) { this.handleFetchError(err, options); return null; } } /** - * Fetches historical radar data from /api/radar/{suburb}/{state}/timeseries + * Fetches historical radar data via HA WebSocket API */ async fetchHistoricalFrames(options: FetchRadarOptions): Promise { - const { serviceUrl, suburb, state, timespan, customStartTime, customEndTime, onError } = options; + const { hass, suburb, state, timespan, customStartTime, customEndTime, onError } = options; try { - let startTime: Date | null = null; - let endTime = new Date(); + let startTime: string | null = null; + let endTime = new Date().toISOString(); if (timespan === 'custom') { - if (customStartTime) startTime = new Date(customStartTime); - if (customEndTime) endTime = new Date(customEndTime); + if (customStartTime) startTime = new Date(customStartTime).toISOString(); + if (customEndTime) endTime = new Date(customEndTime).toISOString(); } else if (timespan) { - // Calculate hours back from now const hours = parseInt(timespan.replace('h', '')) || 1; - startTime = new Date(endTime.getTime() - (hours * 60 * 60 * 1000)); + startTime = new Date(Date.now() - (hours * 60 * 60 * 1000)).toISOString(); } if (!startTime) { throw new Error('Invalid timespan configuration'); } - // Build query parameters for timeseries endpoint - const url = `${serviceUrl}/api/radar/${suburb}/${state}/timeseries?startTime=${startTime.toISOString()}&endTime=${endTime.toISOString()}`; - const response = await fetch(url, { - method: 'GET', - headers: { 'Accept': 'application/json' }, + const data = await hass.callWS({ + type: 'bom_local/get_radar_data', + suburb, + state, + startTime, + endTime, }); - - if (!response.ok) { - const { errorData, parsedJson } = await parseErrorResponse(response); - const error = parseApiError(response, errorData, parsedJson, { - retryAction: () => this.fetchHistoricalFrames(options), - defaultRetryAfter: 30, - }); + + // Handle service-level errors (e.g. TIME_RANGE_ERROR or CACHE_NOT_FOUND) + if (data.errorCode || data.ErrorCode || data.error) { + const error = parseApiError( + { ok: false, status: 404, statusText: 'Not Found', url: '/timeseries' } as Response, + data, + true, + { + retryAction: () => this.fetchHistoricalFrames(options), + defaultRetryAfter: 30, + } + ); onError(error); return null; } - const data: RadarTimeSeriesResponse = await response.json(); - if (!data.cacheFolders || data.cacheFolders.length === 0) { throw new Error('No historical data found for the specified time range.'); } // Flatten all frames from all cache folders - // The service already sets absoluteObservationTime on each frame const allFrames: RadarFrame[] = []; data.cacheFolders.forEach(cacheFolder => { cacheFolder.frames.forEach(frame => { - // Store folder metadata for reference (service already sets absoluteObservationTime) frame.cacheTimestamp = cacheFolder.cacheTimestamp; frame.observationTime = cacheFolder.observationTime; frame.cacheFolderName = cacheFolder.cacheFolderName; - // Resolve relative image URLs against service URL if (frame.imageUrl) { - frame.imageUrl = resolveImageUrl(frame.imageUrl, serviceUrl); + frame.imageUrl = resolveImageUrl(frame.imageUrl); } - // Ensure absoluteObservationTime is set (service should provide this, but handle if missing) if (!frame.absoluteObservationTime && frame.observationTime && frame.minutesAgo !== undefined) { - // Fallback: calculate from observation time and minutes ago const obsTime = new Date(frame.observationTime); frame.absoluteObservationTime = new Date(obsTime.getTime() - (frame.minutesAgo * 60 * 1000)).toISOString(); } @@ -145,39 +132,39 @@ export class RadarApiService { }); // Re-index frames sequentially + allFrames.sort((a, b) => new Date(a.absoluteObservationTime!).getTime() - new Date(b.absoluteObservationTime!).getTime()); allFrames.forEach((frame, idx) => { frame.sequentialIndex = idx; }); - // Fetch latest metadata for display from /api/radar/{suburb}/{state}/metadata + // Fetch latest metadata for display let metadata: Partial = {}; try { - const metadataUrl = `${serviceUrl}/api/radar/${suburb}/${state}/metadata`; - const metadataResponse = await fetch(metadataUrl); - if (metadataResponse.ok) { - metadata = await metadataResponse.json(); - } + metadata = await hass.callWS({ + type: 'bom_local/get_radar_data', + suburb, + state + }); } catch (err) { console.debug('Could not fetch metadata:', err); } - // Create RadarResponse-like object const newestCacheFolder = data.cacheFolders[data.cacheFolders.length - 1]; const radarResponse: RadarResponse = { frames: allFrames, - lastUpdated: endTime.toISOString(), - observationTime: metadata.observationTime || newestCacheFolder?.observationTime || endTime.toISOString(), - forecastTime: endTime.toISOString(), + lastUpdated: endTime, + observationTime: metadata.observationTime || newestCacheFolder?.observationTime || endTime, + forecastTime: endTime, weatherStation: metadata.weatherStation, distance: metadata.distance, cacheIsValid: metadata.cacheIsValid ?? true, - cacheExpiresAt: metadata.cacheExpiresAt || endTime.toISOString(), + cacheExpiresAt: metadata.cacheExpiresAt || endTime, isUpdating: metadata.isUpdating || false, - nextUpdateTime: metadata.nextUpdateTime || endTime.toISOString(), + nextUpdateTime: metadata.nextUpdateTime || endTime, }; return radarResponse; - } catch (err) { + } catch (err: any) { this.handleFetchError(err, options); return null; } @@ -187,21 +174,13 @@ export class RadarApiService { * Handles fetch errors and categorizes them appropriately */ private handleFetchError(err: any, options: FetchRadarOptions): void { - if (err instanceof TypeError && err.message.includes('fetch')) { - options.onError({ - message: 'Network error: Unable to connect to service', - type: 'network', - retryable: true, - retryAction: () => this.fetchLatestFrames(options), - }); - } else { - options.onError({ - message: err instanceof Error ? err.message : 'Unknown error occurred', - type: 'unknown', - retryable: true, - retryAction: () => this.fetchLatestFrames(options), - }); - } + const message = err?.message || (typeof err === 'string' ? err : 'Unknown error occurred'); + options.onError({ + message: message, + type: 'unknown', + retryable: true, + retryAction: () => this.fetchLatestFrames(options), + }); } } diff --git a/src/types.ts b/src/types.ts index 90643d2..8bfd5fb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -109,7 +109,7 @@ export interface BomLocalRadarCardConfig extends LovelaceCardConfig { type: 'custom:bom-local-card'; // Service configuration - service_url?: string; // Base URL of bom-local-service (e.g., "http://localhost:8082") + service_url?: string; // Internal/Legacy: now managed by the Home Assistant integration suburb: string; // Required: suburb name (e.g., "Pomona") state: string; // Required: state abbreviation (e.g., "QLD") diff --git a/src/utils/url-resolver.ts b/src/utils/url-resolver.ts index 308bebf..e52677b 100644 --- a/src/utils/url-resolver.ts +++ b/src/utils/url-resolver.ts @@ -1,25 +1,20 @@ /** - * Resolves a URL relative to the service URL if it's a relative path - * Service returns relative URLs like "/api/radar/.../frame/..." which need - * to be resolved against the service_url base + * Resolves a URL through the Home Assistant integration proxy if it's a relative path. + * The integration provides a proxy at /api/bom_local/proxy/ */ -export function resolveImageUrl(url: string, serviceUrl: string): string { +export function resolveImageUrl(url: string, serviceUrl?: string): string { if (!url) return url; // If URL is already absolute (starts with http:// or https://), use as-is if (url.startsWith('http://') || url.startsWith('https://')) { return url; } - - // Remove trailing slash from serviceUrl - const baseUrl = serviceUrl.replace(/\/$/, ''); - - // If URL is relative (starts with /), resolve against service URL - if (url.startsWith('/')) { - return `${baseUrl}${url}`; - } - - // If URL is relative without leading slash, resolve against service URL with path - return `${baseUrl}/${url}`; -} + // If we are using the HA integration proxy (standard behavior now) + const proxyPrefix = '/api/bom_local/proxy'; + + // Remove leading slash if present to avoid double slashes + const path = url.startsWith('/') ? url : `/${url}`; + + return `${proxyPrefix}${path}`; +}