Refactor to Home Assistant integration using WebSockets and HTTP Proxy View

This commit is contained in:
2025-12-19 02:40:27 +10:00
parent 0cbc6a7a7c
commit 212bf5b18f
28 changed files with 3895 additions and 266 deletions
+1
View File
@@ -4,6 +4,7 @@ node_modules/
# Build outputs
dist/
*.js.map
custom_components/bom_local/www/*.js.map
# Environment files
.env
+73
View File
@@ -0,0 +1,73 @@
"""The BOM Local Radar integration."""
import logging
import voluptuous as vol
from homeassistant.core import HomeAssistant
from homeassistant.components.http import StaticPathConfig
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers import config_validation as cv
from .const import DOMAIN, CONF_SERVICE_URL, DEFAULT_SERVICE_URL
from .views import BomLocalProxyView
from .websocket import async_setup_websocket
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
vol.Required(CONF_SERVICE_URL, default=DEFAULT_SERVICE_URL): cv.string,
}
)
},
extra=vol.ALLOW_EXTRA,
)
async def async_setup(hass: HomeAssistant, config: dict):
"""Set up the BOM Local Radar component from YAML."""
if DOMAIN not in config:
return True
conf = config[DOMAIN]
service_url = conf.get(CONF_SERVICE_URL)
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN]["service_url"] = service_url
await _async_setup_integration(hass, service_url)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up BOM Local Radar from a config entry."""
service_url = entry.data.get(CONF_SERVICE_URL)
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN]["service_url"] = service_url
await _async_setup_integration(hass, service_url)
return True
async def _async_setup_integration(hass: HomeAssistant, service_url: str):
"""Common setup logic for YAML and Config Entry."""
# Register the proxy view
hass.http.register_view(BomLocalProxyView(service_url))
# Register the websocket commands
async_setup_websocket(hass, service_url)
# Register the card as a resource automatically
# Note: In a real HACS install, the path would be /local/community/bom-local-card/bom-local-card.js
# but since this is an integration, it's /local/community/bom_local/bom-local-card.js
await hass.http.async_register_static_paths([
StaticPathConfig(
"/local/community/bom_local/bom-local-card.js",
hass.config.path("custom_components/bom_local/www/bom-local-card.js"),
True
)
])
_LOGGER.info("BOM Local Radar integration setup completed with service URL: %s", service_url)
@@ -0,0 +1,58 @@
import logging
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import callback
from homeassistant.const import CONF_URL
import homeassistant.helpers.config_validation as cv
from .const import DOMAIN, CONF_SERVICE_URL, DEFAULT_SERVICE_URL
_LOGGER = logging.getLogger(__name__)
class BomLocalConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for BOM Local Radar."""
VERSION = 1
async def async_step_user(self, user_input=None):
"""Handle the initial step."""
errors = {}
if user_input is not None:
# We could validate the URL here by trying a fetch
return self.async_create_entry(title="BOM Local Radar", data=user_input)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({
vol.Required(CONF_SERVICE_URL, default=DEFAULT_SERVICE_URL): str,
}),
errors=errors,
)
@staticmethod
@callback
def async_get_options_flow(config_entry):
return BomLocalOptionsFlow(config_entry)
class BomLocalOptionsFlow(config_entries.OptionsFlow):
"""Handle options flow."""
def __init__(self, config_entry):
self.config_entry = config_entry
async def async_step_init(self, user_input=None):
"""Manage the options."""
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema({
vol.Required(
CONF_SERVICE_URL,
default=self.config_entry.data.get(CONF_SERVICE_URL, DEFAULT_SERVICE_URL),
): str,
}),
)
+6
View File
@@ -0,0 +1,6 @@
"""Constants for the BOM Local Radar integration."""
DOMAIN = "bom_local"
CONF_SERVICE_URL = "service_url"
DEFAULT_SERVICE_URL = "http://localhost:8082"
+12
View File
@@ -0,0 +1,12 @@
{
"domain": "bom_local",
"name": "BOM Local Radar",
"codeowners": ["@alexhopeoconnor"],
"config_flow": true,
"iot_class": "local_polling",
"version": "0.2.0",
"dependencies": ["http", "websocket_api"],
"documentation": "https://github.com/alexhopeoconnor/bom-local-card",
"issue_tracker": "https://github.com/alexhopeoconnor/bom-local-card/issues"
}
+31
View File
@@ -0,0 +1,31 @@
{
"config": {
"step": {
"user": {
"title": "BOM Local Radar Configuration",
"description": "Enter the URL of your BOM Local Radar service (e.g., http://192.168.1.50:8082).",
"data": {
"service_url": "Service URL"
}
}
},
"error": {
"cannot_connect": "Failed to connect to the BOM Local Radar service",
"invalid_url": "Please enter a valid URL"
},
"abort": {
"already_configured": "This service is already configured"
}
},
"options": {
"step": {
"init": {
"title": "BOM Local Radar Options",
"data": {
"service_url": "Service URL"
}
}
}
}
}
@@ -0,0 +1,31 @@
{
"config": {
"step": {
"user": {
"title": "BOM Local Radar Configuration",
"description": "Enter the URL of your BOM Local Radar service (e.g., http://192.168.1.50:8082).",
"data": {
"service_url": "Service URL"
}
}
},
"error": {
"cannot_connect": "Failed to connect to the BOM Local Radar service",
"invalid_url": "Please enter a valid URL"
},
"abort": {
"already_configured": "This service is already configured"
}
},
"options": {
"step": {
"init": {
"title": "BOM Local Radar Options",
"data": {
"service_url": "Service URL"
}
}
}
}
}
+42
View File
@@ -0,0 +1,42 @@
import aiohttp
from homeassistant.components.http import HomeAssistantView
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from homeassistant.helpers.aiohttp_client import async_get_clientsession
class BomLocalProxyView(HomeAssistantView):
"""Proxy view for BOM local service images."""
url = "/api/bom_local/proxy/{path:.*}"
name = "api:bom_local:proxy"
requires_auth = False
def __init__(self, service_url: str):
self._service_url = service_url.rstrip('/')
async def get(self, request, path):
"""Fetch image from local service and stream it back."""
# Reconstruct query parameters (important for historical data)
query_string = request.query_string
target_url = f"{self._service_url}/{path}"
if query_string:
target_url = f"{target_url}?{query_string}"
hass = request.app["hass"]
session = async_get_clientsession(hass)
try:
async with session.get(target_url, timeout=15) as response:
if response.status != 200:
return web.Response(status=response.status)
content = await response.read()
return web.Response(
body=content,
content_type=response.content_type,
headers={"Cache-Control": "max-age=3600"}
)
except Exception:
return web.Response(status=502)
from aiohttp import web
+59
View File
@@ -0,0 +1,59 @@
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
import aiohttp
import logging
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
@callback
def async_setup_websocket(hass: HomeAssistant, service_url: str):
"""Set up websocket commands."""
websocket_api.async_register_command(
hass, websocket_get_radar_data
)
@websocket_api.websocket_command({
vol.Required("type"): "bom_local/get_radar_data",
vol.Required("suburb"): str,
vol.Required("state"): str,
vol.Optional("timespan"): str,
vol.Optional("startTime"): str,
vol.Optional("endTime"): str,
})
@websocket_api.async_response
async def websocket_get_radar_data(hass: HomeAssistant, connection, msg):
"""Handle getting radar data."""
service_url = hass.data[DOMAIN]["service_url"].rstrip('/')
suburb = msg["suburb"]
state = msg["state"]
# Construct target URL for the local service
if "startTime" in msg and "endTime" in msg:
# Historical/Timeseries
path = f"api/radar/{suburb}/{state}/timeseries?startTime={msg['startTime']}&endTime={msg['endTime']}"
else:
# Latest
path = f"api/radar/{suburb}/{state}"
target_url = f"{service_url}/{path}"
session = async_get_clientsession(hass)
try:
async with session.get(target_url, timeout=10) as response:
# Even if status is not 200 (e.g. 404), the service returns helpful JSON
# about cache status that the card needs.
data = await response.json()
if response.status == 200:
connection.send_result(msg["id"], data)
else:
# Send the data back but as an error result so the card can parse it
connection.send_result(msg["id"], data)
except Exception as err:
_LOGGER.error("Error fetching radar data from %s: %s", target_url, err)
connection.send_error(msg["id"], "connection_error", str(err))
File diff suppressed because one or more lines are too long
+2
View File
@@ -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
+4 -12
View File
@@ -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
}
+1 -1
View File
@@ -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": [
+1 -1
View File
@@ -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,
},
+18 -12
View File
@@ -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)
+12 -10
View File
@@ -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)"
+15 -108
View File
@@ -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..."
+2 -8
View File
@@ -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<RadarResponse | null> {
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();
+1 -1
View File
@@ -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)
+1 -7
View File
@@ -70,13 +70,7 @@ export class BomLocalRadarCardEditor extends LitElement implements LovelaceCardE
return html`
<div class="editor">
<div class="section">
<h3>Service Configuration</h3>
<ha-textfield
label="Service URL"
.value=${this._config.service_url || ''}
@input=${(e: Event) => this._updateConfig('service_url', (e.target as HTMLInputElement).value)}
helper="Base URL of bom-local-service (e.g., http://localhost:8082)"
></ha-textfield>
<h3>Location Configuration</h3>
<ha-textfield
label="Suburb"
.value=${this._config.suburb || ''}
+68 -89
View File
@@ -1,10 +1,10 @@
import { RadarResponse, RadarTimeSeriesResponse, RadarFrame, ErrorState } from '../types';
import { parseApiError, parseErrorResponse } from '../utils/error-handler';
import { resolveImageUrl } from '../utils/url-resolver';
import { DEFAULT_SERVICE_URL } from '../const';
import { HomeAssistant } from 'custom-card-helpers';
export interface FetchRadarOptions {
serviceUrl: string;
hass: HomeAssistant;
suburb: string;
state: string;
timespan?: string;
@@ -15,127 +15,114 @@ export interface FetchRadarOptions {
export class RadarApiService {
/**
* Fetches latest radar frames from /api/radar/{suburb}/{state}
* Fetches latest radar frames via HA WebSocket API
*/
async fetchLatestFrames(options: FetchRadarOptions): Promise<RadarResponse | null> {
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<any>({
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<RadarResponse | null> {
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<any>({
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<RadarResponse> = {};
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<RadarResponse>({
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),
});
}
}
+1 -1
View File
@@ -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")
+11 -16
View File
@@ -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}`;
}