feat: Home Assistant integration — live status + auto-sync print slots
Deploy via Portainer / deploy (push) Successful in 0s
Deploy via Portainer / deploy (push) Successful in 0s
This commit is contained in:
Binary file not shown.
@@ -1,5 +1,9 @@
|
||||
from flask import Flask, render_template, request, redirect, url_for, jsonify, Response, session, flash
|
||||
import sqlite3, csv, io, os, zipfile, re, math, secrets, functools
|
||||
import sqlite3, csv, io, os, zipfile, re, math, secrets, functools, threading, time
|
||||
try:
|
||||
import requests as _requests
|
||||
except ImportError:
|
||||
_requests = None
|
||||
from datetime import datetime, timedelta, date as date_cls
|
||||
from authlib.integrations.flask_client import OAuth
|
||||
|
||||
@@ -220,6 +224,8 @@ def init_db():
|
||||
('nc_app_password',''),
|
||||
('nc_root_path','/3D'),
|
||||
('ical_token',''),
|
||||
('ha_url',''),
|
||||
('ha_token',''),
|
||||
]
|
||||
conn.executemany('INSERT OR IGNORE INTO settings VALUES (?,?)', defaults)
|
||||
|
||||
@@ -260,6 +266,7 @@ def init_db():
|
||||
'ALTER TABLE materials ADD COLUMN total_wasted_g REAL DEFAULT 0',
|
||||
'ALTER TABLE jobs ADD COLUMN client_token TEXT DEFAULT NULL',
|
||||
'ALTER TABLE jobs ADD COLUMN plate_name TEXT DEFAULT ""',
|
||||
'ALTER TABLE printers ADD COLUMN ha_entity_prefix TEXT DEFAULT ""',
|
||||
]
|
||||
for sql in migrations:
|
||||
try:
|
||||
@@ -1313,10 +1320,12 @@ def planning():
|
||||
def printers_page():
|
||||
conn = get_db()
|
||||
if request.method == 'POST':
|
||||
name = request.form['name']
|
||||
active = 1 if request.form.get('is_active') else 0
|
||||
notes = request.form.get('notes','')
|
||||
conn.execute('INSERT INTO printers(name,is_active,notes) VALUES(?,?,?)', (name,active,notes))
|
||||
name = request.form['name']
|
||||
active = 1 if request.form.get('is_active') else 0
|
||||
notes = request.form.get('notes','')
|
||||
ha_prefix = request.form.get('ha_entity_prefix','').strip().lower()
|
||||
conn.execute('INSERT INTO printers(name,is_active,notes,ha_entity_prefix) VALUES(?,?,?,?)',
|
||||
(name, active, notes, ha_prefix))
|
||||
conn.commit()
|
||||
printers = conn.execute('SELECT * FROM printers ORDER BY id').fetchall()
|
||||
conn.close()
|
||||
@@ -1907,6 +1916,130 @@ def calendar_ics():
|
||||
return Response(ical, mimetype='text/calendar; charset=utf-8',
|
||||
headers={'Content-Disposition': 'inline; filename=planning_3d.ics'})
|
||||
|
||||
|
||||
# ─── Home Assistant — Sync ────────────────────────────────────────────────────
|
||||
|
||||
def _ha_fetch_all_states(ha_url, ha_token):
|
||||
"""Retourne dict {entity_id: state_str} depuis l'API HA."""
|
||||
if not _requests:
|
||||
return {}
|
||||
headers = {'Authorization': f'Bearer {ha_token}'}
|
||||
r = _requests.get(f'{ha_url}/api/states', headers=headers, timeout=15)
|
||||
if r.status_code != 200:
|
||||
return {}
|
||||
return {s['entity_id']: s['state'] for s in r.json()}
|
||||
|
||||
|
||||
def _ha_sync_once():
|
||||
"""Poll HA et met a jour les print_slots actifs."""
|
||||
try:
|
||||
s = get_settings()
|
||||
ha_url = str(s.get('ha_url', '')).rstrip('/')
|
||||
ha_token = str(s.get('ha_token', ''))
|
||||
if not ha_url or not ha_token:
|
||||
return
|
||||
states = _ha_fetch_all_states(ha_url, ha_token)
|
||||
if not states:
|
||||
return
|
||||
conn = get_db()
|
||||
printers = conn.execute(
|
||||
"SELECT id, name, ha_entity_prefix FROM printers "
|
||||
"WHERE ha_entity_prefix IS NOT NULL AND ha_entity_prefix != ''"
|
||||
).fetchall()
|
||||
now_iso = datetime.utcnow().isoformat()
|
||||
window = (datetime.utcnow() + timedelta(minutes=30)).isoformat()
|
||||
for p in printers:
|
||||
prefix = p['ha_entity_prefix']
|
||||
ha_status = states.get(f'sensor.{prefix}_etat_de_l_impression', 'unknown')
|
||||
ha_error = states.get(f'binary_sensor.{prefix}_erreur_d_impression', 'off')
|
||||
slot = conn.execute(
|
||||
"SELECT id, status FROM print_slots "
|
||||
"WHERE printer_id=? AND status IN ('planned','running') "
|
||||
"AND planned_start <= ? AND planned_end >= ? "
|
||||
"ORDER BY planned_start DESC LIMIT 1",
|
||||
(p['id'], window, now_iso)
|
||||
).fetchone()
|
||||
if not slot:
|
||||
continue
|
||||
new_status = None
|
||||
if ha_error == 'on':
|
||||
new_status = 'failed'
|
||||
elif ha_status == 'finish':
|
||||
new_status = 'done'
|
||||
elif ha_status == 'running' and slot['status'] == 'planned':
|
||||
new_status = 'running'
|
||||
if new_status:
|
||||
conn.execute('UPDATE print_slots SET status=? WHERE id=?',
|
||||
(new_status, slot['id']))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
print(f'[HA sync] {e}')
|
||||
|
||||
|
||||
def _start_ha_poll():
|
||||
"""Lance le thread de polling HA en arriere-plan."""
|
||||
def _loop():
|
||||
time.sleep(10)
|
||||
while True:
|
||||
_ha_sync_once()
|
||||
time.sleep(120)
|
||||
t = threading.Thread(target=_loop, daemon=True, name='ha-poll')
|
||||
t.start()
|
||||
|
||||
|
||||
@app.route('/api/ha/status')
|
||||
@login_required
|
||||
def api_ha_status():
|
||||
"""Retourne le statut live HA pour toutes les imprimantes configurees."""
|
||||
s = get_settings()
|
||||
ha_url = str(s.get('ha_url', '')).rstrip('/')
|
||||
ha_token = str(s.get('ha_token', ''))
|
||||
if not ha_url or not ha_token:
|
||||
return jsonify({'error': 'HA non configure', 'printers': {}})
|
||||
try:
|
||||
if not _requests:
|
||||
return jsonify({'error': 'requests non installe', 'printers': {}})
|
||||
headers = {'Authorization': f'Bearer {ha_token}'}
|
||||
r = _requests.get(f'{ha_url}/api/states', headers=headers, timeout=10)
|
||||
if r.status_code != 200:
|
||||
return jsonify({'error': f'HA HTTP {r.status_code}', 'printers': {}})
|
||||
raw_states = r.json()
|
||||
states = {st['entity_id']: st['state'] for st in raw_states}
|
||||
conn = get_db()
|
||||
printers = conn.execute(
|
||||
"SELECT id, name, ha_entity_prefix FROM printers "
|
||||
"WHERE ha_entity_prefix IS NOT NULL AND ha_entity_prefix != ''"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
result = {}
|
||||
for p in printers:
|
||||
pfx = p['ha_entity_prefix']
|
||||
def gs(suf, default='unknown', _p=pfx):
|
||||
return states.get(f'sensor.{_p}_{suf}', default)
|
||||
def gb(suf, _p=pfx):
|
||||
return states.get(f'binary_sensor.{_p}_{suf}', 'off')
|
||||
try: progress = int(float(gs('progression_de_l_impression', '0')))
|
||||
except: progress = 0
|
||||
try: layer = int(float(gs('couche_actuelle', '0')))
|
||||
except: layer = 0
|
||||
try: layers_total = int(float(gs('nombre_total_de_couches', '0')))
|
||||
except: layers_total = 0
|
||||
result[str(p['id'])] = {
|
||||
'printer_name': p['name'],
|
||||
'status': gs('etat_de_l_impression'),
|
||||
'progress': progress,
|
||||
'task_name': gs('nom_de_la_tache'),
|
||||
'heure_fin': gs('heure_de_fin'),
|
||||
'layer': layer,
|
||||
'layers_total': layers_total,
|
||||
'error': gb('erreur_d_impression') == 'on',
|
||||
}
|
||||
return jsonify({'printers': result})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e), 'printers': {}})
|
||||
|
||||
|
||||
# ─── Nextcloud WebDAV ────────────────────────────────────────────────────────
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
@@ -2194,4 +2327,5 @@ def export_csv():
|
||||
|
||||
if __name__ == '__main__':
|
||||
init_db()
|
||||
_start_ha_poll()
|
||||
app.run(host='0.0.0.0', port=5000, debug=False)
|
||||
|
||||
@@ -92,6 +92,21 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live HA Status -->
|
||||
<div class="col-md-5">
|
||||
<div class="card">
|
||||
<div class="card-header py-3 d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-activity me-2 text-success"></i>Imprimantes en direct</span>
|
||||
<span id="haLastUpdate" class="text-muted" style="font-size:.7rem"></span>
|
||||
</div>
|
||||
<div class="card-body p-2" id="haPrinterCards">
|
||||
<div class="text-center text-muted py-3 small">
|
||||
<i class="bi bi-hourglass-split me-1"></i>Chargement…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal planification -->
|
||||
@@ -537,5 +552,65 @@ async function deleteSlot() {
|
||||
}
|
||||
|
||||
initTimeline();
|
||||
|
||||
// ── Home Assistant live status ──────────────────────────────────────────────
|
||||
async function refreshHaStatus() {
|
||||
try {
|
||||
const r = await fetch('/api/ha/status');
|
||||
const d = await r.json();
|
||||
const el = document.getElementById('haPrinterCards');
|
||||
const lu = document.getElementById('haLastUpdate');
|
||||
lu.textContent = 'Mis à jour ' + new Date().toLocaleTimeString('fr-FR', {hour:'2-digit',minute:'2-digit'});
|
||||
if (d.error && !d.printers) {
|
||||
el.innerHTML = `<div class="text-muted small p-3"><i class="bi bi-info-circle me-1"></i>${d.error}</div>`;
|
||||
return;
|
||||
}
|
||||
const printers = d.printers || {};
|
||||
const keys = Object.keys(printers);
|
||||
if (!keys.length) {
|
||||
el.innerHTML = '<div class="text-muted small p-3"><i class="bi bi-info-circle me-1"></i>Aucune imprimante avec préfixe HA. Configurez dans Paramètres + Imprimantes.</div>';
|
||||
return;
|
||||
}
|
||||
const STATUS_LABEL = {running:'En cours',finish:'Terminé',idle:'Idle',pause:'En pause',failed:'Erreur',offline:'Hors ligne',init:'Démarrage',unknown:'—'};
|
||||
const STATUS_CLS = {running:'success',finish:'secondary',idle:'light',pause:'warning',failed:'danger',offline:'dark',init:'info',unknown:'light'};
|
||||
el.innerHTML = keys.map(pid => {
|
||||
const p = printers[pid];
|
||||
const sc = STATUS_CLS[p.status] || 'light';
|
||||
const sl = STATUS_LABEL[p.status] || p.status;
|
||||
const prog = p.progress || 0;
|
||||
const barColor = p.error ? '#ef4444' : p.status === 'running' ? '#22c55e' : '#94a3b8';
|
||||
const layerInfo = (p.layers_total > 0)
|
||||
? `<span class="text-muted" style="font-size:.7rem">Couche ${p.layer}/${p.layers_total}</span>`
|
||||
: '';
|
||||
const finInfo = (p.heure_fin && p.heure_fin !== 'unknown' && p.status === 'running')
|
||||
? `<span class="text-muted" style="font-size:.7rem">Fin ≈ ${new Date(p.heure_fin).toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit'})}</span>`
|
||||
: '';
|
||||
const taskInfo = (p.task_name && p.task_name !== 'unknown')
|
||||
? `<div class="text-truncate" style="font-size:.72rem;color:#6b7280;max-width:220px" title="${p.task_name}">${p.task_name}</div>`
|
||||
: '';
|
||||
return `<div class="p-2 mb-1 rounded" style="background:#f8f9fa;border:1px solid #e5e7eb">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<span class="fw-semibold small">${p.printer_name}</span>
|
||||
<span class="badge bg-${sc} text-${sc==='light'?'dark':'white'}">${sl}</span>
|
||||
</div>
|
||||
${taskInfo}
|
||||
<div class="d-flex justify-content-between align-items-center mt-1">
|
||||
<div class="flex-fill me-2">
|
||||
<div style="height:6px;background:#e5e7eb;border-radius:3px;overflow:hidden">
|
||||
<div style="width:${prog}%;height:100%;background:${barColor};transition:width .5s"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="fw-bold small" style="min-width:36px;text-align:right">${prog}%</span>
|
||||
</div>
|
||||
<div class="d-flex gap-3 mt-1">${layerInfo}${finInfo}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch(e) {
|
||||
console.warn('HA status error:', e);
|
||||
}
|
||||
}
|
||||
refreshHaStatus();
|
||||
setInterval(refreshHaStatus, 30000);
|
||||
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
+14
-1
@@ -15,7 +15,7 @@
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr><th>Nom</th><th>Statut</th><th>Notes</th><th></th></tr>
|
||||
<tr><th>Nom</th><th>Statut</th><th>Préfixe HA</th><th>Notes</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in printers %}
|
||||
@@ -28,6 +28,13 @@
|
||||
<span class="badge bg-secondary">Inactive</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if p.ha_entity_prefix %}
|
||||
<code class="small text-primary">{{ p.ha_entity_prefix }}</code>
|
||||
{% else %}
|
||||
<span class="text-muted small">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-muted small">{{ p.notes or '—' }}</td>
|
||||
<td>
|
||||
<div class="d-flex gap-1">
|
||||
@@ -59,6 +66,12 @@
|
||||
<label class="form-label fw-semibold">Nom</label>
|
||||
<input type="text" name="name" class="form-control" required placeholder="ex: A1 #7, X1C #1">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Préfixe entité Home Assistant <span class="text-muted fw-normal small">(optionnel)</span></label>
|
||||
<input type="text" name="ha_entity_prefix" class="form-control font-monospace"
|
||||
placeholder="ex : patabambulab01">
|
||||
<div class="form-text">Préfixe HA pour la synchro automatique du planning. Configurer l'URL HA dans <a href="{{ url_for('settings') }}">Paramètres</a>.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Notes</label>
|
||||
<input type="text" name="notes" class="form-control" placeholder="couleur, emplacement…">
|
||||
|
||||
@@ -239,6 +239,42 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Home Assistant -->
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header py-3"><i class="bi bi-house-heart me-2"></i>Home Assistant — Sync imprimantes</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">URL Home Assistant</label>
|
||||
<input type="url" name="ha_url" class="form-control"
|
||||
placeholder="https://homeassistant.tondomain.com"
|
||||
value="{{ settings.get('ha_url','') }}">
|
||||
<div class="form-text">Sans slash final. Ex : <code>https://homeassistant.lespatas.ovh</code></div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Token longue durée</label>
|
||||
<input type="password" name="ha_token" class="form-control font-monospace"
|
||||
placeholder="••••••••••••••••••••"
|
||||
value="{{ settings.get('ha_token','') }}">
|
||||
<div class="form-text">Profil HA → Sécurité → Jetons d'accès longue durée.</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="testHaBtn">
|
||||
<i class="bi bi-plug me-1"></i>Tester la connexion HA
|
||||
</button>
|
||||
<span id="testHaResult" class="ms-3 small"></span>
|
||||
<div class="form-text mt-1">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
Configurez ensuite le <strong>Préfixe entité HA</strong> pour chaque imprimante (ex : <code>patabambulab01</code>) dans la page <a href="{{ url_for('printers_page') }}">Imprimantes</a>.
|
||||
Le planning se synchronise alors automatiquement toutes les 2 min.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- iCal -->
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
@@ -307,5 +343,22 @@ document.getElementById('testNcBtn').addEventListener('click', async function ()
|
||||
res.innerHTML = `<span class="text-danger"><i class="bi bi-x-circle me-1"></i>${e.message}</span>`;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('testHaBtn').addEventListener('click', async function () {
|
||||
const res = document.getElementById('testHaResult');
|
||||
res.textContent = 'Test en cours…';
|
||||
try {
|
||||
const r = await fetch('/api/ha/status');
|
||||
const d = await r.json();
|
||||
if (d.error) {
|
||||
res.innerHTML = `<span class="text-danger"><i class="bi bi-x-circle me-1"></i>${d.error}</span>`;
|
||||
} else {
|
||||
const count = Object.keys(d.printers).length;
|
||||
res.innerHTML = `<span class="text-success"><i class="bi bi-check-circle me-1"></i>Connexion OK — ${count} imprimante(s) avec préfixe HA</span>`;
|
||||
}
|
||||
} catch (e) {
|
||||
res.innerHTML = `<span class="text-danger"><i class="bi bi-x-circle me-1"></i>${e.message}</span>`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user