Pārlūkot izejas kodu

first commit

master
root pirms 1 mēnesi
revīzija
c5e7b234b4
89 mainītis faili ar 12789 papildinājumiem un 0 dzēšanām
  1. +33
    -0
      Dockerfile
  2. Binārs
      app/DejaVuSans-Bold.ttf
  3. Binārs
      app/__pycache__/__init__.cpython-310.pyc
  4. Binārs
      app/__pycache__/api_client.cpython-310.pyc
  5. Binārs
      app/__pycache__/auth.cpython-310.pyc
  6. Binārs
      app/__pycache__/beacons.cpython-310.pyc
  7. Binārs
      app/__pycache__/fingerprint.cpython-310.pyc
  8. Binārs
      app/__pycache__/gateways.cpython-310.pyc
  9. Binārs
      app/__pycache__/main.cpython-310.pyc
  10. Binārs
      app/__pycache__/normalize.cpython-310.pyc
  11. Binārs
      app/__pycache__/settings.cpython-310.pyc
  12. Binārs
      app/__pycache__/tls.cpython-310.pyc
  13. +13
    -0
      app/api_client.py
  14. +49
    -0
      app/auth.py
  15. +51
    -0
      app/beacons.py
  16. +66
    -0
      app/csv_config.py
  17. +10
    -0
      app/debug_dump.py
  18. +127
    -0
      app/fingerprint.py
  19. +30
    -0
      app/gateways.py
  20. +202
    -0
      app/infer_mode.py
  21. +58
    -0
      app/leaflet_bridge.js
  22. +36
    -0
      app/logger_utils.py
  23. +817
    -0
      app/main.py
  24. +49
    -0
      app/map_manager.py
  25. +145
    -0
      app/map_manager.py_v1
  26. +49
    -0
      app/map_manager.py_v2
  27. +50
    -0
      app/mqtt_client.py
  28. +69
    -0
      app/mqtt_parser.py
  29. +46
    -0
      app/normalize.py
  30. +82
    -0
      app/online_monitor.py
  31. +32
    -0
      app/settings.py
  32. +5
    -0
      app/tls.py
  33. +349
    -0
      app/train_collect.py
  34. +419
    -0
      app/train_mode.py
  35. +88
    -0
      app/web_beacon.py
  36. +48
    -0
      app/web_gateway.py
  37. +100
    -0
      app/web_inference.py
  38. +128
    -0
      app/web_status.py
  39. +181
    -0
      app/web_suite.py
  40. +72
    -0
      app/web_training_data.py
  41. +113
    -0
      config/config.yaml
  42. +4
    -0
      config/secrets.yaml
  43. +1
    -0
      data/.web_state
  44. +6
    -0
      data/config/beacons.csv
  45. +17
    -0
      data/config/gateway.csv
  46. +13
    -0
      data/infer/infer.csv
  47. Binārs
      data/maps/floor_0.png
  48. Binārs
      data/maps/floor_1.png
  49. +1
    -0
      data/maps/meta_0.json
  50. +1
    -0
      data/maps/meta_1.json
  51. Binārs
      data/model/model.joblib
  52. Binārs
      data/model/model_1770027181.joblib
  53. Binārs
      data/model/model_1770027851.joblib
  54. Binārs
      data/model/model_1770047673.joblib
  55. Binārs
      data/model/model_1770051207.joblib
  56. Binārs
      data/model/model_1770167688.joblib
  57. Binārs
      data/model/model_1770167694.joblib
  58. Binārs
      data/model/model_1770196914.joblib
  59. Binārs
      data/model/model_1770196917.joblib
  60. Binārs
      data/model/model_1770207345.joblib
  61. Binārs
      data/model/model_1770207346.joblib
  62. Binārs
      data/model/model_1770207359.joblib
  63. Binārs
      data/model/model_1770207362.joblib
  64. Binārs
      data/model/model_1770224119.joblib
  65. Binārs
      data/model/model_1770302194.joblib
  66. Binārs
      data/model/model_1770302198.joblib
  67. Binārs
      data/model/model_1770302200.joblib
  68. Binārs
      data/model/model_1770302354.joblib
  69. Binārs
      data/model/model_1770302511.joblib
  70. +2
    -0
      data/train/jobs/done/0_1450_500.csv
  71. +2
    -0
      data/train/jobs/done/0_1550_850.csv
  72. +2
    -0
      data/train/jobs/done/1_1050_1450.csv
  73. +2
    -0
      data/train/jobs/done/1_1250_600.csv
  74. +2
    -0
      data/train/jobs/pending/1_1600_450.csv
  75. +2
    -0
      data/train/samples/0_1250_600.csv
  76. +2
    -0
      data/train/samples/0_1350_700.csv
  77. +2
    -0
      data/train/samples/0_1450_500.csv
  78. +2
    -0
      data/train/samples/0_1550_850.csv
  79. +2
    -0
      data/train/samples/1_1050_1450.csv
  80. +2
    -0
      data/train/samples/1_1195_1315.csv
  81. +2
    -0
      data/train/samples/1_1400_1530.csv
  82. +2
    -0
      data/train/samples/1_1425_1050.csv
  83. +2
    -0
      data/train/samples/1_800_1050.csv
  84. +2
    -0
      data/train/samples/1_850_1545.csv
  85. +20
    -0
      docker-compose.yml
  86. +12
    -0
      entrypoint.sh
  87. +466
    -0
      note.txt
  88. +14
    -0
      requirements.txt
  89. +8687
    -0
      tmp/main_process.log

+ 33
- 0
Dockerfile Parādīt failu

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

Binārs
app/DejaVuSans-Bold.ttf Parādīt failu


Binārs
app/__pycache__/__init__.cpython-310.pyc Parādīt failu


Binārs
app/__pycache__/api_client.cpython-310.pyc Parādīt failu


Binārs
app/__pycache__/auth.cpython-310.pyc Parādīt failu


Binārs
app/__pycache__/beacons.cpython-310.pyc Parādīt failu


Binārs
app/__pycache__/fingerprint.cpython-310.pyc Parādīt failu


Binārs
app/__pycache__/gateways.cpython-310.pyc Parādīt failu


Binārs
app/__pycache__/main.cpython-310.pyc Parādīt failu


Binārs
app/__pycache__/normalize.cpython-310.pyc Parādīt failu


Binārs
app/__pycache__/settings.cpython-310.pyc Parādīt failu


Binārs
app/__pycache__/tls.cpython-310.pyc Parādīt failu


+ 13
- 0
app/api_client.py Parādīt failu

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

+ 49
- 0
app/auth.py Parādīt failu

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

+ 51
- 0
app/beacons.py Parādīt failu

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

+ 66
- 0
app/csv_config.py Parādīt failu

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

+ 10
- 0
app/debug_dump.py Parādīt failu

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

+ 127
- 0
app/fingerprint.py Parādīt failu

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

+ 30
- 0
app/gateways.py Parādīt failu

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

+ 202
- 0
app/infer_mode.py Parādīt failu

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

+ 58
- 0
app/leaflet_bridge.js Parādīt failu

@@ -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()
}
}, '*');
});
}

+ 36
- 0
app/logger_utils.py Parādīt failu

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

+ 817
- 0
app/main.py Parādīt failu

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

+ 49
- 0
app/map_manager.py Parādīt failu

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

+ 145
- 0
app/map_manager.py_v1 Parādīt failu

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

+ 49
- 0
app/map_manager.py_v2 Parādīt failu

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

+ 50
- 0
app/mqtt_client.py Parādīt failu

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

+ 69
- 0
app/mqtt_parser.py Parādīt failu

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

+ 46
- 0
app/normalize.py Parādīt failu

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

+ 82
- 0
app/online_monitor.py Parādīt failu

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

+ 32
- 0
app/settings.py Parādīt failu

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

+ 5
- 0
app/tls.py Parādīt failu

@@ -0,0 +1,5 @@
import urllib3

def silence_insecure_warnings(enable: bool):
if enable:
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

+ 349
- 0
app/train_collect.py Parādīt failu

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

+ 419
- 0
app/train_mode.py Parādīt failu

@@ -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)})"
)

+ 88
- 0
app/web_beacon.py Parādīt failu

@@ -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.")

+ 48
- 0
app/web_gateway.py Parādīt failu

@@ -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.")

+ 100
- 0
app/web_inference.py Parādīt failu

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

+ 128
- 0
app/web_status.py Parādīt failu

@@ -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}")

+ 181
- 0
app/web_suite.py Parādīt failu

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

+ 72
- 0
app/web_training_data.py Parādīt failu

@@ -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)}")

+ 113
- 0
config/config.yaml Parādīt failu

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

+ 4
- 0
config/secrets.yaml Parādīt failu

@@ -0,0 +1,4 @@
oidc:
client_secret: "wojuoB7Z5xhlPFrF2lIxJSSdVHCApEgC"
username: "core"
password: "C0r3_us3r_Cr3d3nt14ls"

+ 1
- 0
data/.web_state Parādīt failu

@@ -0,0 +1 @@
running

+ 6
- 0
data/config/beacons.csv Parādīt failu

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

+ 17
- 0
data/config/gateway.csv Parādīt failu

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

+ 13
- 0
data/infer/infer.csv Parādīt failu

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

Binārs
data/maps/floor_0.png Parādīt failu

Pirms Pēc
Platums: 8337  |  Augstums: 4803  |  Izmērs: 528 KiB

Binārs
data/maps/floor_1.png Parādīt failu

Pirms Pēc
Platums: 8238  |  Augstums: 4672  |  Izmērs: 556 KiB

+ 1
- 0
data/maps/meta_0.json Parādīt failu

@@ -0,0 +1 @@
{"pixel_ratio": 1.574668658056015, "calibrated": true, "origin": [1193, 1031], "grid_size": 50, "show_grid": true}

+ 1
- 0
data/maps/meta_1.json Parādīt failu

@@ -0,0 +1 @@
{"pixel_ratio": 1.5919341046277666, "calibrated": true, "origin": [1251, 1143], "grid_size": 50}

Binārs
data/model/model.joblib Parādīt failu


Binārs
data/model/model_1770027181.joblib Parādīt failu


Binārs
data/model/model_1770027851.joblib Parādīt failu


Binārs
data/model/model_1770047673.joblib Parādīt failu


Binārs
data/model/model_1770051207.joblib Parādīt failu


Binārs
data/model/model_1770167688.joblib Parādīt failu


Binārs
data/model/model_1770167694.joblib Parādīt failu


Binārs
data/model/model_1770196914.joblib Parādīt failu


Binārs
data/model/model_1770196917.joblib Parādīt failu


Binārs
data/model/model_1770207345.joblib Parādīt failu


Binārs
data/model/model_1770207346.joblib Parādīt failu


Binārs
data/model/model_1770207359.joblib Parādīt failu


Binārs
data/model/model_1770207362.joblib Parādīt failu


Binārs
data/model/model_1770224119.joblib Parādīt failu


Binārs
data/model/model_1770302194.joblib Parādīt failu


Binārs
data/model/model_1770302198.joblib Parādīt failu


Binārs
data/model/model_1770302200.joblib Parādīt failu


Binārs
data/model/model_1770302354.joblib Parādīt failu


Binārs
data/model/model_1770302511.joblib Parādīt failu


+ 2
- 0
data/train/jobs/done/0_1450_500.csv Parādīt failu

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

+ 2
- 0
data/train/jobs/done/0_1550_850.csv Parādīt failu

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

+ 2
- 0
data/train/jobs/done/1_1050_1450.csv Parādīt failu

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

+ 2
- 0
data/train/jobs/done/1_1250_600.csv Parādīt failu

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

+ 2
- 0
data/train/jobs/pending/1_1600_450.csv Parādīt failu

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

+ 2
- 0
data/train/samples/0_1250_600.csv Parādīt failu

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

+ 2
- 0
data/train/samples/0_1350_700.csv Parādīt failu

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

+ 2
- 0
data/train/samples/0_1450_500.csv Parādīt failu

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

+ 2
- 0
data/train/samples/0_1550_850.csv Parādīt failu

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

+ 2
- 0
data/train/samples/1_1050_1450.csv Parādīt failu

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

+ 2
- 0
data/train/samples/1_1195_1315.csv Parādīt failu

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

+ 2
- 0
data/train/samples/1_1400_1530.csv Parādīt failu

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

+ 2
- 0
data/train/samples/1_1425_1050.csv Parādīt failu

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

+ 2
- 0
data/train/samples/1_800_1050.csv Parādīt failu

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

+ 2
- 0
data/train/samples/1_850_1545.csv Parādīt failu

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

+ 20
- 0
docker-compose.yml Parādīt failu

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

+ 12
- 0
entrypoint.sh Parādīt failu

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

+ 466
- 0
note.txt Parādīt failu

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

+ 14
- 0
requirements.txt Parādīt failu

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

+ 8687
- 0
tmp/main_process.log
Failā izmaiņas netiks attēlotas, jo tās ir par lielu
Parādīt failu


Notiek ielāde…
Atcelt
Saglabāt