feat: Home Assistant integration — live status + auto-sync print slots
Deploy via Portainer / deploy (push) Successful in 0s

This commit is contained in:
Jo
2026-07-07 11:40:52 +02:00
parent 4206a8934e
commit 5686706a4e
5 changed files with 281 additions and 6 deletions
+139 -5
View File
@@ -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)