From 4ad308fa049df8c529ee4cc5d8534edb97d48cd5 Mon Sep 17 00:00:00 2001 From: Alex Hope-O'Connor Date: Wed, 17 Dec 2025 01:12:55 +1000 Subject: [PATCH] v0.1.1: Fix icon alignment, improve error display, and enhance UI editor --- README.md | 329 +++++- docker-compose.test.yml | 6 + package.json | 2 +- run.sh | 48 +- scripts/test.sh | 31 +- src/bom-local-radar-card.ts | 1872 +++++++++++++++++++++++++++++++---- src/const.ts | 2 +- src/editor.ts | 515 +++++++++- src/types.ts | 74 +- 9 files changed, 2611 insertions(+), 268 deletions(-) diff --git a/README.md b/README.md index 3c3370e..60096da 100644 --- a/README.md +++ b/README.md @@ -16,12 +16,19 @@ The Australian Bureau of Meteorology's radar API endpoint stopped working in Dec ## Features - ๐ŸŒง๏ธ **Live Radar Display**: View the latest BOM rain radar images for any Australian location -- ๐ŸŽฌ **Animated Slideshow**: Play through radar frames to see precipitation movement -- ๐Ÿ“Š **Historical Data**: View radar history from 1 hour to 24 hours ago -- ๐ŸŽฏ **Location-Based**: Support for any Australian suburb/state combination +- ๐ŸŽฌ **Animated Slideshow**: Play through radar frames to see precipitation movement with smooth animations +- ๐Ÿ“Š **Historical Data**: View radar history from 1 hour to 24 hours ago, or custom time ranges +- ๐ŸŽฏ **Location-Based**: Support for any Australian suburb/state combination with dropdown selection - ๐Ÿ”„ **Auto-Refresh**: Automatically updates radar data at configurable intervals -- ๐ŸŽจ **Beautiful UI**: Modern, responsive design that integrates seamlessly with Home Assistant themes -- โš™๏ธ **Visual Editor**: Full GUI configuration editor (no YAML editing required) +- ๐ŸŽจ **Modern UI**: Sleek, responsive design that integrates seamlessly with Home Assistant themes +- โš™๏ธ **Visual Editor**: Full GUI configuration editor with expandable sections (no YAML editing required) +- ๐ŸŽ›๏ธ **Flexible Display**: Granular control over metadata and controls visibility +- ๐Ÿ–ผ๏ธ **Image Customization**: Zoom (0.5x to 3.0x) and fit options (contain/cover/fill) +- ๐Ÿ“ **Overlay Options**: Overlay controls and metadata on images to save space +- โŒจ๏ธ **Keyboard Navigation**: Full keyboard support (arrow keys, spacebar, home/end) +- โ™ฟ **Accessible**: ARIA labels and screen reader support +- ๐Ÿ” **Enhanced Error Handling**: Detailed error messages with retry suggestions and auto-retry +- ๐Ÿ“ฑ **Responsive**: Optimized for mobile, tablet, and desktop ## Prerequisites @@ -138,14 +145,37 @@ For more detailed service setup and configuration options, see the [BOM Local Se **Service Configuration**: - **Service URL**: Base URL of your BOM Local Service (default: `http://localhost:8082`) - **Suburb**: The suburb name (e.g., `Pomona`, `Brisbane`) - **Required** - - **State**: State abbreviation (e.g., `QLD`, `NSW`, `VIC`) - **Required** + - **State**: State dropdown - Select from all Australian states - **Required** **Display**: - - **Card Title**: Optional custom title for the card - - **Show Metadata**: Toggle to show/hide cache status, observation time, and weather station info (default: `true`) + - **Show Card Title**: Toggle to show/hide card title (uses HA card header) + - **Card Title**: Custom title for the card (only shown if "Show Card Title" is enabled) + - **Show Metadata**: Toggle to show/hide metadata section (expandable for granular control) + - When expanded, you can control individual metadata items: + - Cache Status + - Observation Time + - Forecast Time + - Weather Station + - Distance + - Next Update + - Frame Times + - **Metadata Position**: Choose where to display metadata (Above Image, Below Image, Overlay on Image) + - **Metadata Style**: Choose display style (Cards, Compact, Minimal) + - **Show Controls**: Toggle to show/hide controls section (expandable for granular control) + - When expanded, you can control individual controls: + - Play/Pause Button + - Previous/Next Buttons + - Frame Slider + - Navigation Buttons (-10, +10, First, Last) + - Frame Info + - **Overlay Controls on Image**: Toggle to overlay controls on the radar image + - **Overlay Position**: Choose overlay position (Top, Bottom, Left, Right, Center) + - **Overlay Opacity**: Control overlay transparency (0.0 to 1.0) + - **Image Zoom**: Zoom level for radar images (0.5 = 50%, 1.0 = 100%, 2.0 = 200%, range: 0.5-3.0) + - **Image Fit**: How images fit in container (Contain, Cover, Fill) **Slideshow**: - - **Timespan**: Select historical data range - `latest` (Latest 7 frames), `1h`, `3h`, `6h`, `12h`, or `24h` (default: `latest`) + - **Timespan**: Select historical data range - `latest` (Latest 7 frames), `1h`, `3h`, `6h`, `12h`, `24h`, or `custom` (default: `latest`) - **Frame Interval**: Seconds between frames during animation (default: `2.0`, range: 0.5-10) - **Auto Play**: Automatically start animation when data loads (default: `true`) @@ -183,20 +213,75 @@ custom_end_time: "2024-01-15T14:00:00Z" ### Configuration Options +#### Service Configuration + | Option | Type | Default | Required | Description | |--------|------|---------|----------|-------------| | `service_url` | string | `http://localhost:8082` | No | Base URL of the BOM Local Service | | `suburb` | string | - | **Yes** | Suburb name (e.g., "Pomona", "Brisbane") | -| `state` | string | - | **Yes** | State abbreviation (e.g., "QLD", "NSW", "VIC") | -| `card_title` | string | - | No | Custom title displayed at the top of the card | -| `show_metadata` | boolean | `true` | No | Show/hide cache status, observation time, and weather station info | +| `state` | string | - | **Yes** | State abbreviation (e.g., "QLD", "NSW", "VIC") - Use dropdown in editor | + +#### Display Options + +| Option | Type | Default | Required | Description | +|--------|------|---------|----------|-------------| +| `show_card_title` | boolean | `true` | No | Show/hide card title (uses HA card header) | +| `card_title` | string | - | No | Custom title displayed in card header | +| `show_metadata` | boolean \| object | `true` | No | Show/hide metadata. Can be `true`/`false` or object for granular control (see below) | +| `show_controls` | boolean \| object | `true` | No | Show/hide controls. Can be `true`/`false` or object for granular control (see below) | +| `image_zoom` | number | `1.0` | No | Image zoom level: 0.5 = 50%, 1.0 = 100%, 2.0 = 200% (range: 0.5-3.0) | +| `image_fit` | string | `contain` | No | How image fits in container: `contain`, `cover`, or `fill` | +| `overlay_controls` | boolean | `false` | No | Overlay controls on the radar image | +| `overlay_position` | string | `bottom` | No | Overlay position: `top`, `bottom`, `left`, `right`, or `center` | +| `overlay_opacity` | number | `0.9` | No | Overlay opacity (range: 0.0-1.0) | + +#### Metadata Display Configuration (when `show_metadata` is an object) + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `show_cache_status` | boolean | `true` | Show cache validity status | +| `show_observation_time` | boolean | `true` | Show observation time (absolute and relative) | +| `show_forecast_time` | boolean | `true` | Show forecast time | +| `show_weather_station` | boolean | `true` | Show weather station name | +| `show_distance` | boolean | `true` | Show distance to weather station | +| `show_next_update` | boolean | `true` | Show next update time | +| `show_frame_times` | boolean | `true` | Show frame observation times | +| `position` | string | `above` | Where to display: `above`, `below`, or `overlay` | +| `style` | string | `cards` | Display style: `cards`, `compact`, or `minimal` | + +#### Controls Display Configuration (when `show_controls` is an object) + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `show_play_pause` | boolean | `true` | Show play/pause button | +| `show_prev_next` | boolean | `true` | Show previous/next buttons | +| `show_slider` | boolean | `true` | Show frame slider | +| `show_nav_buttons` | boolean | `true` | Show navigation buttons (First, -10, +10, Last) | +| `show_frame_info` | boolean | `true` | Show frame information (frame number, timestamp) | +| `position` | string | `below` | Where to display: `above`, `below`, or `overlay` | + +#### Slideshow Configuration + +| Option | Type | Default | Required | Description | +|--------|------|---------|----------|-------------| | `timespan` | string | `latest` | No | Historical data timespan: `latest`, `1h`, `3h`, `6h`, `12h`, `24h`, or `custom` | | `frame_interval` | number | `2.0` | No | Seconds between frames during animation (range: 0.5-10) | | `auto_play` | boolean | `true` | No | Automatically start animation when data loads | -| `refresh_interval` | number | `30` | No | Seconds between automatic data refreshes (range: 10-300) | | `custom_start_time` | string | - | No | ISO 8601 datetime for custom timespan start (requires `timespan: custom`) | | `custom_end_time` | string | - | No | ISO 8601 datetime for custom timespan end (requires `timespan: custom`) | +#### Auto-Refresh Configuration + +| Option | Type | Default | Required | Description | +|--------|------|---------|----------|-------------| +| `refresh_interval` | number | `30` | No | Seconds between automatic data refreshes (range: 10-300) | + +#### Localization + +| Option | Type | Default | Required | Description | +|--------|------|---------|----------|-------------| +| `locale` | string | HA locale | No | Override locale for date/time formatting (e.g., "en-AU", "en-US") | + ## Usage Examples ### Basic Configuration @@ -210,6 +295,81 @@ state: QLD service_url: http://192.168.1.100:8082 ``` +### Minimal Display (Image Only) + +Show just the radar image with no controls or metadata: + +```yaml +type: custom:bom-local-radar-card +suburb: Brisbane +state: QLD +show_metadata: false +show_controls: false +``` + +### Overlay Controls on Image + +Overlay controls on the radar image to save space: + +```yaml +type: custom:bom-local-radar-card +suburb: Brisbane +state: QLD +overlay_controls: true +overlay_position: bottom +overlay_opacity: 0.85 +show_metadata: + position: overlay + style: minimal +``` + +### Image Zoom + +Zoom in on the radar image (may pixelate at higher zoom levels): + +```yaml +type: custom:bom-local-radar-card +suburb: Brisbane +state: QLD +image_zoom: 1.5 +image_fit: contain +``` + +### Granular Metadata Control + +Show only specific metadata items: + +```yaml +type: custom:bom-local-radar-card +suburb: Brisbane +state: QLD +show_metadata: + show_cache_status: true + show_observation_time: true + show_weather_station: false + show_distance: false + show_next_update: false + show_frame_times: true + position: above + style: compact +``` + +### Granular Controls Control + +Show only specific controls: + +```yaml +type: custom:bom-local-radar-card +suburb: Brisbane +state: QLD +show_controls: + show_play_pause: true + show_slider: true + show_frame_info: true + show_nav_buttons: false + show_prev_next: false +``` + ### Historical Data (Last 3 Hours) View radar history from the past 3 hours: @@ -265,7 +425,7 @@ refresh_interval: 60 ## Controls -The card provides several controls for navigating radar frames: +The card provides several controls for navigating radar frames (all can be individually shown/hidden): - **Play/Pause Button**: Start or stop the animation - **Previous/Next Buttons**: Navigate to the previous or next frame @@ -274,8 +434,31 @@ The card provides several controls for navigating radar frames: - โฎ First frame - -10 / +10: Jump backward/forward by 10 frames - โญ Last frame +- **Frame Info**: Displays frame number, total frames, observation time, and progress percentage -The card displays frame information including frame number, total frames, and timestamp. +### Keyboard Navigation + +The card supports full keyboard navigation when focused: + +- `โ†` / `โ†’`: Navigate to previous/next frame +- `Space`: Play/pause animation +- `Home`: Jump to first frame +- `End`: Jump to last frame + +### Error Handling + +The card provides enhanced error handling with intelligent retry logic: + +- **Structured Error Messages**: Detailed error information from the service with error codes and types +- **Smart Auto-Retry**: Automatically retries failed requests based on service recommendations: + - **Normal retries**: Auto-retries after service-suggested delay (typically 30-120 seconds) + - **Manual refresh recommended**: When previous update failed, shows message and skips auto-retry + - **Network issues**: Suggests checking network before retrying + - **Time range errors**: No auto-retry, suggests adjusting time range +- **Error Details**: Shows error codes, previous update failures, available data ranges, and refresh endpoints +- **Retry Button**: Manual retry option - text changes to "Retry Anyway" when manual refresh is recommended +- **Better Cache Messages**: Clear messages for fresh start scenarios, cache generation status, and update progress +- **Service Integration**: Respects service-calculated retry times and action recommendations ## Development @@ -411,6 +594,50 @@ To test the card against a local version of the BOM Local Service (instead of th This builds the service from your local source and uses it in the test environment. +## Changelog + +### Version 0.1.0 (Current) + +**Major Improvements:** +- โœจ **Enhanced Error Handling**: Full support for structured API error responses with detailed error codes, retry suggestions, and intelligent auto-retry functionality +- ๐Ÿ”„ **Smart Retry Logic**: Action-based retry behavior - respects service recommendations for when to auto-retry vs. manual refresh +- ๐ŸŽจ **Redesigned Editor**: Complete overhaul of the visual editor with native dropdowns, better layout, and expandable sections +- ๐Ÿ–ผ๏ธ **Image Display Fixes**: Fixed image centering issues and added zoom/fit options +- ๐ŸŽ›๏ธ **Granular Configuration**: Fine-grained control over metadata and controls visibility +- ๐Ÿ“ **Overlay Support**: Overlay controls and metadata on images to save dashboard space +- โŒจ๏ธ **Keyboard Navigation**: Full keyboard support for accessibility +- โ™ฟ **Accessibility**: ARIA labels and screen reader support +- ๐Ÿ” **Better Error Messages**: Detailed error information including previous update failures and available data ranges + +**New Configuration Options:** +- `show_card_title`: Control card title visibility +- `show_metadata`: Object-based configuration for granular metadata control +- `show_controls`: Object-based configuration for granular controls control +- `image_zoom`: Zoom images from 0.5x to 3.0x +- `image_fit`: Control how images fit (contain/cover/fill) +- `overlay_controls`: Overlay controls on image +- `overlay_position`: Control overlay position +- `overlay_opacity`: Control overlay transparency + +**Bug Fixes:** +- Fixed editor dropdown crashes +- Fixed image centering issues +- Fixed editor layout problems +- Improved error handling for fresh cache scenarios + +**Service Integration:** +- Enhanced compatibility with service's action-based error recommendations +- Proper handling of `manual_refresh_recommended` action type +- Respects service-calculated retry times based on update progress +- Displays refresh endpoint URLs in error details + +### Version 0.0.1 (Initial Release) + +- Initial release with basic radar display functionality +- Support for latest frames and historical data +- Basic configuration options +- Visual editor support + ## License MIT License - see [LICENSE](LICENSE) file for details @@ -427,20 +654,61 @@ MIT License - see [LICENSE](LICENSE) file for details - Ensure both `suburb` and `state` are configured - Verify the configuration using the visual editor -### Card Shows "Failed to fetch radar data" or "Cache not ready" +### Card Shows Error Messages -- **Check BOM Local Service is running**: Verify the service is accessible at the configured `service_url` -- **Verify Service URL**: Ensure the URL is correct and reachable from your Home Assistant instance -- **Check Cache Status**: The service may be generating the cache for your location. Wait a minute and refresh -- **Network Access**: If the service is on a different machine, ensure network connectivity and firewall rules allow access +The card now provides detailed error messages with intelligent retry behavior: + +- **"Cache Not Ready" / "CACHE_NOT_FOUND"**: + - This is normal for fresh installations or new locations + - The service automatically triggers a cache update in the background + - The card will auto-retry after the service-suggested time (typically 30-120 seconds, calculated based on update progress) + - You can manually retry using the "Retry Now" button + - **Check BOM Local Service is running**: Verify the service is accessible at the configured `service_url` + - **Verify Service URL**: Ensure the URL is correct and reachable from your Home Assistant instance + - **Wait for Cache Generation**: First-time cache generation takes 30-60 seconds + +- **"Previous Update Failed" / "Manual Refresh Recommended"**: + - A previous cache update attempt failed + - The error details will show the specific failure reason and error code + - **Auto-retry is disabled** - the card shows "Manual refresh recommended" message + - The retry button changes to "Retry Anyway" if you want to force a retry + - Check service logs for more details about the failure + - The error details include a refresh endpoint URL for manual cache refresh + +- **"TIME_RANGE_ERROR"**: + - The requested time range exceeds available data or maximum allowed duration + - Error details show available data range and requested range + - **Auto-retry is disabled** - adjust your timespan to match available data + - The service suggests the available time range in error details + +- **Network Errors**: + - Check network connectivity between Home Assistant and the service + - Verify firewall rules allow access + - Check CORS configuration if accessing from browser + - The service may suggest waiting longer before retrying network-related errors + +**Understanding Error Actions:** +- The service provides an `action` field in error responses that guides retry behavior: + - `retry_after_seconds`: Card will auto-retry after the suggested delay + - `manual_refresh_recommended`: Card shows message but doesn't auto-retry (previous update failed) + - `check_network_and_retry`: Suggests checking network before retrying + - `adjust_time_range`: No auto-retry, suggests adjusting time range ### Card Shows "Radar data not found" - The cache may not be available for your location yet -- Trigger a cache update via the BOM Local Service API: - ```bash - curl -X POST http://your-service-url/api/cache/YourSuburb/YourState/refresh - ``` +- The card will automatically retry after the service-suggested delay +- If auto-retry is disabled (manual refresh recommended), you can: + - Click "Retry Anyway" button in the error message + - Or trigger a cache update via the BOM Local Service API: + ```bash + curl -X POST http://your-service-url/api/cache/YourSuburb/YourState/refresh + ``` + - The error details include the refresh endpoint URL for your location +- Check the error message details for specific information about cache status, including: + - Whether an update is in progress + - When the next update is expected + - Previous update failure reasons (if applicable) ### Images Don't Load @@ -453,6 +721,19 @@ MIT License - see [LICENSE](LICENSE) file for details - Check that `auto_play` is set to `true` (default) - Verify frames are loading (check frame count display) - Try manually clicking the Play button +- Check that controls are visible (may be hidden via `show_controls` configuration) + +### Editor Issues + +- **Dropdowns not working**: The editor now uses native HTML select elements for better compatibility +- **Layout looks jumbled**: The editor has been redesigned with better spacing and organization +- **State selection**: Use the dropdown to select from all Australian states (no need to type abbreviations) + +### Image Display Issues + +- **Image not centered**: This has been fixed in the latest version +- **Image too small/large**: Use the `image_zoom` option (0.5 to 3.0) to adjust size +- **Image doesn't fit properly**: Try different `image_fit` options (`contain`, `cover`, `fill`) ### Service URL Configuration diff --git a/docker-compose.test.yml b/docker-compose.test.yml index e743107..4ee9644 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -5,6 +5,9 @@ services: container_name: bom-card-test-service ports: - "8082:8080" + volumes: + # Persist cache between redeploys + - ./test-ha/cache:/app/cache environment: # ASP.NET Core configuration - ASPNETCORE_ENVIRONMENT=Production @@ -20,6 +23,9 @@ services: - CACHEDIRECTORY=/app/cache - CACHERETENTIONHOURS=24 - TIMEZONE=Australia/Brisbane + # Debug output enabled by default for test environment + - DEBUG__ENABLED=true + - DEBUG__WAITMS=2000 shm_size: '1gb' ipc: host restart: unless-stopped diff --git a/package.json b/package.json index 6e067b4..350880b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bom-local-radar-card", - "version": "0.0.1", + "version": "0.1.1", "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-radar-card.js", "keywords": [ diff --git a/run.sh b/run.sh index 311a244..664ffb1 100755 --- a/run.sh +++ b/run.sh @@ -100,6 +100,20 @@ clean_test() { fi fi + # Clean up cache directory (persisted between redeploys, but removed on full clean) + if [ -d "test-ha/cache" ]; then + echo " Removing cache directory..." + if ! rm -rf test-ha/cache 2>/dev/null; then + # Use Docker to remove root-owned files + docker run --rm \ + -v "$(pwd)/test-ha/cache:/cache:rw" \ + -u root \ + alpine:latest \ + sh -c "rm -rf /cache/*" 2>/dev/null || true + fi + echo " โœ… Cleared cache directory" + fi + # Optionally clean build artifacts if [ "${CLEAN_BUILD:-0}" = "1" ]; then echo " Removing build artifacts..." @@ -110,6 +124,33 @@ clean_test() { echo "โœ… Cleanup complete. Run './run.sh test' to start fresh." } +# Stop function - stops containers without removing data +stop_test() { + echo "๐Ÿ›‘ Stopping test environment (preserving cache and data)..." + + # Determine compose files to use + COMPOSE_FILES="-f docker-compose.test.yml" + if [ -f "docker-compose.test.local.yml" ]; then + COMPOSE_FILES="$COMPOSE_FILES -f docker-compose.test.local.yml" + fi + + # Stop containers (preserve volumes to keep cache) + if docker compose version &> /dev/null; then + DOCKER_COMPOSE_CMD="docker compose" + elif command -v docker-compose &> /dev/null; then + DOCKER_COMPOSE_CMD="docker-compose" + else + echo "โŒ Error: docker compose not found" + exit 1 + fi + + if $DOCKER_COMPOSE_CMD $COMPOSE_FILES down 2>/dev/null; then + echo "โœ… Test environment stopped (cache and data preserved)" + else + echo "โš ๏ธ No running containers found or error stopping containers" + fi +} + # Update function - rebuilds card and updates running test environment update_card() { local method="$1" @@ -143,6 +184,9 @@ case "$COMMAND" in test) ./scripts/test.sh ;; + stop) + stop_test + ;; update) update_card "$BUILD_METHOD" ;; @@ -155,6 +199,7 @@ case "$COMMAND" in echo "Commands:" echo " build [docker|npm] - Build the card (auto-detects method if not specified)" echo " test - Build and start test Home Assistant environment" + echo " stop - Stop test environment (preserves cache and data)" echo " update [docker|npm] - Rebuild card and update running test environment" echo " (preserves HA state, auto-detects if containers running)" echo " clean - Clean test environment (stops containers, removes data)" @@ -167,8 +212,9 @@ case "$COMMAND" in echo " ./run.sh build # Auto-detect: Docker (preferred) or npm" echo " ./run.sh build docker # Force Docker build" echo " ./run.sh test # Build and start test environment" + echo " ./run.sh stop # Stop test environment (preserves cache)" echo " ./run.sh update # Rebuild and update running environment" - echo " ./run.sh clean # Clean test environment" + echo " ./run.sh clean # Clean test environment (removes everything)" echo "" echo "Note: For more options (e.g., --service-path), use ./scripts/test.sh directly" exit 1 diff --git a/scripts/test.sh b/scripts/test.sh index 268df5c..400848e 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -389,25 +389,15 @@ if [ -f "docker-compose.test.local.yml" ]; then COMPOSE_FILES_CLEAN="$COMPOSE_FILES_CLEAN -f docker-compose.test.local.yml" fi -# Stop and remove containers with volumes -docker compose $COMPOSE_FILES_CLEAN down -v 2>/dev/null || docker-compose $COMPOSE_FILES_CLEAN down -v 2>/dev/null || true - -# Clean up .storage directory if it exists (using Docker if needed) -if [ -d "test-ha/config/.storage" ]; then - echo "๐Ÿงน Cleaning up existing .storage directory..." - if ! rm -rf test-ha/config/.storage 2>/dev/null; then - echo " Removing with Docker (files may be owned by root)..." - docker run --rm \ - -v "$(pwd)/test-ha/config:/config:rw" \ - -u root \ - alpine:latest \ - sh -c "rm -rf /config/.storage" 2>/dev/null || true - fi -fi +# Stop and remove containers (preserve volumes to keep cache) +docker compose $COMPOSE_FILES_CLEAN down 2>/dev/null || docker-compose $COMPOSE_FILES_CLEAN down 2>/dev/null || true +# Preserve .storage directory to keep user accounts and HA state +# Only create it if it doesn't exist echo "๐Ÿ“ฆ Ensuring test directories exist..." mkdir -p test-ha/config/.storage 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) @@ -486,9 +476,11 @@ done # Create onboarding bypass - skip everything EXCEPT user creation # This way HA will prompt for user creation but skip other setup steps -echo "๐Ÿ“ Creating onboarding bypass..." -mkdir -p test-ha/config/.storage -cat > test-ha/config/.storage/onboarding < test-ha/config/.storage/onboarding < test-ha/config/.storage/onboarding < void; public static async getConfigElement(): Promise { return document.createElement('bom-local-radar-card-editor') as LovelaceCardEditor; @@ -280,8 +895,58 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard { this._config = config; } + /** + * Dynamic card size based on configuration + * Returns height in units (1 unit = 50px) + */ getCardSize(): number { - return 10; + 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 + const controlsConfig = this._getControlsConfig(); + if (controlsConfig && typeof controlsConfig !== 'boolean') { + if (controlsConfig.position !== 'overlay' && !this._config.overlay_controls) { + size += 2; // +100px for controls + } + } else if (controlsConfig === true && !this._config.overlay_controls) { + size += 2; + } + + return size; + } + + /** + * Define grid options for HA's sections view + * This allows the card to integrate with HA's grid system + */ + public getGridOptions(): GridOptions { + // 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 + }; } /** @@ -336,42 +1001,128 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard { }); if (!response.ok) { - if (response.status === 404) { - const errorData = await response.json().catch(() => ({})); - if (errorData.retryAfter) { - this.error = `Cache not ready. Retry in ${errorData.retryAfter} seconds.`; - // Clear any existing retry timer - if (this.retryTimer) { - clearTimeout(this.retryTimer); + // Try to parse structured error response + let errorData: ApiErrorResponse | any; + let parsedJson = false; + try { + errorData = await response.json(); + parsedJson = true; + } catch { + // If JSON parsing fails, create a basic error structure + errorData = { + message: response.statusText || 'Unknown error', + errorCode: response.status === 404 ? 'NOT_FOUND' : 'HTTP_ERROR', + errorType: 'ServiceError' + }; + } + + // Handle structured API error response (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 || {}; + + // Always use structured error if we parsed JSON, or if we have any error information + if (parsedJson || errorCode || message || Object.keys(details).length > 0) { + const retryAfter = suggestions.retryAfter as number || + details.retryAfter as number || 30; + const action = suggestions.action as string || 'retry_after_seconds'; + + // Build enhanced error message based on error details + 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.'; } - this.retryTimer = setTimeout(() => { + } + + // 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 if we should auto-retry based on action + const shouldAutoRetry = action !== 'manual_refresh_recommended' && + action !== 'check_network_and_retry'; + + this.error = { + message: enhancedMessage, + type: this._mapErrorType(errorType || errorCode || (response.status === 404 ? 'CACHE_NOT_FOUND' : 'unknown')), + retryable: response.status === 404 && (errorCode === 'CACHE_NOT_FOUND' || errorCode === 'NOT_FOUND' || !errorCode), + retryAction: () => this.fetchRadarData(), + retryAfter: shouldAutoRetry ? retryAfter : undefined, + errorCode: errorCode || (response.status === 404 ? 'CACHE_NOT_FOUND' : `HTTP_${response.status}`), + details: { + ...details, + action: action, + refreshEndpoint: suggestions.refreshEndpoint as string, + statusEndpoint: suggestions.statusEndpoint as string, + }, + }; + + // Auto-retry only if action suggests it and retryAfter is available + if (this.error.retryable && shouldAutoRetry && retryAfter) { + if (this.retryTimer) { + window.clearTimeout(this.retryTimer); + } + this.retryTimer = window.setTimeout(() => { this.retryTimer = undefined; this.fetchRadarData(); - }, errorData.retryAfter * 1000); - return null; + }, retryAfter * 1000); } - throw new Error('Radar data not found. Cache may be updating.'); + return null; } - throw new Error(`HTTP ${response.status}: ${response.statusText}`); + + // Fallback for non-structured errors + throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`); } const data: any = await response.json(); - // Check if response contains an error (cache not ready, etc.) + // Check if response contains an error (shouldn't happen with structured errors, but handle legacy format) if (data.error) { - if (data.retryAfter) { - this.error = `Cache not ready. Retry in ${data.retryAfter} seconds.`; - // Clear any existing retry timer - if (this.retryTimer) { - clearTimeout(this.retryTimer); - } - this.retryTimer = setTimeout(() => { - this.retryTimer = undefined; - this.fetchRadarData(); - }, data.retryAfter * 1000); - return null; + const retryAfter = data.retryAfter || 30; + this.error = { + message: data.error || 'Service returned an error', + type: 'cache', + retryable: true, + retryAction: () => this.fetchRadarData(), + retryAfter: retryAfter, + }; + if (this.retryTimer) { + window.clearTimeout(this.retryTimer); } - throw new Error(data.error || 'Service returned an error'); + this.retryTimer = window.setTimeout(() => { + this.retryTimer = undefined; + this.fetchRadarData(); + }, retryAfter * 1000); + return null; } // Validate response has frames @@ -406,7 +1157,31 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard { } } catch (err) { this.isLoading = false; - this.error = err instanceof Error ? err.message : 'Failed to fetch radar data'; + + // Categorize errors + if (err instanceof TypeError && err.message.includes('fetch')) { + this.error = { + message: 'Network error: Unable to connect to service', + type: 'network', + retryable: true, + retryAction: () => this.fetchRadarData(), + }; + } else if (err instanceof Error && err.message.includes('Cache')) { + this.error = { + message: err.message, + type: 'cache', + retryable: true, + retryAction: () => this.fetchRadarData(), + }; + } else { + this.error = { + message: err instanceof Error ? err.message : 'Unknown error occurred', + type: 'unknown', + retryable: true, + retryAction: () => this.fetchRadarData(), + }; + } + console.error('Error fetching radar data:', err); return null; } @@ -451,6 +1226,95 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard { }); if (!response.ok) { + // Try to parse structured error response + let errorData: ApiErrorResponse | any; + let parsedJson = false; + try { + errorData = await response.json(); + parsedJson = true; + } catch { + // If JSON parsing fails, create a basic error structure + errorData = { + message: response.statusText || 'Unknown error', + errorCode: response.status === 404 ? 'NOT_FOUND' : 'HTTP_ERROR', + errorType: 'ServiceError' + }; + } + + // Handle structured API error response (support both camelCase and PascalCase) + const errorCode = errorData.errorCode || errorData.ErrorCode; + const message = errorData.message || errorData.Message || response.statusText || 'Failed to fetch historical radar data'; + const errorType = errorData.errorType || errorData.ErrorType; + const details = errorData.details || errorData.Details || {}; + const suggestions = errorData.suggestions || errorData.Suggestions || {}; + + // Always use structured error if we parsed JSON, or if we have any error information + if (parsedJson || errorCode || message || Object.keys(details).length > 0) { + const retryAfter = suggestions.retryAfter as number || 30; + const action = suggestions.action as string || 'retry_after_seconds'; + + // Build enhanced error message based on error details + 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 if we should auto-retry based on action + const shouldAutoRetry = action !== 'manual_refresh_recommended' && + action !== 'check_network_and_retry' && + action !== 'adjust_time_range'; + + this.error = { + message: enhancedMessage, + type: this._mapErrorType(errorType || errorCode || (response.status === 404 ? 'CACHE_NOT_FOUND' : 'unknown')), + retryable: response.status === 404 || response.status === 400, + retryAction: () => this.fetchRadarData(), + retryAfter: shouldAutoRetry ? retryAfter : undefined, + errorCode: errorCode || (response.status === 404 ? 'CACHE_NOT_FOUND' : `HTTP_${response.status}`), + details: { + ...details, + action: action, + refreshEndpoint: suggestions.refreshEndpoint as string, + statusEndpoint: suggestions.statusEndpoint as string, + suggestedRange: suggestions.suggestedRange as any, + }, + }; + return null; + } + + // Fallback: should not reach here if JSON was parsed, but handle gracefully throw new Error(`HTTP ${response.status}: ${response.statusText}`); } @@ -461,16 +1325,27 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard { } // 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 = this.resolveImageUrl(frame.imageUrl, serviceUrl); } + + // 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(); + } + allFrames.push(frame); }); }); @@ -524,7 +1399,24 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard { return radarResponse; } catch (err) { this.isLoading = false; - this.error = err instanceof Error ? err.message : 'Failed to fetch historical radar data'; + + // Categorize errors + if (err instanceof TypeError && err.message.includes('fetch')) { + this.error = { + message: 'Network error: Unable to connect to service', + type: 'network', + retryable: true, + retryAction: () => this.fetchRadarData(), + }; + } else { + this.error = { + message: err instanceof Error ? err.message : 'Failed to fetch historical radar data', + type: 'unknown', + retryable: true, + retryAction: () => this.fetchRadarData(), + }; + } + console.error('Error fetching historical radar data:', err); return null; } @@ -541,14 +1433,123 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard { return frame?.imageUrl || null; } + /** + * Helper to get metadata config + */ + private _getMetadataConfig(): boolean | MetadataDisplayConfig | undefined { + const showMetadata = this._config.show_metadata; + if (showMetadata === undefined) { + return true; // Default: show all metadata + } + return showMetadata; + } + + /** + * Helper to get controls config + */ + private _getControlsConfig(): boolean | ControlsDisplayConfig | undefined { + const showControls = this._config.show_controls; + if (showControls === undefined) { + return true; // Default: show all controls + } + return showControls; + } + + /** + * Check if metadata should be shown + */ + private _shouldShowMetadata(): boolean { + const config = this._getMetadataConfig(); + return config !== false; + } + + /** + * Check if controls should be shown + */ + private _shouldShowControls(): boolean { + const config = this._getControlsConfig(); + return config !== false; + } + + /** + * Get locale for formatting + */ + private _getLocale(): string { + return this._config.locale || this.hass?.locale?.language || 'en-AU'; + } + + /** + * Map API error type to card error type + */ + private _mapErrorType(apiErrorType: string): ErrorState['type'] { + 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'; + } + + /** + * Get error title based on error type + */ + private _getErrorTitle(): string { + switch (this.error?.type) { + case 'cache': + return 'Cache Not Ready'; + case 'network': + return 'Connection Error'; + case 'validation': + return 'Configuration Error'; + default: + return 'Error'; + } + } + + /** + * Get error icon based on error type + */ + private _getErrorIcon(): string { + switch (this.error?.type) { + case 'cache': + return 'mdi:database-refresh'; + case 'network': + return 'mdi:wifi-off'; + case 'validation': + return 'mdi:alert-circle'; + default: + return 'mdi:alert'; + } + } + + /** + * Get error color based on error type + */ + private _getErrorColor(): string { + switch (this.error?.type) { + case 'cache': + return 'var(--warning-color, #ff9800)'; + case 'network': + return 'var(--error-color, #f44336)'; + case 'validation': + return 'var(--error-color, #f44336)'; + default: + return 'var(--warning-color, #ff9800)'; + } + } + /** * Formats timestamp for display */ private formatTimestamp(isoString: string): string { if (!isoString) return '-'; const date = new Date(isoString); - return date.toLocaleString('en-AU', { - timeZone: 'Australia/Brisbane', + const locale = this._getLocale(); + const timeZone = this.hass?.config?.time_zone || 'Australia/Brisbane'; + + return date.toLocaleString(locale, { + timeZone: timeZone, year: 'numeric', month: 'short', day: 'numeric', @@ -558,8 +1559,36 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard { } /** - * Preloads all frame images to prevent jiggle when switching + * Relative time (e.g., "5 min ago", "2 hours ago") + */ + private formatRelativeTime(isoString: string): string { + 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`; + } + + /** + * Check if frame should be preloaded (only nearby frames) + */ + private _shouldPreloadFrame(index: number): boolean { + 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 */ private preloadImages(frames: RadarFrame[]): void { // Clean up old preloaded images @@ -570,16 +1599,32 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard { }); this.preloadedImages = []; - // Preload new images - frames.forEach(frame => { - const img = new Image(); - img.src = frame.imageUrl; - this.preloadedImages.push(img); + // Only preload nearby frames + frames.forEach((frame, index) => { + if (this._shouldPreloadFrame(index)) { + const img = new Image(); + img.src = frame.imageUrl; + this.preloadedImages.push(img); + } }); } /** - * Starts the frame animation loop + * Debounce helper + */ + private _debounce any>( + func: T, + wait: number + ): (...args: Parameters) => void { + let timeout: number | undefined; + return (...args: Parameters) => { + 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 */ private startAnimation(): void { @@ -595,9 +1640,10 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard { const restartDelay = DEFAULT_RESTART_DELAY; const maxFrame = this.frames.length - 1; - // Use a single consistent timer approach (setTimeout chain, not setInterval) - // This prevents timer accumulation and makes cleanup easier - const animate = () => { + let lastFrameTime = performance.now(); + let frameStartTime = lastFrameTime; + + const animate = (currentTime: number) => { // Check if we're still playing (might have been stopped) if (!this.isPlaying) { return; @@ -608,23 +1654,31 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard { return; } + const elapsed = currentTime - lastFrameTime; + if (this.currentFrameIndex >= maxFrame) { - // Pause before restarting - this.animationTimer = setTimeout(() => { - if (!this.isPlaying) return; + // Check if we've waited long enough before restarting + if (elapsed >= restartDelay) { this.currentFrameIndex = 0; this.requestUpdate(); - this.animationTimer = setTimeout(animate, frameInterval); - }, restartDelay); + frameStartTime = currentTime; + lastFrameTime = currentTime; + } } else { - this.currentFrameIndex++; - this.requestUpdate(); - this.animationTimer = setTimeout(animate, frameInterval); + // 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 immediately - this.animationTimer = setTimeout(animate, 0); + // Start animation + this.animationTimer = requestAnimationFrame(animate); } /** @@ -632,8 +1686,7 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard { */ private stopAnimation(): void { if (this.animationTimer) { - clearInterval(this.animationTimer); - clearTimeout(this.animationTimer); + cancelAnimationFrame(this.animationTimer); this.animationTimer = undefined; } this.isPlaying = false; @@ -692,11 +1745,11 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard { */ private startAutoRefresh(): void { if (this.refreshTimer) { - clearInterval(this.refreshTimer); + window.clearInterval(this.refreshTimer); } const refreshInterval = (this._config.refresh_interval || DEFAULT_REFRESH_INTERVAL) * 1000; - this.refreshTimer = setInterval(() => { + this.refreshTimer = window.setInterval(() => { this.fetchRadarData(); }, refreshInterval); } @@ -706,7 +1759,7 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard { */ private stopAutoRefresh(): void { if (this.refreshTimer) { - clearInterval(this.refreshTimer); + window.clearInterval(this.refreshTimer); this.refreshTimer = undefined; } } @@ -718,16 +1771,18 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard { public override connectedCallback(): void { super.connectedCallback(); + this.addEventListener('keydown', this._handleKeyDown); } public override disconnectedCallback(): void { super.disconnectedCallback(); + this.removeEventListener('keydown', this._handleKeyDown); this.stopAnimation(); this.stopAutoRefresh(); // Clean up retry timer if (this.retryTimer) { - clearTimeout(this.retryTimer); + window.clearTimeout(this.retryTimer); this.retryTimer = undefined; } @@ -740,6 +1795,39 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard { this.preloadedImages = []; } + /** + * Handle keyboard navigation + */ + private _handleKeyDown = (e: KeyboardEvent): void => { + // Only handle if card is focused or contains focused element + if (!this.shadowRoot?.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; + } + }; + protected override updated(changedProperties: Map): void { super.updated(changedProperties); @@ -772,35 +1860,361 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard { } } - protected override render(): TemplateResult { - if (!this._config) { - return html`Configuration error`; + /** + * Render metadata section + */ + private _renderMetadata(position: 'above' | 'below' | 'overlay'): TemplateResult | string { + const config = this._getMetadataConfig(); + if (!config || (typeof config === 'boolean' && !config)) { + return ''; } - const cardTitle = this._config.card_title - ? html`
${this._config.card_title}
` - : ''; - - if (this.error) { - return html` - - ${cardTitle} -
${this.error}
-
- `; - } - - const currentFrameUrl = this.getCurrentFrameUrl(); - const currentFrame = this.frames[this.currentFrameIndex]; + const displayConfig = typeof config === 'object' ? config : {}; + const configPosition = displayConfig.position || 'above'; + // Only render if position matches + if (configPosition !== position) { + return ''; + } + + // If overlay, render differently + if (position === 'overlay') { + return this._renderOverlayMetadata(); + } + + const style = displayConfig.style || 'cards'; + + return html` + + `; + } + + /** + * Render cache status + */ + private _renderCacheStatus(): TemplateResult { + if (!this.radarData) return html``; + return html` + + `; + } + + /** + * Render observation time + */ + private _renderObservationTime(): TemplateResult { + if (!this.radarData?.observationTime) return html``; + return html` + + `; + } + + /** + * Render forecast time + */ + private _renderForecastTime(): TemplateResult { + if (!this.radarData?.forecastTime) return html``; + return html` + + `; + } + + /** + * Render weather station + */ + private _renderWeatherStation(): TemplateResult { + if (!this.radarData?.weatherStation) return html``; + return html` + + `; + } + + /** + * Render distance + */ + private _renderDistance(): TemplateResult { + if (!this.radarData?.distance) return html``; + return html` + + `; + } + + /** + * Render next update time + */ + private _renderNextUpdate(): TemplateResult { + if (!this.radarData?.nextUpdateTime) return html``; + return html` + + `; + } + + /** + * Render overlay metadata + */ + private _renderOverlayMetadata(): TemplateResult { + const config = this._getMetadataConfig(); + if (!config || typeof config !== 'object' || config.position !== 'overlay') { + return html``; + } + + const opacity = this._config.overlay_opacity ?? 0.85; + const position = this._config.overlay_position || 'top'; + + return html` + + `; + } + + /** + * Render radar image with zoom and overlay support + */ + private _renderRadarImage(): TemplateResult { + const zoom = this._config.image_zoom || 1.0; + const fit = this._config.image_fit || 'contain'; + const overlayControls = this._config.overlay_controls || false; + const currentFrameUrl = this.getCurrentFrameUrl(); + + return html` +
+ ${this.isLoading + ? html` +
+ +
Loading radar data...
+
+ ` + : currentFrameUrl + ? html` + Radar frame ${this.currentFrameIndex} + ${overlayControls ? this._renderOverlayControls() : ''} + ${this._renderOverlayMetadata()} + ` + : '' + } +
+ `; + } + + /** + * Render overlay controls + */ + private _renderOverlayControls(): TemplateResult { + const config = this._getControlsConfig(); + if (!config || (typeof config === 'boolean' && !config)) { + return html``; + } + + const displayConfig = typeof config === 'object' ? config : {}; + const position = this._config.overlay_position || 'bottom'; + const opacity = this._config.overlay_opacity ?? 0.9; + + return html` +
+ ${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() : ''} +
+ `; + } + + /** + * Render controls section + */ + private _renderControls(): TemplateResult { + const config = this._getControlsConfig(); + if (!config || (typeof config === 'boolean' && !config)) { + return html``; + } + + // If overlay is enabled, controls are rendered in overlay + if (this._config.overlay_controls) { + return html``; + } + + const displayConfig = typeof config === 'object' ? config : {}; + const position = displayConfig.position || 'below'; + + return html` +
+ ${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() : ''} +
+ `; + } + + /** + * Render frame slider + */ + private _renderFrameSlider(): TemplateResult { + if (this.frames.length === 0) return html``; + + return html` +
+
+ +
+
+ `; + } + + /** + * Render navigation buttons + */ + private _renderNavButtons(): TemplateResult { + if (this.frames.length === 0) return html``; + + return html` +
+ + + + +
+ `; + } + + /** + * Render play/pause button + */ + private _renderPlayPause(): TemplateResult { + return html` + + `; + } + + /** + * Render previous/next buttons + */ + private _renderPrevNext(): TemplateResult { + return html` +
+ + +
+ `; + } + + /** + * Render frame info with observation times + */ + private _renderFrameInfo(): TemplateResult { + const currentFrame = this.frames[this.currentFrameIndex]; + if (!currentFrame) return html``; + + const config = this._getMetadataConfig(); + const showFrameTimes = config && typeof config === 'object' && config.show_frame_times !== false; + // Format frame info let frameInfoText = ''; - if (currentFrame) { - if (this.isExtendedMode && currentFrame.absoluteObservationTime) { - frameInfoText = `Frame ${currentFrame.sequentialIndex ?? this.currentFrameIndex} of ${this.frames.length - 1} โ€ข ${this.formatTimestamp(currentFrame.absoluteObservationTime)}`; - } else { - frameInfoText = `Frame ${currentFrame.frameIndex} of ${this.frames.length - 1} โ€ข ${currentFrame.minutesAgo} minutes ago`; - } + if (this.isExtendedMode && currentFrame.absoluteObservationTime) { + frameInfoText = `Frame ${(currentFrame.sequentialIndex ?? this.currentFrameIndex) + 1} of ${this.frames.length}`; + } else { + frameInfoText = `Frame ${currentFrame.frameIndex + 1} of ${this.frames.length}`; } const progress = this.frames.length > 0 @@ -808,105 +2222,187 @@ export class BomLocalRadarCard extends LitElement implements LovelaceCard { : 0; return html` - - ${cardTitle} -
- ${this._config.show_metadata !== false ? html` -
-
-

Cache Status

-
- ${this.radarData?.isUpdating ? 'Updating' : - this.radarData?.cacheIsValid ? 'Valid' : 'Invalid'} -
-
-
-

Observation Time

-
${this.radarData?.observationTime ? this.formatTimestamp(this.radarData.observationTime) : '-'}
-
-
-

Weather Station

-
${this.radarData?.weatherStation || '-'}
-
-
- ` : ''} - -
- ${this.isLoading - ? html`
Loading radar data...
` - : currentFrameUrl - ? html` - Radar frame ${this.currentFrameIndex} - ` - : '' - } +
+
${frameInfoText}
+ ${showFrameTimes && currentFrame.absoluteObservationTime ? html` +
+ ${this.formatTimestamp(currentFrame.absoluteObservationTime)}
- - ${this.frames.length > 0 ? html` -
-
- - - - - + ` : showFrameTimes && currentFrame.minutesAgo !== undefined ? html` +
+ ${currentFrame.minutesAgo} minutes ago +
+ ` : ''} + ${this.radarData?.observationTime && showFrameTimes ? html` +
+ Obs: ${this.formatTimestamp(this.radarData.observationTime)} +
+ ` : ''} +
Progress: ${progress}%
+
+ `; + } + + protected override render(): TemplateResult { + if (!this._config) { + return html`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) { + const action = this.error.details?.action as string; + const isManualRefreshRecommended = action === 'manual_refresh_recommended'; + const refreshEndpoint = this.error.details?.refreshEndpoint as string; + + const retryInfo = this.error.retryAfter + ? html`
Auto-retrying in ${this.error.retryAfter} seconds...
` + : isManualRefreshRecommended + ? html`
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() + ? html`
${line}
` + : html`
` + ); + + // 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 html` + +
+
+
+ +
${this._getErrorTitle()}
-
- ${frameInfoText} โ€ข Progress: ${progress}% +
+
${errorMessage}
+ ${this.error.errorCode ? html` +
+ + Error Code: ${this.error.errorCode} +
+ ` : ''} + ${showDetails ? html` +
+ ${this.error.details?.previousUpdateFailed ? html` +
+ +
+ Previous Update Failed: ${this.error.details.previousError || 'Unknown error'} + ${this.error.details.previousErrorCode ? html` (${this.error.details.previousErrorCode})` : ''} +
+
+ ` : ''} + ${this.error.details?.availableRange ? html` +
+ +
+ Available Data Range: ${this.error.details.availableRange.oldest || 'N/A'} to ${this.error.details.availableRange.newest || 'N/A'} + ${this.error.details.availableRange.totalCacheFolders ? html` (${this.error.details.availableRange.totalCacheFolders} folders)` : ''} +
+
+ ` : ''} + ${this.error.details?.requestedRange ? html` +
+ +
+ Requested Range: ${this.error.details.requestedRange.start || 'N/A'} to ${this.error.details.requestedRange.end || 'N/A'} +
+
+ ` : ''} + ${this.error.details?.suggestedRange ? html` +
+ +
+ Suggested Range: ${this.error.details.suggestedRange.start || 'N/A'} to ${this.error.details.suggestedRange.end || 'N/A'} +
Try using this time range instead
+
+
+ ` : ''} + ${refreshEndpoint ? html` +
+ +
+ Refresh Endpoint: ${refreshEndpoint} +
+
+ ` : ''} +
+ ` : ''} + ${retryInfo} + ${this.error.retryable && this.error.retryAction ? html` +
+ +
+ ` : ''}
- -
- - - -
- ` : ''} +
+ + `; + } + + return html` + +
+ ${this._renderMetadata('above')} + ${this._renderRadarImage()} + ${this._renderMetadata('below')} + ${this._renderControls()}
`; } + + /** + * Get CSS classes for root element + */ + private _getRootClasses(): string { + const classes: string[] = []; + if (this._config.overlay_controls) { + classes.push('overlay-enabled'); + } + return classes.join(' '); + } } if (!customElements.get('bom-local-radar-card')) { diff --git a/src/const.ts b/src/const.ts index d42087e..cbab09a 100644 --- a/src/const.ts +++ b/src/const.ts @@ -1,4 +1,4 @@ -export const CARD_VERSION = '0.0.1'; +export const CARD_VERSION = '0.1.1'; 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 f114546..defceaf 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -2,12 +2,26 @@ import { LitElement, html, css, type CSSResultGroup } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { HomeAssistant, LovelaceCardEditor, fireEvent } from 'custom-card-helpers'; import type { TemplateResult } from 'lit'; -import { BomLocalRadarCardConfig } from './types'; +import { BomLocalRadarCardConfig, MetadataDisplayConfig, ControlsDisplayConfig } from './types'; + +// 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' }, +]; @customElement('bom-local-radar-card-editor') export class BomLocalRadarCardEditor extends LitElement implements LovelaceCardEditor { @property({ attribute: false }) public hass?: HomeAssistant; @state() private _config: BomLocalRadarCardConfig = this._mergeWithDefaults(); + @state() private _metadataExpanded = false; + @state() private _controlsExpanded = false; private _mergeWithDefaults(config: Partial = {}): BomLocalRadarCardConfig { const defaults: Partial = { @@ -16,8 +30,12 @@ export class BomLocalRadarCardEditor extends LitElement implements LovelaceCardE frame_interval: 2.0, refresh_interval: 30, auto_play: true, - show_timestamp: true, + show_card_title: true, show_metadata: true, + show_controls: true, + image_zoom: 1.0, + image_fit: 'contain', + overlay_opacity: 0.9, }; return { @@ -51,43 +69,268 @@ export class BomLocalRadarCardEditor extends LitElement implements LovelaceCardE @input=${(e: Event) => this._updateConfig('suburb', (e.target as HTMLInputElement).value)} required > - this._updateConfig('state', (e.target as HTMLInputElement).value)} - helper="State abbreviation (e.g., QLD, NSW, VIC)" - required - > +
+ + +

Display

- this._updateConfig('card_title', (e.target as HTMLInputElement).value)} - > this._updateConfig('show_metadata', (e.target as HTMLInputElement).checked)} + label="Show Card Title" + .checked=${this._config.show_card_title !== false} + @change=${(e: Event) => { + const checked = (e.target as HTMLInputElement).checked; + this._updateConfig('show_card_title', checked); + }} > + ${this._config.show_card_title !== false ? html` + this._updateConfig('card_title', (e.target as HTMLInputElement).value)} + helper="Leave empty to use default" + > + ` : ''} + + + +
+
+ { + const checked = (e.target as HTMLInputElement).checked; + this._updateControlsToggle(checked); + }} + > + ${this._getControlsEnabled() ? html` + { this._controlsExpanded = !this._controlsExpanded; }} + > + + + ` : ''} +
+ ${this._getControlsEnabled() && this._controlsExpanded ? html` +
+ this._updateControlsConfig('show_play_pause', (e.target as HTMLInputElement).checked)} + > + this._updateControlsConfig('show_prev_next', (e.target as HTMLInputElement).checked)} + > + this._updateControlsConfig('show_slider', (e.target as HTMLInputElement).checked)} + > + this._updateControlsConfig('show_nav_buttons', (e.target as HTMLInputElement).checked)} + > + this._updateControlsConfig('show_frame_info', (e.target as HTMLInputElement).checked)} + > + { + const checked = (e.target as HTMLInputElement).checked; + this._updateConfig('overlay_controls', checked); + }} + > + ${this._config.overlay_controls ? html` +
+ + +
+ this._updateConfig('overlay_opacity', parseFloat((e.target as HTMLInputElement).value))} + > + ` : ''} +
+ ` : ''} +
+ + this._updateConfig('image_zoom', parseFloat((e.target as HTMLInputElement).value))} + helper="Zoom level: 0.5 = 50%, 1.0 = 100%, 2.0 = 200%" + > +
+ + +

Slideshow

- this._updateConfig('timespan', (e.target as HTMLSelectElement).value)} - > - - - - - - - +
+ + +
this._updateConfig('auto_play', (e.target as HTMLInputElement).checked)} + @change=${(e: Event) => { + const checked = (e.target as HTMLInputElement).checked; + this._updateConfig('auto_play', checked); + }} >
@@ -126,10 +372,213 @@ export class BomLocalRadarCardEditor extends LitElement implements LovelaceCardE fireEvent(this, 'config-changed', { config }); } + // Metadata configuration helpers + private _getMetadataEnabled(): boolean { + 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 + } + + private _updateMetadataToggle(enabled: boolean): void { + 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); + } + } + + private _getMetadataConfig(key: keyof MetadataDisplayConfig): boolean { + const config = this._config.show_metadata; + if (typeof config === 'boolean') { + return config; // If boolean, all metadata follows this value + } + if (typeof config === 'object') { + return config[key] !== false; // Default to true if not explicitly false + } + return true; // Default + } + + private _updateMetadataConfig(key: keyof MetadataDisplayConfig, value: boolean | string): void { + let config: MetadataDisplayConfig; + + if (typeof this._config.show_metadata === 'object') { + config = { ...this._config.show_metadata }; + } else { + config = {}; + } + + // Type-safe assignment based on key + if (key === 'position' || key === 'style') { + (config as any)[key] = value; + } else { + (config as any)[key] = value; + } + this._updateConfig('show_metadata', config); + } + + // Controls configuration helpers + private _getControlsEnabled(): boolean { + 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 + } + + private _updateControlsToggle(enabled: boolean): void { + 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); + } + } + + private _getControlsConfig(key: keyof ControlsDisplayConfig): boolean { + const config = this._config.show_controls; + if (typeof config === 'boolean') { + return config; + } + if (typeof config === 'object') { + return config[key] !== false; + } + return true; // Default + } + + private _updateControlsConfig(key: keyof ControlsDisplayConfig, value: boolean): void { + let config: ControlsDisplayConfig; + + if (typeof this._config.show_controls === 'object') { + config = { ...this._config.show_controls }; + } else { + config = {}; + } + + // All control config values are boolean (position is handled separately in template) + (config as any)[key] = value; + this._updateConfig('show_controls', config); + } + static styles: CSSResultGroup = css` - .editor { padding: 8px 16px; } - .section { margin: 12px 0; } - .section h3 { margin: 0 0 8px; font-weight: 600; } + .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); + } + + ha-switch { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 0; + margin-bottom: 0; + } + + ha-icon-button { + --mdc-icon-button-size: 32px; + } `; } diff --git a/src/types.ts b/src/types.ts index 6fd6885..ac10152 100644 --- a/src/types.ts +++ b/src/types.ts @@ -50,6 +50,60 @@ export interface CacheRangeInfo { }; } +// Metadata display configuration +export interface MetadataDisplayConfig { + show_cache_status?: boolean; + show_observation_time?: boolean; + show_forecast_time?: boolean; + show_weather_station?: boolean; + show_distance?: boolean; + show_next_update?: boolean; + show_frame_times?: boolean; // Show frame observation times + position?: 'above' | 'below' | 'overlay'; + style?: 'cards' | 'compact' | 'minimal'; +} + +// Controls display configuration +export interface ControlsDisplayConfig { + show_play_pause?: boolean; + show_prev_next?: boolean; + show_slider?: boolean; + show_nav_buttons?: boolean; // First, -10, +10, Last + show_frame_info?: boolean; // Frame X of Y, timestamp + position?: 'above' | 'below' | 'overlay'; +} + +// API Error Response structure matching the service +export interface ApiErrorResponse { + errorCode: string; + message: string; + errorType: string; + details?: Record; + suggestions?: Record; + timestamp?: string; +} + +// Error state interface +export interface ErrorState { + message: string; + type: 'network' | 'cache' | 'config' | 'validation' | 'unknown'; + retryable: boolean; + retryAction?: () => void; + retryAfter?: number; // seconds (undefined if manual refresh recommended) + errorCode?: string; + details?: Record; // Includes action, refreshEndpoint, statusEndpoint from API suggestions +} + +// Grid options for HA sections view +export interface GridOptions { + columns: number; + rows: number; + min_columns?: number; + min_rows?: number; + max_columns?: number; + max_rows?: number; +} + // Card configuration export interface BomLocalRadarCardConfig extends LovelaceCardConfig { type: 'custom:bom-local-radar-card'; @@ -60,9 +114,22 @@ export interface BomLocalRadarCardConfig extends LovelaceCardConfig { state: string; // Required: state abbreviation (e.g., "QLD") // Display + show_card_title?: boolean; // Show/hide card title (uses HA card header) card_title?: string; - show_timestamp?: boolean; - show_metadata?: boolean; + show_timestamp?: boolean; // Deprecated - use show_metadata instead + show_metadata?: boolean | MetadataDisplayConfig; // Granular metadata display + + // Control visibility + show_controls?: boolean | ControlsDisplayConfig; // Granular control visibility + + // Image display + image_zoom?: number; // 1.0 = 100%, 1.5 = 150%, etc. (0.5 to 3.0) + image_fit?: 'contain' | 'cover' | 'fill'; + + // Overlay options + overlay_controls?: boolean; // Overlay controls on image + overlay_position?: 'top' | 'bottom' | 'left' | 'right' | 'center'; + overlay_opacity?: number; // 0.0 to 1.0 // Slideshow configuration timespan?: 'latest' | '1h' | '3h' | '6h' | '12h' | '24h' | 'custom'; // Historical data timespan @@ -75,6 +142,9 @@ export interface BomLocalRadarCardConfig extends LovelaceCardConfig { // Custom time range (for timespan: 'custom') custom_start_time?: string; // ISO 8601 datetime custom_end_time?: string; // ISO 8601 datetime + + // Localization + locale?: string; // Override locale (defaults to HA locale) }