| @@ -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 | |||