mirror of
https://github.com/alexhopeoconnor/bom-local-card.git
synced 2026-10-04 04:28:11 +10:00
Refactor to Home Assistant integration using WebSockets and HTTP Proxy View
This commit is contained in:
@@ -4,6 +4,7 @@ node_modules/
|
|||||||
# Build outputs
|
# Build outputs
|
||||||
dist/
|
dist/
|
||||||
*.js.map
|
*.js.map
|
||||||
|
custom_components/bom_local/www/*.js.map
|
||||||
|
|
||||||
# Environment files
|
# Environment files
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
@@ -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"
|
||||||
|
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
@@ -40,6 +40,8 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
# Mount HA config directory
|
# Mount HA config directory
|
||||||
- ./test-ha/config:/config
|
- ./test-ha/config:/config
|
||||||
|
# Mount the integration
|
||||||
|
- ./custom_components/bom_local:/config/custom_components/bom_local
|
||||||
environment:
|
environment:
|
||||||
- TZ=Australia/Brisbane
|
- TZ=Australia/Brisbane
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -1,14 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "BOM Local Radar Card",
|
"name": "BOM Local Radar",
|
||||||
"render_readme": true,
|
"content_type": "integration",
|
||||||
"filename": "bom-local-card.js",
|
"domains": ["bom_local"],
|
||||||
"homeassistant": "2024.1.0"
|
"render_readme": true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bom-local-card",
|
"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",
|
"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",
|
"module": "bom-local-card.js",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ const isProd = process.env.NODE_ENV === 'production';
|
|||||||
export default {
|
export default {
|
||||||
input: 'src/bom-local-card.ts',
|
input: 'src/bom-local-card.ts',
|
||||||
output: {
|
output: {
|
||||||
file: 'dist/bom-local-card.js',
|
file: 'custom_components/bom_local/www/bom-local-card.js',
|
||||||
format: 'es',
|
format: 'es',
|
||||||
sourcemap: !isProd,
|
sourcemap: !isProd,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -101,18 +101,24 @@ clean_test() {
|
|||||||
echo " ✅ Cleared .storage directory"
|
echo " ✅ Cleared .storage directory"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Clean up www directory files (card file) using Docker if needed
|
# Clean up www directory files (wiping all old card versions)
|
||||||
if [ -f "test-ha/config/www/bom-local-card.js" ]; then
|
echo " Cleaning www directory..."
|
||||||
echo " Removing card file..."
|
if [ -d "test-ha/config/www" ]; then
|
||||||
CURRENT_UID=$(id -u)
|
docker run --rm \
|
||||||
CURRENT_GID=$(id -g)
|
-v "$(pwd)/test-ha/config/www:/www:rw" \
|
||||||
if ! rm -f test-ha/config/www/bom-local-card.js 2>/dev/null; then
|
-u root \
|
||||||
docker run --rm \
|
alpine:latest \
|
||||||
-v "$(pwd)/test-ha/config/www:/www:rw" \
|
sh -c "rm -rf /www/*" 2>/dev/null || true
|
||||||
-u root \
|
fi
|
||||||
alpine:latest \
|
|
||||||
sh -c "rm -f /www/bom-local-card.js" 2>/dev/null || true
|
# Clean up custom_components directory (wiping all old integration versions)
|
||||||
fi
|
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
|
fi
|
||||||
|
|
||||||
# Clean up cache directory (persisted between redeploys, but removed on full clean)
|
# Clean up cache directory (persisted between redeploys, but removed on full clean)
|
||||||
|
|||||||
+12
-10
@@ -17,11 +17,12 @@ if ! command -v docker &> /dev/null; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Clean dist directory to ensure fresh build
|
# New build path for integration
|
||||||
if [ -d "dist" ]; then
|
CARD_BUILD_DIR="custom_components/bom_local/www"
|
||||||
rm -rf dist/*
|
CARD_BUILD_PATH="$CARD_BUILD_DIR/bom-local-card.js"
|
||||||
fi
|
|
||||||
mkdir -p dist
|
# Ensure build directory exists
|
||||||
|
mkdir -p "$CARD_BUILD_DIR"
|
||||||
|
|
||||||
# Build the image
|
# Build the image
|
||||||
echo "📦 Building Docker image..."
|
echo "📦 Building Docker image..."
|
||||||
@@ -45,15 +46,16 @@ if [ -n "$NODE_ENV" ]; then
|
|||||||
ENV_ARGS="-e NODE_ENV=$NODE_ENV"
|
ENV_ARGS="-e NODE_ENV=$NODE_ENV"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Run the container (using volume for dist to get the output)
|
# Run the container (using volume for the integration www folder to get the output)
|
||||||
docker run --rm $ENV_ARGS -v "$(pwd)/dist:/build/dist" bom-local-card-builder
|
# 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
|
# Check if build succeeded
|
||||||
if [ ! -f "dist/bom-local-card.js" ]; then
|
if [ ! -f "$CARD_BUILD_PATH" ]; then
|
||||||
echo "❌ Error: Build failed - dist/bom-local-card.js not found"
|
echo "❌ Error: Build failed - $CARD_BUILD_PATH not found"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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)"
|
echo "✅ Build complete (${FILE_SIZE} bytes)"
|
||||||
|
|
||||||
|
|||||||
+15
-108
@@ -168,13 +168,15 @@ build_with_docker() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Build card
|
# Build card
|
||||||
|
CARD_BUILD_PATH="custom_components/bom_local/www/bom-local-card.js"
|
||||||
|
|
||||||
if [ "$SKIP_BUILD" = "1" ]; then
|
if [ "$SKIP_BUILD" = "1" ]; then
|
||||||
echo "⏭️ Skipping build (--skip-build)"
|
echo "⏭️ Skipping build (--skip-build)"
|
||||||
if [ ! -f "dist/bom-local-card.js" ]; then
|
if [ ! -f "$CARD_BUILD_PATH" ]; then
|
||||||
echo "❌ Error: dist/bom-local-card.js not found and --skip-build was used"
|
echo "❌ Error: $CARD_BUILD_PATH not found and --skip-build was used"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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 is set, also force Docker rebuild
|
||||||
if [ "$FORCE_BUILD" = "1" ] && [ "$USE_DOCKER_BUILD" = "1" ]; then
|
if [ "$FORCE_BUILD" = "1" ] && [ "$USE_DOCKER_BUILD" = "1" ]; then
|
||||||
export FORCE_REBUILD=1
|
export FORCE_REBUILD=1
|
||||||
@@ -192,13 +194,13 @@ elif [ "$FORCE_BUILD" = "1" ] || [ ! -f "dist/bom-local-card.js" ]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ ! -f "dist/bom-local-card.js" ]; then
|
if [ ! -f "$CARD_BUILD_PATH" ]; then
|
||||||
echo "❌ Error: dist/bom-local-card.js not found after build"
|
echo "❌ Error: $CARD_BUILD_PATH not found after build"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Verify build quality
|
# 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
|
if [ "$FILE_SIZE" -lt 50000 ]; then
|
||||||
echo "⚠️ Warning: Built file is suspiciously small (${FILE_SIZE} bytes)"
|
echo "⚠️ Warning: Built file is suspiciously small (${FILE_SIZE} bytes)"
|
||||||
echo " This might indicate dependencies weren't bundled correctly"
|
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
|
fi
|
||||||
|
|
||||||
# Check for external imports (should not exist if dependencies are bundled)
|
# Check for external imports (should not exist if dependencies are bundled)
|
||||||
if grep -q "from ['\"]lit['\"]" dist/bom-local-card.js 2>/dev/null || \
|
if grep -q "from ['\"]lit['\"]" "$CARD_BUILD_PATH" 2>/dev/null || \
|
||||||
grep -q "from ['\"]custom-card-helpers['\"]" dist/bom-local-card.js 2>/dev/null; then
|
grep -q "from ['\"]custom-card-helpers['\"]" "$CARD_BUILD_PATH" 2>/dev/null; then
|
||||||
echo "⚠️ Warning: Found external import statements in built file"
|
echo "⚠️ Warning: Found external import statements in built file"
|
||||||
echo " Dependencies may not be bundled. Check rollup.config.js"
|
echo " Dependencies may not be bundled. Check rollup.config.js"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "✅ Build complete (${FILE_SIZE} bytes)"
|
echo "✅ Build complete (${FILE_SIZE} bytes)"
|
||||||
else
|
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)"
|
echo " (Use --force-build to rebuild, --docker-build or --npm-build to specify method)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -246,42 +248,8 @@ fi
|
|||||||
if [ "$CONTAINERS_RUNNING" = "1" ]; then
|
if [ "$CONTAINERS_RUNNING" = "1" ]; then
|
||||||
echo "🔄 Updating card in running test environment (preserving HA state)..."
|
echo "🔄 Updating card in running test environment (preserving HA state)..."
|
||||||
|
|
||||||
# Copy card file
|
# Copy card file - no longer needed as integration is mounted
|
||||||
echo "📋 Updating card file..."
|
echo "📋 Integration is mounted directly, no file copy needed."
|
||||||
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 configuration files to HA config directory (for dashboard changes)
|
# Copy configuration files to HA config directory (for dashboard changes)
|
||||||
echo "📋 Updating HA configuration files..."
|
echo "📋 Updating HA configuration files..."
|
||||||
@@ -409,69 +377,8 @@ mkdir -p test-ha/config/www
|
|||||||
mkdir -p test-ha/cache
|
mkdir -p test-ha/cache
|
||||||
|
|
||||||
# Ensure directories exist (Docker will create files as current user due to user: setting in compose)
|
# 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)
|
# Integration is mounted directly, skipping redundant copy
|
||||||
echo "📋 Copying card file to www directory..."
|
echo "📋 Integration is mounted directly."
|
||||||
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
|
|
||||||
|
|
||||||
# Copy configuration files to HA config directory
|
# Copy configuration files to HA config directory
|
||||||
echo "📋 Copying HA configuration files..."
|
echo "📋 Copying HA configuration files..."
|
||||||
|
|||||||
@@ -79,7 +79,6 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard {
|
|||||||
type: 'custom:bom-local-card',
|
type: 'custom:bom-local-card',
|
||||||
suburb: 'Pomona',
|
suburb: 'Pomona',
|
||||||
state: 'QLD',
|
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
|
* Supports both latest frames and historical timeseries
|
||||||
*/
|
*/
|
||||||
private async fetchRadarData(): Promise<RadarResponse | null> {
|
private async fetchRadarData(): Promise<RadarResponse | null> {
|
||||||
const serviceUrl = this._config.service_url || DEFAULT_SERVICE_URL;
|
|
||||||
const suburb = encodeURIComponent(this._config.suburb);
|
const suburb = encodeURIComponent(this._config.suburb);
|
||||||
const state = encodeURIComponent(this._config.state);
|
const state = encodeURIComponent(this._config.state);
|
||||||
const timespan = this._config.timespan || 'latest';
|
const timespan = this._config.timespan || 'latest';
|
||||||
@@ -158,7 +156,7 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard {
|
|||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
serviceUrl,
|
hass: this.hass,
|
||||||
suburb,
|
suburb,
|
||||||
state,
|
state,
|
||||||
timespan: timespan !== 'latest' ? timespan : undefined,
|
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) {
|
oldConfig.custom_end_time !== this._config.custom_end_time) {
|
||||||
this.fetchRadarData();
|
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 refresh interval changed, restart auto-refresh
|
||||||
if (oldConfig.refresh_interval !== this._config.refresh_interval) {
|
if (oldConfig.refresh_interval !== this._config.refresh_interval) {
|
||||||
this.startAutoRefresh();
|
this.startAutoRefresh();
|
||||||
|
|||||||
+1
-1
@@ -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_SERVICE_URL = 'http://localhost:8082';
|
||||||
export const DEFAULT_FRAME_INTERVAL = 2.0; // seconds
|
export const DEFAULT_FRAME_INTERVAL = 2.0; // seconds
|
||||||
export const DEFAULT_RESTART_DELAY = 2000; // ms (pause before looping)
|
export const DEFAULT_RESTART_DELAY = 2000; // ms (pause before looping)
|
||||||
|
|||||||
+1
-7
@@ -70,13 +70,7 @@ export class BomLocalRadarCardEditor extends LitElement implements LovelaceCardE
|
|||||||
return html`
|
return html`
|
||||||
<div class="editor">
|
<div class="editor">
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h3>Service Configuration</h3>
|
<h3>Location 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>
|
|
||||||
<ha-textfield
|
<ha-textfield
|
||||||
label="Suburb"
|
label="Suburb"
|
||||||
.value=${this._config.suburb || ''}
|
.value=${this._config.suburb || ''}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { RadarResponse, RadarTimeSeriesResponse, RadarFrame, ErrorState } from '../types';
|
import { RadarResponse, RadarTimeSeriesResponse, RadarFrame, ErrorState } from '../types';
|
||||||
import { parseApiError, parseErrorResponse } from '../utils/error-handler';
|
import { parseApiError, parseErrorResponse } from '../utils/error-handler';
|
||||||
import { resolveImageUrl } from '../utils/url-resolver';
|
import { resolveImageUrl } from '../utils/url-resolver';
|
||||||
import { DEFAULT_SERVICE_URL } from '../const';
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
|
|
||||||
export interface FetchRadarOptions {
|
export interface FetchRadarOptions {
|
||||||
serviceUrl: string;
|
hass: HomeAssistant;
|
||||||
suburb: string;
|
suburb: string;
|
||||||
state: string;
|
state: string;
|
||||||
timespan?: string;
|
timespan?: string;
|
||||||
@@ -15,127 +15,114 @@ export interface FetchRadarOptions {
|
|||||||
|
|
||||||
export class RadarApiService {
|
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> {
|
async fetchLatestFrames(options: FetchRadarOptions): Promise<RadarResponse | null> {
|
||||||
const { serviceUrl, suburb, state, onError } = options;
|
const { hass, suburb, state, onError } = options;
|
||||||
const url = `${serviceUrl}/api/radar/${suburb}/${state}`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const data = await hass.callWS<any>({
|
||||||
method: 'GET',
|
type: 'bom_local/get_radar_data',
|
||||||
headers: { 'Accept': 'application/json' },
|
suburb,
|
||||||
|
state,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
// Handle service-level errors (e.g. CACHE_NOT_FOUND)
|
||||||
const { errorData, parsedJson } = await parseErrorResponse(response);
|
if (data.errorCode || data.ErrorCode || data.error) {
|
||||||
const error = parseApiError(response, errorData, parsedJson, {
|
const error = parseApiError(
|
||||||
retryAction: () => this.fetchLatestFrames(options),
|
{ ok: false, status: 404, statusText: 'Not Found', url: '/api/radar' } as Response,
|
||||||
defaultRetryAfter: 30,
|
data,
|
||||||
});
|
true,
|
||||||
|
{
|
||||||
|
retryAction: () => this.fetchLatestFrames(options),
|
||||||
|
defaultRetryAfter: 30,
|
||||||
|
}
|
||||||
|
);
|
||||||
onError(error);
|
onError(error);
|
||||||
return null;
|
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
|
// Validate response has frames
|
||||||
if (!data.frames || data.frames.length === 0) {
|
if (!data.frames || data.frames.length === 0) {
|
||||||
throw new Error('No frames available in response');
|
throw new Error('No frames available in response');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve relative image URLs against service URL
|
// Resolve relative image URLs through HA proxy
|
||||||
// Service returns relative URLs like "/api/radar/.../frame/..."
|
|
||||||
// which need to be absolute for browser to fetch from correct origin
|
|
||||||
data.frames.forEach((frame: RadarFrame) => {
|
data.frames.forEach((frame: RadarFrame) => {
|
||||||
if (frame.imageUrl) {
|
if (frame.imageUrl) {
|
||||||
frame.imageUrl = resolveImageUrl(frame.imageUrl, serviceUrl);
|
frame.imageUrl = resolveImageUrl(frame.imageUrl);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
this.handleFetchError(err, options);
|
this.handleFetchError(err, options);
|
||||||
return null;
|
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> {
|
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 {
|
try {
|
||||||
let startTime: Date | null = null;
|
let startTime: string | null = null;
|
||||||
let endTime = new Date();
|
let endTime = new Date().toISOString();
|
||||||
|
|
||||||
if (timespan === 'custom') {
|
if (timespan === 'custom') {
|
||||||
if (customStartTime) startTime = new Date(customStartTime);
|
if (customStartTime) startTime = new Date(customStartTime).toISOString();
|
||||||
if (customEndTime) endTime = new Date(customEndTime);
|
if (customEndTime) endTime = new Date(customEndTime).toISOString();
|
||||||
} else if (timespan) {
|
} else if (timespan) {
|
||||||
// Calculate hours back from now
|
|
||||||
const hours = parseInt(timespan.replace('h', '')) || 1;
|
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) {
|
if (!startTime) {
|
||||||
throw new Error('Invalid timespan configuration');
|
throw new Error('Invalid timespan configuration');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build query parameters for timeseries endpoint
|
const data = await hass.callWS<any>({
|
||||||
const url = `${serviceUrl}/api/radar/${suburb}/${state}/timeseries?startTime=${startTime.toISOString()}&endTime=${endTime.toISOString()}`;
|
type: 'bom_local/get_radar_data',
|
||||||
const response = await fetch(url, {
|
suburb,
|
||||||
method: 'GET',
|
state,
|
||||||
headers: { 'Accept': 'application/json' },
|
startTime,
|
||||||
|
endTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
// Handle service-level errors (e.g. TIME_RANGE_ERROR or CACHE_NOT_FOUND)
|
||||||
const { errorData, parsedJson } = await parseErrorResponse(response);
|
if (data.errorCode || data.ErrorCode || data.error) {
|
||||||
const error = parseApiError(response, errorData, parsedJson, {
|
const error = parseApiError(
|
||||||
retryAction: () => this.fetchHistoricalFrames(options),
|
{ ok: false, status: 404, statusText: 'Not Found', url: '/timeseries' } as Response,
|
||||||
defaultRetryAfter: 30,
|
data,
|
||||||
});
|
true,
|
||||||
|
{
|
||||||
|
retryAction: () => this.fetchHistoricalFrames(options),
|
||||||
|
defaultRetryAfter: 30,
|
||||||
|
}
|
||||||
|
);
|
||||||
onError(error);
|
onError(error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: RadarTimeSeriesResponse = await response.json();
|
|
||||||
|
|
||||||
if (!data.cacheFolders || data.cacheFolders.length === 0) {
|
if (!data.cacheFolders || data.cacheFolders.length === 0) {
|
||||||
throw new Error('No historical data found for the specified time range.');
|
throw new Error('No historical data found for the specified time range.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flatten all frames from all cache folders
|
// Flatten all frames from all cache folders
|
||||||
// The service already sets absoluteObservationTime on each frame
|
|
||||||
const allFrames: RadarFrame[] = [];
|
const allFrames: RadarFrame[] = [];
|
||||||
data.cacheFolders.forEach(cacheFolder => {
|
data.cacheFolders.forEach(cacheFolder => {
|
||||||
cacheFolder.frames.forEach(frame => {
|
cacheFolder.frames.forEach(frame => {
|
||||||
// Store folder metadata for reference (service already sets absoluteObservationTime)
|
|
||||||
frame.cacheTimestamp = cacheFolder.cacheTimestamp;
|
frame.cacheTimestamp = cacheFolder.cacheTimestamp;
|
||||||
frame.observationTime = cacheFolder.observationTime;
|
frame.observationTime = cacheFolder.observationTime;
|
||||||
frame.cacheFolderName = cacheFolder.cacheFolderName;
|
frame.cacheFolderName = cacheFolder.cacheFolderName;
|
||||||
|
|
||||||
// Resolve relative image URLs against service URL
|
|
||||||
if (frame.imageUrl) {
|
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) {
|
if (!frame.absoluteObservationTime && frame.observationTime && frame.minutesAgo !== undefined) {
|
||||||
// Fallback: calculate from observation time and minutes ago
|
|
||||||
const obsTime = new Date(frame.observationTime);
|
const obsTime = new Date(frame.observationTime);
|
||||||
frame.absoluteObservationTime = new Date(obsTime.getTime() - (frame.minutesAgo * 60 * 1000)).toISOString();
|
frame.absoluteObservationTime = new Date(obsTime.getTime() - (frame.minutesAgo * 60 * 1000)).toISOString();
|
||||||
}
|
}
|
||||||
@@ -145,39 +132,39 @@ export class RadarApiService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Re-index frames sequentially
|
// Re-index frames sequentially
|
||||||
|
allFrames.sort((a, b) => new Date(a.absoluteObservationTime!).getTime() - new Date(b.absoluteObservationTime!).getTime());
|
||||||
allFrames.forEach((frame, idx) => {
|
allFrames.forEach((frame, idx) => {
|
||||||
frame.sequentialIndex = idx;
|
frame.sequentialIndex = idx;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch latest metadata for display from /api/radar/{suburb}/{state}/metadata
|
// Fetch latest metadata for display
|
||||||
let metadata: Partial<RadarResponse> = {};
|
let metadata: Partial<RadarResponse> = {};
|
||||||
try {
|
try {
|
||||||
const metadataUrl = `${serviceUrl}/api/radar/${suburb}/${state}/metadata`;
|
metadata = await hass.callWS<RadarResponse>({
|
||||||
const metadataResponse = await fetch(metadataUrl);
|
type: 'bom_local/get_radar_data',
|
||||||
if (metadataResponse.ok) {
|
suburb,
|
||||||
metadata = await metadataResponse.json();
|
state
|
||||||
}
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.debug('Could not fetch metadata:', err);
|
console.debug('Could not fetch metadata:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create RadarResponse-like object
|
|
||||||
const newestCacheFolder = data.cacheFolders[data.cacheFolders.length - 1];
|
const newestCacheFolder = data.cacheFolders[data.cacheFolders.length - 1];
|
||||||
const radarResponse: RadarResponse = {
|
const radarResponse: RadarResponse = {
|
||||||
frames: allFrames,
|
frames: allFrames,
|
||||||
lastUpdated: endTime.toISOString(),
|
lastUpdated: endTime,
|
||||||
observationTime: metadata.observationTime || newestCacheFolder?.observationTime || endTime.toISOString(),
|
observationTime: metadata.observationTime || newestCacheFolder?.observationTime || endTime,
|
||||||
forecastTime: endTime.toISOString(),
|
forecastTime: endTime,
|
||||||
weatherStation: metadata.weatherStation,
|
weatherStation: metadata.weatherStation,
|
||||||
distance: metadata.distance,
|
distance: metadata.distance,
|
||||||
cacheIsValid: metadata.cacheIsValid ?? true,
|
cacheIsValid: metadata.cacheIsValid ?? true,
|
||||||
cacheExpiresAt: metadata.cacheExpiresAt || endTime.toISOString(),
|
cacheExpiresAt: metadata.cacheExpiresAt || endTime,
|
||||||
isUpdating: metadata.isUpdating || false,
|
isUpdating: metadata.isUpdating || false,
|
||||||
nextUpdateTime: metadata.nextUpdateTime || endTime.toISOString(),
|
nextUpdateTime: metadata.nextUpdateTime || endTime,
|
||||||
};
|
};
|
||||||
|
|
||||||
return radarResponse;
|
return radarResponse;
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
this.handleFetchError(err, options);
|
this.handleFetchError(err, options);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -187,21 +174,13 @@ export class RadarApiService {
|
|||||||
* Handles fetch errors and categorizes them appropriately
|
* Handles fetch errors and categorizes them appropriately
|
||||||
*/
|
*/
|
||||||
private handleFetchError(err: any, options: FetchRadarOptions): void {
|
private handleFetchError(err: any, options: FetchRadarOptions): void {
|
||||||
if (err instanceof TypeError && err.message.includes('fetch')) {
|
const message = err?.message || (typeof err === 'string' ? err : 'Unknown error occurred');
|
||||||
options.onError({
|
options.onError({
|
||||||
message: 'Network error: Unable to connect to service',
|
message: message,
|
||||||
type: 'network',
|
type: 'unknown',
|
||||||
retryable: true,
|
retryable: true,
|
||||||
retryAction: () => this.fetchLatestFrames(options),
|
retryAction: () => this.fetchLatestFrames(options),
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
options.onError({
|
|
||||||
message: err instanceof Error ? err.message : 'Unknown error occurred',
|
|
||||||
type: 'unknown',
|
|
||||||
retryable: true,
|
|
||||||
retryAction: () => this.fetchLatestFrames(options),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -109,7 +109,7 @@ export interface BomLocalRadarCardConfig extends LovelaceCardConfig {
|
|||||||
type: 'custom:bom-local-card';
|
type: 'custom:bom-local-card';
|
||||||
|
|
||||||
// Service configuration
|
// 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")
|
suburb: string; // Required: suburb name (e.g., "Pomona")
|
||||||
state: string; // Required: state abbreviation (e.g., "QLD")
|
state: string; // Required: state abbreviation (e.g., "QLD")
|
||||||
|
|
||||||
|
|||||||
+11
-16
@@ -1,25 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* Resolves a URL relative to the service URL if it's a relative path
|
* Resolves a URL through the Home Assistant integration proxy if it's a relative path.
|
||||||
* Service returns relative URLs like "/api/radar/.../frame/..." which need
|
* The integration provides a proxy at /api/bom_local/proxy/
|
||||||
* to be resolved against the service_url base
|
|
||||||
*/
|
*/
|
||||||
export function resolveImageUrl(url: string, serviceUrl: string): string {
|
export function resolveImageUrl(url: string, serviceUrl?: string): string {
|
||||||
if (!url) return url;
|
if (!url) return url;
|
||||||
|
|
||||||
// If URL is already absolute (starts with http:// or https://), use as-is
|
// If URL is already absolute (starts with http:// or https://), use as-is
|
||||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||||
return url;
|
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}`;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user