Initial implementation: Bordanlage boat onboard system
Complete multiroom audio + navigation dashboard: - Docker stack: SignalK, Snapcast (4 zones), librespot, shairport-sync, Mopidy, Jellyfin, Portainer - React 18 + Vite dashboard with nautical dark theme - Full mock system (SignalK NMEA simulation, Snapcast zones, Mopidy player) - Real API clients for all services with reconnect logic - SVG instruments: Compass, WindRose, Gauge, DepthSounder, SpeedLog - Pages: Overview, Navigation, Audio (zones/radio/library), Systems - Dev mode runs fully without hardware (make dev) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
25
bordanlage/.env
Normal file
25
bordanlage/.env
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# General
|
||||||
|
COMPOSE_PROJECT_NAME=bordanlage
|
||||||
|
DEV=true
|
||||||
|
|
||||||
|
# Spotify Connect
|
||||||
|
SPOTIFY_NAME=Bordanlage
|
||||||
|
SPOTIFY_BITRATE=320
|
||||||
|
SPOTIFY_CACHE_SIZE=1024
|
||||||
|
|
||||||
|
# Boat Info
|
||||||
|
BOAT_NAME=My Yacht
|
||||||
|
BOAT_MMSI=123456789
|
||||||
|
|
||||||
|
# Paths
|
||||||
|
MUSIC_PATH=./music
|
||||||
|
|
||||||
|
# Jellyfin API Key (set after first run)
|
||||||
|
JELLYFIN_API_KEY=
|
||||||
|
|
||||||
|
# Service URLs (used by dashboard)
|
||||||
|
VITE_SNAPCAST_HOST=localhost
|
||||||
|
VITE_SIGNALK_HOST=localhost
|
||||||
|
VITE_MOPIDY_HOST=localhost
|
||||||
|
VITE_JELLYFIN_HOST=localhost
|
||||||
|
VITE_PORTAINER_HOST=localhost
|
||||||
25
bordanlage/.env.example
Normal file
25
bordanlage/.env.example
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# General
|
||||||
|
COMPOSE_PROJECT_NAME=bordanlage
|
||||||
|
DEV=true
|
||||||
|
|
||||||
|
# Spotify Connect
|
||||||
|
SPOTIFY_NAME=Bordanlage
|
||||||
|
SPOTIFY_BITRATE=320
|
||||||
|
SPOTIFY_CACHE_SIZE=1024
|
||||||
|
|
||||||
|
# Boat Info
|
||||||
|
BOAT_NAME=My Yacht
|
||||||
|
BOAT_MMSI=123456789
|
||||||
|
|
||||||
|
# Paths
|
||||||
|
MUSIC_PATH=./music
|
||||||
|
|
||||||
|
# Jellyfin API Key (set after first run)
|
||||||
|
JELLYFIN_API_KEY=
|
||||||
|
|
||||||
|
# Service URLs (used by dashboard)
|
||||||
|
VITE_SNAPCAST_HOST=localhost
|
||||||
|
VITE_SIGNALK_HOST=localhost
|
||||||
|
VITE_MOPIDY_HOST=localhost
|
||||||
|
VITE_JELLYFIN_HOST=localhost
|
||||||
|
VITE_PORTAINER_HOST=localhost
|
||||||
22
bordanlage/Makefile
Normal file
22
bordanlage/Makefile
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
.PHONY: dev boot stop logs rebuild status pipes
|
||||||
|
|
||||||
|
dev: pipes
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||||
|
|
||||||
|
boot: pipes
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
stop:
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
logs:
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
rebuild:
|
||||||
|
docker compose build --no-cache
|
||||||
|
|
||||||
|
status:
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
pipes:
|
||||||
|
@bash scripts/init-pipes.sh
|
||||||
125
bordanlage/README.md
Normal file
125
bordanlage/README.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# Bordanlage – Boat Onboard System
|
||||||
|
|
||||||
|
A complete multiroom audio + navigation dashboard system for boats.
|
||||||
|
Runs on any Docker-capable computer – fully simulated in dev mode (no hardware needed).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- [Docker Desktop](https://www.docker.com/products/docker-desktop/) (or Docker Engine + Compose)
|
||||||
|
- `make`
|
||||||
|
- For Spotify Connect: a Spotify Premium account
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd bordanlage
|
||||||
|
make dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Dashboard: **http://localhost:8080**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Service URLs
|
||||||
|
|
||||||
|
| Service | URL | Description |
|
||||||
|
|---------------|------------------------------|------------------------------|
|
||||||
|
| Dashboard | http://localhost:8080 | Main touch UI |
|
||||||
|
| SignalK | http://localhost:3000 | Navigation data + chart viewer |
|
||||||
|
| Mopidy/Iris | http://localhost:6680/iris/ | Music player UI |
|
||||||
|
| Snapcast Web | http://localhost:1780 | Multiroom audio control |
|
||||||
|
| Jellyfin | http://localhost:8096 | Media library |
|
||||||
|
| Portainer | http://localhost:9000 | Docker management |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spotify Connect
|
||||||
|
|
||||||
|
1. Run `make dev` (or `make boot` on the boat)
|
||||||
|
2. Open Spotify on your phone/computer
|
||||||
|
3. Tap the device icon (bottom right) → look for **"Bordanlage"**
|
||||||
|
4. If it doesn't appear automatically: Go to **Connect to a Device** → enter the IP of the host machine manually
|
||||||
|
|
||||||
|
> On Linux (boat): set `network_mode: host` for the `librespot` service in `docker-compose.yml` for reliable mDNS discovery.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AirPlay
|
||||||
|
|
||||||
|
1. Ensure the `shairport` container is running
|
||||||
|
2. On your iPhone/Mac: open Control Center → tap AirPlay → select **"Bordanlage AirPlay"**
|
||||||
|
|
||||||
|
> On Mac: AirPlay works natively via Bonjour.
|
||||||
|
> On Windows WSL2: the `avahi` container in `docker-compose.dev.yml` handles mDNS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding Music
|
||||||
|
|
||||||
|
Drop audio files into `./music/`. Mopidy and Jellyfin both mount this directory.
|
||||||
|
|
||||||
|
Trigger a Mopidy library scan:
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:6680/mopidy/rpc -d '{"jsonrpc":"2.0","id":1,"method":"local.scan"}' \
|
||||||
|
-H "Content-Type: application/json"
|
||||||
|
```
|
||||||
|
|
||||||
|
For Jellyfin: open http://localhost:8096 → Settings → Libraries → Scan.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Connecting Real NMEA Hardware
|
||||||
|
|
||||||
|
Edit `docker-compose.yml` and uncomment the `signalk` device section:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
signalk:
|
||||||
|
devices:
|
||||||
|
- /dev/ttyUSB0:/dev/ttyUSB0 # NMEA 0183 via USB-Serial
|
||||||
|
```
|
||||||
|
|
||||||
|
Then configure the NMEA connection in SignalK at http://localhost:3000 → Server → Connections.
|
||||||
|
|
||||||
|
For NMEA 2000: use a Yacht Devices YDNU-02 or similar USB gateway.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration to Real Boat
|
||||||
|
|
||||||
|
Changes needed in `docker-compose.yml`:
|
||||||
|
|
||||||
|
1. **Audio output per zone**: add `--soundcard hw:N,0` to each `zone-*` command and uncomment `/dev/snd`
|
||||||
|
2. **Spotify/AirPlay discovery**: set `network_mode: host` for `librespot` and `shairport`
|
||||||
|
3. **Hardware video decoding** (optional): uncomment `/dev/dri` in `jellyfin`
|
||||||
|
4. **NMEA hardware**: uncomment `/dev/ttyUSB0` in `signalk`
|
||||||
|
5. Set `DEV=false` in `.env`
|
||||||
|
|
||||||
|
Run `make boot` instead of `make dev`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**Spotify device not showing up on Mac:**
|
||||||
|
- Ensure port 57621 (UDP+TCP) is accessible. Docker Desktop on Mac sometimes blocks UDP.
|
||||||
|
- Try connecting manually: Spotify → "Connect to a Device" → "Connect to [IP]"
|
||||||
|
|
||||||
|
**AirPlay not visible on Windows:**
|
||||||
|
- The `avahi` container requires D-Bus. Run Docker Desktop with host networking or use WSL2.
|
||||||
|
|
||||||
|
**Snapcast zones show as offline:**
|
||||||
|
- Audio pipes must exist before snapserver starts. Run `make pipes` or `bash scripts/init-pipes.sh`
|
||||||
|
|
||||||
|
**Mopidy won't start:**
|
||||||
|
- Check `docker compose logs mopidy`. The custom Dockerfile installs plugins on first build; rebuild with `make rebuild`
|
||||||
|
|
||||||
|
**Dashboard shows "No signal":**
|
||||||
|
- In dev mode this is normal until mock data initializes (1–2 seconds)
|
||||||
|
- In production: check that SignalK is running and the WebSocket URL is correct in `.env`
|
||||||
|
|
||||||
|
**Port conflicts:**
|
||||||
|
- Edit the port mappings in `docker-compose.yml` or `docker-compose.dev.yml`
|
||||||
32
bordanlage/config/mopidy.conf
Normal file
32
bordanlage/config/mopidy.conf
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
[core]
|
||||||
|
data_dir = /var/lib/mopidy
|
||||||
|
|
||||||
|
[logging]
|
||||||
|
verbosity = 1
|
||||||
|
|
||||||
|
[audio]
|
||||||
|
output = audioresample ! audioconvert ! audio/x-raw,rate=44100,channels=2,format=S16LE ! filesink location=/tmp/audio/mopidy.pcm
|
||||||
|
|
||||||
|
[http]
|
||||||
|
enabled = true
|
||||||
|
hostname = 0.0.0.0
|
||||||
|
port = 6680
|
||||||
|
allowed_origins =
|
||||||
|
|
||||||
|
[mpd]
|
||||||
|
enabled = false
|
||||||
|
|
||||||
|
[local]
|
||||||
|
enabled = true
|
||||||
|
media_dir = /music
|
||||||
|
scan_timeout = 1000
|
||||||
|
|
||||||
|
[stream]
|
||||||
|
enabled = true
|
||||||
|
protocols = http, https, mms, rtmp, rtsp
|
||||||
|
|
||||||
|
[tunein]
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
[iris]
|
||||||
|
enabled = true
|
||||||
48
bordanlage/config/nginx/default.conf
Normal file
48
bordanlage/config/nginx/default.conf
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Serve React app – HTML5 history mode
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy SignalK
|
||||||
|
location /signalk/ {
|
||||||
|
proxy_pass http://signalk:3000/signalk/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy Snapcast WebSocket API
|
||||||
|
location /snapcast/ {
|
||||||
|
proxy_pass http://snapserver:1780/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy Mopidy
|
||||||
|
location /mopidy/ {
|
||||||
|
proxy_pass http://mopidy:6680/mopidy/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy Jellyfin
|
||||||
|
location /jellyfin/ {
|
||||||
|
proxy_pass http://jellyfin:8096/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript;
|
||||||
|
}
|
||||||
15
bordanlage/config/shairport.conf
Normal file
15
bordanlage/config/shairport.conf
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
general = {
|
||||||
|
name = "Bordanlage AirPlay";
|
||||||
|
port = 5000;
|
||||||
|
interpolation = "auto";
|
||||||
|
output_backend = "pipe";
|
||||||
|
};
|
||||||
|
|
||||||
|
pipe = {
|
||||||
|
name = "/tmp/audio/airplay.pcm";
|
||||||
|
};
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
enabled = yes;
|
||||||
|
include_cover_art = yes;
|
||||||
|
};
|
||||||
16
bordanlage/config/snapserver.conf
Normal file
16
bordanlage/config/snapserver.conf
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[stream]
|
||||||
|
source = pipe:///tmp/audio/spotify.pcm?name=Spotify&codec=pcm&sampleformat=44100:16:2&chunk_ms=20
|
||||||
|
source = pipe:///tmp/audio/airplay.pcm?name=AirPlay&codec=pcm&sampleformat=44100:16:2&chunk_ms=20
|
||||||
|
source = pipe:///tmp/audio/mopidy.pcm?name=Mopidy&codec=pcm&sampleformat=44100:16:2&chunk_ms=20
|
||||||
|
|
||||||
|
[server]
|
||||||
|
threads = -1
|
||||||
|
|
||||||
|
[http]
|
||||||
|
enabled = true
|
||||||
|
bind_to_address = 0.0.0.0
|
||||||
|
port = 1780
|
||||||
|
|
||||||
|
[logging]
|
||||||
|
sink = system
|
||||||
|
filter = *:info
|
||||||
14
bordanlage/dashboard/Dockerfile
Normal file
14
bordanlage/dashboard/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Stage 1: Build React app
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Serve with nginx
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
15
bordanlage/dashboard/index.html
Normal file
15
bordanlage/dashboard/index.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
|
<title>Bordanlage</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
13
bordanlage/dashboard/nginx.conf
Normal file
13
bordanlage/dashboard/nginx.conf
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||||
|
gzip_min_length 1000;
|
||||||
|
}
|
||||||
19
bordanlage/dashboard/package.json
Normal file
19
bordanlage/dashboard/package.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "bordanlage-dashboard",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"vite": "^5.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
46
bordanlage/dashboard/src/App.jsx
Normal file
46
bordanlage/dashboard/src/App.jsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import TopBar from './components/layout/TopBar.jsx'
|
||||||
|
import TabNav from './components/layout/TabNav.jsx'
|
||||||
|
import Overview from './pages/Overview.jsx'
|
||||||
|
import Navigation from './pages/Navigation.jsx'
|
||||||
|
import Audio from './pages/Audio.jsx'
|
||||||
|
import Systems from './pages/Systems.jsx'
|
||||||
|
|
||||||
|
const PAGES = {
|
||||||
|
overview: Overview,
|
||||||
|
navigation: Navigation,
|
||||||
|
audio: Audio,
|
||||||
|
systems: Systems,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [tab, setTab] = useState('overview')
|
||||||
|
const Page = PAGES[tab] || Overview
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.app}>
|
||||||
|
<TopBar />
|
||||||
|
<TabNav activeTab={tab} onTabChange={setTab} />
|
||||||
|
<main style={styles.main}>
|
||||||
|
<Page />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
app: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100vh',
|
||||||
|
overflow: 'hidden',
|
||||||
|
background: 'var(--bg)',
|
||||||
|
},
|
||||||
|
main: {
|
||||||
|
flex: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
minHeight: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
36
bordanlage/dashboard/src/api/jellyfin.js
Normal file
36
bordanlage/dashboard/src/api/jellyfin.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// Jellyfin REST API client.
|
||||||
|
|
||||||
|
export function createJellyfinClient(baseUrl, apiKey) {
|
||||||
|
const headers = {
|
||||||
|
'Authorization': `MediaBrowser Token="${apiKey}", Client="Bordanlage", Device="Dashboard", DeviceId="bordanlage-1", Version="1.0"`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
|
||||||
|
async function get(path) {
|
||||||
|
const res = await fetch(`${baseUrl}${path}`, { headers })
|
||||||
|
if (!res.ok) throw new Error(`Jellyfin ${res.status}: ${path}`)
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async getArtists() {
|
||||||
|
return get('/Artists?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Audio&Recursive=true')
|
||||||
|
},
|
||||||
|
async getAlbums(artistId) {
|
||||||
|
const q = artistId ? `&ArtistIds=${artistId}` : ''
|
||||||
|
return get(`/Items?SortBy=SortName&IncludeItemTypes=MusicAlbum&Recursive=true${q}`)
|
||||||
|
},
|
||||||
|
async getTracks(albumId) {
|
||||||
|
return get(`/Items?ParentId=${albumId}&IncludeItemTypes=Audio&SortBy=IndexNumber`)
|
||||||
|
},
|
||||||
|
async search(query) {
|
||||||
|
return get(`/Items?SearchTerm=${encodeURIComponent(query)}&IncludeItemTypes=Audio,MusicAlbum,MusicArtist&Recursive=true&Limit=20`)
|
||||||
|
},
|
||||||
|
getStreamUrl(itemId) {
|
||||||
|
return `${baseUrl}/Audio/${itemId}/stream?static=true&api_key=${apiKey}`
|
||||||
|
},
|
||||||
|
getImageUrl(itemId, type = 'Primary') {
|
||||||
|
return `${baseUrl}/Items/${itemId}/Images/${type}?api_key=${apiKey}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
84
bordanlage/dashboard/src/api/mopidy.js
Normal file
84
bordanlage/dashboard/src/api/mopidy.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
// Real Mopidy JSON-RPC WebSocket client.
|
||||||
|
|
||||||
|
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000]
|
||||||
|
|
||||||
|
export function createMopidyClient(baseUrl) {
|
||||||
|
const wsUrl = `${baseUrl}/mopidy/ws`
|
||||||
|
let ws = null
|
||||||
|
let reconnectAttempt = 0
|
||||||
|
let destroyed = false
|
||||||
|
let msgId = 1
|
||||||
|
const pending = new Map()
|
||||||
|
const listeners = {}
|
||||||
|
|
||||||
|
function emit(event, data) {
|
||||||
|
if (listeners[event]) listeners[event].forEach(fn => fn(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
if (destroyed) return
|
||||||
|
ws = new WebSocket(wsUrl)
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
reconnectAttempt = 0
|
||||||
|
emit('connected', null)
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onmessage = ({ data }) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(data)
|
||||||
|
|
||||||
|
if (msg.id !== undefined && pending.has(msg.id)) {
|
||||||
|
const { resolve, reject } = pending.get(msg.id)
|
||||||
|
pending.delete(msg.id)
|
||||||
|
if (msg.error) reject(new Error(msg.error.message))
|
||||||
|
else resolve(msg.result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mopidy events (no id)
|
||||||
|
if (msg.event) emit(`event:${msg.event}`, msg)
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
emit('disconnected', null)
|
||||||
|
for (const [, { reject }] of pending) reject(new Error('Connection closed'))
|
||||||
|
pending.clear()
|
||||||
|
if (!destroyed) scheduleReconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onerror = () => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReconnect() {
|
||||||
|
const delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)]
|
||||||
|
reconnectAttempt++
|
||||||
|
setTimeout(connect, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
connect()
|
||||||
|
|
||||||
|
return {
|
||||||
|
call(method, params = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||||
|
return reject(new Error('Mopidy not connected'))
|
||||||
|
}
|
||||||
|
const id = msgId++
|
||||||
|
pending.set(id, { resolve, reject })
|
||||||
|
ws.send(JSON.stringify({ jsonrpc: '2.0', id, method, params }))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
on(event, fn) {
|
||||||
|
if (!listeners[event]) listeners[event] = []
|
||||||
|
listeners[event].push(fn)
|
||||||
|
},
|
||||||
|
off(event, fn) {
|
||||||
|
if (listeners[event]) listeners[event] = listeners[event].filter(l => l !== fn)
|
||||||
|
},
|
||||||
|
disconnect() {
|
||||||
|
destroyed = true
|
||||||
|
ws?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
82
bordanlage/dashboard/src/api/signalk.js
Normal file
82
bordanlage/dashboard/src/api/signalk.js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
// Real SignalK WebSocket client with reconnect.
|
||||||
|
|
||||||
|
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000]
|
||||||
|
|
||||||
|
export function createSignalKClient(baseUrl) {
|
||||||
|
const wsUrl = `${baseUrl}/signalk/v1/stream?subscribe=self`
|
||||||
|
const listeners = {}
|
||||||
|
let ws = null
|
||||||
|
let reconnectAttempt = 0
|
||||||
|
let destroyed = false
|
||||||
|
|
||||||
|
function emit(event, data) {
|
||||||
|
if (listeners[event]) listeners[event].forEach(fn => fn(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
if (destroyed) return
|
||||||
|
ws = new WebSocket(wsUrl)
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
reconnectAttempt = 0
|
||||||
|
emit('connected', null)
|
||||||
|
|
||||||
|
// Subscribe to relevant paths
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
context: 'vessels.self',
|
||||||
|
subscribe: [
|
||||||
|
{ path: 'navigation.speedOverGround' },
|
||||||
|
{ path: 'navigation.courseOverGroundTrue' },
|
||||||
|
{ path: 'navigation.headingTrue' },
|
||||||
|
{ path: 'navigation.position' },
|
||||||
|
{ path: 'environment.depth.belowKeel' },
|
||||||
|
{ path: 'environment.wind.speedApparent' },
|
||||||
|
{ path: 'environment.wind.angleApparent' },
|
||||||
|
{ path: 'environment.water.temperature' },
|
||||||
|
{ path: 'environment.outside.temperature' },
|
||||||
|
{ path: 'propulsion.main.revolutions' },
|
||||||
|
{ path: 'electrical.batteries.starter.voltage' },
|
||||||
|
{ path: 'electrical.batteries.house.voltage' },
|
||||||
|
{ path: 'steering.rudderAngle' },
|
||||||
|
{ path: 'tanks.fuel.0.currentLevel' },
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onmessage = ({ data }) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(data)
|
||||||
|
if (msg.updates) emit('delta', msg)
|
||||||
|
} catch (e) { /* ignore parse errors */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
emit('disconnected', null)
|
||||||
|
if (!destroyed) scheduleReconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onerror = () => { /* onclose will handle reconnect */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReconnect() {
|
||||||
|
const delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)]
|
||||||
|
reconnectAttempt++
|
||||||
|
setTimeout(connect, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
connect()
|
||||||
|
|
||||||
|
return {
|
||||||
|
on(event, fn) {
|
||||||
|
if (!listeners[event]) listeners[event] = []
|
||||||
|
listeners[event].push(fn)
|
||||||
|
},
|
||||||
|
off(event, fn) {
|
||||||
|
if (listeners[event]) listeners[event] = listeners[event].filter(l => l !== fn)
|
||||||
|
},
|
||||||
|
disconnect() {
|
||||||
|
destroyed = true
|
||||||
|
ws?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
85
bordanlage/dashboard/src/api/snapcast.js
Normal file
85
bordanlage/dashboard/src/api/snapcast.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
// Real Snapcast JSON-RPC WebSocket client with reconnect + request/response matching.
|
||||||
|
|
||||||
|
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000]
|
||||||
|
|
||||||
|
export function createSnapcastClient(wsUrl) {
|
||||||
|
let ws = null
|
||||||
|
let reconnectAttempt = 0
|
||||||
|
let destroyed = false
|
||||||
|
let msgId = 1
|
||||||
|
const pending = new Map()
|
||||||
|
const listeners = {}
|
||||||
|
|
||||||
|
function emit(event, data) {
|
||||||
|
if (listeners[event]) listeners[event].forEach(fn => fn(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
if (destroyed) return
|
||||||
|
ws = new WebSocket(wsUrl)
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
reconnectAttempt = 0
|
||||||
|
emit('connected', null)
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onmessage = ({ data }) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(data)
|
||||||
|
|
||||||
|
// JSON-RPC response
|
||||||
|
if (msg.id !== undefined && pending.has(msg.id)) {
|
||||||
|
const { resolve, reject } = pending.get(msg.id)
|
||||||
|
pending.delete(msg.id)
|
||||||
|
if (msg.error) reject(new Error(msg.error.message))
|
||||||
|
else resolve(msg.result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server-sent notification
|
||||||
|
if (msg.method) emit('update', msg)
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
emit('disconnected', null)
|
||||||
|
// Reject all pending requests
|
||||||
|
for (const [, { reject }] of pending) reject(new Error('Connection closed'))
|
||||||
|
pending.clear()
|
||||||
|
if (!destroyed) scheduleReconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onerror = () => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReconnect() {
|
||||||
|
const delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)]
|
||||||
|
reconnectAttempt++
|
||||||
|
setTimeout(connect, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
connect()
|
||||||
|
|
||||||
|
return {
|
||||||
|
call(method, params = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||||
|
return reject(new Error('Snapcast not connected'))
|
||||||
|
}
|
||||||
|
const id = msgId++
|
||||||
|
pending.set(id, { resolve, reject })
|
||||||
|
ws.send(JSON.stringify({ id, jsonrpc: '2.0', method, params }))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
on(event, fn) {
|
||||||
|
if (!listeners[event]) listeners[event] = []
|
||||||
|
listeners[event].push(fn)
|
||||||
|
},
|
||||||
|
off(event, fn) {
|
||||||
|
if (listeners[event]) listeners[event] = listeners[event].filter(l => l !== fn)
|
||||||
|
},
|
||||||
|
disconnect() {
|
||||||
|
destroyed = true
|
||||||
|
ws?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
bordanlage/dashboard/src/components/audio/LibraryBrowser.jsx
Normal file
53
bordanlage/dashboard/src/components/audio/LibraryBrowser.jsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { getApi } from '../../mock/index.js'
|
||||||
|
|
||||||
|
export default function LibraryBrowser() {
|
||||||
|
const [tracks, setTracks] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const { mopidy } = getApi()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mopidy.call('tracklist.get_tracks')
|
||||||
|
.then(t => { setTracks(t || []); setLoading(false) })
|
||||||
|
.catch(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function playTrack(uri) {
|
||||||
|
await mopidy.call('tracklist.clear')
|
||||||
|
await mopidy.call('tracklist.add', { uris: [uri] })
|
||||||
|
await mopidy.call('playback.play')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <div style={{ color: 'var(--muted)', padding: 16 }}>Loading library…</div>
|
||||||
|
if (!tracks.length) return <div style={{ color: 'var(--muted)', padding: 16 }}>No tracks found. Add music to ./music</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.list}>
|
||||||
|
{tracks.map((track, i) => (
|
||||||
|
<button key={track.uri || i} style={styles.row} onClick={() => playTrack(track.uri)}>
|
||||||
|
<span style={styles.num}>{i + 1}</span>
|
||||||
|
<div style={styles.meta}>
|
||||||
|
<span style={styles.name}>{track.name}</span>
|
||||||
|
<span style={styles.artist}>{track.artists?.map(a => a.name).join(', ')}</span>
|
||||||
|
</div>
|
||||||
|
<span style={styles.album}>{track.album?.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
list: { display: 'flex', flexDirection: 'column', gap: 2, overflow: 'auto', maxHeight: 400 },
|
||||||
|
row: {
|
||||||
|
display: 'flex', alignItems: 'center', gap: 12,
|
||||||
|
padding: '8px 12px', borderRadius: 'var(--radius)',
|
||||||
|
background: 'none', border: '1px solid transparent',
|
||||||
|
color: 'var(--text)', textAlign: 'left', minHeight: 48,
|
||||||
|
},
|
||||||
|
num: { fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--muted)', minWidth: 20 },
|
||||||
|
meta: { flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 2 },
|
||||||
|
name: { fontSize: 13, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' },
|
||||||
|
artist: { fontSize: 11, color: 'var(--muted)' },
|
||||||
|
album: { fontSize: 11, color: 'var(--muted)', minWidth: 100, textAlign: 'right', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
|
||||||
|
}
|
||||||
90
bordanlage/dashboard/src/components/audio/NowPlaying.jsx
Normal file
90
bordanlage/dashboard/src/components/audio/NowPlaying.jsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { usePlayer } from '../../hooks/usePlayer.js'
|
||||||
|
|
||||||
|
function formatTime(ms) {
|
||||||
|
if (!ms) return '0:00'
|
||||||
|
const s = Math.floor(ms / 1000)
|
||||||
|
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NowPlaying({ compact = false }) {
|
||||||
|
const { currentTrack, state, position, play, pause, next, previous, connected } = usePlayer()
|
||||||
|
|
||||||
|
if (!connected) {
|
||||||
|
return (
|
||||||
|
<div style={styles.container}>
|
||||||
|
<span style={{ color: 'var(--muted)', fontSize: 13 }}>Audio not connected</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress = currentTrack?.duration
|
||||||
|
? Math.min(100, (position / currentTrack.duration) * 100)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ ...styles.container, ...(compact ? styles.compact : {}) }}>
|
||||||
|
{/* Cover placeholder */}
|
||||||
|
<div style={styles.cover}>
|
||||||
|
{currentTrack?.coverUrl
|
||||||
|
? <img src={currentTrack.coverUrl} alt="cover" style={styles.coverImg} />
|
||||||
|
: <span style={styles.coverIcon}>♫</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={styles.info}>
|
||||||
|
<div style={styles.title}>{currentTrack?.title || 'Nothing playing'}</div>
|
||||||
|
<div style={styles.artist}>{currentTrack?.artist || ''}</div>
|
||||||
|
{!compact && <div style={styles.album}>{currentTrack?.album || ''}</div>}
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
{currentTrack && (
|
||||||
|
<div style={styles.progressRow}>
|
||||||
|
<span style={styles.timeText}>{formatTime(position)}</span>
|
||||||
|
<div style={styles.progressBg}>
|
||||||
|
<div style={{ ...styles.progressFill, width: `${progress}%` }} />
|
||||||
|
</div>
|
||||||
|
<span style={styles.timeText}>{formatTime(currentTrack.duration)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div style={styles.controls}>
|
||||||
|
<button style={styles.btn} onClick={previous}>⏮</button>
|
||||||
|
<button style={{ ...styles.btn, ...styles.playBtn }}
|
||||||
|
onClick={state === 'playing' ? pause : play}>
|
||||||
|
{state === 'playing' ? '⏸' : '▶'}
|
||||||
|
</button>
|
||||||
|
<button style={styles.btn} onClick={next}>⏭</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
container: {
|
||||||
|
display: 'flex', gap: 16, padding: 16,
|
||||||
|
background: 'var(--surface)', borderRadius: 'var(--radius)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
compact: { padding: '10px 14px' },
|
||||||
|
cover: {
|
||||||
|
width: 64, height: 64, flexShrink: 0,
|
||||||
|
background: 'var(--surface2)', borderRadius: 6,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
coverImg: { width: '100%', height: '100%', objectFit: 'cover' },
|
||||||
|
coverIcon: { fontSize: 28, color: 'var(--muted)' },
|
||||||
|
info: { flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 },
|
||||||
|
title: { fontWeight: 600, fontSize: 14, color: 'var(--text)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' },
|
||||||
|
artist: { fontSize: 12, color: 'var(--muted)' },
|
||||||
|
album: { fontSize: 11, color: 'var(--muted)', opacity: 0.7 },
|
||||||
|
progressRow: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 4 },
|
||||||
|
progressBg: { flex: 1, height: 3, background: 'var(--border)', borderRadius: 2, overflow: 'hidden' },
|
||||||
|
progressFill: { height: '100%', background: 'var(--accent)', borderRadius: 2, transition: 'width 1s linear' },
|
||||||
|
timeText: { fontSize: 10, color: 'var(--muted)', fontFamily: 'var(--font-mono)', minWidth: 30 },
|
||||||
|
controls: { display: 'flex', gap: 4, marginTop: 4 },
|
||||||
|
btn: { width: 36, height: 36, fontSize: 14, background: 'var(--surface2)', color: 'var(--text)', minWidth: 36 },
|
||||||
|
playBtn: { background: 'var(--accent)', color: '#000', fontWeight: 700 },
|
||||||
|
}
|
||||||
61
bordanlage/dashboard/src/components/audio/RadioBrowser.jsx
Normal file
61
bordanlage/dashboard/src/components/audio/RadioBrowser.jsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { getApi } from '../../mock/index.js'
|
||||||
|
|
||||||
|
const BUILT_IN_STATIONS = [
|
||||||
|
{ name: 'SWR3', uri: 'http://stream.swr3.de/swr3/mp3-128/stream.mp3' },
|
||||||
|
{ name: 'NDR 1 Welle Nord', uri: 'http://ndr.de/ndr1welle-nord-128.mp3' },
|
||||||
|
{ name: 'Deutschlandfunk', uri: 'https://st01.sslstream.dlf.de/dlf/01/128/mp3/stream.mp3' },
|
||||||
|
{ name: 'KISS FM', uri: 'http://topstream.kissfm.de/kissfm' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function RadioBrowser() {
|
||||||
|
const [playing, setPlaying] = useState(null)
|
||||||
|
const { mopidy } = getApi()
|
||||||
|
|
||||||
|
async function playStation(uri, name) {
|
||||||
|
try {
|
||||||
|
await mopidy.call('tracklist.clear')
|
||||||
|
await mopidy.call('tracklist.add', { uris: [uri] })
|
||||||
|
await mopidy.call('playback.play')
|
||||||
|
setPlaying(uri)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Radio play error:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.container}>
|
||||||
|
<div style={styles.title}>Radio Stations</div>
|
||||||
|
{BUILT_IN_STATIONS.map(s => (
|
||||||
|
<button
|
||||||
|
key={s.uri}
|
||||||
|
style={{
|
||||||
|
...styles.station,
|
||||||
|
...(playing === s.uri ? styles.active : {}),
|
||||||
|
}}
|
||||||
|
onClick={() => playStation(s.uri, s.name)}
|
||||||
|
>
|
||||||
|
<span style={styles.dot}>◉</span>
|
||||||
|
<span style={styles.stationName}>{s.name}</span>
|
||||||
|
{playing === s.uri && <span style={styles.live}>LIVE</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
container: { display: 'flex', flexDirection: 'column', gap: 6 },
|
||||||
|
title: { fontWeight: 600, fontSize: 12, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 4 },
|
||||||
|
station: {
|
||||||
|
display: 'flex', alignItems: 'center', gap: 12,
|
||||||
|
padding: '12px 16px', background: 'var(--surface)',
|
||||||
|
border: '1px solid var(--border)', borderRadius: 'var(--radius)',
|
||||||
|
color: 'var(--text)', textAlign: 'left', minHeight: 48,
|
||||||
|
transition: 'border-color 0.15s',
|
||||||
|
},
|
||||||
|
active: { borderColor: 'var(--accent)', background: 'var(--surface2)' },
|
||||||
|
dot: { color: 'var(--muted)', fontSize: 10 },
|
||||||
|
stationName: { flex: 1, fontSize: 14 },
|
||||||
|
live: { fontSize: 9, padding: '2px 5px', background: '#ef444422', color: 'var(--danger)', borderRadius: 3, fontWeight: 700 },
|
||||||
|
}
|
||||||
37
bordanlage/dashboard/src/components/audio/SourcePicker.jsx
Normal file
37
bordanlage/dashboard/src/components/audio/SourcePicker.jsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
const SOURCES = [
|
||||||
|
{ id: 'Spotify', label: 'Spotify', color: 'var(--spotify)', icon: '🎵' },
|
||||||
|
{ id: 'AirPlay', label: 'AirPlay', color: 'var(--airplay)', icon: '📡' },
|
||||||
|
{ id: 'Mopidy', label: 'Mopidy', color: 'var(--accent)', icon: '📻' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function SourcePicker({ activeSource, onSelect }) {
|
||||||
|
return (
|
||||||
|
<div style={styles.row}>
|
||||||
|
{SOURCES.map(s => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
style={{
|
||||||
|
...styles.btn,
|
||||||
|
...(activeSource === s.id ? { background: s.color + '22', borderColor: s.color, color: s.color } : {}),
|
||||||
|
}}
|
||||||
|
onClick={() => onSelect(s.id)}
|
||||||
|
>
|
||||||
|
<span>{s.icon}</span>
|
||||||
|
<span style={{ fontSize: 12 }}>{s.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
row: { display: 'flex', gap: 8 },
|
||||||
|
btn: {
|
||||||
|
flex: 1, height: 52, flexDirection: 'column',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
|
||||||
|
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius)', color: 'var(--muted)',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
minHeight: 52,
|
||||||
|
},
|
||||||
|
}
|
||||||
53
bordanlage/dashboard/src/components/audio/ZoneCard.jsx
Normal file
53
bordanlage/dashboard/src/components/audio/ZoneCard.jsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
export default function ZoneCard({ zone, onVolume, onMute, onSource }) {
|
||||||
|
const { id, name, active, volume, muted, source } = zone
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
...styles.card,
|
||||||
|
opacity: active ? 1 : 0.45,
|
||||||
|
borderColor: active && !muted ? 'var(--accent)' : 'var(--border)',
|
||||||
|
}}>
|
||||||
|
<div style={styles.header}>
|
||||||
|
<span style={styles.name}>{name}</span>
|
||||||
|
<div style={styles.badges}>
|
||||||
|
<span style={{ ...styles.badge, background: active ? '#34d39922' : 'var(--border)', color: active ? 'var(--success)' : 'var(--muted)' }}>
|
||||||
|
{active ? 'ON' : 'OFF'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={styles.source}>{source}</div>
|
||||||
|
|
||||||
|
<div style={styles.volumeRow}>
|
||||||
|
<button style={styles.muteBtn} onClick={() => onMute(id, !muted)}>
|
||||||
|
{muted ? '🔇' : '🔊'}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="range" min={0} max={100} value={muted ? 0 : volume}
|
||||||
|
onChange={e => onVolume(id, Number(e.target.value))}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<span style={styles.volVal}>{muted ? '–' : volume}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
card: {
|
||||||
|
padding: 14,
|
||||||
|
background: 'var(--surface)',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 10,
|
||||||
|
transition: 'border-color 0.2s, opacity 0.2s',
|
||||||
|
},
|
||||||
|
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' },
|
||||||
|
name: { fontWeight: 600, fontSize: 14 },
|
||||||
|
badges: { display: 'flex', gap: 4 },
|
||||||
|
badge: { fontSize: 10, padding: '2px 6px', borderRadius: 4, fontWeight: 700 },
|
||||||
|
source: { fontSize: 11, color: 'var(--muted)' },
|
||||||
|
volumeRow: { display: 'flex', alignItems: 'center', gap: 10 },
|
||||||
|
muteBtn: { fontSize: 18, minWidth: 44, minHeight: 44 },
|
||||||
|
volVal: { fontFamily: 'var(--font-mono)', fontSize: 13, minWidth: 26, textAlign: 'right', color: 'var(--muted)' },
|
||||||
|
}
|
||||||
32
bordanlage/dashboard/src/components/audio/ZoneGrid.jsx
Normal file
32
bordanlage/dashboard/src/components/audio/ZoneGrid.jsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { useZones } from '../../hooks/useZones.js'
|
||||||
|
import ZoneCard from './ZoneCard.jsx'
|
||||||
|
|
||||||
|
export default function ZoneGrid() {
|
||||||
|
const { zones, setVolume, setMuted, setSource } = useZones()
|
||||||
|
|
||||||
|
if (!zones.length) {
|
||||||
|
return <div style={{ color: 'var(--muted)', padding: 24, textAlign: 'center' }}>Loading zones…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.grid}>
|
||||||
|
{zones.map(zone => (
|
||||||
|
<ZoneCard
|
||||||
|
key={zone.id}
|
||||||
|
zone={zone}
|
||||||
|
onVolume={setVolume}
|
||||||
|
onMute={setMuted}
|
||||||
|
onSource={setSource}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
grid: {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
|
||||||
|
gap: 12,
|
||||||
|
}
|
||||||
|
}
|
||||||
94
bordanlage/dashboard/src/components/instruments/Compass.jsx
Normal file
94
bordanlage/dashboard/src/components/instruments/Compass.jsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
// Animated SVG compass rose.
|
||||||
|
|
||||||
|
const CX = 96, CY = 96, R = 80
|
||||||
|
|
||||||
|
const CARDINALS = [
|
||||||
|
{ label: 'N', angle: 0 },
|
||||||
|
{ label: 'E', angle: 90 },
|
||||||
|
{ label: 'S', angle: 180 },
|
||||||
|
{ label: 'W', angle: 270 },
|
||||||
|
]
|
||||||
|
|
||||||
|
function polarXY(cx, cy, r, angleDeg) {
|
||||||
|
const rad = (angleDeg - 90) * Math.PI / 180
|
||||||
|
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Compass({ heading = 0, cog }) {
|
||||||
|
const hdg = heading ?? 0
|
||||||
|
const hasCog = cog != null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={192} height={192} viewBox="0 0 192 192">
|
||||||
|
{/* Outer ring */}
|
||||||
|
<circle cx={CX} cy={CY} r={R} fill="none" stroke="var(--border)" strokeWidth={2} />
|
||||||
|
<circle cx={CX} cy={CY} r={R - 12} fill="none" stroke="var(--border)" strokeWidth={1} />
|
||||||
|
|
||||||
|
{/* Rotating rose */}
|
||||||
|
<g style={{
|
||||||
|
transformOrigin: `${CX}px ${CY}px`,
|
||||||
|
transform: `rotate(${-hdg}deg)`,
|
||||||
|
transition: 'transform 0.8s ease',
|
||||||
|
}}>
|
||||||
|
{/* 36 tick marks */}
|
||||||
|
{Array.from({ length: 36 }, (_, i) => {
|
||||||
|
const angle = i * 10
|
||||||
|
const outer = polarXY(CX, CY, R, angle)
|
||||||
|
const inner = polarXY(CX, CY, R - (i % 3 === 0 ? 10 : 5), angle)
|
||||||
|
return (
|
||||||
|
<line key={i}
|
||||||
|
x1={outer.x} y1={outer.y}
|
||||||
|
x2={inner.x} y2={inner.y}
|
||||||
|
stroke={i % 9 === 0 ? 'var(--accent)' : 'var(--muted)'}
|
||||||
|
strokeWidth={i % 9 === 0 ? 2 : 1}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Cardinal labels */}
|
||||||
|
{CARDINALS.map(c => {
|
||||||
|
const p = polarXY(CX, CY, R - 22, c.angle)
|
||||||
|
return (
|
||||||
|
<text key={c.label}
|
||||||
|
x={p.x} y={p.y + 4}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontFamily="var(--font-mono)" fontSize={12} fontWeight={700}
|
||||||
|
fill={c.label === 'N' ? 'var(--danger)' : 'var(--text)'}
|
||||||
|
>
|
||||||
|
{c.label}
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Fixed lubber line (ship's bow = top) */}
|
||||||
|
<line x1={CX} y1={CY - R + 2} x2={CX} y2={CY - R + 16}
|
||||||
|
stroke="var(--accent)" strokeWidth={3} strokeLinecap="round" />
|
||||||
|
|
||||||
|
{/* COG indicator */}
|
||||||
|
{hasCog && (() => {
|
||||||
|
const cogAngle = cog - hdg
|
||||||
|
const tip = polarXY(CX, CY, R - 6, cogAngle)
|
||||||
|
return (
|
||||||
|
<line x1={CX} y1={CY} x2={tip.x} y2={tip.y}
|
||||||
|
stroke="var(--warning)" strokeWidth={2} strokeDasharray="4,3"
|
||||||
|
style={{ transformOrigin: `${CX}px ${CY}px`, transition: 'all 0.8s ease' }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Center */}
|
||||||
|
<circle cx={CX} cy={CY} r={4} fill="var(--accent)" />
|
||||||
|
|
||||||
|
{/* Heading value */}
|
||||||
|
<text x={CX} y={CY + 26} textAnchor="middle"
|
||||||
|
fontFamily="var(--font-mono)" fontSize={18} fill="var(--text)">
|
||||||
|
{Math.round(hdg).toString().padStart(3, '0')}°
|
||||||
|
</text>
|
||||||
|
<text x={CX} y={186} textAnchor="middle"
|
||||||
|
fontFamily="var(--font-ui)" fontSize={11} fill="var(--muted)" fontWeight={600}>
|
||||||
|
HEADING
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// Depth sounder with alarm threshold.
|
||||||
|
import Gauge from './Gauge.jsx'
|
||||||
|
|
||||||
|
export default function DepthSounder({ depth }) {
|
||||||
|
return (
|
||||||
|
<Gauge
|
||||||
|
value={depth}
|
||||||
|
min={0}
|
||||||
|
max={30}
|
||||||
|
label="Depth"
|
||||||
|
unit="m"
|
||||||
|
warning={3}
|
||||||
|
danger={1.5}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
118
bordanlage/dashboard/src/components/instruments/Gauge.jsx
Normal file
118
bordanlage/dashboard/src/components/instruments/Gauge.jsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
// Analog round gauge with animated needle.
|
||||||
|
|
||||||
|
const R = 80
|
||||||
|
const CX = 96
|
||||||
|
const CY = 96
|
||||||
|
const START_ANGLE = -225
|
||||||
|
const SWEEP = 270
|
||||||
|
|
||||||
|
function polarToXY(cx, cy, r, angleDeg) {
|
||||||
|
const rad = (angleDeg - 90) * Math.PI / 180
|
||||||
|
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function arcPath(cx, cy, r, startDeg, endDeg) {
|
||||||
|
const start = polarToXY(cx, cy, r, startDeg)
|
||||||
|
const end = polarToXY(cx, cy, r, endDeg)
|
||||||
|
const large = endDeg - startDeg > 180 ? 1 : 0
|
||||||
|
return `M ${start.x} ${start.y} A ${r} ${r} 0 ${large} 1 ${end.x} ${end.y}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTicks(startDeg, sweep, count) {
|
||||||
|
return Array.from({ length: count + 1 }, (_, i) => {
|
||||||
|
const angle = startDeg + (sweep / count) * i
|
||||||
|
const outer = polarToXY(CX, CY, R, angle)
|
||||||
|
const inner = polarToXY(CX, CY, R - (i % 2 === 0 ? 10 : 6), angle)
|
||||||
|
return { outer, inner, major: i % 2 === 0 }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Gauge({ value, min = 0, max = 10, label = '', unit = '', danger, warning }) {
|
||||||
|
const clampedVal = Math.min(max, Math.max(min, value ?? min))
|
||||||
|
const ratio = (clampedVal - min) / (max - min)
|
||||||
|
const needleAngle = START_ANGLE + ratio * SWEEP
|
||||||
|
|
||||||
|
const warnRatio = warning != null ? (warning - min) / (max - min) : null
|
||||||
|
const dangRatio = danger != null ? (danger - min) / (max - min) : null
|
||||||
|
|
||||||
|
const ticks = buildTicks(START_ANGLE, SWEEP, 10)
|
||||||
|
|
||||||
|
const needleTip = polarToXY(CX, CY, R - 12, needleAngle)
|
||||||
|
const needleBase1 = polarToXY(CX, CY, 6, needleAngle + 90)
|
||||||
|
const needleBase2 = polarToXY(CX, CY, 6, needleAngle - 90)
|
||||||
|
|
||||||
|
const isWarning = warning != null && clampedVal >= warning
|
||||||
|
const isDanger = danger != null && clampedVal >= danger
|
||||||
|
const needleColor = isDanger ? 'var(--danger)' : isWarning ? 'var(--warning)' : 'var(--accent)'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={192} height={192} viewBox="0 0 192 192" style={{ overflow: 'visible' }}>
|
||||||
|
{/* Background arc */}
|
||||||
|
<path
|
||||||
|
d={arcPath(CX, CY, R, START_ANGLE, START_ANGLE + SWEEP)}
|
||||||
|
fill="none" stroke="var(--border)" strokeWidth={8} strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Warning zone */}
|
||||||
|
{warnRatio != null && (
|
||||||
|
<path
|
||||||
|
d={arcPath(CX, CY, R,
|
||||||
|
START_ANGLE + warnRatio * SWEEP,
|
||||||
|
START_ANGLE + (dangRatio ?? 1) * SWEEP)}
|
||||||
|
fill="none" stroke="#f59e0b33" strokeWidth={8}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Danger zone */}
|
||||||
|
{dangRatio != null && (
|
||||||
|
<path
|
||||||
|
d={arcPath(CX, CY, R, START_ANGLE + dangRatio * SWEEP, START_ANGLE + SWEEP)}
|
||||||
|
fill="none" stroke="#ef444433" strokeWidth={8}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Progress arc */}
|
||||||
|
<path
|
||||||
|
d={arcPath(CX, CY, R, START_ANGLE, needleAngle)}
|
||||||
|
fill="none" stroke={needleColor} strokeWidth={4} strokeLinecap="round"
|
||||||
|
style={{ transition: 'all 0.6s ease' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Ticks */}
|
||||||
|
{ticks.map((t, i) => (
|
||||||
|
<line key={i}
|
||||||
|
x1={t.outer.x} y1={t.outer.y}
|
||||||
|
x2={t.inner.x} y2={t.inner.y}
|
||||||
|
stroke={t.major ? 'var(--muted)' : 'var(--border)'}
|
||||||
|
strokeWidth={t.major ? 1.5 : 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Needle */}
|
||||||
|
<polygon
|
||||||
|
points={`${needleTip.x},${needleTip.y} ${needleBase1.x},${needleBase1.y} ${needleBase2.x},${needleBase2.y}`}
|
||||||
|
fill={needleColor}
|
||||||
|
style={{ transition: 'all 0.6s ease', transformOrigin: `${CX}px ${CY}px` }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Center cap */}
|
||||||
|
<circle cx={CX} cy={CY} r={6} fill="var(--surface2)" stroke={needleColor} strokeWidth={2} />
|
||||||
|
|
||||||
|
{/* Value text */}
|
||||||
|
<text x={CX} y={CY + 28} textAnchor="middle"
|
||||||
|
fontFamily="var(--font-mono)" fontSize={18} fill="var(--text)"
|
||||||
|
style={{ transition: 'all 0.3s' }}>
|
||||||
|
{value != null ? (Number.isInteger(max - min) && max - min <= 20
|
||||||
|
? Math.round(clampedVal)
|
||||||
|
: clampedVal.toFixed(1)) : '--'}
|
||||||
|
</text>
|
||||||
|
<text x={CX} y={CY + 42} textAnchor="middle" fontFamily="var(--font-mono)" fontSize={10} fill="var(--muted)">
|
||||||
|
{unit}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Label */}
|
||||||
|
<text x={CX} y={186} textAnchor="middle" fontFamily="var(--font-ui)" fontSize={11} fill="var(--muted)" fontWeight={600}>
|
||||||
|
{label.toUpperCase()}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
14
bordanlage/dashboard/src/components/instruments/SpeedLog.jsx
Normal file
14
bordanlage/dashboard/src/components/instruments/SpeedLog.jsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// Speed through water / SOG gauge.
|
||||||
|
import Gauge from './Gauge.jsx'
|
||||||
|
|
||||||
|
export default function SpeedLog({ sog }) {
|
||||||
|
return (
|
||||||
|
<Gauge
|
||||||
|
value={sog}
|
||||||
|
min={0}
|
||||||
|
max={12}
|
||||||
|
label="Speed"
|
||||||
|
unit="kn"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
76
bordanlage/dashboard/src/components/instruments/WindRose.jsx
Normal file
76
bordanlage/dashboard/src/components/instruments/WindRose.jsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
// Wind rose showing apparent wind angle and speed.
|
||||||
|
|
||||||
|
const CX = 96, CY = 96, R = 70
|
||||||
|
|
||||||
|
function polarXY(cx, cy, r, angleDeg) {
|
||||||
|
const rad = (angleDeg - 90) * Math.PI / 180
|
||||||
|
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WindRose({ windAngle = 0, windSpeed = 0 }) {
|
||||||
|
const angle = windAngle ?? 0
|
||||||
|
const speed = windSpeed ?? 0
|
||||||
|
const tipLen = Math.min(R - 10, 20 + speed * 2.5)
|
||||||
|
|
||||||
|
const tip = polarXY(CX, CY, tipLen, angle)
|
||||||
|
const left = polarXY(CX, CY, 10, angle + 120)
|
||||||
|
const right = polarXY(CX, CY, 10, angle - 120)
|
||||||
|
|
||||||
|
const color = speed > 18 ? 'var(--danger)' : speed > 12 ? 'var(--warning)' : 'var(--accent)'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={192} height={192} viewBox="0 0 192 192">
|
||||||
|
{/* Background rings */}
|
||||||
|
<circle cx={CX} cy={CY} r={R} fill="none" stroke="var(--border)" strokeWidth={1} />
|
||||||
|
<circle cx={CX} cy={CY} r={R * 0.6} fill="none" stroke="var(--border)" strokeWidth={1} strokeDasharray="3,4" />
|
||||||
|
|
||||||
|
{/* Dividers every 45° */}
|
||||||
|
{Array.from({ length: 8 }, (_, i) => {
|
||||||
|
const p = polarXY(CX, CY, R, i * 45)
|
||||||
|
return <line key={i} x1={CX} y1={CY} x2={p.x} y2={p.y}
|
||||||
|
stroke="var(--border)" strokeWidth={1} />
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Wind arrow */}
|
||||||
|
<g style={{ transition: 'all 0.6s ease' }}>
|
||||||
|
<polygon
|
||||||
|
points={`${tip.x},${tip.y} ${left.x},${left.y} ${right.x},${right.y}`}
|
||||||
|
fill={color} opacity={0.85}
|
||||||
|
style={{ transformOrigin: `${CX}px ${CY}px`, transition: 'all 0.6s ease' }}
|
||||||
|
/>
|
||||||
|
<line x1={CX} y1={CY} x2={tip.x} y2={tip.y}
|
||||||
|
stroke={color} strokeWidth={2}
|
||||||
|
style={{ transformOrigin: `${CX}px ${CY}px`, transition: 'all 0.6s ease' }}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<circle cx={CX} cy={CY} r={5} fill="var(--surface2)" stroke={color} strokeWidth={2} />
|
||||||
|
|
||||||
|
{/* Speed value */}
|
||||||
|
<text x={CX} y={CY + 26} textAnchor="middle"
|
||||||
|
fontFamily="var(--font-mono)" fontSize={18} fill="var(--text)">
|
||||||
|
{speed.toFixed(1)}
|
||||||
|
</text>
|
||||||
|
<text x={CX} y={CY + 40} textAnchor="middle"
|
||||||
|
fontFamily="var(--font-mono)" fontSize={10} fill="var(--muted)">
|
||||||
|
kn
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Labels */}
|
||||||
|
{[['N', 0], ['E', 90], ['S', 180], ['W', 270]].map(([l, a]) => {
|
||||||
|
const p = polarXY(CX, CY, R + 12, a)
|
||||||
|
return (
|
||||||
|
<text key={l} x={p.x} y={p.y + 4} textAnchor="middle"
|
||||||
|
fontFamily="var(--font-mono)" fontSize={10} fill="var(--muted)">
|
||||||
|
{l}
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
<text x={CX} y={186} textAnchor="middle"
|
||||||
|
fontFamily="var(--font-ui)" fontSize={11} fill="var(--muted)" fontWeight={600}>
|
||||||
|
WIND
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
56
bordanlage/dashboard/src/components/layout/TabNav.jsx
Normal file
56
bordanlage/dashboard/src/components/layout/TabNav.jsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
const TABS = [
|
||||||
|
{ id: 'overview', label: 'Overview', icon: '◈' },
|
||||||
|
{ id: 'navigation', label: 'Navigation', icon: '⊕' },
|
||||||
|
{ id: 'audio', label: 'Audio', icon: '♫' },
|
||||||
|
{ id: 'systems', label: 'Systems', icon: '⚙' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function TabNav({ activeTab, onTabChange }) {
|
||||||
|
return (
|
||||||
|
<nav style={styles.nav}>
|
||||||
|
{TABS.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
style={{
|
||||||
|
...styles.tab,
|
||||||
|
...(activeTab === tab.id ? styles.active : {}),
|
||||||
|
}}
|
||||||
|
onClick={() => onTabChange(tab.id)}
|
||||||
|
>
|
||||||
|
<span style={styles.icon}>{tab.icon}</span>
|
||||||
|
<span style={styles.label}>{tab.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
nav: {
|
||||||
|
display: 'flex',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
tab: {
|
||||||
|
flex: 1,
|
||||||
|
height: 48,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 2,
|
||||||
|
fontSize: 11,
|
||||||
|
color: 'var(--muted)',
|
||||||
|
borderRadius: 0,
|
||||||
|
borderBottom: '2px solid transparent',
|
||||||
|
transition: 'color 0.15s, border-color 0.15s',
|
||||||
|
minHeight: 48,
|
||||||
|
},
|
||||||
|
active: {
|
||||||
|
color: 'var(--accent)',
|
||||||
|
borderBottom: '2px solid var(--accent)',
|
||||||
|
},
|
||||||
|
icon: { fontSize: 16 },
|
||||||
|
label: { fontSize: 10, fontWeight: 600, letterSpacing: '0.05em', textTransform: 'uppercase' },
|
||||||
|
}
|
||||||
77
bordanlage/dashboard/src/components/layout/TopBar.jsx
Normal file
77
bordanlage/dashboard/src/components/layout/TopBar.jsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNMEA } from '../../hooks/useNMEA.js'
|
||||||
|
|
||||||
|
const isDev = import.meta.env.DEV
|
||||||
|
|
||||||
|
function formatTime() {
|
||||||
|
return new Date().toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TopBar() {
|
||||||
|
const { sog, heading, connected } = useNMEA()
|
||||||
|
const [time, setTime] = useState(formatTime())
|
||||||
|
|
||||||
|
// Clock tick
|
||||||
|
useState(() => {
|
||||||
|
const t = setInterval(() => setTime(formatTime()), 5000)
|
||||||
|
return () => clearInterval(t)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header style={styles.bar}>
|
||||||
|
<div style={styles.left}>
|
||||||
|
<span style={styles.logo}>⚓ Bordanlage</span>
|
||||||
|
{isDev && <span style={styles.devBadge}>DEV · MOCK DATA</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={styles.center}>
|
||||||
|
{connected && sog != null && (
|
||||||
|
<span style={styles.stat}>
|
||||||
|
<span style={styles.val}>{sog.toFixed(1)}</span>
|
||||||
|
<span style={styles.unit}>kn</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{connected && heading != null && (
|
||||||
|
<span style={styles.stat}>
|
||||||
|
<span style={styles.val}>{Math.round(heading)}°</span>
|
||||||
|
<span style={styles.unit}>HDG</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!connected && (
|
||||||
|
<span style={{ color: 'var(--muted)', fontSize: 13 }}>No signal</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={styles.right}>
|
||||||
|
<span style={styles.time}>{time}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
bar: {
|
||||||
|
height: 52,
|
||||||
|
background: 'var(--surface)',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '0 16px',
|
||||||
|
gap: 16,
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
left: { display: 'flex', alignItems: 'center', gap: 10, flex: 1 },
|
||||||
|
center: { display: 'flex', gap: 20, alignItems: 'center' },
|
||||||
|
right: { flex: 1, display: 'flex', justifyContent: 'flex-end' },
|
||||||
|
logo: { fontWeight: 700, fontSize: 15, color: 'var(--accent)', letterSpacing: '0.04em' },
|
||||||
|
devBadge: {
|
||||||
|
fontSize: 10, fontWeight: 600, padding: '2px 7px',
|
||||||
|
background: '#f59e0b22', color: 'var(--warning)',
|
||||||
|
border: '1px solid var(--warning)', borderRadius: 4,
|
||||||
|
letterSpacing: '0.06em',
|
||||||
|
},
|
||||||
|
stat: { display: 'flex', alignItems: 'baseline', gap: 3 },
|
||||||
|
val: { fontFamily: 'var(--font-mono)', fontSize: 16, color: 'var(--text)' },
|
||||||
|
unit: { fontSize: 10, color: 'var(--muted)' },
|
||||||
|
time: { fontFamily: 'var(--font-mono)', fontSize: 14, color: 'var(--muted)' },
|
||||||
|
}
|
||||||
37
bordanlage/dashboard/src/components/nav/ChartPlaceholder.jsx
Normal file
37
bordanlage/dashboard/src/components/nav/ChartPlaceholder.jsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useNMEA } from '../../hooks/useNMEA.js'
|
||||||
|
|
||||||
|
export default function ChartPlaceholder() {
|
||||||
|
const { lat, lon } = useNMEA()
|
||||||
|
const signalkHost = import.meta.env.VITE_SIGNALK_HOST || 'localhost'
|
||||||
|
|
||||||
|
// SignalK has a built-in chart viewer
|
||||||
|
const chartUrl = `http://${signalkHost}:3000/@signalk/freeboard-sk/`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.container}>
|
||||||
|
<iframe
|
||||||
|
src={chartUrl}
|
||||||
|
style={styles.iframe}
|
||||||
|
title="Chart"
|
||||||
|
onError={() => {}}
|
||||||
|
/>
|
||||||
|
{lat != null && (
|
||||||
|
<div style={styles.coords}>
|
||||||
|
<span style={styles.coord}>{lat.toFixed(5)}°N</span>
|
||||||
|
<span style={styles.coord}>{lon.toFixed(5)}°E</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
container: { position: 'relative', flex: 1, borderRadius: 'var(--radius)', overflow: 'hidden', border: '1px solid var(--border)' },
|
||||||
|
iframe: { width: '100%', height: '100%', border: 'none', background: 'var(--surface2)' },
|
||||||
|
coords: {
|
||||||
|
position: 'absolute', bottom: 12, left: 12,
|
||||||
|
background: '#07111fcc', borderRadius: 6, padding: '6px 10px',
|
||||||
|
display: 'flex', gap: 12,
|
||||||
|
},
|
||||||
|
coord: { fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--accent)' },
|
||||||
|
}
|
||||||
67
bordanlage/dashboard/src/components/nav/InstrumentPanel.jsx
Normal file
67
bordanlage/dashboard/src/components/nav/InstrumentPanel.jsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { useNMEA } from '../../hooks/useNMEA.js'
|
||||||
|
import Gauge from '../instruments/Gauge.jsx'
|
||||||
|
import Compass from '../instruments/Compass.jsx'
|
||||||
|
import WindRose from '../instruments/WindRose.jsx'
|
||||||
|
|
||||||
|
function DataRow({ label, value, unit }) {
|
||||||
|
return (
|
||||||
|
<div style={styles.row}>
|
||||||
|
<span style={styles.label}>{label}</span>
|
||||||
|
<span style={styles.value}>
|
||||||
|
{value != null ? `${typeof value === 'number' ? value.toFixed(1) : value}` : '—'}
|
||||||
|
{unit && <span style={styles.unit}> {unit}</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InstrumentPanel() {
|
||||||
|
const nmea = useNMEA()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.panel}>
|
||||||
|
{/* Gauges row */}
|
||||||
|
<div style={styles.gauges}>
|
||||||
|
<Compass heading={nmea.heading} cog={nmea.cog} />
|
||||||
|
<WindRose windAngle={nmea.windAngle} windSpeed={nmea.windSpeed} />
|
||||||
|
<Gauge value={nmea.depth} min={0} max={30} label="Depth" unit="m" warning={3} danger={1.5} />
|
||||||
|
<Gauge value={nmea.sog} min={0} max={12} label="SOG" unit="kn" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Data table */}
|
||||||
|
<div style={styles.table}>
|
||||||
|
<DataRow label="COG" value={nmea.cog != null ? Math.round(nmea.cog) : null} unit="°" />
|
||||||
|
<DataRow label="Heading" value={nmea.heading != null ? Math.round(nmea.heading) : null} unit="°" />
|
||||||
|
<DataRow label="SOG" value={nmea.sog} unit="kn" />
|
||||||
|
<DataRow label="Depth" value={nmea.depth} unit="m" />
|
||||||
|
<DataRow label="Wind Speed" value={nmea.windSpeed} unit="kn" />
|
||||||
|
<DataRow label="Wind Angle" value={nmea.windAngle} unit="°" />
|
||||||
|
<DataRow label="Water Temp" value={nmea.waterTemp} unit="°C" />
|
||||||
|
<DataRow label="Air Temp" value={nmea.airTemp} unit="°C" />
|
||||||
|
<DataRow label="RPM" value={nmea.rpm} unit="" />
|
||||||
|
<DataRow label="Rudder" value={nmea.rudder} unit="°" />
|
||||||
|
<DataRow label="Fuel" value={nmea.fuel} unit="%" />
|
||||||
|
<DataRow label="Lat" value={nmea.lat?.toFixed(5)} unit="°N" />
|
||||||
|
<DataRow label="Lon" value={nmea.lon?.toFixed(5)} unit="°E" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
panel: { display: 'flex', flexDirection: 'column', gap: 16 },
|
||||||
|
gauges: { display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' },
|
||||||
|
table: {
|
||||||
|
display: 'grid', gridTemplateColumns: '1fr 1fr',
|
||||||
|
gap: '0 24px', background: 'var(--surface)',
|
||||||
|
borderRadius: 'var(--radius)', padding: 16,
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
padding: '7px 0', borderBottom: '1px solid var(--border)',
|
||||||
|
},
|
||||||
|
label: { fontSize: 12, color: 'var(--muted)' },
|
||||||
|
value: { fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--text)' },
|
||||||
|
unit: { color: 'var(--muted)', fontSize: 11 },
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
function BatteryBar({ label, voltage, nominal, low, critical }) {
|
||||||
|
const percent = Math.min(100, Math.max(0, ((voltage - critical) / (nominal - critical)) * 100))
|
||||||
|
const color = voltage < critical ? 'var(--danger)' : voltage < low ? 'var(--warning)' : 'var(--success)'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.battery}>
|
||||||
|
<div style={styles.header}>
|
||||||
|
<span style={styles.label}>{label}</span>
|
||||||
|
<span style={{ ...styles.voltage, color }}>{voltage != null ? `${voltage.toFixed(2)} V` : '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div style={styles.barBg}>
|
||||||
|
<div style={{ ...styles.barFill, width: `${percent}%`, background: color, transition: 'width 0.6s, background 0.3s' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BatteryStatus({ battery1, battery2 }) {
|
||||||
|
return (
|
||||||
|
<div style={styles.container}>
|
||||||
|
<BatteryBar label="Starter (12V)" voltage={battery1} nominal={12.8} low={12.2} critical={11.8} />
|
||||||
|
<BatteryBar label="House (24V)" voltage={battery2} nominal={25.6} low={24.8} critical={23.5} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
container: { display: 'flex', flexDirection: 'column', gap: 14, padding: 16, background: 'var(--surface)', borderRadius: 'var(--radius)', border: '1px solid var(--border)' },
|
||||||
|
battery: { display: 'flex', flexDirection: 'column', gap: 6 },
|
||||||
|
header: { display: 'flex', justifyContent: 'space-between' },
|
||||||
|
label: { fontSize: 12, color: 'var(--muted)' },
|
||||||
|
voltage: { fontFamily: 'var(--font-mono)', fontSize: 14, fontWeight: 600 },
|
||||||
|
barBg: { height: 8, background: 'var(--border)', borderRadius: 4, overflow: 'hidden' },
|
||||||
|
barFill: { height: '100%', borderRadius: 4 },
|
||||||
|
}
|
||||||
22
bordanlage/dashboard/src/components/systems/EngineData.jsx
Normal file
22
bordanlage/dashboard/src/components/systems/EngineData.jsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import Gauge from '../instruments/Gauge.jsx'
|
||||||
|
import { useNMEA } from '../../hooks/useNMEA.js'
|
||||||
|
|
||||||
|
export default function EngineData() {
|
||||||
|
const { rpm, waterTemp, fuel } = useNMEA()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.grid}>
|
||||||
|
<Gauge value={rpm} min={0} max={3000} label="RPM" unit="rpm" warning={2500} danger={2800} />
|
||||||
|
<Gauge value={waterTemp} min={0} max={120} label="Coolant" unit="°C" warning={85} danger={100} />
|
||||||
|
<Gauge value={fuel} min={0} max={100} label="Fuel" unit="%" warning={20} danger={10} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
grid: {
|
||||||
|
display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center',
|
||||||
|
padding: 16, background: 'var(--surface)',
|
||||||
|
borderRadius: 'var(--radius)', border: '1px solid var(--border)',
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useDocker } from '../../hooks/useDocker.js'
|
||||||
|
|
||||||
|
const STATUS_COLOR = {
|
||||||
|
online: 'var(--success)',
|
||||||
|
offline: 'var(--danger)',
|
||||||
|
unknown: 'var(--muted)',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ServiceHealth() {
|
||||||
|
const { services, refresh } = useDocker()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.container}>
|
||||||
|
<div style={styles.header}>
|
||||||
|
<span style={styles.title}>Services</span>
|
||||||
|
<button style={styles.refreshBtn} onClick={refresh}>↻ Refresh</button>
|
||||||
|
</div>
|
||||||
|
{services.map(s => (
|
||||||
|
<div key={s.id} style={styles.row}>
|
||||||
|
<span style={{ ...styles.dot, color: STATUS_COLOR[s.status] }}>●</span>
|
||||||
|
<span style={styles.name}>{s.name}</span>
|
||||||
|
<a href={s.url} target="_blank" rel="noreferrer" style={styles.link}>{s.url.replace(/^https?:\/\//, '')}</a>
|
||||||
|
<span style={{ ...styles.status, color: STATUS_COLOR[s.status] }}>{s.status}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
container: { padding: 16, background: 'var(--surface)', borderRadius: 'var(--radius)', border: '1px solid var(--border)', display: 'flex', flexDirection: 'column', gap: 10 },
|
||||||
|
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 },
|
||||||
|
title: { fontWeight: 600, fontSize: 13, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--muted)' },
|
||||||
|
refreshBtn: { fontSize: 12, color: 'var(--accent)', minWidth: 0, minHeight: 0, padding: '4px 8px', height: 'auto' },
|
||||||
|
row: { display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0', borderBottom: '1px solid var(--border)' },
|
||||||
|
dot: { fontSize: 10 },
|
||||||
|
name: { minWidth: 90, fontWeight: 500, fontSize: 13 },
|
||||||
|
link: { flex: 1, fontSize: 11, color: 'var(--muted)', textDecoration: 'none', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
|
||||||
|
status: { fontSize: 11, fontWeight: 600, textTransform: 'uppercase' },
|
||||||
|
}
|
||||||
40
bordanlage/dashboard/src/hooks/useDocker.js
Normal file
40
bordanlage/dashboard/src/hooks/useDocker.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
const SERVICES = [
|
||||||
|
{ id: 'signalk', name: 'SignalK', url: 'http://localhost:3000/signalk' },
|
||||||
|
{ id: 'snapserver', name: 'Snapcast', url: 'http://localhost:1780' },
|
||||||
|
{ id: 'mopidy', name: 'Mopidy', url: 'http://localhost:6680' },
|
||||||
|
{ id: 'jellyfin', name: 'Jellyfin', url: 'http://localhost:8096' },
|
||||||
|
{ id: 'portainer', name: 'Portainer', url: 'http://localhost:9000' },
|
||||||
|
]
|
||||||
|
|
||||||
|
async function ping(url) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(2000) })
|
||||||
|
return res.ok || res.status < 500
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDocker() {
|
||||||
|
const [services, setServices] = useState(
|
||||||
|
SERVICES.map(s => ({ ...s, status: 'unknown' }))
|
||||||
|
)
|
||||||
|
|
||||||
|
async function checkAll() {
|
||||||
|
const results = await Promise.all(SERVICES.map(async s => ({
|
||||||
|
...s,
|
||||||
|
status: await ping(s.url) ? 'online' : 'offline',
|
||||||
|
})))
|
||||||
|
setServices(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkAll()
|
||||||
|
const timer = setInterval(checkAll, 30000)
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { services, refresh: checkAll }
|
||||||
|
}
|
||||||
93
bordanlage/dashboard/src/hooks/useNMEA.js
Normal file
93
bordanlage/dashboard/src/hooks/useNMEA.js
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { getApi } from '../mock/index.js'
|
||||||
|
|
||||||
|
const radToDeg = r => r * 180 / Math.PI
|
||||||
|
const kelvinToC = k => k - 273.15
|
||||||
|
|
||||||
|
function parseDelta(updates) {
|
||||||
|
const values = {}
|
||||||
|
for (const update of updates) {
|
||||||
|
for (const { path, value } of (update.values || [])) {
|
||||||
|
values[path] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_STATE = {
|
||||||
|
sog: null, cog: null, heading: null, depth: null,
|
||||||
|
windSpeed: null, windAngle: null, windDirection: null,
|
||||||
|
lat: null, lon: null, rpm: null,
|
||||||
|
battery1: null, battery2: null,
|
||||||
|
waterTemp: null, airTemp: null,
|
||||||
|
rudder: null, fuel: null,
|
||||||
|
connected: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNMEA() {
|
||||||
|
const [data, setData] = useState(DEFAULT_STATE)
|
||||||
|
const clientRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const { signalk } = getApi()
|
||||||
|
clientRef.current = signalk
|
||||||
|
|
||||||
|
const onDelta = ({ updates }) => {
|
||||||
|
const v = parseDelta(updates)
|
||||||
|
|
||||||
|
setData(prev => {
|
||||||
|
const next = { ...prev, connected: true }
|
||||||
|
|
||||||
|
if (v['navigation.speedOverGround'] != null)
|
||||||
|
next.sog = v['navigation.speedOverGround'] * 1.944 // m/s → knots
|
||||||
|
if (v['navigation.courseOverGroundTrue'] != null)
|
||||||
|
next.cog = radToDeg(v['navigation.courseOverGroundTrue'])
|
||||||
|
if (v['navigation.headingTrue'] != null)
|
||||||
|
next.heading = radToDeg(v['navigation.headingTrue'])
|
||||||
|
if (v['navigation.position'] != null) {
|
||||||
|
next.lat = v['navigation.position'].latitude
|
||||||
|
next.lon = v['navigation.position'].longitude
|
||||||
|
}
|
||||||
|
if (v['environment.depth.belowKeel'] != null)
|
||||||
|
next.depth = v['environment.depth.belowKeel']
|
||||||
|
if (v['environment.wind.speedApparent'] != null)
|
||||||
|
next.windSpeed = v['environment.wind.speedApparent'] * 1.944
|
||||||
|
if (v['environment.wind.angleApparent'] != null) {
|
||||||
|
next.windAngle = radToDeg(v['environment.wind.angleApparent'])
|
||||||
|
next.windDirection = ((next.heading || 0) + next.windAngle + 360) % 360
|
||||||
|
}
|
||||||
|
if (v['propulsion.main.revolutions'] != null)
|
||||||
|
next.rpm = Math.round(v['propulsion.main.revolutions'] * 60)
|
||||||
|
if (v['electrical.batteries.starter.voltage'] != null)
|
||||||
|
next.battery1 = v['electrical.batteries.starter.voltage']
|
||||||
|
if (v['electrical.batteries.house.voltage'] != null)
|
||||||
|
next.battery2 = v['electrical.batteries.house.voltage']
|
||||||
|
if (v['environment.water.temperature'] != null)
|
||||||
|
next.waterTemp = kelvinToC(v['environment.water.temperature'])
|
||||||
|
if (v['environment.outside.temperature'] != null)
|
||||||
|
next.airTemp = kelvinToC(v['environment.outside.temperature'])
|
||||||
|
if (v['steering.rudderAngle'] != null)
|
||||||
|
next.rudder = radToDeg(v['steering.rudderAngle'])
|
||||||
|
if (v['tanks.fuel.0.currentLevel'] != null)
|
||||||
|
next.fuel = v['tanks.fuel.0.currentLevel'] * 100
|
||||||
|
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDisconnected = () => setData(prev => ({ ...prev, connected: false }))
|
||||||
|
const onConnected = () => setData(prev => ({ ...prev, connected: true }))
|
||||||
|
|
||||||
|
signalk.on('delta', onDelta)
|
||||||
|
signalk.on('connected', onConnected)
|
||||||
|
signalk.on('disconnected', onDisconnected)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
signalk.off('delta', onDelta)
|
||||||
|
signalk.off('connected', onConnected)
|
||||||
|
signalk.off('disconnected', onDisconnected)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
89
bordanlage/dashboard/src/hooks/usePlayer.js
Normal file
89
bordanlage/dashboard/src/hooks/usePlayer.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
import { getApi } from '../mock/index.js'
|
||||||
|
|
||||||
|
const DEFAULT = {
|
||||||
|
currentTrack: null,
|
||||||
|
state: 'stopped',
|
||||||
|
position: 0,
|
||||||
|
activeSource: 'mopidy',
|
||||||
|
connected: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePlayer() {
|
||||||
|
const [player, setPlayer] = useState(DEFAULT)
|
||||||
|
const { mopidy } = getApi()
|
||||||
|
const posTimer = useRef(null)
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
const [track, state, position] = await Promise.all([
|
||||||
|
mopidy.call('playback.get_current_track'),
|
||||||
|
mopidy.call('playback.get_state'),
|
||||||
|
mopidy.call('playback.get_time_position'),
|
||||||
|
])
|
||||||
|
|
||||||
|
setPlayer(prev => ({
|
||||||
|
...prev,
|
||||||
|
connected: true,
|
||||||
|
state: state || 'stopped',
|
||||||
|
position: position || 0,
|
||||||
|
currentTrack: track ? {
|
||||||
|
title: track.name,
|
||||||
|
artist: track.artists?.map(a => a.name).join(', ') || 'Unknown',
|
||||||
|
album: track.album?.name || '',
|
||||||
|
duration: track.length || 0,
|
||||||
|
coverUrl: null,
|
||||||
|
} : null,
|
||||||
|
}))
|
||||||
|
} catch {
|
||||||
|
setPlayer(prev => ({ ...prev, connected: false }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh()
|
||||||
|
|
||||||
|
const onStateChange = ({ new_state }) =>
|
||||||
|
setPlayer(prev => ({ ...prev, state: new_state }))
|
||||||
|
|
||||||
|
const onTrackChange = ({ tl_track }) => {
|
||||||
|
const track = tl_track?.track
|
||||||
|
if (track) setPlayer(prev => ({
|
||||||
|
...prev,
|
||||||
|
position: 0,
|
||||||
|
currentTrack: {
|
||||||
|
title: track.name,
|
||||||
|
artist: track.artists?.map(a => a.name).join(', ') || 'Unknown',
|
||||||
|
album: track.album?.name || '',
|
||||||
|
duration: track.length || 0,
|
||||||
|
coverUrl: null,
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
mopidy.on('event:playbackStateChanged', onStateChange)
|
||||||
|
mopidy.on('event:trackPlaybackStarted', onTrackChange)
|
||||||
|
|
||||||
|
// Advance position locally
|
||||||
|
posTimer.current = setInterval(() => {
|
||||||
|
setPlayer(prev => {
|
||||||
|
if (prev.state !== 'playing') return prev
|
||||||
|
return { ...prev, position: prev.position + 1000 }
|
||||||
|
})
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mopidy.off('event:playbackStateChanged', onStateChange)
|
||||||
|
mopidy.off('event:trackPlaybackStarted', onTrackChange)
|
||||||
|
clearInterval(posTimer.current)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const play = useCallback(() => mopidy.call('playback.play'), [])
|
||||||
|
const pause = useCallback(() => mopidy.call('playback.pause'), [])
|
||||||
|
const next = useCallback(() => mopidy.call('playback.next'), [])
|
||||||
|
const previous = useCallback(() => mopidy.call('playback.previous'), [])
|
||||||
|
const seek = useCallback(pos => mopidy.call('playback.seek', { time_position: pos }), [])
|
||||||
|
|
||||||
|
return { ...player, play, pause, next, previous, seek }
|
||||||
|
}
|
||||||
69
bordanlage/dashboard/src/hooks/useZones.js
Normal file
69
bordanlage/dashboard/src/hooks/useZones.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { getApi } from '../mock/index.js'
|
||||||
|
|
||||||
|
function parseStatus(status) {
|
||||||
|
return (status?.server?.groups || []).map(group => {
|
||||||
|
const client = group.clients?.[0] || {}
|
||||||
|
return {
|
||||||
|
id: group.id,
|
||||||
|
name: client.config?.name || group.id,
|
||||||
|
active: client.connected ?? false,
|
||||||
|
volume: client.config?.volume?.percent ?? 50,
|
||||||
|
muted: client.config?.volume?.muted ?? false,
|
||||||
|
source: group.stream_id || 'Mopidy',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useZones() {
|
||||||
|
const [zones, setZones] = useState([])
|
||||||
|
const [connected, setConnected] = useState(false)
|
||||||
|
const { snapcast } = getApi()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true
|
||||||
|
|
||||||
|
async function loadStatus() {
|
||||||
|
try {
|
||||||
|
const status = await snapcast.call('Server.GetStatus')
|
||||||
|
if (alive) {
|
||||||
|
setZones(parseStatus(status))
|
||||||
|
setConnected(true)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (alive) setConnected(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onUpdate = (msg) => {
|
||||||
|
if (msg?.result?.server) setZones(parseStatus(msg.result))
|
||||||
|
}
|
||||||
|
|
||||||
|
snapcast.on('update', onUpdate)
|
||||||
|
loadStatus()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
alive = false
|
||||||
|
snapcast.off('update', onUpdate)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setVolume = useCallback(async (zoneId, volume) => {
|
||||||
|
await snapcast.call('Client.SetVolume', { id: zoneId, volume: { percent: volume, muted: false } })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setMuted = useCallback(async (zoneId, muted) => {
|
||||||
|
await snapcast.call('Client.SetMuted', { id: zoneId, muted })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setSource = useCallback(async (zoneId, streamId) => {
|
||||||
|
await snapcast.call('Group.SetStream', { id: zoneId, stream_id: streamId })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggleZone = useCallback(async (zoneId) => {
|
||||||
|
const zone = zones.find(z => z.id === zoneId)
|
||||||
|
if (zone) await setMuted(zoneId, !zone.muted)
|
||||||
|
}, [zones, setMuted])
|
||||||
|
|
||||||
|
return { zones, connected, setVolume, setMuted, setSource, toggleZone }
|
||||||
|
}
|
||||||
79
bordanlage/dashboard/src/index.css
Normal file
79
bordanlage/dashboard/src/index.css
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/* ─── CSS Custom Properties ───────────────────────────────────────────────── */
|
||||||
|
:root {
|
||||||
|
--bg: #07111f;
|
||||||
|
--surface: #0a1928;
|
||||||
|
--surface2: #0d2035;
|
||||||
|
--border: #1e2a3a;
|
||||||
|
--text: #e2eaf2;
|
||||||
|
--muted: #4a6080;
|
||||||
|
--accent: #38bdf8;
|
||||||
|
--success: #34d399;
|
||||||
|
--warning: #f59e0b;
|
||||||
|
--danger: #ef4444;
|
||||||
|
--spotify: #1DB954;
|
||||||
|
--airplay: #60a5fa;
|
||||||
|
|
||||||
|
--font-ui: 'DM Sans', system-ui, sans-serif;
|
||||||
|
--font-mono: 'DM Mono', 'Courier New', monospace;
|
||||||
|
|
||||||
|
--radius: 8px;
|
||||||
|
--radius-lg: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Reset ───────────────────────────────────────────────────────────────── */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
html, body, #root {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 16px;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Scrollbar ───────────────────────────────────────────────────────────── */
|
||||||
|
::-webkit-scrollbar { width: 4px; }
|
||||||
|
::-webkit-scrollbar-track { background: var(--surface); }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||||
|
|
||||||
|
/* ─── Utilities ───────────────────────────────────────────────────────────── */
|
||||||
|
.mono { font-family: var(--font-mono); }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.accent { color: var(--accent); }
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
transition: background 0.15s, opacity 0.15s;
|
||||||
|
}
|
||||||
|
button:active { opacity: 0.7; }
|
||||||
|
|
||||||
|
input[type=range] {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--border);
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
input[type=range]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
10
bordanlage/dashboard/src/main.jsx
Normal file
10
bordanlage/dashboard/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App.jsx'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
38
bordanlage/dashboard/src/mock/index.js
Normal file
38
bordanlage/dashboard/src/mock/index.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// Mock router: returns real API clients in production, mock implementations in dev.
|
||||||
|
import { createSignalKMock } from './signalk.mock.js'
|
||||||
|
import { createSnapcastMock } from './snapcast.mock.js'
|
||||||
|
import { createMopidyMock } from './mopidy.mock.js'
|
||||||
|
import { createSignalKClient } from '../api/signalk.js'
|
||||||
|
import { createSnapcastClient } from '../api/snapcast.js'
|
||||||
|
import { createMopidyClient } from '../api/mopidy.js'
|
||||||
|
|
||||||
|
const isDev = import.meta.env.DEV
|
||||||
|
|
||||||
|
export function createApi() {
|
||||||
|
if (isDev) {
|
||||||
|
return {
|
||||||
|
signalk: createSignalKMock(),
|
||||||
|
snapcast: createSnapcastMock(),
|
||||||
|
mopidy: createMopidyMock(),
|
||||||
|
isMock: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapcastHost = import.meta.env.VITE_SNAPCAST_HOST || 'localhost'
|
||||||
|
const signalkHost = import.meta.env.VITE_SIGNALK_HOST || 'localhost'
|
||||||
|
const mopidyHost = import.meta.env.VITE_MOPIDY_HOST || 'localhost'
|
||||||
|
|
||||||
|
return {
|
||||||
|
signalk: createSignalKClient(`ws://${signalkHost}:3000`),
|
||||||
|
snapcast: createSnapcastClient(`ws://${snapcastHost}:1705`),
|
||||||
|
mopidy: createMopidyClient(`ws://${mopidyHost}:6680`),
|
||||||
|
isMock: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton – one API instance for the whole app
|
||||||
|
let _api = null
|
||||||
|
export function getApi() {
|
||||||
|
if (!_api) _api = createApi()
|
||||||
|
return _api
|
||||||
|
}
|
||||||
149
bordanlage/dashboard/src/mock/mopidy.mock.js
Normal file
149
bordanlage/dashboard/src/mock/mopidy.mock.js
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
// Simulates the Mopidy JSON-RPC WebSocket API.
|
||||||
|
|
||||||
|
const TRACKS = [
|
||||||
|
{
|
||||||
|
uri: 'mock:track:1',
|
||||||
|
name: 'Ocean Drive',
|
||||||
|
artists: [{ name: 'Duke Dumont' }],
|
||||||
|
album: { name: 'Ocean Drive', images: [] },
|
||||||
|
length: 232000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uri: 'mock:track:2',
|
||||||
|
name: 'Feel It Still',
|
||||||
|
artists: [{ name: 'Portugal. The Man' }],
|
||||||
|
album: { name: 'Woodstock', images: [] },
|
||||||
|
length: 178000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uri: 'mock:track:3',
|
||||||
|
name: 'Sailing',
|
||||||
|
artists: [{ name: 'Christopher Cross' }],
|
||||||
|
album: { name: 'Christopher Cross', images: [] },
|
||||||
|
length: 261000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uri: 'mock:track:4',
|
||||||
|
name: 'Beyond the Sea',
|
||||||
|
artists: [{ name: 'Bobby Darin' }],
|
||||||
|
album: { name: 'That\'s All', images: [] },
|
||||||
|
length: 185000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uri: 'mock:track:5',
|
||||||
|
name: 'Into the Mystic',
|
||||||
|
artists: [{ name: 'Van Morrison' }],
|
||||||
|
album: { name: 'Moondance', images: [] },
|
||||||
|
length: 215000,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const RADIO_STATIONS = [
|
||||||
|
{ uri: 'http://stream.swr3.de/swr3/mp3-128/stream.mp3', name: 'SWR3' },
|
||||||
|
{ uri: 'http://ndr.de/ndr1welle-nord-128.mp3', name: 'NDR 1 Welle Nord' },
|
||||||
|
{ uri: 'http://live-bauhaus.radiobt.de/bauhaus/mp3-128', name: 'Radio Bauhaus' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function createMopidyMock() {
|
||||||
|
let state = 'playing'
|
||||||
|
let currentIndex = 0
|
||||||
|
let position = 0
|
||||||
|
let positionTimer = null
|
||||||
|
|
||||||
|
const listeners = {}
|
||||||
|
|
||||||
|
function emit(event, data) {
|
||||||
|
if (listeners[event]) listeners[event].forEach(fn => fn(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTimer() {
|
||||||
|
if (positionTimer) return
|
||||||
|
positionTimer = setInterval(() => {
|
||||||
|
if (state === 'playing') {
|
||||||
|
position += 1000
|
||||||
|
const track = TRACKS[currentIndex]
|
||||||
|
if (position >= track.length) {
|
||||||
|
currentIndex = (currentIndex + 1) % TRACKS.length
|
||||||
|
position = 0
|
||||||
|
emit('event:trackPlaybackStarted', { tl_track: { track: TRACKS[currentIndex] } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
startTimer()
|
||||||
|
|
||||||
|
async function call(method, params = {}) {
|
||||||
|
await new Promise(r => setTimeout(r, 15))
|
||||||
|
|
||||||
|
switch (method) {
|
||||||
|
case 'playback.get_current_track':
|
||||||
|
return TRACKS[currentIndex]
|
||||||
|
|
||||||
|
case 'playback.get_state':
|
||||||
|
return state
|
||||||
|
|
||||||
|
case 'playback.get_time_position':
|
||||||
|
return position
|
||||||
|
|
||||||
|
case 'playback.play':
|
||||||
|
state = 'playing'
|
||||||
|
emit('event:playbackStateChanged', { new_state: 'playing' })
|
||||||
|
return null
|
||||||
|
|
||||||
|
case 'playback.pause':
|
||||||
|
state = 'paused'
|
||||||
|
emit('event:playbackStateChanged', { new_state: 'paused' })
|
||||||
|
return null
|
||||||
|
|
||||||
|
case 'playback.stop':
|
||||||
|
state = 'stopped'
|
||||||
|
position = 0
|
||||||
|
emit('event:playbackStateChanged', { new_state: 'stopped' })
|
||||||
|
return null
|
||||||
|
|
||||||
|
case 'playback.next':
|
||||||
|
currentIndex = (currentIndex + 1) % TRACKS.length
|
||||||
|
position = 0
|
||||||
|
state = 'playing'
|
||||||
|
emit('event:trackPlaybackStarted', { tl_track: { track: TRACKS[currentIndex] } })
|
||||||
|
return null
|
||||||
|
|
||||||
|
case 'playback.previous':
|
||||||
|
currentIndex = (currentIndex - 1 + TRACKS.length) % TRACKS.length
|
||||||
|
position = 0
|
||||||
|
state = 'playing'
|
||||||
|
emit('event:trackPlaybackStarted', { tl_track: { track: TRACKS[currentIndex] } })
|
||||||
|
return null
|
||||||
|
|
||||||
|
case 'playback.seek':
|
||||||
|
position = params.time_position || 0
|
||||||
|
return null
|
||||||
|
|
||||||
|
case 'tracklist.get_tracks':
|
||||||
|
return TRACKS
|
||||||
|
|
||||||
|
case 'library.browse':
|
||||||
|
return TRACKS.map(t => ({ uri: t.uri, name: t.name, type: 'track' }))
|
||||||
|
|
||||||
|
case 'library.search':
|
||||||
|
return [{ tracks: TRACKS }]
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
call,
|
||||||
|
on(event, fn) {
|
||||||
|
if (!listeners[event]) listeners[event] = []
|
||||||
|
listeners[event].push(fn)
|
||||||
|
},
|
||||||
|
off(event, fn) {
|
||||||
|
if (listeners[event]) listeners[event] = listeners[event].filter(l => l !== fn)
|
||||||
|
},
|
||||||
|
getTracks: () => TRACKS,
|
||||||
|
getRadioStations: () => RADIO_STATIONS,
|
||||||
|
}
|
||||||
|
}
|
||||||
118
bordanlage/dashboard/src/mock/signalk.mock.js
Normal file
118
bordanlage/dashboard/src/mock/signalk.mock.js
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
// Simulates a SignalK WebSocket delta stream with realistic Baltic Sea boat data.
|
||||||
|
|
||||||
|
const INTERVAL_MS = 1000
|
||||||
|
|
||||||
|
// Starting position: Kiel Fjord, Baltic Sea
|
||||||
|
const BASE_LAT = 54.3233
|
||||||
|
const BASE_LON = 10.1394
|
||||||
|
|
||||||
|
function randomWalk(value, min, max, step) {
|
||||||
|
const delta = (Math.random() - 0.5) * step * 2
|
||||||
|
return Math.min(max, Math.max(min, value + delta))
|
||||||
|
}
|
||||||
|
|
||||||
|
function degToRad(d) { return d * Math.PI / 180 }
|
||||||
|
|
||||||
|
export function createSignalKMock() {
|
||||||
|
const listeners = {}
|
||||||
|
let timer = null
|
||||||
|
let running = false
|
||||||
|
|
||||||
|
// Initial state
|
||||||
|
const state = {
|
||||||
|
sog: 5.2,
|
||||||
|
cog: 215,
|
||||||
|
heading: 217,
|
||||||
|
depth: 12.4,
|
||||||
|
windSpeed: 13.5,
|
||||||
|
windAngle: 42,
|
||||||
|
rpm: 1800,
|
||||||
|
battery1: 12.6, // starter
|
||||||
|
battery2: 25.1, // house (24V)
|
||||||
|
waterTemp: 17.8,
|
||||||
|
lat: BASE_LAT,
|
||||||
|
lon: BASE_LON,
|
||||||
|
routeIndex: 0,
|
||||||
|
rudder: 2.5,
|
||||||
|
airTemp: 14.2,
|
||||||
|
fuel: 68,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate boat moving along a rough course
|
||||||
|
function advancePosition() {
|
||||||
|
const speedMs = state.sog * 0.514 // knots to m/s
|
||||||
|
const headRad = degToRad(state.heading)
|
||||||
|
const dLat = (speedMs * Math.cos(headRad) * INTERVAL_MS / 1000) / 111320
|
||||||
|
const dLon = (speedMs * Math.sin(headRad) * INTERVAL_MS / 1000) / (111320 * Math.cos(degToRad(state.lat)))
|
||||||
|
state.lat += dLat
|
||||||
|
state.lon += dLon
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDelta() {
|
||||||
|
state.sog = randomWalk(state.sog, 3.5, 8, 0.15)
|
||||||
|
state.cog = randomWalk(state.cog, 200, 235, 1.5)
|
||||||
|
state.heading = randomWalk(state.heading, 198, 237, 1.2)
|
||||||
|
state.depth = randomWalk(state.depth, 6, 25, 0.3)
|
||||||
|
state.windSpeed = randomWalk(state.windSpeed, 8, 22, 0.8)
|
||||||
|
state.windAngle = randomWalk(state.windAngle, 25, 70, 2)
|
||||||
|
state.rpm = Math.round(randomWalk(state.rpm, 1500, 2100, 40))
|
||||||
|
state.battery1 = randomWalk(state.battery1, 12.2, 12.9, 0.02)
|
||||||
|
state.battery2 = randomWalk(state.battery2, 24.5, 25.6, 0.04)
|
||||||
|
state.waterTemp = randomWalk(state.waterTemp, 16, 20, 0.05)
|
||||||
|
state.rudder = randomWalk(state.rudder, -15, 15, 1.5)
|
||||||
|
advancePosition()
|
||||||
|
|
||||||
|
return {
|
||||||
|
updates: [{
|
||||||
|
source: { label: 'mock', type: 'NMEA2000' },
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
values: [
|
||||||
|
{ path: 'navigation.speedOverGround', value: state.sog * 0.514 },
|
||||||
|
{ path: 'navigation.courseOverGroundTrue', value: degToRad(state.cog) },
|
||||||
|
{ path: 'navigation.headingTrue', value: degToRad(state.heading) },
|
||||||
|
{ path: 'navigation.position', value: { latitude: state.lat, longitude: state.lon } },
|
||||||
|
{ path: 'environment.depth.belowKeel', value: state.depth },
|
||||||
|
{ path: 'environment.wind.speedApparent', value: state.windSpeed * 0.514 },
|
||||||
|
{ path: 'environment.wind.angleApparent', value: degToRad(state.windAngle) },
|
||||||
|
{ path: 'environment.water.temperature', value: state.waterTemp + 273.15 },
|
||||||
|
{ path: 'environment.outside.temperature', value: state.airTemp + 273.15 },
|
||||||
|
{ path: 'propulsion.main.revolutions', value: state.rpm / 60 },
|
||||||
|
{ path: 'electrical.batteries.starter.voltage', value: state.battery1 },
|
||||||
|
{ path: 'electrical.batteries.house.voltage', value: state.battery2 },
|
||||||
|
{ path: 'steering.rudderAngle', value: degToRad(state.rudder) },
|
||||||
|
{ path: 'tanks.fuel.0.currentLevel', value: state.fuel / 100 },
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function emit(event, data) {
|
||||||
|
if (listeners[event]) listeners[event].forEach(fn => fn(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
if (running) return
|
||||||
|
running = true
|
||||||
|
// Send initial delta immediately
|
||||||
|
emit('delta', buildDelta())
|
||||||
|
timer = setInterval(() => emit('delta', buildDelta()), INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
if (timer) clearInterval(timer)
|
||||||
|
running = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
on(event, fn) {
|
||||||
|
if (!listeners[event]) listeners[event] = []
|
||||||
|
listeners[event].push(fn)
|
||||||
|
if (event === 'delta' && !running) start()
|
||||||
|
},
|
||||||
|
off(event, fn) {
|
||||||
|
if (listeners[event]) listeners[event] = listeners[event].filter(l => l !== fn)
|
||||||
|
},
|
||||||
|
getSnapshot: () => ({ ...state }),
|
||||||
|
disconnect: stop,
|
||||||
|
}
|
||||||
|
}
|
||||||
92
bordanlage/dashboard/src/mock/snapcast.mock.js
Normal file
92
bordanlage/dashboard/src/mock/snapcast.mock.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
// Simulates the Snapcast JSON-RPC API (WebSocket-based).
|
||||||
|
|
||||||
|
const STREAMS = [
|
||||||
|
{ id: 'Spotify', status: 'idle', uri: { name: 'Spotify' } },
|
||||||
|
{ id: 'AirPlay', status: 'idle', uri: { name: 'AirPlay' } },
|
||||||
|
{ id: 'Mopidy', status: 'playing', uri: { name: 'Mopidy' } },
|
||||||
|
]
|
||||||
|
|
||||||
|
const initialZones = [
|
||||||
|
{ id: 'zone-salon', name: 'Salon', volume: 72, muted: false, connected: true, stream: 'Mopidy' },
|
||||||
|
{ id: 'zone-cockpit', name: 'Cockpit', volume: 58, muted: false, connected: true, stream: 'Mopidy' },
|
||||||
|
{ id: 'zone-bug', name: 'Bug', volume: 45, muted: true, connected: true, stream: 'Spotify' },
|
||||||
|
{ id: 'zone-heck', name: 'Heck', volume: 60, muted: false, connected: false, stream: 'Mopidy' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function createSnapcastMock() {
|
||||||
|
const zones = initialZones.map(z => ({ ...z }))
|
||||||
|
const listeners = {}
|
||||||
|
|
||||||
|
function emit(event, data) {
|
||||||
|
if (listeners[event]) listeners[event].forEach(fn => fn(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStatus() {
|
||||||
|
return {
|
||||||
|
server: {
|
||||||
|
groups: zones.map(z => ({
|
||||||
|
id: z.id,
|
||||||
|
name: z.name,
|
||||||
|
stream_id: z.stream,
|
||||||
|
clients: [{
|
||||||
|
id: z.id,
|
||||||
|
connected: z.connected,
|
||||||
|
config: {
|
||||||
|
name: z.name,
|
||||||
|
volume: { percent: z.volume, muted: z.muted }
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
})),
|
||||||
|
streams: STREAMS,
|
||||||
|
version: '0.27.0',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function call(method, params = {}) {
|
||||||
|
await new Promise(r => setTimeout(r, 10)) // simulate network
|
||||||
|
|
||||||
|
switch (method) {
|
||||||
|
case 'Server.GetStatus':
|
||||||
|
return buildStatus()
|
||||||
|
|
||||||
|
case 'Server.GetRPCVersion':
|
||||||
|
return { major: 2, minor: 0, patch: 0 }
|
||||||
|
|
||||||
|
case 'Client.SetVolume': {
|
||||||
|
const z = zones.find(z => z.id === params.id)
|
||||||
|
if (z) { z.volume = params.volume.percent; z.muted = params.volume.muted }
|
||||||
|
emit('update', buildStatus())
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'Client.SetMuted': {
|
||||||
|
const z = zones.find(z => z.id === params.id)
|
||||||
|
if (z) z.muted = params.muted
|
||||||
|
emit('update', buildStatus())
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'Group.SetStream': {
|
||||||
|
const z = zones.find(z => z.id === params.id)
|
||||||
|
if (z) z.stream = params.stream_id
|
||||||
|
emit('update', buildStatus())
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Snapcast mock: unknown method "${method}"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
call,
|
||||||
|
on(event, fn) {
|
||||||
|
if (!listeners[event]) listeners[event] = []
|
||||||
|
listeners[event].push(fn)
|
||||||
|
},
|
||||||
|
off(event, fn) {
|
||||||
|
if (listeners[event]) listeners[event] = listeners[event].filter(l => l !== fn)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
49
bordanlage/dashboard/src/pages/Audio.jsx
Normal file
49
bordanlage/dashboard/src/pages/Audio.jsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import NowPlaying from '../components/audio/NowPlaying.jsx'
|
||||||
|
import ZoneGrid from '../components/audio/ZoneGrid.jsx'
|
||||||
|
import SourcePicker from '../components/audio/SourcePicker.jsx'
|
||||||
|
import RadioBrowser from '../components/audio/RadioBrowser.jsx'
|
||||||
|
import LibraryBrowser from '../components/audio/LibraryBrowser.jsx'
|
||||||
|
|
||||||
|
const SUB_TABS = ['Zones', 'Radio', 'Library']
|
||||||
|
|
||||||
|
export default function Audio() {
|
||||||
|
const [subTab, setSubTab] = useState('Zones')
|
||||||
|
const [activeSource, setActiveSource] = useState('Mopidy')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.page}>
|
||||||
|
<NowPlaying />
|
||||||
|
|
||||||
|
<SourcePicker activeSource={activeSource} onSelect={setActiveSource} />
|
||||||
|
|
||||||
|
{/* Sub-tabs */}
|
||||||
|
<div style={styles.subTabs}>
|
||||||
|
{SUB_TABS.map(t => (
|
||||||
|
<button key={t} style={{ ...styles.subTab, ...(subTab === t ? styles.subTabActive : {}) }}
|
||||||
|
onClick={() => setSubTab(t)}>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={styles.content}>
|
||||||
|
{subTab === 'Zones' && <ZoneGrid />}
|
||||||
|
{subTab === 'Radio' && <RadioBrowser />}
|
||||||
|
{subTab === 'Library' && <LibraryBrowser />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
page: { padding: 16, display: 'flex', flexDirection: 'column', gap: 14, overflow: 'auto', flex: 1 },
|
||||||
|
subTabs: { display: 'flex', gap: 2, background: 'var(--surface)', borderRadius: 'var(--radius)', padding: 3, border: '1px solid var(--border)' },
|
||||||
|
subTab: {
|
||||||
|
flex: 1, height: 36, fontSize: 13, fontWeight: 500,
|
||||||
|
color: 'var(--muted)', borderRadius: 6,
|
||||||
|
background: 'none', minHeight: 36,
|
||||||
|
},
|
||||||
|
subTabActive: { background: 'var(--surface2)', color: 'var(--text)' },
|
||||||
|
content: { flex: 1 },
|
||||||
|
}
|
||||||
28
bordanlage/dashboard/src/pages/Navigation.jsx
Normal file
28
bordanlage/dashboard/src/pages/Navigation.jsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import ChartPlaceholder from '../components/nav/ChartPlaceholder.jsx'
|
||||||
|
import InstrumentPanel from '../components/nav/InstrumentPanel.jsx'
|
||||||
|
|
||||||
|
export default function Navigation() {
|
||||||
|
return (
|
||||||
|
<div style={styles.layout}>
|
||||||
|
<div style={styles.chart}>
|
||||||
|
<ChartPlaceholder />
|
||||||
|
</div>
|
||||||
|
<div style={styles.panel}>
|
||||||
|
<InstrumentPanel />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
layout: {
|
||||||
|
display: 'flex',
|
||||||
|
gap: 16,
|
||||||
|
padding: 16,
|
||||||
|
flex: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
minHeight: 0,
|
||||||
|
},
|
||||||
|
chart: { flex: 2, display: 'flex', minHeight: 0 },
|
||||||
|
panel: { flex: 1, overflow: 'auto', minHeight: 0 },
|
||||||
|
}
|
||||||
42
bordanlage/dashboard/src/pages/Overview.jsx
Normal file
42
bordanlage/dashboard/src/pages/Overview.jsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { useNMEA } from '../hooks/useNMEA.js'
|
||||||
|
import Compass from '../components/instruments/Compass.jsx'
|
||||||
|
import SpeedLog from '../components/instruments/SpeedLog.jsx'
|
||||||
|
import DepthSounder from '../components/instruments/DepthSounder.jsx'
|
||||||
|
import WindRose from '../components/instruments/WindRose.jsx'
|
||||||
|
import NowPlaying from '../components/audio/NowPlaying.jsx'
|
||||||
|
import ZoneGrid from '../components/audio/ZoneGrid.jsx'
|
||||||
|
|
||||||
|
export default function Overview() {
|
||||||
|
const nmea = useNMEA()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.page}>
|
||||||
|
{/* Instruments row */}
|
||||||
|
<section style={styles.instruments}>
|
||||||
|
<Compass heading={nmea.heading} cog={nmea.cog} />
|
||||||
|
<SpeedLog sog={nmea.sog} />
|
||||||
|
<DepthSounder depth={nmea.depth} />
|
||||||
|
<WindRose windAngle={nmea.windAngle} windSpeed={nmea.windSpeed} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Now Playing */}
|
||||||
|
<NowPlaying compact />
|
||||||
|
|
||||||
|
{/* Zone quick overview */}
|
||||||
|
<section>
|
||||||
|
<div style={styles.sectionTitle}>Audio Zones</div>
|
||||||
|
<ZoneGrid />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
page: { padding: 16, display: 'flex', flexDirection: 'column', gap: 16, overflow: 'auto', flex: 1 },
|
||||||
|
instruments: {
|
||||||
|
display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center',
|
||||||
|
padding: 16, background: 'var(--surface)',
|
||||||
|
borderRadius: 'var(--radius)', border: '1px solid var(--border)',
|
||||||
|
},
|
||||||
|
sectionTitle: { fontWeight: 600, fontSize: 12, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 10 },
|
||||||
|
}
|
||||||
32
bordanlage/dashboard/src/pages/Systems.jsx
Normal file
32
bordanlage/dashboard/src/pages/Systems.jsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { useNMEA } from '../hooks/useNMEA.js'
|
||||||
|
import BatteryStatus from '../components/systems/BatteryStatus.jsx'
|
||||||
|
import EngineData from '../components/systems/EngineData.jsx'
|
||||||
|
import ServiceHealth from '../components/systems/ServiceHealth.jsx'
|
||||||
|
|
||||||
|
export default function Systems() {
|
||||||
|
const { battery1, battery2 } = useNMEA()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.page}>
|
||||||
|
<section>
|
||||||
|
<div style={styles.sectionTitle}>Batteries</div>
|
||||||
|
<BatteryStatus battery1={battery1} battery2={battery2} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div style={styles.sectionTitle}>Engine</div>
|
||||||
|
<EngineData />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div style={styles.sectionTitle}>Services</div>
|
||||||
|
<ServiceHealth />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
page: { padding: 16, display: 'flex', flexDirection: 'column', gap: 20, overflow: 'auto', flex: 1 },
|
||||||
|
sectionTitle: { fontWeight: 600, fontSize: 12, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 10 },
|
||||||
|
}
|
||||||
15
bordanlage/dashboard/vite.config.js
Normal file
15
bordanlage/dashboard/vite.config.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 8080,
|
||||||
|
proxy: {
|
||||||
|
'/signalk': { target: 'http://localhost:3000', ws: true },
|
||||||
|
'/snapcast-ws': { target: 'http://localhost:1780', ws: true },
|
||||||
|
'/mopidy': { target: 'http://localhost:6680', ws: true },
|
||||||
|
'/jellyfin': { target: 'http://localhost:8096', changeOrigin: true },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
58
bordanlage/docker-compose.dev.yml
Normal file
58
bordanlage/docker-compose.dev.yml
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
# Development override – run without any hardware.
|
||||||
|
# Usage: docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||||
|
|
||||||
|
services:
|
||||||
|
|
||||||
|
signalk:
|
||||||
|
environment:
|
||||||
|
- SIGNALK_DEMO=true # Built-in demo NMEA data generator
|
||||||
|
|
||||||
|
# Spotify Connect still works over TCP on dev (no host network needed)
|
||||||
|
librespot:
|
||||||
|
environment:
|
||||||
|
- LIBRESPOT_DISABLE_DISCOVERY=false
|
||||||
|
ports:
|
||||||
|
- "57621:57621/udp"
|
||||||
|
- "57621:57621/tcp"
|
||||||
|
|
||||||
|
# avahi container for AirPlay mDNS discovery on Mac/Windows
|
||||||
|
avahi:
|
||||||
|
image: flungo/avahi
|
||||||
|
restart: unless-stopped
|
||||||
|
network_mode: host
|
||||||
|
volumes:
|
||||||
|
- /var/run/dbus:/var/run/dbus
|
||||||
|
|
||||||
|
# Null-player zones – no audio hardware needed
|
||||||
|
zone-salon:
|
||||||
|
command: snapclient --host snapserver --hostID zone-salon --player null
|
||||||
|
|
||||||
|
zone-cockpit:
|
||||||
|
command: snapclient --host snapserver --hostID zone-cockpit --player null
|
||||||
|
|
||||||
|
zone-bug:
|
||||||
|
command: snapclient --host snapserver --hostID zone-bug --player null
|
||||||
|
|
||||||
|
zone-heck:
|
||||||
|
command: snapclient --host snapserver --hostID zone-heck --player null
|
||||||
|
|
||||||
|
# Vite dev server with HMR instead of built nginx image
|
||||||
|
dashboard:
|
||||||
|
build: .
|
||||||
|
image: node:20-alpine
|
||||||
|
working_dir: /app
|
||||||
|
volumes:
|
||||||
|
- ./dashboard:/app
|
||||||
|
- /app/node_modules
|
||||||
|
command: sh -c "npm install && npm run dev -- --host 0.0.0.0 --port 8080"
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
- VITE_SNAPCAST_HOST=localhost
|
||||||
|
- VITE_SIGNALK_HOST=localhost
|
||||||
|
- VITE_MOPIDY_HOST=localhost
|
||||||
|
- VITE_JELLYFIN_HOST=localhost
|
||||||
|
# Override the build-based image
|
||||||
|
image: node:20-alpine
|
||||||
204
bordanlage/docker-compose.yml
Normal file
204
bordanlage/docker-compose.yml
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
# Production docker-compose for boat deployment.
|
||||||
|
# For development use: docker compose -f docker-compose.yml -f docker-compose.dev.yml up
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
signalk-data:
|
||||||
|
mopidy-data:
|
||||||
|
jellyfin-config:
|
||||||
|
jellyfin-cache:
|
||||||
|
portainer-data:
|
||||||
|
pipes:
|
||||||
|
driver: local
|
||||||
|
driver_opts:
|
||||||
|
type: tmpfs
|
||||||
|
device: tmpfs
|
||||||
|
|
||||||
|
networks:
|
||||||
|
bordanlage:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
services:
|
||||||
|
|
||||||
|
# ─── Navigation ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
signalk:
|
||||||
|
image: signalk/signalk-server:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- signalk-data:/home/node/.signalk
|
||||||
|
environment:
|
||||||
|
- SIGNALK_ADMIN_USERNAME=admin
|
||||||
|
- SIGNALK_ADMIN_PASSWORD=bordanlage
|
||||||
|
# Uncomment for real NMEA hardware on boat:
|
||||||
|
# devices:
|
||||||
|
# - /dev/ttyUSB0:/dev/ttyUSB0
|
||||||
|
networks:
|
||||||
|
- bordanlage
|
||||||
|
|
||||||
|
# ─── Audio Sources ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
librespot:
|
||||||
|
image: ghcr.io/librespot-org/librespot:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
# On Linux (boat): use host network for mDNS/Spotify discovery
|
||||||
|
# network_mode: host
|
||||||
|
ports:
|
||||||
|
- "57621:57621/udp" # Spotify zeroconf discovery
|
||||||
|
- "57621:57621/tcp"
|
||||||
|
environment:
|
||||||
|
- SPOTIFY_NAME=${SPOTIFY_NAME:-Bordanlage}
|
||||||
|
- SPOTIFY_BITRATE=${SPOTIFY_BITRATE:-320}
|
||||||
|
command: >
|
||||||
|
--name "${SPOTIFY_NAME:-Bordanlage}"
|
||||||
|
--bitrate ${SPOTIFY_BITRATE:-320}
|
||||||
|
--backend pipe
|
||||||
|
--device /tmp/audio/spotify.pcm
|
||||||
|
--zeroconf-port 57621
|
||||||
|
--cache-size-limit ${SPOTIFY_CACHE_SIZE:-1024}
|
||||||
|
volumes:
|
||||||
|
- pipes:/tmp/audio
|
||||||
|
networks:
|
||||||
|
- bordanlage
|
||||||
|
|
||||||
|
shairport:
|
||||||
|
image: mikebrady/shairport-sync:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
# On Linux (boat): use host network for mDNS/AirPlay discovery
|
||||||
|
# network_mode: host
|
||||||
|
ports:
|
||||||
|
- "5000:5000/tcp"
|
||||||
|
- "5000:5000/udp"
|
||||||
|
- "6001-6011:6001-6011/udp"
|
||||||
|
volumes:
|
||||||
|
- ./config/shairport.conf:/etc/shairport-sync.conf:ro
|
||||||
|
- pipes:/tmp/audio
|
||||||
|
networks:
|
||||||
|
- bordanlage
|
||||||
|
|
||||||
|
mopidy:
|
||||||
|
build: ./docker/mopidy
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "6680:6680"
|
||||||
|
volumes:
|
||||||
|
- ./config/mopidy.conf:/etc/mopidy/mopidy.conf:ro
|
||||||
|
- mopidy-data:/var/lib/mopidy
|
||||||
|
- ${MUSIC_PATH:-./music}:/music:ro
|
||||||
|
- pipes:/tmp/audio
|
||||||
|
networks:
|
||||||
|
- bordanlage
|
||||||
|
|
||||||
|
# ─── Media Library ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
jellyfin:
|
||||||
|
image: jellyfin/jellyfin:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8096:8096"
|
||||||
|
volumes:
|
||||||
|
- jellyfin-config:/config
|
||||||
|
- jellyfin-cache:/cache
|
||||||
|
- ${MUSIC_PATH:-./music}:/music:ro
|
||||||
|
environment:
|
||||||
|
- JELLYFIN_PublishedServerUrl=http://localhost:8096
|
||||||
|
# Uncomment for hardware video decoding on boat:
|
||||||
|
# devices:
|
||||||
|
# - /dev/dri:/dev/dri
|
||||||
|
networks:
|
||||||
|
- bordanlage
|
||||||
|
|
||||||
|
# ─── Multiroom Audio ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
snapserver:
|
||||||
|
image: ghcr.io/badaix/snapcast:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "1704:1704" # Snapcast protocol
|
||||||
|
- "1705:1705" # Control API (JSON-RPC)
|
||||||
|
- "1780:1780" # Snapweb UI
|
||||||
|
volumes:
|
||||||
|
- ./config/snapserver.conf:/etc/snapserver.conf:ro
|
||||||
|
- pipes:/tmp/audio
|
||||||
|
depends_on:
|
||||||
|
- librespot
|
||||||
|
- mopidy
|
||||||
|
command: snapserver -c /etc/snapserver.conf
|
||||||
|
networks:
|
||||||
|
- bordanlage
|
||||||
|
|
||||||
|
zone-salon:
|
||||||
|
image: ghcr.io/badaix/snapcast:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- snapserver
|
||||||
|
# On boat: add --soundcard hw:0,0 and device /dev/snd
|
||||||
|
# devices:
|
||||||
|
# - /dev/snd:/dev/snd
|
||||||
|
command: snapclient --host snapserver --hostID zone-salon --player null
|
||||||
|
networks:
|
||||||
|
- bordanlage
|
||||||
|
|
||||||
|
zone-cockpit:
|
||||||
|
image: ghcr.io/badaix/snapcast:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- snapserver
|
||||||
|
command: snapclient --host snapserver --hostID zone-cockpit --player null
|
||||||
|
networks:
|
||||||
|
- bordanlage
|
||||||
|
|
||||||
|
zone-bug:
|
||||||
|
image: ghcr.io/badaix/snapcast:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- snapserver
|
||||||
|
command: snapclient --host snapserver --hostID zone-bug --player null
|
||||||
|
networks:
|
||||||
|
- bordanlage
|
||||||
|
|
||||||
|
zone-heck:
|
||||||
|
image: ghcr.io/badaix/snapcast:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- snapserver
|
||||||
|
command: snapclient --host snapserver --hostID zone-heck --player null
|
||||||
|
networks:
|
||||||
|
- bordanlage
|
||||||
|
|
||||||
|
# ─── Management ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
portainer:
|
||||||
|
image: portainer/portainer-ce:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "9000:9000"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- portainer-data:/data
|
||||||
|
networks:
|
||||||
|
- bordanlage
|
||||||
|
|
||||||
|
# ─── Dashboard ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
dashboard:
|
||||||
|
build:
|
||||||
|
context: ./dashboard
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
environment:
|
||||||
|
- VITE_SNAPCAST_HOST=${VITE_SNAPCAST_HOST:-localhost}
|
||||||
|
- VITE_SIGNALK_HOST=${VITE_SIGNALK_HOST:-localhost}
|
||||||
|
- VITE_MOPIDY_HOST=${VITE_MOPIDY_HOST:-localhost}
|
||||||
|
- VITE_JELLYFIN_HOST=${VITE_JELLYFIN_HOST:-localhost}
|
||||||
|
depends_on:
|
||||||
|
- snapserver
|
||||||
|
- signalk
|
||||||
|
- mopidy
|
||||||
|
networks:
|
||||||
|
- bordanlage
|
||||||
20
bordanlage/docker/mopidy/Dockerfile
Normal file
20
bordanlage/docker/mopidy/Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
FROM ghcr.io/mopidy/mopidy:latest
|
||||||
|
|
||||||
|
USER root
|
||||||
|
|
||||||
|
RUN pip3 install \
|
||||||
|
mopidy-iris \
|
||||||
|
mopidy-local \
|
||||||
|
mopidy-stream \
|
||||||
|
mopidy-tunein \
|
||||||
|
mopidy-podcast \
|
||||||
|
--break-system-packages
|
||||||
|
|
||||||
|
# Ensure audio pipe directory exists at startup
|
||||||
|
RUN echo '#!/bin/sh\nmkdir -p /tmp/audio\nexec "$@"' > /entrypoint-wrapper.sh \
|
||||||
|
&& chmod +x /entrypoint-wrapper.sh
|
||||||
|
|
||||||
|
USER mopidy
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint-wrapper.sh"]
|
||||||
|
CMD ["mopidy", "--config", "/etc/mopidy/mopidy.conf"]
|
||||||
17
bordanlage/scripts/init-pipes.sh
Executable file
17
bordanlage/scripts/init-pipes.sh
Executable file
@@ -0,0 +1,17 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
PIPE_DIR="${PIPE_DIR:-/tmp/audio}"
|
||||||
|
mkdir -p "$PIPE_DIR"
|
||||||
|
|
||||||
|
for pipe in spotify.pcm airplay.pcm mopidy.pcm; do
|
||||||
|
path="$PIPE_DIR/$pipe"
|
||||||
|
if [ ! -p "$path" ]; then
|
||||||
|
mkfifo "$path"
|
||||||
|
echo "Created pipe: $path"
|
||||||
|
else
|
||||||
|
echo "Pipe already exists: $path"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "All audio pipes ready."
|
||||||
25
bordanlage/scripts/setup-boot.sh
Executable file
25
bordanlage/scripts/setup-boot.sh
Executable file
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Setting up production (boat) environment..."
|
||||||
|
|
||||||
|
# Copy .env if not exists
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
cp .env.example .env
|
||||||
|
echo "Created .env – please edit SPOTIFY_NAME, BOAT_NAME, BOAT_MMSI"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create music directory
|
||||||
|
mkdir -p music
|
||||||
|
|
||||||
|
# Init pipes (persistent on boat)
|
||||||
|
PIPE_DIR=/tmp/audio bash scripts/init-pipes.sh
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Edit .env then run 'make boot' to start."
|
||||||
|
echo "Dashboard: http://<boat-ip>:8080"
|
||||||
|
echo ""
|
||||||
|
echo "IMPORTANT: Review docker-compose.yml and uncomment:"
|
||||||
|
echo " - /dev/ttyUSB0 for NMEA hardware"
|
||||||
|
echo " - /dev/snd for zone audio output"
|
||||||
|
echo " - /dev/dri for hardware video decoding"
|
||||||
21
bordanlage/scripts/setup-dev.sh
Executable file
21
bordanlage/scripts/setup-dev.sh
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Setting up development environment..."
|
||||||
|
|
||||||
|
# Copy .env if not exists
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
cp .env.example .env
|
||||||
|
echo "Created .env from .env.example"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create music directory
|
||||||
|
mkdir -p music
|
||||||
|
echo "Music directory: ./music (drop your files here)"
|
||||||
|
|
||||||
|
# Init pipes
|
||||||
|
bash scripts/init-pipes.sh
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Done! Run 'make dev' to start all services."
|
||||||
|
echo "Dashboard: http://localhost:8080"
|
||||||
482
plan.md
Normal file
482
plan.md
Normal file
@@ -0,0 +1,482 @@
|
|||||||
|
# Bordanlage – Vollständiges Multiroom Audio + Bootsdaten Dashboard
|
||||||
|
|
||||||
|
## Projektbeschreibung
|
||||||
|
|
||||||
|
Baue ein vollständiges, produktionsreifes System namens `bordanlage` für ein Boot.
|
||||||
|
Es besteht aus drei Teilen:
|
||||||
|
|
||||||
|
1. **Docker-Stack** – alle Backend-Dienste (Audio, Navigation, Management)
|
||||||
|
2. **React-Dashboard** – ein modernes, touch-optimiertes Web-UI für Touchscreen-Display
|
||||||
|
3. **Dev-Modus** – vollständig ohne Hardware lauffähig (Mac & Windows), mit simulierten Daten
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technologie-Stack
|
||||||
|
|
||||||
|
### Backend (Docker)
|
||||||
|
- **Snapcast** (snapserver + snapclient) → synchrones Multiroom-Audio, 4 Zonen
|
||||||
|
- **librespot** → Spotify Connect (echter Endpunkt, erscheint in Spotify App)
|
||||||
|
- **shairport-sync** → AirPlay 2 Empfänger (erscheint auf iPhone/Mac)
|
||||||
|
- **Mopidy** + Plugins → Web Radio (HTTP-Streams) + lokale Musikbibliothek
|
||||||
|
- **Jellyfin** → Mediathek (Musik, Hörbücher, Videos von Festplatte/USB)
|
||||||
|
- **SignalK** → NMEA 2000 / NMEA 0183 Gateway (Bootsdaten)
|
||||||
|
- **Portainer** → Docker Management UI
|
||||||
|
- **Nginx** → Reverse Proxy, served das Dashboard
|
||||||
|
|
||||||
|
### Frontend (React + Vite)
|
||||||
|
- React 18 + Vite
|
||||||
|
- Keine UI-Bibliothek – eigenes Design-System
|
||||||
|
- WebSocket-Verbindung zu SignalK für Live-Bootsdaten
|
||||||
|
- Snapcast JSON-RPC API für Zonen-Steuerung
|
||||||
|
- Mopidy JSON-RPC API für Musik-Steuerung
|
||||||
|
- Jellyfin REST API für Mediathek
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Projektstruktur
|
||||||
|
|
||||||
|
Erstelle folgende Verzeichnisstruktur:
|
||||||
|
|
||||||
|
```
|
||||||
|
bordanlage/
|
||||||
|
├── docker-compose.yml # Produktion (Boot)
|
||||||
|
├── docker-compose.dev.yml # Development Override (Mac/Windows)
|
||||||
|
├── docker-compose.override.yml # Symlink → dev wenn DEV=true
|
||||||
|
├── .env.example
|
||||||
|
├── .env
|
||||||
|
├── Makefile # make dev / make boot / make stop / make logs
|
||||||
|
├── README.md
|
||||||
|
│
|
||||||
|
├── config/
|
||||||
|
│ ├── snapserver.conf
|
||||||
|
│ ├── mopidy.conf
|
||||||
|
│ ├── shairport.conf
|
||||||
|
│ └── nginx/
|
||||||
|
│ └── default.conf
|
||||||
|
│
|
||||||
|
├── dashboard/ # React App
|
||||||
|
│ ├── package.json
|
||||||
|
│ ├── vite.config.js
|
||||||
|
│ ├── index.html
|
||||||
|
│ └── src/
|
||||||
|
│ ├── main.jsx
|
||||||
|
│ ├── App.jsx
|
||||||
|
│ ├── api/
|
||||||
|
│ │ ├── snapcast.js # Snapcast JSON-RPC Client
|
||||||
|
│ │ ├── mopidy.js # Mopidy JSON-RPC Client
|
||||||
|
│ │ ├── signalk.js # SignalK WebSocket Client
|
||||||
|
│ │ └── jellyfin.js # Jellyfin REST Client
|
||||||
|
│ ├── mock/
|
||||||
|
│ │ ├── index.js # Mock-Router: echte API wenn prod, fake wenn dev
|
||||||
|
│ │ ├── signalk.mock.js # Simulierte NMEA-Daten mit realistischen Werten
|
||||||
|
│ │ ├── snapcast.mock.js # Simulierte Zonen, Lautstärke, Quellen
|
||||||
|
│ │ └── mopidy.mock.js # Simulierte Tracks, Radio, Wiedergabe
|
||||||
|
│ ├── hooks/
|
||||||
|
│ │ ├── useNMEA.js # Hook: NMEA-Daten (echt oder mock)
|
||||||
|
│ │ ├── useZones.js # Hook: Snapcast Zonen
|
||||||
|
│ │ ├── usePlayer.js # Hook: Wiedergabe-Steuerung
|
||||||
|
│ │ └── useDocker.js # Hook: Container-Status via Portainer API
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── layout/
|
||||||
|
│ │ │ ├── TopBar.jsx
|
||||||
|
│ │ │ └── TabNav.jsx
|
||||||
|
│ │ ├── instruments/
|
||||||
|
│ │ │ ├── Gauge.jsx # Analoges Rundinstrument (SVG)
|
||||||
|
│ │ │ ├── Compass.jsx # Kompassrose (SVG, animiert)
|
||||||
|
│ │ │ ├── WindRose.jsx # Windrose (SVG, animiert)
|
||||||
|
│ │ │ ├── DepthSounder.jsx # Tiefenmesser mit Warngrenze
|
||||||
|
│ │ │ └── SpeedLog.jsx # Fahrtmesser
|
||||||
|
│ │ ├── audio/
|
||||||
|
│ │ │ ├── NowPlaying.jsx # Track-Info, Cover, Playback-Controls
|
||||||
|
│ │ │ ├── ZoneCard.jsx # Eine Snapcast-Zone
|
||||||
|
│ │ │ ├── ZoneGrid.jsx # Alle Zonen
|
||||||
|
│ │ │ ├── SourcePicker.jsx # Quelle wählen (Spotify/AirPlay/Radio/Jellyfin)
|
||||||
|
│ │ │ ├── RadioBrowser.jsx # Senderliste mit Suche
|
||||||
|
│ │ │ └── LibraryBrowser.jsx # Jellyfin Mediathek
|
||||||
|
│ │ ├── nav/
|
||||||
|
│ │ │ ├── ChartPlaceholder.jsx # Seekarten-Iframe (OpenCPN/SignalK)
|
||||||
|
│ │ │ └── InstrumentPanel.jsx
|
||||||
|
│ │ └── systems/
|
||||||
|
│ │ ├── BatteryStatus.jsx
|
||||||
|
│ │ ├── EngineData.jsx
|
||||||
|
│ │ └── ServiceHealth.jsx # Docker-Container Status
|
||||||
|
│ └── pages/
|
||||||
|
│ ├── Overview.jsx # Tab 1: Instrumente + Now Playing + Zonen
|
||||||
|
│ ├── Navigation.jsx # Tab 2: Seekarte + alle Navigationsdaten
|
||||||
|
│ ├── Audio.jsx # Tab 3: Vollständige Audio-Steuerung
|
||||||
|
│ └── Systems.jsx # Tab 4: Batterien, Motor, Docker-Status
|
||||||
|
│
|
||||||
|
└── scripts/
|
||||||
|
├── init-pipes.sh # Named Pipes anlegen
|
||||||
|
├── setup-dev.sh # Erstkonfiguration Entwicklung
|
||||||
|
└── setup-boot.sh # Erstkonfiguration echtes Boot
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Detaillierte Anforderungen
|
||||||
|
|
||||||
|
### 1. Docker Compose – Produktion (`docker-compose.yml`)
|
||||||
|
|
||||||
|
Definiere folgende Services:
|
||||||
|
|
||||||
|
**signalk:**
|
||||||
|
- Image: `signalk/signalk-server:latest`
|
||||||
|
- Port: 3000
|
||||||
|
- Volume: signalk-data
|
||||||
|
- Kommentar für NMEA-Adapter: `/dev/ttyUSB0` (auskommentiert)
|
||||||
|
|
||||||
|
**librespot (Spotify Connect):**
|
||||||
|
- Image: `ghcr.io/librespot-org/librespot:latest`
|
||||||
|
- Umgebungsvariablen aus `.env`: `SPOTIFY_NAME`, `SPOTIFY_BITRATE` (default: 320)
|
||||||
|
- Backend: `pipe`, Output: `/tmp/audio/spotify.pcm`
|
||||||
|
- Volume: pipes
|
||||||
|
- WICHTIG: Der Service muss wirklich in der Spotify App als Gerät erscheinen.
|
||||||
|
Stelle sicher, dass `network_mode: host` auf Linux gesetzt ist (Boot).
|
||||||
|
Auf Mac/Windows alternative Port-Mappings verwenden.
|
||||||
|
|
||||||
|
**shairport-sync (AirPlay):**
|
||||||
|
- Image: `mikebrady/shairport-sync:latest`
|
||||||
|
- Config: `./config/shairport.conf` (Output auf `/tmp/audio/airplay.pcm`)
|
||||||
|
- Avahi/mDNS: Auf Linux `network_mode: host`. Auf Mac/Windows eigenen
|
||||||
|
avahi-daemon Container (`hauscontribs/avahi`) bereitstellen damit
|
||||||
|
AirPlay discoverable ist.
|
||||||
|
- Volume: pipes
|
||||||
|
|
||||||
|
**mopidy:**
|
||||||
|
- Image: `ghcr.io/mopidy/mopidy:latest`
|
||||||
|
- Port: 6680
|
||||||
|
- Plugins installieren via Custom-Dockerfile in `./docker/mopidy/`:
|
||||||
|
```
|
||||||
|
mopidy-iris
|
||||||
|
mopidy-local
|
||||||
|
mopidy-stream
|
||||||
|
mopidy-tunein (Web Radio Suche)
|
||||||
|
mopidy-podcast
|
||||||
|
```
|
||||||
|
- Audio-Output via GStreamer-Pipeline auf `/tmp/audio/mopidy.pcm`
|
||||||
|
- Volumes: pipes, mopidy-data, music
|
||||||
|
|
||||||
|
**jellyfin:**
|
||||||
|
- Image: `jellyfin/jellyfin:latest`
|
||||||
|
- Port: 8096
|
||||||
|
- Volumes: jellyfin-config, jellyfin-cache, music (read-only)
|
||||||
|
- Hardware-Decoding: `/dev/dri` auskommentiert für Boot
|
||||||
|
|
||||||
|
**snapserver:**
|
||||||
|
- Image: `ghcr.io/badaix/snapcast:latest`
|
||||||
|
- Ports: 1704 (Protokoll), 1705 (Control API), 1780 (Snapweb)
|
||||||
|
- Config: `./config/snapserver.conf`
|
||||||
|
- 3 Streams: Spotify, AirPlay, Mopidy
|
||||||
|
- depends_on: librespot, mopidy
|
||||||
|
|
||||||
|
**zone-salon, zone-cockpit, zone-bug, zone-heck:**
|
||||||
|
- Je ein `ghcr.io/badaix/snapcast:latest` Container als snapclient
|
||||||
|
- `--host snapserver --hostID <zonename>`
|
||||||
|
- Auf dem Boot: `--soundcard hw:N,0` und `/dev/snd` device
|
||||||
|
- Im Dev-Modus: Ohne Soundkarte (Audio wird simuliert, kein Fehler)
|
||||||
|
|
||||||
|
**portainer:**
|
||||||
|
- Image: `portainer/portainer-ce:latest`
|
||||||
|
- Port: 9000
|
||||||
|
- Volume: docker.sock + portainer-data
|
||||||
|
|
||||||
|
**dashboard:**
|
||||||
|
- Custom Dockerfile in `./dashboard/`
|
||||||
|
- Nginx serviert den gebauten React-Build
|
||||||
|
- Port: 8080
|
||||||
|
- Env-Variablen: alle API-URLs
|
||||||
|
|
||||||
|
### 2. Docker Compose Dev Override (`docker-compose.dev.yml`)
|
||||||
|
|
||||||
|
Überschreibt für lokale Entwicklung ohne Hardware:
|
||||||
|
|
||||||
|
- **signalk**: Zusätzliche Umgebungsvariable `SIGNALK_DEMO=true` →
|
||||||
|
SignalK generiert dann selbst Demo-NMEA-Daten (eingebautes Feature!)
|
||||||
|
|
||||||
|
- **snapclients**: Alle 4 Zonen erhalten `command: snapclient --host snapserver --hostID <name> --player null`
|
||||||
|
(Null-Player: kein Audio-Output, kein Fehler)
|
||||||
|
|
||||||
|
- **dashboard**: Statt gebautem Nginx → Vite Dev-Server mit Hot Reload
|
||||||
|
(`node:20-alpine`, `npm run dev`, Port 8080)
|
||||||
|
|
||||||
|
- **librespot**: `LIBRESPOT_DISABLE_DISCOVERY=false` → erscheint trotzdem in Spotify
|
||||||
|
(funktioniert über TCP auch ohne host network, solange Port 57621 gemappt)
|
||||||
|
|
||||||
|
- **shairport**: avahi-container für mDNS-Discovery auf Mac/Windows
|
||||||
|
|
||||||
|
### 3. `.env` Konfiguration
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Allgemein
|
||||||
|
COMPOSE_PROJECT_NAME=bordanlage
|
||||||
|
DEV=true
|
||||||
|
|
||||||
|
# Spotify Connect
|
||||||
|
SPOTIFY_NAME=Bordanlage
|
||||||
|
SPOTIFY_BITRATE=320
|
||||||
|
SPOTIFY_CACHE_SIZE=1024
|
||||||
|
|
||||||
|
# Boot-Info
|
||||||
|
BOAT_NAME=Meine Yacht
|
||||||
|
BOAT_MMSI=123456789
|
||||||
|
|
||||||
|
# Pfade (Musik, Logs)
|
||||||
|
MUSIC_PATH=./music
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Makefile
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
dev:
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||||
|
|
||||||
|
boot:
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
stop:
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
logs:
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
rebuild:
|
||||||
|
docker compose build --no-cache
|
||||||
|
|
||||||
|
status:
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Dashboard – Mock-System
|
||||||
|
|
||||||
|
Das Mock-System ist KRITISCH. Es muss folgendes können:
|
||||||
|
|
||||||
|
**`src/mock/index.js`:**
|
||||||
|
Exportiert einen `createApi()`-Factory, der prüft ob `import.meta.env.DEV === true`.
|
||||||
|
- Im Dev-Modus: gibt Mock-Implementierungen zurück
|
||||||
|
- Im Prod-Modus: gibt echte API-Clients zurück
|
||||||
|
|
||||||
|
**`src/mock/signalk.mock.js`:**
|
||||||
|
Simuliert einen SignalK WebSocket mit realistischen Bootsdaten.
|
||||||
|
Werte ändern sich kontinuierlich und realistisch:
|
||||||
|
- SOG: 4–7 Knoten, sanfte Schwankungen
|
||||||
|
- COG/Heading: 200–230°, leichte Drift
|
||||||
|
- Tiefe: 8–20m, langsame Änderung
|
||||||
|
- Windgeschwindigkeit: 10–18 Knoten, böig
|
||||||
|
- Windwinkel: 30–60° (am Wind), variabel
|
||||||
|
- Motorendrehzahl: 1600–2000 RPM
|
||||||
|
- Batterien: Starter 12.4–12.8V, Bord 24.8–25.4V
|
||||||
|
- Wassertemperatur: 17–19°C
|
||||||
|
- GPS: bewegt sich entlang einer realistischen Route (Ostsee-Koordinaten)
|
||||||
|
|
||||||
|
Gibt ein EventEmitter-ähnliches Objekt zurück, das `on('delta', callback)` unterstützt.
|
||||||
|
Format: echtes SignalK Delta-Format (`{"updates": [{"values": [...]}]}`)
|
||||||
|
|
||||||
|
**`src/mock/snapcast.mock.js`:**
|
||||||
|
Simuliert die Snapcast JSON-RPC API:
|
||||||
|
- 4 Zonen (Salon, Cockpit, Bug, Heck)
|
||||||
|
- Salon + Cockpit initial aktiv
|
||||||
|
- Implementiert: `Server.GetStatus`, `Client.SetVolume`, `Client.SetMuted`,
|
||||||
|
`Group.SetStream`, `Server.GetRPCVersion`
|
||||||
|
|
||||||
|
**`src/mock/mopidy.mock.js`:**
|
||||||
|
Simuliert die Mopidy WebSocket JSON-RPC API:
|
||||||
|
- Tracks mit realistischen Metadaten (Titel, Künstler, Album, Dauer)
|
||||||
|
- Wiedergabestatus: playing/paused/stopped
|
||||||
|
- Position läuft mit
|
||||||
|
- Implementiert: `playback.get_current_track`, `playback.get_state`,
|
||||||
|
`playback.get_time_position`, `playback.play`, `playback.pause`,
|
||||||
|
`playback.next`, `playback.previous`, `tracklist.get_tracks`,
|
||||||
|
`library.browse`, `library.search`
|
||||||
|
|
||||||
|
### 6. Dashboard – API-Clients
|
||||||
|
|
||||||
|
**`src/api/snapcast.js`:**
|
||||||
|
Echter Snapcast JSON-RPC Client über WebSocket (`ws://host:1705`).
|
||||||
|
Implementiert alle Methoden des Mock-Clients.
|
||||||
|
Reconnect-Logik mit exponential backoff.
|
||||||
|
|
||||||
|
**`src/api/signalk.js`:**
|
||||||
|
Echter SignalK WebSocket Client (`ws://host:3000/signalk/v1/stream`).
|
||||||
|
Abonniert alle relevanten Pfade:
|
||||||
|
- `navigation.speedOverGround`
|
||||||
|
- `navigation.courseOverGroundTrue`
|
||||||
|
- `navigation.headingTrue`
|
||||||
|
- `environment.depth.belowKeel`
|
||||||
|
- `environment.wind.speedApparent`
|
||||||
|
- `environment.wind.angleApparent`
|
||||||
|
- `propulsion.*.revolutions`
|
||||||
|
- `electrical.batteries.*.voltage`
|
||||||
|
|
||||||
|
**`src/api/mopidy.js`:**
|
||||||
|
Echter Mopidy JSON-RPC Client über WebSocket (`ws://host:6680/mopidy/ws`).
|
||||||
|
|
||||||
|
**`src/api/jellyfin.js`:**
|
||||||
|
Jellyfin REST API Client. Authentifizierung via API-Key aus `.env`.
|
||||||
|
Implementiert: Musik browsen, Alben, Künstler, Suche, Stream-URL.
|
||||||
|
|
||||||
|
### 7. Dashboard – Hooks
|
||||||
|
|
||||||
|
**`useNMEA()`:**
|
||||||
|
Verbindet sich mit SignalK (echt oder mock).
|
||||||
|
Gibt strukturiertes Objekt zurück:
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
sog, cog, heading, depth, windSpeed, windAngle, windDirection,
|
||||||
|
lat, lon, rpm, battery1, battery2, waterTemp, airTemp, rudder, fuel
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`useZones()`:**
|
||||||
|
Verbindet sich mit Snapcast.
|
||||||
|
Gibt zurück:
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
zones, // Array aller Zonen mit {id, name, active, volume, source, muted}
|
||||||
|
setVolume, // (zoneId, volume) => void
|
||||||
|
setMuted, // (zoneId, muted) => void
|
||||||
|
setSource, // (zoneId, streamId) => void
|
||||||
|
toggleZone, // (zoneId) => void
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`usePlayer()`:**
|
||||||
|
Verbindet sich mit Mopidy und Spotify-Status-API.
|
||||||
|
Gibt zurück:
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
currentTrack, // {title, artist, album, duration, coverUrl}
|
||||||
|
state, // 'playing' | 'paused' | 'stopped'
|
||||||
|
position, // Sekunden
|
||||||
|
activeSource, // 'spotify' | 'airplay' | 'mopidy' | 'jellyfin'
|
||||||
|
play, pause, next, previous, seek
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Dashboard – Design
|
||||||
|
|
||||||
|
**Farbschema:** Nautisch-dunkel. Hauptfarben:
|
||||||
|
- Background: `#07111f` (tiefes Marineblau)
|
||||||
|
- Surface: `#0a1928`
|
||||||
|
- Border: `#1e2a3a`
|
||||||
|
- Text primary: `#e2eaf2`
|
||||||
|
- Text muted: `#4a6080`
|
||||||
|
- Accent: `#38bdf8` (Cyan/Himmelblau)
|
||||||
|
- Success: `#34d399`
|
||||||
|
- Warning: `#f59e0b`
|
||||||
|
- Danger: `#ef4444`
|
||||||
|
- Spotify: `#1DB954`
|
||||||
|
- AirPlay: `#60a5fa`
|
||||||
|
|
||||||
|
**Schriften:** `DM Mono` für Instrumentenwerte, `DM Sans` für UI-Text.
|
||||||
|
|
||||||
|
**Instrumente (SVG-basiert, animiert):**
|
||||||
|
- Rundinstrumente mit Zeiger (Gauge.jsx): SOG, Tiefe, RPM, Wassertemp
|
||||||
|
- Kompassrose (animiert, dreht sich): Heading
|
||||||
|
- Windrose: Windrichtung + Geschwindigkeit
|
||||||
|
- Alle Instrumente reagieren flüssig auf Daten-Updates (CSS transitions)
|
||||||
|
|
||||||
|
**Touch-Optimierung:**
|
||||||
|
- Alle Buttons mindestens 44×44px
|
||||||
|
- Volume Slider touch-fähig (pointer events)
|
||||||
|
- Keine Hover-only Interaktionen
|
||||||
|
|
||||||
|
**Tabs:**
|
||||||
|
- Übersicht: Alle Instrumente + Now Playing + Zonen-Schnellübersicht
|
||||||
|
- Navigation: Seekarte (SignalK iframe / Placeholder) + Detaildaten
|
||||||
|
- Audio: Vollständige Zonen-Steuerung + Quelle + Radio + Bibliothek
|
||||||
|
- Systeme: Batterien, Motor, Kraftstoff, Container-Status
|
||||||
|
|
||||||
|
**DEV-Indikator:**
|
||||||
|
Wenn `import.meta.env.DEV === true`: kleines Badge "DEV · MOCK DATA" in der TopBar.
|
||||||
|
|
||||||
|
### 9. Snapserver Konfiguration
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[stream]
|
||||||
|
source = pipe:///tmp/audio/spotify.pcm?name=Spotify&codec=pcm&sampleformat=44100:16:2&chunk_ms=20
|
||||||
|
source = pipe:///tmp/audio/airplay.pcm?name=AirPlay&codec=pcm&sampleformat=44100:16:2&chunk_ms=20
|
||||||
|
source = pipe:///tmp/audio/mopidy.pcm?name=Mopidy&codec=pcm&sampleformat=44100:16:2&chunk_ms=20
|
||||||
|
|
||||||
|
[server]
|
||||||
|
threads = -1
|
||||||
|
|
||||||
|
[http]
|
||||||
|
enabled = true
|
||||||
|
bind_to_address = 0.0.0.0
|
||||||
|
port = 1780
|
||||||
|
|
||||||
|
[logging]
|
||||||
|
sink = system
|
||||||
|
filter = *:info
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10. Shairport-sync Konfiguration
|
||||||
|
|
||||||
|
```conf
|
||||||
|
general = {
|
||||||
|
name = "Bordanlage AirPlay";
|
||||||
|
port = 5000;
|
||||||
|
interpolation = "auto";
|
||||||
|
output_backend = "pipe";
|
||||||
|
};
|
||||||
|
pipe = {
|
||||||
|
name = "/tmp/audio/airplay.pcm";
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11. README.md
|
||||||
|
|
||||||
|
Erstelle eine vollständige README mit:
|
||||||
|
- Voraussetzungen (Docker Desktop, make)
|
||||||
|
- Schnellstart (`make dev` → http://localhost:8080)
|
||||||
|
- Alle Service-URLs
|
||||||
|
- Anleitung: Wie erscheint der Spotify Connect Endpunkt?
|
||||||
|
- Anleitung: Wie erscheint der AirPlay Endpunkt?
|
||||||
|
- Anleitung: Wie füge ich Musik hinzu? (./music Ordner)
|
||||||
|
- Anleitung: Wie verbinde ich echte NMEA-Hardware?
|
||||||
|
- Migration Boot: Was muss geändert werden? (network_mode, soundcards, dri)
|
||||||
|
- Troubleshooting (häufige Fehler auf Mac/Windows)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementierungsreihenfolge
|
||||||
|
|
||||||
|
1. Projektstruktur und alle Konfigurationsdateien anlegen
|
||||||
|
2. `docker-compose.yml` + `docker-compose.dev.yml` + `.env` + `Makefile`
|
||||||
|
3. Mopidy Custom-Dockerfile mit Plugins
|
||||||
|
4. Dashboard Grundgerüst (Vite + React Setup)
|
||||||
|
5. Mock-System vollständig implementieren
|
||||||
|
6. Echte API-Clients implementieren
|
||||||
|
7. Hooks implementieren (useNMEA, useZones, usePlayer)
|
||||||
|
8. UI-Komponenten (Instrumente, Audio, Navigation)
|
||||||
|
9. Pages (Overview, Navigation, Audio, Systems)
|
||||||
|
10. Dashboard Dockerfile + Nginx-Config
|
||||||
|
11. README.md
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wichtige Hinweise für die Implementierung
|
||||||
|
|
||||||
|
- **Spotify Connect** funktioniert über mDNS. Auf Mac/Windows muss Port 57621 (UDP)
|
||||||
|
gemappt sein und `--zeroconf-port 57621` an librespot übergeben werden.
|
||||||
|
Alternativ: Spotify-Nutzer kann den Endpunkt auch manuell über "Gerät verbinden"
|
||||||
|
mit der IP des Computers finden.
|
||||||
|
|
||||||
|
- **AirPlay** braucht Bonjour/mDNS. Auf Mac läuft Bonjour nativ, daher kann
|
||||||
|
shairport-sync mit `network_mode: host` auf Mac funktionieren wenn
|
||||||
|
`--host` Netzwerk erlaubt ist. Auf Windows WSL2 muss avahi im Container laufen.
|
||||||
|
|
||||||
|
- **Named Pipes** müssen VOR dem Start existieren. Das `init-pipes.sh` Skript
|
||||||
|
legt sie an. In Docker: via `entrypoint` sicherstellen dass die Pipes existieren
|
||||||
|
bevor der eigentliche Prozess startet.
|
||||||
|
|
||||||
|
- **Multiroom** bedeutet: alle aktiven Snapclients spielen synchron denselben Stream.
|
||||||
|
Jede Zone kann aber einen anderen Stream (Quelle) zugewiesen bekommen.
|
||||||
|
|
||||||
|
- **Fehlertoleranz**: Wenn eine API nicht erreichbar ist (z.B. SignalK offline),
|
||||||
|
soll das Dashboard nicht abstürzen – stattdessen graceful degradation mit
|
||||||
|
"Nicht verbunden" Anzeige.
|
||||||
|
|
||||||
|
Starte jetzt mit der Implementierung. Beginne mit der Verzeichnisstruktur und
|
||||||
|
arbeite dich dann systematisch durch alle Komponenten.
|
||||||
Reference in New Issue
Block a user