{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"
+ >
+ ` : ''}
+
+
+
+
+
+ ${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 `
+
+
+
+
+
${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 `
+
+ ${displayConfig.show_cache_status !== false ? this._renderCacheStatus() : ''}
+ ${displayConfig.show_observation_time !== false ? this._renderObservationTime() : ''}
+ ${displayConfig.show_forecast_time !== false ? this._renderForecastTime() : ''}
+ ${displayConfig.show_weather_station !== false ? this._renderWeatherStation() : ''}
+ ${displayConfig.show_distance !== false ? this._renderDistance() : ''}
+ ${displayConfig.show_next_update !== false ? this._renderNextUpdate() : ''}
+
+ `;
+ }
+ _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 `
+
+ ${displayConfig.show_cache_status !== false ? this._renderCacheStatus() : ''}
+ ${displayConfig.show_observation_time !== false ? this._renderObservationTime() : ''}
+ ${displayConfig.show_forecast_time !== false ? this._renderForecastTime() : ''}
+ ${displayConfig.show_weather_station !== false ? this._renderWeatherStation() : ''}
+ ${displayConfig.show_distance !== false ? this._renderDistance() : ''}
+ ${displayConfig.show_next_update !== false ? this._renderNextUpdate() : ''}
+
+ `;
+ }
+ _renderCacheStatus() {
+ if (!this.radarData)
+ return x ``;
+ return x `
+
+ Cache:
+
+ ${this.radarData.isUpdating ? 'Updating' :
+ this.radarData.cacheIsValid ? 'Valid' : 'Invalid'}
+
+
+ `;
+ }
+ _renderObservationTime() {
+ var _a;
+ if (!((_a = this.radarData) === null || _a === void 0 ? void 0 : _a.observationTime))
+ return x ``;
+ return x `
+
+ Observation:
+
+ ${formatTimestamp(this.radarData.observationTime, this.locale, this.timeZone)}
+
+
+ (${formatRelativeTime(this.radarData.observationTime)})
+
+
+ `;
+ }
+ _renderForecastTime() {
+ var _a;
+ if (!((_a = this.radarData) === null || _a === void 0 ? void 0 : _a.forecastTime))
+ return x ``;
+ return x `
+
+ Forecast:
+ ${formatTimestamp(this.radarData.forecastTime, this.locale, this.timeZone)}
+
+ `;
+ }
+ _renderWeatherStation() {
+ var _a;
+ if (!((_a = this.radarData) === null || _a === void 0 ? void 0 : _a.weatherStation))
+ return x ``;
+ return x `
+
+ Station:
+ ${this.radarData.weatherStation}
+
+ `;
+ }
+ _renderDistance() {
+ var _a;
+ if (!((_a = this.radarData) === null || _a === void 0 ? void 0 : _a.distance))
+ return x ``;
+ return x `
+
+ Distance:
+ ${this.radarData.distance}
+
+ `;
+ }
+ _renderNextUpdate() {
+ var _a;
+ if (!((_a = this.radarData) === null || _a === void 0 ? void 0 : _a.nextUpdateTime))
+ return x ``;
+ return x `
+
+ Next Update:
+ ${formatTimestamp(this.radarData.nextUpdateTime, this.locale, this.timeZone)}
+
+ `;
+ }
+};
+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 `
+
+
+ {
+ const index = parseInt(e.target.value);
+ if (this.onFrameChange) {
+ this.onFrameChange(index);
+ }
+ }}"
+ aria-label="Frame slider"
+ />
+
+
+ `;
+ }
+ _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 `
+

{
+ this.error = {
+ message: 'Failed to load radar image',
+ type: 'unknown',
+ retryable: true,
+ retryAction: () => this.fetchRadarData(),
+ };
+ }}"
+ />
+ ${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}`;
+}