v1.8.18: MQTT rc=5 Fehlerhandling, Port-Absicherung, Flash-Wizard + NuttX OTA
MQTT: - rc=5 (Not Authorized) stoppt Reconnect-Loop via _auth_failed Flag - Fehlermeldung im MQTT-Einstellungen-Banner sichtbar Sicherheit: - /api/* nur über HAOS-Ingress (X-Ingress-Path) oder Loopback erreichbar Flash-Wizard (Baustelle B): - Neuer Tab "Flash" mit IP-Eingabe und OTA-Modus-Erkennung - OTA: integrierte oder eigene Firmware via POST /api/flash/update auf Stick - Fortschrittsbalken + Polling bis Stick nach Reset wieder online - ST-Link-Erstflash-Anleitung (Pinout, st-flash Kommando) - Firmware-Binaries im Docker-Image unter /firmware/ NuttX OTA (Baustelle A, shinelanx-modbus): - ota_http.c: Zwei-Phasen OTA für STM32F103 Single-Bank Flash Stage 1: Firmware in Staging-Bereich (obere Flashhälfte) schreiben Stage 2: .ramfuncs aus SRAM heraus — Staging → App-Bereich kopieren, Reset - ota_http.h, Makefile und main.c entsprechend erweitert - ld.script.dfu: .ramfuncs in .data Section → Ausführung aus SRAM Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
@@ -30,6 +31,21 @@ WEB_DIR = os.path.join(os.path.dirname(__file__), "web")
|
||||
|
||||
app = Flask(__name__, static_folder=WEB_DIR)
|
||||
|
||||
|
||||
@app.before_request
|
||||
def _check_ingress():
|
||||
# Static files and the root page are always served (no API data inside)
|
||||
if request.path == "/" or not request.path.startswith("/api/"):
|
||||
return None
|
||||
# Requests routed through HAOS ingress proxy carry this header
|
||||
if request.headers.get("X-Ingress-Path"):
|
||||
return None
|
||||
# Allow loopback for local debugging / health checks
|
||||
if request.remote_addr in ("127.0.0.1", "::1"):
|
||||
return None
|
||||
return jsonify({"error": "Zugriff nur über die HAOS-Oberfläche erlaubt"}), 403
|
||||
|
||||
|
||||
# ── Aggregation ───────────────────────────────────────────────
|
||||
# Welche Sensor-IDs fließen in welchen Aggregat-Bucket (Summe, außer AGG_AVG)
|
||||
|
||||
@@ -452,6 +468,7 @@ def api_get_config():
|
||||
cfg = {**State.mqtt_cfg, "inverters": State.inverters_cfg}
|
||||
cfg.pop("mqtt_pass", None)
|
||||
cfg["mqtt_connected"] = _publisher.connected if _publisher else False
|
||||
cfg["mqtt_error"] = _publisher.last_error if _publisher else None
|
||||
return jsonify(cfg)
|
||||
|
||||
@app.post("/api/config")
|
||||
@@ -856,6 +873,68 @@ def api_spot_price():
|
||||
log.warning("Spot-Price API Fehler: %s", e)
|
||||
return jsonify({"ok": False, "data": _spot_cache.get("data", [])})
|
||||
|
||||
_IP_RE = re.compile(r'^(\d{1,3}\.){3}\d{1,3}$')
|
||||
|
||||
@app.get("/api/flash/probe")
|
||||
def api_flash_probe():
|
||||
import urllib.request as _ur
|
||||
ip = request.args.get("ip", "").strip()
|
||||
if not ip or not _IP_RE.match(ip):
|
||||
return jsonify({"ota": False, "error": "invalid ip"}), 400
|
||||
try:
|
||||
with _ur.urlopen(f"http://{ip}/", timeout=5) as r:
|
||||
data = json.loads(r.read().decode())
|
||||
return jsonify({"ota": bool(data.get("ota")), "info": data})
|
||||
except Exception:
|
||||
return jsonify({"ota": False})
|
||||
|
||||
@app.get("/api/flash/firmware")
|
||||
def api_flash_firmware():
|
||||
fw_dir = "/firmware"
|
||||
try:
|
||||
files = sorted(f for f in os.listdir(fw_dir) if f.endswith(".bin"))
|
||||
return jsonify({"files": files})
|
||||
except Exception:
|
||||
return jsonify({"files": []})
|
||||
|
||||
@app.post("/api/flash/update")
|
||||
def api_flash_update():
|
||||
import urllib.request as _ur
|
||||
ip = request.args.get("ip", "").strip()
|
||||
fw_name = request.args.get("fw", "").strip()
|
||||
if not ip or not _IP_RE.match(ip):
|
||||
return jsonify({"ok": False, "error": "invalid ip"}), 400
|
||||
try:
|
||||
if fw_name:
|
||||
fw_path = os.path.join("/firmware", os.path.basename(fw_name))
|
||||
with open(fw_path, "rb") as f:
|
||||
data = f.read()
|
||||
else:
|
||||
data = request.get_data()
|
||||
if len(data) < 256:
|
||||
return jsonify({"ok": False, "error": "firmware too small"}), 400
|
||||
req = _ur.Request(
|
||||
f"http://{ip}/update", data=data, method="POST",
|
||||
headers={"Content-Type": "application/octet-stream",
|
||||
"Content-Length": str(len(data))})
|
||||
with _ur.urlopen(req, timeout=90) as r:
|
||||
return jsonify({"ok": True, "response": r.read().decode(errors="replace")})
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "error": str(e)}), 502
|
||||
|
||||
@app.post("/api/flash/reboot")
|
||||
def api_flash_reboot():
|
||||
import urllib.request as _ur
|
||||
ip = request.args.get("ip", "").strip()
|
||||
if not ip or not _IP_RE.match(ip):
|
||||
return jsonify({"ok": False, "error": "invalid ip"}), 400
|
||||
try:
|
||||
req = _ur.Request(f"http://{ip}/reboot", data=b"", method="POST")
|
||||
with _ur.urlopen(req, timeout=10) as r:
|
||||
return jsonify({"ok": True})
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "error": str(e)}), 502
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
return send_from_directory(WEB_DIR, "index.html")
|
||||
|
||||
Reference in New Issue
Block a user