Creation nouvelle interface Docker Vehicule et Multimedia

This commit is contained in:
Christian CUNAT-BRULE
2026-09-12 08:58:05 +02:00
parent 97d3cca5cc
commit d170f95c92
16 changed files with 603 additions and 1 deletions
+12
View File
@@ -0,0 +1,12 @@
**
!package.json
!server/
!server/server.mjs
!server/telemetry.mjs
!server/Dockerfile
!ui/
!ui/index.html
!ui/styles.css
!ui/app.js
!ui/nginx.conf
!ui/Dockerfile
+3
View File
@@ -1,2 +1,5 @@
Thumbs.db
.DS_Store
node_modules/
.env
__pycache__/
+87 -1
View File
@@ -1 +1,87 @@
# CARIA-AUTOMOTIVE
# CARIA Automotive · Cockpit
Première version de l’interface intérieure : accueil multimédia et instrumentation automobile, indépendants du matériel. Le projet scolaire original reste dans `WebControl/` ; le cockpit utilise `ui/` et `server/`.
## Démarrage avec Docker
Prérequis : Docker Engine et Docker Compose, avec des conteneurs Linux.
```sh
docker compose up -d --build
```
Ouvrir **http://localhost:8080** sur la machine hôte, ou `http://ADRESSE_DU_SERVEUR:8080` depuis un écran connecté au même réseau.
```sh
docker compose ps
docker compose logs --tail=50
docker compose down
```
Le port est configurable avec `CARIA_PORT` dans un fichier `.env` à la racine (exemple : `CARIA_PORT=8090`). Deux services : Nginx sert l’interface et relaie `/api/` vers le simulateur Node.js. Seul le port de l’interface est publié. Aucun périphérique ni privilège matériel n’est demandé.
Les images de base ne forcent pas l’architecture. La construction sur l’ODroid utilisera les variantes disponibles pour son système. **Le modèle, l’OS et l’architecture de l’ODroid restent à confirmer avant de garantir la compatibilité**, notamment si son OS est en 32 bits. Le déploiement Docker n’a pas pu être exécuté sur le poste de développement, où Docker est absent.
## Démarrage sans Docker
Avec Node.js 22 ou supérieur, sans installation de paquet :
```sh
node server/server.mjs
```
Le serveur local sert également les trois fichiers de l’interface, sur le même port 8080. `HOST` et `PORT` permettent de modifier l’écoute. Docker reste la méthode prévue pour le déploiement final.
## Fonctions disponibles
- **Accueil** : heure/date de l’appareil, météo de démonstration, carburant/autonomie et accès à la musique.
- **Conduite** : vitesse et régime moteur à aiguilles SVG, rapport, carburant, température moteur, tension batterie, consommation et trajet simulés.
- **Musique** : sélection multiple de fichiers audio locaux, liste de lecture, lecture/pause, précédent/suivant, progression et volume via le lecteur natif. Les fichiers restent sur l’appareil qui affiche le navigateur ; ils ne sont pas transférés au serveur. La sélection doit être refaite après un rechargement. Une bibliothèque persistante sur disque/USB côté serveur pourra être ajoutée ensuite.
- **Streaming** : lien Jellyfin préconfiguré vers `https://media.cunatbrule.fr/web/`, modifiable dans les réglages. Ouverture dans un autre onglet ; aucune intégration de compte ni lecteur vidéo Jellyfin embarqué à ce stade.
- **Réglages** : ambiance jour/nuit, adresse du streaming, raccourcis pour ouvrir deux écrans et bouton plein écran. Les réglages sont conservés dans le stockage local du navigateur.
Le menu à gauche contient les quatre applications : accueil, conduite, musique et streaming. Les réglages sont en bas. La météo est fictive et explicitement marquée « DÉMO » ; l’heure est réelle. L’interface et la musique sélectionnée fonctionnent sans Internet tant que le serveur local est accessible. Jellyfin dépend de l’accès à son serveur.
## Un ou plusieurs écrans
- Instrumentation : `http://ADRESSE_DU_SERVEUR:8080/#drive`
- Multimédia : `http://ADRESSE_DU_SERVEUR:8080/#home`
Chaque écran ouvre sa propre page. Les valeurs proviennent du même serveur. Le profil route/ville/arrêt se synchronise entre les onglets d’un même navigateur et d’une même origine ; sur deux appareils, sélectionner le même profil sur chacun. Les fichiers audio et réglages restent propres à chaque navigateur.
Docker héberge l’application ; **l’affichage tactile, le son et le navigateur en mode kiosque restent gérés par l’OS de l’ODroid**. L’écran peut aussi utiliser un serveur distant. Aucun environnement graphique n’est embarqué dans les conteneurs.
## Architecture et suite CAN
```text
Navigateur ODroid / tablette / second écran
│ HTTP :8080
▼
Interface Nginx (ui/)
│ /api/telemetry
▼
Simulateur Node.js (server/)
Étape suivante : passerelle Raspberry Pi CAN → données normalisées → interface
```
L’interface utilise HTML/CSS/JavaScript sans framework, sans CDN ni police distante. Les compteurs SVG s’adaptent à la résolution, avec une actualisation toutes les 500 ms et une transition d’aiguille. Le serveur utilise uniquement les modules standard de Node.js. Aucun gain de performance sur ODroid n’est encore mesuré.
La passerelle CAN est un futur composant : pas de décodage Mégane III, de SocketCAN ni de commande véhicule dans cette version. Elle pourra être écrite indépendamment en Python, Rust, C++ ou JavaScript. Le contrat proposé est décrit dans [docs/architecture.md](docs/architecture.md). Le passage à des données réelles nécessitera également une adaptation de leur validation et de leurs indicateurs dans l’interface.
Navigation, météo réelle, alertes radar et bibliothèque audio sur le serveur sont des étapes ultérieures. Les composants techniques choisis sont open source ; le dépôt historique n’a pas de licence globale, à définir avant une distribution publique du projet complet en tenant compte des fichiers tiers.
## Validation
```sh
node --test server/telemetry.test.mjs
node --check ui/app.js
```
Tests exécutés : contrat HTTP, limites des données simulées, profil invalide, API en lecture seule et impossibilité d’accéder aux fichiers du projet historique via le nouveau serveur. Tests Chrome automatisés : quatre vues à 1280×800, 1024×600, 800×480 et 390×844, absence de débordement horizontal, réglages persistants, lien Jellyfin, lecture de deux WAV locaux, changement de piste et retour après coupure API. Les captures accueil et conduite ont été examinées. Défilement vertical possible sur petits écrans selon la vue.
À valider sur la cible : Docker/Compose, architecture ARM exacte, résolution et densité de l’écran, tactile, sortie audio, démarrage kiosque, consommation CPU/RAM et lecture Jellyfin réelle avec le compte utilisateur. Le lien Jellyfin a été vérifié dans l’interface, sans connexion au service ni test de son contenu.
Voir [l’audit du dossier historique](docs/audit-initial.md).
Références de déploiement : [services et healthchecks Docker Compose](https://docs.docker.com/reference/compose-file/services/), [image officielle Nginx](https://hub.docker.com/_/nginx).
+30
View File
@@ -0,0 +1,30 @@
name: caria
services:
interface:
build:
context: .
dockerfile: ui/Dockerfile
ports:
- "${CARIA_PORT:-8080}:80"
depends_on:
telemetry:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/"]
interval: 30s
timeout: 3s
retries: 3
telemetry:
build:
context: .
dockerfile: server/Dockerfile
restart: unless-stopped
read_only: true
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8080/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 15s
timeout: 3s
retries: 3
+47
View File
@@ -0,0 +1,47 @@
# Contrat et déploiement
## API de démonstration v1
`GET /api/health` renvoie `{"status":"ok","source":"demo"}`.
`GET /api/telemetry?profile=route` renvoie un instantané JSON. Profils : `route`, `ville`, `arret`. Un profil inconnu renvoie HTTP 400. Les écritures renvoient HTTP 405. L’API n’accepte aucune commande moteur.
| Champ | Type / unité | Usage |
| --- | --- | --- |
| schemaVersion | entier, 1 | Version du contrat |
| source | `demo` | Provenance explicite |
| timestamp | millisecondes Unix | Fraîcheur des données |
| profile | texte | Profil simulé |
| speedKph | nombre, km/h | Vitesse |
| rpm | nombre, tr/min | Régime moteur |
| gear | texte | Rapport, N à l’arrêt |
| fuelPercent | nombre, % | Niveau carburant |
| coolantCelsius | nombre, °C | Liquide de refroidissement |
| batteryVolts | nombre, V | Batterie |
| outsideCelsius | nombre, °C | Température extérieure |
| rangeKm | nombre, km | Autonomie |
| tripKm | nombre, km | Trajet fictif constant |
| consumptionL100 | nombre, L/100 km | Consommation fictive |
| weather | objet | Source, description, température fictives |
La vitesse et le régime évoluent ; les autres valeurs sont des exemples fixes, sauf la température moteur à l’arrêt. Il ne s’agit pas d’un modèle physique du véhicule. Le temps du serveur produit une animation cohérente entre clients utilisant le même profil.
Le client refuse les instantanés mal formés ou vieux de plus de 10 secondes, avec une limite de 3 secondes pour chaque requête. En cas de coupure il masque les aiguilles, remplace les mesures par des tirets et retente automatiquement. Les horloges du serveur et des écrans doivent être synchronisées ; un écart de plus de 10 secondes sera traité comme une donnée invalide.
## Passage à une source CAN
Prévoir un processus de lecture sur le Raspberry Pi, avec accès au périphérique CAN uniquement sur cette machine. La découverte des identifiants, facteurs, unités et fréquences des trames de la Mégane III reste à réaliser à partir des données du véhicule. Ne pas déduire les identifiants CAN de cette démonstration.
La passerelle produira le contrat normalisé, avec une source `can`, des valeurs manquantes explicites et un horodatage par signal si nécessaire. La version actuelle du client n’accepte volontairement que `demo` : adapter la validation, les limites par champ et les indicateurs source/fraîcheur lors de cette étape. Choisir ensuite le transport Raspberry → service (HTTP, MQTT ou WebSocket) selon le réseau et la fréquence nécessaires.
Le simulateur et la future passerelle doivent être sélectionnés explicitement ; ne jamais substituer silencieusement des valeurs fictives à une liaison CAN perdue.
## Évolutions indépendantes
- Bibliothèque audio persistante : volume en lecture seule, catalogue et prise en charge des requêtes Range ; la musique serait alors lue depuis le serveur plutôt que choisie dans l’appareil client.
- Météo réelle : fournisseur à définir, position configurable, cache et date de dernière mise à jour.
- Cartographie/radar : fournisseur open source, besoin hors ligne, zone géographique et données à définir avec l’utilisateur.
- Affichage : kiosque Linux et configuration des moniteurs au niveau de l’OS ; paramètres propres à chaque écran si nécessaire.
- Distribution : figer les images par version/digest une fois la première construction ARM validée ; ajouter une construction automatisée multiarchitecture au dépôt si son hébergement CI le permet.
Les tags Docker actuels suivent Node 22 Alpine et Nginx stable Alpine. Ils facilitent ce prototype mais ne rendent pas les builds strictement reproductibles.
+57
View File
@@ -0,0 +1,57 @@
# Audit initial — 12 septembre 2026
## Périmètre
Inventaire de tous les fichiers de travail de CARIA-AUTOMOTIVE, examen du serveur, des interfaces, des scripts de lancement, des modules et scénarios Python et des dépendances. `WebControl/` contient 53 fichiers, environ 2,33 Mo. Le projet comporte aussi `.gitignore`, un README initial minimal et les métadonnées Git. Les binaires, polices, bibliothèques JavaScript/CSS tierces et journaux ont été inventoriés ; ils ne font pas l’objet d’un audit exhaustif de leur contenu. Les certificats n’ont pas été reproduits dans le rapport.
L’application matérielle n’a pas été exécutée : elle initialise les GPIO et le port série et certains scripts commandent les moteurs. Aucune connexion au véhicule n’a été tentée.
## Organisation trouvée
| Dossier / fichier | Rôle |
| --- | --- |
| `WebControl/main.py` | Serveur Bottle, commandes robot, pages, résultats d’accès et commandes système |
| `WebControl/MainCLIControl.py` | Menu CLI de lancement des scénarios/tests |
| `WebControl/templates/` | Administration, accès, page de tests presque vide |
| `WebControl/js/` et `css/` | jQuery, Bootstrap et compteur de vitesse bitmap |
| `WebControl/images/` et `fonts/` | Fond du compteur, aiguille, police LED et Glyphicons |
| `WebControl/modules/AlphaBot.py` | GPIO, moteurs PWM, servo, lidar série, obstacles et journalisation |
| `WebControl/modules/*MFRC522*` | Lecteur RFID et accès SPI |
| `WebControl/apps/` | RFID, scénario de porte, itinéraires, lidar et évitement d’obstacles |
| `WebControl/tests/` | Essais physiques, pas une suite de tests unitaires |
| `WebControl/logs/` | Journaux d’exécution, résultats et trajets JSON |
| `WebControl/install/` | Liste figée de nombreux paquets du poste Raspberry Pi |
| `WebControl/fixs/`, `webcontrol-launch.sh`, `NOTE.txt` | Redémarrage, Motion, RFID, crontab et sudo |
| `WebControl/sendMyIP.py` | Exemple d’envoi d’adresse IP vers un endpoint fictif |
| `WebControl/ssl/` | Certificat et clé présents dans le dépôt historique |
## Constats qui déterminent la nouvelle architecture
1. **Couplage matériel au démarrage** : `main.py` instancie `AlphaBot`, qui ouvre `/dev/ttyAMA0` et initialise les GPIO. L’interface ne peut donc pas être simplement transférée sur un PC ou ODroid sans le matériel et ces bibliothèques.
2. **Chemins et adresses figés** : `/home/christian/WebControl`, adresses `192.168.253.194`, commandes `sudo`, dépendance à Motion. Le nouveau cockpit utilise des URL relatives et une configuration Docker.
3. **Vitesse non mesurée** : le curseur historique pilote un rapport cyclique PWM ; ce n’est pas une vitesse réelle en km/h. Le nouveau compteur est alimenté par des valeurs explicitement simulées. Son mapping d’aiguille couvre directement -130° à +130°.
4. **Distribution des fichiers trop large** : la route générique de Bottle sert depuis `./`, avec en plus des routes vers modules, scripts et templates. Avec le répertoire de lancement prévu, les sources et fichiers SSL peuvent être demandés via HTTP. La nouvelle image web ne copie que ses fichiers publics ; l’ancien dossier est exclu du contexte Docker.
5. **Commandes système et robot accessibles par HTTP sans authentification apparente** : reboot, Motion, scripts et moteurs sont mélangés à l’administration. Ces fonctions ne font pas partie du nouveau cockpit, qui fournit une API en lecture seule.
6. **Dépendances non portables** : le fichier d’installation ressemble à un export complet du système scolaire, incluant GPIO, bureau et paquets anciens. La nouvelle version n’en dépend pas.
7. **Licence globale absente** : certains modules tiers contiennent des notices propres (MFRC522 notamment). Il faudra clarifier les licences avant diffusion publique du dépôt complet.
## Anomalies historiques repérées (non corrigées dans ce lot)
- `js/script.js` associe tous les boutons à `/cmd`, y compris ceux sans lien avec la conduite, et lie plusieurs événements aux curseurs.
- `js/test_status.js` appelle `/test_status`, sans route correspondante dans le serveur lu.
- Le template utilise `testRFIDCarDoor`, alors que le serveur attend `appRFIDCarDoor`.
- `modules/joyit_mfrc522/__init__.py` importe `.SimpleMFRC522`, absent de ce sous-dossier ; un fichier homonyme est au niveau supérieur.
- `itineraire_suivre_emergency_speed.py` attend sur `Ab.vitesse_queue.get()` avant de démarrer le producteur. `execute_maneuver` y a deux paramètres mais plusieurs appels en passent trois.
- `AlphaBot.monitor_obstacle` utilise une variable globale du module, distincte de celle des scénarios ; sa boucle d’attente d’un obstacle ne consulte pas l’événement d’arrêt.
- `AlphaBot.monitor_vitesse` peut boucler sans pause lorsqu’aucune trame n’est disponible.
- Le scénario RFID attribue `scenario successful` puis compare le statut final à `movement successful`.
Ces éléments justifient de conserver le projet scolaire comme référence et de développer l’interface multimédia dans des dossiers dédiés. Ils ne constituent pas une certification du fonctionnement des autres chemins du robot.
## Références DEMO-Interfaces
Inventaire du launcher Python/Tkinter NUC (raccourcis Windows, applications, images, arrière-plans), de sa capture `Interface GUI 2025/interface.png` et des projets Tasker auto/moto (XML, APK et images). Le launcher repose sur des chemins Windows et des exécutables locaux. Inspiration retenue : applications clairement identifiables, grands boutons et thème sombre. La nouvelle interface réinterprète ces principes avec quatre accès latéraux et un accueil central ; les exécutables/APK n’ont pas été lancés et les assets tiers n’ont pas été copiés.
## Livraison de cette étape
`ui/` fournit les vues multimédia et conduite ; `server/` fournit les données de démonstration ; `compose.yaml` sépare les deux services. Le code historique reste inchangé. Voir le README pour les validations exécutées et les limites matérielles restantes.
+8
View File
@@ -0,0 +1,8 @@
{
"name": "caria-cockpit",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": { "start": "node server/server.mjs", "test": "node --test server/*.test.mjs" },
"engines": { "node": ">=22" }
}
+8
View File
@@ -0,0 +1,8 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
COPY server ./server
USER node
ENV PORT=8080
EXPOSE 8080
CMD ["node", "server/server.mjs"]
+38
View File
@@ -0,0 +1,38 @@
import http from 'node:http';
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
import { telemetry, profiles } from './telemetry.mjs';
const assets = new Map([
['/', ['index.html', 'text/html']], ['/index.html', ['index.html', 'text/html']],
['/app.js', ['app.js', 'text/javascript']], ['/styles.css', ['styles.css', 'text/css']]
]);
export function createServer() {
return http.createServer(async (req, res) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Cache-Control', 'no-store');
const send = (status, value) => {
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(value));
};
if (req.method !== 'GET' && req.method !== 'HEAD') return send(405, { error: 'Lecture seule' });
const url = new URL(req.url, 'http://localhost');
if (url.pathname === '/api/health') return send(200, { status: 'ok', source: 'demo' });
if (url.pathname === '/api/telemetry') {
const profile = url.searchParams.get('profile') || 'route';
return profiles.includes(profile) ? send(200, telemetry(profile)) : send(400, { error: 'Profil inconnu' });
}
const asset = assets.get(url.pathname);
if (!asset) return send(404, { error: 'Introuvable' });
try {
const content = await readFile(new URL(`../ui/${asset[0]}`, import.meta.url));
res.writeHead(200, { 'Content-Type': `${asset[1]}; charset=utf-8` });
res.end(req.method === 'HEAD' ? undefined : content);
} catch { send(500, { error: 'Interface indisponible' }); }
});
}
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
const port = Number(process.env.PORT || 8080);
createServer().listen(port, process.env.HOST || '0.0.0.0', () => console.log(`CARIA : http://localhost:${port}`));
}
+18
View File
@@ -0,0 +1,18 @@
export const profiles = ['route', 'ville', 'arret'];
// All displays use the same clock: no GPIO, CAN or vehicle commands here.
export function telemetry(profile = 'route', now = Date.now()) {
if (!profiles.includes(profile)) throw new RangeError('Profil inconnu');
const t = now / 1000;
const speed = profile === 'arret' ? 0 : Math.round(profile === 'ville'
? 23 + 23 * Math.sin(t / 18) : 82 + 12 * Math.sin(t / 15));
const gear = speed === 0 ? 'N' : String(Math.min(6, Math.max(1, Math.ceil(speed / 20))));
return {
schemaVersion: 1, source: 'demo', timestamp: now, profile,
speedKph: speed, rpm: speed === 0 ? 850 : Math.round(1400 + speed * 13 + 100 * Math.sin(t / 3)),
fuelPercent: 68, coolantCelsius: profile === 'arret' ? 76 : 89,
batteryVolts: 14.2, outsideCelsius: 21, rangeKm: 540,
tripKm: 42.6, consumptionL100: 5.8, gear,
weather: { source: 'demo', label: 'Éclaircies', celsius: 21 }
};
}
+32
View File
@@ -0,0 +1,32 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { telemetry, profiles } from './telemetry.mjs';
import { createServer } from './server.mjs';
test('demo values remain bounded and identical across displays', () => {
for (const profile of profiles) for (let t = 0; t < 300000; t += 250) {
const d = telemetry(profile, t);
assert.ok(d.speedKph >= 0 && d.speedKph <= 220);
assert.ok(d.rpm >= 0 && d.rpm <= 7000);
assert.equal(d.source, 'demo');
assert.deepEqual(d, telemetry(profile, t));
if (profile === 'arret') assert.equal(d.speedKph, 0);
}
assert.throws(() => telemetry('can'), RangeError);
});
test('HTTP contract, invalid profiles, isolation from legacy and read-only API', async () => {
const server = createServer();
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const base = `http://127.0.0.1:${server.address().port}`;
try {
assert.equal((await (await fetch(`${base}/api/health`)).json()).status, 'ok');
assert.equal((await (await fetch(`${base}/api/telemetry?profile=arret`)).json()).speedKph, 0);
assert.equal((await fetch(`${base}/api/telemetry?profile=wrong`)).status, 400);
assert.equal((await fetch(`${base}/api/telemetry`, { method: 'POST' })).status, 405);
for (const path of ['/WebControl/main.py', '/ssl/key.pem', '/package.json', '/cmd']) {
assert.equal((await fetch(base + path)).status, 404);
}
assert.match(await (await fetch(base)).text(), /CARIA/);
} finally { await new Promise(resolve => server.close(resolve)); }
});
+4
View File
@@ -0,0 +1,4 @@
FROM nginx:stable-alpine
COPY ui/nginx.conf /etc/nginx/conf.d/default.conf
COPY ui/index.html ui/styles.css ui/app.js /usr/share/nginx/html/
EXPOSE 80
+181
View File
@@ -0,0 +1,181 @@
const $ = selector => document.querySelector(selector);
const all = selector => document.querySelectorAll(selector);
const storage = {
get(key, fallback = '') { try { return localStorage.getItem(`caria.${key}`) ?? fallback; } catch { return fallback; } },
set(key, value) { try { localStorage.setItem(`caria.${key}`, value); return true; } catch { return false; } }
};
function toast(message) { $('#toast').textContent = message; $('#toast').hidden = false; clearTimeout(toast.timer); toast.timer = setTimeout(() => $('#toast').hidden = true, 4000); }
function navigate() {
const view = ['home', 'drive', 'music', 'stream'].includes(location.hash.slice(1)) ? location.hash.slice(1) : 'home';
all('.view').forEach(section => section.hidden = section.id !== view);
all('[data-view]').forEach(link => {
const active = link.dataset.view === view;
link.classList.toggle('active', active);
if (active) link.setAttribute('aria-current', 'page'); else link.removeAttribute('aria-current');
});
document.title = `CARIA · ${$(`[data-view="${view}"]`).textContent.trim()}`;
}
window.addEventListener('hashchange', navigate);
navigate();
function clock() {
const date = new Date();
const time = date.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
['#hero-clock', '#header-clock'].forEach(id => { $(id).textContent = time; $(id).dateTime = date.toISOString(); });
$('#date').textContent = date.toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
}
clock(); setInterval(clock, 1000);
function makeGauge(id, max, step, unit, label) {
const point = (angle, radius) => {
const r = angle * Math.PI / 180;
return [150 + radius * Math.sin(r), 150 - radius * Math.cos(r)];
};
let ticks = '';
for (let v = 0; v <= max; v += step / 4) {
const angle = -130 + v / max * 260;
const major = v % step === 0;
const a = point(angle, major ? 112 : 119), b = point(angle, 126), n = point(angle, 97);
ticks += `<line class="tick ${major ? 'major' : ''}" x1="${a[0]}" y1="${a[1]}" x2="${b[0]}" y2="${b[1]}"/>`;
if (major) ticks += `<text x="${n[0]}" y="${n[1] + 4}">${max === 7000 ? v / 1000 : v}</text>`;
}
$(id).innerHTML = `<svg viewBox="0 0 300 285" role="img" aria-label="${label} : en attente"><circle cx="150" cy="150" r="133" fill="none" stroke="var(--border)"/>${ticks}<text x="150" y="110" class="dial-label">${unit}</text><line class="needle" x1="150" y1="150" x2="150" y2="43" style="transform-origin:150px 150px;transform:rotate(-130deg)"/><circle cx="150" cy="150" r="7" fill="var(--accent)"/><circle cx="150" cy="150" r="3" fill="var(--surface)"/><text x="150" y="212" class="dial-value">—</text><text x="150" y="239" class="dial-label">${label}</text></svg>`;
return value => {
const valid = typeof value === 'number' && Number.isFinite(value);
$(`${id} .dial-value`).textContent = valid ? Math.round(value).toLocaleString('fr-FR') : '—';
$(`${id} .needle`).style.opacity = valid ? '1' : '0';
if (valid) $(`${id} .needle`).style.transform = `rotate(${-130 + Math.max(0, Math.min(max, value)) / max * 260}deg)`;
$(`${id} svg`).setAttribute('aria-label', `${label} : ${valid ? Math.round(value) : 'indisponible'} ${max === 7000 ? 'tr/min' : 'km/h'}`);
};
}
const speedGauge = makeGauge('#speed-gauge', 220, 20, 'KM/H', 'VITESSE');
const rpmGauge = makeGauge('#rpm-gauge', 7000, 1000, '× 1 000 TR/MIN', 'RÉGIME MOTEUR');
const savedProfile = storage.get('profile', 'route');
$('#profile').value = ['route', 'ville', 'arret'].includes(savedProfile) ? savedProfile : 'route';
let generation = 0, timer;
function offline() {
$('#connection').textContent = 'Données indisponibles';
$('#connection').classList.add('error');
$('#footer-status').textContent = 'Connexion perdue · tentative de reconnexion';
document.body.classList.add('stale');
all('[data-value]').forEach(el => el.textContent = '—');
all('[data-fuel]').forEach(el => el.style.width = '0%');
speedGauge(null); rpmGauge(null);
}
async function poll() {
const current = ++generation;
clearTimeout(timer);
try {
const response = await fetch(`/api/telemetry?profile=${encodeURIComponent($('#profile').value)}`, { signal: AbortSignal.timeout(3000), cache: 'no-store' });
if (!response.ok) throw new Error('API');
const data = await response.json();
if (current !== generation) return;
const numeric = ['speedKph', 'rpm', 'fuelPercent', 'coolantCelsius', 'batteryVolts', 'outsideCelsius', 'rangeKm', 'tripKm', 'consumptionL100'];
if (data.schemaVersion !== 1 || data.source !== 'demo' || !Number.isFinite(data.timestamp) || Math.abs(Date.now() - data.timestamp) > 10000 || numeric.some(key => !Number.isFinite(data[key]))) throw new Error('Données invalides');
$('#connection').textContent = '● Démonstration';
$('#connection').classList.remove('error');
document.body.classList.remove('stale');
$('#footer-status').textContent = 'Mode démonstration · CAN non connecté';
all('[data-value]').forEach(el => {
const value = data[el.dataset.value];
el.textContent = typeof value === 'number' ? value.toLocaleString('fr-FR', { maximumFractionDigits: 1 }) : value;
});
all('[data-fuel]').forEach(el => el.style.width = `${Math.max(0, Math.min(100, data.fuelPercent))}%`);
speedGauge(data.speedKph); rpmGauge(data.rpm);
} catch { if (current === generation) offline(); }
finally { if (current === generation) timer = setTimeout(poll, 500); }
}
$('#profile').addEventListener('change', () => { storage.set('profile', $('#profile').value); poll(); });
window.addEventListener('storage', event => {
if (event.key === 'caria.profile' && ['route', 'ville', 'arret'].includes(event.newValue)) { $('#profile').value = event.newValue; poll(); }
if (event.key === 'caria.stream') configureStream();
if (event.key === 'caria.theme') document.body.classList.toggle('light', event.newValue === 'light');
});
poll();
function validUrl(value) {
try { const url = new URL(value); return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password ? url.href : ''; } catch { return ''; }
}
const defaultStream = 'https://media.cunatbrule.fr/web/';
function configureStream() {
const url = validUrl(storage.get('stream', defaultStream));
$('#stream-link').hidden = !url;
if (url) $('#stream-link').href = url; else $('#stream-link').removeAttribute('href');
$('#stream-description').textContent = url ? new URL(url).hostname : 'Ajoutez l’adresse de votre site dans les réglages.';
$('#stream-configure').textContent = url ? 'Modifier le site' : 'Configurer mon site';
}
configureStream();
document.body.classList.toggle('light', storage.get('theme') === 'light');
function openSettings() {
$('#stream-url').value = storage.get('stream', defaultStream);
$('#theme').value = document.body.classList.contains('light') ? 'light' : 'dark';
$('#settings-message').textContent = '';
$('#settings').showModal();
}
$('#settings-open').onclick = openSettings;
$('#stream-configure').onclick = openSettings;
$('#settings-close').onclick = () => $('#settings').close();
$('#settings-form').addEventListener('submit', event => {
event.preventDefault();
const raw = $('#stream-url').value.trim(), url = validUrl(raw);
if (raw && !url) { $('#settings-message').textContent = 'Saisissez une adresse HTTP ou HTTPS, sans identifiant ni mot de passe.'; return; }
const streamSaved = storage.set('stream', url), themeSaved = storage.set('theme', $('#theme').value);
if (!streamSaved || !themeSaved) { $('#settings-message').textContent = 'Le navigateur bloque l’enregistrement local des réglages.'; return; }
document.body.classList.toggle('light', $('#theme').value === 'light');
configureStream(); $('#settings').close(); toast('Réglages enregistrés');
});
$('#fullscreen').onclick = async () => {
try { if (document.fullscreenElement) await document.exitFullscreen(); else await document.documentElement.requestFullscreen(); }
catch { toast('Le plein écran n’est pas disponible dans ce navigateur.'); }
};
const audio = $('#audio');
let tracks = [], selected = -1;
function renderPlaylist() {
$('#playlist').replaceChildren();
tracks.forEach((track, index) => {
const li = document.createElement('li'), button = document.createElement('button');
const number = document.createElement('span'), name = document.createElement('b');
number.textContent = String(index + 1).padStart(2, '0'); name.textContent = track.name;
button.append(number, name); button.classList.toggle('selected', index === selected);
button.setAttribute('aria-pressed', String(index === selected)); button.onclick = () => selectTrack(index, true);
li.append(button); $('#playlist').append(li);
});
$('#track-count').textContent = `${tracks.length} TITRE${tracks.length > 1 ? 'S' : ''}`;
$('#empty-library').hidden = tracks.length > 0;
['#play', '#previous', '#next'].forEach(id => $(id).disabled = tracks.length === 0);
}
async function playAudio() {
try { await audio.play(); $('#audio-message').textContent = 'Lecture locale · aucun fichier envoyé au serveur.'; }
catch { $('#audio-message').textContent = 'Lecture impossible : réessayez ou choisissez un format audio pris en charge.'; }
}
function selectTrack(index, play = false) {
if (!tracks.length) return;
selected = (index + tracks.length) % tracks.length;
audio.src = tracks[selected].url;
$('#track-title').textContent = tracks[selected].name;
$('#home-track').textContent = tracks[selected].name;
$('#track-subtitle').textContent = `Morceau ${selected + 1} sur ${tracks.length} · Bibliothèque locale`;
$('#home-track-sub').textContent = 'Bibliothèque locale';
renderPlaylist(); if (play) playAudio();
}
$('#music-files').addEventListener('change', event => {
let added = 0;
for (const file of event.target.files) {
if (!file.type.startsWith('audio/') && !/\.(mp3|flac|ogg|wav|m4a|aac|opus)$/i.test(file.name)) continue;
tracks.push({ name: file.name.replace(/\.[^.]+$/, ''), url: URL.createObjectURL(file) }); added++;
}
renderPlaylist();
if (selected === -1 && tracks.length) selectTrack(0);
toast(added ? `${added} morceau${added > 1 ? 'x' : ''} ajouté${added > 1 ? 's' : ''}` : 'Aucun fichier audio reconnu');
event.target.value = '';
});
$('#play').onclick = () => { if (selected === -1) return; if (audio.paused) playAudio(); else audio.pause(); };
$('#previous').onclick = () => selectTrack(selected - 1, true);
$('#next').onclick = () => selectTrack(selected + 1, true);
audio.addEventListener('play', () => $('#play').textContent = 'Pause');
audio.addEventListener('pause', () => $('#play').textContent = 'Lecture');
audio.addEventListener('ended', () => { if (selected + 1 < tracks.length) selectTrack(selected + 1, true); else $('#play').textContent = 'Lecture'; });
audio.addEventListener('error', () => $('#audio-message').textContent = 'Ce fichier ne peut pas être lu. Essayez un autre format audio.');
window.addEventListener('pagehide', event => { if (!event.persisted) tracks.forEach(track => URL.revokeObjectURL(track.url)); });
renderPlaylist();
+51
View File
@@ -0,0 +1,51 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="theme-color" content="#101313"><title>CARIA · Cockpit</title>
<link rel="stylesheet" href="/styles.css"><script type="module" src="/app.js"></script>
</head>
<body>
<div class="shell">
<aside class="rail">
<a href="#home" class="brand" aria-label="CARIA accueil">C<span> /</span></a>
<nav aria-label="Applications">
<a href="#home" data-view="home"><span class="icon">⌂</span><span>Accueil</span></a>
<a href="#drive" data-view="drive"><span class="icon">◉</span><span>Conduite</span></a>
<a href="#music" data-view="music"><span class="icon">♫</span><span>Musique</span></a>
<a href="#stream" data-view="stream"><span class="icon">▷</span><span>Streaming</span></a>
</nav>
<button id="settings-open" class="settings-button"><span class="icon">⚙</span><span>Réglages</span></button>
</aside>
<div class="workspace">
<header><div class="wordmark">CARIA<span>AUTOMOTIVE</span></div><div class="header-right"><span id="connection" class="badge">Connexion…</span><span class="header-temp">21° <small>EXT. · DÉMO</small></span><time id="header-clock">--:--</time><button id="fullscreen" aria-label="Plein écran" title="Plein écran">⛶</button></div></header>
<main>
<section id="home" class="view">
<div class="page-heading"><div><p class="eyebrow">VOTRE ESPACE À BORD</p><h1>Le plaisir du trajet.</h1></div><span class="subtle">Cockpit personnel <span class="accent">/ 01</span></span></div>
<div class="home-grid">
<article class="clock-card card"><div class="card-top"><span class="eyebrow">BONJOUR, CONDUCTEUR</span><span class="tiny-dot"></span></div><time id="hero-clock">--:--</time><p id="date">—</p><div class="horizon" aria-hidden="true"><div class="sun"></div><div class="mountain one"></div><div class="mountain two"></div><div class="road"></div></div><div class="clock-bottom"><span>Un nouvel horizon vous attend.</span><span>CARIA / ON BOARD</span></div></article>
<article class="weather-card card"><div class="card-top"><span class="eyebrow">À L’EXTÉRIEUR</span><span class="label">DÉMO</span></div><div class="weather-main"><span class="weather-sun">☀</span><strong>21<span>°</span></strong></div><h2>Quelques éclaircies</h2><p>Une belle journée pour prendre la route.</p><div class="weather-bottom"><span>Min. <b>16°</b></span><span>Max. <b>24°</b></span><span>Vent <b>8 km/h</b></span></div></article>
<article class="vehicle-card card"><div class="card-top"><span class="eyebrow">VOTRE VÉHICULE</span><span class="label">DÉMO</span></div><h2>Mégane III</h2><div class="vehicle-stats"><div><strong data-value="rangeKm">—</strong><span>km d’autonomie</span></div><div><strong><span data-value="fuelPercent">—</span><small> %</small></strong><span>carburant</span></div></div><div class="fuel-track"><span data-fuel></span></div><a class="text-link" href="#drive">Ouvrir le tableau de bord <span>↗</span></a></article>
<article class="music-card card"><div class="album-art" aria-hidden="true"><div class="record"></div><span>C / SOUND</span></div><div><p class="eyebrow">LA BANDE-SON DU VOYAGE</p><h2 id="home-track">Votre musique, partout.</h2><p id="home-track-sub">Emportez votre bibliothèque locale.</p><a class="pill" href="#music">♫ &nbsp; Ouvrir ma musique <span>↗</span></a></div></article>
</div>
</section>
<section id="drive" class="view" hidden>
<div class="page-heading"><div><p class="eyebrow">LES ESSENTIELS EN UN REGARD</p><h1>Tableau de bord</h1></div><label class="profile-label">Simulation <select id="profile"><option value="route">Route</option><option value="ville">Ville</option><option value="arret">À l’arrêt</option></select></label></div>
<div class="cluster card"><div class="cluster-top"><span>MÉGANE III <span class="subtle">/ INSTRUMENTATION</span></span><span class="label">DONNÉES DE DÉMONSTRATION</span></div><div class="gauges"><div class="gauge" id="speed-gauge"></div><div class="gear"><p class="eyebrow">RAPPORT</p><strong data-value="gear">—</strong><span>DEMO</span></div><div class="gauge" id="rpm-gauge"></div></div><div class="cluster-bottom"><span>Trajet <b><span data-value="tripKm">—</span> km</b></span><span>Consommation <b><span data-value="consumptionL100">—</span> L/100</b></span></div></div>
<div class="instrument-grid"><article class="card"><p class="eyebrow">CARBURANT</p><h2><span data-value="fuelPercent">—</span><small> %</small></h2><div class="fuel-track"><span data-fuel></span></div><p><span data-value="rangeKm">—</span> km d’autonomie</p></article><article class="card"><p class="eyebrow">TEMPÉRATURE MOTEUR</p><h2><span data-value="coolantCelsius">—</span><small> °C</small></h2><p>Liquide de refroidissement</p></article><article class="card"><p class="eyebrow">BATTERIE</p><h2><span data-value="batteryVolts">—</span><small> V</small></h2><p>Tension simulée</p></article><article class="card"><p class="eyebrow">TEMPÉRATURE EXTÉRIEURE</p><h2><span data-value="outsideCelsius">—</span><small> °C</small></h2><p>Mesure de démonstration</p></article></div>
</section>
<section id="music" class="view" hidden>
<div class="page-heading"><div><p class="eyebrow">VOTRE BIBLIOTHÈQUE EMBARQUÉE</p><h1>Musique locale</h1></div><label class="pill file-picker">+ Ajouter des morceaux<input id="music-files" type="file" accept="audio/*,.mp3,.flac,.ogg,.wav,.m4a" multiple></label></div>
<div class="player-grid"><article class="card player"><div class="album-art large" aria-hidden="true"><div class="record"></div><span>C / SOUND</span></div><p class="eyebrow">EN ÉCOUTE</p><h2 id="track-title">À vous de donner le tempo.</h2><p id="track-subtitle">Choisissez des fichiers sur cet appareil.</p><audio id="audio" controls preload="metadata"></audio><div class="player-actions"><button id="previous" aria-label="Morceau précédent">❮</button><button id="play" class="pill">Lecture</button><button id="next" aria-label="Morceau suivant">❯</button></div><p id="audio-message" role="status">MP3, WAV, OGG… selon les formats pris en charge par votre navigateur.</p></article><article class="card library"><div class="card-top"><h2>Vos morceaux</h2><span id="track-count" class="label">0 TITRE</span></div><ol id="playlist"></ol><div id="empty-library"><span>♫</span><h3>La route a sa bande-son.</h3><p>Ajoutez vos morceaux avec le bouton ci-dessus.<br>Ils restent sur cet appareil, sans envoi au serveur.</p></div></article></div>
</section>
<section id="stream" class="view" hidden>
<div class="page-heading"><div><p class="eyebrow">VOTRE APPLICATION WEB</p><h1>Streaming</h1></div></div><article class="card stream-card"><span class="stream-symbol">▷</span><p class="eyebrow">VOTRE CINÉMA À BORD</p><h2>Un accès direct à votre univers.</h2><p id="stream-description">Ajoutez l’adresse de votre site dans les réglages.</p><a id="stream-link" class="pill" target="_blank" rel="noopener noreferrer" hidden>Ouvrir mon streaming ↗</a><button id="stream-configure" class="pill">Configurer mon site</button><p class="subtle">S’ouvre dans un nouvel onglet. Votre cockpit reste accessible.</p></article>
</section>
</main>
<footer><span><i class="tiny-dot"></i> COCKPIT <span class="subtle">/ VERSION 0.1</span></span><span id="footer-status">Mode démonstration · CAN non connecté</span><span>FAIT POUR LE VOYAGE <span class="accent">↗</span></span></footer>
</div>
</div>
<dialog id="settings"><form id="settings-form"><div class="card-top"><h2>Votre cockpit</h2><button type="button" id="settings-close" aria-label="Fermer les réglages">✕</button></div><label>Adresse de votre streaming<input id="stream-url" type="url" placeholder="https://votre-site.fr"></label><p class="subtle">Enregistrée dans ce navigateur. Laissez vide pour retirer le lien.</p><label>Ambiance<select id="theme"><option value="dark">Nuit</option><option value="light">Jour</option></select></label><p>Plusieurs écrans : ouvrez la conduite sur l’un et l’accueil sur l’autre.</p><div class="screen-links"><a href="/#drive" target="_blank" rel="noopener">Écran conduite ↗</a><a href="/#home" target="_blank" rel="noopener">Écran multimédia ↗</a></div><p class="subtle">Météo et véhicule : démonstration. Navigation et radar seront ajoutés dans une prochaine étape.</p><button class="pill" type="submit">Enregistrer</button><p id="settings-message" role="status"></p></form></dialog>
<div id="toast" role="status" hidden></div>
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
add_header X-Content-Type-Options nosniff;
location /api/ {
resolver 127.0.0.11 valid=10s ipv6=off;
set $backend http://telemetry:8080;
proxy_pass $backend;
proxy_connect_timeout 2s;
proxy_read_timeout 5s;
}
location / { try_files $uri $uri/ =404; }
}
+12
View File
File diff suppressed because one or more lines are too long