Sauvegarde Home Assistant : configuree, poussee sur deux autres machines, verifiee
Aucune sauvegarde HA n avait jamais ete faite, et git ne la couvrait pas -- un seul fichier HA y etait versionne. HA declarait pourtant trois destinations Synology, dont 10.10.10.22 et une troisieme machine en 10.10.10.50 absente de toute la documentation du projet. Quotidienne 04:45, sept copies, base d historique exclue (20 Mo au lieu de 4,4 Go), poussee sur les trois machines. La configuration passe par le websocket car l API REST ne l expose pas. Verifiee archive en main : .storage, packages, dashboards, secrets et custom_components presents, base d historique absente comme voulu. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
This commit is contained in:
62
HANDOFF.md
62
HANDOFF.md
@@ -2259,3 +2259,65 @@ Le contacteur EDF reste incommandable si internet tombe (Tuya cloud). Un
|
|||||||
contacteur local — ou un `localtuya` — est le prochain vrai gain de résilience.
|
contacteur local — ou un `localtuya` — est le prochain vrai gain de résilience.
|
||||||
La sauvegarde (6 Mo d'identité vers Gitea, 6,1 Go d'historique en hebdomadaire
|
La sauvegarde (6 Mo d'identité vers Gitea, 6,1 Go d'historique en hebdomadaire
|
||||||
avec `sqlite3 .backup`) et le secours froid n8n restent à faire.
|
avec `sqlite3 .backup`) et le secours froid n8n restent à faire.
|
||||||
|
|
||||||
|
## 34. Session du 01/09/2026 (nuit) — Sauvegarde Home Assistant : elle n'existait pas, elle existe
|
||||||
|
|
||||||
|
### Le point de départ
|
||||||
|
Aucune sauvegarde HA n'avait jamais été faite (§32). Et **non, git ne la
|
||||||
|
couvrait pas** : le dépôt contient le code du contrôleur, pas Home Assistant.
|
||||||
|
Un seul fichier HA y était versionné (`dashboards/bilan_energetique.yaml`).
|
||||||
|
Les helpers, les entités, les paquets, les cartes, les intégrations n'avaient
|
||||||
|
aucune copie nulle part.
|
||||||
|
|
||||||
|
### La trouvaille qui a simplifié le problème
|
||||||
|
`backup/agents/info` déclare **quatre destinations**, et le croisement avec les
|
||||||
|
entrées `synology_dsm` de `.storage/core.config_entries` donne leur hôte réel —
|
||||||
|
le nom seul ne dit pas où chacune écrit :
|
||||||
|
|
||||||
|
| agent | nom | hôte |
|
||||||
|
|---|---|---|
|
||||||
|
| `backup.local` | local | 10.10.10.17 |
|
||||||
|
| `synology_dsm.1660NZN396007` | ARCHIVE2 | **10.10.10.22** |
|
||||||
|
| `synology_dsm.14B0MPN914514` | ARCHIVE | **10.10.10.50** |
|
||||||
|
| `synology_dsm.20A0SBRVM9PF2` | ARCHIVE3 | 10.10.10.17 ⚠️ à ne PAS utiliser |
|
||||||
|
|
||||||
|
Home Assistant sait donc **pousser lui-même ses sauvegardes vers deux autres
|
||||||
|
machines**. Aucun script de recopie, aucune tâche planifiée, aucun montage NFS
|
||||||
|
supplémentaire : il n'y avait qu'à le configurer. (Troisième machine 10.10.10.50
|
||||||
|
découverte au passage — elle n'apparaissait dans aucun document du projet.)
|
||||||
|
|
||||||
|
### Configuration appliquée (`tools/ha-backup-config.mjs`, rejouable)
|
||||||
|
La configuration de sauvegarde **n'est pas exposée par l'API REST** : elle vit
|
||||||
|
dans `.storage/backup` et ne se règle que par l'interface ou le **websocket**.
|
||||||
|
Le script passe par le websocket, ce qui la rend reproductible et relisible.
|
||||||
|
|
||||||
|
- **Destinations** : local + 10.10.10.22 + 10.10.10.50. ARCHIVE3 (10.10.10.17)
|
||||||
|
volontairement exclu : une sauvegarde qui ne survit pas à la panne qu'elle
|
||||||
|
couvre ne protège rien.
|
||||||
|
- **Base de données EXCLUE.** Ce qu'il faut restaurer vite, c'est l'identité du
|
||||||
|
système — 20 Mo compressés. L'historique pèse 4,4 Go, change en permanence et
|
||||||
|
n'a aucune valeur d'urgence : il se sauvegarde à part, plus rarement, et avec
|
||||||
|
les précautions SQLite (mode WAL, jamais un `cp` à chaud).
|
||||||
|
- **Quotidienne à 04:45**, rétention **7 copies**.
|
||||||
|
- **Sans mot de passe**, choix assumé : ces archives restent sur le réseau local
|
||||||
|
du propriétaire, et une sauvegarde restaurable vaut mieux qu'une sauvegarde
|
||||||
|
chiffrée dont la clé est perdue le jour où elle sert.
|
||||||
|
|
||||||
|
### Vérifiée, pas seulement déclenchée
|
||||||
|
Première archive : `Automatic backup 2026.8.1` (a9d35ac9), 01/09 03:11,
|
||||||
|
**20,3 Mo, présente sur les trois machines**. Contenu contrôlé archive en main :
|
||||||
|
`.storage` 39 fichiers · `packages` 18 · `dashboards` 30 ·
|
||||||
|
`custom_components` 2 283 · `configuration.yaml`, `secrets.yaml`,
|
||||||
|
`automations.yaml`, `scripts.yaml` · et **zéro occurrence** de
|
||||||
|
`home-assistant_v2.db`, comme voulu.
|
||||||
|
`tools/ha-backup-list.mjs` affiche pour chaque sauvegarde les hôtes où elle est
|
||||||
|
réellement présente — c'est cette colonne-là qui compte.
|
||||||
|
|
||||||
|
### Ce que cette sauvegarde ne couvre PAS
|
||||||
|
1. **n8n.** Ni ses workflows (déjà en git, donc couverts autrement), ni sa base
|
||||||
|
`database.sqlite` (1,69 Go), ni surtout **la clé de chiffrement de 56 octets**
|
||||||
|
(§32) sans laquelle aucun credential n'est récupérable. C'est le prochain
|
||||||
|
trou à boucher, et le plus petit.
|
||||||
|
2. **L'historique HA** (4,4 Go) : perdu en cas de restauration. Conséquence
|
||||||
|
réelle pour le chantier du bilan énergétique, qui s'appuie sur les
|
||||||
|
statistiques long terme. À traiter séparément, en hebdomadaire.
|
||||||
|
|||||||
134
tools/ha-backup-config.mjs
Normal file
134
tools/ha-backup-config.mjs
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Configure la sauvegarde automatique de Home Assistant (installation Core /
|
||||||
|
* Container, HA 2026.8.1 — pas de Supervisor ici).
|
||||||
|
*
|
||||||
|
* POURQUOI CE SCRIPT PLUTÔT QUE L'INTERFACE
|
||||||
|
* La configuration de sauvegarde n'est PAS exposée par l'API REST : elle vit
|
||||||
|
* dans `.storage/backup` et ne se règle que par l'interface ou par le
|
||||||
|
* websocket. Passer par le websocket la rend reproductible, versionnée, et
|
||||||
|
* relisible dans six mois — ce qu'un clic dans l'interface n'est pas.
|
||||||
|
*
|
||||||
|
* CE QUI EST RÉGLÉ, ET POURQUOI
|
||||||
|
* - Destinations : les agents Synology **ARCHIVE2 (10.10.10.22)** et
|
||||||
|
* **ARCHIVE (10.10.10.50)**, plus une copie locale. Le NAS principal
|
||||||
|
* (ARCHIVE3, 10.10.10.17) est délibérément EXCLU comme destination : une
|
||||||
|
* sauvegarde qui ne survit pas à la panne qu'elle couvre ne sert à rien.
|
||||||
|
* - Base de données EXCLUE. Ce qu'il faut pouvoir restaurer vite, c'est
|
||||||
|
* l'identité du système (entités, helpers, cartes, paquets, intégrations) :
|
||||||
|
* environ 60 Mo. L'historique, lui, pèse 4,4 Go, change en permanence, et
|
||||||
|
* n'a aucune valeur d'urgence — il se sauvegarde séparément, plus rarement,
|
||||||
|
* et avec les précautions SQLite (mode WAL : jamais un `cp` à chaud).
|
||||||
|
* - Rétention 7 copies : une semaine pour s'apercevoir d'une bêtise.
|
||||||
|
* - Pas de mot de passe. Choix assumé : ces archives restent sur le réseau
|
||||||
|
* local du propriétaire, et une sauvegarde restaurable vaut mieux qu'une
|
||||||
|
* sauvegarde chiffrée dont la clé est perdue le jour où elle sert.
|
||||||
|
*
|
||||||
|
* Usage :
|
||||||
|
* node tools/ha-backup-config.mjs --dry-run (affiche avant/après, n'écrit rien)
|
||||||
|
* node tools/ha-backup-config.mjs (applique)
|
||||||
|
* node tools/ha-backup-config.mjs --run-now (applique puis déclenche une sauvegarde)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const HA_URL = process.env.HA_URL || 'http://10.10.10.17:8123';
|
||||||
|
const ARGS = new Set(process.argv.slice(2));
|
||||||
|
const DRY = ARGS.has('--dry-run');
|
||||||
|
const RUN_NOW = ARGS.has('--run-now');
|
||||||
|
|
||||||
|
function loadToken() {
|
||||||
|
if (process.env.HA_TOKEN) return process.env.HA_TOKEN;
|
||||||
|
const m = fs.readFileSync(path.join(ROOT, '.env'), 'utf8').match(/^\s*HA_TOKEN\s*=\s*(.+?)\s*$/m);
|
||||||
|
if (!m) { console.error('HA_TOKEN absent de .env'); process.exit(1); }
|
||||||
|
return m[1].replace(/^["']|["']$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agents de destination. Identifiants relevés via `backup/agents/info` et
|
||||||
|
// croisés avec les entrées `synology_dsm` de `.storage/core.config_entries`
|
||||||
|
// pour connaître l'hôte réel de chacun — le nom seul ne dit pas où il écrit.
|
||||||
|
const AGENTS = {
|
||||||
|
local: 'backup.local', // 10.10.10.17, restauration rapide
|
||||||
|
archive2: 'synology_dsm.1660NZN396007', // 10.10.10.22 — second Synology
|
||||||
|
archive: 'synology_dsm.14B0MPN914514', // 10.10.10.50 — troisième machine
|
||||||
|
// 'synology_dsm.20A0SBRVM9PF2' = ARCHIVE3 = 10.10.10.17 : NE PAS UTILISER
|
||||||
|
// comme destination, c'est la machine dont on se protège.
|
||||||
|
};
|
||||||
|
|
||||||
|
const DESIRED = {
|
||||||
|
create_backup: {
|
||||||
|
agent_ids: [AGENTS.local, AGENTS.archive2, AGENTS.archive],
|
||||||
|
include_database: false,
|
||||||
|
include_folders: null,
|
||||||
|
include_all_addons: false,
|
||||||
|
include_addons: null,
|
||||||
|
name: null,
|
||||||
|
password: null,
|
||||||
|
},
|
||||||
|
retention: { copies: 7, days: null },
|
||||||
|
schedule: { recurrence: 'daily', time: '04:45:00', days: [] },
|
||||||
|
};
|
||||||
|
|
||||||
|
const token = loadToken();
|
||||||
|
const ws = new WebSocket(HA_URL.replace(/^http/, 'ws') + '/api/websocket');
|
||||||
|
let seq = 1;
|
||||||
|
const pending = new Map();
|
||||||
|
|
||||||
|
function call(msg) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const id = seq++;
|
||||||
|
pending.set(id, { resolve, reject });
|
||||||
|
ws.send(JSON.stringify({ id, ...msg }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onmessage = async (ev) => {
|
||||||
|
const m = JSON.parse(ev.data);
|
||||||
|
if (m.type === 'auth_required') return ws.send(JSON.stringify({ type: 'auth', access_token: token }));
|
||||||
|
if (m.type === 'auth_invalid') { console.error('jeton HA refusé'); process.exit(1); }
|
||||||
|
if (m.type === 'auth_ok') return main().catch(e => { console.error(e.message); process.exit(1); });
|
||||||
|
if (m.type === 'result') {
|
||||||
|
const p = pending.get(m.id); pending.delete(m.id);
|
||||||
|
if (!p) return;
|
||||||
|
m.success ? p.resolve(m.result) : p.reject(new Error(JSON.stringify(m.error)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onerror = () => { console.error(`websocket injoignable sur ${HA_URL}`); process.exit(1); };
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const before = (await call({ type: 'backup/config/info' })).config;
|
||||||
|
console.log('=== AVANT ===');
|
||||||
|
console.log(` configurée : ${before.automatic_backups_configured}`);
|
||||||
|
console.log(` récurrence : ${before.schedule.recurrence} à ${before.schedule.time ?? '—'}`);
|
||||||
|
console.log(` destinations : ${before.create_backup.agent_ids.join(', ') || '— aucune —'}`);
|
||||||
|
console.log(` base incluse : ${before.create_backup.include_database}`);
|
||||||
|
console.log(` rétention : ${before.retention.copies ?? '—'} copies`);
|
||||||
|
|
||||||
|
console.log('\n=== APRÈS (souhaité) ===');
|
||||||
|
console.log(` récurrence : ${DESIRED.schedule.recurrence} à ${DESIRED.schedule.time}`);
|
||||||
|
console.log(' destinations :');
|
||||||
|
console.log(` ${AGENTS.local} → 10.10.10.17 (copie locale, restauration rapide)`);
|
||||||
|
console.log(` ${AGENTS.archive2} → 10.10.10.22 (second Synology)`);
|
||||||
|
console.log(` ${AGENTS.archive} → 10.10.10.50 (troisième machine)`);
|
||||||
|
console.log(` base incluse : ${DESIRED.create_backup.include_database} (les 4,4 Go d'historique se sauvegardent à part)`);
|
||||||
|
console.log(` rétention : ${DESIRED.retention.copies} copies`);
|
||||||
|
|
||||||
|
if (DRY) { console.log('\n[DRY-RUN] aucune écriture.'); ws.close(); process.exit(0); }
|
||||||
|
|
||||||
|
await call({ type: 'backup/config/update', ...DESIRED });
|
||||||
|
const after = (await call({ type: 'backup/config/info' })).config;
|
||||||
|
console.log(`\n✅ appliqué — configurée=${after.automatic_backups_configured}, ` +
|
||||||
|
`prochaine=${after.next_automatic_backup ?? '?'}`);
|
||||||
|
|
||||||
|
if (RUN_NOW) {
|
||||||
|
console.log('\nDéclenchement d\'une sauvegarde de vérification…');
|
||||||
|
await call({ type: 'backup/generate_with_automatic_settings' });
|
||||||
|
console.log(' demandée. Suivre avec: node tools/ha-backup-list.mjs');
|
||||||
|
}
|
||||||
|
ws.close(); process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => { console.error('délai dépassé'); process.exit(1); }, 60000);
|
||||||
48
tools/ha-backup-list.mjs
Normal file
48
tools/ha-backup-list.mjs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Liste les sauvegardes Home Assistant et, pour chacune, les destinations où
|
||||||
|
// elle est RÉELLEMENT présente. C'est cette dernière colonne qui compte : une
|
||||||
|
// sauvegarde qui n'existe que sur la machine dont on se protège ne protège rien.
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const HA_URL = process.env.HA_URL || 'http://10.10.10.17:8123';
|
||||||
|
const token = process.env.HA_TOKEN ||
|
||||||
|
fs.readFileSync(path.join(ROOT, '.env'), 'utf8').match(/^\s*HA_TOKEN\s*=\s*(.+?)\s*$/m)[1].replace(/^["']|["']$/g, '');
|
||||||
|
|
||||||
|
const HOST = {
|
||||||
|
'backup.local': '10.10.10.17 (local)',
|
||||||
|
'synology_dsm.1660NZN396007': '10.10.10.22',
|
||||||
|
'synology_dsm.14B0MPN914514': '10.10.10.50',
|
||||||
|
'synology_dsm.20A0SBRVM9PF2': '10.10.10.17',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ws = new WebSocket(HA_URL.replace(/^http/, 'ws') + '/api/websocket');
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
const m = JSON.parse(ev.data);
|
||||||
|
if (m.type === 'auth_required') return ws.send(JSON.stringify({ type: 'auth', access_token: token }));
|
||||||
|
if (m.type === 'auth_ok') return ws.send(JSON.stringify({ id: 1, type: 'backup/info' }));
|
||||||
|
if (m.type === 'result') {
|
||||||
|
const r = m.result || {};
|
||||||
|
if (r.backups?.length) {
|
||||||
|
for (const b of r.backups) {
|
||||||
|
const mo = (b.agents ? Object.entries(b.agents) : []);
|
||||||
|
console.log(`\n${b.name} (${b.backup_id})`);
|
||||||
|
console.log(` créée : ${new Date(b.date).toLocaleString('fr-FR', { timeZone: 'Europe/Paris' })}`);
|
||||||
|
console.log(` base incluse : ${b.database_included} automatique : ${b.with_automatic_settings}`);
|
||||||
|
for (const [agent, info] of mo) {
|
||||||
|
const mb = info.size ? (info.size / 1048576).toFixed(1) + ' Mo' : '?';
|
||||||
|
console.log(` → ${(HOST[agent] || agent).padEnd(22)} ${mb.padStart(9)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('Aucune sauvegarde.');
|
||||||
|
}
|
||||||
|
if (r.next_automatic_backup) {
|
||||||
|
console.log(`\nProchaine automatique : ${new Date(r.next_automatic_backup).toLocaleString('fr-FR', { timeZone: 'Europe/Paris' })}`);
|
||||||
|
}
|
||||||
|
ws.close(); process.exit(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setTimeout(() => { console.error('délai dépassé'); process.exit(1); }, 30000);
|
||||||
Reference in New Issue
Block a user