mirror of
https://github.com/alexhopeoconnor/bom-local-card.git
synced 2026-10-03 22:22:00 +10:00
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:
+23
@@ -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/
|
||||||
@@ -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"]
|
||||||
|
|
||||||
@@ -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).
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "BOM Local Radar Card",
|
||||||
|
"render_readme": true,
|
||||||
|
"filename": "bom-local-radar-card.js",
|
||||||
|
"homeassistant": "2024.1.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Generated
+649
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
Executable
+62
@@ -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)"
|
||||||
|
|
||||||
Executable
+619
@@ -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 ""
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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
@@ -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; }
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user