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
This commit is contained in:
2025-12-16 19:51:16 +10:00
parent 1259a2e779
commit d49228d671
16 changed files with 3174 additions and 0 deletions
+23
View File
@@ -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/
+22
View File
@@ -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"]
+280
View File
@@ -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).
+49
View File
@@ -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
+14
View File
@@ -0,0 +1,14 @@
{
"name": "BOM Local Radar Card",
"render_readme": true,
"filename": "bom-local-radar-card.js",
"homeassistant": "2024.1.0"
}
+649
View File
@@ -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"
}
}
}
+51
View File
@@ -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]"
}
+35
View File
@@ -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
};
Executable
+177
View File
@@ -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
+62
View File
@@ -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)"
+619
View File
@@ -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 <<EOF
Usage: $0 [OPTIONS]
Start or update the test environment for bom-local-card.
Options:
--skip-build Skip building the card (use existing dist file)
--force-build Force rebuild of the card (no cache)
--docker-build Use Docker to build the card (isolated environment)
--npm-build Use npm to build the card (requires local Node.js)
--service-path PATH Build service from local source at PATH instead of using pre-built image
-h, --help Show this help message
Examples:
$0 # Auto-detect build method, use pre-built service
$0 --force-build # Force rebuild card, use pre-built service
$0 --docker-build # Build card with Docker
$0 --service-path ../bom-local-service # Build service from local source
$0 --force-build --service-path ../bom-local-service # Both options
Notes:
- If containers are already running, the script automatically updates the card
and restarts Home Assistant (preserves HA state)
- Use --service-path to test local service changes without pushing releases
- The script auto-detects Docker or npm for building (Docker preferred)
EOF
exit 0
}
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
--skip-build)
SKIP_BUILD=1
shift
;;
--force-build)
FORCE_BUILD=1
shift
;;
--docker-build)
USE_DOCKER_BUILD=1
shift
;;
--npm-build)
USE_NPM_BUILD=1
shift
;;
--service-path)
if [ -z "$2" ]; then
echo "❌ Error: --service-path requires a path argument"
exit 1
fi
SERVICE_PATH="$2"
shift 2
;;
-h|--help)
show_help
;;
*)
echo "❌ Error: Unknown option: $1"
echo " Use --help for usage information"
exit 1
;;
esac
done
# Validate service path if provided
if [ -n "$SERVICE_PATH" ]; then
# Resolve to absolute path
if [ ! -d "$SERVICE_PATH" ]; then
echo "❌ Error: Service path does not exist: $SERVICE_PATH"
exit 1
fi
SERVICE_PATH="$(cd "$SERVICE_PATH" && pwd)"
if [ ! -f "$SERVICE_PATH/BomLocalService.csproj" ]; then
echo "❌ Error: SERVICE_PATH does not point to a valid service directory"
echo " Expected BomLocalService.csproj at: $SERVICE_PATH/BomLocalService.csproj"
exit 1
fi
echo "📦 Using local service source: $SERVICE_PATH"
fi
# Auto-detect build method if not specified
if [ -z "$USE_DOCKER_BUILD" ] && [ -z "$USE_NPM_BUILD" ]; then
# Prefer Docker if available, fallback to npm
if command -v docker &> /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" <<EOF
services:
bom-local-service:
build:
context: $SERVICE_PATH
dockerfile: Dockerfile
image: bom-local-service:local-test
EOF
fi
# Check if containers are already running
CONTAINERS_RUNNING=0
COMPOSE_FILES_CHECK="-f docker-compose.test.yml"
if [ -f "docker-compose.test.local.yml" ]; then
COMPOSE_FILES_CHECK="$COMPOSE_FILES_CHECK -f docker-compose.test.local.yml"
fi
if docker compose $COMPOSE_FILES_CHECK ps 2>/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" <<EOF
services:
bom-local-service:
build:
context: $SERVICE_PATH
dockerfile: Dockerfile
image: bom-local-service:local-test
EOF
fi
COMPOSE_FILES="$COMPOSE_FILES -f $OVERRIDE_FILE"
# Rebuild service - Docker's cache will detect source file changes automatically
# Only use --no-cache if explicitly needed (it's much slower)
echo " Building service image (Docker will detect code changes automatically)..."
if $DOCKER_COMPOSE_CMD $COMPOSE_FILES build bom-local-service; then
echo " ✅ Service rebuilt successfully"
echo "🔄 Restarting service container..."
if $DOCKER_COMPOSE_CMD $COMPOSE_FILES up -d --no-deps bom-local-service; then
echo " ✅ Service restarted with latest code"
else
echo " ❌ Error: Failed to restart service container"
echo " Check logs: docker logs bom-card-test-service"
exit 1
fi
else
echo " ❌ Error: Service rebuild failed"
echo " Check the build output above for details"
exit 1
fi
fi
echo "🔄 Restarting Home Assistant to load updated card..."
$DOCKER_COMPOSE_CMD $COMPOSE_FILES restart homeassistant
# Check and restart the service if it's not running (it may have crashed)
SERVICE_STATUS=$($DOCKER_COMPOSE_CMD $COMPOSE_FILES ps bom-local-service 2>/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 <<EOF
{
"data": {
"done": ["core_config", "integration"]
},
"key": "onboarding",
"version": 3
}
EOF
# Try docker compose (newer) first, fallback to docker-compose (older)
# Define this early so it can be used for service builds
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 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" <<EOF
services:
bom-local-service:
build:
context: $SERVICE_PATH
dockerfile: Dockerfile
image: bom-local-service:local-test
# Remove image specification when building
EOF
DOCKER_COMPOSE_FILES="$DOCKER_COMPOSE_FILES -f $OVERRIDE_FILE"
# Build service - Docker's cache will detect source file changes automatically
# The Dockerfile is structured to cache dependencies, so only changed source files trigger rebuilds
# This is much faster (~10-20s) than --no-cache (~100s) when only code changes
echo " Building service image (Docker will detect code changes automatically)..."
if ! $DOCKER_COMPOSE_CMD $DOCKER_COMPOSE_FILES build bom-local-service; then
echo " ❌ Error: Service build failed"
echo " Check the build output above for details"
exit 1
fi
echo " ✅ Service built successfully"
else
echo "🐳 Pulling latest service image..."
if ! docker pull ghcr.io/alexhopeoconnor/bom-local-service:latest; then
echo " ⚠️ Warning: Failed to pull service image, will use cached version if available"
else
echo " ✅ Service image pulled successfully"
fi
fi
echo "🐳 Starting test environment..."
echo " - BOM Local Service: http://localhost:${SERVICE_HOST_PORT}"
echo " - Home Assistant: http://localhost:${HA_HOST_PORT}"
echo ""
echo " On first access to HA, you'll be prompted to create a user account."
echo " This is a one-time setup - the account persists across restarts."
echo ""
echo " The service may take a minute to initialize on first run"
echo ""
# Start containers (use override file if building from local source)
$DOCKER_COMPOSE_CMD $DOCKER_COMPOSE_FILES up -d --build || {
echo "❌ Error: Failed to start containers"
cleanup
exit 1
}
# Wait for containers to be running
echo "⏳ Waiting for containers to start..."
sleep 3
# Check if containers are running
if ! $DOCKER_COMPOSE_CMD $DOCKER_COMPOSE_FILES ps | grep -q "Up"; then
echo " ⚠️ Warning: Some containers may not be running. Check logs:"
echo " $DOCKER_COMPOSE_CMD $DOCKER_COMPOSE_FILES logs"
fi
# Wait for HA to be ready (check if port is responding)
echo "⏳ Waiting for Home Assistant to be ready..."
MAX_WAIT=120
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 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 ""
+923
View File
@@ -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<LovelaceCardEditor> {
return document.createElement('bom-local-radar-card-editor') as LovelaceCardEditor;
}
public static getStubConfig(): Record<string, unknown> {
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<RadarResponse | null> {
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<RadarResponse | null> {
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<RadarResponse> = {};
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<void> {
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<string | number | symbol, unknown>): 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`<hui-warning>Configuration error</hui-warning>`;
}
const cardTitle = this._config.card_title
? html`<div id="card-title">${this._config.card_title}</div>`
: '';
if (this.error) {
return html`
<ha-card id="card">
${cardTitle}
<div class="error">${this.error}</div>
</ha-card>
`;
}
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`
<ha-card id="card">
${cardTitle}
<div id="root">
${this._config.show_metadata !== false ? html`
<div class="info-section">
<div class="info-card">
<h3>Cache Status</h3>
<div class="value">
${this.radarData?.isUpdating ? 'Updating' :
this.radarData?.cacheIsValid ? 'Valid' : 'Invalid'}
</div>
</div>
<div class="info-card">
<h3>Observation Time</h3>
<div class="value">${this.radarData?.observationTime ? this.formatTimestamp(this.radarData.observationTime) : '-'}</div>
</div>
<div class="info-card">
<h3>Weather Station</h3>
<div class="value">${this.radarData?.weatherStation || '-'}</div>
</div>
</div>
` : ''}
<div class="radar-image-container">
${this.isLoading
? html`<div class="loading">Loading radar data...</div>`
: currentFrameUrl
? html`
<img
class="radar-image"
src="${currentFrameUrl}"
alt="Radar frame ${this.currentFrameIndex}"
@error="${() => { this.error = 'Failed to load radar image'; }}"
/>
`
: ''
}
</div>
${this.frames.length > 0 ? html`
<div class="frame-slider-container">
<div class="frame-slider-wrapper">
<button
class="frame-nav-btn"
@click="${() => this.showFrame(0)}"
?disabled="${this.currentFrameIndex === 0}"
title="First frame"
>⏮</button>
<button
class="frame-nav-btn"
@click="${() => this.jumpFrame(-10)}"
?disabled="${this.currentFrameIndex === 0}"
title="Go back 10 frames"
>-10</button>
<input
type="range"
class="frame-slider"
min="0"
max="${this.frames.length - 1}"
.value="${this.currentFrameIndex}"
@input="${(e: Event) => this.showFrame(parseInt((e.target as HTMLInputElement).value))}"
/>
<button
class="frame-nav-btn"
@click="${() => this.jumpFrame(10)}"
?disabled="${this.currentFrameIndex >= this.frames.length - 1}"
title="Go forward 10 frames"
>+10</button>
<button
class="frame-nav-btn"
@click="${() => this.showFrame(this.frames.length - 1)}"
?disabled="${this.currentFrameIndex >= this.frames.length - 1}"
title="Last frame"
>⏭</button>
</div>
<div class="frame-info">
${frameInfoText} • Progress: ${progress}%
</div>
</div>
<div class="play-controls">
<button
class="play-btn"
@click="${() => this.toggleAnimation()}"
>${this.isPlaying ? '⏸ Pause' : '▶ Play'}</button>
<button
class="play-btn"
@click="${() => this.previousFrame()}"
>◀ Previous</button>
<button
class="play-btn"
@click="${() => this.nextFrame()}"
>Next ▶</button>
</div>
` : ''}
</div>
</ha-card>
`;
}
}
if (!customElements.get('bom-local-radar-card')) {
customElements.define('bom-local-radar-card', BomLocalRadarCard);
}
+15
View File
@@ -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
+143
View File
@@ -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> = {}): BomLocalRadarCardConfig {
const defaults: Partial<BomLocalRadarCardConfig> = {
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`
<div class="editor">
<div class="section">
<h3>Service Configuration</h3>
<ha-textfield
label="Service URL"
.value=${this._config.service_url || ''}
@input=${(e: Event) => this._updateConfig('service_url', (e.target as HTMLInputElement).value)}
helper="Base URL of bom-local-service (e.g., http://localhost:8082)"
></ha-textfield>
<ha-textfield
label="Suburb"
.value=${this._config.suburb || ''}
@input=${(e: Event) => this._updateConfig('suburb', (e.target as HTMLInputElement).value)}
required
></ha-textfield>
<ha-textfield
label="State"
.value=${this._config.state || ''}
@input=${(e: Event) => this._updateConfig('state', (e.target as HTMLInputElement).value)}
helper="State abbreviation (e.g., QLD, NSW, VIC)"
required
></ha-textfield>
</div>
<div class="section">
<h3>Display</h3>
<ha-textfield
label="Card Title"
.value=${this._config.card_title || ''}
@input=${(e: Event) => this._updateConfig('card_title', (e.target as HTMLInputElement).value)}
></ha-textfield>
<ha-switch
label="Show Metadata"
.checked=${this._config.show_metadata !== false}
@change=${(e: Event) => this._updateConfig('show_metadata', (e.target as HTMLInputElement).checked)}
></ha-switch>
</div>
<div class="section">
<h3>Slideshow</h3>
<ha-select
label="Timespan"
.value=${this._config.timespan || 'latest'}
@selected=${(e: Event) => this._updateConfig('timespan', (e.target as HTMLSelectElement).value)}
>
<option value="latest">Latest 7 frames</option>
<option value="1h">Last 1 hour</option>
<option value="3h">Last 3 hours</option>
<option value="6h">Last 6 hours</option>
<option value="12h">Last 12 hours</option>
<option value="24h">Last 24 hours</option>
</ha-select>
<ha-textfield
label="Frame Interval (seconds)"
type="number"
step="0.5"
min="0.5"
max="10"
.value=${String(this._config.frame_interval || 2.0)}
@input=${(e: Event) => this._updateConfig('frame_interval', parseFloat((e.target as HTMLInputElement).value))}
></ha-textfield>
<ha-switch
label="Auto Play"
.checked=${this._config.auto_play !== false}
@change=${(e: Event) => this._updateConfig('auto_play', (e.target as HTMLInputElement).checked)}
></ha-switch>
</div>
<div class="section">
<h3>Auto Refresh</h3>
<ha-textfield
label="Refresh Interval (seconds)"
type="number"
min="10"
max="300"
step="10"
.value=${String(this._config.refresh_interval || 30)}
@input=${(e: Event) => this._updateConfig('refresh_interval', parseInt((e.target as HTMLInputElement).value))}
></ha-textfield>
</div>
</div>
`;
}
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; }
`;
}
+88
View File
@@ -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
}
+24
View File
@@ -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
}
}