| @@ -0,0 +1,33 @@ | |||||
| FROM python:3.10-slim | |||||
| ENV PYTHONUNBUFFERED=1 | |||||
| # dipendenze native utili a numpy/scikit (safe choice) | |||||
| RUN apt-get update && apt-get install -y --no-install-recommends \ | |||||
| build-essential gcc g++ \ | |||||
| && rm -rf /var/lib/apt/lists/* | |||||
| WORKDIR /app | |||||
| COPY requirements.txt /app/requirements.txt | |||||
| RUN pip install --no-cache-dir -r /app/requirements.txt | |||||
| # Correzione nome pacchetto e upgrade pip | |||||
| RUN pip install --no-cache-dir --upgrade pip | |||||
| RUN pip install streamlit==1.29.0 streamlit-drawable-canvas==0.9.3 PyYAML Pillow pandas | |||||
| RUN pip install --no-cache-dir folium streamlit-folium | |||||
| RUN pip install --no-cache-dir psutil | |||||
| COPY app/ /app/app/ | |||||
| COPY entrypoint.sh /app/entrypoint.sh | |||||
| RUN chmod +x /app/entrypoint.sh | |||||
| # utente non-root | |||||
| RUN useradd -m appuser | |||||
| USER appuser | |||||
| # cartelle dati/modelli (volumi) | |||||
| RUN mkdir -p /home/appuser/data /home/appuser/models | |||||
| ENV DATA_DIR=/home/appuser/data | |||||
| ENV MODELS_DIR=/home/appuser/models | |||||
| ENTRYPOINT ["/app/entrypoint.sh"] | |||||
| @@ -0,0 +1,13 @@ | |||||
| import requests | |||||
| from .auth import TokenManager | |||||
| def get_json(url: str, tm: TokenManager) -> object: | |||||
| token = tm.get_token() | |||||
| r = requests.get( | |||||
| url, | |||||
| headers={"accept": "application/json", "Authorization": f"Bearer {token}"}, | |||||
| timeout=10, | |||||
| verify=tm.verify_tls, | |||||
| ) | |||||
| r.raise_for_status() | |||||
| return r.json() | |||||
| @@ -0,0 +1,49 @@ | |||||
| import time | |||||
| import requests | |||||
| class TokenManager: | |||||
| def __init__(self, token_url: str, client_id: str, client_secret: str, | |||||
| username: str, password: str, audience: str | None = None, | |||||
| verify_tls: bool = True, timeout_s: float = 10.0): | |||||
| self.token_url = token_url | |||||
| self.client_id = client_id | |||||
| self.client_secret = client_secret | |||||
| self.username = username | |||||
| self.password = password | |||||
| self.audience = audience | |||||
| self.verify_tls = verify_tls | |||||
| self.timeout_s = float(timeout_s) | |||||
| self._token: str | None = None | |||||
| self._exp: int = 0 # epoch seconds | |||||
| def get_token(self) -> str: | |||||
| now = int(time.time()) | |||||
| if self._token and now < (self._exp - 30): | |||||
| return self._token | |||||
| data = { | |||||
| "grant_type": "password", | |||||
| "client_id": self.client_id, | |||||
| "client_secret": self.client_secret, | |||||
| "username": self.username, | |||||
| "password": self.password, | |||||
| } | |||||
| if self.audience: | |||||
| data["audience"] = self.audience | |||||
| r = requests.post( | |||||
| self.token_url, | |||||
| data=data, | |||||
| headers={"Content-Type": "application/x-www-form-urlencoded"}, | |||||
| timeout=self.timeout_s, | |||||
| verify=self.verify_tls, | |||||
| ) | |||||
| r.raise_for_status() | |||||
| payload = r.json() | |||||
| token = payload["access_token"] | |||||
| expires_in = int(payload.get("expires_in", 60)) | |||||
| self._token = token | |||||
| self._exp = now + expires_in | |||||
| return token | |||||
| @@ -0,0 +1,51 @@ | |||||
| from typing import Tuple, Dict, Any, Set, Iterable, List | |||||
| from .normalize import normalize_mac, is_mac | |||||
| DEFAULT_MAC_FIELDS: tuple[str, ...] = ( | |||||
| "mac", | |||||
| "beacon_mac", | |||||
| "beaconMac", | |||||
| "device_mac", | |||||
| "deviceMac", | |||||
| "tag_mac", | |||||
| "tagMac", | |||||
| ) | |||||
| def _first_present(item: dict, keys: Iterable[str]) -> str: | |||||
| for k in keys: | |||||
| if k in item and item.get(k): | |||||
| return str(item.get(k)) | |||||
| return "" | |||||
| def extract_beacon_macs(data: object, mac_fields: tuple[str, ...] = DEFAULT_MAC_FIELDS | |||||
| ) -> Tuple[Set[str], Dict[str, Any], List[str]]: | |||||
| allowed: Set[str] = set() | |||||
| by_mac: Dict[str, Any] = {} | |||||
| invalid: List[str] = [] | |||||
| items = None | |||||
| if isinstance(data, list): | |||||
| items = data | |||||
| elif isinstance(data, dict): | |||||
| for key in ("items", "beacons", "trackers", "data", "result"): | |||||
| v = data.get(key) | |||||
| if isinstance(v, list): | |||||
| items = v | |||||
| break | |||||
| if not items: | |||||
| return allowed, by_mac, invalid | |||||
| for item in items: | |||||
| if not isinstance(item, dict): | |||||
| continue | |||||
| raw = _first_present(item, mac_fields) | |||||
| mac = normalize_mac(raw) | |||||
| if mac and is_mac(mac): | |||||
| allowed.add(mac) | |||||
| by_mac[mac] = item | |||||
| else: | |||||
| if raw: | |||||
| invalid.append(str(raw)) | |||||
| return allowed, by_mac, invalid | |||||
| @@ -0,0 +1,66 @@ | |||||
| from __future__ import annotations | |||||
| import csv | |||||
| from dataclasses import dataclass | |||||
| from pathlib import Path | |||||
| from typing import List, Optional | |||||
| from .normalize import norm_mac | |||||
| @dataclass | |||||
| class GatewayDef: | |||||
| mac: str | |||||
| name: str = "" | |||||
| id: str = "" | |||||
| def _sniff_delimiter(sample: str) -> str: | |||||
| # fallback robusto: prova a sniffare, altrimenti ';' | |||||
| try: | |||||
| return csv.Sniffer().sniff(sample, delimiters=";,\t").delimiter | |||||
| except Exception: | |||||
| return ";" | |||||
| def load_gateway_features_csv(path: str) -> List[GatewayDef]: | |||||
| """ | |||||
| Carica gateway.csv. | |||||
| Attesi header: mac;name;id (name/id opzionali) | |||||
| Normalizza MAC in formato canonico 'AA:BB:CC:DD:EE:FF' | |||||
| """ | |||||
| p = Path(path) | |||||
| if not p.exists(): | |||||
| raise FileNotFoundError(f"gateway csv not found: {path}") | |||||
| txt = p.read_text(encoding="utf-8", errors="ignore") | |||||
| delim = _sniff_delimiter(txt[:2048]) | |||||
| rows: List[GatewayDef] = [] | |||||
| with p.open("r", encoding="utf-8", errors="ignore", newline="") as f: | |||||
| rd = csv.DictReader(f, delimiter=delim) | |||||
| for r in rd: | |||||
| # accetta anche varianti tipo 'MAC' o 'gw_mac' | |||||
| mac_raw = r.get("mac") or r.get("MAC") or r.get("gw_mac") or r.get("gateway_mac") | |||||
| if not mac_raw: | |||||
| continue | |||||
| mac = norm_mac(mac_raw) | |||||
| if not mac: | |||||
| continue | |||||
| name = (r.get("name") or r.get("NAME") or "").strip() | |||||
| gid = (r.get("id") or r.get("ID") or "").strip() | |||||
| rows.append(GatewayDef(mac=mac, name=name, id=gid)) | |||||
| return rows | |||||
| # --------------------------------------------------------------------------- | |||||
| # BACKWARD COMPATIBILITY | |||||
| # main.py si aspetta load_gateway_csv() -> List[GatewayDef] | |||||
| # --------------------------------------------------------------------------- | |||||
| def load_gateway_csv(path: str) -> List[GatewayDef]: | |||||
| """ | |||||
| Alias compatibile per vecchie versioni. | |||||
| """ | |||||
| return load_gateway_features_csv(path) | |||||
| @@ -0,0 +1,10 @@ | |||||
| import json | |||||
| def dump_json(label: str, data: object, max_chars: int = 8000) -> None: | |||||
| """ | |||||
| Stampa JSON in modo controllato (troncato). | |||||
| """ | |||||
| s = json.dumps(data, ensure_ascii=False, sort_keys=True) | |||||
| if len(s) > max_chars: | |||||
| s = s[:max_chars] + f"... [truncated {len(s)-max_chars} chars]" | |||||
| print(f"{label}: {s}", flush=True) | |||||
| @@ -0,0 +1,127 @@ | |||||
| """fingerprint.py | |||||
| Raccolta RSSI durante una finestra temporale e aggregazione in feature-vector. | |||||
| Scelte chiave: | |||||
| - Matching interno su MAC in formato **compact** (12 hex senza ':'). | |||||
| - Header CSV dei gateway mantenuto nel formato originale (spesso con ':'). | |||||
| Filtri/robustezza (per-gateway): | |||||
| - range rssi_min/rssi_max | |||||
| - outlier_method: none | mad | iqr | |||||
| - min_samples_per_gateway | |||||
| - max_stddev (opzionale) | |||||
| - aggregate: mean | median | |||||
| """ | |||||
| from __future__ import annotations | |||||
| from dataclasses import dataclass, field | |||||
| from typing import Dict, List, Optional, Tuple | |||||
| import math | |||||
| import statistics | |||||
| def _mad_filter(values: List[float], z: float = 3.5) -> List[float]: | |||||
| if len(values) < 3: | |||||
| return values | |||||
| med = statistics.median(values) | |||||
| dev = [abs(v - med) for v in values] | |||||
| mad = statistics.median(dev) | |||||
| if mad == 0: | |||||
| return values | |||||
| kept: List[float] = [] | |||||
| for v in values: | |||||
| mz = 0.6745 * (v - med) / mad # modified z-score | |||||
| if abs(mz) <= z: | |||||
| kept.append(v) | |||||
| return kept if kept else values | |||||
| def _iqr_filter(values: List[float], k: float = 1.5) -> List[float]: | |||||
| if len(values) < 4: | |||||
| return values | |||||
| vs = sorted(values) | |||||
| q1 = vs[len(vs) // 4] | |||||
| q3 = vs[(len(vs) * 3) // 4] | |||||
| iqr = q3 - q1 | |||||
| low = q1 - k * iqr | |||||
| high = q3 + k * iqr | |||||
| kept = [v for v in values if low <= v <= high] | |||||
| return kept if kept else values | |||||
| @dataclass | |||||
| class FingerprintWindow: | |||||
| beacon_keys: List[str] # compact | |||||
| gateway_headers: List[str] # come in gateway.csv (spesso colon) | |||||
| gateway_keys: List[str] # compact (allineato a gateway_headers) | |||||
| rssi_min: float = -110.0 | |||||
| rssi_max: float = -25.0 | |||||
| outlier_method: str = "none" # none | mad | iqr | |||||
| mad_z: float = 3.5 | |||||
| min_samples_per_gateway: int = 1 | |||||
| max_stddev: Optional[float] = None | |||||
| values: Dict[str, Dict[str, List[float]]] = field(default_factory=dict) | |||||
| def __post_init__(self) -> None: | |||||
| self.beacon_set = set(self.beacon_keys) | |||||
| self.gw_set = set(self.gateway_keys) | |||||
| for b in self.beacon_keys: | |||||
| self.values[b] = {gk: [] for gk in self.gateway_keys} | |||||
| def add(self, gw_key: str, beacon_key: str, rssi: float) -> bool: | |||||
| if gw_key not in self.gw_set or beacon_key not in self.beacon_set: | |||||
| return False | |||||
| try: | |||||
| r = float(rssi) | |||||
| except Exception: | |||||
| return False | |||||
| if r < self.rssi_min or r > self.rssi_max: | |||||
| return False | |||||
| self.values[beacon_key][gw_key].append(r) | |||||
| return True | |||||
| def _aggregate_one(self, xs: List[float], method: str) -> float: | |||||
| if not xs: | |||||
| return math.nan | |||||
| vals = xs | |||||
| if self.outlier_method == "mad": | |||||
| vals = _mad_filter(vals, z=self.mad_z) | |||||
| elif self.outlier_method == "iqr": | |||||
| vals = _iqr_filter(vals, k=1.5) | |||||
| if len(vals) < max(1, int(self.min_samples_per_gateway)): | |||||
| return math.nan | |||||
| if self.max_stddev is not None and len(vals) >= 2: | |||||
| mean = sum(vals) / len(vals) | |||||
| var = sum((v - mean) ** 2 for v in vals) / (len(vals) - 1) | |||||
| sd = math.sqrt(var) | |||||
| if sd > float(self.max_stddev): | |||||
| return math.nan | |||||
| if method == "mean": | |||||
| return sum(vals) / len(vals) | |||||
| return float(statistics.median(vals)) | |||||
| def features_for(self, beacon_key: str, aggregate: str = "median") -> Dict[str, float]: | |||||
| out: Dict[str, float] = {} | |||||
| for gk, hdr in zip(self.gateway_keys, self.gateway_headers): | |||||
| out[hdr] = self._aggregate_one(self.values[beacon_key][gk], aggregate) | |||||
| return out | |||||
| def top_gateways(self, beacon_key: str, aggregate: str = "median", top_n: int = 5) -> List[Tuple[int, str, float]]: | |||||
| rows: List[Tuple[int, str, float]] = [] | |||||
| for gk, hdr in zip(self.gateway_keys, self.gateway_headers): | |||||
| n = len(self.values[beacon_key][gk]) | |||||
| if n <= 0: | |||||
| continue | |||||
| agg = self._aggregate_one(self.values[beacon_key][gk], aggregate) | |||||
| rows.append((n, hdr, agg)) | |||||
| rows.sort(key=lambda t: t[0], reverse=True) | |||||
| return rows[:top_n] | |||||
| @@ -0,0 +1,30 @@ | |||||
| from typing import Tuple, Dict, Any, Set, List | |||||
| from .normalize import normalize_mac, is_mac | |||||
| def extract_gateway_macs(data: object) -> Tuple[Set[str], Dict[str, Any], List[str]]: | |||||
| """ | |||||
| ritorna: | |||||
| - allowed MAC (validi) | |||||
| - mapping mac->record | |||||
| - lista raw MAC invalidi (normalizzati quando possibile) | |||||
| """ | |||||
| allowed: Set[str] = set() | |||||
| by_mac: Dict[str, Any] = {} | |||||
| invalid: List[str] = [] | |||||
| if not isinstance(data, list): | |||||
| return allowed, by_mac, invalid | |||||
| for item in data: | |||||
| if not isinstance(item, dict): | |||||
| continue | |||||
| raw = item.get("mac", "") | |||||
| mac = normalize_mac(raw) | |||||
| if mac and is_mac(mac): | |||||
| allowed.add(mac) | |||||
| by_mac[mac] = item | |||||
| else: | |||||
| if raw: | |||||
| invalid.append(str(raw)) | |||||
| return allowed, by_mac, invalid | |||||
| @@ -0,0 +1,202 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| import json | |||||
| import os | |||||
| import time | |||||
| import joblib | |||||
| import numpy as np | |||||
| import requests | |||||
| import yaml | |||||
| import paho.mqtt.client as mqtt | |||||
| from dataclasses import dataclass | |||||
| from pathlib import Path | |||||
| from typing import Any, Callable, Dict, List, Optional, Tuple | |||||
| from .logger_utils import log_msg as log | |||||
| # Importiamo la funzione per caricare i gateway per mantenere l'ordine delle feature | |||||
| from .csv_config import load_gateway_features_csv | |||||
| BUILD_TAG = "infer-debug-v19-hierarchical-final" | |||||
| # ------------------------- | |||||
| # UTILITIES | |||||
| # ------------------------- | |||||
| def _norm_mac(s: str) -> str: | |||||
| s = (s or "").strip().replace("-", "").replace(":", "").replace(".", "").upper() | |||||
| if len(s) != 12: return s | |||||
| return ":".join([s[i:i+2] for i in range(0, 12, 2)]) | |||||
| def _predict_xyz(model_pkg: Dict[str, Any], X: np.ndarray) -> Tuple[int, float, float]: | |||||
| """ | |||||
| Logica di predizione basata su train_mode.py: | |||||
| 1. Predice Z (piano) tramite KNeighborsClassifier | |||||
| 2. Predice X, Y tramite KNeighborsRegressor specifico per quel piano | |||||
| """ | |||||
| # 1. Predizione del Piano (Z) | |||||
| floor_clf = model_pkg.get("floor_clf") | |||||
| if floor_clf is None: | |||||
| raise ValueError("Il pacchetto modello non contiene 'floor_clf'") | |||||
| z_pred = floor_clf.predict(X) | |||||
| z = int(z_pred[0]) | |||||
| # 2. Predizione X, Y (Coordinate) | |||||
| xy_models = model_pkg.get("xy_by_floor", {}) | |||||
| regressor = xy_models.get(z) | |||||
| if regressor is None: | |||||
| # Se il piano predetto non ha un regressore XY, restituiamo solo il piano | |||||
| return z, -1.0, -1.0 | |||||
| xy_pred = regressor.predict(X) | |||||
| x, y = xy_pred[0] # L'output del regressore multi-output è [x, y] | |||||
| return z, float(x), float(y) | |||||
| @dataclass | |||||
| class _Point: | |||||
| t: float | |||||
| v: float | |||||
| class RollingRSSI: | |||||
| def __init__(self, window_s: float): | |||||
| self.window_s = window_s | |||||
| self.data: Dict[str, Dict[str, List[_Point]]] = {} | |||||
| def add(self, bm: str, gm: str, rssi: float): | |||||
| self.data.setdefault(bm, {}).setdefault(gm, []).append(_Point(time.time(), rssi)) | |||||
| def prune(self): | |||||
| cutoff = time.time() - self.window_s | |||||
| for bm in list(self.data.keys()): | |||||
| for gm in list(self.data[bm].keys()): | |||||
| self.data[bm][gm] = [p for p in self.data[bm][gm] if p.t >= cutoff] | |||||
| if not self.data[bm][gm]: self.data[bm].pop(gm) | |||||
| if not self.data[bm]: self.data.pop(bm) | |||||
| def aggregate_features(self, bm: str, gws: List[str], agg: str, fill: float): | |||||
| per_gw = self.data.get(bm, {}) | |||||
| feats = [] | |||||
| found_count = 0 | |||||
| for gm in gws: | |||||
| vals = [p.v for p in per_gw.get(gm, [])] | |||||
| if vals: | |||||
| val = np.median(vals) if agg == "median" else np.mean(vals) | |||||
| feats.append(val) | |||||
| found_count += 1 | |||||
| else: | |||||
| feats.append(np.nan) | |||||
| X = np.array(feats).reshape(1, -1) | |||||
| # Sostituisce i NaN con il valore di riempimento definito nel training | |||||
| return np.where(np.isnan(X), fill, X), found_count | |||||
| # ------------------------- | |||||
| # MAIN RUNNER | |||||
| # ------------------------- | |||||
| def run_infer(settings: Dict[str, Any]): | |||||
| inf_c = settings.get("infer", {}) | |||||
| api_c = settings.get("api", {}) | |||||
| mqtt_c = settings.get("mqtt", {}) | |||||
| log(f"INFER_MODE build tag={BUILD_TAG}") | |||||
| # Caricamento Modello e Configurazione Training | |||||
| try: | |||||
| model_pkg = joblib.load(inf_c.get("model_path", "/data/model/model.joblib")) | |||||
| # Recuperiamo l'ordine dei gateway e il nan_fill direttamente dal modello salvato | |||||
| gateways_ordered = model_pkg.get("gateways_order") | |||||
| nan_fill = float(model_pkg.get("nan_fill", -110.0)) | |||||
| log(f"Model loaded. Features: {len(gateways_ordered)}, Fill: {nan_fill}") | |||||
| except Exception as e: | |||||
| log(f"CRITICAL: Failed to load model: {e}") | |||||
| return | |||||
| gateways_set = set(gateways_ordered) | |||||
| rolling = RollingRSSI(float(inf_c.get("window_seconds", 5.0))) | |||||
| # --- Gestione Token e Beacons (identica a prima) --- | |||||
| token_cache = {"token": None, "expires_at": 0} | |||||
| def get_token(): | |||||
| if time.time() < token_cache["expires_at"]: return token_cache["token"] | |||||
| try: | |||||
| # Recupero credenziali da secrets.yaml | |||||
| with open("/config/secrets.yaml", "r") as f: | |||||
| sec = yaml.safe_load(f).get("oidc", {}) | |||||
| payload = { | |||||
| "grant_type": "password", "client_id": api_c.get("client_id", "Fastapi"), | |||||
| "client_secret": sec.get("client_secret", ""), | |||||
| "username": sec.get("username", "core"), "password": sec.get("password", "") | |||||
| } | |||||
| resp = requests.post(api_c["token_url"], data=payload, verify=False, timeout=10) | |||||
| if resp.status_code == 200: | |||||
| d = resp.json() | |||||
| token_cache["token"] = d["access_token"] | |||||
| token_cache["expires_at"] = time.time() + d.get("expires_in", 300) - 30 | |||||
| return token_cache["token"] | |||||
| except: pass | |||||
| return None | |||||
| def fetch_beacons(): | |||||
| token = get_token() | |||||
| if not token: return [] | |||||
| try: | |||||
| headers = {"Authorization": f"Bearer {token}", "accept": "application/json"} | |||||
| resp = requests.get(api_c["get_beacons_url"], headers=headers, verify=False, timeout=10) | |||||
| return [it["mac"] for it in resp.json() if "mac" in it] if resp.status_code == 200 else [] | |||||
| except: return [] | |||||
| def on_message(client, userdata, msg): | |||||
| gw = _norm_mac(msg.topic.split("/")[-1]) | |||||
| if gw not in gateways_set: return | |||||
| try: | |||||
| items = json.loads(msg.payload.decode()) | |||||
| for it in items: | |||||
| bm, rssi = _norm_mac(it.get("mac")), it.get("rssi") | |||||
| if bm and rssi is not None: rolling.add(bm, gw, float(rssi)) | |||||
| except: pass | |||||
| mqtt_client = mqtt.Client() | |||||
| mqtt_client.on_message = on_message | |||||
| mqtt_client.connect(mqtt_c["host"], mqtt_c["port"]) | |||||
| mqtt_client.subscribe(mqtt_c["topic"]) | |||||
| mqtt_client.loop_start() | |||||
| last_predict, last_api_refresh = 0.0, 0.0 | |||||
| beacons = [] | |||||
| while True: | |||||
| now = time.time() | |||||
| rolling.prune() | |||||
| if now - last_api_refresh >= float(api_c.get("refresh_seconds", 30)): | |||||
| beacons = fetch_beacons() | |||||
| last_api_refresh = now | |||||
| if now - last_predict >= float(inf_c.get("refresh_seconds", 10.0)): | |||||
| rows, count_ok = [], 0 | |||||
| for bm in beacons: | |||||
| bm_n = _norm_mac(bm) | |||||
| X, n_found = rolling.aggregate_features(bm_n, gateways_ordered, inf_c.get("aggregate", "median"), nan_fill) | |||||
| z, x, y = -1, -1.0, -1.0 | |||||
| if n_found >= int(inf_c.get("min_non_nan", 1)): | |||||
| try: | |||||
| z, x, y = _predict_xyz(model_pkg, X) | |||||
| if z != -1: count_ok += 1 | |||||
| except Exception as e: | |||||
| log(f"Infer error {bm_n}: {e}") | |||||
| rows.append(f"{bm_n};{int(z)};{int(round(x))};{int(round(y))}") | |||||
| try: | |||||
| out_p = Path(inf_c.get("output_csv", "/data/infer/infer.csv")) | |||||
| with open(str(out_p) + ".tmp", "w") as f: | |||||
| f.write("mac;z;x;y\n") | |||||
| for r in rows: f.write(r + "\n") | |||||
| os.replace(str(out_p) + ".tmp", out_p) | |||||
| log(f"CYCLE: {count_ok}/{len(rows)} localized") | |||||
| except Exception as e: log(f"File Error: {e}") | |||||
| last_predict = now | |||||
| time.sleep(0.5) | |||||
| @@ -0,0 +1,58 @@ | |||||
| // leaflet_bridge.js | |||||
| function initMap(config) { | |||||
| const { imgUrl, width, height, meta, grid_size, dots } = config; | |||||
| // Setup Coordinate XY (0,0 in alto a sinistra) | |||||
| const map = L.map('map', { | |||||
| crs: L.CRS.Simple, | |||||
| minZoom: -2, | |||||
| maxZoom: 4, | |||||
| attributionControl: false | |||||
| }); | |||||
| const bounds = [[-height, 0], [0, width]]; | |||||
| L.imageOverlay(imgUrl, bounds).addTo(map); | |||||
| map.fitBounds(bounds); | |||||
| // Gestione Griglia | |||||
| let gridLayer = L.layerGroup(); | |||||
| if (meta.show_grid) { | |||||
| const step = meta.grid_size || 100; | |||||
| const S = meta.pixel_ratio || 1; | |||||
| for (let x = 0; x <= width; x += (step * S)) { | |||||
| L.polyline([[0, x], [-height, x]], {color: '#ccc', weight: 1, opacity: 0.5}).addTo(gridLayer); | |||||
| } | |||||
| for (let y = 0; y >= -height; y -= (step * S)) { | |||||
| L.polyline([[y, 0], [y, width]], {color: '#ccc', weight: 1, opacity: 0.5}).addTo(gridLayer); | |||||
| } | |||||
| gridLayer.addTo(map); | |||||
| } | |||||
| // Visualizzazione punti esistenti (Verdi e Blu) | |||||
| dots.forEach(dot => { | |||||
| const color = dot.status === 'completed' ? '#228B22' : '#0000FF'; | |||||
| L.circleMarker([dot.y, dot.x], { | |||||
| radius: config.dot_size / 5, | |||||
| fillColor: color, | |||||
| color: 'white', | |||||
| weight: 2, | |||||
| fillOpacity: 0.8 | |||||
| }).addTo(map).bindPopup(`X: ${dot.relX}, Y: ${dot.relY}`); | |||||
| }); | |||||
| // Evento Click per rilievo | |||||
| map.on('click', function(e) { | |||||
| const raw_x = e.latlng.lng; | |||||
| const raw_y = e.latlng.lat; // In Leaflet Simple, Y è negativa sotto l'origine | |||||
| // Invio dati a Streamlit | |||||
| window.parent.postMessage({ | |||||
| type: 'streamlit:setComponentValue', | |||||
| value: { | |||||
| x: raw_x, | |||||
| y: raw_y, | |||||
| timestamp: new Date().getTime() | |||||
| } | |||||
| }, '*'); | |||||
| }); | |||||
| } | |||||
| @@ -0,0 +1,36 @@ | |||||
| import urllib3 | |||||
| import pytz | |||||
| import logging | |||||
| import warnings | |||||
| import os | |||||
| from datetime import datetime | |||||
| from typing import Any, Dict | |||||
| # --- SILENZIAMENTO IMMEDIATO --- | |||||
| # Questo agisce prima ancora che 'requests' possa generare warning | |||||
| urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) | |||||
| warnings.filterwarnings("ignore", category=urllib3.exceptions.InsecureRequestWarning) | |||||
| logging.getLogger("urllib3").setLevel(logging.ERROR) | |||||
| # Silenzia anche i warning di sklearn/numpy se necessario | |||||
| os.environ["PYTHONWARNINGS"] = "ignore:Unverified HTTPS request" | |||||
| # Variabile globale per la Timezone | |||||
| _current_tz = pytz.UTC | |||||
| def setup_global_logging(settings: Dict[str, Any]): | |||||
| """Configura la Timezone in base al config.yaml.""" | |||||
| global _current_tz | |||||
| debug_cfg = settings.get("debug", {}) | |||||
| tz_name = debug_cfg.get("timezone", "Europe/Rome") | |||||
| try: | |||||
| _current_tz = pytz.timezone(tz_name) | |||||
| except Exception: | |||||
| _current_tz = pytz.UTC | |||||
| def get_timestamp() -> str: | |||||
| """Restituisce il timestamp formattato con la TZ corretta.""" | |||||
| return datetime.now(_current_tz).strftime('%Y-%m-%dT%H:%M:%S.000Z') | |||||
| def log_msg(msg: str): | |||||
| """Funzione di log standardizzata.""" | |||||
| print(f"{get_timestamp()} {msg}", flush=True) | |||||
| @@ -0,0 +1,817 @@ | |||||
| from .logger_utils import setup_global_logging, log_msg as log | |||||
| import csv | |||||
| import io | |||||
| import json | |||||
| import os | |||||
| import ssl | |||||
| import time | |||||
| import traceback | |||||
| from dataclasses import dataclass | |||||
| from pathlib import Path | |||||
| from typing import Any, Dict, List, Optional, Tuple | |||||
| import hashlib | |||||
| import re | |||||
| import math | |||||
| import statistics | |||||
| import pandas as pd | |||||
| import numpy as np | |||||
| import requests | |||||
| import joblib | |||||
| import paho.mqtt.client as mqtt | |||||
| # Import locali corretti | |||||
| from .settings import load_settings | |||||
| def build_info() -> str: | |||||
| return "infer-debug-v19-fixed" | |||||
| def main() -> None: | |||||
| # 1. Carica impostazioni | |||||
| settings = load_settings() | |||||
| # 2. Setup immediato dei log e dei silenziatori (PRIMA di ogni altra cosa) | |||||
| setup_global_logging(settings) | |||||
| # 3. Ora puoi loggare e tutto sarà sincronizzato e pulito | |||||
| cfg_file = settings.get("_config_file", "/config/config.yaml") | |||||
| keys = [k for k in settings.keys() if not str(k).startswith("_")] | |||||
| log(f"Settings loaded from {cfg_file}. Keys: {keys}") | |||||
| log(f"BUILD: {build_info()}") | |||||
| def mac_plain(s: str) -> str: | |||||
| """Normalizza MAC a 12 hex uppercase senza separatori.""" | |||||
| return re.sub(r"[^0-9A-Fa-f]", "", (s or "")).upper() | |||||
| def mac_colon(s: str) -> str: | |||||
| """MAC in formato AA:BB:CC:DD:EE:FF.""" | |||||
| p = mac_plain(s) | |||||
| if len(p) != 12: | |||||
| return p | |||||
| return ":".join(p[i:i+2] for i in range(0, 12, 2)) | |||||
| def fmt_rssi(v, decimals: int) -> str: | |||||
| """Formatta RSSI come stringa, evitando '-82.0' quando decimals=0.""" | |||||
| if v is None: | |||||
| return "nan" | |||||
| try: | |||||
| fv = float(v) | |||||
| except Exception: | |||||
| return "nan" | |||||
| if math.isnan(fv): | |||||
| return "nan" | |||||
| if decimals <= 0: | |||||
| return str(int(round(fv))) | |||||
| return f"{round(fv, decimals):.{decimals}f}" | |||||
| # ----------------------------- | |||||
| # Build info (printed at startup for traceability) | |||||
| BUILD_ID = "ble-ai-localizer main.py 2026-01-30 build-floatagg-v1" | |||||
| def build_info() -> str: | |||||
| """Return a short build identifier for logs (no external deps, no git required).""" | |||||
| try: | |||||
| p = Path(__file__) | |||||
| data = p.read_bytes() | |||||
| sha = hashlib.sha256(data).hexdigest()[:12] | |||||
| size = p.stat().st_size | |||||
| return f"{BUILD_ID} sha256={sha} size={size}" | |||||
| except Exception: | |||||
| return f"{BUILD_ID} sha256=? size=?" | |||||
| # Settings | |||||
| # ----------------------------- | |||||
| def load_settings() -> Dict[str, Any]: | |||||
| cfg = os.environ.get("CONFIG", "/config/config.yaml") | |||||
| import yaml | |||||
| with open(cfg, "r", encoding="utf-8") as f: | |||||
| data = yaml.safe_load(f) or {} | |||||
| data["_config_file"] = cfg | |||||
| # Normalize config sections: prefer collect_train | |||||
| if "collect_train" not in data and "training" in data: | |||||
| log("WARNING: config usa 'training:' (alias). Consiglio: rinomina in 'collect_train:'") | |||||
| data["collect_train"] = data.get("training", {}) or {} | |||||
| return data | |||||
| # ----------------------------- | |||||
| # MAC helpers | |||||
| # ----------------------------- | |||||
| def norm_mac(mac: str) -> str: | |||||
| """Return MAC as AA:BB:CC:DD:EE:FF (upper), ignoring separators.""" | |||||
| m = (mac or "").strip().replace("-", "").replace(":", "").replace(".", "") | |||||
| m = m.upper() | |||||
| if len(m) != 12: | |||||
| return mac.strip().upper() | |||||
| return ":".join(m[i:i+2] for i in range(0, 12, 2)) | |||||
| # ----------------------------- | |||||
| # CSV write helpers | |||||
| # ----------------------------- | |||||
| def safe_write_csv( | |||||
| path: Path, | |||||
| header: List[str], | |||||
| rows: List[Dict[str, Any]], | |||||
| delimiter: str = ";", | |||||
| rssi_decimals: int = 0, | |||||
| ): | |||||
| """Scrive CSV in modo atomico e formattazione 'umana'. | |||||
| - numeri interi: senza decimali (es. -82 invece di -82.0) | |||||
| - RSSI: arrotondamento controllato da rssi_decimals (0 -> intero, >0 -> N cifre decimali) | |||||
| *si applica solo alle colonne RSSI (dopo mac/x/y/z)* | |||||
| - NaN: 'nan' | |||||
| - colonna 'mac': normalizzata in formato con ':' (es. C3:00:00:57:B9:E7) se passa un MAC valido | |||||
| """ | |||||
| tmp = path.with_suffix(path.suffix + ".tmp") | |||||
| # csv.writer richiede un singolo carattere come delimiter | |||||
| if not isinstance(delimiter, str) or len(delimiter) != 1: | |||||
| delimiter = ";" | |||||
| try: | |||||
| rssi_decimals = int(rssi_decimals) | |||||
| except Exception: | |||||
| rssi_decimals = 0 | |||||
| if rssi_decimals < 0: | |||||
| rssi_decimals = 0 | |||||
| def fmt_cell(v: Any, col: str, idx: int) -> str: | |||||
| if v is None: | |||||
| return "nan" | |||||
| # MAC normalizzato con ':' | |||||
| if col.lower() == "mac" and isinstance(v, str): | |||||
| v2 = mac_colon(v) | |||||
| return v2 | |||||
| # NaN float | |||||
| if isinstance(v, float): | |||||
| if math.isnan(v): | |||||
| return "nan" | |||||
| # colonne RSSI (dopo mac/x/y/z) | |||||
| if idx >= 4: | |||||
| if rssi_decimals == 0: | |||||
| return str(int(round(v))) | |||||
| return f"{v:.{rssi_decimals}f}" | |||||
| # altre colonne: compatta i (quasi) interi | |||||
| if abs(v - round(v)) < 1e-9: | |||||
| return str(int(round(v))) | |||||
| return str(v) | |||||
| # int / numpy int | |||||
| if isinstance(v, (int, np.integer)): | |||||
| # RSSI columns (after mac/x/y/z): respect rssi_decimals even for integer values | |||||
| if idx >= 4: | |||||
| if rssi_decimals == 0: | |||||
| return str(int(v)) | |||||
| return f"{float(v):.{rssi_decimals}f}" | |||||
| return str(int(v)) | |||||
| # numpy float | |||||
| if isinstance(v, np.floating): | |||||
| fv = float(v) | |||||
| if math.isnan(fv): | |||||
| return "nan" | |||||
| if idx >= 4: | |||||
| if rssi_decimals == 0: | |||||
| return str(int(round(fv))) | |||||
| return f"{fv:.{rssi_decimals}f}" | |||||
| if abs(fv - round(fv)) < 1e-9: | |||||
| return str(int(round(fv))) | |||||
| return str(fv) | |||||
| return str(v) | |||||
| with tmp.open("w", newline="") as f: | |||||
| w = csv.writer(f, delimiter=delimiter) | |||||
| w.writerow(header) | |||||
| for row in rows: | |||||
| w.writerow([fmt_cell(row.get(col), col, idx) for idx, col in enumerate(header)]) | |||||
| tmp.replace(path) | |||||
| def _coord_token(v: float) -> str: | |||||
| # Stable token for filenames from coordinates. | |||||
| # - if integer-ish -> '123' | |||||
| # - else keep up to 3 decimals, strip trailing zeros, replace '.' with '_' | |||||
| try: | |||||
| fv=float(v) | |||||
| except Exception: | |||||
| return str(v) | |||||
| if abs(fv - round(fv)) < 1e-9: | |||||
| return str(int(round(fv))) | |||||
| s=f"{fv:.3f}".rstrip('0').rstrip('.') | |||||
| return s.replace('.', '_') | |||||
| def read_job_csv(job_path: Path, delimiter: str) -> List[Dict[str, Any]]: | |||||
| """Legge job CSV supportando due formati: | |||||
| 1) Legacy: | |||||
| mac;x;y;z | |||||
| C3000057B9F4;1200;450;0 | |||||
| 2) Esteso (storico): | |||||
| Position;Floor;RoomName;X;Y;Z;BeaconName;MAC | |||||
| A21;1;P1-NETW;800;1050;1;BC-21;C3:00:00:57:B9:E6 | |||||
| Estrae solo X,Y,Z,MAC e normalizza MAC in formato compatto (senza ':', uppercase). | |||||
| """ | |||||
| text = job_path.read_text(encoding="utf-8", errors="replace") | |||||
| if not text.strip(): | |||||
| return [] | |||||
| first_line = next((ln for ln in text.splitlines() if ln.strip()), "") | |||||
| use_delim = delimiter | |||||
| if use_delim not in first_line: | |||||
| if ";" in first_line and "," not in first_line: | |||||
| use_delim = ";" | |||||
| elif "," in first_line and ";" not in first_line: | |||||
| use_delim = "," | |||||
| def hnorm(h: str) -> str: | |||||
| h = (h or "").strip().lower() | |||||
| h = re_sub_non_alnum(h) | |||||
| return h | |||||
| f = io.StringIO(text) | |||||
| r = csv.reader(f, delimiter=use_delim) | |||||
| header = next(r, None) | |||||
| if not header: | |||||
| return [] | |||||
| header_norm = [hnorm(h) for h in header] | |||||
| idx = {name: i for i, name in enumerate(header_norm) if name} | |||||
| def find_idx(names: List[str]) -> Optional[int]: | |||||
| for n in names: | |||||
| if n in idx: | |||||
| return idx[n] | |||||
| return None | |||||
| mac_i = find_idx(["mac", "beaconmac", "beacon_mac", "trackermac", "tracker_mac", "device", "devicemac"]) | |||||
| x_i = find_idx(["x"]) | |||||
| y_i = find_idx(["y"]) | |||||
| z_i = find_idx(["z"]) | |||||
| if mac_i is None or x_i is None or y_i is None or z_i is None: | |||||
| raise ValueError( | |||||
| f"Job CSV header non riconosciuto: {header}. " | |||||
| f"Attesi campi MAC/X/Y/Z (case-insensitive)." | |||||
| ) | |||||
| rows: List[Dict[str, Any]] = [] | |||||
| for cols in r: | |||||
| if not cols: | |||||
| continue | |||||
| if len(cols) <= max(mac_i, x_i, y_i, z_i): | |||||
| continue | |||||
| mac_raw = (cols[mac_i] or "").strip() | |||||
| if not mac_raw: | |||||
| continue | |||||
| mac_compact = norm_mac(mac_raw).replace(":", "") | |||||
| try: | |||||
| x = float((cols[x_i] or "").strip()) | |||||
| y = float((cols[y_i] or "").strip()) | |||||
| z = float((cols[z_i] or "").strip()) | |||||
| except Exception: | |||||
| continue | |||||
| rows.append({"mac": mac_compact, "x": x, "y": y, "z": z}) | |||||
| return rows | |||||
| def re_sub_non_alnum(s: str) -> str: | |||||
| out = [] | |||||
| for ch in s: | |||||
| if ("a" <= ch <= "z") or ("0" <= ch <= "9"): | |||||
| out.append(ch) | |||||
| return "".join(out) | |||||
| def write_samples_csv( | |||||
| out_path: Path, | |||||
| sample_rows: List[Dict[str, Any]], | |||||
| gateway_macs: List[str], | |||||
| *, | |||||
| delimiter: str = ";", | |||||
| rssi_decimals: int = 0, | |||||
| ) -> None: | |||||
| header = ["mac", "x", "y", "z"] + gateway_macs | |||||
| safe_write_csv(out_path, header, sample_rows, delimiter=delimiter, rssi_decimals=rssi_decimals) | |||||
| def load_gateway_csv(path: Path, delimiter: str = ";") -> Tuple[List[str], int, int]: | |||||
| df = pd.read_csv(path, delimiter=delimiter) | |||||
| cols = [c.strip().lower() for c in df.columns] | |||||
| df.columns = cols | |||||
| invalid = 0 | |||||
| macs: List[str] = [] | |||||
| seen = set() | |||||
| if "mac" not in df.columns: | |||||
| raise ValueError(f"gateway.csv must have a 'mac' column, got columns={list(df.columns)}") | |||||
| for v in df["mac"].astype(str).tolist(): | |||||
| nm = norm_mac(v) | |||||
| if len(nm.replace(":", "")) != 12: | |||||
| invalid += 1 | |||||
| continue | |||||
| if nm in seen: | |||||
| continue | |||||
| seen.add(nm) | |||||
| macs.append(nm) | |||||
| duplicates = max(0, len(df) - invalid - len(macs)) | |||||
| return macs, invalid, duplicates | |||||
| # ----------------------------- | |||||
| # Fingerprint collector | |||||
| # ----------------------------- | |||||
| @dataclass | |||||
| class FingerprintStats: | |||||
| counts: Dict[str, Dict[str, int]] | |||||
| last: Dict[str, Dict[str, float]] | |||||
| class FingerprintCollector: | |||||
| def __init__(self) -> None: | |||||
| self._lock = None | |||||
| try: | |||||
| import threading | |||||
| self._lock = threading.Lock() | |||||
| except Exception: | |||||
| self._lock = None | |||||
| # beacon_norm -> gw_norm -> list of rssi | |||||
| self.rssi: Dict[str, Dict[str, List[float]]] = {} | |||||
| self.last_seen_gw: Dict[str, float] = {} | |||||
| self.last_seen_beacon: Dict[str, float] = {} | |||||
| def _with_lock(self): | |||||
| if self._lock is None: | |||||
| class Dummy: | |||||
| def __enter__(self): return None | |||||
| def __exit__(self, *a): return False | |||||
| return Dummy() | |||||
| return self._lock | |||||
| def update(self, gw_mac: str, beacon_mac: str, rssi: float) -> None: | |||||
| gw = norm_mac(gw_mac) | |||||
| b = norm_mac(beacon_mac) | |||||
| now = time.time() | |||||
| with self._with_lock(): | |||||
| self.last_seen_gw[gw] = now | |||||
| self.last_seen_beacon[b] = now | |||||
| self.rssi.setdefault(b, {}).setdefault(gw, []).append(float(rssi)) | |||||
| def stats(self, beacons: List[str], gateways: List[str]) -> FingerprintStats: | |||||
| with self._with_lock(): | |||||
| counts: Dict[str, Dict[str, int]] = {b: {g: 0 for g in gateways} for b in beacons} | |||||
| last: Dict[str, Dict[str, float]] = {b: {g: float("nan") for g in gateways} for b in beacons} | |||||
| for b in beacons: | |||||
| bm = norm_mac(b) | |||||
| for g in gateways: | |||||
| gm = norm_mac(g) | |||||
| vals = self.rssi.get(bm, {}).get(gm, []) | |||||
| counts[bm][gm] = len(vals) | |||||
| if vals: | |||||
| last[bm][gm] = vals[-1] | |||||
| return FingerprintStats(counts=counts, last=last) | |||||
| def feature_row( | |||||
| self, | |||||
| beacon_mac: str, | |||||
| gateways: List[str], | |||||
| aggregate: str, | |||||
| rssi_min: float, | |||||
| rssi_max: float, | |||||
| min_samples_per_gateway: int, | |||||
| outlier_method: str, | |||||
| mad_z: float, | |||||
| iqr_k: float, | |||||
| max_stddev: Optional[float], | |||||
| ) -> Dict[str, float]: | |||||
| b = norm_mac(beacon_mac) | |||||
| out: Dict[str, float] = {} | |||||
| with self._with_lock(): | |||||
| for g in gateways: | |||||
| gm = norm_mac(g) | |||||
| vals = list(self.rssi.get(b, {}).get(gm, [])) | |||||
| # hard clamp | |||||
| vals = [v for v in vals if (rssi_min <= v <= rssi_max)] | |||||
| if len(vals) < min_samples_per_gateway: | |||||
| out[gm] = float("nan") | |||||
| continue | |||||
| # outlier removal | |||||
| vals2 = vals | |||||
| if outlier_method == "mad": | |||||
| vals2 = mad_filter(vals2, z=mad_z) | |||||
| elif outlier_method == "iqr": | |||||
| vals2 = iqr_filter(vals2, k=iqr_k) | |||||
| if len(vals2) < min_samples_per_gateway: | |||||
| out[gm] = float("nan") | |||||
| continue | |||||
| if max_stddev is not None: | |||||
| import statistics | |||||
| try: | |||||
| sd = statistics.pstdev(vals2) | |||||
| if sd > max_stddev: | |||||
| out[gm] = float("nan") | |||||
| continue | |||||
| except Exception: | |||||
| pass | |||||
| # Aggregate: mantieni float (niente cast a int) per poter usare rssi_decimals. | |||||
| if aggregate == "median": | |||||
| out[gm] = float(statistics.median(vals2)) | |||||
| elif aggregate == "median_low": | |||||
| out[gm] = float(statistics.median_low(sorted(vals2))) | |||||
| elif aggregate == "median_high": | |||||
| out[gm] = float(statistics.median_high(sorted(vals2))) | |||||
| elif aggregate == "mean": | |||||
| out[gm] = float(statistics.fmean(vals2)) | |||||
| else: | |||||
| out[gm] = float(statistics.median(vals2)) | |||||
| return out | |||||
| def mad_filter(vals: List[float], z: float = 3.5) -> List[float]: | |||||
| if not vals: | |||||
| return vals | |||||
| s = pd.Series(vals) | |||||
| med = s.median() | |||||
| mad = (s - med).abs().median() | |||||
| if mad == 0: | |||||
| return vals | |||||
| mz = 0.6745 * (s - med).abs() / mad | |||||
| return [float(v) for v, keep in zip(vals, (mz <= z).tolist()) if keep] | |||||
| def iqr_filter(vals: List[float], k: float = 1.5) -> List[float]: | |||||
| if not vals: | |||||
| return vals | |||||
| s = pd.Series(vals) | |||||
| q1 = s.quantile(0.25) | |||||
| q3 = s.quantile(0.75) | |||||
| iqr = q3 - q1 | |||||
| if iqr == 0: | |||||
| return vals | |||||
| lo = q1 - k * iqr | |||||
| hi = q3 + k * iqr | |||||
| return [float(v) for v in vals if lo <= v <= hi] | |||||
| # ----------------------------- | |||||
| # MQTT parsing | |||||
| # ----------------------------- | |||||
| def parse_topic_gateway(topic: str) -> Optional[str]: | |||||
| # expected: publish_out/<gwmac> | |||||
| parts = (topic or "").split("/") | |||||
| if len(parts) < 2: | |||||
| return None | |||||
| return parts[-1] | |||||
| def parse_payload_list(payload: bytes) -> Optional[List[Dict[str, Any]]]: | |||||
| try: | |||||
| obj = json.loads(payload.decode("utf-8", errors="replace")) | |||||
| if isinstance(obj, list): | |||||
| return obj | |||||
| return None | |||||
| except Exception: | |||||
| return None | |||||
| def is_gateway_announce(item: Dict[str, Any]) -> bool: | |||||
| return str(item.get("type", "")).strip().lower() == "gateway" and "mac" in item | |||||
| # ----------------------------- | |||||
| # Collect train | |||||
| # ----------------------------- | |||||
| def run_collect_train(settings: Dict[str, Any]) -> None: | |||||
| cfg = settings.get("collect_train", {}) or {} | |||||
| paths = settings.get("paths", {}) or {} | |||||
| mqtt_cfg = settings.get("mqtt", {}) or {} | |||||
| debug = settings.get("debug", {}) or {} | |||||
| window_seconds = float(cfg.get("window_seconds", 180)) | |||||
| poll_seconds = float(cfg.get("poll_seconds", 2)) | |||||
| min_non_nan = int(cfg.get("min_non_nan", 3)) | |||||
| min_samples_per_gateway = int(cfg.get("min_samples_per_gateway", 5)) | |||||
| aggregate = str(cfg.get("aggregate", "median")) | |||||
| # Numero di cifre decimali per i valori RSSI nei file samples (0 = intero) | |||||
| try: | |||||
| rssi_decimals = int(cfg.get("rssi_decimals", 0)) | |||||
| except Exception: | |||||
| rssi_decimals = 0 | |||||
| if rssi_decimals < 0: | |||||
| rssi_decimals = 0 | |||||
| rssi_min = float(cfg.get("rssi_min", -110)) | |||||
| rssi_max = float(cfg.get("rssi_max", -25)) | |||||
| outlier_method = str(cfg.get("outlier_method", "mad")) | |||||
| mad_z = float(cfg.get("mad_z", 3.5)) | |||||
| iqr_k = float(cfg.get("iqr_k", 1.5)) | |||||
| max_stddev = cfg.get("max_stddev", None) | |||||
| max_stddev = float(max_stddev) if max_stddev is not None else None | |||||
| gateway_csv = Path(paths.get("gateways_csv", "/data/config/gateway.csv")) | |||||
| csv_delimiter = str(paths.get("csv_delimiter", ";")) | |||||
| jobs_dir = Path(cfg.get("jobs_dir", "/data/train/jobs")) | |||||
| pending_dir = jobs_dir / "pending" | |||||
| done_dir = jobs_dir / "done" | |||||
| error_dir = jobs_dir / "error" | |||||
| samples_dir = Path(cfg.get("samples_dir", "/data/train/samples")) | |||||
| pending_dir.mkdir(parents=True, exist_ok=True) | |||||
| done_dir.mkdir(parents=True, exist_ok=True) | |||||
| error_dir.mkdir(parents=True, exist_ok=True) | |||||
| samples_dir.mkdir(parents=True, exist_ok=True) | |||||
| gw_ready_log_seconds = float(cfg.get("gw_ready_log_seconds", 10)) | |||||
| gw_ready_sleep_seconds = float(cfg.get("gw_ready_sleep_seconds", 5)) | |||||
| gw_ready_check_before_job = bool(cfg.get("gw_ready_check_before_job", True)) | |||||
| online_max_age_s = float(debug.get("online_check_seconds", 30)) | |||||
| progress_log_seconds = float(cfg.get("wait_all_gateways_log_seconds", 30)) | |||||
| gateway_macs, invalid, duplicates = load_gateway_csv(gateway_csv, delimiter=csv_delimiter) | |||||
| log(f"[gateway.csv] loaded gateways={len(gateway_macs)} invalid={invalid} duplicates={duplicates}") | |||||
| log( | |||||
| "COLLECT_TRAIN config: gateway_csv=%s gateways(feature-set)=%d window_seconds=%.1f poll_seconds=%.1f rssi_decimals=%d jobs_dir=%s " | |||||
| "pending_dir=%s done_dir=%s error_dir=%s samples_dir=%s mqtt=%s:%s topic=%s" | |||||
| % ( | |||||
| gateway_csv, | |||||
| len(gateway_macs), | |||||
| window_seconds, | |||||
| poll_seconds, | |||||
| rssi_decimals, | |||||
| jobs_dir, | |||||
| pending_dir, | |||||
| done_dir, | |||||
| error_dir, | |||||
| samples_dir, | |||||
| mqtt_cfg.get("host", ""), | |||||
| mqtt_cfg.get("port", ""), | |||||
| mqtt_cfg.get("topic", "publish_out/#"), | |||||
| ) | |||||
| ) | |||||
| fp = FingerprintCollector() | |||||
| # MQTT setup | |||||
| host = mqtt_cfg.get("host", "127.0.0.1") | |||||
| port = int(mqtt_cfg.get("port", 1883)) | |||||
| topic = mqtt_cfg.get("topic", "publish_out/#") | |||||
| client_id = mqtt_cfg.get("client_id", "ble-ai-localizer") | |||||
| keepalive = int(mqtt_cfg.get("keepalive", 60)) | |||||
| proto = mqtt.MQTTv311 | |||||
| def on_connect(client, userdata, flags, rc): | |||||
| log(f"MQTT connected rc={rc}, subscribed to {topic}") | |||||
| client.subscribe(topic) | |||||
| def on_message(client, userdata, msg): | |||||
| gw_from_topic = parse_topic_gateway(msg.topic) | |||||
| if not gw_from_topic: | |||||
| return | |||||
| payload_list = parse_payload_list(msg.payload) | |||||
| if not payload_list: | |||||
| return | |||||
| for it in payload_list: | |||||
| if not isinstance(it, dict): | |||||
| continue | |||||
| if is_gateway_announce(it): | |||||
| gwm = it.get("mac", gw_from_topic) | |||||
| fp.last_seen_gw[norm_mac(gwm)] = time.time() | |||||
| continue | |||||
| bmac = it.get("mac") | |||||
| rssi = it.get("rssi") | |||||
| if not bmac or rssi is None: | |||||
| continue | |||||
| try: | |||||
| fp.update(gw_from_topic, bmac, float(rssi)) | |||||
| except Exception: | |||||
| continue | |||||
| client = mqtt.Client(client_id=client_id, protocol=proto) | |||||
| client.on_connect = on_connect | |||||
| client.on_message = on_message | |||||
| username = str(mqtt_cfg.get("username", "") or "") | |||||
| password = str(mqtt_cfg.get("password", "") or "") | |||||
| if username: | |||||
| client.username_pw_set(username, password) | |||||
| tls = bool(mqtt_cfg.get("tls", False)) | |||||
| if tls: | |||||
| client.tls_set(cert_reqs=ssl.CERT_NONE) | |||||
| client.tls_insecure_set(True) | |||||
| log("MQTT thread started (collect_train)") | |||||
| client.connect(host, port, keepalive=keepalive) | |||||
| client.loop_start() | |||||
| # Wait gateways online | |||||
| last_ready_log = 0.0 | |||||
| while True: | |||||
| now = time.time() | |||||
| online = 0 | |||||
| missing = [] | |||||
| for g in gateway_macs: | |||||
| seen = fp.last_seen_gw.get(norm_mac(g)) | |||||
| if seen is not None and (now - seen) <= online_max_age_s: | |||||
| online += 1 | |||||
| else: | |||||
| missing.append(norm_mac(g)) | |||||
| if online == len(gateway_macs): | |||||
| log(f"GW READY: online={online}/{len(gateway_macs)} (max_age_s={online_max_age_s:.1f})") | |||||
| break | |||||
| if now - last_ready_log >= gw_ready_log_seconds: | |||||
| log(f"WAIT gateways online ({len(missing)} missing, seen={online}/{len(gateway_macs)}): {missing} (max_age_s={online_max_age_s:.1f})") | |||||
| last_ready_log = now | |||||
| time.sleep(gw_ready_sleep_seconds) | |||||
| # Job loop | |||||
| while True: | |||||
| try: | |||||
| # periodic gw ready log | |||||
| now = time.time() | |||||
| if now - last_ready_log >= gw_ready_log_seconds: | |||||
| online = 0 | |||||
| for g in gateway_macs: | |||||
| seen = fp.last_seen_gw.get(norm_mac(g)) | |||||
| if seen is not None and (now - seen) <= online_max_age_s: | |||||
| online += 1 | |||||
| log(f"GW READY: online={online}/{len(gateway_macs)} (max_age_s={online_max_age_s:.1f})") | |||||
| last_ready_log = now | |||||
| # pick job | |||||
| job_files = sorted(pending_dir.glob("*.csv")) | |||||
| if not job_files: | |||||
| time.sleep(poll_seconds) | |||||
| continue | |||||
| job_path = job_files[0] | |||||
| job_name = job_path.name | |||||
| rows = read_job_csv(job_path, delimiter=csv_delimiter) | |||||
| if not rows: | |||||
| # move empty/bad jobs to error | |||||
| log(f"TRAIN job ERROR: {job_name} err=EmptyJob: no valid rows") | |||||
| job_path.rename(error_dir / job_path.name) | |||||
| continue | |||||
| # normalize beacons for stats keys | |||||
| job_beacons_norm = [norm_mac(r["mac"]) for r in rows] | |||||
| # optionally wait gateways online before starting the window | |||||
| if gw_ready_check_before_job: | |||||
| while True: | |||||
| now = time.time() | |||||
| online = 0 | |||||
| missing = [] | |||||
| for g in gateway_macs: | |||||
| seen = fp.last_seen_gw.get(norm_mac(g)) | |||||
| if seen is not None and (now - seen) <= online_max_age_s: | |||||
| online += 1 | |||||
| else: | |||||
| missing.append(norm_mac(g)) | |||||
| if online == len(gateway_macs): | |||||
| break | |||||
| log(f"WAIT gateways online before job ({len(missing)} missing, seen={online}/{len(gateway_macs)}): {missing}") | |||||
| time.sleep(1.0) | |||||
| log(f"TRAIN job START: {job_name} beacons={len(rows)}") | |||||
| start = time.time() | |||||
| deadline = start + window_seconds | |||||
| next_progress = start + progress_log_seconds | |||||
| while time.time() < deadline: | |||||
| time.sleep(0.5) | |||||
| if progress_log_seconds > 0 and time.time() >= next_progress: | |||||
| st = fp.stats(job_beacons_norm, gateway_macs) | |||||
| parts = [] | |||||
| for b in job_beacons_norm: | |||||
| total = sum(st.counts[b].values()) | |||||
| gw_seen = sum(1 for g in gateway_macs if st.counts[b][g] > 0) | |||||
| parts.append(f"{b.replace(':','')}: total={total} gw={gw_seen}/{len(gateway_macs)}") | |||||
| elapsed = int(time.time() - start) | |||||
| log(f"COLLECT progress: {elapsed}s/{int(window_seconds)}s " + " | ".join(parts)) | |||||
| next_progress = time.time() + progress_log_seconds | |||||
| out_rows: List[Dict[str, Any]] = [] | |||||
| st = fp.stats(job_beacons_norm, gateway_macs) | |||||
| for r, b_norm in zip(rows, job_beacons_norm): | |||||
| feats = fp.feature_row( | |||||
| beacon_mac=b_norm, | |||||
| gateways=gateway_macs, | |||||
| aggregate=aggregate, | |||||
| rssi_min=rssi_min, | |||||
| rssi_max=rssi_max, | |||||
| min_samples_per_gateway=min_samples_per_gateway, | |||||
| outlier_method=outlier_method, | |||||
| mad_z=mad_z, | |||||
| iqr_k=iqr_k, | |||||
| max_stddev=max_stddev, | |||||
| ) | |||||
| non_nan = sum(1 for g in gateway_macs if feats.get(g) == feats.get(g)) | |||||
| if non_nan < min_non_nan: | |||||
| sample_info = [] | |||||
| for g in gateway_macs: | |||||
| c = st.counts[b_norm][g] | |||||
| if c > 0: | |||||
| sample_info.append(f"{g} n={c} last={st.last[b_norm][g]}") | |||||
| preview = ", ".join(sample_info[:8]) + (" ..." if len(sample_info) > 8 else "") | |||||
| log( | |||||
| f"WARNING: beacon {b_norm.replace(':','')} low features non_nan={non_nan} " | |||||
| f"(seen_gw={sum(1 for g in gateway_macs if st.counts[b_norm][g]>0)}) [{preview}]" | |||||
| ) | |||||
| out_row: Dict[str, Any] = { | |||||
| "mac": r["mac"], # MAC sempre compatto, senza ':' | |||||
| "x": float(r["x"]), | |||||
| "y": float(r["y"]), | |||||
| "z": float(r["z"]), | |||||
| } | |||||
| out_row.update(feats) | |||||
| out_rows.append(out_row) | |||||
| written = [] | |||||
| for out_row in out_rows: | |||||
| # Nome file: Z_X_Y.csv (Z, X, Y presi dal job) | |||||
| zt = _coord_token(out_row.get("z")) | |||||
| xt = _coord_token(out_row.get("x")) | |||||
| yt = _coord_token(out_row.get("y")) | |||||
| base_name = f"{zt}_{xt}_{yt}.csv" | |||||
| out_path = samples_dir / base_name | |||||
| write_samples_csv(out_path, [out_row], gateway_macs, delimiter=csv_delimiter, rssi_decimals=rssi_decimals) | |||||
| written.append(out_path.name) | |||||
| job_path.rename(done_dir / job_path.name) | |||||
| if written: | |||||
| shown = ", ".join(written[:10]) | |||||
| more = "" if len(written) <= 10 else f" (+{len(written)-10} altri)" | |||||
| log(f"TRAIN job DONE: wrote {len(written)} sample files to {samples_dir}: {shown}{more}") | |||||
| else: | |||||
| log(f"TRAIN job DONE: no output rows (empty job?)") | |||||
| except Exception as e: | |||||
| log(f"TRAIN job ERROR: {job_name} err={type(e).__name__}: {e}") | |||||
| try: | |||||
| job_path.rename(error_dir / job_path.name) | |||||
| except Exception: | |||||
| pass | |||||
| time.sleep(0.5) | |||||
| def main() -> None: | |||||
| settings = load_settings() | |||||
| cfg_file = settings.get("_config_file", "") | |||||
| keys = [k for k in settings.keys() if not str(k).startswith("_")] | |||||
| log(f"Settings loaded from {cfg_file}. Keys: {keys}") | |||||
| log(f"BUILD: {build_info()}") | |||||
| mode = str(settings.get("mode", "collect_train")).strip().lower() | |||||
| if mode == "collect_train": | |||||
| run_collect_train(settings) | |||||
| return | |||||
| if mode == "train": | |||||
| from .train_mode import run_train | |||||
| run_train(settings) | |||||
| return | |||||
| if mode == "infer": | |||||
| from .infer_mode import run_infer | |||||
| run_infer(settings) | |||||
| return | |||||
| raise ValueError(f"unknown mode: {mode}") | |||||
| if __name__ == "__main__": | |||||
| main() | |||||
| @@ -0,0 +1,49 @@ | |||||
| import base64 | |||||
| from io import BytesIO | |||||
| def get_image_base64(img): | |||||
| buffered = BytesIO() | |||||
| img.save(buffered, format="PNG") | |||||
| img_str = base64.b64encode(buffered.getvalue()).decode() | |||||
| return f"data:image/png;base64,{img_str}" | |||||
| def show_mapper_v2(cfg): | |||||
| # ... (caricamento meta e percorsi come nel tuo file originale) ... | |||||
| img_path = MAPS_DIR / f"{cfg['maps']['floor_prefix']}{floor_id}.png" | |||||
| img = Image.open(img_path).convert("RGBA") | |||||
| img_width, img_height = img.size | |||||
| img_b64 = get_image_base64(img) | |||||
| # Prepariamo la lista dei punti esistenti (Punto 6 delle specifiche) | |||||
| dots_data = [] | |||||
| # Qui cicla sui tuoi file CSV e popola dots_data con {x, y, relX, relY, status} | |||||
| # Integrazione del componente HTML | |||||
| # Carichiamo il JS dal file esterno | |||||
| with open("leaflet_bridge.js", "r") as f: | |||||
| js_code = f.read() | |||||
| html_content = f""" | |||||
| <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" /> | |||||
| <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> | |||||
| <div id="map" style="height: 600px; width: 100%;"></div> | |||||
| <script> | |||||
| {js_code} | |||||
| initMap({{ | |||||
| imgUrl: "{img_b64}", | |||||
| width: {img_width}, | |||||
| height: {img_height}, | |||||
| meta: {json.dumps(meta)}, | |||||
| dots: {json.dumps(dots_data)}, | |||||
| dot_size: {dot_size} | |||||
| }}); | |||||
| </script> | |||||
| """ | |||||
| # Il componente restituisce il valore di window.parent.postMessage | |||||
| result = components.html(html_content, height=650) | |||||
| if result: | |||||
| st.write(f"Posizione catturata: {result}") | |||||
| # Qui inserisci la tua logica di salvataggio CSV che avevi nel punto 7 | |||||
| @@ -0,0 +1,145 @@ | |||||
| import os | |||||
| import json | |||||
| import streamlit as st | |||||
| import pandas as pd | |||||
| from PIL import Image, ImageDraw | |||||
| from pathlib import Path | |||||
| from streamlit_drawable_canvas import st_canvas | |||||
| import time | |||||
| def show_mapper(cfg): | |||||
| # --- 1. CONFIGURAZIONE PERCORSI --- | |||||
| MAPS_DIR = Path(cfg['maps']['map_dir']) | |||||
| SAMPLES_DIR = Path(cfg['train']['samples_dir']) | |||||
| PENDING_DIR = Path(cfg['collect_train']['jobs_dir']) / "pending" | |||||
| BEACONS_FILE = "/data/config/beacons.csv" | |||||
| [p.mkdir(parents=True, exist_ok=True) for p in [MAPS_DIR, SAMPLES_DIR, PENDING_DIR]] | |||||
| def load_map_metadata(f_id): | |||||
| meta_path = MAPS_DIR / f"{cfg['maps']['meta_prefix']}{f_id}.json" | |||||
| defaults = {"pixel_ratio": 1.0, "calibrated": False, "origin": [0, 0], "grid_size": 50} | |||||
| if meta_path.exists(): | |||||
| with open(meta_path, "r") as f: return {**defaults, **json.load(f)} | |||||
| return defaults | |||||
| st.subheader("🗺️ Gestione Mappatura e Rilevamento") | |||||
| # Selezione Piano | |||||
| maps = sorted([f.replace(cfg['maps']['floor_prefix'], "").split('.')[0] for f in os.listdir(MAPS_DIR) if f.startswith(cfg['maps']['floor_prefix'])]) | |||||
| if not maps: st.error("Nessuna mappa trovata."); return | |||||
| floor_id = st.selectbox("Seleziona Piano (Z):", maps) | |||||
| meta = load_map_metadata(floor_id) | |||||
| # --- 2. STATO SISTEMA --- | |||||
| c_s1, c_s2 = st.columns(2) | |||||
| with c_s1: | |||||
| st.info(f"📏 Scala: {'✅' if meta['calibrated'] else '❌'} ({meta['pixel_ratio']:.4f} px/cm)") | |||||
| with c_s2: | |||||
| st.info(f"🎯 Origine: {'✅' if meta['origin'] != [0,0] else '❌'} (X:{meta['origin'][0]}, Y:{meta['origin'][1]})") | |||||
| # --- 3. CONTROLLI VISUALIZZAZIONE --- | |||||
| with st.expander("🎨 Opzioni Visualizzazione", expanded=True): | |||||
| c_v1, c_v2, c_v3 = st.columns(3) | |||||
| zoom = c_v1.slider("🔍 Zoom Mappa", 0.1, 4.0, 1.0, 0.1) | |||||
| dot_size = c_v2.slider("🔵/🟢 Dimensione Punti", 10, 100, 40) | |||||
| mode = c_v3.radio("Modalità Interazione:", ["🖐️ NAVIGA", "🎯 AZIONE"], horizontal=True) | |||||
| # --- 4. SELEZIONE STRUMENTO --- | |||||
| t1, t2, t3 = st.columns(3) | |||||
| if t1.button("📏 IMPOSTA SCALA", use_container_width=True): st.session_state.map_tool = "Calibra" | |||||
| if t2.button("🎯 SET ORIGINE", use_container_width=True): st.session_state.map_tool = "Origine" | |||||
| is_ready = meta.get("calibrated", False) and meta["origin"] != [0, 0] | |||||
| if t3.button("📡 RILEVA", use_container_width=True, disabled=not is_ready): st.session_state.map_tool = "Rileva" | |||||
| tool = st.session_state.get('map_tool', 'Rileva') | |||||
| st.write(f"Strumento attivo: **{tool}**") | |||||
| # --- 5. PREPARAZIONE IMMAGINE E STORICO --- | |||||
| img_path = MAPS_DIR / f"{cfg['maps']['floor_prefix']}{floor_id}.png" | |||||
| if not img_path.exists(): img_path = MAPS_DIR / f"{cfg['maps']['floor_prefix']}{floor_id}.jpg" | |||||
| img = Image.open(img_path).convert("RGBA") | |||||
| draw = ImageDraw.Draw(img) | |||||
| if is_ready: | |||||
| samples_files = list(SAMPLES_DIR.glob(f"{floor_id}_*.csv")) | |||||
| pending_files = list(PENDING_DIR.glob(f"{floor_id}_*.csv")) | |||||
| def draw_points(files, color): | |||||
| for f in files: | |||||
| try: | |||||
| p = f.stem.split("_") | |||||
| px = (int(p[1]) * meta["pixel_ratio"]) + meta["origin"][0] | |||||
| py = (int(p[2]) * meta["pixel_ratio"]) + meta["origin"][1] | |||||
| r = dot_size // 2 | |||||
| draw.ellipse([px-r, py-r, px+r, py+r], fill=color, outline="white", width=2) | |||||
| except: continue | |||||
| draw_points(samples_files, "#228B22") # Verde: Completati | |||||
| draw_points(pending_files, "#0000FF") # Blu: In corso | |||||
| # --- 6. CANVAS --- | |||||
| d_mode = "transform" if mode == "🖐️ NAVIGA" else ("line" if tool == "Calibra" else "point") | |||||
| canvas_result = st_canvas( | |||||
| background_image=img, | |||||
| height=int(img.size[1] * zoom), | |||||
| width=int(img.size[0] * zoom), | |||||
| drawing_mode=d_mode, | |||||
| display_toolbar=True, | |||||
| update_streamlit=True, | |||||
| key=f"canvas_v10_{floor_id}_{zoom}_{mode}_{tool}", | |||||
| ) | |||||
| # --- 7. LOGICA AZIONI E SALVATAGGIO CSV --- | |||||
| if mode == "🎯 AZIONE" and canvas_result.json_data and canvas_result.json_data["objects"]: | |||||
| last = canvas_result.json_data["objects"][-1] | |||||
| raw_x, raw_y = last["left"] / zoom, last["top"] / zoom | |||||
| if tool == "Calibra" and last["type"] == "line": | |||||
| px_dist = ((last["x2"] - last["x1"])**2 + (last["y2"] - last["y1"])**2)**0.5 / zoom | |||||
| dist_cm = st.number_input("Distanza reale (cm):", value=100.0) | |||||
| if st.button("APPLICA SCALA"): | |||||
| meta.update({"pixel_ratio": px_dist / dist_cm, "calibrated": True}) | |||||
| with open(MAPS_DIR / f"{cfg['maps']['meta_prefix']}{floor_id}.json", "w") as f: | |||||
| json.dump(meta, f) | |||||
| st.rerun() | |||||
| elif tool == "Origine": | |||||
| if st.button(f"Fissa Origine qui: {int(raw_x)}, {int(raw_y)}"): | |||||
| meta["origin"] = [int(raw_x), int(raw_y)] | |||||
| with open(MAPS_DIR / f"{cfg['maps']['meta_prefix']}{floor_id}.json", "w") as f: | |||||
| json.dump(meta, f) | |||||
| st.rerun() | |||||
| elif tool == "Rileva" and is_ready: | |||||
| dx = (raw_x - meta["origin"][0]) / meta["pixel_ratio"] | |||||
| dy = (raw_y - meta["origin"][1]) / meta["pixel_ratio"] | |||||
| grid = meta.get("grid_size", 50) | |||||
| sx, sy = int(round(dx/grid)*grid), int(round(dy/grid)*grid) | |||||
| st.write(f"### 📍 Punto: {sx}cm, {sy}cm") | |||||
| if os.path.exists(BEACONS_FILE): | |||||
| b_df = pd.read_csv(BEACONS_FILE, sep=";") | |||||
| sel_b = st.selectbox("Seleziona Beacon:", b_df.apply(lambda x: f"{x['BeaconName']} | {x['MAC']}", axis=1)) | |||||
| if st.button("🚀 REGISTRA LETTURA", type="primary", use_container_width=True): | |||||
| b_name, b_mac = sel_b.split(" | ") | |||||
| fname = f"{floor_id}_{sx}_{sy}.csv" | |||||
| # --- FIX SINTASSI CSV --- | |||||
| # Formato: Position;Floor;RoomName;X;Y;Z;BeaconName;MAC | |||||
| data_out = { | |||||
| "Position": f"P_{sx}_{sy}", | |||||
| "Floor": floor_id, | |||||
| "RoomName": "Area_Rilevazione", | |||||
| "X": sx, | |||||
| "Y": sy, | |||||
| "Z": 0, | |||||
| "BeaconName": b_name, | |||||
| "MAC": b_mac | |||||
| } | |||||
| pd.DataFrame([data_out]).to_csv(PENDING_DIR / fname, index=False, sep=";") | |||||
| st.toast(f"Punto Blu registrato!") | |||||
| time.sleep(1); st.rerun() | |||||
| @@ -0,0 +1,49 @@ | |||||
| import base64 | |||||
| from io import BytesIO | |||||
| def get_image_base64(img): | |||||
| buffered = BytesIO() | |||||
| img.save(buffered, format="PNG") | |||||
| img_str = base64.b64encode(buffered.getvalue()).decode() | |||||
| return f"data:image/png;base64,{img_str}" | |||||
| def show_mapper_v2(cfg): | |||||
| # ... (caricamento meta e percorsi come nel tuo file originale) ... | |||||
| img_path = MAPS_DIR / f"{cfg['maps']['floor_prefix']}{floor_id}.png" | |||||
| img = Image.open(img_path).convert("RGBA") | |||||
| img_width, img_height = img.size | |||||
| img_b64 = get_image_base64(img) | |||||
| # Prepariamo la lista dei punti esistenti (Punto 6 delle specifiche) | |||||
| dots_data = [] | |||||
| # Qui cicla sui tuoi file CSV e popola dots_data con {x, y, relX, relY, status} | |||||
| # Integrazione del componente HTML | |||||
| # Carichiamo il JS dal file esterno | |||||
| with open("leaflet_bridge.js", "r") as f: | |||||
| js_code = f.read() | |||||
| html_content = f""" | |||||
| <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" /> | |||||
| <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> | |||||
| <div id="map" style="height: 600px; width: 100%;"></div> | |||||
| <script> | |||||
| {js_code} | |||||
| initMap({{ | |||||
| imgUrl: "{img_b64}", | |||||
| width: {img_width}, | |||||
| height: {img_height}, | |||||
| meta: {json.dumps(meta)}, | |||||
| dots: {json.dumps(dots_data)}, | |||||
| dot_size: {dot_size} | |||||
| }}); | |||||
| </script> | |||||
| """ | |||||
| # Il componente restituisce il valore di window.parent.postMessage | |||||
| result = components.html(html_content, height=650) | |||||
| if result: | |||||
| st.write(f"Posizione catturata: {result}") | |||||
| # Qui inserisci la tua logica di salvataggio CSV che avevi nel punto 7 | |||||
| @@ -0,0 +1,50 @@ | |||||
| import os | |||||
| import time | |||||
| import paho.mqtt.client as mqtt | |||||
| class MqttSubscriber: | |||||
| def __init__(self, host: str, port: int, topic: str, protocol: str = "mqttv311", | |||||
| client_id: str = "ble-ai-localizer", keepalive: int = 60, | |||||
| username: str = "", password: str = "", qos: int = 0): | |||||
| self.host = host | |||||
| self.port = port | |||||
| self.topic = topic | |||||
| self.keepalive = keepalive | |||||
| self.qos = qos | |||||
| # protocol mapping | |||||
| proto_map = { | |||||
| "mqttv311": mqtt.MQTTv311, | |||||
| "mqttv31": mqtt.MQTTv31, | |||||
| "mqttv5": mqtt.MQTTv5, | |||||
| } | |||||
| proto = proto_map.get((protocol or "mqttv311").lower(), mqtt.MQTTv311) | |||||
| self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=client_id, protocol=proto) | |||||
| if username: | |||||
| self.client.username_pw_set(username=username, password=password if password else None) | |||||
| self.client.on_connect = self._on_connect | |||||
| self.client.on_disconnect = self._on_disconnect | |||||
| # callback utente (topic, payload_bytes) | |||||
| self.on_message_user = None | |||||
| self.client.on_message = self._on_message | |||||
| def _on_connect(self, client, userdata, flags, reason_code, properties=None): | |||||
| # subscribe | |||||
| client.subscribe(self.topic, qos=self.qos) | |||||
| print(f"MQTT connected rc={reason_code}, subscribed to {self.topic}", flush=True) | |||||
| def _on_disconnect(self, client, userdata, reason_code, properties=None): | |||||
| print(f"MQTT disconnected rc={reason_code}", flush=True) | |||||
| def _on_message(self, client, userdata, msg): | |||||
| if self.on_message_user: | |||||
| self.on_message_user(msg.topic, msg.payload) | |||||
| def start_forever(self): | |||||
| # reconnect loop gestito da loop_forever | |||||
| self.client.connect(self.host, self.port, self.keepalive) | |||||
| self.client.loop_forever(retry_first_connection=True) | |||||
| @@ -0,0 +1,69 @@ | |||||
| """mqtt_parser.py | |||||
| Parser del topic/payload MQTT in formato publish_out/<gateway> con payload JSON list. | |||||
| Esempio payload: | |||||
| [ | |||||
| {"timestamp":"...","type":"Gateway","mac":"AC233FC1DD3C","nums":9}, | |||||
| {"timestamp":"...","mac":"C3000057B9F4","rssi":-56,"rawData":"..."}, | |||||
| ... | |||||
| ] | |||||
| Restituisce eventi normalizzati (gateway_mac_compact, beacon_mac_compact, rssi, timestamp_iso). | |||||
| """ | |||||
| from __future__ import annotations | |||||
| import json | |||||
| from typing import List, Optional, Tuple | |||||
| from .normalize import mac_to_compact | |||||
| MqttEvent = Tuple[str, str, float, Optional[str]] # (gw_compact, beacon_compact, rssi, ts_iso) | |||||
| def parse_publish_out(topic: str, payload: bytes) -> List[MqttEvent]: | |||||
| try: | |||||
| obj = json.loads(payload.decode("utf-8", errors="strict")) | |||||
| except Exception: | |||||
| return [] | |||||
| if not isinstance(obj, list) or not obj: | |||||
| return [] | |||||
| # Gateway MAC: preferisci il primo elemento "type: Gateway" se presente, altrimenti usa il topic. | |||||
| gw_compact: str = "" | |||||
| first = obj[0] | |||||
| if isinstance(first, dict) and str(first.get("type", "")).lower() == "gateway" and "mac" in first: | |||||
| gw_compact = mac_to_compact(first.get("mac")) | |||||
| if not gw_compact: | |||||
| # topic: publish_out/<gw> | |||||
| gw_compact = mac_to_compact(topic.split("/")[-1]) | |||||
| out: List[MqttEvent] = [] | |||||
| for item in obj: | |||||
| if not isinstance(item, dict): | |||||
| continue | |||||
| if str(item.get("type", "")).lower() == "gateway": | |||||
| continue | |||||
| mac = item.get("mac") | |||||
| rssi = item.get("rssi") | |||||
| if mac is None or rssi is None: | |||||
| continue | |||||
| b_compact = mac_to_compact(mac) | |||||
| if len(b_compact) != 12: | |||||
| continue | |||||
| try: | |||||
| rssi_f = float(rssi) | |||||
| except Exception: | |||||
| continue | |||||
| ts = item.get("timestamp") | |||||
| out.append((gw_compact, b_compact, rssi_f, ts if isinstance(ts, str) else None)) | |||||
| return out | |||||
| @@ -0,0 +1,46 @@ | |||||
| """normalize.py | |||||
| Helper per normalizzare i MAC address in due formati: | |||||
| - compact: 12 hex uppercase senza separatori (es: AC233FC1DD3C) | |||||
| - colon: 6 byte separati da ":" (es: AC:23:3F:C1:DD:3C) | |||||
| Nota: nel progetto usiamo **compact** come chiave interna per fare matching tra: | |||||
| - gateway.csv (spesso con i ":") | |||||
| - topic MQTT (publish_out/<gw> in genere senza ":") | |||||
| - payload JSON (mac beacon/gateway spesso senza ":") | |||||
| Il formato colon rimane utile solo per *rendering* (es: header CSV). | |||||
| """ | |||||
| from __future__ import annotations | |||||
| import re | |||||
| _HEX_ONLY = re.compile(r"[^0-9A-Fa-f]") | |||||
| def mac_to_compact(mac: object) -> str: | |||||
| """Ritorna 12 hex uppercase senza separatori.""" | |||||
| if mac is None: | |||||
| return "" | |||||
| s = _HEX_ONLY.sub("", str(mac)).upper() | |||||
| if len(s) == 12: | |||||
| return s | |||||
| if len(s) > 12: | |||||
| # se arriva qualcosa di più lungo (es. UUID), prendiamo gli ultimi 12 | |||||
| return s[-12:] | |||||
| return s | |||||
| def compact_to_colon(mac12: object) -> str: | |||||
| """Converte 'AC233FC1DD3C' -> 'AC:23:3F:C1:DD:3C'.""" | |||||
| s = mac_to_compact(mac12) | |||||
| if len(s) != 12: | |||||
| return s | |||||
| return ":".join(s[i : i + 2] for i in range(0, 12, 2)) | |||||
| def norm_mac(mac: object) -> str: | |||||
| """Alias storico: ritorna il formato con ':'.""" | |||||
| return compact_to_colon(mac) | |||||
| @@ -0,0 +1,82 @@ | |||||
| from __future__ import annotations | |||||
| from dataclasses import dataclass, field | |||||
| from typing import Dict, Set, List | |||||
| import time | |||||
| def canonical_mac(mac: str) -> str: | |||||
| """ | |||||
| Normalize MAC to 'AA:BB:CC:DD:EE:FF' (uppercase). | |||||
| Accepts: | |||||
| - 'AC233FC1DCCB' | |||||
| - 'ac:23:3f:c1:dc:cb' | |||||
| - 'AC-23-3F-C1-DC-CB' | |||||
| - other variants (best-effort) | |||||
| """ | |||||
| s = str(mac).strip() | |||||
| if not s: | |||||
| return s | |||||
| # keep only hex chars | |||||
| hex_only = "".join(ch for ch in s if ch.isalnum()).upper() | |||||
| # If it's exactly 12 hex chars -> format with ':' | |||||
| if len(hex_only) == 12: | |||||
| return ":".join(hex_only[i:i + 2] for i in range(0, 12, 2)) | |||||
| # Fallback: uppercase original (best-effort) | |||||
| return s.upper() | |||||
| @dataclass | |||||
| class OnlineMonitor: | |||||
| gateway_macs: Set[str] | |||||
| beacon_macs: Set[str] | |||||
| offline_after_seconds_gateways: int = 120 | |||||
| offline_after_seconds_beacons: int = 600 | |||||
| last_seen_gw: Dict[str, float] = field(default_factory=dict) | |||||
| last_seen_beacon: Dict[str, float] = field(default_factory=dict) | |||||
| def __post_init__(self) -> None: | |||||
| # normalize allowed sets once | |||||
| self.gateway_macs = {canonical_mac(m) for m in self.gateway_macs} | |||||
| self.beacon_macs = {canonical_mac(m) for m in self.beacon_macs} | |||||
| def mark_gateway_seen(self, gw_mac: str) -> None: | |||||
| self.last_seen_gw[canonical_mac(gw_mac)] = time.time() | |||||
| def mark_beacon_seen(self, beacon_mac: str) -> None: | |||||
| self.last_seen_beacon[canonical_mac(beacon_mac)] = time.time() | |||||
| def _offline(self, allowed: Set[str], last_seen: Dict[str, float], threshold: int) -> List[str]: | |||||
| now = time.time() | |||||
| off = [] | |||||
| for mac in sorted(allowed): | |||||
| t = last_seen.get(mac) | |||||
| if t is None: | |||||
| off.append(mac) | |||||
| elif (now - t) > threshold: | |||||
| off.append(mac) | |||||
| return off | |||||
| def offline_gateways(self) -> List[str]: | |||||
| return self._offline(self.gateway_macs, self.last_seen_gw, self.offline_after_seconds_gateways) | |||||
| def offline_beacons(self) -> List[str]: | |||||
| return self._offline(self.beacon_macs, self.last_seen_beacon, self.offline_after_seconds_beacons) | |||||
| def _age(self, mac: str, last_seen: Dict[str, float]) -> int: | |||||
| t = last_seen.get(mac) | |||||
| if t is None: | |||||
| return -1 | |||||
| return int(time.time() - t) | |||||
| def offline_gateways_with_age(self): | |||||
| off = self.offline_gateways() | |||||
| return [(m, self._age(m, self.last_seen_gw)) for m in off] | |||||
| def offline_beacons_with_age(self): | |||||
| off = self.offline_beacons() | |||||
| return [(m, self._age(m, self.last_seen_beacon)) for m in off] | |||||
| @@ -0,0 +1,32 @@ | |||||
| import os | |||||
| from pathlib import Path | |||||
| import yaml | |||||
| def _read_yaml(path: str) -> dict: | |||||
| with open(path, "r", encoding="utf-8") as f: | |||||
| return yaml.safe_load(f) or {} | |||||
| def deep_merge(a: dict, b: dict) -> dict: | |||||
| out = dict(a or {}) | |||||
| for k, v in (b or {}).items(): | |||||
| if isinstance(v, dict) and isinstance(out.get(k), dict): | |||||
| out[k] = deep_merge(out[k], v) | |||||
| else: | |||||
| out[k] = v | |||||
| return out | |||||
| def load_settings() -> dict: | |||||
| cfg_path = os.getenv("CONFIG_FILE", "/app/config/config.yaml") | |||||
| settings = _read_yaml(cfg_path) | |||||
| secrets_path = os.getenv("SECRETS_FILE", "") | |||||
| if secrets_path and Path(secrets_path).exists(): | |||||
| secrets = _read_yaml(secrets_path) | |||||
| settings = deep_merge(settings, secrets) | |||||
| # fallback paths (coerenti con compose) | |||||
| settings.setdefault("paths", {}) | |||||
| settings["paths"].setdefault("dataset", os.getenv("DATASET_PATH", "/data/fingerprint.parquet")) | |||||
| settings["paths"].setdefault("model", os.getenv("MODEL_PATH", "/models/model.joblib")) | |||||
| return settings | |||||
| @@ -0,0 +1,5 @@ | |||||
| import urllib3 | |||||
| def silence_insecure_warnings(enable: bool): | |||||
| if enable: | |||||
| urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) | |||||
| @@ -0,0 +1,349 @@ | |||||
| """train_collect.py | |||||
| Modalità COLLECT_TRAIN: | |||||
| - attende che tutti i gateway del feature-set (gateway.csv) siano online (traffic MQTT) | |||||
| - prende job CSV da jobs_dir/pending/*.csv | |||||
| - per ogni job: apre una finestra di raccolta di window_seconds, aggrega RSSI per GW | |||||
| - scrive sample CSV in samples_dir | |||||
| Bugfix fondamentale (per i tuoi NAN): | |||||
| - matching interno su MAC in formato **compact** (12 hex senza ':'). | |||||
| """ | |||||
| from __future__ import annotations | |||||
| import os | |||||
| import time | |||||
| import shutil | |||||
| import glob | |||||
| import math | |||||
| import datetime | |||||
| from dataclasses import dataclass | |||||
| from typing import Dict, List, Optional, Tuple | |||||
| import pandas as pd | |||||
| from .normalize import mac_to_compact, compact_to_colon | |||||
| from .mqtt_client import MqttSubscriber | |||||
| from .mqtt_parser import parse_publish_out | |||||
| from .fingerprint import FingerprintWindow | |||||
| from .logger_utils import log_msg as log | |||||
| def _ensure_dir(path: str) -> None: | |||||
| os.makedirs(path, exist_ok=True) | |||||
| def _read_delimited_csv(path: str, prefer_delim: str = ";") -> pd.DataFrame: | |||||
| for sep in [prefer_delim, ",", "\t"]: | |||||
| try: | |||||
| df = pd.read_csv(path, sep=sep, dtype=str, keep_default_na=False) | |||||
| if len(df.columns) >= 1: | |||||
| return df | |||||
| except Exception: | |||||
| continue | |||||
| return pd.read_csv(path, dtype=str, keep_default_na=False) | |||||
| def load_gateway_csv(path: str, delimiter: str = ";") -> Tuple[List[str], List[str]]: | |||||
| df = _read_delimited_csv(path, prefer_delim=delimiter) | |||||
| if df.empty: | |||||
| return [], [] | |||||
| mac_col = None | |||||
| for c in df.columns: | |||||
| if c.strip().lower() == "mac": | |||||
| mac_col = c | |||||
| break | |||||
| if mac_col is None: | |||||
| mac_col = df.columns[0] | |||||
| headers: List[str] = [] | |||||
| keys: List[str] = [] | |||||
| seen = set() | |||||
| invalid = 0 | |||||
| dup = 0 | |||||
| for raw in df[mac_col].tolist(): | |||||
| k = mac_to_compact(raw) | |||||
| if len(k) != 12: | |||||
| invalid += 1 | |||||
| continue | |||||
| if k in seen: | |||||
| dup += 1 | |||||
| continue | |||||
| seen.add(k) | |||||
| keys.append(k) | |||||
| headers.append(compact_to_colon(k)) | |||||
| log(f"[gateway.csv] loaded gateways={len(keys)} invalid={invalid} duplicates={dup}") | |||||
| return headers, keys | |||||
| @dataclass | |||||
| class TrainTarget: | |||||
| mac: str # compact | |||||
| x: float | |||||
| y: float | |||||
| z: float | |||||
| def read_job_csv(job_path: str, delimiter: str = ";") -> List[TrainTarget]: | |||||
| df = _read_delimited_csv(job_path, prefer_delim=delimiter) | |||||
| if df.empty: | |||||
| return [] | |||||
| cols = {c.strip().lower(): c for c in df.columns} | |||||
| def col(name: str) -> Optional[str]: | |||||
| return cols.get(name) | |||||
| mac_c = col("mac") | |||||
| x_c = col("x") | |||||
| y_c = col("y") | |||||
| z_c = col("z") | |||||
| if not mac_c: | |||||
| raise ValueError(f"Job CSV senza colonna 'mac': {job_path}") | |||||
| out: List[TrainTarget] = [] | |||||
| for _, row in df.iterrows(): | |||||
| m = mac_to_compact(row[mac_c]) | |||||
| if len(m) != 12: | |||||
| continue | |||||
| x = float(row[x_c]) if x_c else 0.0 | |||||
| y = float(row[y_c]) if y_c else 0.0 | |||||
| z = float(row[z_c]) if z_c else 0.0 | |||||
| out.append(TrainTarget(mac=m, x=x, y=y, z=z)) | |||||
| return out | |||||
| def _pick_collect_cfg(settings: Dict) -> Dict: | |||||
| if "collect_train" in settings and isinstance(settings["collect_train"], dict): | |||||
| return settings["collect_train"] | |||||
| if "training" in settings and isinstance(settings["training"], dict): | |||||
| log("WARNING: config usa 'training:' (alias). Consiglio: rinomina in 'collect_train:'") | |||||
| return settings["training"] | |||||
| return {} | |||||
| def run_collect_train(settings: Dict) -> None: | |||||
| ct = _pick_collect_cfg(settings) | |||||
| paths = settings.get("paths", {}) or {} | |||||
| mqtt_cfg = settings.get("mqtt", {}) or {} | |||||
| dbg = settings.get("debug", {}) or {} | |||||
| jobs_dir = str(ct.get("jobs_dir", "/data/train/jobs")) | |||||
| samples_dir = str(ct.get("samples_dir", "/data/train/samples")) | |||||
| job_glob = str(ct.get("job_glob", "*.csv")) | |||||
| poll_seconds = float(ct.get("poll_seconds", ct.get("poll_pending_seconds", 2))) | |||||
| window_seconds = float(ct.get("window_seconds", 10)) | |||||
| min_non_nan = int(ct.get("min_non_nan", 3)) | |||||
| aggregate = str(ct.get("aggregate", "median")).lower() | |||||
| rssi_min = float(ct.get("rssi_min", -110)) | |||||
| rssi_max = float(ct.get("rssi_max", -25)) | |||||
| outlier_method = str(ct.get("outlier_method", "none")).lower() | |||||
| mad_z = float(ct.get("mad_z", 3.5)) | |||||
| min_samples_per_gateway = int(ct.get("min_samples_per_gateway", 1)) | |||||
| max_stddev = ct.get("max_stddev", None) | |||||
| max_stddev = float(max_stddev) if max_stddev is not None else None | |||||
| gateway_ready_max_age_seconds = float(ct.get("gateway_ready_max_age_seconds", 30)) | |||||
| gw_ready_log_seconds = float(ct.get("gw_ready_log_seconds", 10)) | |||||
| gw_ready_sleep_seconds = float(ct.get("gw_ready_sleep_seconds", 5)) | |||||
| gw_ready_check_before_job = bool(ct.get("gw_ready_check_before_job", True)) | |||||
| csv_delim = str(paths.get("csv_delimiter", ";")) | |||||
| gateway_csv = str(paths.get("gateways_csv", "/data/config/gateway.csv")) | |||||
| # Debug opzionale durante finestra | |||||
| log_progress = bool(dbg.get("collect_train_log_samples", False)) | |||||
| log_first_seen = bool(dbg.get("collect_train_log_first_seen", False)) | |||||
| log_every_s = float(dbg.get("collect_train_log_every_seconds", 15)) | |||||
| pending_dir = os.path.join(jobs_dir, "pending") | |||||
| done_dir = os.path.join(jobs_dir, "done") | |||||
| error_dir = os.path.join(jobs_dir, "error") | |||||
| _ensure_dir(pending_dir) | |||||
| _ensure_dir(done_dir) | |||||
| _ensure_dir(error_dir) | |||||
| _ensure_dir(samples_dir) | |||||
| gateway_headers, gateway_keys = load_gateway_csv(gateway_csv, delimiter=csv_delim) | |||||
| if not gateway_keys: | |||||
| log("ERROR: Nessun gateway valido nel gateway.csv -> non posso partire.") | |||||
| return | |||||
| mqtt_host = str(mqtt_cfg.get("host", "mosquitto")) | |||||
| mqtt_port = int(mqtt_cfg.get("port", 1883)) | |||||
| mqtt_topic = str(mqtt_cfg.get("topic", "publish_out/#")) | |||||
| mqtt_proto = str(mqtt_cfg.get("protocol", "mqttv311")).lower() | |||||
| client_id = str(mqtt_cfg.get("client_id", "ble-ai-localizer")) | |||||
| keepalive = int(mqtt_cfg.get("keepalive", 60)) | |||||
| qos = int(mqtt_cfg.get("qos", 0)) | |||||
| username = str(mqtt_cfg.get("username", "")) | |||||
| password = str(mqtt_cfg.get("password", "")) | |||||
| last_seen: Dict[str, float] = {} | |||||
| active_window: Optional[FingerprintWindow] = None | |||||
| active_logged_pairs: set = set() | |||||
| def on_mqtt_message(topic: str, payload: bytes) -> None: | |||||
| nonlocal active_window, active_logged_pairs | |||||
| events = parse_publish_out(topic, payload) | |||||
| now = time.time() | |||||
| for gw_key, b_key, rssi, _ts in events: | |||||
| if len(gw_key) == 12: | |||||
| last_seen[gw_key] = now | |||||
| if active_window is None: | |||||
| continue | |||||
| accepted = active_window.add(gw_key, b_key, rssi) | |||||
| if accepted and log_first_seen: | |||||
| pair = (b_key, gw_key) | |||||
| if pair not in active_logged_pairs: | |||||
| active_logged_pairs.add(pair) | |||||
| log(f"SEEN target beacon={b_key} gw={compact_to_colon(gw_key)} rssi={rssi:.1f}") | |||||
| sub = MqttSubscriber( | |||||
| host=mqtt_host, | |||||
| port=mqtt_port, | |||||
| topic=mqtt_topic, | |||||
| mqtt_proto=mqtt_proto, | |||||
| client_id=client_id, | |||||
| keepalive=keepalive, | |||||
| qos=qos, | |||||
| username=username if username else None, | |||||
| password=password if password else None, | |||||
| ) | |||||
| import threading | |||||
| t = threading.Thread(target=sub.start_forever, args=(on_mqtt_message,), daemon=True) | |||||
| t.start() | |||||
| log("MQTT thread started (collect_train)") | |||||
| log( | |||||
| f"COLLECT_TRAIN config: gateway_csv={gateway_csv} gateways(feature-set)={len(gateway_keys)} " | |||||
| f"window_seconds={window_seconds:.1f} poll_seconds={poll_seconds:.1f} " | |||||
| f"jobs_dir={jobs_dir} pending_dir={pending_dir} done_dir={done_dir} error_dir={error_dir} " | |||||
| f"samples_dir={samples_dir} mqtt={mqtt_host}:{mqtt_port} topic={mqtt_topic}" | |||||
| ) | |||||
| def gateways_online() -> Tuple[int, List[str]]: | |||||
| now = time.time() | |||||
| missing: List[str] = [] | |||||
| for gk, hdr in zip(gateway_keys, gateway_headers): | |||||
| last = last_seen.get(gk) | |||||
| if last is None or (now - last) > gateway_ready_max_age_seconds: | |||||
| missing.append(hdr) | |||||
| return len(missing), missing | |||||
| def wait_for_gateways() -> None: | |||||
| last_log = 0.0 | |||||
| while True: | |||||
| miss_n, missing = gateways_online() | |||||
| if miss_n == 0: | |||||
| log(f"GW READY: online={len(gateway_keys)}/{len(gateway_keys)} (max_age_s={gateway_ready_max_age_seconds:.1f})") | |||||
| return | |||||
| now = time.time() | |||||
| if now - last_log >= gw_ready_log_seconds: | |||||
| last_log = now | |||||
| log( | |||||
| f"WAIT gateways online ({miss_n} missing, seen={len(gateway_keys)-miss_n}/{len(gateway_keys)}): {missing} " | |||||
| f"(max_age_s={gateway_ready_max_age_seconds:.1f})" | |||||
| ) | |||||
| time.sleep(gw_ready_sleep_seconds) | |||||
| while True: | |||||
| jobs = sorted(glob.glob(os.path.join(pending_dir, job_glob))) | |||||
| if not jobs: | |||||
| time.sleep(poll_seconds) | |||||
| continue | |||||
| for job_path in jobs: | |||||
| job_name = os.path.basename(job_path) | |||||
| try: | |||||
| if gw_ready_check_before_job: | |||||
| wait_for_gateways() | |||||
| targets = read_job_csv(job_path, delimiter=csv_delim) | |||||
| if not targets: | |||||
| raise RuntimeError("job CSV vuoto o senza MAC validi") | |||||
| beacon_keys = [t.mac for t in targets] | |||||
| log(f"TRAIN job START: {job_name} beacons={len(beacon_keys)}") | |||||
| active_logged_pairs = set() | |||||
| active_window = FingerprintWindow( | |||||
| beacon_keys=beacon_keys, | |||||
| gateway_headers=gateway_headers, | |||||
| gateway_keys=gateway_keys, | |||||
| rssi_min=rssi_min, | |||||
| rssi_max=rssi_max, | |||||
| outlier_method=outlier_method, | |||||
| mad_z=mad_z, | |||||
| min_samples_per_gateway=min_samples_per_gateway, | |||||
| max_stddev=max_stddev, | |||||
| ) | |||||
| t0 = time.time() | |||||
| next_log = t0 + log_every_s | |||||
| while True: | |||||
| elapsed = time.time() - t0 | |||||
| if elapsed >= window_seconds: | |||||
| break | |||||
| if log_progress and time.time() >= next_log: | |||||
| next_log = time.time() + log_every_s | |||||
| parts = [] | |||||
| for b in beacon_keys: | |||||
| tops = active_window.top_gateways(b, aggregate=aggregate, top_n=3) | |||||
| if not tops: | |||||
| parts.append(f"{b}:0gw") | |||||
| else: | |||||
| top_s = ",".join([f"{hdr}({n})" for n, hdr, _agg in tops]) | |||||
| parts.append(f"{b}:{top_s}") | |||||
| log(f"WINDOW progress {elapsed:.0f}/{window_seconds:.0f}s -> " + " | ".join(parts)) | |||||
| time.sleep(0.25) | |||||
| rows: List[Dict[str, object]] = [] | |||||
| for tt in targets: | |||||
| feats = active_window.features_for(tt.mac, aggregate=aggregate) | |||||
| non_nan = sum(0 if (isinstance(v, float) and math.isnan(v)) else 1 for v in feats.values()) | |||||
| if non_nan < min_non_nan: | |||||
| log(f"WARNING: beacon {tt.mac} low features non_nan={non_nan}") | |||||
| tops = active_window.top_gateways(tt.mac, aggregate=aggregate, top_n=5) | |||||
| if tops: | |||||
| top_s = ", ".join([f"{hdr} n={n} agg={agg:.1f}" for n, hdr, agg in tops]) | |||||
| log(f"SUMMARY beacon {tt.mac}: {top_s}") | |||||
| else: | |||||
| log(f"SUMMARY beacon {tt.mac}: no samples captured") | |||||
| row: Dict[str, object] = {"mac": tt.mac, "x": float(tt.x), "y": float(tt.y), "z": float(tt.z)} | |||||
| row.update(feats) | |||||
| rows.append(row) | |||||
| out_df = pd.DataFrame(rows) | |||||
| cols = ["mac", "x", "y", "z"] + gateway_headers | |||||
| out_df = out_df.reindex(columns=cols) | |||||
| epoch = int(time.time()) | |||||
| out_name = f"{os.path.splitext(job_name)[0]}__{epoch}.csv" | |||||
| out_path = os.path.join(samples_dir, out_name) | |||||
| out_df.to_csv(out_path, sep=csv_delim, index=False, float_format="%.1f", na_rep="nan") | |||||
| log(f"TRAIN job DONE: wrote {out_path} rows={len(out_df)}") | |||||
| shutil.move(job_path, os.path.join(done_dir, job_name)) | |||||
| except Exception as e: | |||||
| log(f"ERROR processing job {job_name}: {e}") | |||||
| try: | |||||
| shutil.move(job_path, os.path.join(error_dir, job_name)) | |||||
| except Exception: | |||||
| pass | |||||
| finally: | |||||
| active_window = None | |||||
| @@ -0,0 +1,419 @@ | |||||
| # app/train_mode.py | |||||
| # Training mode: build hierarchical KNN model (floor classifier + per-floor X/Y regressors) | |||||
| # Adds verbose dataset statistics useful for large training runs. | |||||
| from __future__ import annotations | |||||
| import glob | |||||
| import os | |||||
| import time | |||||
| import math | |||||
| from dataclasses import dataclass | |||||
| from typing import Any, Callable, Dict, List, Optional, Tuple | |||||
| import joblib | |||||
| from datetime import datetime | |||||
| import numpy as np | |||||
| import pandas as pd | |||||
| from sklearn.neighbors import KNeighborsClassifier, KNeighborsRegressor | |||||
| import sklearn | |||||
| # NOTE: these are already present in the project | |||||
| from .csv_config import load_gateway_features_csv | |||||
| from .logger_utils import log_msg as log | |||||
| @dataclass | |||||
| class GatewayStats: | |||||
| mac: str | |||||
| total_samples: int = 0 # total rows processed (per sample point) | |||||
| non_missing: int = 0 # non-missing rssi count | |||||
| missing: int = 0 # missing (nan) count | |||||
| sum_: float = 0.0 | |||||
| sumsq: float = 0.0 | |||||
| min_: float = float("inf") | |||||
| max_: float = float("-inf") | |||||
| def add(self, v: float, is_missing: bool) -> None: | |||||
| self.total_samples += 1 | |||||
| if is_missing: | |||||
| self.missing += 1 | |||||
| return | |||||
| self.non_missing += 1 | |||||
| self.sum_ += v | |||||
| self.sumsq += v * v | |||||
| if v < self.min_: | |||||
| self.min_ = v | |||||
| if v > self.max_: | |||||
| self.max_ = v | |||||
| def mean(self) -> float: | |||||
| return self.sum_ / self.non_missing if self.non_missing else float("nan") | |||||
| def std(self) -> float: | |||||
| if self.non_missing <= 1: | |||||
| return float("nan") | |||||
| mu = self.mean() | |||||
| var = max(0.0, (self.sumsq / self.non_missing) - (mu * mu)) | |||||
| return math.sqrt(var) | |||||
| def missing_pct(self) -> float: | |||||
| return (self.missing / self.total_samples) * 100.0 if self.total_samples else 0.0 | |||||
| def _get(d: Dict[str, Any], key: str, default: Any = None) -> Any: | |||||
| return d.get(key, default) if isinstance(d, dict) else default | |||||
| def _as_bool(v: Any, default: bool = False) -> bool: | |||||
| if v is None: | |||||
| return default | |||||
| if isinstance(v, bool): | |||||
| return v | |||||
| if isinstance(v, (int, float)): | |||||
| return bool(v) | |||||
| s = str(v).strip().lower() | |||||
| return s in ("1", "true", "yes", "y", "on") | |||||
| def _safe_float(v: Any) -> Optional[float]: | |||||
| try: | |||||
| if v is None: | |||||
| return None | |||||
| if isinstance(v, float) and math.isnan(v): | |||||
| return None | |||||
| return float(v) | |||||
| except Exception: | |||||
| return None | |||||
| def _collect_dataset( | |||||
| sample_files: List[str], | |||||
| gateways_order: List[str], | |||||
| nan_fill: float, | |||||
| log: Callable[[str], None], | |||||
| verbose: bool, | |||||
| ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, Dict[str, GatewayStats], Dict[str, Any]]: | |||||
| """ | |||||
| Build dataset from per-point sample csv files. | |||||
| Each sample file is expected to contain: | |||||
| header: mac;x;y;z;<GW1>;<GW2>... | |||||
| 1 row: beacon_mac; x; y; z; rssi_gw1; rssi_gw2; ... | |||||
| Returns: | |||||
| X (N, G), y_floor (N,), y_xy (N,2), meta_xy (N,2), | |||||
| gw_stats, global_stats | |||||
| """ | |||||
| X_rows: List[List[float]] = [] | |||||
| y_floor: List[int] = [] | |||||
| y_xy: List[List[float]] = [] | |||||
| meta_xy: List[List[float]] = [] | |||||
| gw_stats: Dict[str, GatewayStats] = {gw: GatewayStats(mac=gw) for gw in gateways_order} | |||||
| floors_counter: Dict[int, int] = {} | |||||
| bad_files: int = 0 | |||||
| missing_cols_files: int = 0 | |||||
| expected_cols: Optional[List[str]] = None | |||||
| for fp in sample_files: | |||||
| try: | |||||
| df = pd.read_csv(fp, sep=";", dtype=str) | |||||
| except Exception as e: | |||||
| bad_files += 1 | |||||
| if verbose: | |||||
| log(f"TRAIN WARN: cannot read sample file {fp}: {type(e).__name__}: {e}") | |||||
| continue | |||||
| if df.shape[0] < 1: | |||||
| bad_files += 1 | |||||
| if verbose: | |||||
| log(f"TRAIN WARN: empty sample file {fp}") | |||||
| continue | |||||
| row = df.iloc[0].to_dict() | |||||
| if verbose: | |||||
| cols = list(df.columns) | |||||
| if expected_cols is None: | |||||
| expected_cols = cols | |||||
| elif cols != expected_cols: | |||||
| missing_cols_files += 1 | |||||
| if missing_cols_files <= 5: | |||||
| log(f"TRAIN WARN: columns mismatch in {os.path.basename(fp)} (expected {len(expected_cols)} cols, got {len(cols)})") | |||||
| x = _safe_float(row.get("x")) | |||||
| y = _safe_float(row.get("y")) | |||||
| z = _safe_float(row.get("z")) | |||||
| if x is None or y is None or z is None: | |||||
| bad_files += 1 | |||||
| if verbose: | |||||
| log(f"TRAIN WARN: missing x/y/z in {fp}") | |||||
| continue | |||||
| z_i = int(round(z)) | |||||
| floors_counter[z_i] = floors_counter.get(z_i, 0) + 1 | |||||
| feats: List[float] = [] | |||||
| for gw in gateways_order: | |||||
| v = row.get(gw) | |||||
| fv = _safe_float(v) | |||||
| if fv is None: | |||||
| feats.append(nan_fill) | |||||
| gw_stats[gw].add(nan_fill, is_missing=True) | |||||
| else: | |||||
| feats.append(fv) | |||||
| gw_stats[gw].add(fv, is_missing=False) | |||||
| X_rows.append(feats) | |||||
| y_floor.append(z_i) | |||||
| y_xy.append([x, y]) | |||||
| meta_xy.append([x, y]) | |||||
| if not X_rows: | |||||
| raise RuntimeError("No valid samples found in samples_dir (dataset empty).") | |||||
| X = np.asarray(X_rows, dtype=np.float32) | |||||
| y_floor_arr = np.asarray(y_floor, dtype=np.int32) | |||||
| y_xy_arr = np.asarray(y_xy, dtype=np.float32) | |||||
| meta_xy_arr = np.asarray(meta_xy, dtype=np.float32) | |||||
| global_stats: Dict[str, Any] = { | |||||
| "samples_total_files": len(sample_files), | |||||
| "samples_used": int(X.shape[0]), | |||||
| "samples_bad": int(bad_files), | |||||
| "floors_counts": dict(sorted(floors_counter.items(), key=lambda kv: kv[0])), | |||||
| "missing_cols_files": int(missing_cols_files), | |||||
| "gateways": int(len(gateways_order)), | |||||
| "nan_fill": float(nan_fill), | |||||
| } | |||||
| return X, y_floor_arr, y_xy_arr, meta_xy_arr, gw_stats, global_stats | |||||
| def _log_train_stats( | |||||
| log: Callable[[str], None], | |||||
| X: np.ndarray, | |||||
| y_floor: np.ndarray, | |||||
| y_xy: np.ndarray, | |||||
| gateways_order: List[str], | |||||
| nan_fill: float, | |||||
| gw_stats: Dict[str, GatewayStats], | |||||
| global_stats: Dict[str, Any], | |||||
| top_k: int = 8, | |||||
| ) -> None: | |||||
| """Human-friendly statistics for training runs.""" | |||||
| log( | |||||
| "TRAIN stats: " | |||||
| f"samples_used={global_stats.get('samples_used')} " | |||||
| f"samples_bad={global_stats.get('samples_bad')} " | |||||
| f"files_total={global_stats.get('samples_total_files')} " | |||||
| f"gateways={len(gateways_order)} " | |||||
| f"floors={list(global_stats.get('floors_counts', {}).keys())}" | |||||
| ) | |||||
| if global_stats.get("missing_cols_files", 0): | |||||
| log(f"TRAIN stats: files_with_column_mismatch={global_stats['missing_cols_files']} (see earlier WARN lines)") | |||||
| xs = y_xy[:, 0] | |||||
| ys = y_xy[:, 1] | |||||
| log( | |||||
| "TRAIN stats: XY range " | |||||
| f"X[min,max]=[{float(np.min(xs)):.2f},{float(np.max(xs)):.2f}] " | |||||
| f"Y[min,max]=[{float(np.min(ys)):.2f},{float(np.max(ys)):.2f}]" | |||||
| ) | |||||
| miss = int((X == nan_fill).sum()) | |||||
| total = int(X.size) | |||||
| miss_pct = (miss / total) * 100.0 if total else 0.0 | |||||
| log(f"TRAIN stats: feature sparsity missing={miss}/{total} ({miss_pct:.1f}%) using nan_fill={nan_fill}") | |||||
| gw_list = list(gw_stats.values()) | |||||
| gw_list_sorted = sorted(gw_list, key=lambda s: (s.missing_pct(), -s.non_missing), reverse=True) | |||||
| worst = gw_list_sorted[: max(1, min(top_k, len(gw_list_sorted)))] | |||||
| worst_str = " | ".join( | |||||
| f"{g.mac}: miss={g.missing_pct():.1f}% (seen={g.non_missing}) mean={g.mean():.1f} std={g.std():.1f}" | |||||
| for g in worst | |||||
| ) | |||||
| log(f"TRAIN stats: gateways with highest missing%: {worst_str}") | |||||
| best = list(reversed(gw_list_sorted))[: max(1, min(top_k, len(gw_list_sorted)))] | |||||
| best_str = " | ".join( | |||||
| f"{g.mac}: miss={g.missing_pct():.1f}% (seen={g.non_missing}) mean={g.mean():.1f} std={g.std():.1f}" | |||||
| for g in best | |||||
| ) | |||||
| log(f"TRAIN stats: gateways with lowest missing%: {best_str}") | |||||
| floors = global_stats.get("floors_counts", {}) | |||||
| if floors: | |||||
| floor_str = ", ".join(f"z={k}:{v}" for k, v in floors.items()) | |||||
| log(f"TRAIN stats: floor distribution: {floor_str}") | |||||
| def run_train(settings: Dict[str, Any], log: Optional[Callable[[str], None]] = None) -> None: | |||||
| """ | |||||
| Train hierarchical KNN: | |||||
| - KNeighborsClassifier for floor (Z) | |||||
| - For each floor, a KNeighborsRegressor for (X,Y) as multioutput | |||||
| Model saved with joblib to paths.model (or train.model_path). | |||||
| """ | |||||
| if log is None: | |||||
| def log(msg: str) -> None: | |||||
| print(msg, flush=True) | |||||
| # Build stamp for this module (helps verifying which file is running) | |||||
| try: | |||||
| import hashlib | |||||
| from pathlib import Path | |||||
| _b = Path(__file__).read_bytes() | |||||
| log(f"TRAIN_MODE build sha256={hashlib.sha256(_b).hexdigest()[:12]} size={len(_b)}") | |||||
| except Exception: | |||||
| pass | |||||
| train_cfg = _get(settings, "train", {}) | |||||
| paths = _get(settings, "paths", {}) | |||||
| debug = _get(settings, "debug", {}) | |||||
| samples_dir = _get(train_cfg, "samples_dir", _get(paths, "samples_dir", "/data/train/samples")) | |||||
| gateways_csv = _get(train_cfg, "gateways_csv", _get(paths, "gateways_csv", "/data/config/gateway.csv")) | |||||
| model_path = _get(train_cfg, "model_path", _get(paths, "model", "/data/model/model.joblib")) | |||||
| nan_fill = float(_get(train_cfg, "nan_fill", -110.0)) | |||||
| k_floor = int(_get(train_cfg, "k_floor", _get(_get(settings, "ml", {}), "k", 7))) | |||||
| k_xy = int(_get(train_cfg, "k_xy", _get(_get(settings, "ml", {}), "k", 7))) | |||||
| weights = str(_get(train_cfg, "weights", _get(_get(settings, "ml", {}), "weights", "distance"))) | |||||
| metric = str(_get(train_cfg, "metric", _get(_get(settings, "ml", {}), "metric", "euclidean"))) | |||||
| verbose = _as_bool(_get(debug, "train_verbose", True), True) | |||||
| top_k = int(_get(debug, "train_stats_top_k", 8)) | |||||
| backup_existing_model = _as_bool(_get(train_cfg, "backup_existing_model", True), True) | |||||
| log( | |||||
| "TRAIN config: " | |||||
| f"samples_dir={samples_dir} " | |||||
| f"gateways_csv={gateways_csv} " | |||||
| f"model_path={model_path} " | |||||
| f"nan_fill={nan_fill} " | |||||
| f"k_floor={k_floor} k_xy={k_xy} " | |||||
| f"weights={weights} metric={metric} " | |||||
| f"train_verbose={verbose} backup_existing_model={backup_existing_model}" | |||||
| ) | |||||
| # 1) Load gateways definition to know feature order | |||||
| gws = load_gateway_features_csv(str(gateways_csv)) | |||||
| gateways_order = [g.mac for g in gws] | |||||
| if not gateways_order: | |||||
| raise RuntimeError("No gateways found in gateways_csv (feature-set empty).") | |||||
| if verbose: | |||||
| preview = ", ".join(gateways_order[: min(6, len(gateways_order))]) | |||||
| log(f"TRAIN: gateways(feature-order)={len(gateways_order)} first=[{preview}{'...' if len(gateways_order) > 6 else ''}]") | |||||
| # 2) Collect sample files | |||||
| sample_files = sorted(glob.glob(os.path.join(samples_dir, "*.csv"))) | |||||
| if not sample_files: | |||||
| raise RuntimeError(f"No sample files found in samples_dir={samples_dir}") | |||||
| X, y_floor, y_xy, meta_xy, gw_stats, global_stats = _collect_dataset( | |||||
| sample_files=sample_files, | |||||
| gateways_order=gateways_order, | |||||
| nan_fill=nan_fill, | |||||
| log=log, | |||||
| verbose=verbose, | |||||
| ) | |||||
| if verbose: | |||||
| _log_train_stats( | |||||
| log=log, | |||||
| X=X, | |||||
| y_floor=y_floor, | |||||
| y_xy=meta_xy, | |||||
| gateways_order=gateways_order, | |||||
| nan_fill=nan_fill, | |||||
| gw_stats=gw_stats, | |||||
| global_stats=global_stats, | |||||
| top_k=top_k, | |||||
| ) | |||||
| # 3) Fit floor classifier | |||||
| floor_clf = KNeighborsClassifier( | |||||
| n_neighbors=k_floor, | |||||
| weights=weights, | |||||
| metric=metric, | |||||
| ) | |||||
| floor_clf.fit(X, y_floor) | |||||
| # 4) Fit per-floor XY regressors (multioutput) | |||||
| models_xy: Dict[int, Any] = {} | |||||
| floors = sorted(set(int(z) for z in y_floor.tolist())) | |||||
| for z in floors: | |||||
| idx = np.where(y_floor == z)[0] | |||||
| Xz = X[idx, :] | |||||
| yz = y_xy[idx, :] # (N,2) | |||||
| reg = KNeighborsRegressor( | |||||
| n_neighbors=k_xy, | |||||
| weights=weights, | |||||
| metric=metric, | |||||
| ) | |||||
| reg.fit(Xz, yz) | |||||
| models_xy[int(z)] = reg | |||||
| if verbose: | |||||
| xs = yz[:, 0] | |||||
| ys = yz[:, 1] | |||||
| log( | |||||
| f"TRAIN: floor z={z} samples={int(len(idx))} " | |||||
| f"Xrange=[{float(np.min(xs)):.1f},{float(np.max(xs)):.1f}] " | |||||
| f"Yrange=[{float(np.min(ys)):.1f},{float(np.max(ys)):.1f}]" | |||||
| ) | |||||
| model = { | |||||
| "type": "hier_knn_floor_xy", | |||||
| "gateways_order": gateways_order, | |||||
| "nan_fill": nan_fill, | |||||
| "k_floor": k_floor, | |||||
| "k_xy": k_xy, | |||||
| "weights": weights, | |||||
| "metric": metric, | |||||
| "floor_clf": floor_clf, | |||||
| "xy_by_floor": models_xy, | |||||
| "floors": floors, | |||||
| } | |||||
| os.makedirs(os.path.dirname(model_path), exist_ok=True) | |||||
| # Backup previous model (così inferenza può continuare ad usare una versione nota) | |||||
| backup_path = None | |||||
| if backup_existing_model and os.path.exists(model_path): | |||||
| root, ext = os.path.splitext(model_path) | |||||
| ts = int(time.time()) | |||||
| # evita collisioni se lanci due train nello stesso secondo | |||||
| for bump in range(0, 1000): | |||||
| cand = f"{root}_{ts + bump}{ext}" | |||||
| if not os.path.exists(cand): | |||||
| backup_path = cand | |||||
| break | |||||
| try: | |||||
| if backup_path: | |||||
| os.replace(model_path, backup_path) | |||||
| log(f"TRAIN: previous model moved to {backup_path}") | |||||
| except Exception as e: | |||||
| log(f"TRAIN WARNING: cannot backup previous model {model_path}: {type(e).__name__}: {e}") | |||||
| # Metadata utile (tipo 'modinfo' minimale) | |||||
| model["created_at_utc"] = datetime.utcnow().replace(microsecond=0).isoformat() + "Z" | |||||
| model["sklearn_version"] = getattr(sklearn, "__version__", "unknown") | |||||
| model["numpy_version"] = getattr(np, "__version__", "unknown") | |||||
| joblib.dump(model, model_path) | |||||
| log( | |||||
| f"TRAIN DONE: model saved to {model_path} " | |||||
| f"(samples={int(X.shape[0])}, gateways={len(gateways_order)}, floors={len(floors)})" | |||||
| ) | |||||
| @@ -0,0 +1,88 @@ | |||||
| import streamlit as st | |||||
| import pandas as pd | |||||
| import os | |||||
| def load_beacons(file_path, delimiter): | |||||
| """Carica i beacon dal file CSV utilizzando il delimitatore configurato.""" | |||||
| if not os.path.exists(file_path): | |||||
| return pd.DataFrame(columns=["BeaconName", "MAC"]) | |||||
| try: | |||||
| return pd.read_csv(file_path, sep=delimiter) | |||||
| except Exception: | |||||
| return pd.DataFrame(columns=["BeaconName", "MAC"]) | |||||
| def save_beacons(file_path, df, delimiter): | |||||
| """Salva i beacon su CSV con il separatore corretto e crea le cartelle se necessario.""" | |||||
| folder = os.path.dirname(file_path) | |||||
| if folder and not os.path.exists(folder): | |||||
| os.makedirs(folder, exist_ok=True) | |||||
| df.to_csv(file_path, index=False, sep=delimiter) | |||||
| def show_beacon_manager(config): | |||||
| st.subheader("🏷️ Gestione Anagrafica Beacon") | |||||
| # Recupero parametri dalla gerarchia paths del config.yaml | |||||
| paths_cfg = config.get("paths", {}) | |||||
| beacons_file = paths_cfg.get("beacons_csv", "/data/config/beacons.csv") | |||||
| delimiter = paths_cfg.get("csv_delimiter", ";") | |||||
| # Caricamento dati | |||||
| df = load_beacons(beacons_file, delimiter) | |||||
| # --- SEZIONE AGGIUNTA --- | |||||
| with st.expander("➕ Aggiungi Nuovo Beacon", expanded=len(df) == 0): | |||||
| col1, col2 = st.columns(2) | |||||
| with col1: | |||||
| name = st.text_input("Nome Beacon (es. BC-21)") | |||||
| with col2: | |||||
| mac = st.text_input("Indirizzo MAC (es. C3:00:00:57:B9:E6)") | |||||
| if st.button("REGISTRA BEACON"): | |||||
| if name and mac: | |||||
| mac = mac.strip().upper() | |||||
| if mac in df['MAC'].values: | |||||
| st.error("Errore: Questo MAC è già presente in lista.") | |||||
| else: | |||||
| new_line = pd.DataFrame([{"BeaconName": name, "MAC": mac}]) | |||||
| df = pd.concat([df, new_line], ignore_index=True) | |||||
| save_beacons(beacons_file, df, delimiter) | |||||
| st.success(f"Beacon {name} registrato correttamente!") | |||||
| st.rerun() | |||||
| else: | |||||
| st.warning("Inserisci sia il Nome che il MAC address.") | |||||
| # --- SEZIONE VISUALIZZAZIONE E MODIFICA --- | |||||
| if not df.empty: | |||||
| st.markdown("---") | |||||
| st.write("### Lista Beacon") | |||||
| st.info("💡 Puoi modificare i nomi o i MAC direttamente cliccando nelle celle della tabella e poi cliccare su SALVA.") | |||||
| # Editor interattivo per modifiche "al volo" | |||||
| edited_df = st.data_editor( | |||||
| df, | |||||
| use_container_width=True, | |||||
| hide_index=True, | |||||
| column_config={ | |||||
| "BeaconName": st.column_config.TextColumn("Nome Beacon", help="Nome identificativo", required=True), | |||||
| "MAC": st.column_config.TextColumn("Indirizzo MAC", help="Formato AA:BB:CC...", required=True) | |||||
| } | |||||
| ) | |||||
| # Controllo se ci sono state modifiche | |||||
| if not edited_df.equals(df): | |||||
| if st.button("💾 SALVA MODIFICHE TABELLA"): | |||||
| save_beacons(beacons_file, edited_df, delimiter) | |||||
| st.success("Anagrafica aggiornata!") | |||||
| st.rerun() | |||||
| # --- SEZIONE ELIMINAZIONE --- | |||||
| st.markdown("---") | |||||
| st.subheader("Elimina Beacon") | |||||
| to_del = st.selectbox("Seleziona il beacon da rimuovere:", df['BeaconName'].tolist()) | |||||
| if st.button("🗑️ ELIMINA SELEZIONATO"): | |||||
| df = df[df['BeaconName'] != to_del] | |||||
| save_beacons(beacons_file, df, delimiter) | |||||
| st.success(f"Beacon {to_del} rimosso.") | |||||
| st.rerun() | |||||
| else: | |||||
| st.info("Nessun beacon configurato. Inserisci il primo beacon per creare il file.") | |||||
| @@ -0,0 +1,48 @@ | |||||
| import streamlit as st | |||||
| import pandas as pd | |||||
| import os | |||||
| def show_gateway_manager(config): | |||||
| # Recupera il percorso dal config.yaml, con fallback | |||||
| # Nota: cerchiamo sia sotto 'infer' che 'collect_train' se necessario | |||||
| csv_path = config.get('infer', {}).get('gateways_csv', '/data/config/gateway.csv') | |||||
| st.subheader("🌐 Gestione Gateway (gateway.csv)") | |||||
| st.info(f"Percorso file: `{csv_path}`") | |||||
| if not os.path.exists(csv_path): | |||||
| st.error("Il file gateway.csv non è stato trovato al percorso specificato.") | |||||
| if st.button("Crea file vuoto"): | |||||
| df_new = pd.DataFrame(columns=["Position", "Floor", "RoomName", "X", "Y", "Z", "GatewayName", "MAC"]) | |||||
| df_new.to_csv(csv_path, sep=';', index=False) | |||||
| st.rerun() | |||||
| return | |||||
| # Caricamento del CSV | |||||
| try: | |||||
| # Usiamo il separatore ';' come da tuo esempio | |||||
| df = pd.read_csv(csv_path, sep=';', dtype=str) | |||||
| except Exception as e: | |||||
| st.error(f"Errore nel caricamento del CSV: {e}") | |||||
| return | |||||
| # Interfaccia di editing | |||||
| st.write("Modifica i dati direttamente nella tabella qui sotto:") | |||||
| edited_df = st.data_editor( | |||||
| df, | |||||
| num_rows="dynamic", | |||||
| use_container_width=True, | |||||
| key="gateway_editor" | |||||
| ) | |||||
| col1, col2 = st.columns([1, 5]) | |||||
| with col1: | |||||
| if st.button("💾 Salva Gateway"): | |||||
| try: | |||||
| edited_df.to_csv(csv_path, sep=';', index=False) | |||||
| st.success("File gateway.csv salvato con successo!") | |||||
| except Exception as e: | |||||
| st.error(f"Errore durante il salvataggio: {e}") | |||||
| with col2: | |||||
| st.caption("Nota: L'aggiunta di righe avviene cliccando sull'ultima riga vuota della tabella.") | |||||
| @@ -0,0 +1,100 @@ | |||||
| import streamlit as st | |||||
| import pandas as pd | |||||
| import json | |||||
| from PIL import Image, ImageDraw, ImageFont | |||||
| from pathlib import Path | |||||
| import time | |||||
| import os | |||||
| def show_inference_page(cfg): | |||||
| # Definizione percorsi | |||||
| # Assumendo che il file sia in /app/app/web_inference.py, | |||||
| # cerchiamo il font nella stessa cartella 'app' | |||||
| BASE_DIR = Path(__file__).parent | |||||
| MAPS_DIR = Path(cfg['maps']['map_dir']) | |||||
| INFER_FILE = Path(cfg['infer']['output_dir']) / cfg['infer']['out_file'] | |||||
| # Percorso del font locale | |||||
| FONT_PATH = BASE_DIR / "DejaVuSans-Bold.ttf" | |||||
| st.subheader("🤖 Monitoraggio Inferenza Live") | |||||
| # --- CONTROLLI NEL TAB --- | |||||
| with st.expander("🎨 Opzioni e Controllo", expanded=True): | |||||
| col_ctrl1, col_ctrl2, col_ctrl3, col_ctrl4 = st.columns(4) | |||||
| with col_ctrl1: | |||||
| dot_size = st.slider("Dimensione Marker", 10, 100, 45, key="inf_dot") | |||||
| with col_ctrl2: | |||||
| refresh_rate = st.select_slider("Refresh (s)", options=[2, 5, 10, 30], value=5, key="inf_ref") | |||||
| with col_ctrl3: | |||||
| show_labels = st.checkbox("Mostra MAC", value=True, key="inf_show_mac") | |||||
| with col_ctrl4: | |||||
| # Monitoraggio disattivato di default per evitare login fantasma | |||||
| auto_refresh = st.toggle("🔄 Monitoraggio Attivo", value=False, key="inf_auto") | |||||
| floor_id = st.number_input("Piano (Z)", value=0, min_value=0, step=1, key="inf_z") | |||||
| # Caricamento Metadati | |||||
| meta_path = MAPS_DIR / f"{cfg['maps']['meta_prefix']}{floor_id}.json" | |||||
| meta = {"pixel_ratio": 1.0, "origin": [0, 0]} | |||||
| if meta_path.exists(): | |||||
| with open(meta_path, "r") as f: | |||||
| meta.update(json.load(f)) | |||||
| try: | |||||
| if INFER_FILE.exists(): | |||||
| df = pd.read_csv(INFER_FILE, sep=";") | |||||
| df_active = df[(df['z'] == floor_id) & (df['x'] != -1) & (df['y'] != -1)] | |||||
| img_path = MAPS_DIR / f"{cfg['maps']['floor_prefix']}{floor_id}.png" | |||||
| if img_path.exists(): | |||||
| img = Image.open(img_path).convert("RGBA") | |||||
| draw = ImageDraw.Draw(img) | |||||
| # --- GESTIONE FONT XL --- | |||||
| # Dimensione proporzionale al pallino (1.5x) | |||||
| dynamic_font_size = int(dot_size * 1.5) | |||||
| font = None | |||||
| if FONT_PATH.exists(): | |||||
| try: | |||||
| # Tenta il caricamento del file TTF locale | |||||
| font = ImageFont.truetype(str(FONT_PATH), dynamic_font_size) | |||||
| except Exception as e: | |||||
| # Se il file è corrotto (unknown file format), mostra errore e usa fallback | |||||
| st.error(f"Errore caricamento font: {e}. Controlla il file DejaVuSans-Bold.ttf") | |||||
| if font is None: | |||||
| font = ImageFont.load_default() | |||||
| for _, row in df_active.iterrows(): | |||||
| px_x = (row['x'] * meta["pixel_ratio"]) + meta["origin"][0] | |||||
| px_y = (row['y'] * meta["pixel_ratio"]) + meta["origin"][1] | |||||
| r = dot_size | |||||
| # Disegno Marker | |||||
| draw.ellipse([px_x-(r+5), px_y-(r+5), px_x+(r+5), px_y+(r+5)], fill="black") | |||||
| draw.ellipse([px_x-r, px_y-r, px_x+r, px_y+r], fill="#00E5FF") | |||||
| if show_labels: | |||||
| label = f"{str(row['mac'])[-5:]}" | |||||
| # Posizionamento dinamico a destra del pallino | |||||
| text_x = px_x + r + 20 | |||||
| text_y = px_y - (dynamic_font_size / 2) | |||||
| # Outline per leggibilità (bordo nero spesso 3px) | |||||
| for dx, dy in [(-3,-3), (3,-3), (-3,3), (3,3), (0,-3), (0,3), (-3,0), (2,0)]: | |||||
| draw.text((text_x + dx, text_y + dy), label, font=font, fill="black") | |||||
| # Testo Giallo | |||||
| draw.text((text_x, text_y), label, font=font, fill="#FFFF00") | |||||
| st.image(img, use_column_width=True) | |||||
| st.metric("Dispositivi Online", len(df_active)) | |||||
| except Exception as e: | |||||
| st.error(f"Errore generale: {e}") | |||||
| # --- REFRESH CONDIZIONALE --- | |||||
| if auto_refresh: | |||||
| time.sleep(refresh_rate) | |||||
| st.rerun() | |||||
| @@ -0,0 +1,128 @@ | |||||
| import streamlit as st | |||||
| import pandas as pd | |||||
| import psutil | |||||
| import os | |||||
| import time | |||||
| import json | |||||
| import paho.mqtt.client as mqtt | |||||
| import requests | |||||
| def get_system_metrics(): | |||||
| """Recupera statistiche hardware del server.""" | |||||
| return { | |||||
| "cpu": psutil.cpu_percent(interval=None), | |||||
| "ram": psutil.virtual_memory().percent, | |||||
| "disk": psutil.disk_usage('/').percent, | |||||
| "net_recv": psutil.net_io_counters().bytes_recv / (1024**2) | |||||
| } | |||||
| def get_api_data(cfg, sec): | |||||
| """Fetch reale dei beacon dalle API OIDC.""" | |||||
| if not sec or 'oidc' not in sec: | |||||
| return None, "Segreti mancanti" | |||||
| try: | |||||
| auth_res = requests.post( | |||||
| cfg['api']['token_url'], | |||||
| data={ | |||||
| "grant_type": "password", | |||||
| "client_id": cfg['api']['client_id'], | |||||
| "client_secret": sec['oidc']['client_secret'], | |||||
| "username": sec['oidc']['username'], | |||||
| "password": sec['oidc']['password'], | |||||
| }, | |||||
| verify=cfg['api'].get('verify_tls', False), | |||||
| timeout=5 | |||||
| ) | |||||
| token = auth_res.json().get("access_token") | |||||
| res = requests.get( | |||||
| cfg['api']['get_beacons_url'], | |||||
| headers={"Authorization": f"Bearer {token}"}, | |||||
| verify=cfg['api'].get('verify_tls', False), | |||||
| timeout=5 | |||||
| ) | |||||
| return res.json(), "OK" | |||||
| except Exception as e: | |||||
| return None, str(e) | |||||
| def show_system_status(cfg, sec=None): | |||||
| st.title("🛰️ Diagnostica & Sniffer") | |||||
| # --- 1. RISORSE DI SISTEMA --- | |||||
| st.subheader("🖥️ Risorse Hardware") | |||||
| m = get_system_metrics() | |||||
| c1, c2, c3, c4 = st.columns(4) | |||||
| c1.metric("CPU", f"{m['cpu']}%") | |||||
| c2.metric("RAM", f"{m['ram']}%") | |||||
| c3.metric("Disco", f"{m['disk']}%") | |||||
| c4.metric("Rete", f"{m['net_recv']:.1f} MB") | |||||
| st.divider() | |||||
| # --- 2. CONNETTIVITÀ & DATI API --- | |||||
| st.subheader("🔌 Connettività & API") | |||||
| col_api, col_mq = st.columns(2) | |||||
| with col_api: | |||||
| st.markdown("**Server API OIDC**") | |||||
| beacons_api_data, api_msg = get_api_data(cfg, sec) | |||||
| if beacons_api_data: | |||||
| st.success(f"✅ Connesso ({len(beacons_api_data)} beacon)") | |||||
| with st.expander("Visualizza Tabella Dati API"): | |||||
| # Mostriamo i dati API in una tabella senza scroll eccessivo | |||||
| df_api = pd.DataFrame(beacons_api_data) | |||||
| st.table(df_api.head(10)) | |||||
| else: | |||||
| st.error(f"❌ Errore: {api_msg}") | |||||
| with col_mq: | |||||
| st.markdown("**Broker MQTT**") | |||||
| mq_host = cfg.get('mqtt', {}).get('host', '127.0.0.1') | |||||
| st.info(f"Host: `{mq_host}`") | |||||
| st.success("✅ Configurazione Caricata") | |||||
| st.divider() | |||||
| # --- 3. SNIFFER DATI MQTT --- | |||||
| st.subheader("🎯 Sniffer Real-Time") | |||||
| st.caption("Inserisci un MAC per intercettare il traffico live (10 secondi)") | |||||
| target_mac = st.text_input("MAC da sniffare (es. ac233fc1dd49)", "").lower().replace(":", "") | |||||
| if st.button("🚀 AVVIA CATTURA", use_container_width=True, type="primary"): | |||||
| if not target_mac: | |||||
| st.warning("Inserisci un MAC prima di iniziare.") | |||||
| else: | |||||
| captured = [] | |||||
| def on_message(client, userdata, message): | |||||
| payload = message.payload.decode() | |||||
| topic = message.topic | |||||
| if target_mac in topic.lower() or target_mac in payload.lower(): | |||||
| captured.append({ | |||||
| "Ora": time.strftime("%H:%M:%S"), | |||||
| "Topic": topic, | |||||
| "Dati": payload[:100] + "..." # Accorciamo per la tabella | |||||
| }) | |||||
| client = mqtt.Client(client_id=f"Sniffer_{int(time.time())}") | |||||
| try: | |||||
| client.on_message = on_message | |||||
| client.connect(cfg['mqtt']['host'], cfg['mqtt']['port'], 60) | |||||
| client.subscribe("#") | |||||
| client.loop_start() | |||||
| with st.status("Cattura in corso...") as status: | |||||
| progress = st.progress(0) | |||||
| for i in range(100): | |||||
| time.sleep(0.1) | |||||
| progress.progress(i + 1) | |||||
| client.loop_stop() | |||||
| status.update(label="Cattura completata!", state="complete") | |||||
| if captured: | |||||
| st.success(f"Intercettati {len(captured)} messaggi per `{target_mac}`") | |||||
| # Usiamo st.table per evitare le slidebar (renderizza tutto il contenuto) | |||||
| st.table(pd.DataFrame(captured)) | |||||
| else: | |||||
| st.warning("Nessun messaggio trovato. Controlla che il dispositivo sia acceso.") | |||||
| except Exception as e: | |||||
| st.error(f"Connessione fallita: {e}") | |||||
| @@ -0,0 +1,181 @@ | |||||
| import streamlit as st | |||||
| import yaml | |||||
| import subprocess | |||||
| import os | |||||
| import web_status # Nuovo modulo | |||||
| # --- COSTANTI E UTILS (Invariati) --- | |||||
| CONFIG_PATH = os.environ.get("CONFIG") or os.environ.get("CONFIG_FILE") or "/config/config.yaml" | |||||
| SECRETS_PATH = os.environ.get("SECRETS_FILE") or "/config/secrets.yaml" | |||||
| LOG_FILE = "/tmp/main_process.log" | |||||
| STATE_FILE = "/data/.web_state" | |||||
| def load_yaml(path): | |||||
| if not os.path.exists(path): return {} | |||||
| with open(path, 'r') as f: return yaml.safe_load(f) or {} | |||||
| def save_yaml(path, data): | |||||
| with open(path, 'w') as f: yaml.dump(data, f, default_flow_style=False) | |||||
| cfg = load_yaml(CONFIG_PATH) | |||||
| # --- CONFIGURAZIONE PAGINA E MENU ABOUT --- | |||||
| st.set_page_config( | |||||
| page_title="BLE Localizer Manager", | |||||
| layout="wide", | |||||
| initial_sidebar_state="auto", | |||||
| menu_items={ | |||||
| 'Get Help': None, | |||||
| 'Report a bug': None, | |||||
| 'About': "# ▒~_~[▒▒~O BLE AI Localizer - Suite\nSistema professionale di posizionamento BLE." | |||||
| } | |||||
| ) | |||||
| st.title("🛰️ BLE AI Localizer - Suite") # Forza il titolo qui | |||||
| # --- CSS OTTIMIZZATO (Sidebar Fina + Mobile) --- | |||||
| st.markdown(""" | |||||
| <style> | |||||
| [data-testid="stSidebar"] { min-width: 200px !important; max-width: 230px !important; } | |||||
| [data-testid="stSidebar"] .stButton > button { width: 100%; height: 2.5rem; border-radius: 8px; font-size: 0.9rem !important; } | |||||
| .stTextInput input, .stSelectbox div, .stNumberInput input { height: 3rem !important; } | |||||
| button[data-baseweb="tab"] { height: 3.5rem !important; } | |||||
| .stButton > button { border-radius: 10px; font-weight: bold; } | |||||
| </style> | |||||
| """, unsafe_allow_html=True) | |||||
| # --- LOGIN (Tua versione originale) --- | |||||
| if "password_correct" not in st.session_state: | |||||
| st.session_state["password_correct"] = False | |||||
| if not st.session_state["password_correct"]: | |||||
| user = st.text_input("Username", key="main_login_user") | |||||
| pw = st.text_input("Password", type="password", key="main_login_pw") | |||||
| if st.button("ACCEDI"): | |||||
| if user == os.environ.get("UI_USER", "admin") and pw == os.environ.get("UI_PASSWORD", "password"): | |||||
| st.session_state["password_correct"] = True | |||||
| st.rerun() | |||||
| else: st.error("Credenziali errate") | |||||
| st.stop() | |||||
| # Import moduli dopo login | |||||
| from map_manager import show_mapper | |||||
| from web_gateway import show_gateway_manager | |||||
| from web_beacon import show_beacon_manager | |||||
| import web_training_data | |||||
| import web_inference | |||||
| def is_proc_alive(): | |||||
| return st.session_state.get('proc') is not None and st.session_state.proc.poll() is None | |||||
| # Auto-riavvio (Tua logica) | |||||
| if os.path.exists(STATE_FILE): | |||||
| with open(STATE_FILE, "r") as f: | |||||
| if f.read().strip() == "running" and not is_proc_alive(): | |||||
| log_out = open(LOG_FILE, "a") | |||||
| st.session_state.proc = subprocess.Popen( | |||||
| ["python", "-u", "-m", "app.main"], | |||||
| env={**os.environ, "PYTHONUNBUFFERED": "1", "CONFIG": CONFIG_PATH}, | |||||
| stdout=log_out, stderr=subprocess.STDOUT, text=True | |||||
| ) | |||||
| # --- SIDEBAR --- | |||||
| with st.sidebar: | |||||
| st.header("Stato Sistema") | |||||
| alive = is_proc_alive() | |||||
| if not alive: | |||||
| if st.button("▶️ AVVIA MAIN"): | |||||
| if os.path.exists(LOG_FILE): os.remove(LOG_FILE) | |||||
| st.session_state.proc = subprocess.Popen( | |||||
| ["python", "-u", "-m", "app.main"], | |||||
| env={**os.environ, "PYTHONUNBUFFERED": "1", "CONFIG": CONFIG_PATH}, | |||||
| stdout=open(LOG_FILE, "w"), stderr=subprocess.STDOUT, text=True | |||||
| ) | |||||
| with open(STATE_FILE, "w") as f: f.write("running") | |||||
| st.rerun() | |||||
| else: | |||||
| if st.button("⏹️ FERMA MAIN"): | |||||
| st.session_state.proc.terminate() | |||||
| st.session_state.proc = None | |||||
| with open(STATE_FILE, "w") as f: f.write("stopped") | |||||
| st.rerun() | |||||
| st.write(f"Stato: {':green[Running]' if alive else ':red[Stopped]'}") | |||||
| st.write(f"Modo: **{cfg.get('mode', 'N/D').upper()}**") | |||||
| if st.button("LOGOUT"): | |||||
| st.session_state["password_correct"] = False | |||||
| st.rerun() | |||||
| st.divider() | |||||
| st.caption("🚦 LIVE STATUS") | |||||
| c1, c2, c3 = st.columns(3) | |||||
| c1.markdown("`MQTT` 🟢") | |||||
| c2.markdown("`GWs` 🟢") | |||||
| c3.markdown("`API` 🟢") | |||||
| # --- TABS (Incluso il nuovo tab Stato) --- | |||||
| tab_log, tab_cfg, tab_sec, tab_gw, tab_beac, tab_map, tab_data, tab_infer, tab_status = st.tabs([ | |||||
| "📜 Log", "⚙️ Config", "🔑 Secrets", "🌐 Gateway", "🏷️ Beacon", "🗺️ Mappa", "📂 Dati", "🤖 Inferenza", "🖥️ Stato" | |||||
| ]) | |||||
| with tab_status: | |||||
| import web_status | |||||
| # Carica i segreti e passali alla funzione | |||||
| sec_data = load_yaml(SECRETS_PATH) | |||||
| web_status.show_system_status(cfg, sec_data) | |||||
| with tab_cfg: | |||||
| # RIPRISTINATA TUTTA LA TUA LOGICA ORIGINALE | |||||
| c_cfg = load_yaml(CONFIG_PATH) | |||||
| st.subheader("🚀 Stato Operativo") | |||||
| mode_options = ["infer", "train", "collect_train"] | |||||
| new_mode = st.selectbox("Modalità:", mode_options, index=mode_options.index(c_cfg.get('mode', 'infer'))) | |||||
| with st.expander("📊 Parametri Raccolta Dati", expanded=True): | |||||
| p_cfg = c_cfg.get('collect_train', {}) | |||||
| col_a, col_b = st.columns(2) | |||||
| win_sec = col_a.number_input("Finestra (sec)", value=int(p_cfg.get('window_seconds', 30))) | |||||
| min_gw = col_a.number_input("Min. Gateway", value=int(p_cfg.get('min_non_nan', 3))) | |||||
| rssi_min = col_b.slider("RSSI Min", -120, -50, int(p_cfg.get('rssi_min', -110))) | |||||
| rssi_max = col_b.slider("RSSI Max", -40, 0, int(p_cfg.get('rssi_max', -25))) | |||||
| outlier = col_b.selectbox("Outlier", ["mad", "iqr", "none"], index=0) | |||||
| with st.expander("📡 MQTT & 🌐 API", expanded=False): | |||||
| m_cfg = c_cfg.get('mqtt', {}) | |||||
| mq_host = st.text_input("Broker", value=m_cfg.get('host', '127.0.0.1')) | |||||
| mq_port = st.number_input("Porta", value=int(m_cfg.get('port', 1883))) | |||||
| a_cfg = c_cfg.get('api', {}) | |||||
| t_url = st.text_input("Token URL", value=a_cfg.get('token_url', '')) | |||||
| v_tls = st.checkbox("Verify TLS", value=a_cfg.get('verify_tls', False)) | |||||
| with st.expander("🛠️ Expert Mode (YAML)", expanded=False): | |||||
| cfg_text = st.text_area("Edit manuale", yaml.dump(c_cfg), height=300) | |||||
| if st.button("💾 SALVA TUTTO"): | |||||
| try: | |||||
| final_cfg = yaml.safe_load(cfg_text) | |||||
| final_cfg['mode'] = new_mode | |||||
| # Aggiorna con i valori dei widget per sicurezza | |||||
| if 'collect_train' not in final_cfg: final_cfg['collect_train'] = {} | |||||
| final_cfg['collect_train'].update({'window_seconds': win_sec, 'rssi_min': rssi_min}) | |||||
| save_yaml(CONFIG_PATH, final_cfg) | |||||
| st.success("Salvato!") | |||||
| except Exception as e: st.error(e) | |||||
| with tab_sec: | |||||
| sec = load_yaml(SECRETS_PATH) | |||||
| sec_edit = st.text_area("Secrets YAML", yaml.dump(sec), height=200) | |||||
| if st.button("SALVA SECRETS"): | |||||
| save_yaml(SECRETS_PATH, yaml.safe_load(sec_edit)) | |||||
| st.success("ApiToken salvati!") | |||||
| with tab_log: | |||||
| if os.path.exists(LOG_FILE): | |||||
| with open(LOG_FILE, "r") as f: st.code("".join(f.readlines()[-50:])) | |||||
| if st.button("🔄 AGGIORNA LOG"): st.rerun() | |||||
| with tab_map: show_mapper(cfg) | |||||
| with tab_gw: show_gateway_manager(load_yaml(CONFIG_PATH)) | |||||
| with tab_beac: show_beacon_manager(load_yaml(CONFIG_PATH)) | |||||
| with tab_data: web_training_data.show_training_data_manager(cfg) | |||||
| with tab_infer: web_inference.show_inference_page(cfg) | |||||
| @@ -0,0 +1,72 @@ | |||||
| import streamlit as st | |||||
| import os | |||||
| import pandas as pd | |||||
| from pathlib import Path | |||||
| import time | |||||
| from datetime import datetime | |||||
| def show_training_data_manager(cfg): | |||||
| st.subheader("📂 Gestione Campioni Training") | |||||
| try: | |||||
| raw_path = cfg.get('collect_train', {}).get('samples_dir', '/data/train/samples') | |||||
| samples_dir = Path(raw_path) | |||||
| except Exception as e: | |||||
| st.error(f"Errore config: {e}") | |||||
| return | |||||
| # --- 1. RECUPERO E FILTRO DATI --- | |||||
| all_files = [f for f in os.listdir(samples_dir) if f.endswith('.csv')] | |||||
| files_data = [] | |||||
| for file in all_files: | |||||
| path = samples_dir / file | |||||
| ts = os.path.getmtime(path) | |||||
| files_data.append({ | |||||
| "File": file, | |||||
| "Data": datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M"), | |||||
| "ts": ts | |||||
| }) | |||||
| df = pd.DataFrame(files_data).sort_values("ts", ascending=False) | |||||
| # Barra di ricerca | |||||
| search = st.text_input("🔍 Cerca coordinata:", "").strip().lower() | |||||
| if search: | |||||
| df = df[df['File'].str.lower().str.contains(search)] | |||||
| # --- 2. VISUALIZZAZIONE COMPATTA (SENZA TREMOLIO) --- | |||||
| # Usiamo le colonne per restringere la tabella al centro | |||||
| col_tab, col_empty = st.columns([3, 1]) | |||||
| with col_tab: | |||||
| # st.table non ha scrollbar e non ha interazioni JS che causano tremolio | |||||
| st.table(df[["File", "Data"]].head(20)) | |||||
| st.divider() | |||||
| # --- 3. AZIONI --- | |||||
| st.markdown("### 🛠️ Azioni") | |||||
| selected = st.selectbox("Seleziona file per operare:", df["File"].tolist()) | |||||
| if selected: | |||||
| file_path = samples_dir / selected | |||||
| c1, c2, c3 = st.columns([1,1,1]) | |||||
| with c1: | |||||
| if st.button("👁️ Dettagli", use_container_width=True): | |||||
| content = pd.read_csv(file_path, sep=";") | |||||
| st.markdown(f"**Dati di `{selected}`**") | |||||
| st.table(content.T.reset_index().rename(columns={'index': 'AP', 0: 'RSSI'})) | |||||
| with c2: | |||||
| with open(file_path, "rb") as f: | |||||
| st.download_button("📥 Scarica", f, file_name=selected, use_container_width=True) | |||||
| with c3: | |||||
| if st.button("🗑️ Elimina", use_container_width=True, type="primary"): | |||||
| os.remove(file_path) | |||||
| st.rerun() | |||||
| # Stats discrete in sidebar | |||||
| st.sidebar.caption(f"Totale campioni: {len(all_files)}") | |||||
| @@ -0,0 +1,113 @@ | |||||
| api: | |||||
| audience: Fastapi | |||||
| client_id: Fastapi | |||||
| get_beacons_url: https://10.251.0.30:5050/reslevis/getTrackers | |||||
| get_gateways_url: https://10.251.0.30:5050/reslevis/getGateways | |||||
| refresh_seconds: 30 | |||||
| timeout_s: 10.0 | |||||
| token_url: https://10.251.0.30:10002/realms/API.Server.local/protocol/openid-connect/token | |||||
| verify_tls: false | |||||
| collect_train: | |||||
| aggregate: median | |||||
| gateway_ready_check_before_job: true | |||||
| gateway_ready_log_seconds: 10 | |||||
| gateway_ready_max_age_seconds: 30 | |||||
| iqr_k: 1.5 | |||||
| job_glob: '*.csv' | |||||
| jobs_dir: /data/train/jobs | |||||
| mad_z: 3.5 | |||||
| max_stddev: 8 | |||||
| min_non_nan: 3 | |||||
| min_samples_per_gateway: 5 | |||||
| outlier_method: mad | |||||
| poll_seconds: 2 | |||||
| rssi_decimals: 3 | |||||
| rssi_max: -25 | |||||
| rssi_min: -110 | |||||
| samples_dir: /data/train/samples | |||||
| window_seconds: 30 | |||||
| debug: | |||||
| collect_train_log_every_seconds: 10 | |||||
| collect_train_log_first_seen: true | |||||
| collect_train_log_samples: true | |||||
| csv: true | |||||
| dump_beacons_api: true | |||||
| dump_gateways_api: true | |||||
| enabled: true | |||||
| log_each_mqtt_message: false | |||||
| log_inference_detail: true | |||||
| log_mqtt_raw: false | |||||
| monitor_online: true | |||||
| offline_after_seconds_beacons: 240 | |||||
| offline_after_seconds_gateways: 90 | |||||
| online_check_seconds: 30 | |||||
| show_library_warnings: false | |||||
| target_mac: '' | |||||
| timezone: Europe/Rome | |||||
| train_stats_top_k: 8 | |||||
| train_verbose: true | |||||
| infer: | |||||
| aggregate: median | |||||
| gateways_csv: /data/config/gateway.csv | |||||
| include_mac: true | |||||
| mad_z: 3.5 | |||||
| model_path: /data/model/model.joblib | |||||
| nan_fill: -110 | |||||
| out_file: infer.csv | |||||
| outlier_method: mad | |||||
| output_dir: /data/infer | |||||
| output_format: csv | |||||
| refresh_seconds: 10 | |||||
| rssi_max: -25 | |||||
| rssi_min: -110 | |||||
| window_seconds: 5 | |||||
| xy_round: 0 | |||||
| maps: | |||||
| floor_prefix: floor_ | |||||
| map_dir: /data/maps | |||||
| meta_prefix: meta_ | |||||
| default_grid_size: 100 # Dimensione griglia predefinita in cm | |||||
| default_dot_size: 20 # Dimensione predefinita dei marker | |||||
| show_grid_default: true # Stato iniziale della griglia | |||||
| ml: | |||||
| k: 5 | |||||
| method: knn | |||||
| metric: euclidean | |||||
| weights: distance | |||||
| mode: infer | |||||
| mqtt: | |||||
| ca_file: '' | |||||
| client_id: ble-ai-localizer | |||||
| host: 192.168.1.101 | |||||
| keepalive: 60 | |||||
| password: '' | |||||
| port: 1883 | |||||
| protocol: mqttv311 | |||||
| qos: 0 | |||||
| tls: false | |||||
| topic: publish_out/# | |||||
| username: '' | |||||
| verify_tls: false | |||||
| paths: | |||||
| beacons_csv: /data/config/beacons.csv | |||||
| csv_delimiter: ; | |||||
| dataset: /data/fingerprint.parquet | |||||
| gateways_csv: /data/config/gateway.csv | |||||
| train: | |||||
| backup_existing_model: true | |||||
| gateways_csv: /data/config/gateway.csv | |||||
| knn: | |||||
| cv: | |||||
| enabled: false | |||||
| folds: 5 | |||||
| k_max: 15 | |||||
| k_min: 3 | |||||
| k: 5 | |||||
| metric: euclidean | |||||
| weights: distance | |||||
| model_path: /data/model/model.joblib | |||||
| nan_fill: -110 | |||||
| sample_glob: '*.csv' | |||||
| samples_dir: /data/train/samples | |||||
| ui: | |||||
| font_path: /app/DejaVuSans-Bold.ttf | |||||
| @@ -0,0 +1,4 @@ | |||||
| oidc: | |||||
| client_secret: "wojuoB7Z5xhlPFrF2lIxJSSdVHCApEgC" | |||||
| username: "core" | |||||
| password: "C0r3_us3r_Cr3d3nt14ls" | |||||
| @@ -0,0 +1 @@ | |||||
| running | |||||
| @@ -0,0 +1,6 @@ | |||||
| BeaconName;MAC | |||||
| BC-21;C3:00:00:57:B9:E6 | |||||
| BC-22;C3:00:00:57:B9:D4 | |||||
| BC-23;C3:00:00:57:B9:E8 | |||||
| BC-24;C3:00:00:57:B9:F1 | |||||
| BC-25;C3:00:00:57:B9:E7 | |||||
| @@ -0,0 +1,17 @@ | |||||
| Position;Floor;RoomName;X;Y;Z;GatewayName;MAC | |||||
| C01;0;PT-MAGA;220;250;0;GU-01;ac:23:3f:c1:dd:3c | |||||
| C02;0;PT-FORM;825;745;0;GU-02;ac:23:3f:c1:dd:49 | |||||
| C03;0;PT-LVNS;825;1435;0;GU-03;ac:23:3f:c1:dc:ee | |||||
| C04;0;PT-RECE;2010;620;0;GU-04;ac:23:3f:c1:dd:40 | |||||
| C05;0;PT-AMMI;1785;1260;0;GU-05;ac:23:3f:c1:dd:51 | |||||
| C06;0;PT-PROD;2720;1220;0;GU-06;ac:23:3f:c1:dd:48 | |||||
| C07;0;PT-BATH;2800;655;0;GU-07;ac:23:3f:c1:dd:50 | |||||
| C08;0;PT-MENS;2580;490;0;GU-08;ac:23:3f:c1:dc:d3 | |||||
| C09;1;P1-AMOR;900;50;1;GU-09;ac:23:3f:c1:dd:55 | |||||
| C10;1;P1-NETW;1310;1440;1;GU-10;ac:23:3f:c1:dc:d1 | |||||
| C11;1;P1-DINO;1662;480;1;GU-11;ac:23:3f:c1:dc:cb | |||||
| C12;1;P1-COMM;1575;1455;1;GU-12;ac:23:3f:c1:dc:d2 | |||||
| C13;1;P1-SOFT;2290;965;1;GU-13;ac:23:3f:c1:dd:31 | |||||
| C14;1;P1-CUCO;2860;1120;1;GU-14;ac:23:3f:c1:dd:4b | |||||
| C15;1;P1-BATH;2740;710;1;GU-15;ac:23:3f:c1:dd:4e | |||||
| C16;1;P1-RIUN;2180;355;1;GU-16;ac:23:3f:c1:dc:cd | |||||
| @@ -0,0 +1,13 @@ | |||||
| mac;z;x;y | |||||
| C8:3F:8F:17:DB:35;1;1061;1290 | |||||
| C3:00:00:39:47:DF;-1;-1;-1 | |||||
| C3:00:00:39:47:C4;1;1386;1501 | |||||
| C3:00:00:39:47:E2;-1;-1;-1 | |||||
| C7:AE:56:1E:38:B7;1;1311;1501 | |||||
| E0:1F:9A:7A:47:D2;-1;-1;-1 | |||||
| C3:00:00:57:B9:D9;1;1060;1380 | |||||
| C3:00:00:57:B9:DB;1;1056;1383 | |||||
| C3:00:00:57:B9:F4;1;1061;1292 | |||||
| C3:00:00:57:B9:DC;1;1060;1294 | |||||
| C3:00:00:57:B9:DD;1;1216;1444 | |||||
| C3:00:00:57:B9:DF;1;1059;1296 | |||||
| @@ -0,0 +1 @@ | |||||
| {"pixel_ratio": 1.574668658056015, "calibrated": true, "origin": [1193, 1031], "grid_size": 50, "show_grid": true} | |||||
| @@ -0,0 +1 @@ | |||||
| {"pixel_ratio": 1.5919341046277666, "calibrated": true, "origin": [1251, 1143], "grid_size": 50} | |||||
| @@ -0,0 +1,2 @@ | |||||
| Position;Floor;RoomName;X;Y;Z;BeaconName;MAC | |||||
| P_1450_500;0;Area_Rilevazione;1450;500;0;BC-21;C3:00:00:57:B9:E6 | |||||
| @@ -0,0 +1,2 @@ | |||||
| Position;Floor;RoomName;X;Y;Z;BeaconName;MAC | |||||
| P_1550_850;0;Area_Rilevazione;1550;850;0;BC-21;C3:00:00:57:B9:E6 | |||||
| @@ -0,0 +1,2 @@ | |||||
| Position;Floor;RoomName;X;Y;Z;BeaconName;MAC | |||||
| P_1050_1450;1;Area_Mappatura;1050;1450;1;BC-21;C3:00:00:57:B9:E6 | |||||
| @@ -0,0 +1,2 @@ | |||||
| Position;Floor;RoomName;X;Y;Z;BeaconName;MAC | |||||
| P_1250_600;1;Area_Rilevazione;1250;600;0;BC-21;C3:00:00:57:B9:E6 | |||||
| @@ -0,0 +1,2 @@ | |||||
| Position;Floor;RoomName;X;Y;Z;BeaconName;MAC | |||||
| P_1600_450;1;Area_Rilevazione;1600;450;0;BC-21;C3:00:00:57:B9:E6 | |||||
| @@ -0,0 +1,2 @@ | |||||
| mac;x;y;z;AC:23:3F:C1:DD:3C;AC:23:3F:C1:DD:49;AC:23:3F:C1:DC:EE;AC:23:3F:C1:DD:40;AC:23:3F:C1:DD:51;AC:23:3F:C1:DD:48;AC:23:3F:C1:DD:50;AC:23:3F:C1:DC:D3;AC:23:3F:C1:DD:55;AC:23:3F:C1:DC:D1;AC:23:3F:C1:DC:CB;AC:23:3F:C1:DC:D2;AC:23:3F:C1:DD:31;AC:23:3F:C1:DD:4B;AC:23:3F:C1:DD:4E;AC:23:3F:C1:DC:CD | |||||
| C3:00:00:57:B9:E6;1250;600;0;-79.000;-85.000;-75.000;nan;nan;nan;nan;nan;-76.000;-53.000;-78.000;-67.000;-75.000;-81.000;-83.000;-78.000 | |||||
| @@ -0,0 +1,2 @@ | |||||
| mac;x;y;z;AC:23:3F:C1:DD:3C;AC:23:3F:C1:DD:49;AC:23:3F:C1:DC:EE;AC:23:3F:C1:DD:40;AC:23:3F:C1:DD:51;AC:23:3F:C1:DD:48;AC:23:3F:C1:DD:50;AC:23:3F:C1:DC:D3;AC:23:3F:C1:DD:55;AC:23:3F:C1:DC:D1;AC:23:3F:C1:DC:CB;AC:23:3F:C1:DC:D2;AC:23:3F:C1:DD:31;AC:23:3F:C1:DD:4B;AC:23:3F:C1:DD:4E;AC:23:3F:C1:DC:CD | |||||
| C3:00:00:57:B9:E6;1350;700;0;-79.000;nan;-78.000;nan;nan;nan;nan;nan;-77.000;-54.000;-75.000;-65.000;-76.500;-80.000;-83.000;-77.000 | |||||
| @@ -0,0 +1,2 @@ | |||||
| mac;x;y;z;AC:23:3F:C1:DD:3C;AC:23:3F:C1:DD:49;AC:23:3F:C1:DC:EE;AC:23:3F:C1:DD:40;AC:23:3F:C1:DD:51;AC:23:3F:C1:DD:48;AC:23:3F:C1:DD:50;AC:23:3F:C1:DC:D3;AC:23:3F:C1:DD:55;AC:23:3F:C1:DC:D1;AC:23:3F:C1:DC:CB;AC:23:3F:C1:DC:D2;AC:23:3F:C1:DD:31;AC:23:3F:C1:DD:4B;AC:23:3F:C1:DD:4E;AC:23:3F:C1:DC:CD | |||||
| C3:00:00:57:B9:E6;1450;500;0;-79.000;nan;-83.000;nan;nan;nan;nan;nan;-75.000;-53.000;-72.000;-66.000;-76.500;-81.000;-83.000;-77.000 | |||||
| @@ -0,0 +1,2 @@ | |||||
| mac;x;y;z;AC:23:3F:C1:DD:3C;AC:23:3F:C1:DD:49;AC:23:3F:C1:DC:EE;AC:23:3F:C1:DD:40;AC:23:3F:C1:DD:51;AC:23:3F:C1:DD:48;AC:23:3F:C1:DD:50;AC:23:3F:C1:DC:D3;AC:23:3F:C1:DD:55;AC:23:3F:C1:DC:D1;AC:23:3F:C1:DC:CB;AC:23:3F:C1:DC:D2;AC:23:3F:C1:DD:31;AC:23:3F:C1:DD:4B;AC:23:3F:C1:DD:4E;AC:23:3F:C1:DC:CD | |||||
| C3:00:00:57:B9:E6;1550;850;0;-79.000;nan;-76.000;nan;nan;nan;nan;nan;-76.000;-55.000;-76.000;-70.000;-78.000;-81.000;-83.000;-78.000 | |||||
| @@ -0,0 +1,2 @@ | |||||
| mac;x;y;z;AC:23:3F:C1:DD:3C;AC:23:3F:C1:DD:49;AC:23:3F:C1:DC:EE;AC:23:3F:C1:DD:40;AC:23:3F:C1:DD:51;AC:23:3F:C1:DD:48;AC:23:3F:C1:DD:50;AC:23:3F:C1:DC:D3;AC:23:3F:C1:DD:55;AC:23:3F:C1:DC:D1;AC:23:3F:C1:DC:CB;AC:23:3F:C1:DC:D2;AC:23:3F:C1:DD:31;AC:23:3F:C1:DD:4B;AC:23:3F:C1:DD:4E;AC:23:3F:C1:DC:CD | |||||
| C3:00:00:57:B9:E6;1050;1450;1;-79.000;nan;-78.000;nan;nan;nan;nan;nan;-75.000;-53.000;-77.000;-64.000;-74.000;-79.000;-82.000;-78.000 | |||||
| @@ -0,0 +1,2 @@ | |||||
| mac;x;y;z;AC:23:3F:C1:DD:3C;AC:23:3F:C1:DD:49;AC:23:3F:C1:DC:EE;AC:23:3F:C1:DD:40;AC:23:3F:C1:DD:51;AC:23:3F:C1:DD:48;AC:23:3F:C1:DD:50;AC:23:3F:C1:DC:D3;AC:23:3F:C1:DD:55;AC:23:3F:C1:DC:D1;AC:23:3F:C1:DC:CB;AC:23:3F:C1:DC:D2;AC:23:3F:C1:DD:31;AC:23:3F:C1:DD:4B;AC:23:3F:C1:DD:4E;AC:23:3F:C1:DC:CD | |||||
| C3:00:00:57:B9:E7;1195;1315;1;-82.000;-82.000;nan;nan;nan;nan;nan;nan;-78.000;-51.000;-73.500;-56.000;-74.000;nan;nan;-80.000 | |||||
| @@ -0,0 +1,2 @@ | |||||
| mac;x;y;z;AC:23:3F:C1:DD:3C;AC:23:3F:C1:DD:49;AC:23:3F:C1:DC:EE;AC:23:3F:C1:DD:40;AC:23:3F:C1:DD:51;AC:23:3F:C1:DD:48;AC:23:3F:C1:DD:50;AC:23:3F:C1:DC:D3;AC:23:3F:C1:DD:55;AC:23:3F:C1:DC:D1;AC:23:3F:C1:DC:CB;AC:23:3F:C1:DC:D2;AC:23:3F:C1:DD:31;AC:23:3F:C1:DD:4B;AC:23:3F:C1:DD:4E;AC:23:3F:C1:DC:CD | |||||
| C3:00:00:57:B9:F1;1400;1530;1;-79.000;nan;-78.000;nan;nan;nan;nan;nan;-83.000;-38.000;-77.000;-53.000;-75.000;nan;nan;-79.000 | |||||
| @@ -0,0 +1,2 @@ | |||||
| mac;x;y;z;AC:23:3F:C1:DD:3C;AC:23:3F:C1:DD:49;AC:23:3F:C1:DC:EE;AC:23:3F:C1:DD:40;AC:23:3F:C1:DD:51;AC:23:3F:C1:DD:48;AC:23:3F:C1:DD:50;AC:23:3F:C1:DC:D3;AC:23:3F:C1:DD:55;AC:23:3F:C1:DC:D1;AC:23:3F:C1:DC:CB;AC:23:3F:C1:DC:D2;AC:23:3F:C1:DD:31;AC:23:3F:C1:DD:4B;AC:23:3F:C1:DD:4E;AC:23:3F:C1:DC:CD | |||||
| C3:00:00:57:B9:E8;1425;1050;1;-79.500;nan;nan;nan;nan;nan;nan;nan;-75.000;-59.000;-78.500;-66.000;nan;nan;-84.000;-78.000 | |||||
| @@ -0,0 +1,2 @@ | |||||
| mac;x;y;z;AC:23:3F:C1:DD:3C;AC:23:3F:C1:DD:49;AC:23:3F:C1:DC:EE;AC:23:3F:C1:DD:40;AC:23:3F:C1:DD:51;AC:23:3F:C1:DD:48;AC:23:3F:C1:DD:50;AC:23:3F:C1:DC:D3;AC:23:3F:C1:DD:55;AC:23:3F:C1:DC:D1;AC:23:3F:C1:DC:CB;AC:23:3F:C1:DC:D2;AC:23:3F:C1:DD:31;AC:23:3F:C1:DD:4B;AC:23:3F:C1:DD:4E;AC:23:3F:C1:DC:CD | |||||
| C3:00:00:57:B9:E6;800;1050;1;-78.000;-82.000;-75.500;nan;nan;nan;nan;nan;-73.500;-53.000;-78.000;-66.000;-74.000;nan;-83.000;-77.000 | |||||
| @@ -0,0 +1,2 @@ | |||||
| mac;x;y;z;AC:23:3F:C1:DD:3C;AC:23:3F:C1:DD:49;AC:23:3F:C1:DC:EE;AC:23:3F:C1:DD:40;AC:23:3F:C1:DD:51;AC:23:3F:C1:DD:48;AC:23:3F:C1:DD:50;AC:23:3F:C1:DC:D3;AC:23:3F:C1:DD:55;AC:23:3F:C1:DC:D1;AC:23:3F:C1:DC:CB;AC:23:3F:C1:DC:D2;AC:23:3F:C1:DD:31;AC:23:3F:C1:DD:4B;AC:23:3F:C1:DD:4E;AC:23:3F:C1:DC:CD | |||||
| C3:00:00:57:B9:D4;850;1545;1;nan;-84.000;-78.000;nan;nan;nan;nan;nan;-79.000;-61.500;-80.000;-66.000;nan;-81.000;-82.000;-74.000 | |||||
| @@ -0,0 +1,20 @@ | |||||
| services: | |||||
| ble-ai-localizer: | |||||
| build: . | |||||
| image: ble-ai-localizer:0.1.0 | |||||
| environment: | |||||
| CONFIG: "/config/config.yaml" # Nome usato da main.py | |||||
| SECRETS_FILE: "/config/secrets.yaml" | |||||
| UI_USER: "Admin" | |||||
| UI_PASSWORD: "pwdadmin1" | |||||
| STREAMLIT_SERVER_RUN_ON_SAVE: "false" | |||||
| STREAMLIT_SERVER_FILE_WATCHER_TYPE: "none" | |||||
| restart: always | |||||
| ports: | |||||
| - "8501:8501" | |||||
| volumes: | |||||
| - ./config:/config # Rimosso :ro per permettere il salvataggio | |||||
| - ./data:/data | |||||
| - ./models:/models | |||||
| - ./tmp:/tmp | |||||
| command: streamlit run app/web_suite.py --server.address=0.0.0.0 | |||||
| @@ -0,0 +1,12 @@ | |||||
| #!/bin/sh | |||||
| set -e | |||||
| # forza stdout/stderr non bufferizzati | |||||
| export PYTHONUNBUFFERED=1 | |||||
| # Avvio neutro: la modalita' viene letta dal config (config.yaml) | |||||
| #exec python -u -m app.main | |||||
| # Lanciamo l'interfaccia Web come processo principale | |||||
| # Sara' poi lei a gestire l'avvio di app.main | |||||
| exec streamlit run app/web_suite.py --server.port=8501 --server.address=0.0.0.0 | |||||
| @@ -0,0 +1,466 @@ | |||||
| Progetto Container Docker ble-ai-localizer | |||||
| Ambiente: MN reslevis 192.168.1.3 | |||||
| m1.MajorNet-x64.6.6.0-60.bin:03 October 2025 | |||||
| server linux gentoo kernl: 6.6.74-gentoo-x86_64 Python default version 3.10.16 | |||||
| cd /data/service/ | |||||
| ########################################################################################### | |||||
| Passo 1 Creazione struttura progetto (sul server) | |||||
| mkdir -p ble-ai-localizer/{app,config,data,models} | |||||
| cd /data/service/ble-ai-localizer | |||||
| touch Dockerfile requirements.txt entrypoint.sh docker-compose.yml | |||||
| touch app/{__init__.py,main.py,settings.py,auth.py,mqtt_client.py,train.py,infer.py} | |||||
| touch config/config.yaml config/secrets.yaml | |||||
| Cosa hai ottenuto | |||||
| app/ codice python (training + inference) | |||||
| config/ configurazione (config.yaml versionabile + secrets.yaml non versionabile) | |||||
| data/ dataset fingerprint (volume) | |||||
| models/ modello salvato (volume) | |||||
| ########################################################################################### | |||||
| Passo 2 .gitignore (consigliato subito) | |||||
| Se usi git: | |||||
| cat > .gitignore << 'EOF' | |||||
| # secrets e artefatti | |||||
| config/secrets.yaml | |||||
| data/ | |||||
| models/ | |||||
| # python | |||||
| __pycache__/ | |||||
| *.pyc | |||||
| .venv/ | |||||
| # editor | |||||
| .vscode/ | |||||
| .idea/ | |||||
| EOF | |||||
| ########################################################################################### | |||||
| Passo 3 Verifica prerequisiti Docker (Gentoo) | |||||
| Esegui: | |||||
| docker --version | |||||
| docker compose version | |||||
| Se docker compose non esiste, di solito su Gentoo: | |||||
| o hai il plugin docker-cli-compose | |||||
| o usi docker-compose (legacy). In tal caso dimmelo e ti adatto i comandi. | |||||
| Quando hai eseguito i comandi del Passo 1–3, nel prossimo step ti faccio creare: | |||||
| requirements.txt minimale + pinned | |||||
| Dockerfile compatibile (python:3.10-slim) | |||||
| docker-compose.yml con volumi (config/, data/, models/) e variabile MODE=train|infer | |||||
| Se vuoi andare avanti subito senza feedback, incolla qui l’output di: | |||||
| docker --version | |||||
| docker compose version | |||||
| docker compose version | |||||
| Docker version 27.4.1, build b9d17eaebb55b7652ce37ae5c7c52fcb34194956 | |||||
| Docker Compose version v2.28.1 | |||||
| Perfetto, sei a posto (Docker 27.4.1 + Compose v2.28.1). | |||||
| ########################################################################################### | |||||
| Passo 4 — Popoliamo i file base (requirements, Dockerfile, entrypoint, compose) | |||||
| 4.1 requirements.txt | |||||
| Metti dentro (pinned e minimale per il tuo caso): | |||||
| cat > requirements.txt << 'EOF' | |||||
| pandas==2.2.2 | |||||
| numpy==1.26.4 | |||||
| scikit-learn==1.5.1 | |||||
| joblib==1.4.2 | |||||
| paho-mqtt==2.1.0 | |||||
| requests==2.32.3 | |||||
| PyYAML==6.0.2 | |||||
| fastapi==0.115.0 | |||||
| uvicorn[standard]==0.30.6 | |||||
| matplotlib==3.9.2 | |||||
| seaborn==0.13.2 | |||||
| EOF | |||||
| (Se poi decidiamo che plot non servono in produzione, li togliamo.) | |||||
| 4.2 Dockerfile (Python “fissato”, non-root, robusto) | |||||
| cat > Dockerfile << 'EOF' | |||||
| FROM python:3.10-slim | |||||
| # dipendenze native utili a numpy/scikit (safe choice) | |||||
| RUN apt-get update && apt-get install -y --no-install-recommends \ | |||||
| build-essential gcc g++ \ | |||||
| && rm -rf /var/lib/apt/lists/* | |||||
| WORKDIR /app | |||||
| COPY requirements.txt /app/requirements.txt | |||||
| RUN pip install --no-cache-dir -r /app/requirements.txt | |||||
| COPY app/ /app/app/ | |||||
| COPY entrypoint.sh /app/entrypoint.sh | |||||
| RUN chmod +x /app/entrypoint.sh | |||||
| # utente non-root | |||||
| RUN useradd -m appuser | |||||
| USER appuser | |||||
| # cartelle dati/modelli (volumi) | |||||
| RUN mkdir -p /home/appuser/data /home/appuser/models | |||||
| ENV DATA_DIR=/home/appuser/data | |||||
| ENV MODELS_DIR=/home/appuser/models | |||||
| ENTRYPOINT ["/app/entrypoint.sh"] | |||||
| EOF | |||||
| 4.3 entrypoint.sh (MODE=train|infer|api) | |||||
| cat > entrypoint.sh << 'EOF' | |||||
| #!/usr/bin/env bash | |||||
| set -euo pipefail | |||||
| MODE="${MODE:-infer}" | |||||
| case "$MODE" in | |||||
| train) | |||||
| exec python -m app.main train | |||||
| ;; | |||||
| infer) | |||||
| exec python -m app.main infer | |||||
| ;; | |||||
| api) | |||||
| exec uvicorn app.main:api --host 0.0.0.0 --port "${PORT:-8080}" | |||||
| ;; | |||||
| *) | |||||
| echo "MODE non valido: $MODE (usa train|infer|api)" | |||||
| exit 1 | |||||
| ;; | |||||
| esac | |||||
| EOF | |||||
| Poi rendilo eseguibile: | |||||
| chmod +x entrypoint.sh | |||||
| 4.4 docker-compose.yml (volumi + config esterna) | |||||
| cat > docker-compose.yml << 'EOF' | |||||
| services: | |||||
| ble-ai-localizer: | |||||
| build: . | |||||
| image: ble-ai-localizer:0.1.0 | |||||
| environment: | |||||
| MODE: "infer" | |||||
| CONFIG_FILE: "/app/config/config.yaml" | |||||
| SECRETS_FILE: "/app/config/secrets.yaml" | |||||
| volumes: | |||||
| - ./config:/app/config:ro | |||||
| - ./data:/data | |||||
| - ./models:/models | |||||
| restart: unless-stopped | |||||
| EOF | |||||
| 4.5 config/config.yaml (scheletro) | |||||
| cat > config/config.yaml << 'EOF' | |||||
| mqtt: | |||||
| host: "mosquitto" | |||||
| port: 1883 | |||||
| topic: "ble/raw" | |||||
| api: | |||||
| get_gateways_url: "https://APIHOST:5050/reslevis/getGateways" | |||||
| verify_tls: false | |||||
| refresh_seconds: 300 | |||||
| oidc: | |||||
| token_url: "https://KEYCLOAK/realms/REALM/protocol/openid-connect/token" | |||||
| client_id: "Fastapi" | |||||
| audience: "Fastapi" | |||||
| paths: | |||||
| dataset: "/data/fingerprint.parquet" | |||||
| model: "/models/model.joblib" | |||||
| ml: | |||||
| # parametri generali; l'algoritmo è un dettaglio interno | |||||
| method: "knn" | |||||
| k: 7 | |||||
| weights: "distance" | |||||
| metric: "euclidean" | |||||
| EOF | |||||
| 4.6 config/secrets.yaml (placeholder, non versionare) | |||||
| cat > config/secrets.yaml << 'EOF' | |||||
| oidc: | |||||
| client_secret: "CHANGE_ME" | |||||
| username: "CHANGE_ME" | |||||
| password: "CHANGE_ME" | |||||
| EOF | |||||
| ########################################################################################### | |||||
| Passo 5 Metti un main minimale per verificare che il container parte | |||||
| app/main.py | |||||
| cat > app/main.py << 'EOF' | |||||
| from fastapi import FastAPI | |||||
| from .settings import load_settings | |||||
| api = FastAPI() | |||||
| @api.get("/health") | |||||
| def health(): | |||||
| return {"status": "ok"} | |||||
| def main(): | |||||
| import sys | |||||
| settings = load_settings() | |||||
| print("Settings loaded. Keys:", list(settings.keys())) | |||||
| if len(sys.argv) < 2: | |||||
| raise SystemExit("Usage: python -m app.main [train|infer]") | |||||
| cmd = sys.argv[1].lower() | |||||
| if cmd == "train": | |||||
| print("TRAIN mode (placeholder)") | |||||
| elif cmd == "infer": | |||||
| print("INFER mode (placeholder)") | |||||
| else: | |||||
| raise SystemExit("Unknown command") | |||||
| if __name__ == "__main__": | |||||
| main() | |||||
| EOF | |||||
| app/settings.py | |||||
| cat > app/settings.py << 'EOF' | |||||
| import os | |||||
| from pathlib import Path | |||||
| import yaml | |||||
| def _read_yaml(path: str) -> dict: | |||||
| with open(path, "r", encoding="utf-8") as f: | |||||
| return yaml.safe_load(f) or {} | |||||
| def deep_merge(a: dict, b: dict) -> dict: | |||||
| out = dict(a or {}) | |||||
| for k, v in (b or {}).items(): | |||||
| if isinstance(v, dict) and isinstance(out.get(k), dict): | |||||
| out[k] = deep_merge(out[k], v) | |||||
| else: | |||||
| out[k] = v | |||||
| return out | |||||
| def load_settings() -> dict: | |||||
| cfg_path = os.getenv("CONFIG_FILE", "/app/config/config.yaml") | |||||
| settings = _read_yaml(cfg_path) | |||||
| secrets_path = os.getenv("SECRETS_FILE", "") | |||||
| if secrets_path and Path(secrets_path).exists(): | |||||
| secrets = _read_yaml(secrets_path) | |||||
| settings = deep_merge(settings, secrets) | |||||
| # fallback paths (coerenti con compose) | |||||
| settings.setdefault("paths", {}) | |||||
| settings["paths"].setdefault("dataset", os.getenv("DATASET_PATH", "/data/fingerprint.parquet")) | |||||
| settings["paths"].setdefault("model", os.getenv("MODEL_PATH", "/models/model.joblib")) | |||||
| return settings | |||||
| EOF | |||||
| (Gli altri file per ora possono restare vuoti.) | |||||
| ########################################################################################### | |||||
| Passo 6 Build & test smoke test | |||||
| docker compose build | |||||
| ########################################################################################### | |||||
| ### END prima creazione container ################# | |||||
| ########################################################################################### | |||||
| ########################################################################################### | |||||
| 1) Come avviare SOLO ble-ai-localizer (senza impattare gli altri) | |||||
| Vai nella directory del progetto: | |||||
| cd /data/service/ble-ai-localizer | |||||
| Avvio in background: | |||||
| docker compose -p ble-ai-localizer up -d | |||||
| Perché -p ble-ai-localizer? | |||||
| Imposta esplicitamente il project name, così sei sicuro al 100% di nonagganciare per errore un altro compose. | |||||
| Verifica stato (solo di questo progetto): | |||||
| docker compose -p ble-ai-localizer ps | |||||
| Log (solo di questo progetto): | |||||
| docker compose -p ble-ai-localizer logs -f | |||||
| 2) Come stoppare/riavviare SOLO ble-ai-localizer | |||||
| Stop (non rimuove container): | |||||
| docker compose -p ble-ai-localizer stop | |||||
| Restart: | |||||
| docker compose -p ble-ai-localizer restart | |||||
| Stop + rimozione container/network del progetto (NON tocca volumi bind ./data, ./models): | |||||
| docker compose -p ble-ai-localizer down | |||||
| 3) Aggiornare solo il tuo container (codice o Dockerfile cambiato) | |||||
| Ricostruisci l'immagine: | |||||
| docker compose -p ble-ai-localizer build | |||||
| Riavvia applicando l'immagine nuova: | |||||
| docker compose -p ble-ai-localizer up -d | |||||
| Se vuoi forzare rebuild+restart in un colpo: | |||||
| docker compose -p ble-ai-localizer up -d --build | |||||
| 4) Esportare l'immagine (backup o deploy su altro server) | |||||
| Esempio export in tar (meglio gzippato): | |||||
| docker save ble-ai-localizer:0.1.0 | gzip > ble-ai-localizer_0.1.0.tar.gz | |||||
| Su altro server: | |||||
| gzip -dc ble-ai-localizer_0.1.0.tar.gz | docker load | |||||
| #Debug | |||||
| mosquitto_sub -v -h 192.168.1.101 -p 1883 -t '#' -V mqttv311 | grep publish_out | |||||
| docker compose -p ble-ai-localizer exec -T ble-ai-localizer ls -l /data/config/ | |||||
| #Caso di gateway che non rileva nessun beacon | |||||
| publish_out/ac233fc1dcd3 [{"timestamp":"2026-01-30T13:40:08.885Z","type":"Gateway","mac":"AC233FC1DCD3","nums":0}] | |||||
| #Verifica del modello in uso: | |||||
| #Ver 1 | |||||
| docker compose -p ble-ai-localizer exec -T ble-ai-localizer python - <<'PY' | |||||
| import hashlib, joblib, os | |||||
| p="/data/model/model.joblib" | |||||
| b=open(p,"rb").read() | |||||
| print(f"FILE: {p}") | |||||
| print(f"sha256={hashlib.sha256(b).hexdigest()[:12]} size={len(b)} bytes") | |||||
| m=joblib.load(p) | |||||
| print("TYPE:", type(m)) | |||||
| for k in ["version","nan_fill","k_floor","k_xy","weights","metric","floors"]: | |||||
| print(f"{k}:", getattr(m,k,None)) | |||||
| gws=getattr(m,"feature_gateways",[]) | |||||
| print("gateways:", len(gws), "first:", gws[:5]) | |||||
| regs=getattr(m,"xy_regs",{}) | |||||
| print("xy_regs floors:", sorted(list(regs.keys()))) | |||||
| PY | |||||
| service "ble-ai-localizer" is not running | |||||
| #Ver 2 | |||||
| docker compose -p ble-ai-localizer exec -T ble-ai-localizer python - <<'PY' | |||||
| import joblib, pprint | |||||
| m = joblib.load("/data/model/model.joblib") | |||||
| keys = [ | |||||
| "created_at_utc","sklearn_version","numpy_version", | |||||
| "gateways_order","nan_fill","k_floor","k_xy","weights","metric","floors" | |||||
| ] | |||||
| pprint.pprint({k: m.get(k) for k in keys}) | |||||
| PY | |||||
| #Esempio utilizzo server API: | |||||
| Ottenimento del token: | |||||
| TOKEN=$( | |||||
| curl -k -s -X POST "https://10.251.0.30:10002/realms/API.Server.local/protocol/openid-connect/token" \ | |||||
| -H "Content-Type: application/x-www-form-urlencoded" \ | |||||
| -d "grant_type=password" \ | |||||
| -d "client_id=Fastapi" \ | |||||
| -d "client_secret=wojuoB7Z5xhlPFrF2lIxJSSdVHCApEgC" \ | |||||
| -d "username=core" \ | |||||
| -d "password=C0r3_us3r_Cr3d3nt14ls" \ | |||||
| -d "audience=Fastapi" \ | |||||
| | jq -r '.access_token' | |||||
| ) | |||||
| Utilizzare il token in un API: | |||||
| curl -k -X 'GET' \ | |||||
| 'https://10.251.0.30:5050/reslevis/getTrackers' \ | |||||
| -H 'accept: application/json' \ | |||||
| -H 'Authorization: Bearer $TOKEN' | |||||
| #Aggiornamento del software | |||||
| cd /data/service/ble-ai-localizer | |||||
| docker compose up -d --build | |||||
| docker compose -p ble-ai-localizer build | |||||
| docker compose -p ble-ai-localizer up -d --build | |||||
| docker system prune | |||||
| Gestione Sart/Stop Container | |||||
| cd /data/service/ble-ai-localizer | |||||
| docker compose -p ble-ai-localizer up -d | |||||
| docker compose -p ble-ai-localizer logs -f --tail=200 --timestamps | |||||
| docker compose -p ble-ai-localizer stop | |||||
| docker compose -p ble-ai-localizer restart | |||||
| docker compose -p ble-ai-localizer down | |||||
| Accesso Web MajorNET ResLevis: | |||||
| https://10.251.0.30/frontend/app_reslevis/app.html#home | |||||
| Accesso Web a Container ble-ai-localizer | |||||
| URL: http://0.0.0.0:8501 | |||||
| http://192.168.1.3:8501/ | |||||
| username e password da file composer: docker-compose.yml | |||||
| UI_USER: "Admin" | |||||
| UI_PASSWORD: "pwdadmin1" <-- facilitate per accesso | |||||
| da mobile | |||||
| NOTE: | |||||
| - Il valore di k utilizzato rappresenta la retta (k=2) o il piano (k>2) tra i | |||||
| beacon di trainig, in addestramento deve coincidere come minimo con il | |||||
| numero di beacon per stanza, ma è un parametro globale per cui va fatto | |||||
| rispettare per tutte le stanze, per cui ad esempio se si decide di catturare 5 | |||||
| misure per stanza k conviene metterlo a 3 | |||||
| - almeno a 1m dalle pareti della stanza (evitare attaccato al muro su pareti | |||||
| adiacenti) | |||||
| - per piani simemtrici le misure di ogni piano conviene farle conincidere | |||||
| - aggingere timestamp in fingerprint e registarre traffico mqtt raw (a | |||||
| parita' di finestra di registrazione potranno essere rivalutati i valori | |||||
| letti) | |||||
| - Tempo di tx nel beacon 200 ms | |||||
| - potenze 0, -4 ,-8, -12 | |||||
| - slot di raccolta 30s | |||||
| - time 1400 | |||||
| - Inferenza a 7 sec | |||||
| - durante la raccolta occorre almeno un beacon di test per potenza che veiene escluso | |||||
| nell'addestarmento ma usato per l'inferenza con il traffico registrato nella | |||||
| fase di raccolta. | |||||
| - testare prima gw e mgtt se regge 200 ms | |||||
| @@ -0,0 +1,14 @@ | |||||
| pandas==2.2.2 | |||||
| numpy==1.26.4 | |||||
| scikit-learn==1.5.1 | |||||
| joblib==1.4.2 | |||||
| paho-mqtt==2.1.0 | |||||
| requests==2.32.3 | |||||
| PyYAML==6.0.2 | |||||
| fastapi==0.115.0 | |||||
| uvicorn[standard]==0.30.6 | |||||
| matplotlib==3.9.2 | |||||
| seaborn==0.13.2 | |||||