diff --git a/__pycache__/app.cpython-310.pyc b/__pycache__/app.cpython-310.pyc index db6855d..596867e 100644 Binary files a/__pycache__/app.cpython-310.pyc and b/__pycache__/app.cpython-310.pyc differ diff --git a/app.py b/app.py index 31546c4..8b31023 100644 --- a/app.py +++ b/app.py @@ -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) diff --git a/templates/planning.html b/templates/planning.html index 67c7dc9..89915a0 100644 --- a/templates/planning.html +++ b/templates/planning.html @@ -92,6 +92,21 @@ + + +
+
+
+ Imprimantes en direct + +
+
+
+ Chargement… +
+
+
+
@@ -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 = `
${d.error}
`; + return; + } + const printers = d.printers || {}; + const keys = Object.keys(printers); + if (!keys.length) { + el.innerHTML = '
Aucune imprimante avec préfixe HA. Configurez dans Paramètres + Imprimantes.
'; + 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) + ? `Couche ${p.layer}/${p.layers_total}` + : ''; + const finInfo = (p.heure_fin && p.heure_fin !== 'unknown' && p.status === 'running') + ? `Fin ≈ ${new Date(p.heure_fin).toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit'})}` + : ''; + const taskInfo = (p.task_name && p.task_name !== 'unknown') + ? `
${p.task_name}
` + : ''; + return `
+
+ ${p.printer_name} + ${sl} +
+ ${taskInfo} +
+
+
+
+
+
+ ${prog}% +
+
${layerInfo}${finInfo}
+
`; + }).join(''); + } catch(e) { + console.warn('HA status error:', e); + } +} +refreshHaStatus(); +setInterval(refreshHaStatus, 30000); + {% endblock %} diff --git a/templates/printers.html b/templates/printers.html index dfee709..273bd5b 100644 --- a/templates/printers.html +++ b/templates/printers.html @@ -15,7 +15,7 @@
- + {% for p in printers %} @@ -28,6 +28,13 @@ Inactive {% endif %} +
NomStatutNotes
NomStatutPréfixe HANotes
+ {% if p.ha_entity_prefix %} + {{ p.ha_entity_prefix }} + {% else %} + + {% endif %} + {{ p.notes or '—' }}
@@ -59,6 +66,12 @@
+
+ + +
Préfixe HA pour la synchro automatique du planning. Configurer l'URL HA dans Paramètres.
+
diff --git a/templates/settings.html b/templates/settings.html index 2d53b11..03a5422 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -239,6 +239,42 @@
+ +
+
+
Home Assistant — Sync imprimantes
+
+
+
+ + +
Sans slash final. Ex : https://homeassistant.lespatas.ovh
+
+
+ + +
Profil HA → Sécurité → Jetons d'accès longue durée.
+
+
+ + +
+ + Configurez ensuite le Préfixe entité HA pour chaque imprimante (ex : patabambulab01) dans la page Imprimantes. + Le planning se synchronise alors automatiquement toutes les 2 min. +
+
+
+
+
+
+
@@ -307,5 +343,22 @@ document.getElementById('testNcBtn').addEventListener('click', async function () res.innerHTML = `${e.message}`; } }); + +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 = `${d.error}`; + } else { + const count = Object.keys(d.printers).length; + res.innerHTML = `Connexion OK — ${count} imprimante(s) avec préfixe HA`; + } + } catch (e) { + res.innerHTML = `${e.message}`; + } +}); {% endblock %}