From d49228d67150153f021d60c9e2be8b521bddd30c Mon Sep 17 00:00:00 2001 From: Alex Hope-O'Connor Date: Tue, 16 Dec 2025 19:51:16 +1000 Subject: [PATCH] Initial commit: BOM Local Radar Card - Custom Home Assistant card for displaying BOM radar data - Docker-based build system (no local Node.js required) - Test environment with Home Assistant and BOM service - TypeScript source with LitElement framework - HACS integration ready --- .gitignore | 23 + Dockerfile.build | 22 + README.md | 280 +++++++++++ docker-compose.test.yml | 49 ++ hacs.json | 14 + package-lock.json | 649 +++++++++++++++++++++++++ package.json | 51 ++ rollup.config.js | 35 ++ run.sh | 177 +++++++ scripts/build-docker.sh | 62 +++ scripts/test.sh | 619 ++++++++++++++++++++++++ src/bom-local-radar-card.ts | 923 ++++++++++++++++++++++++++++++++++++ src/const.ts | 15 + src/editor.ts | 143 ++++++ src/types.ts | 88 ++++ tsconfig.json | 24 + 16 files changed, 3174 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile.build create mode 100644 README.md create mode 100644 docker-compose.test.yml create mode 100644 hacs.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 rollup.config.js create mode 100755 run.sh create mode 100755 scripts/build-docker.sh create mode 100755 scripts/test.sh create mode 100644 src/bom-local-radar-card.ts create mode 100644 src/const.ts create mode 100644 src/editor.ts create mode 100644 src/types.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b525567 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Dependencies +node_modules/ + +# Build outputs +dist/ +*.js.map + +# Environment files +.env +.env.local + +# OS files +.DS_Store +*.log + +# Docker Compose override files (generated by test script) +docker-compose.test.local.yml + +# Test Home Assistant instance - entire directory is test-only +test-ha/ + +# Docker build cache +.docker/ diff --git a/Dockerfile.build b/Dockerfile.build new file mode 100644 index 0000000..b6f6a8f --- /dev/null +++ b/Dockerfile.build @@ -0,0 +1,22 @@ +# Dockerfile for building the BOM Local Radar Card +# This isolates Node.js/npm in a container for security +FROM node:20-alpine + +WORKDIR /build + +# Copy package files first for better caching +COPY package*.json ./ +COPY tsconfig.json ./ +COPY rollup.config.js ./ + +# Install dependencies (including devDependencies) +# Use npm install since we may have updated package.json +RUN npm install + +# Copy source files +COPY src/ ./src/ + +# Default command: run the build +# This ensures the build runs when the container starts, not during image creation +CMD ["npm", "run", "build"] + diff --git a/README.md b/README.md new file mode 100644 index 0000000..54b4be1 --- /dev/null +++ b/README.md @@ -0,0 +1,280 @@ +# BOM Local Radar Card + +A Home Assistant custom card that displays Australian Bureau of Meteorology (BOM) rain radar data using the local [BOM Local Service](https://github.com/alexhopeoconnor/bom-local-service). + +## Background + +The Australian Bureau of Meteorology's radar API endpoint stopped working in December 2024, breaking integrations like the popular bom-radar-card for Home Assistant. This card works alongside the [BOM Local Service](https://github.com/alexhopeoconnor/bom-local-service) to provide a reliable local solution by consuming cached radar data from a local service. + +## 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 +- ๐Ÿ”„ **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) + +## Prerequisites + +1. **BOM Local Service**: This card requires the [BOM Local Service](https://github.com/alexhopeoconnor/bom-local-service) to be running. The service provides the cached radar data that the card displays. + + See the [BOM Local Service README](https://github.com/alexhopeoconnor/bom-local-service) for installation instructions. + +2. **Home Assistant**: Version 2024.1.0 or later + +## Installation + +### HACS (Recommended) + +1. Open HACS in Home Assistant +2. Go to **Frontend** โ†’ **Explore & Download Repositories** +3. Search for **BOM Local Radar Card** +4. Click **Download** +5. Restart Home Assistant + +### Manual Installation + +1. Download the latest `bom-local-radar-card.js` from the [releases page](https://github.com/alexhopeoconnor/bom-local-card/releases) +2. Copy the file to your Home Assistant `www` directory (usually `/config/www/`) +3. Add the resource reference to your Lovelace configuration: + + **Option A: Via UI** (Recommended) + - Go to **Settings** โ†’ **Dashboards** โ†’ **Resources** (three dots menu) + - Click **Add Resource** + - Set URL to `/local/bom-local-radar-card.js` + - Set Resource type to **JavaScript Module** + - Click **Create** + + **Option B: Via YAML** + Add to your `configuration.yaml`: + ```yaml + lovelace: + resources: + - url: /local/bom-local-radar-card.js + type: module + ``` +4. Restart Home Assistant + +## Configuration + +### Using the Visual Editor (Recommended) + +1. Add a card to your Lovelace dashboard +2. Search for **BOM Local Radar Card** or select **Custom: BOM Local Radar Card** +3. Configure using the visual editor: + - **Service URL**: Base URL of your BOM Local Service (default: `http://localhost:8082`) + - **Suburb**: The suburb name (e.g., `Pomona`, `Brisbane`) + - **State**: State abbreviation (e.g., `QLD`, `NSW`, `VIC`) + - **Card Title**: Optional custom title for the card + - **Show Metadata**: Toggle to show/hide cache status and observation time + - **Timespan**: Select historical data range (Latest, 1h, 3h, 6h, 12h, 24h) + - **Frame Interval**: Seconds between frames during animation (default: 2.0) + - **Auto Play**: Automatically start animation when data loads + - **Refresh Interval**: Seconds between automatic data refreshes (default: 30) + +### Using YAML + +```yaml +type: custom:bom-local-radar-card +service_url: http://localhost:8082 +suburb: Pomona +state: QLD +card_title: Local Weather Radar +show_metadata: true +timespan: latest +frame_interval: 2.0 +auto_play: true +refresh_interval: 30 +``` + +### Configuration Options + +| 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 | +| `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 (0.5-10) | +| `auto_play` | boolean | `true` | No | Automatically start animation when data loads | +| `refresh_interval` | number | `30` | No | Seconds between automatic data refreshes (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`) | + +## Usage Examples + +### Basic Configuration + +Display the latest radar frames for a location: + +```yaml +type: custom:bom-local-radar-card +suburb: Brisbane +state: QLD +service_url: http://192.168.1.100:8082 +``` + +### Historical Data (Last 3 Hours) + +View radar history from the past 3 hours: + +```yaml +type: custom:bom-local-radar-card +suburb: Melbourne +state: VIC +service_url: http://192.168.1.100:8082 +timespan: 3h +auto_play: true +frame_interval: 1.5 +``` + +### Custom Time Range + +View radar data for a specific time period: + +```yaml +type: custom:bom-local-radar-card +suburb: Sydney +state: NSW +service_url: http://192.168.1.100:8082 +timespan: custom +custom_start_time: "2024-01-15T10:00:00Z" +custom_end_time: "2024-01-15T14:00:00Z" +``` + +### Manual Control (No Auto-Play) + +Display radar with manual controls only: + +```yaml +type: custom:bom-local-radar-card +suburb: Adelaide +state: SA +service_url: http://192.168.1.100:8082 +auto_play: false +frame_interval: 3.0 +``` + +### Different Service Location + +If your BOM Local Service is running on a different machine: + +```yaml +type: custom:bom-local-radar-card +suburb: Perth +state: WA +service_url: http://192.168.1.50:8082 +refresh_interval: 60 +``` + +## Controls + +The card provides several controls for navigating radar frames: + +- **Play/Pause Button**: Start or stop the animation +- **Previous/Next Buttons**: Navigate to the previous or next frame +- **Frame Slider**: Drag to jump to any frame +- **Navigation Buttons**: + - โฎ First frame + - -10 / +10: Jump backward/forward by 10 frames + - โญ Last frame + +The card displays frame information including frame number, total frames, and timestamp. + +## Troubleshooting + +### Card Shows "Configuration Error" + +- 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" + +- **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 + +### 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 + ``` + +### Images Don't Load + +- Check browser console for CORS errors (may indicate service configuration issue) +- Verify the service URL is correct and images are accessible +- If using a different machine, ensure CORS is properly configured in the service + +### Animation Not Playing + +- Check that `auto_play` is set to `true` (default) +- Verify frames are loading (check frame count display) +- Try manually clicking the Play button + +### Service URL Configuration + +- **Local service**: Use `http://localhost:8082` if the service runs on the same machine as Home Assistant +- **Remote service**: Use the IP address or hostname of the machine running the service (e.g., `http://192.168.1.100:8082`) +- **Docker network**: If Home Assistant and the service are in the same Docker network, use the service container name (e.g., `http://bom-local-service:8080`) + +## Development + +### Building from Source + +1. Clone the repository: + ```bash + git clone https://github.com/alexhopeoconnor/bom-local-card.git + cd bom-local-card + ``` + +2. Install dependencies: + ```bash + npm install + ``` + +3. Build the card: + ```bash + npm run build + ``` + + The built file will be in `dist/bom-local-radar-card.js` + +4. For development with watch mode: + ```bash + npm run watch + ``` + +### Testing with Home Assistant + +The repository includes Docker Compose configuration for testing with Home Assistant: + +```bash +npm run test:ha +``` + +This will: +- Start Home Assistant in a Docker container +- Build and copy the card to the Home Assistant `www` directory +- Allow you to test the card in a real Home Assistant environment + +## License + +MIT License - see [LICENSE](LICENSE) file for details + +## Credits + +- Built for use with [BOM Local Service](https://github.com/alexhopeoconnor/bom-local-service) +- Inspired by the original [bom-radar-card](https://github.com/Makin-Things/bom-radar-card) project + +## Support + +For issues, questions, or contributions, please visit the [GitHub repository](https://github.com/alexhopeoconnor/bom-local-card). + diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..e743107 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,49 @@ +services: + # BOM Local Service - provides radar data (using pre-built image) + bom-local-service: + image: ghcr.io/alexhopeoconnor/bom-local-service:latest + container_name: bom-card-test-service + ports: + - "8082:8080" + environment: + # ASP.NET Core configuration + - ASPNETCORE_ENVIRONMENT=Production + - ASPNETCORE_URLS=http://+:8080 + # Virtual display for non-headless browser + - DISPLAY=:99 + # CORS - allow requests from HA test instance (both container name and localhost) + - CORS__ALLOWEDORIGINS=http://homeassistant:8123,http://localhost:8124,http://127.0.0.1:8124 + - CORS__ALLOWEDMETHODS=GET,POST,OPTIONS + - CORS__ALLOWEDHEADERS=* + - CORS__ALLOWCREDENTIALS=false + # Application configuration + - CACHEDIRECTORY=/app/cache + - CACHERETENTIONHOURS=24 + - TIMEZONE=Australia/Brisbane + shm_size: '1gb' + ipc: host + restart: unless-stopped + networks: + - bom-test-network + + # Home Assistant test instance + # HA runs as root (default) - we fix file permissions after it creates files + homeassistant: + container_name: bom-card-test-ha + image: "ghcr.io/home-assistant/home-assistant:stable" + volumes: + # Mount HA config directory + - ./test-ha/config:/config + environment: + - TZ=Australia/Brisbane + restart: unless-stopped + ports: + - "8124:8123" # Use 8124 to avoid conflicts with main HA instance + depends_on: + - bom-local-service + networks: + - bom-test-network + +networks: + bom-test-network: + driver: bridge diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..60cdff3 --- /dev/null +++ b/hacs.json @@ -0,0 +1,14 @@ +{ + "name": "BOM Local Radar Card", + "render_readme": true, + "filename": "bom-local-radar-card.js", + "homeassistant": "2024.1.0" +} + + + + + + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7328b74 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,649 @@ +{ + "name": "bom-local-radar-card", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bom-local-radar-card", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "custom-card-helpers": "^1.9.0", + "lit": "^3.1.2" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^24.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "rollup": "^2.79.1", + "typescript": "~5.3.3" + } + }, + "node_modules/@formatjs/ecma402-abstract": { + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.11.4.tgz", + "integrity": "sha512-EBikYFp2JCdIfGEb5G9dyCkTGDmC57KSHhRQOC3aYxoPWVZvfWCDjZwkGYHN7Lis/fmuWl906bnNTJifDQ3sXw==", + "license": "MIT", + "dependencies": { + "@formatjs/intl-localematcher": "0.2.25", + "tslib": "^2.1.0" + } + }, + "node_modules/@formatjs/fast-memoize": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-1.2.1.tgz", + "integrity": "sha512-Rg0e76nomkz3vF9IPlKeV+Qynok0r7YZjL6syLz4/urSg0IbjPZCB/iYUMNsYA643gh4mgrX3T7KEIFIxJBQeg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@formatjs/icu-messageformat-parser": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.1.0.tgz", + "integrity": "sha512-Qxv/lmCN6hKpBSss2uQ8IROVnta2r9jd3ymUEIjm2UyIkUCHVcbUVRGL/KS/wv7876edvsPe+hjHVJ4z8YuVaw==", + "license": "MIT", + "dependencies": { + "@formatjs/ecma402-abstract": "1.11.4", + "@formatjs/icu-skeleton-parser": "1.3.6", + "tslib": "^2.1.0" + } + }, + "node_modules/@formatjs/icu-skeleton-parser": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.3.6.tgz", + "integrity": "sha512-I96mOxvml/YLrwU2Txnd4klA7V8fRhb6JG/4hm3VMNmeJo1F03IpV2L3wWt7EweqNLES59SZ4d6hVOPCSf80Bg==", + "license": "MIT", + "dependencies": { + "@formatjs/ecma402-abstract": "1.11.4", + "tslib": "^2.1.0" + } + }, + "node_modules/@formatjs/intl-localematcher": { + "version": "0.2.25", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.2.25.tgz", + "integrity": "sha512-YmLcX70BxoSopLFdLr1Ds99NdlTI2oWoLbaUW2M406lxOIPzE1KQhRz2fPUkq34xVZQaihCoU29h0KK7An3bhA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@formatjs/intl-utils": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/@formatjs/intl-utils/-/intl-utils-3.8.4.tgz", + "integrity": "sha512-j5C6NyfKevIxsfLK8KwO1C0vvP7k1+h4A9cFpc+cr6mEwCc1sPkr17dzh0Ke6k9U5pQccAQoXdcNBl3IYa4+ZQ==", + "deprecated": "the package is rather renamed to @formatjs/ecma-abstract with some changes in functionality (primarily selectUnit is removed and we don't plan to make any further changes to this package", + "license": "MIT", + "dependencies": { + "emojis-list": "^3.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@lit-labs/ssr-dom-shim": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.4.0.tgz", + "integrity": "sha512-ficsEARKnmmW5njugNYKipTm4SFnbik7CXtoencDZzmzo/dQ+2Q0bgkzJuoJP20Aj0F+izzJjOqsnkd6F/o1bw==", + "license": "BSD-3-Clause" + }, + "node_modules/@lit/reactive-element": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.1.tgz", + "integrity": "sha512-N+dm5PAYdQ8e6UlywyyrgI2t++wFGXfHx+dSJ1oBrg6FAxUj40jId++EaRm80MKX5JnlH1sBsyZ5h0bcZKemCg==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.4.0" + } + }, + "node_modules/@rollup/plugin-commonjs": { + "version": "24.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-24.1.0.tgz", + "integrity": "sha512-eSL45hjhCWI0jCCXcNtLVqM5N1JlBGvlFfY0m6oOYnLCJ6N0qEXoZql4sY2MOUArzhH4SA/qBpTxvvZp2Sc+DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "glob": "^8.0.3", + "is-reference": "1.2.1", + "magic-string": "^0.27.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz", + "integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-typescript": { + "version": "11.1.6", + "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-11.1.6.tgz", + "integrity": "sha512-R92yOmIACgYdJ7dJ97p4K69I8gg6IEHt8M7dUBxN3W6nrO8uUxX5ixl0yU/N3aZTi8WhPuICvOHXQvF6FaykAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.14.0||^3.0.0||^4.0.0", + "tslib": "*", + "typescript": ">=3.7.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + }, + "tslib": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/custom-card-helpers": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/custom-card-helpers/-/custom-card-helpers-1.9.0.tgz", + "integrity": "sha512-5IW4OXq3MiiCqDvqeu+MYsM1NmntKW1WfJhyJFsdP2tbzqEI4BOnqRz2qzdp08lE4QLVhYfRLwe0WAqgQVNeFg==", + "license": "MIT", + "dependencies": { + "@formatjs/intl-utils": "^3.8.4", + "home-assistant-js-websocket": "^6.0.1", + "intl-messageformat": "^9.11.1", + "lit": "^2.1.1", + "rollup": "^2.63.0", + "superstruct": "^0.15.3", + "typescript": "^4.5.4" + } + }, + "node_modules/custom-card-helpers/node_modules/@lit/reactive-element": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.6.3.tgz", + "integrity": "sha512-QuTgnG52Poic7uM1AN5yJ09QMe0O28e10XzSvWDz02TJiiKee4stsiownEIadWm8nYzyDAyT+gKzUoZmiWQtsQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.0.0" + } + }, + "node_modules/custom-card-helpers/node_modules/lit": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/lit/-/lit-2.8.0.tgz", + "integrity": "sha512-4Sc3OFX9QHOJaHbmTMk28SYgVxLN3ePDjg7hofEft2zWlehFL3LiAuapWc4U/kYwMYJSh2hTCPZ6/LIC7ii0MA==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit/reactive-element": "^1.6.0", + "lit-element": "^3.3.0", + "lit-html": "^2.8.0" + } + }, + "node_modules/custom-card-helpers/node_modules/lit-element": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.3.3.tgz", + "integrity": "sha512-XbeRxmTHubXENkV4h8RIPyr8lXc+Ff28rkcQzw3G6up2xg5E8Zu1IgOWIwBLEQsu3cOVFqdYwiVi0hv0SlpqUA==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.1.0", + "@lit/reactive-element": "^1.3.0", + "lit-html": "^2.8.0" + } + }, + "node_modules/custom-card-helpers/node_modules/lit-html": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.8.0.tgz", + "integrity": "sha512-o9t+MQM3P4y7M7yNzqAyjp7z+mQGa4NS4CxiyLqFPyFWyc4O+nodLrkrxSaCTrla6M5YOLaT3RpbbqjszB5g3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "@types/trusted-types": "^2.0.2" + } + }, + "node_modules/custom-card-helpers/node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/home-assistant-js-websocket": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/home-assistant-js-websocket/-/home-assistant-js-websocket-6.1.1.tgz", + "integrity": "sha512-TnZFzF4mn5F/v0XKUTK2GMQXrn/+eQpgaSDSELl6U0HSwSbFwRhGWLz330YT+hiKMspDflamsye//RPL+zwhDw==", + "license": "Apache-2.0" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/intl-messageformat": { + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-9.13.0.tgz", + "integrity": "sha512-7sGC7QnSQGa5LZP7bXLDhVDtQOeKGeBFGHF2Y8LVBwYZoQZCgWeKoPGTa5GMG8g/TzDgeXuYJQis7Ggiw2xTOw==", + "license": "BSD-3-Clause", + "dependencies": { + "@formatjs/ecma402-abstract": "1.11.4", + "@formatjs/fast-memoize": "1.2.1", + "@formatjs/icu-messageformat-parser": "2.1.0", + "tslib": "^2.1.0" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/lit": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.1.tgz", + "integrity": "sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit/reactive-element": "^2.1.0", + "lit-element": "^4.2.0", + "lit-html": "^3.3.0" + } + }, + "node_modules/lit-element": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.1.tgz", + "integrity": "sha512-WGAWRGzirAgyphK2urmYOV72tlvnxw7YfyLDgQ+OZnM9vQQBQnumQ7jUJe6unEzwGU3ahFOjuz1iz1jjrpCPuw==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.4.0", + "@lit/reactive-element": "^2.1.0", + "lit-html": "^3.3.0" + } + }, + "node_modules/lit-html": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.1.tgz", + "integrity": "sha512-S9hbyDu/vs1qNrithiNyeyv64c9yqiW9l+DBgI18fL+MTvOtWoFR0FWiyq1TxaYef5wNlpEmzlXoBlZEO+WjoA==", + "license": "BSD-3-Clause", + "dependencies": { + "@types/trusted-types": "^2.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.27.0.tgz", + "integrity": "sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.13" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rollup": { + "version": "2.79.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", + "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", + "license": "MIT", + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=10.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/superstruct": { + "version": "0.15.5", + "resolved": "https://registry.npmjs.org/superstruct/-/superstruct-0.15.5.tgz", + "integrity": "sha512-4AOeU+P5UuE/4nOUkmcQdW5y7i9ndt1cQd/3iUe+LTz3RxESf/W/5lg4B74HbDMMv8PHnPnGCQFH45kBcrQYoQ==", + "license": "MIT" + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..105ef39 --- /dev/null +++ b/package.json @@ -0,0 +1,51 @@ +{ + "name": "bom-local-radar-card", + "version": "1.0.0", + "description": "A rain radar card using the local BOM service", + "module": "bom-local-radar-card.js", + "keywords": [ + "home-assistant", + "homeassistant", + "hass", + "automation", + "lovelace", + "custom-cards", + "bom", + "radar", + "weather" + ], + "repository": { + "type": "git", + "url": "https://github.com/alexhopeoconnor/bom-local-card.git" + }, + "author": "Alex Hope-O'Connor", + "license": "MIT", + "dependencies": { + "custom-card-helpers": "^1.9.0", + "lit": "^3.1.2" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^24.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "rollup": "^2.79.1", + "typescript": "~5.3.3" + }, + "scripts": { + "build": "rollup -c", + "build:docker": "bash scripts/build-docker.sh", + "watch": "rollup -c --watch", + "test": "./run.sh test", + "test:ha": "./run.sh test", + "test:ha:down": "docker compose -f docker-compose.test.yml down 2>/dev/null || docker-compose -f docker-compose.test.yml down", + "test:ha:clean": "./run.sh clean" + }, + "_comment": "Note: All commands can be run without npm using: ./run.sh [build|test|clean]" +} + + + + + + + diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..5507d7a --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,35 @@ +import resolve from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; +import typescript from '@rollup/plugin-typescript'; + +export default { + input: 'src/bom-local-radar-card.ts', + output: { + file: 'dist/bom-local-radar-card.js', + format: 'es', + }, + onwarn(warning, warn) { + if (warning.code === 'THIS_IS_UNDEFINED') { + return; + } + warn(warning); + }, + plugins: [ + resolve({ + browser: true, + }), + commonjs(), + typescript({ + tsconfig: './tsconfig.json', + }), + ], + // Dependencies are bundled (not external) so the card works in HA + // HA doesn't provide lit or custom-card-helpers globally +}; + + + + + + + diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..311a244 --- /dev/null +++ b/run.sh @@ -0,0 +1,177 @@ +#!/bin/bash +# Main entry point - works with or without npm +# Usage: ./run.sh [build|test|clean] [docker] + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +COMMAND="${1:-test}" +BUILD_METHOD="${2:-}" + +# Build function - handles both npm and Docker +build_card() { + local method="$1" + + # Auto-detect if not specified + if [ -z "$method" ]; then + if command -v docker &> /dev/null; then + method="docker" + elif command -v npm &> /dev/null; then + method="npm" + else + echo "โŒ Error: Neither Docker nor npm found" + echo " Please install Docker or Node.js/npm" + exit 1 + fi + fi + + if [ "$method" = "docker" ]; then + echo "๐Ÿณ Building with Docker..." + if ! command -v docker &> /dev/null; then + echo "โŒ Error: docker not found" + exit 1 + fi + # Pass FORCE_REBUILD if set + FORCE_REBUILD="${FORCE_REBUILD:-0}" bash scripts/build-docker.sh + else + echo "๐Ÿ”จ Building with npm..." + if ! command -v npm &> /dev/null; then + echo "โŒ Error: npm not found" + echo " Install Node.js/npm or use: ./run.sh build docker" + exit 1 + fi + + if [ ! -d "node_modules" ]; then + echo "๐Ÿ“ฆ Installing dependencies..." + npm install + fi + + npm run build + fi +} + +# Clean function - full cleanup without sudo +clean_test() { + echo "๐Ÿงน Cleaning test environment..." + + # 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 and remove containers with volumes + echo " Stopping and removing containers..." + docker compose $COMPOSE_FILES down -v 2>/dev/null || \ + docker-compose $COMPOSE_FILES down -v 2>/dev/null || true + + # Remove override file + rm -f docker-compose.test.local.yml + + # Clean up .storage directory using Docker (no sudo needed) + if [ -d "test-ha/config/.storage" ]; then + echo " Removing .storage directory..." + CURRENT_UID=$(id -u) + CURRENT_GID=$(id -g) + if ! rm -rf test-ha/config/.storage 2>/dev/null; then + # Use Docker to remove root-owned files + 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 + echo " โœ… Cleared .storage directory" + fi + + # Clean up www directory files (card file) using Docker if needed + if [ -f "test-ha/config/www/bom-local-radar-card.js" ]; then + echo " Removing card file..." + CURRENT_UID=$(id -u) + CURRENT_GID=$(id -g) + if ! rm -f test-ha/config/www/bom-local-radar-card.js 2>/dev/null; then + docker run --rm \ + -v "$(pwd)/test-ha/config/www:/www:rw" \ + -u root \ + alpine:latest \ + sh -c "rm -f /www/bom-local-radar-card.js" 2>/dev/null || true + fi + fi + + # Optionally clean build artifacts + if [ "${CLEAN_BUILD:-0}" = "1" ]; then + echo " Removing build artifacts..." + rm -rf dist/ node_modules/ 2>/dev/null || true + fi + + echo "" + echo "โœ… Cleanup complete. Run './run.sh test' to start fresh." +} + +# Update function - rebuilds card and updates running test environment +update_card() { + local method="$1" + + echo "๐Ÿ”„ Rebuilding and updating card in running test environment..." + + # Always force rebuild when updating to ensure changes are picked up + if [ -z "$method" ]; then + if command -v docker &> /dev/null; then + method="docker" + elif command -v npm &> /dev/null; then + method="npm" + fi + fi + + # Force rebuild + if [ "$method" = "docker" ]; then + FORCE_REBUILD=1 bash scripts/build-docker.sh + else + npm run build + fi + + # Update card in running environment (test.sh auto-detects running containers) + ./scripts/test.sh +} + +case "$COMMAND" in + build) + build_card "$BUILD_METHOD" + ;; + test) + ./scripts/test.sh + ;; + update) + update_card "$BUILD_METHOD" + ;; + clean) + clean_test + ;; + *) + echo "Usage: ./run.sh [COMMAND] [BUILD_METHOD]" + echo "" + 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 " 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)" + echo "" + echo "Build Methods:" + echo " docker - Use Docker to build (isolated, no local Node.js needed)" + echo " npm - Use local npm/Node.js to build" + echo "" + echo "Examples:" + 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 update # Rebuild and update running environment" + echo " ./run.sh clean # Clean test environment" + echo "" + echo "Note: For more options (e.g., --service-path), use ./scripts/test.sh directly" + exit 1 + ;; +esac + diff --git a/scripts/build-docker.sh b/scripts/build-docker.sh new file mode 100755 index 0000000..27c4c6f --- /dev/null +++ b/scripts/build-docker.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# Build the card using Docker (isolates Node.js/npm for security) + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +cd "$PROJECT_DIR" + +echo "๐Ÿณ Building card using Docker (isolated environment)..." + +# Check if docker is available +if ! command -v docker &> /dev/null; then + echo "โŒ Error: docker not found" + echo " Install Docker or use: npm run build" + exit 1 +fi + +# Clean dist directory to ensure fresh build +if [ -d "dist" ]; then + rm -rf dist/* +fi +mkdir -p dist + +# Build the image +echo "๐Ÿ“ฆ Building Docker image..." +# Use --no-cache if FORCE_REBUILD is set, or if config files changed recently +FORCE_REBUILD="${FORCE_REBUILD:-0}" +if [ "$FORCE_REBUILD" = "1" ]; then + echo " Force rebuild (no cache)..." + docker build --no-cache -f Dockerfile.build -t bom-local-card-builder . +else + # Check if config files are newer than the image + # This ensures we rebuild when tsconfig.json, rollup.config.js, or package.json change + docker build -f Dockerfile.build -t bom-local-card-builder . +fi + +# Run the build container and copy file out +echo "๐Ÿ”จ Running build..." +CONTAINER_ID=$(docker create bom-local-card-builder) +docker start -a "$CONTAINER_ID" + +# Ensure dist directory exists +mkdir -p dist + +# Copy the built file from container +echo "๐Ÿ“‹ Copying built file from container..." +docker cp "$CONTAINER_ID:/build/dist/bom-local-radar-card.js" dist/bom-local-radar-card.js + +# Remove the container +docker rm "$CONTAINER_ID" > /dev/null + +# Check if build succeeded +if [ ! -f "dist/bom-local-radar-card.js" ]; then + echo "โŒ Error: Build failed - dist/bom-local-radar-card.js not found" + exit 1 +fi + +FILE_SIZE=$(stat -f%z "dist/bom-local-radar-card.js" 2>/dev/null || stat -c%s "dist/bom-local-radar-card.js" 2>/dev/null || echo "0") +echo "โœ… Build complete (${FILE_SIZE} bytes)" + diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..268df5c --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,619 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +cd "$PROJECT_DIR" + +# Cleanup function to stop containers +cleanup() { + echo "" + echo "๐Ÿ›‘ Shutting down containers..." + 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 + docker compose $COMPOSE_FILES down 2>/dev/null || docker-compose $COMPOSE_FILES down 2>/dev/null || true + rm -f docker-compose.test.local.yml + exit 0 +} + +# Trap SIGINT (Ctrl+C) and SIGTERM to run cleanup +trap cleanup SIGINT SIGTERM + +# Port configuration (can be overridden via environment variables) +SERVICE_HOST_PORT="${BOM_SERVICE_PORT:-8082}" +HA_HOST_PORT="${HA_PORT:-8124}" + + +# Parse command-line arguments +SKIP_BUILD=0 +FORCE_BUILD=0 +USE_DOCKER_BUILD="" +USE_NPM_BUILD="" +SERVICE_PATH="" + +show_help() { + cat < /dev/null; then + USE_DOCKER_BUILD=1 + elif command -v npm &> /dev/null; then + USE_NPM_BUILD=1 + else + echo "โŒ Error: Neither Docker nor npm found" + echo " Please install Docker or Node.js/npm" + exit 1 + fi +fi + +# Function to build using npm (local) +build_with_npm() { + echo "๐Ÿ”จ Building card with npm (local)..." + if ! command -v npm &> /dev/null; then + echo "โŒ Error: npm not found" + echo " Install Node.js/npm or use Docker: ./scripts/test.sh --docker-build" + exit 1 + fi + + # Check if node_modules exists + if [ ! -d "node_modules" ]; then + echo "๐Ÿ“ฆ Installing dependencies..." + npm install + fi + + npm run build +} + +# Function to build using Docker +build_with_docker() { + echo "๐Ÿณ Building card using Docker (isolated environment)..." + if ! command -v docker &> /dev/null; then + echo "โŒ Error: docker not found" + echo " Install Docker or use npm: ./scripts/test.sh --npm-build" + exit 1 + fi + + # Pass FORCE_REBUILD if FORCE_BUILD is set + if [ "$FORCE_BUILD" = "1" ]; then + FORCE_REBUILD=1 bash scripts/build-docker.sh + else + bash scripts/build-docker.sh + fi +} + +# Build card +if [ "$SKIP_BUILD" = "1" ]; then + echo "โญ๏ธ Skipping build (--skip-build)" + if [ ! -f "dist/bom-local-radar-card.js" ]; then + echo "โŒ Error: dist/bom-local-radar-card.js not found and --skip-build was used" + exit 1 + fi +elif [ "$FORCE_BUILD" = "1" ] || [ ! -f "dist/bom-local-radar-card.js" ]; then + # If FORCE_BUILD is set, also force Docker rebuild + if [ "$FORCE_BUILD" = "1" ] && [ "$USE_DOCKER_BUILD" = "1" ]; then + export FORCE_REBUILD=1 + fi + if [ "$USE_DOCKER_BUILD" = "1" ]; then + build_with_docker + elif [ "$USE_NPM_BUILD" = "1" ]; then + build_with_npm + else + # Auto-detect: prefer Docker, fallback to npm + if command -v docker &> /dev/null; then + build_with_docker + else + build_with_npm + fi + fi + + if [ ! -f "dist/bom-local-radar-card.js" ]; then + echo "โŒ Error: dist/bom-local-radar-card.js not found after build" + exit 1 + fi + + # Verify build quality + FILE_SIZE=$(stat -f%z "dist/bom-local-radar-card.js" 2>/dev/null || stat -c%s "dist/bom-local-radar-card.js" 2>/dev/null || echo "0") + if [ "$FILE_SIZE" -lt 50000 ]; then + echo "โš ๏ธ Warning: Built file is suspiciously small (${FILE_SIZE} bytes)" + echo " This might indicate dependencies weren't bundled correctly" + echo " Check rollup.config.js - dependencies should NOT be marked as external" + fi + + # Check for external imports (should not exist if dependencies are bundled) + if grep -q "from ['\"]lit['\"]" dist/bom-local-radar-card.js 2>/dev/null || \ + grep -q "from ['\"]custom-card-helpers['\"]" dist/bom-local-radar-card.js 2>/dev/null; then + echo "โš ๏ธ Warning: Found external import statements in built file" + echo " Dependencies may not be bundled. Check rollup.config.js" + fi + + echo "โœ… Build complete (${FILE_SIZE} bytes)" +else + echo "โœ… Using existing dist/bom-local-radar-card.js" + echo " (Use --force-build to rebuild, --docker-build or --npm-build to specify method)" +fi + +# If SERVICE_PATH is set, create override file for local service build +if [ -n "$SERVICE_PATH" ] && [ ! -f "docker-compose.test.local.yml" ]; then + OVERRIDE_FILE="docker-compose.test.local.yml" + cat > "$OVERRIDE_FILE" </dev/null | grep -q "Up" || \ + docker-compose $COMPOSE_FILES_CHECK ps 2>/dev/null | grep -q "Up"; then + CONTAINERS_RUNNING=1 +fi + +# If containers are running, automatically update the card (no need for UPDATE_MODE) +if [ "$CONTAINERS_RUNNING" = "1" ]; then + echo "๐Ÿ”„ Updating card in running test environment (preserving HA state)..." + + # Copy card file + echo "๐Ÿ“‹ Updating card file..." + mkdir -p test-ha/config/www + + # Copy file using Docker (containers run as root, so we use root to write) + docker run --rm \ + -v "$(pwd)/dist/bom-local-radar-card.js:/source/bom-local-radar-card.js:ro" \ + -v "$(pwd)/test-ha/config/www:/dest:rw" \ + -u root \ + alpine:latest \ + sh -c "cp /source/bom-local-radar-card.js /dest/bom-local-radar-card.js && chmod 644 /dest/bom-local-radar-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-radar-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-radar-card.js" 2>/dev/null || stat -c%s "dist/bom-local-radar-card.js" 2>/dev/null) + DEST_SIZE=$(stat -f%z "test-ha/config/www/bom-local-radar-card.js" 2>/dev/null || stat -c%s "test-ha/config/www/bom-local-radar-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-radar-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)" + + # Restart HA to pick up changes + 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 + + # Determine compose files to use (check if override exists) + 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 + + # If service path is specified, rebuild and restart the service + if [ -n "$SERVICE_PATH" ]; then + echo "๐Ÿ”จ Rebuilding service from local source: $SERVICE_PATH" + + # Ensure override file exists for local service build + OVERRIDE_FILE="docker-compose.test.local.yml" + if [ ! -f "$OVERRIDE_FILE" ]; then + cat > "$OVERRIDE_FILE" </dev/null | grep -c "Up" || echo "0") + if [ "$SERVICE_STATUS" = "0" ]; then + echo "๐Ÿ”„ Restarting BOM service (was not running)..." + $DOCKER_COMPOSE_CMD $COMPOSE_FILES restart bom-local-service || \ + $DOCKER_COMPOSE_CMD $COMPOSE_FILES up -d bom-local-service + + # Wait a moment and check if service started + sleep 3 + if ! curl -s -f "http://localhost:${SERVICE_HOST_PORT}/api/radar/Brisbane/QLD" > /dev/null 2>&1; then + echo " โš ๏ธ Warning: BOM service may not be responding correctly" + echo " Check logs: docker logs bom-card-test-service" + else + echo " โœ… Service is responding" + fi + fi + + echo "โณ Waiting for Home Assistant to restart..." + sleep 5 + + MAX_WAIT=60 + WAIT_COUNT=0 + while [ $WAIT_COUNT -lt $MAX_WAIT ]; do + if curl -s -f "http://localhost:${HA_HOST_PORT}" > /dev/null 2>&1; then + echo "โœ… Home Assistant restarted with updated card!" + break + fi + sleep 2 + WAIT_COUNT=$((WAIT_COUNT + 2)) + done + + echo "" + echo "โœ… Card updated! Refresh your browser (Ctrl+F5) to see changes." + exit 0 +fi + +# Normal mode - full setup (containers not running) +echo "๐Ÿš€ Setting up fresh test environment..." +echo "๐Ÿณ Stopping any existing containers..." +# Clean up any existing override files +rm -f docker-compose.test.local.yml + +# Determine compose files to use +COMPOSE_FILES_CLEAN="-f docker-compose.test.yml" +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 + +echo "๐Ÿ“ฆ Ensuring test directories exist..." +mkdir -p test-ha/config/.storage +mkdir -p test-ha/config/www + +# Ensure directories exist (Docker will create files as current user due to user: setting in compose) +# Copy built card file to www directory (for /local/ access in HA) +echo "๐Ÿ“‹ Copying card file to www directory..." +mkdir -p test-ha/config/www + +# Check if update is needed by comparing file sizes and content +NEEDS_UPDATE=1 +if [ -f "test-ha/config/www/bom-local-radar-card.js" ]; then + SOURCE_SIZE=$(stat -f%z "dist/bom-local-radar-card.js" 2>/dev/null || stat -c%s "dist/bom-local-radar-card.js" 2>/dev/null) + DEST_SIZE=$(stat -f%z "test-ha/config/www/bom-local-radar-card.js" 2>/dev/null || stat -c%s "test-ha/config/www/bom-local-radar-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-radar-card.js 2>/dev/null || \ + grep -q "Resolve relative image URLs" test-ha/config/www/bom-local-radar-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-radar-card.js" 2>/dev/null || stat -c%Y "dist/bom-local-radar-card.js" 2>/dev/null) + DEST_MTIME=$(stat -f%m "test-ha/config/www/bom-local-radar-card.js" 2>/dev/null || stat -c%Y "test-ha/config/www/bom-local-radar-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-radar-card.js:/source/bom-local-radar-card.js:ro" \ + -v "$(pwd)/test-ha/config/www:/dest:rw" \ + -u root \ + alpine:latest \ + sh -c "cp /source/bom-local-radar-card.js /dest/bom-local-radar-card.js && chmod 644 /dest/bom-local-radar-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-radar-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-radar-card.js" 2>/dev/null || stat -c%s "dist/bom-local-radar-card.js" 2>/dev/null) + DEST_SIZE=$(stat -f%z "test-ha/config/www/bom-local-radar-card.js" 2>/dev/null || stat -c%s "test-ha/config/www/bom-local-radar-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-radar-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 +echo "๐Ÿ“‹ Copying HA configuration files..." +for config_file in configuration.yaml ui-lovelace.yaml; do + if [ ! -f "test-ha/$config_file" ]; then + echo " โš ๏ธ Warning: test-ha/$config_file not found, skipping..." + continue + fi + cp "test-ha/$config_file" "test-ha/config/$config_file" +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 < /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 or docker-compose not found" + exit 1 +fi + +# Handle service build - either from local source or pre-built image +DOCKER_COMPOSE_FILES="-f docker-compose.test.yml" +if [ -n "$SERVICE_PATH" ]; then + echo "๐Ÿ”จ Building service from local source: $SERVICE_PATH" + + # Create docker-compose override file for local service build + OVERRIDE_FILE="docker-compose.test.local.yml" + cat > "$OVERRIDE_FILE" < /dev/null 2>&1; then + echo "โœ… Home Assistant is ready!" + break + fi + sleep 2 + WAIT_COUNT=$((WAIT_COUNT + 2)) + if [ $((WAIT_COUNT % 10)) -eq 0 ]; then + echo " Still waiting... (${WAIT_COUNT}s / ${MAX_WAIT}s)" + fi +done + +if [ $WAIT_COUNT -ge $MAX_WAIT ]; then + echo " โš ๏ธ Warning: Home Assistant may not be fully ready yet" + echo " Check logs: $DOCKER_COMPOSE_CMD $DOCKER_COMPOSE_FILES logs homeassistant" +fi + +echo "" +echo "โœ… Test environment started!" +echo "" +echo "๐Ÿ“‹ Next steps:" +echo " 1. Open http://localhost:${HA_HOST_PORT} in your browser" +echo " 2. Create a user account (first time only)" +echo " 3. The test dashboard should be available with your custom card" +echo " 4. Check browser console (F12) for any errors" +echo "" +echo "๐Ÿ”ง Useful commands:" +echo " View logs: $DOCKER_COMPOSE_CMD $DOCKER_COMPOSE_FILES logs -f" +echo " Stop: $DOCKER_COMPOSE_CMD $DOCKER_COMPOSE_FILES down" +echo " Clean restart: ./run.sh clean && ./scripts/test.sh" +echo " Force rebuild: ./scripts/test.sh --force-build" +echo " Docker build: ./scripts/test.sh --docker-build" +echo " NPM build: ./scripts/test.sh --npm-build" +if [ -n "$SERVICE_PATH" ]; then + echo " Local service: ./scripts/test.sh --service-path $SERVICE_PATH" +fi +echo " Help: ./scripts/test.sh --help" +echo "" diff --git a/src/bom-local-radar-card.ts b/src/bom-local-radar-card.ts new file mode 100644 index 0000000..0d4e6f4 --- /dev/null +++ b/src/bom-local-radar-card.ts @@ -0,0 +1,923 @@ +import { LitElement, html, css, type CSSResultGroup, type TemplateResult } from 'lit'; +import { property, customElement, state } from 'lit/decorators.js'; +import { HomeAssistant, LovelaceCardEditor, LovelaceCard } from 'custom-card-helpers'; +import './editor'; +import { BomLocalRadarCardConfig, RadarResponse, RadarFrame, RadarTimeSeriesResponse } from './types'; +import { CARD_VERSION, DEFAULT_SERVICE_URL, DEFAULT_FRAME_INTERVAL, DEFAULT_REFRESH_INTERVAL, DEFAULT_RESTART_DELAY } from './const'; + +console.info( + `%c BOM-LOCAL-RADAR-CARD \n%c Version ${CARD_VERSION} `, + 'color: orange; font-weight: bold; background: black', + 'color: white; font-weight: bold; background: dimgray', +); + +type LovelaceCustomCard = { + type: string; + name: string; + preview?: boolean; + description?: string; +}; + +declare global { + interface Window { + customCards?: LovelaceCustomCard[]; + } +} + +window.customCards = window.customCards ?? []; +window.customCards.push({ + type: 'bom-local-radar-card', + name: 'BOM Local Radar Card', + description: 'A rain radar card using the local BOM service', +}); + +@customElement('bom-local-radar-card') +export class BomLocalRadarCard extends LitElement implements LovelaceCard { + static override styles: CSSResultGroup = css` + #card { + overflow: hidden; + } + #root { + width: 100%; + position: relative; + } + .radar-image-container { + position: relative; + width: 100%; + background: #000; + border-radius: 8px; + overflow: hidden; + margin-bottom: 15px; + aspect-ratio: 16/9; + min-height: 250px; + } + .radar-image { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: contain; + display: block; + image-rendering: crisp-edges; + } + .loading { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: white; + font-size: 1.2em; + } + .frame-slider-container { + width: 100%; + padding: 15px; + background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%); + border-radius: 12px; + border: 2px solid #e0e0e0; + box-shadow: 0 4px 16px rgba(0,0,0,0.08); + margin-bottom: 15px; + box-sizing: border-box; + } + .frame-slider-wrapper { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; + flex-wrap: wrap; + } + .frame-nav-btn { + padding: 10px 16px; + border: 2px solid var(--primary-color, #667eea); + background: white; + color: var(--primary-color, #667eea); + border-radius: 10px; + cursor: pointer; + font-weight: 700; + font-size: 0.9em; + transition: all 0.2s; + min-width: 50px; + display: flex; + align-items: center; + justify-content: center; + } + .frame-nav-btn:hover:not(:disabled) { + background: var(--primary-color, #667eea); + color: white; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); + } + .frame-nav-btn:disabled { + opacity: 0.4; + cursor: not-allowed; + background: #f0f0f0; + border-color: #d0d0d0; + color: #999; + } + .frame-slider { + flex: 1; + min-width: 120px; + height: 10px; + border-radius: 5px; + background: #e0e0e0; + outline: none; + -webkit-appearance: none; + cursor: pointer; + order: 3; + } + .frame-slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 24px; + height: 24px; + border-radius: 50%; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + cursor: pointer; + box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4); + border: 3px solid white; + transition: all 0.2s; + } + .frame-slider::-webkit-slider-thumb:hover { + transform: scale(1.15); + box-shadow: 0 4px 16px rgba(102, 126, 234, 0.6); + } + .frame-slider::-webkit-slider-runnable-track { + height: 10px; + border-radius: 5px; + background: linear-gradient(to right, #667eea 0%, #764ba2 100%); + } + .frame-slider::-moz-range-thumb { + width: 24px; + height: 24px; + border-radius: 50%; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + cursor: pointer; + border: 3px solid white; + box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4); + } + .frame-slider::-moz-range-track { + height: 10px; + border-radius: 5px; + background: #e0e0e0; + } + .frame-slider::-moz-range-progress { + height: 10px; + border-radius: 5px; + background: linear-gradient(to right, #667eea 0%, #764ba2 100%); + } + .play-controls { + display: flex; + gap: 8px; + justify-content: center; + margin-bottom: 15px; + flex-wrap: wrap; + } + .play-btn { + padding: 12px 20px; + border: none; + background: var(--primary-color, #667eea); + color: white; + border-radius: 8px; + cursor: pointer; + font-weight: 600; + font-size: 0.9em; + transition: all 0.2s; + min-height: 44px; + } + .play-btn:hover:not(:disabled) { + background: var(--primary-color-dark, #5568d3); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); + } + .play-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + .frame-info { + text-align: center; + color: var(--secondary-text-color, #666); + font-size: 0.9em; + margin-top: 10px; + } + .info-section { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 15px; + margin-bottom: 15px; + } + .info-card { + background: var(--card-background-color, white); + border-radius: 8px; + padding: 15px; + border-left: 4px solid var(--primary-color, #667eea); + } + .info-card h3 { + font-size: 0.8em; + text-transform: uppercase; + color: var(--secondary-text-color, #666); + margin-bottom: 6px; + letter-spacing: 0.5px; + } + .info-card .value { + font-size: 1.2em; + font-weight: 600; + color: var(--primary-text-color, #333); + } + .error { + background: #f8d7da; + color: #721c24; + padding: 15px; + border-radius: 8px; + margin: 20px; + border-left: 4px solid #dc3545; + } + #card-title { + margin: 8px 0px 4px 8px; + font-size: 1.5em; + } + @media (min-width: 480px) { + .frame-slider-wrapper { + flex-wrap: nowrap; + } + .frame-slider { + order: 0; + } + } + `; + + @property({ attribute: false }) public hass!: HomeAssistant; + @property({ attribute: false }) private _config!: BomLocalRadarCardConfig; + @property({ attribute: false }) public editMode?: boolean; + + @state() private radarData?: RadarResponse; + @state() private currentFrameIndex = 0; + @state() private isLoading = false; + @state() private error?: string; + @state() private animationTimer?: NodeJS.Timeout; + @state() private refreshTimer?: NodeJS.Timeout; + @state() private retryTimer?: NodeJS.Timeout; + @state() private isPlaying = false; + @state() private frames: RadarFrame[] = []; + @state() private isExtendedMode = false; + private preloadedImages: HTMLImageElement[] = []; + + public static async getConfigElement(): Promise { + return document.createElement('bom-local-radar-card-editor') as LovelaceCardEditor; + } + + public static getStubConfig(): Record { + return { + type: 'custom:bom-local-radar-card', + suburb: 'Pomona', + state: 'QLD', + service_url: 'http://localhost:8082', + }; + } + + public setConfig(config: BomLocalRadarCardConfig): void { + if (!config.suburb || !config.state) { + throw new Error('suburb and state are required'); + } + this._config = config; + } + + getCardSize(): number { + return 10; + } + + /** + * Resolves a URL relative to the service URL if it's a relative path + * Service returns relative URLs like "/api/radar/.../frame/..." which need + * to be resolved against the service_url base + */ + private resolveImageUrl(url: string, serviceUrl: string): string { + if (!url) return url; + // If URL is already absolute (starts with http:// or https://), use as-is + if (url.startsWith('http://') || url.startsWith('https://')) { + return url; + } + // If URL is relative (starts with /), resolve against service URL + if (url.startsWith('/')) { + // Remove trailing slash from serviceUrl if present + const baseUrl = serviceUrl.replace(/\/$/, ''); + return `${baseUrl}${url}`; + } + // If URL is relative without leading slash, resolve against service URL with path + const baseUrl = serviceUrl.replace(/\/$/, ''); + return `${baseUrl}/${url}`; + } + + /** + * Fetches radar data from the local service + * Supports both latest frames and historical timeseries + */ + private async fetchRadarData(): Promise { + const serviceUrl = this._config.service_url || DEFAULT_SERVICE_URL; + const suburb = encodeURIComponent(this._config.suburb); + const state = encodeURIComponent(this._config.state); + + try { + this.isLoading = true; + this.error = undefined; + + // Check if we're in extended mode (historical data) + const timespan = this._config.timespan || 'latest'; + + if (timespan !== 'latest') { + // Fetch historical data via timeseries endpoint + return await this.fetchHistoricalRadar(serviceUrl, suburb, state, timespan); + } else { + // Fetch latest frames + const url = `${serviceUrl}/api/radar/${suburb}/${state}`; + const response = await fetch(url, { + method: 'GET', + headers: { + 'Accept': 'application/json', + }, + }); + + 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); + } + this.retryTimer = setTimeout(() => { + this.retryTimer = undefined; + this.fetchRadarData(); + }, errorData.retryAfter * 1000); + return null; + } + throw new Error('Radar data not found. Cache may be updating.'); + } + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data: any = await response.json(); + + // Check if response contains an error (cache not ready, etc.) + 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; + } + throw new Error(data.error || 'Service returned an error'); + } + + // Validate response has frames + if (!data.frames || data.frames.length === 0) { + throw new Error('No frames available in response'); + } + + // Resolve relative image URLs against service URL + // Service returns relative URLs like "/api/radar/.../frame/..." + // which need to be absolute for browser to fetch from correct origin + data.frames.forEach((frame: RadarFrame) => { + if (frame.imageUrl) { + frame.imageUrl = this.resolveImageUrl(frame.imageUrl, serviceUrl); + } + }); + + this.radarData = data; + this.frames = data.frames.sort((a, b) => a.frameIndex - b.frameIndex); + this.isExtendedMode = false; + this.isLoading = false; + + // Preload images + this.preloadImages(this.frames); + + // Start animation if auto-play is enabled and not already playing + // Only restart if frames changed or we're not playing + if (this._config.auto_play !== false && !this.isPlaying) { + this.startAnimation(); + } + + return data; + } + } catch (err) { + this.isLoading = false; + this.error = err instanceof Error ? err.message : 'Failed to fetch radar data'; + console.error('Error fetching radar data:', err); + return null; + } + } + + /** + * Fetches historical radar data for extended timespan + */ + private async fetchHistoricalRadar( + serviceUrl: string, + suburb: string, + state: string, + timespan: string + ): Promise { + try { + let startTime: Date | null = null; + let endTime = new Date(); + + if (timespan === 'custom') { + if (this._config.custom_start_time) { + startTime = new Date(this._config.custom_start_time); + } + if (this._config.custom_end_time) { + endTime = new Date(this._config.custom_end_time); + } + } else { + // Calculate hours back from now + const hours = parseInt(timespan.replace('h', '')) || 1; + startTime = new Date(endTime.getTime() - (hours * 60 * 60 * 1000)); + } + + if (!startTime) { + throw new Error('Invalid timespan configuration'); + } + + const url = `${serviceUrl}/api/radar/${suburb}/${state}/timeseries?startTime=${startTime.toISOString()}&endTime=${endTime.toISOString()}`; + const response = await fetch(url, { + method: 'GET', + headers: { + 'Accept': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data: RadarTimeSeriesResponse = await response.json(); + + if (!data.cacheFolders || data.cacheFolders.length === 0) { + throw new Error('No historical data found for the specified time range.'); + } + + // Flatten all frames from all cache folders + const allFrames: RadarFrame[] = []; + data.cacheFolders.forEach(cacheFolder => { + cacheFolder.frames.forEach(frame => { + 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); + } + allFrames.push(frame); + }); + }); + + // Re-index frames sequentially + allFrames.forEach((frame, idx) => { + frame.sequentialIndex = idx; + }); + + // Fetch latest metadata for display + let metadata: Partial = {}; + try { + const metadataUrl = `${serviceUrl}/api/radar/${suburb}/${state}/metadata`; + const metadataResponse = await fetch(metadataUrl); + if (metadataResponse.ok) { + metadata = await metadataResponse.json(); + } + } catch (err) { + console.debug('Could not fetch metadata:', err); + } + + // Create RadarResponse-like object + const newestCacheFolder = data.cacheFolders[data.cacheFolders.length - 1]; + const radarResponse: RadarResponse = { + frames: allFrames, + lastUpdated: endTime.toISOString(), + observationTime: metadata.observationTime || newestCacheFolder?.observationTime || endTime.toISOString(), + forecastTime: endTime.toISOString(), + weatherStation: metadata.weatherStation, + distance: metadata.distance, + cacheIsValid: metadata.cacheIsValid ?? true, + cacheExpiresAt: metadata.cacheExpiresAt || endTime.toISOString(), + isUpdating: metadata.isUpdating || false, + nextUpdateTime: metadata.nextUpdateTime || endTime.toISOString(), + }; + + this.radarData = radarResponse; + this.frames = allFrames; + this.isExtendedMode = true; + this.isLoading = false; + + // Preload images + this.preloadImages(this.frames); + + // Start animation if auto-play is enabled and not already playing + // Only restart if frames changed or we're not playing + if (this._config.auto_play !== false && !this.isPlaying) { + this.startAnimation(); + } + + return radarResponse; + } catch (err) { + this.isLoading = false; + this.error = err instanceof Error ? err.message : 'Failed to fetch historical radar data'; + console.error('Error fetching historical radar data:', err); + return null; + } + } + + /** + * Gets the current frame image URL + */ + private getCurrentFrameUrl(): string | null { + if (!this.frames || this.frames.length === 0) { + return null; + } + const frame = this.frames[this.currentFrameIndex]; + return frame?.imageUrl || null; + } + + /** + * 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', + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } + + /** + * Preloads all frame images to prevent jiggle when switching + * Cleans up old preloaded images to prevent memory leaks + */ + private preloadImages(frames: RadarFrame[]): void { + // Clean up old preloaded images + this.preloadedImages.forEach(img => { + img.src = ''; + img.onload = null; + img.onerror = null; + }); + this.preloadedImages = []; + + // Preload new images + frames.forEach(frame => { + const img = new Image(); + img.src = frame.imageUrl; + this.preloadedImages.push(img); + }); + } + + /** + * Starts the frame animation loop + * Ensures only one animation timer is active at a time + */ + private startAnimation(): void { + // Always stop existing animation first to prevent accumulation + this.stopAnimation(); + + if (this.frames.length === 0) { + return; + } + + this.isPlaying = true; + const frameInterval = (this._config.frame_interval || DEFAULT_FRAME_INTERVAL) * 1000; + const restartDelay = DEFAULT_RESTART_DELAY; + const maxFrame = this.frames.length - 1; + + // Use a single consistent timer approach (setTimeout chain, not setInterval) + // This prevents timer accumulation and makes cleanup easier + const animate = () => { + // Check if we're still playing (might have been stopped) + if (!this.isPlaying) { + return; + } + + // Check if animation was cleared (component disconnected) + if (!this.animationTimer) { + return; + } + + if (this.currentFrameIndex >= maxFrame) { + // Pause before restarting + this.animationTimer = setTimeout(() => { + if (!this.isPlaying) return; + this.currentFrameIndex = 0; + this.requestUpdate(); + this.animationTimer = setTimeout(animate, frameInterval); + }, restartDelay); + } else { + this.currentFrameIndex++; + this.requestUpdate(); + this.animationTimer = setTimeout(animate, frameInterval); + } + }; + + // Start immediately + this.animationTimer = setTimeout(animate, 0); + } + + /** + * Stops the animation + */ + private stopAnimation(): void { + if (this.animationTimer) { + clearInterval(this.animationTimer); + clearTimeout(this.animationTimer); + this.animationTimer = undefined; + } + this.isPlaying = false; + } + + /** + * Toggles play/pause + */ + private toggleAnimation(): void { + if (this.isPlaying) { + this.stopAnimation(); + } else { + this.startAnimation(); + } + } + + /** + * Shows a specific frame + */ + private showFrame(index: number): void { + if (index < 0 || index >= this.frames.length) { + return; + } + this.currentFrameIndex = index; + this.requestUpdate(); + } + + /** + * Navigate to previous frame + */ + private previousFrame(): void { + this.stopAnimation(); + const newIndex = this.currentFrameIndex > 0 ? this.currentFrameIndex - 1 : this.frames.length - 1; + this.showFrame(newIndex); + } + + /** + * Navigate to next frame + */ + private nextFrame(): void { + this.stopAnimation(); + const newIndex = (this.currentFrameIndex + 1) % this.frames.length; + this.showFrame(newIndex); + } + + /** + * Jump forward/backward by N frames + */ + private jumpFrame(offset: number): void { + const newIndex = Math.max(0, Math.min(this.frames.length - 1, this.currentFrameIndex + offset)); + this.showFrame(newIndex); + } + + /** + * Auto-refresh data + */ + private startAutoRefresh(): void { + if (this.refreshTimer) { + clearInterval(this.refreshTimer); + } + + const refreshInterval = (this._config.refresh_interval || DEFAULT_REFRESH_INTERVAL) * 1000; + this.refreshTimer = setInterval(() => { + this.fetchRadarData(); + }, refreshInterval); + } + + /** + * Stops auto-refresh + */ + private stopAutoRefresh(): void { + if (this.refreshTimer) { + clearInterval(this.refreshTimer); + this.refreshTimer = undefined; + } + } + + async firstUpdated(): Promise { + await this.fetchRadarData(); + this.startAutoRefresh(); + } + + public override connectedCallback(): void { + super.connectedCallback(); + } + + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this.stopAnimation(); + this.stopAutoRefresh(); + + // Clean up retry timer + if (this.retryTimer) { + clearTimeout(this.retryTimer); + this.retryTimer = undefined; + } + + // Clean up preloaded images + this.preloadedImages.forEach(img => { + img.src = ''; + img.onload = null; + img.onerror = null; + }); + this.preloadedImages = []; + } + + protected override updated(changedProperties: Map): void { + super.updated(changedProperties); + + // Handle config changes + if (changedProperties.has('_config')) { + const oldConfig = changedProperties.get('_config') as BomLocalRadarCardConfig | undefined; + if (oldConfig && this._config) { + // If location or timespan changed, refetch data + if (oldConfig.suburb !== this._config.suburb || + oldConfig.state !== this._config.state || + oldConfig.timespan !== this._config.timespan || + oldConfig.custom_start_time !== this._config.custom_start_time || + oldConfig.custom_end_time !== this._config.custom_end_time) { + this.fetchRadarData(); + } + // If service URL changed, refetch + if (oldConfig.service_url !== this._config.service_url) { + this.fetchRadarData(); + } + // If refresh interval changed, restart auto-refresh + if (oldConfig.refresh_interval !== this._config.refresh_interval) { + this.startAutoRefresh(); + } + // If frame interval changed and playing, restart animation + if (oldConfig.frame_interval !== this._config.frame_interval && this.isPlaying) { + this.stopAnimation(); + this.startAnimation(); + } + } + } + } + + protected override render(): TemplateResult { + if (!this._config) { + return html`Configuration error`; + } + + 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]; + + // 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`; + } + } + + const progress = this.frames.length > 0 + ? Math.round(((this.currentFrameIndex + 1) / this.frames.length) * 100) + : 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} + ` + : '' + } +
+ + ${this.frames.length > 0 ? html` +
+
+ + + + + +
+
+ ${frameInfoText} โ€ข Progress: ${progress}% +
+
+ +
+ + + +
+ ` : ''} +
+
+ `; + } +} + +if (!customElements.get('bom-local-radar-card')) { + customElements.define('bom-local-radar-card', BomLocalRadarCard); +} + + + + + + + + + diff --git a/src/const.ts b/src/const.ts new file mode 100644 index 0000000..fd87837 --- /dev/null +++ b/src/const.ts @@ -0,0 +1,15 @@ +export const CARD_VERSION = '1.0.0'; +export const DEFAULT_SERVICE_URL = 'http://localhost:8082'; +export const DEFAULT_FRAME_INTERVAL = 2.0; // seconds +export const DEFAULT_RESTART_DELAY = 2000; // ms (pause before looping) +export const DEFAULT_REFRESH_INTERVAL = 30; // seconds + + + + + + + + + + diff --git a/src/editor.ts b/src/editor.ts new file mode 100644 index 0000000..f114546 --- /dev/null +++ b/src/editor.ts @@ -0,0 +1,143 @@ +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'; + +@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(); + + private _mergeWithDefaults(config: Partial = {}): BomLocalRadarCardConfig { + const defaults: Partial = { + service_url: 'http://localhost:8082', + timespan: 'latest', + frame_interval: 2.0, + refresh_interval: 30, + auto_play: true, + show_timestamp: true, + show_metadata: true, + }; + + return { + ...defaults, + ...config, + type: 'custom:bom-local-radar-card', + } as BomLocalRadarCardConfig; + } + + public setConfig(config: BomLocalRadarCardConfig): void { + this._config = this._mergeWithDefaults(config); + this.requestUpdate(); + } + + protected render(): TemplateResult { + if (!this.hass) return html``; + + return html` +
+
+

Service Configuration

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

Slideshow

+ this._updateConfig('timespan', (e.target as HTMLSelectElement).value)} + > + + + + + + + + this._updateConfig('frame_interval', parseFloat((e.target as HTMLInputElement).value))} + > + this._updateConfig('auto_play', (e.target as HTMLInputElement).checked)} + > +
+ +
+

Auto Refresh

+ this._updateConfig('refresh_interval', parseInt((e.target as HTMLInputElement).value))} + > +
+
+ `; + } + + private _updateConfig(key: string, value: unknown): void { + const config = { ...this._config, [key]: value }; + this._config = config; + fireEvent(this, 'config-changed', { config }); + } + + static styles: CSSResultGroup = css` + .editor { padding: 8px 16px; } + .section { margin: 12px 0; } + .section h3 { margin: 0 0 8px; font-weight: 600; } + `; +} + + + + + + + + + diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..c7798ca --- /dev/null +++ b/src/types.ts @@ -0,0 +1,88 @@ +import { LovelaceCardConfig } from 'custom-card-helpers'; + +// API Response types matching the .NET service +export interface RadarResponse { + frames: RadarFrame[]; + lastUpdated: string; // ISO 8601 UTC + observationTime: string; // ISO 8601 UTC + forecastTime: string; // ISO 8601 UTC + weatherStation?: string; + distance?: string; + cacheIsValid: boolean; + cacheExpiresAt?: string; // ISO 8601 UTC + isUpdating: boolean; + nextUpdateTime?: string; // ISO 8601 UTC +} + +export interface RadarFrame { + frameIndex: number; + imageUrl: string; + minutesAgo: number; + absoluteObservationTime?: string; // ISO 8601 UTC + cacheTimestamp?: string; // For extended mode + observationTime?: string; // For extended mode + cacheFolderName?: string; // For extended mode + sequentialIndex?: number; // For extended mode +} + +export interface RadarTimeSeriesResponse { + cacheFolders: RadarCacheFolderFrames[]; + startTime?: string; + endTime?: string; + totalFrames: number; +} + +export interface RadarCacheFolderFrames { + cacheFolderName: string; + cacheTimestamp: string; + observationTime: string; + frames: RadarFrame[]; +} + +export interface CacheRangeInfo { + totalCacheFolders: number; + timeSpanMinutes?: number; + oldestCache: { + cacheTimestamp: string; + }; + newestCache: { + cacheTimestamp: string; + }; +} + +// Card configuration +export interface BomLocalRadarCardConfig extends LovelaceCardConfig { + type: 'custom:bom-local-radar-card'; + + // Service configuration + service_url?: string; // Base URL of bom-local-service (e.g., "http://localhost:8082") + suburb: string; // Required: suburb name (e.g., "Pomona") + state: string; // Required: state abbreviation (e.g., "QLD") + + // Display + card_title?: string; + show_timestamp?: boolean; + show_metadata?: boolean; + + // Slideshow configuration + timespan?: 'latest' | '1h' | '3h' | '6h' | '12h' | '24h' | 'custom'; // Historical data timespan + frame_interval?: number; // Seconds between frames (default: 2.0) + auto_play?: boolean; // Auto-start animation (default: true) + + // Auto-refresh + refresh_interval?: number; // Seconds between API refreshes (default: 30) + + // Custom time range (for timespan: 'custom') + custom_start_time?: string; // ISO 8601 datetime + custom_end_time?: string; // ISO 8601 datetime +} + + + + + + + + + + diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bab6a18 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2017", + "module": "esnext", + "moduleResolution": "node", + "lib": ["es2017", "dom", "dom.iterable"], + "noEmit": true, + "strict": true, + "noImplicitAny": false, + "skipLibCheck": true, + "resolveJsonModule": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true + } +} + + + + + + + + +