app backend prima
This commit is contained in:
25
.env.example
Normal file
25
.env.example
Normal file
@@ -0,0 +1,25 @@
|
||||
# Database
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/terrain_monitor
|
||||
|
||||
# JWT Configuration
|
||||
SECRET_KEY=your-secret-key-change-in-production
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
|
||||
# MQTT Configuration
|
||||
# Nuova architettura: terrain/{cliente_id}/{sito_id}/{message_type}
|
||||
# Wildcards: + (single level), # (multi level)
|
||||
MQTT_BROKER_HOST=localhost
|
||||
MQTT_BROKER_PORT=1883
|
||||
MQTT_TOPIC_ALARMS=terrain/+/+/alarms
|
||||
MQTT_TOPIC_TELEMETRY=terrain/+/+/telemetry
|
||||
MQTT_TOPIC_STATUS=terrain/+/+/status
|
||||
MQTT_USERNAME=
|
||||
MQTT_PASSWORD=
|
||||
|
||||
# Firebase Configuration
|
||||
FIREBASE_CREDENTIALS_PATH=./firebase-credentials.json
|
||||
|
||||
# Application
|
||||
DEBUG=True
|
||||
APP_NAME=Terrain Monitor API
|
||||
42
.gitignore
vendored
Normal file
42
.gitignore
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Firebase
|
||||
firebase-credentials.json
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite3
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Alembic
|
||||
alembic/versions/*.py
|
||||
!alembic/versions/.gitkeep
|
||||
|
||||
# mosquitto docker files
|
||||
mosquitto/
|
||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.12
|
||||
420
ARCHITECTURE.md
Normal file
420
ARCHITECTURE.md
Normal file
@@ -0,0 +1,420 @@
|
||||
# Architettura Sistema Terrain Monitor
|
||||
|
||||
## Overview
|
||||
|
||||
Sistema distribuito per il monitoraggio di infrastrutture critiche (ponti, dighe, gallerie, frane) con notifiche push real-time su app mobile.
|
||||
|
||||
## Diagramma Architettura
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ SISTEMA CENTRALIZZATO │
|
||||
│ (Elaborazione dati sensori) │
|
||||
└────────────────────────────┬────────────────────────────────────────┘
|
||||
│
|
||||
│ Pubblica allarmi
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ MQTT Broker │
|
||||
│ (Mosquitto) │
|
||||
│ Port: 1883 │
|
||||
└────────┬────────┘
|
||||
│
|
||||
│ Topic: terrain/alarms/#
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ BACKEND (FastAPI) │
|
||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||
│ │ MQTT Client │ │
|
||||
│ │ - Sottoscrive topic allarmi │ │
|
||||
│ │ - Valida payload JSON │ │
|
||||
│ └─────────────────────┬────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Alarm Handler │ │
|
||||
│ │ 1. Salva allarme in DB │ │
|
||||
│ │ 2. Recupera utenti cliente │ │
|
||||
│ │ 3. Filtra per FCM token │ │
|
||||
│ └─────────────────────┬────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Firebase Service │ │
|
||||
│ │ - Prepara notifica (titolo, body, data) │ │
|
||||
│ │ - Determina priorità (critical/warning/info) │ │
|
||||
│ │ - Invia multicast a tutti i dispositivi │ │
|
||||
│ └─────────────────────┬────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────────────────┴────────────────────────────────────────┐ │
|
||||
│ │ REST API │ │
|
||||
│ │ - /auth/* : Autenticazione, FCM token registration │ │
|
||||
│ │ - /allarmi/* : Lista, dettagli, aggiornamento allarmi │ │
|
||||
│ └──────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||
│ │ PostgreSQL Database │ │
|
||||
│ │ - clienti, siti, utenti, allarmi │ │
|
||||
│ └──────────────────────────────────────────────────────────────┘ │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ Firebase Cloud Messaging (FCM)
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ FIREBASE CLOUD MESSAGING │
|
||||
│ (Google Infrastructure) │
|
||||
└─────────────────────┬──────────────────────┬───────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────────────┐ ┌──────────────────┐
|
||||
│ APP ANDROID │ │ APP iOS │
|
||||
│ │ │ │
|
||||
│ - Login │ │ - Login │
|
||||
│ - FCM Token │ │ - FCM Token │
|
||||
│ - Ricevi notif. │ │ - Ricevi notif. │
|
||||
│ - Lista allarmi │ │ - Lista allarmi │
|
||||
│ - Dettagli │ │ - Dettagli │
|
||||
└──────────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
## Componenti Dettagliati
|
||||
|
||||
### 1. Sistema Centralizzato di Monitoraggio
|
||||
|
||||
**Responsabilità:**
|
||||
- Raccolta dati da centrali remote
|
||||
- Elaborazione e analisi dati
|
||||
- Rilevamento anomalie
|
||||
- Generazione allarmi
|
||||
|
||||
**Output:**
|
||||
- Messaggi JSON su MQTT quando rileva un allarme
|
||||
- Topic structure: `terrain/alarms/{sito_id}`
|
||||
|
||||
### 2. MQTT Broker (Mosquitto)
|
||||
|
||||
**Caratteristiche:**
|
||||
- Protocollo leggero per IoT
|
||||
- Publish/Subscribe pattern
|
||||
- QoS (Quality of Service) configurabile
|
||||
- Persistenza messaggi
|
||||
|
||||
**Configurazione:**
|
||||
- Port: 1883 (MQTT)
|
||||
- Port: 9001 (WebSocket - opzionale)
|
||||
|
||||
### 3. Backend FastAPI
|
||||
|
||||
#### 3.1 MQTT Client
|
||||
```python
|
||||
- Connessione persistente al broker
|
||||
- Auto-reconnect in caso di disconnessione
|
||||
- Parsing e validazione payload JSON
|
||||
- Handler per processare messaggi
|
||||
```
|
||||
|
||||
#### 3.2 Alarm Handler
|
||||
```python
|
||||
Flusso di processamento:
|
||||
1. Valida payload (sito_id obbligatorio)
|
||||
2. Verifica esistenza sito in DB
|
||||
3. Crea record Allarme
|
||||
4. Query utenti del cliente con FCM token attivo
|
||||
5. Delega a Firebase Service per invio
|
||||
```
|
||||
|
||||
#### 3.3 Firebase Service
|
||||
```python
|
||||
Funzionalità:
|
||||
- Singleton per riutilizzo connessione
|
||||
- send_notification() per singolo dispositivo
|
||||
- send_multicast() per batch invio
|
||||
- Gestione errori (token scaduti, etc.)
|
||||
- Configurazione priorità per piattaforma
|
||||
```
|
||||
|
||||
#### 3.4 REST API
|
||||
```
|
||||
Endpoints principali:
|
||||
|
||||
Authentication:
|
||||
POST /auth/token # OAuth2 login
|
||||
POST /auth/login # JSON login
|
||||
POST /auth/register-fcm-token # Registra device
|
||||
GET /auth/me # User info
|
||||
|
||||
Allarmi:
|
||||
GET /allarmi # Lista paginata
|
||||
GET /allarmi/{id} # Dettaglio
|
||||
PATCH /allarmi/{id} # Aggiorna stato
|
||||
GET /allarmi/sito/{sito_id} # Per sito
|
||||
```
|
||||
|
||||
#### 3.5 Database (PostgreSQL)
|
||||
|
||||
**Schema:**
|
||||
|
||||
```sql
|
||||
clienti
|
||||
├─ id (PK)
|
||||
├─ nome, email, telefono
|
||||
└─ attivo
|
||||
|
||||
siti
|
||||
├─ id (PK)
|
||||
├─ cliente_id (FK → clienti)
|
||||
├─ nome, tipo (enum)
|
||||
├─ lat, lng, altitudine
|
||||
└─ codice_identificativo
|
||||
|
||||
utenti
|
||||
├─ id (PK)
|
||||
├─ cliente_id (FK → clienti)
|
||||
├─ email (unique)
|
||||
├─ password_hash
|
||||
├─ fcm_token (per notifiche)
|
||||
└─ ruolo (enum: admin, operatore, viewer)
|
||||
|
||||
allarmi
|
||||
├─ id (PK)
|
||||
├─ sito_id (FK → siti)
|
||||
├─ tipo (enum: movimento_terreno, etc.)
|
||||
├─ severita (enum: critical, warning, info)
|
||||
├─ stato (enum: nuovo, risolto, etc.)
|
||||
├─ valore_rilevato, valore_soglia
|
||||
├─ dati_sensori (JSONB)
|
||||
└─ timestamp_rilevamento
|
||||
```
|
||||
|
||||
**Indici:**
|
||||
```sql
|
||||
- utenti.email (unique)
|
||||
- allarmi.sito_id + timestamp_rilevamento
|
||||
- allarmi.severita
|
||||
- allarmi.stato
|
||||
```
|
||||
|
||||
### 4. Firebase Cloud Messaging
|
||||
|
||||
**Vantaggi:**
|
||||
- ✓ Supporto nativo Android + iOS
|
||||
- ✓ Delivery garantito (anche app chiusa)
|
||||
- ✓ Priorità configurabile
|
||||
- ✓ Topic e targeting avanzato
|
||||
- ✓ Analytics integrato
|
||||
- ✓ Gratuito fino a volumi molto alti
|
||||
|
||||
**Formato Notifica:**
|
||||
```json
|
||||
{
|
||||
"notification": {
|
||||
"title": "CRITICAL: Ponte Morandi",
|
||||
"body": "Movimento terreno rilevato: 15.5mm"
|
||||
},
|
||||
"data": {
|
||||
"alarm_id": "456",
|
||||
"sito_id": "123",
|
||||
"tipo": "movimento_terreno",
|
||||
"severita": "critical"
|
||||
},
|
||||
"android": {
|
||||
"priority": "high",
|
||||
"notification": {
|
||||
"sound": "default",
|
||||
"priority": "max"
|
||||
}
|
||||
},
|
||||
"apns": {
|
||||
"payload": {
|
||||
"aps": {
|
||||
"sound": "default",
|
||||
"badge": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. App Mobile (Flutter/React Native)
|
||||
|
||||
**Funzionalità principali:**
|
||||
1. Login con JWT
|
||||
2. Registrazione FCM token al login
|
||||
3. Ricezione notifiche push in background
|
||||
4. Lista allarmi (filtri, paginazione)
|
||||
5. Dettaglio allarme con mappa
|
||||
6. Aggiornamento stato (risolto, in gestione)
|
||||
7. Visualizzazione dati sensori
|
||||
|
||||
**Tecnologie consigliate:**
|
||||
- **Flutter**: Singola codebase, performance native
|
||||
- **React Native**: Ecosystem JavaScript
|
||||
- **Firebase SDK**: Gestione notifiche
|
||||
- **HTTP Client**: Chiamate API REST
|
||||
|
||||
## Flusso Dati End-to-End
|
||||
|
||||
### Scenario: Allarme Movimento Terreno
|
||||
|
||||
```
|
||||
1. SENSORE
|
||||
↓ (dati grezzi via LoRa/4G)
|
||||
|
||||
2. CENTRALINA LOCALE
|
||||
↓ (aggregazione e validazione)
|
||||
|
||||
3. SISTEMA CENTRALIZZATO
|
||||
- Riceve dati ogni 5 minuti
|
||||
- Elabora: valore_attuale = 15.5mm
|
||||
- Confronta: soglia = 10.0mm
|
||||
- TRIGGER: 15.5 > 10.0 → ALLARME!
|
||||
↓ (pubblica MQTT)
|
||||
|
||||
4. MQTT BROKER
|
||||
- Topic: terrain/alarms/123
|
||||
- Payload JSON con dettagli
|
||||
↓ (forward a subscriber)
|
||||
|
||||
5. BACKEND MQTT CLIENT
|
||||
- Riceve messaggio
|
||||
- Valida JSON
|
||||
- Chiama alarm_handler
|
||||
↓
|
||||
|
||||
6. ALARM HANDLER
|
||||
- INSERT INTO allarmi (...)
|
||||
- SELECT utenti WHERE cliente_id = X AND fcm_token IS NOT NULL
|
||||
- Trova 3 utenti attivi
|
||||
↓
|
||||
|
||||
7. FIREBASE SERVICE
|
||||
- Prepara notifica multicast
|
||||
- Priorità: HIGH (severità = critical)
|
||||
- Invia a 3 FCM tokens
|
||||
↓
|
||||
|
||||
8. FIREBASE CLOUD MESSAGING
|
||||
- Delivery a Google/Apple servers
|
||||
- Push a dispositivi
|
||||
↓
|
||||
|
||||
9. APP MOBILE (3 dispositivi)
|
||||
- Riceve notifica
|
||||
- Mostra: "CRITICAL: Ponte Morandi"
|
||||
- Suono + vibrazione
|
||||
- Badge icon: 1 nuovo
|
||||
- Tap → apre dettaglio allarme
|
||||
↓
|
||||
|
||||
10. UTENTE
|
||||
- Visualizza dati completi
|
||||
- Vede mappa sito
|
||||
- Cambia stato → "in_gestione"
|
||||
- PATCH /allarmi/456 → aggiorna DB
|
||||
```
|
||||
|
||||
**Latenza totale:** < 2 secondi (dal publish MQTT alla notifica)
|
||||
|
||||
## Sicurezza
|
||||
|
||||
### Autenticazione
|
||||
- JWT tokens (HS256)
|
||||
- Password hashing (bcrypt)
|
||||
- Token expiration configurabile
|
||||
|
||||
### Autorizzazione
|
||||
- Multi-tenant: isolamento per cliente_id
|
||||
- Ruoli: admin, operatore, visualizzatore
|
||||
- Filter automatico query per cliente
|
||||
|
||||
### Network
|
||||
- HTTPS obbligatorio (produzione)
|
||||
- MQTT con TLS + credenziali
|
||||
- CORS configurato per domini specifici
|
||||
- Rate limiting (TODO)
|
||||
|
||||
### Database
|
||||
- Prepared statements (SQLAlchemy ORM)
|
||||
- No SQL injection possibile
|
||||
- Backup automatici
|
||||
- Encryption at rest (configurazione DB)
|
||||
|
||||
## Scalabilità
|
||||
|
||||
### Orizzontale
|
||||
- Backend stateless → multiple instances
|
||||
- Load balancer (nginx/HAProxy)
|
||||
- Database: read replicas per query
|
||||
|
||||
### Verticale
|
||||
- PostgreSQL: ottimizzazioni indici
|
||||
- MQTT: clustering (EMQX per produzione)
|
||||
- Firebase: gestisce automaticamente
|
||||
|
||||
### Performance Stimata
|
||||
- 1000 allarmi/minuto: OK con 1 istanza
|
||||
- 10000+ allarmi/minuto: 3-5 istanze + DB tuning
|
||||
- FCM: 1M+ notifiche/secondo (gestito da Google)
|
||||
|
||||
## Monitoring e Logging
|
||||
|
||||
### Logs
|
||||
- Application: Python logging module
|
||||
- MQTT: Mosquitto logs
|
||||
- Database: PostgreSQL logs
|
||||
- Aggregation: ELK Stack o CloudWatch
|
||||
|
||||
### Metriche
|
||||
- Allarmi ricevuti/inviati
|
||||
- Latenza MQTT → FCM
|
||||
- Success rate notifiche
|
||||
- Database query performance
|
||||
|
||||
### Alerting
|
||||
- Backend down → PagerDuty
|
||||
- MQTT disconnection → Email
|
||||
- Database errors → Slack
|
||||
- High latency → Grafana alerts
|
||||
|
||||
## Disaster Recovery
|
||||
|
||||
### Backup
|
||||
- Database: pg_dump giornaliero
|
||||
- Configurazione: Git repository
|
||||
- Logs: retention 30 giorni
|
||||
|
||||
### Fault Tolerance
|
||||
- MQTT: auto-reconnect + QoS 1
|
||||
- Database: replication master-slave
|
||||
- Backend: health check + auto-restart
|
||||
- FCM: retry automatico (gestito da SDK)
|
||||
|
||||
## Costi Stimati (Produzione)
|
||||
|
||||
### Infrastruttura
|
||||
- Backend: $50-100/mese (2 istanze AWS EC2 t3.medium)
|
||||
- Database: $100-200/mese (AWS RDS PostgreSQL)
|
||||
- MQTT: $50-100/mese (EMQX Cloud o self-hosted)
|
||||
- **Totale:** ~$200-400/mese
|
||||
|
||||
### Servizi Esterni
|
||||
- Firebase FCM: **GRATIS** (fino a milioni di notifiche/giorno)
|
||||
- Domain + SSL: $20/anno
|
||||
- Monitoring: $50/mese (Datadog/New Relic)
|
||||
|
||||
### Totale Stimato
|
||||
**$250-500/mese** per sistema completo in produzione
|
||||
|
||||
---
|
||||
|
||||
## Prossimi Miglioramenti
|
||||
|
||||
1. **WebSocket** per notifiche real-time su web dashboard
|
||||
2. **GraphQL** alternativa a REST API
|
||||
3. **Redis** per caching e rate limiting
|
||||
4. **Kubernetes** per orchestrazione container
|
||||
5. **TimescaleDB** per dati time-series sensori
|
||||
6. **Machine Learning** per predizione anomalie
|
||||
7. **Geofencing** per notifiche basate su posizione
|
||||
8. **Multi-language** support (i18n)
|
||||
257
MQTT_ARCHITECTURE.md
Normal file
257
MQTT_ARCHITECTURE.md
Normal file
@@ -0,0 +1,257 @@
|
||||
# Architettura MQTT - Sistema di Monitoraggio Terreni
|
||||
|
||||
## 📡 Struttura Topic
|
||||
|
||||
La nuova architettura usa una struttura gerarchica dei topic MQTT per garantire scalabilità e multi-tenancy:
|
||||
|
||||
```
|
||||
terrain/{cliente_id}/{sito_id}/{message_type}
|
||||
```
|
||||
|
||||
### Tipi di Messaggi
|
||||
|
||||
| Topic Type | Descrizione | QoS | Frequenza |
|
||||
|------------|-------------|-----|-----------|
|
||||
| `alarms` | Allarmi critici/warning/info | 1 | On-event |
|
||||
| `telemetry` | Dati sensori periodici | 0 | 1-60 min |
|
||||
| `status` | Stato gateway/sensori | 0 | Periodico |
|
||||
|
||||
### Esempi
|
||||
|
||||
```bash
|
||||
# Allarme critico per il sito 17 del cliente 5
|
||||
terrain/5/17/alarms
|
||||
|
||||
# Telemetria sensori
|
||||
terrain/5/17/telemetry
|
||||
|
||||
# Stato del gateway
|
||||
terrain/5/17/status
|
||||
```
|
||||
|
||||
## 📋 Formato Messaggi
|
||||
|
||||
### Allarmi (`/alarms`)
|
||||
|
||||
**Topic**: `terrain/{cliente_id}/{sito_id}/alarms`
|
||||
|
||||
**Payload** (JSON):
|
||||
```json
|
||||
{
|
||||
"tipo": "movimento_terreno",
|
||||
"severita": "critical",
|
||||
"titolo": "Movimento terreno rilevato",
|
||||
"descrizione": "Rilevato movimento anomalo...",
|
||||
"valore_rilevato": 25.5,
|
||||
"valore_soglia": 10.0,
|
||||
"unita_misura": "mm",
|
||||
"timestamp": "2025-10-19T10:30:00Z",
|
||||
"dati_sensori": {
|
||||
"sensore_1": 25.5,
|
||||
"sensore_2": 18.3
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note**:
|
||||
- `sito_id` e `cliente_id` vengono estratti dal topic (non dal payload)
|
||||
- `severita`: `critical`, `warning`, `info`
|
||||
- `tipo`: `movimento_terreno`, `inclinazione`, `vibrazioni`, `cedimento`, `altro`
|
||||
|
||||
### Telemetria (`/telemetry`)
|
||||
|
||||
**Topic**: `terrain/{cliente_id}/{sito_id}/telemetry`
|
||||
|
||||
**Payload** (JSON):
|
||||
```json
|
||||
{
|
||||
"timestamp": "2025-10-19T10:30:00Z",
|
||||
"sensori": {
|
||||
"inclinometro_1": {
|
||||
"valore": 12.5,
|
||||
"unita": "gradi",
|
||||
"stato": "ok"
|
||||
},
|
||||
"temperatura": {
|
||||
"valore": 15.5,
|
||||
"unita": "celsius"
|
||||
}
|
||||
},
|
||||
"batteria": 85,
|
||||
"segnale": -65
|
||||
}
|
||||
```
|
||||
|
||||
### Status (`/status`)
|
||||
|
||||
**Topic**: `terrain/{cliente_id}/{sito_id}/status`
|
||||
|
||||
**Payload** (JSON):
|
||||
```json
|
||||
{
|
||||
"timestamp": "2025-10-19T10:30:00Z",
|
||||
"gateway_online": true,
|
||||
"sensori_attivi": 4,
|
||||
"sensori_totali": 4,
|
||||
"ultimo_heartbeat": "2025-10-19T10:29:00Z",
|
||||
"errori": []
|
||||
}
|
||||
```
|
||||
|
||||
## 🔌 Sottoscrizioni Backend
|
||||
|
||||
Il backend si sottoscrive usando wildcards MQTT:
|
||||
|
||||
```python
|
||||
# Sottoscrivi a tutti gli allarmi
|
||||
client.subscribe("terrain/+/+/alarms", qos=1)
|
||||
|
||||
# Sottoscrivi a tutta la telemetria
|
||||
client.subscribe("terrain/+/+/telemetry", qos=0)
|
||||
|
||||
# Sottoscrivi a tutti i messaggi di status
|
||||
client.subscribe("terrain/+/+/status", qos=0)
|
||||
```
|
||||
|
||||
**Wildcards**:
|
||||
- `+` : single-level wildcard (un livello)
|
||||
- `#` : multi-level wildcard (tutti i livelli rimanenti)
|
||||
|
||||
## 🔒 Sicurezza
|
||||
|
||||
### Development
|
||||
- Autenticazione: Anonymous (allow_anonymous = true)
|
||||
- Encryption: Nessuna
|
||||
|
||||
### Production (TODO)
|
||||
```conf
|
||||
# mosquitto.conf
|
||||
allow_anonymous false
|
||||
password_file /mosquitto/config/passwd
|
||||
|
||||
# TLS/SSL
|
||||
listener 8883
|
||||
cafile /mosquitto/certs/ca.crt
|
||||
certfile /mosquitto/certs/server.crt
|
||||
keyfile /mosquitto/certs/server.key
|
||||
```
|
||||
|
||||
## 💾 Persistenza
|
||||
|
||||
Mosquitto è configurato con persistenza su disco:
|
||||
|
||||
```conf
|
||||
persistence true
|
||||
persistence_location /mosquitto/data/
|
||||
autosave_interval 300 # Salva ogni 5 minuti
|
||||
```
|
||||
|
||||
**Vantaggi**:
|
||||
- Messaggi QoS > 0 non persi in caso di restart
|
||||
- Sottoscrizioni persistenti
|
||||
- Retained messages salvati
|
||||
|
||||
## 🧪 Test
|
||||
|
||||
### 1. Invia allarme di test
|
||||
|
||||
```bash
|
||||
cd /home/alex/devel/web-app-python
|
||||
source .venv/bin/activate
|
||||
python scripts/test_mqtt_improved.py
|
||||
```
|
||||
|
||||
### 2. Monitor MQTT (subscriber)
|
||||
|
||||
```bash
|
||||
# Installa mosquitto-clients
|
||||
sudo apt-get install mosquitto-clients
|
||||
|
||||
# Sottoscrivi a tutti gli allarmi
|
||||
mosquitto_sub -h localhost -t "terrain/+/+/alarms" -v
|
||||
|
||||
# Sottoscrivi a tutti i messaggi
|
||||
mosquitto_sub -h localhost -t "terrain/#" -v
|
||||
```
|
||||
|
||||
### 3. Pubblica messaggio manuale
|
||||
|
||||
```bash
|
||||
# Allarme critico
|
||||
mosquitto_pub -h localhost \
|
||||
-t "terrain/5/17/alarms" \
|
||||
-m '{"tipo":"movimento_terreno","severita":"critical","titolo":"Test","timestamp":"2025-10-19T10:30:00Z"}' \
|
||||
-q 1
|
||||
```
|
||||
|
||||
## 📊 Helper Python
|
||||
|
||||
Usa l'helper `MQTTTopics` per costruire topic standardizzati:
|
||||
|
||||
```python
|
||||
from app.mqtt.topics import MQTTTopics
|
||||
|
||||
# Costruisci topic
|
||||
topic = MQTTTopics.build_topic(
|
||||
cliente_id=5,
|
||||
sito_id=17,
|
||||
topic_type="alarms"
|
||||
)
|
||||
# Output: "terrain/5/17/alarms"
|
||||
|
||||
# Parse topic
|
||||
info = MQTTTopics.parse_topic("terrain/5/17/alarms")
|
||||
# Output: {'cliente_id': 5, 'sito_id': 17, 'topic_type': 'alarms'}
|
||||
|
||||
# Pattern sottoscrizione
|
||||
pattern = MQTTTopics.get_subscription_pattern("alarms")
|
||||
# Output: "terrain/+/+/alarms"
|
||||
```
|
||||
|
||||
## 🚀 Migrazione dal Vecchio Sistema
|
||||
|
||||
### Prima (vecchio)
|
||||
```
|
||||
Topic: terrain/alarms/1
|
||||
Payload: {"sito_id": 17, "tipo": "...", ...}
|
||||
```
|
||||
|
||||
### Dopo (nuovo)
|
||||
```
|
||||
Topic: terrain/5/17/alarms
|
||||
Payload: {"tipo": "...", ...} # sito_id estratto dal topic
|
||||
```
|
||||
|
||||
### Retrocompatibilità
|
||||
|
||||
Il sistema continua a funzionare con il vecchio formato, ma è consigliato migrare ai nuovi topic per:
|
||||
- ✅ Migliore separazione multi-tenant
|
||||
- ✅ Filtraggio per cliente
|
||||
- ✅ Scalabilità
|
||||
- ✅ Standard IoT
|
||||
|
||||
## 📈 Performance
|
||||
|
||||
- **Max throughput**: ~10,000 msg/sec (con QoS 0)
|
||||
- **Latenza**: < 10ms (LAN)
|
||||
- **Retained messages**: 1000 max
|
||||
- **Connections**: Illimitate (configurabile)
|
||||
|
||||
## 🔍 Monitoraggio
|
||||
|
||||
### Log Mosquitto
|
||||
```bash
|
||||
docker-compose logs -f mosquitto
|
||||
```
|
||||
|
||||
### Log Backend MQTT
|
||||
```bash
|
||||
# Filtro solo log MQTT
|
||||
grep "MQTT" logs/app.log | tail -50
|
||||
```
|
||||
|
||||
### Statistiche Broker
|
||||
```bash
|
||||
# Connetti e visualizza statistiche
|
||||
mosquitto_sub -h localhost -t '$SYS/#' -v
|
||||
```
|
||||
239
QUICKSTART.md
Normal file
239
QUICKSTART.md
Normal file
@@ -0,0 +1,239 @@
|
||||
# Quick Start Guide - Terrain Monitor
|
||||
|
||||
Guida rapida per avviare il sistema di monitoraggio terreni in locale.
|
||||
|
||||
## Prerequisiti
|
||||
|
||||
- Python 3.12+
|
||||
- Docker e Docker Compose
|
||||
- Account Firebase (gratuito)
|
||||
|
||||
## Setup in 5 Passi
|
||||
|
||||
### 1. Clona e Prepara Ambiente
|
||||
|
||||
```bash
|
||||
# Entra nella directory del progetto
|
||||
cd web-app-python
|
||||
|
||||
# Crea virtual environment
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate # Linux/Mac
|
||||
# .venv\Scripts\activate # Windows
|
||||
|
||||
# Installa dipendenze
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
### 2. Avvia Infrastruttura (PostgreSQL + MQTT)
|
||||
|
||||
```bash
|
||||
# Avvia database e broker MQTT
|
||||
docker-compose up -d
|
||||
|
||||
# Verifica che siano in esecuzione
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
Servizi disponibili:
|
||||
- PostgreSQL: `localhost:5432`
|
||||
- MQTT Broker: `localhost:1883`
|
||||
- pgAdmin: http://localhost:5050 (user: admin@terrain.local, pwd: admin)
|
||||
|
||||
### 3. Configura Firebase
|
||||
|
||||
1. Vai su https://console.firebase.google.com/
|
||||
2. Crea nuovo progetto "Terrain Monitor"
|
||||
3. Abilita Cloud Messaging
|
||||
4. Scarica credenziali:
|
||||
- ⚙️ Settings → Service Accounts
|
||||
- "Generate new private key"
|
||||
- Salva come `firebase-credentials.json` nella root
|
||||
|
||||
### 4. Configura Variabili d'Ambiente
|
||||
|
||||
```bash
|
||||
# Copia template
|
||||
cp .env.example .env
|
||||
|
||||
# Modifica .env (usa questi valori per local development)
|
||||
nano .env
|
||||
```
|
||||
|
||||
```env
|
||||
DATABASE_URL=postgresql://terrain_user:terrain_pass@localhost:5432/terrain_monitor
|
||||
SECRET_KEY=dev-secret-key-change-in-production-minimum-32-chars
|
||||
MQTT_BROKER_HOST=localhost
|
||||
MQTT_BROKER_PORT=1883
|
||||
MQTT_TOPIC=terrain/alarms/#
|
||||
FIREBASE_CREDENTIALS_PATH=./firebase-credentials.json
|
||||
DEBUG=True
|
||||
```
|
||||
|
||||
### 5. Inizializza Database e Avvia Server
|
||||
|
||||
```bash
|
||||
# Inizializza database con dati di esempio
|
||||
python scripts/init_db.py
|
||||
|
||||
# Avvia il server FastAPI
|
||||
python main.py
|
||||
```
|
||||
|
||||
Server disponibile su: http://localhost:8000
|
||||
|
||||
## Test del Sistema
|
||||
|
||||
### 1. Verifica API
|
||||
|
||||
Apri http://localhost:8000/docs per la documentazione interattiva Swagger.
|
||||
|
||||
### 2. Login
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "admin@azienda.it",
|
||||
"password": "admin123"
|
||||
}'
|
||||
```
|
||||
|
||||
Salva il token restituito.
|
||||
|
||||
### 3. Test Allarme MQTT
|
||||
|
||||
In un nuovo terminale:
|
||||
|
||||
```bash
|
||||
# Attiva virtual environment
|
||||
source .venv/bin/activate
|
||||
|
||||
# Invia allarme di test
|
||||
python scripts/test_mqtt.py
|
||||
```
|
||||
|
||||
Dovresti vedere:
|
||||
1. ✓ Log nel backend che riceve l'allarme
|
||||
2. ✓ Nuovo record nella tabella `allarmi`
|
||||
3. ✓ Notifica push inviata (se hai configurato FCM token)
|
||||
|
||||
### 4. Verifica Allarmi via API
|
||||
|
||||
```bash
|
||||
curl -X GET http://localhost:8000/allarmi \
|
||||
-H "Authorization: Bearer YOUR_TOKEN"
|
||||
```
|
||||
|
||||
## Credenziali Utenti Demo
|
||||
|
||||
Dopo `init_db.py` avrai questi utenti:
|
||||
|
||||
| Email | Password | Ruolo |
|
||||
|-------|----------|-------|
|
||||
| admin@azienda.it | admin123 | Admin |
|
||||
| operatore@azienda.it | operatore123 | Operatore |
|
||||
| viewer@azienda.it | viewer123 | Visualizzatore |
|
||||
|
||||
## Siti Monitorati Demo
|
||||
|
||||
1. **Ponte Morandi** (ID: 1) - Genova
|
||||
2. **Galleria San Boldo** (ID: 2) - Treviso
|
||||
3. **Diga del Vajont** (ID: 3) - Pordenone
|
||||
4. **Versante Cà di Sotto** (ID: 4) - Parma
|
||||
|
||||
## Struttura Allarme MQTT
|
||||
|
||||
Per inviare allarmi personalizzati via MQTT:
|
||||
|
||||
```bash
|
||||
mosquitto_pub -h localhost -t "terrain/alarms/1" -m '{
|
||||
"sito_id": 1,
|
||||
"tipo": "movimento_terreno",
|
||||
"severita": "critical",
|
||||
"titolo": "Movimento rilevato",
|
||||
"descrizione": "Movimento anomalo di 15mm",
|
||||
"valore_rilevato": 15.0,
|
||||
"valore_soglia": 10.0,
|
||||
"unita_misura": "mm",
|
||||
"timestamp": "2025-10-18T10:30:00Z",
|
||||
"dati_sensori": {
|
||||
"sensore_1": 15.0,
|
||||
"sensore_2": 12.3
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
## Monitoraggio Logs
|
||||
|
||||
```bash
|
||||
# Backend logs
|
||||
# Visibili nel terminale dove hai eseguito python main.py
|
||||
|
||||
# MQTT broker logs
|
||||
docker-compose logs -f mosquitto
|
||||
|
||||
# Database logs
|
||||
docker-compose logs -f postgres
|
||||
```
|
||||
|
||||
## Stop e Cleanup
|
||||
|
||||
```bash
|
||||
# Ferma il server FastAPI
|
||||
Ctrl+C nel terminale
|
||||
|
||||
# Ferma i container Docker
|
||||
docker-compose down
|
||||
|
||||
# Rimuovi anche i dati (ATTENZIONE: cancella il database!)
|
||||
docker-compose down -v
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Errore: "Connection refused" su PostgreSQL
|
||||
```bash
|
||||
# Verifica che il container sia in esecuzione
|
||||
docker-compose ps
|
||||
|
||||
# Riavvia PostgreSQL
|
||||
docker-compose restart postgres
|
||||
```
|
||||
|
||||
### Errore: "Firebase credentials not found"
|
||||
```bash
|
||||
# Verifica che il file esista
|
||||
ls -la firebase-credentials.json
|
||||
|
||||
# Controlla il path in .env
|
||||
cat .env | grep FIREBASE
|
||||
```
|
||||
|
||||
### MQTT non riceve messaggi
|
||||
```bash
|
||||
# Testa la connessione MQTT
|
||||
mosquitto_sub -h localhost -t "terrain/alarms/#" -v
|
||||
|
||||
# In un altro terminale, pubblica un test
|
||||
mosquitto_pub -h localhost -t "terrain/alarms/test" -m "hello"
|
||||
```
|
||||
|
||||
## Prossimi Passi
|
||||
|
||||
1. **App Mobile**: Sviluppa client Flutter/React Native
|
||||
2. **Integrazione**: Connetti il tuo sistema di monitoraggio reale
|
||||
3. **Deployment**: Usa Docker per deployare in produzione
|
||||
4. **Sicurezza**: Configura MQTT con autenticazione
|
||||
5. **Monitoring**: Aggiungi logging e metriche
|
||||
|
||||
## Risorse
|
||||
|
||||
- FastAPI Docs: https://fastapi.tiangolo.com/
|
||||
- Firebase FCM: https://firebase.google.com/docs/cloud-messaging
|
||||
- MQTT Protocol: https://mqtt.org/
|
||||
- SQLAlchemy: https://docs.sqlalchemy.org/
|
||||
|
||||
## Supporto
|
||||
|
||||
Per problemi o domande, consulta il [README.md](README.md) completo.
|
||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
0
app/api/__init__.py
Normal file
0
app/api/__init__.py
Normal file
171
app/api/allarmi.py
Normal file
171
app/api/allarmi.py
Normal file
@@ -0,0 +1,171 @@
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models import Allarme, Utente, Sito
|
||||
from app.schemas.allarme import AllarmeResponse, AllarmeList, AllarmeUpdate
|
||||
from app.api.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/allarmi", tags=["Allarmi"])
|
||||
|
||||
|
||||
@router.get("", response_model=AllarmeList)
|
||||
async def get_allarmi(
|
||||
current_user: Annotated[Utente, Depends(get_current_user)],
|
||||
db: Session = Depends(get_db),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=100),
|
||||
sito_id: Optional[int] = None,
|
||||
severita: Optional[str] = None,
|
||||
stato: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Recupera gli allarmi per il cliente dell'utente autenticato.
|
||||
Supporta filtri e paginazione.
|
||||
"""
|
||||
|
||||
# Query base: solo allarmi dei siti del cliente dell'utente
|
||||
query = (
|
||||
db.query(Allarme)
|
||||
.join(Sito)
|
||||
.filter(Sito.cliente_id == current_user.cliente_id)
|
||||
)
|
||||
|
||||
# Applica filtri opzionali
|
||||
if sito_id:
|
||||
query = query.filter(Allarme.sito_id == sito_id)
|
||||
if severita:
|
||||
query = query.filter(Allarme.severita == severita)
|
||||
if stato:
|
||||
query = query.filter(Allarme.stato == stato)
|
||||
|
||||
# Conta totale
|
||||
total = query.count()
|
||||
|
||||
# Applica paginazione e ordinamento
|
||||
allarmi = (
|
||||
query.order_by(desc(Allarme.timestamp_rilevamento))
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
|
||||
return AllarmeList(
|
||||
total=total,
|
||||
items=allarmi,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{allarme_id}", response_model=AllarmeResponse)
|
||||
async def get_allarme(
|
||||
allarme_id: int,
|
||||
current_user: Annotated[Utente, Depends(get_current_user)],
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Recupera un singolo allarme per ID"""
|
||||
|
||||
allarme = (
|
||||
db.query(Allarme)
|
||||
.join(Sito)
|
||||
.filter(
|
||||
Allarme.id == allarme_id,
|
||||
Sito.cliente_id == current_user.cliente_id
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not allarme:
|
||||
raise HTTPException(status_code=404, detail="Allarme non trovato")
|
||||
|
||||
return allarme
|
||||
|
||||
|
||||
@router.patch("/{allarme_id}", response_model=AllarmeResponse)
|
||||
async def update_allarme(
|
||||
allarme_id: int,
|
||||
update_data: AllarmeUpdate,
|
||||
current_user: Annotated[Utente, Depends(get_current_user)],
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Aggiorna lo stato o le note di un allarme"""
|
||||
|
||||
allarme = (
|
||||
db.query(Allarme)
|
||||
.join(Sito)
|
||||
.filter(
|
||||
Allarme.id == allarme_id,
|
||||
Sito.cliente_id == current_user.cliente_id
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not allarme:
|
||||
raise HTTPException(status_code=404, detail="Allarme non trovato")
|
||||
|
||||
# Aggiorna campi se forniti
|
||||
if update_data.stato is not None:
|
||||
allarme.stato = update_data.stato
|
||||
|
||||
# Se viene risolto, registra chi e quando
|
||||
if update_data.stato == "risolto":
|
||||
from datetime import datetime, timezone
|
||||
allarme.risolto_da = f"{current_user.nome} {current_user.cognome}"
|
||||
allarme.timestamp_risoluzione = datetime.now(timezone.utc)
|
||||
|
||||
if update_data.note is not None:
|
||||
allarme.note = update_data.note
|
||||
|
||||
if update_data.risolto_da is not None:
|
||||
allarme.risolto_da = update_data.risolto_da
|
||||
|
||||
db.commit()
|
||||
db.refresh(allarme)
|
||||
|
||||
return allarme
|
||||
|
||||
|
||||
@router.get("/sito/{sito_id}", response_model=AllarmeList)
|
||||
async def get_allarmi_by_sito(
|
||||
sito_id: int,
|
||||
current_user: Annotated[Utente, Depends(get_current_user)],
|
||||
db: Session = Depends(get_db),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=100),
|
||||
):
|
||||
"""Recupera tutti gli allarmi per un sito specifico"""
|
||||
|
||||
# Verifica che il sito appartenga al cliente dell'utente
|
||||
sito = (
|
||||
db.query(Sito)
|
||||
.filter(
|
||||
Sito.id == sito_id,
|
||||
Sito.cliente_id == current_user.cliente_id
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not sito:
|
||||
raise HTTPException(status_code=404, detail="Sito non trovato")
|
||||
|
||||
query = db.query(Allarme).filter(Allarme.sito_id == sito_id)
|
||||
|
||||
total = query.count()
|
||||
|
||||
allarmi = (
|
||||
query.order_by(desc(Allarme.timestamp_rilevamento))
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
|
||||
return AllarmeList(
|
||||
total=total,
|
||||
items=allarmi,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
130
app/api/auth.py
Normal file
130
app/api/auth.py
Normal file
@@ -0,0 +1,130 @@
|
||||
from datetime import timedelta
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.core.security import verify_password, create_access_token, decode_access_token
|
||||
from app.models import Utente
|
||||
from app.schemas.auth import Token, LoginRequest, RegisterFCMToken
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["Authentication"])
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
token: Annotated[str, Depends(oauth2_scheme)],
|
||||
db: Session = Depends(get_db)
|
||||
) -> Utente:
|
||||
"""Dependency per ottenere l'utente corrente dal token JWT"""
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Credenziali non valide",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
payload = decode_access_token(token)
|
||||
if payload is None:
|
||||
raise credentials_exception
|
||||
|
||||
email: str = payload.get("sub")
|
||||
if email is None:
|
||||
raise credentials_exception
|
||||
|
||||
user = db.query(Utente).filter(Utente.email == email).first()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
|
||||
if not user.attivo:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Utente non attivo"
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/token", response_model=Token)
|
||||
async def login(
|
||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Endpoint per login con OAuth2 password flow"""
|
||||
user = db.query(Utente).filter(Utente.email == form_data.username).first()
|
||||
|
||||
if not user or not verify_password(form_data.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Email o password non corretti",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if not user.attivo:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Utente non attivo"
|
||||
)
|
||||
|
||||
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
access_token = create_access_token(
|
||||
data={"sub": user.email, "cliente_id": user.cliente_id},
|
||||
expires_delta=access_token_expires
|
||||
)
|
||||
|
||||
return {"access_token": access_token, "token_type": "bearer"}
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login_json(login_data: LoginRequest, db: Session = Depends(get_db)):
|
||||
"""Endpoint per login con JSON"""
|
||||
user = db.query(Utente).filter(Utente.email == login_data.email).first()
|
||||
|
||||
if not user or not verify_password(login_data.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Email o password non corretti"
|
||||
)
|
||||
|
||||
if not user.attivo:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Utente non attivo"
|
||||
)
|
||||
|
||||
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
access_token = create_access_token(
|
||||
data={"sub": user.email, "cliente_id": user.cliente_id},
|
||||
expires_delta=access_token_expires
|
||||
)
|
||||
|
||||
return {"access_token": access_token, "token_type": "bearer"}
|
||||
|
||||
|
||||
@router.post("/register-fcm-token")
|
||||
async def register_fcm_token(
|
||||
token_data: RegisterFCMToken,
|
||||
current_user: Annotated[Utente, Depends(get_current_user)],
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Registra o aggiorna il FCM token per l'utente corrente"""
|
||||
current_user.fcm_token = token_data.fcm_token
|
||||
db.commit()
|
||||
|
||||
return {"message": "FCM token registrato con successo"}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_me(current_user: Annotated[Utente, Depends(get_current_user)]):
|
||||
"""Restituisce le informazioni dell'utente corrente"""
|
||||
return {
|
||||
"id": current_user.id,
|
||||
"email": current_user.email,
|
||||
"nome": current_user.nome,
|
||||
"cognome": current_user.cognome,
|
||||
"ruolo": current_user.ruolo,
|
||||
"cliente_id": current_user.cliente_id,
|
||||
}
|
||||
50
app/api/siti.py
Normal file
50
app/api/siti.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
Endpoint API per la gestione dei siti monitorati
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models import Sito, Utente
|
||||
from app.api.auth import get_current_user
|
||||
from app.schemas.sito import SitoResponse, SitoListResponse
|
||||
|
||||
router = APIRouter(prefix="/siti", tags=["siti"])
|
||||
|
||||
|
||||
@router.get("/{sito_id}", response_model=SitoResponse)
|
||||
async def get_sito(
|
||||
sito_id: int,
|
||||
current_user: Utente = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Recupera dettagli di un sito specifico"""
|
||||
sito = db.query(Sito).filter(
|
||||
Sito.id == sito_id,
|
||||
Sito.cliente_id == current_user.cliente_id,
|
||||
).first()
|
||||
|
||||
if not sito:
|
||||
raise HTTPException(status_code=404, detail="Sito non trovato")
|
||||
|
||||
return sito
|
||||
|
||||
|
||||
@router.get("", response_model=SitoListResponse)
|
||||
async def get_siti(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
tipo: str | None = None,
|
||||
current_user: Utente = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Recupera lista siti del cliente"""
|
||||
query = db.query(Sito).filter(Sito.cliente_id == current_user.cliente_id)
|
||||
|
||||
if tipo:
|
||||
query = query.filter(Sito.tipo == tipo)
|
||||
|
||||
total = query.count()
|
||||
siti = query.offset(skip).limit(limit).all()
|
||||
|
||||
return {"total": total, "items": siti}
|
||||
127
app/api/statistiche.py
Normal file
127
app/api/statistiche.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
Endpoint API per statistiche e dashboard
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models import Allarme, Sito, Utente
|
||||
from app.api.auth import get_current_user
|
||||
from app.schemas.statistiche import StatisticheResponse, AllarmiPerGiornoResponse
|
||||
|
||||
router = APIRouter(prefix="/statistiche", tags=["statistiche"])
|
||||
|
||||
|
||||
@router.get("", response_model=StatisticheResponse)
|
||||
async def get_statistiche(
|
||||
current_user: Utente = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Recupera statistiche generali per il dashboard"""
|
||||
|
||||
# Totali
|
||||
totale_allarmi = db.query(Allarme).join(Sito).filter(
|
||||
Sito.cliente_id == current_user.cliente_id
|
||||
).count()
|
||||
|
||||
totale_siti = db.query(Sito).filter(
|
||||
Sito.cliente_id == current_user.cliente_id
|
||||
).count()
|
||||
|
||||
# Allarmi per severità
|
||||
allarmi_per_severita = db.query(
|
||||
Allarme.severita,
|
||||
func.count(Allarme.id).label('count')
|
||||
).join(Sito).filter(
|
||||
Sito.cliente_id == current_user.cliente_id
|
||||
).group_by(Allarme.severita).all()
|
||||
|
||||
severita_dict = {s: c for s, c in allarmi_per_severita}
|
||||
|
||||
# Allarmi per stato
|
||||
allarmi_per_stato = db.query(
|
||||
Allarme.stato,
|
||||
func.count(Allarme.id).label('count')
|
||||
).join(Sito).filter(
|
||||
Sito.cliente_id == current_user.cliente_id
|
||||
).group_by(Allarme.stato).all()
|
||||
|
||||
stato_dict = {s: c for s, c in allarmi_per_stato}
|
||||
|
||||
# Allarmi aperti (non risolti)
|
||||
allarmi_aperti = db.query(Allarme).join(Sito).filter(
|
||||
Sito.cliente_id == current_user.cliente_id,
|
||||
Allarme.stato != 'risolto'
|
||||
).count()
|
||||
|
||||
# Allarmi ultimi 7 giorni
|
||||
seven_days_ago = datetime.now() - timedelta(days=7)
|
||||
allarmi_recenti = db.query(Allarme).join(Sito).filter(
|
||||
Sito.cliente_id == current_user.cliente_id,
|
||||
Allarme.created_at >= seven_days_ago
|
||||
).count()
|
||||
|
||||
# Siti per tipo
|
||||
siti_per_tipo = db.query(
|
||||
Sito.tipo,
|
||||
func.count(Sito.id).label('count')
|
||||
).filter(
|
||||
Sito.cliente_id == current_user.cliente_id
|
||||
).group_by(Sito.tipo).all()
|
||||
|
||||
tipo_dict = {t: c for t, c in siti_per_tipo}
|
||||
|
||||
return {
|
||||
"totale_allarmi": totale_allarmi,
|
||||
"totale_siti": totale_siti,
|
||||
"allarmi_aperti": allarmi_aperti,
|
||||
"allarmi_recenti_7gg": allarmi_recenti,
|
||||
"allarmi_critical": severita_dict.get('critical', 0),
|
||||
"allarmi_warning": severita_dict.get('warning', 0),
|
||||
"allarmi_info": severita_dict.get('info', 0),
|
||||
"allarmi_nuovo": stato_dict.get('nuovo', 0),
|
||||
"allarmi_in_gestione": stato_dict.get('in_gestione', 0),
|
||||
"allarmi_risolto": stato_dict.get('risolto', 0),
|
||||
"siti_ponte": tipo_dict.get('ponte', 0),
|
||||
"siti_galleria": tipo_dict.get('galleria', 0),
|
||||
"siti_diga": tipo_dict.get('diga', 0),
|
||||
"siti_frana": tipo_dict.get('frana', 0),
|
||||
"siti_versante": tipo_dict.get('versante', 0),
|
||||
"siti_edificio": tipo_dict.get('edificio', 0),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/allarmi-per-giorno", response_model=AllarmiPerGiornoResponse)
|
||||
async def get_allarmi_per_giorno(
|
||||
giorni: int = 30,
|
||||
current_user: Utente = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Recupera statistiche allarmi per giorno (per grafici temporali)"""
|
||||
|
||||
start_date = datetime.now() - timedelta(days=giorni)
|
||||
|
||||
# Query allarmi raggruppati per giorno
|
||||
allarmi_giornalieri = db.query(
|
||||
func.date(Allarme.created_at).label('data'),
|
||||
func.count(Allarme.id).label('count')
|
||||
).join(Sito).filter(
|
||||
Sito.cliente_id == current_user.cliente_id,
|
||||
Allarme.created_at >= start_date
|
||||
).group_by(
|
||||
func.date(Allarme.created_at)
|
||||
).order_by(
|
||||
func.date(Allarme.created_at)
|
||||
).all()
|
||||
|
||||
# Converti in lista di dict
|
||||
dati = []
|
||||
for data, count in allarmi_giornalieri:
|
||||
dati.append({
|
||||
"data": data.isoformat(),
|
||||
"count": count
|
||||
})
|
||||
|
||||
return {"dati": dati}
|
||||
0
app/core/__init__.py
Normal file
0
app/core/__init__.py
Normal file
44
app/core/config.py
Normal file
44
app/core/config.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from pathlib import Path
|
||||
|
||||
# Determina il percorso del file .env (nella root del progetto)
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
ENV_FILE = BASE_DIR / ".env"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Configurazione applicazione caricata da variabili d'ambiente"""
|
||||
|
||||
# Database
|
||||
DATABASE_URL: str = "postgresql://user:password@localhost:5432/terrain_monitor"
|
||||
|
||||
# JWT
|
||||
SECRET_KEY: str
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
|
||||
# MQTT
|
||||
MQTT_BROKER_HOST: str = "localhost"
|
||||
MQTT_BROKER_PORT: int = 1883
|
||||
# Topic pattern: terrain/{cliente_id}/{sito_id}/alarms
|
||||
# Wildcards: + (single level), # (multi level)
|
||||
MQTT_TOPIC_ALARMS: str = "terrain/+/+/alarms"
|
||||
MQTT_TOPIC_TELEMETRY: str = "terrain/+/+/telemetry" # Dati sensori periodici
|
||||
MQTT_TOPIC_STATUS: str = "terrain/+/+/status" # Stato sensori/gateway
|
||||
MQTT_USERNAME: str = ""
|
||||
MQTT_PASSWORD: str = ""
|
||||
|
||||
# Firebase
|
||||
FIREBASE_CREDENTIALS_PATH: str = "./firebase-credentials.json"
|
||||
|
||||
# Application
|
||||
DEBUG: bool = False
|
||||
APP_NAME: str = "Terrain Monitor API"
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=str(ENV_FILE),
|
||||
env_file_encoding='utf-8'
|
||||
)
|
||||
|
||||
|
||||
settings = Settings()
|
||||
19
app/core/database.py
Normal file
19
app/core/database.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
engine = create_engine(settings.DATABASE_URL)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
"""Dependency per ottenere una sessione database"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
45
app/core/firebase.py
Normal file
45
app/core/firebase.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import logging
|
||||
import firebase_admin
|
||||
from firebase_admin import credentials
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
firebase_app = None
|
||||
|
||||
|
||||
def init_firebase():
|
||||
"""Inizializza Firebase Admin SDK"""
|
||||
global firebase_app
|
||||
|
||||
if firebase_app is not None:
|
||||
logger.info("Firebase già inizializzato")
|
||||
return firebase_app
|
||||
|
||||
try:
|
||||
# Percorso al file di credenziali
|
||||
cred_path = Path(__file__).parent.parent.parent / "firebase-credentials.json"
|
||||
|
||||
if not cred_path.exists():
|
||||
logger.error(f"File credenziali Firebase non trovato: {cred_path}")
|
||||
raise FileNotFoundError(f"firebase-credentials.json non trovato in {cred_path}")
|
||||
|
||||
# Inizializza con le credenziali
|
||||
cred = credentials.Certificate(str(cred_path))
|
||||
firebase_app = firebase_admin.initialize_app(cred)
|
||||
|
||||
logger.info("Firebase Admin SDK inizializzato con successo")
|
||||
return firebase_app
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Errore nell'inizializzazione di Firebase: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def get_firebase_app():
|
||||
"""Ottiene l'istanza di Firebase App"""
|
||||
if firebase_app is None:
|
||||
return init_firebase()
|
||||
return firebase_app
|
||||
46
app/core/security.py
Normal file
46
app/core/security.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verifica una password contro il suo hash"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""Genera l'hash di una password"""
|
||||
# bcrypt ha un limite di 72 byte, tronchiamo se necessario
|
||||
# Questo è sicuro perché 72 byte forniscono comunque entropia sufficiente
|
||||
if len(password.encode('utf-8')) > 72:
|
||||
password = password.encode('utf-8')[:72].decode('utf-8', errors='ignore')
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""Crea un JWT token"""
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.now(timezone.utc) + expires_delta
|
||||
else:
|
||||
expire = datetime.now(timezone.utc) + timedelta(
|
||||
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> Optional[dict]:
|
||||
"""Decodifica e valida un JWT token"""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
107
app/main.py
Normal file
107
app/main.py
Normal file
@@ -0,0 +1,107 @@
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import Base, engine
|
||||
from app.api import auth, allarmi, siti, statistiche
|
||||
from app.mqtt.client import MQTTClient
|
||||
from app.mqtt.handler import alarm_handler
|
||||
|
||||
# Configurazione logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO if not settings.DEBUG else logging.DEBUG,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Client MQTT globale
|
||||
mqtt_client = None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Gestione lifecycle dell'applicazione"""
|
||||
global mqtt_client
|
||||
|
||||
# Startup
|
||||
logger.info("Avvio applicazione...")
|
||||
|
||||
# Crea tabelle database
|
||||
logger.info("Creazione tabelle database...")
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# Avvia client MQTT
|
||||
logger.info("Avvio client MQTT...")
|
||||
mqtt_client = MQTTClient(message_handler=alarm_handler.handle_alarm)
|
||||
try:
|
||||
mqtt_client.start()
|
||||
logger.info("Client MQTT avviato con successo")
|
||||
except Exception as e:
|
||||
logger.error(f"Errore nell'avvio del client MQTT: {e}")
|
||||
logger.warning("L'applicazione continuerà senza il client MQTT")
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
logger.info("Arresto applicazione...")
|
||||
if mqtt_client:
|
||||
mqtt_client.stop()
|
||||
logger.info("Client MQTT fermato")
|
||||
|
||||
|
||||
# Crea applicazione FastAPI
|
||||
app = FastAPI(
|
||||
title=settings.APP_NAME,
|
||||
description="API per il sistema di monitoraggio terreni con notifiche push",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Configurazione CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # In produzione, specificare i domini permessi
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Registrazione router
|
||||
app.include_router(auth.router)
|
||||
app.include_router(allarmi.router)
|
||||
app.include_router(siti.router)
|
||||
app.include_router(statistiche.router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Endpoint root per verificare che l'API sia attiva"""
|
||||
return {
|
||||
"message": "Terrain Monitor API",
|
||||
"version": "1.0.0",
|
||||
"status": "active",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint"""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"mqtt_connected": mqtt_client.client.is_connected() if mqtt_client else False,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(
|
||||
"app.main:app",
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
reload=settings.DEBUG,
|
||||
)
|
||||
16
app/models/__init__.py
Normal file
16
app/models/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from app.models.cliente import Cliente
|
||||
from app.models.sito import Sito, TipoSito
|
||||
from app.models.utente import Utente, RuoloUtente
|
||||
from app.models.allarme import Allarme, SeveritaAllarme, StatoAllarme, TipoAllarme
|
||||
|
||||
__all__ = [
|
||||
"Cliente",
|
||||
"Sito",
|
||||
"TipoSito",
|
||||
"Utente",
|
||||
"RuoloUtente",
|
||||
"Allarme",
|
||||
"SeveritaAllarme",
|
||||
"StatoAllarme",
|
||||
"TipoAllarme",
|
||||
]
|
||||
78
app/models/allarme.py
Normal file
78
app/models/allarme.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Float, ForeignKey, Enum as SQLEnum, JSON, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
import enum
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class SeveritaAllarme(str, enum.Enum):
|
||||
"""Livelli di severità degli allarmi"""
|
||||
CRITICAL = "critical" # Critico - richiede azione immediata
|
||||
WARNING = "warning" # Avviso - richiede attenzione
|
||||
INFO = "info" # Informativo
|
||||
|
||||
|
||||
class StatoAllarme(str, enum.Enum):
|
||||
"""Stati di un allarme"""
|
||||
NUOVO = "nuovo"
|
||||
VISUALIZZATO = "visualizzato"
|
||||
IN_GESTIONE = "in_gestione"
|
||||
RISOLTO = "risolto"
|
||||
FALSO_POSITIVO = "falso_positivo"
|
||||
|
||||
|
||||
class TipoAllarme(str, enum.Enum):
|
||||
"""Tipologie di allarmi"""
|
||||
MOVIMENTO_TERRENO = "movimento_terreno"
|
||||
DEFORMAZIONE = "deformazione"
|
||||
ACCELERAZIONE = "accelerazione"
|
||||
INCLINAZIONE = "inclinazione"
|
||||
FESSURAZIONE = "fessurazione"
|
||||
VIBRAZIONE = "vibrazione"
|
||||
TEMPERATURA_ANOMALA = "temperatura_anomala"
|
||||
UMIDITA_ANOMALA = "umidita_anomala"
|
||||
PERDITA_SEGNALE = "perdita_segnale"
|
||||
BATTERIA_SCARICA = "batteria_scarica"
|
||||
ALTRO = "altro"
|
||||
|
||||
|
||||
class Allarme(Base):
|
||||
"""Modello per gli allarmi generati dal sistema di monitoraggio"""
|
||||
|
||||
__tablename__ = "allarmi"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
sito_id = Column(Integer, ForeignKey("siti.id"), nullable=False)
|
||||
|
||||
# Classificazione allarme
|
||||
tipo = Column(SQLEnum(TipoAllarme), nullable=False)
|
||||
severita = Column(SQLEnum(SeveritaAllarme), nullable=False, index=True)
|
||||
stato = Column(SQLEnum(StatoAllarme), default=StatoAllarme.NUOVO, index=True)
|
||||
|
||||
# Dettagli allarme
|
||||
titolo = Column(String(255), nullable=False)
|
||||
descrizione = Column(Text)
|
||||
messaggio = Column(Text)
|
||||
|
||||
# Dati sensori (JSON con i valori rilevati)
|
||||
dati_sensori = Column(JSON)
|
||||
|
||||
# Valori soglia
|
||||
valore_rilevato = Column(Float)
|
||||
valore_soglia = Column(Float)
|
||||
unita_misura = Column(String(20))
|
||||
|
||||
# Metadata temporale
|
||||
timestamp_rilevamento = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||
timestamp_notifica = Column(DateTime(timezone=True))
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Note operative
|
||||
note = Column(Text)
|
||||
risolto_da = Column(String(255))
|
||||
timestamp_risoluzione = Column(DateTime(timezone=True))
|
||||
|
||||
# Relazioni
|
||||
sito = relationship("Sito", back_populates="allarmi")
|
||||
27
app/models/cliente.py
Normal file
27
app/models/cliente.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Cliente(Base):
|
||||
"""Modello per i clienti che possiedono siti monitorati"""
|
||||
|
||||
__tablename__ = "clienti"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
nome = Column(String(255), nullable=False)
|
||||
ragione_sociale = Column(String(255))
|
||||
codice_fiscale = Column(String(16), unique=True)
|
||||
partita_iva = Column(String(11), unique=True)
|
||||
email = Column(String(255), nullable=False)
|
||||
telefono = Column(String(20))
|
||||
indirizzo = Column(String(500))
|
||||
attivo = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Relazioni
|
||||
siti = relationship("Sito", back_populates="cliente", cascade="all, delete-orphan")
|
||||
utenti = relationship("Utente", back_populates="cliente", cascade="all, delete-orphan")
|
||||
49
app/models/sito.py
Normal file
49
app/models/sito.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Float, ForeignKey, Enum as SQLEnum
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
import enum
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class TipoSito(str, enum.Enum):
|
||||
"""Tipi di siti monitorati"""
|
||||
FRANA = "frana"
|
||||
GALLERIA = "galleria"
|
||||
PONTE = "ponte"
|
||||
DIGA = "diga"
|
||||
VERSANTE = "versante"
|
||||
EDIFICIO = "edificio"
|
||||
ALTRO = "altro"
|
||||
|
||||
|
||||
class Sito(Base):
|
||||
"""Modello per i siti monitorati"""
|
||||
|
||||
__tablename__ = "siti"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
cliente_id = Column(Integer, ForeignKey("clienti.id"), nullable=False)
|
||||
nome = Column(String(255), nullable=False)
|
||||
tipo = Column(SQLEnum(TipoSito), nullable=False)
|
||||
descrizione = Column(String(1000))
|
||||
|
||||
# Coordinate geografiche
|
||||
latitudine = Column(Float)
|
||||
longitudine = Column(Float)
|
||||
altitudine = Column(Float)
|
||||
|
||||
# Indirizzo
|
||||
indirizzo = Column(String(500))
|
||||
comune = Column(String(100))
|
||||
provincia = Column(String(2))
|
||||
regione = Column(String(100))
|
||||
|
||||
# Metadata
|
||||
codice_identificativo = Column(String(50), unique=True, index=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Relazioni
|
||||
cliente = relationship("Cliente", back_populates="siti")
|
||||
allarmi = relationship("Allarme", back_populates="sito", cascade="all, delete-orphan")
|
||||
43
app/models/utente.py
Normal file
43
app/models/utente.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, Enum as SQLEnum
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
import enum
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class RuoloUtente(str, enum.Enum):
|
||||
"""Ruoli utente nell'applicazione"""
|
||||
ADMIN = "admin"
|
||||
OPERATORE = "operatore"
|
||||
VISUALIZZATORE = "visualizzatore"
|
||||
|
||||
|
||||
class Utente(Base):
|
||||
"""Modello per gli utenti dell'applicazione"""
|
||||
|
||||
__tablename__ = "utenti"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
cliente_id = Column(Integer, ForeignKey("clienti.id"), nullable=False)
|
||||
email = Column(String(255), unique=True, nullable=False, index=True)
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
nome = Column(String(100), nullable=False)
|
||||
cognome = Column(String(100), nullable=False)
|
||||
telefono = Column(String(20))
|
||||
ruolo = Column(SQLEnum(RuoloUtente), default=RuoloUtente.VISUALIZZATORE)
|
||||
|
||||
# FCM token per notifiche push
|
||||
fcm_token = Column(String(500))
|
||||
|
||||
# Flags
|
||||
attivo = Column(Boolean, default=True)
|
||||
email_verificata = Column(Boolean, default=False)
|
||||
|
||||
# Metadata
|
||||
ultimo_accesso = Column(DateTime(timezone=True))
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Relazioni
|
||||
cliente = relationship("Cliente", back_populates="utenti")
|
||||
0
app/mqtt/__init__.py
Normal file
0
app/mqtt/__init__.py
Normal file
139
app/mqtt/client.py
Normal file
139
app/mqtt/client.py
Normal file
@@ -0,0 +1,139 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Callable, Optional
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MQTTClient:
|
||||
"""Client MQTT per ricevere allarmi dal sistema di monitoraggio"""
|
||||
|
||||
def __init__(self, message_handler: Optional[Callable] = None):
|
||||
self.client = mqtt.Client(client_id="terrain_monitor_backend")
|
||||
self.message_handler = message_handler
|
||||
self._setup_callbacks()
|
||||
|
||||
def _setup_callbacks(self):
|
||||
"""Configura i callback MQTT"""
|
||||
self.client.on_connect = self._on_connect
|
||||
self.client.on_disconnect = self._on_disconnect
|
||||
self.client.on_message = self._on_message
|
||||
|
||||
def _on_connect(self, client, userdata, flags, rc):
|
||||
"""Callback chiamato quando il client si connette al broker"""
|
||||
if rc == 0:
|
||||
logger.info("Connesso al broker MQTT")
|
||||
# Sottoscrizione ai topic (pattern: terrain/{cliente_id}/{sito_id}/alarms)
|
||||
client.subscribe(settings.MQTT_TOPIC_ALARMS, qos=1)
|
||||
logger.info(f"Sottoscritto al topic allarmi: {settings.MQTT_TOPIC_ALARMS}")
|
||||
|
||||
# Opzionale: sottoscrivi anche telemetry e status se necessario
|
||||
# client.subscribe(settings.MQTT_TOPIC_TELEMETRY, qos=0)
|
||||
# client.subscribe(settings.MQTT_TOPIC_STATUS, qos=0)
|
||||
else:
|
||||
logger.error(f"Connessione fallita con codice: {rc}")
|
||||
|
||||
def _on_disconnect(self, client, userdata, rc):
|
||||
"""Callback chiamato quando il client si disconnette"""
|
||||
if rc != 0:
|
||||
logger.warning(f"Disconnessione inaspettata. Codice: {rc}")
|
||||
else:
|
||||
logger.info("Disconnesso dal broker MQTT")
|
||||
|
||||
def _on_message(self, client, userdata, msg):
|
||||
"""
|
||||
Callback chiamato quando arriva un messaggio
|
||||
|
||||
Topic pattern: terrain/{cliente_id}/{sito_id}/alarms
|
||||
|
||||
Formato messaggio atteso (JSON):
|
||||
{
|
||||
"tipo": "movimento_terreno",
|
||||
"severita": "critical",
|
||||
"titolo": "Movimento terreno rilevato",
|
||||
"descrizione": "Rilevato movimento anomalo...",
|
||||
"valore_rilevato": 12.5,
|
||||
"valore_soglia": 10.0,
|
||||
"unita_misura": "mm",
|
||||
"timestamp": "2025-10-18T10:30:00Z",
|
||||
"dati_sensori": {
|
||||
"sensore_1": 12.5,
|
||||
"sensore_2": 8.3
|
||||
}
|
||||
}
|
||||
|
||||
Nota: sito_id e cliente_id vengono estratti dal topic, non dal payload
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Messaggio ricevuto sul topic {msg.topic}")
|
||||
|
||||
# Parse topic per estrarre cliente_id e sito_id
|
||||
# Formato: terrain/{cliente_id}/{sito_id}/alarms
|
||||
topic_parts = msg.topic.split('/')
|
||||
if len(topic_parts) >= 4:
|
||||
cliente_id_str = topic_parts[1]
|
||||
sito_id_str = topic_parts[2]
|
||||
|
||||
# Decodifica e parse payload
|
||||
payload = json.loads(msg.payload.decode())
|
||||
|
||||
# Aggiungi informazioni dal topic al payload
|
||||
payload['sito_id'] = int(sito_id_str)
|
||||
payload['cliente_id'] = int(cliente_id_str)
|
||||
|
||||
logger.debug(f"Cliente: {cliente_id_str}, Sito: {sito_id_str}, Payload: {payload}")
|
||||
|
||||
if self.message_handler:
|
||||
self.message_handler(payload)
|
||||
else:
|
||||
logger.warning("Nessun handler configurato per processare il messaggio")
|
||||
else:
|
||||
logger.error(f"Topic malformato: {msg.topic}. Atteso: terrain/{{cliente_id}}/{{sito_id}}/alarms")
|
||||
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"Errore nel parsing del JSON: {msg.payload}")
|
||||
except ValueError as e:
|
||||
logger.error(f"Errore nella conversione di cliente_id o sito_id: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Errore nel processamento del messaggio: {e}")
|
||||
|
||||
def connect(self):
|
||||
"""Connette al broker MQTT"""
|
||||
try:
|
||||
if settings.MQTT_USERNAME and settings.MQTT_PASSWORD:
|
||||
self.client.username_pw_set(
|
||||
settings.MQTT_USERNAME,
|
||||
settings.MQTT_PASSWORD
|
||||
)
|
||||
|
||||
self.client.connect(
|
||||
settings.MQTT_BROKER_HOST,
|
||||
settings.MQTT_BROKER_PORT,
|
||||
keepalive=60
|
||||
)
|
||||
logger.info(
|
||||
f"Connessione a {settings.MQTT_BROKER_HOST}:"
|
||||
f"{settings.MQTT_BROKER_PORT}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Errore nella connessione al broker MQTT: {e}")
|
||||
raise
|
||||
|
||||
def start(self):
|
||||
"""Avvia il loop MQTT in background"""
|
||||
self.connect()
|
||||
self.client.loop_start()
|
||||
logger.info("Loop MQTT avviato")
|
||||
|
||||
def stop(self):
|
||||
"""Ferma il loop MQTT"""
|
||||
self.client.loop_stop()
|
||||
self.client.disconnect()
|
||||
logger.info("Loop MQTT fermato")
|
||||
|
||||
def set_message_handler(self, handler: Callable):
|
||||
"""Imposta l'handler per i messaggi ricevuti"""
|
||||
self.message_handler = handler
|
||||
150
app/mqtt/handler.py
Normal file
150
app/mqtt/handler.py
Normal file
@@ -0,0 +1,150 @@
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import SessionLocal
|
||||
from app.models import Allarme, Sito, Utente
|
||||
from app.services.firebase import firebase_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AlarmHandler:
|
||||
"""Handler per processare gli allarmi ricevuti via MQTT e inviarli tramite FCM"""
|
||||
|
||||
def __init__(self):
|
||||
self.db: Session = None
|
||||
|
||||
def _get_db(self) -> Session:
|
||||
"""Ottiene una nuova sessione database"""
|
||||
if self.db is None or not self.db.is_active:
|
||||
self.db = SessionLocal()
|
||||
return self.db
|
||||
|
||||
def handle_alarm(self, payload: dict):
|
||||
"""
|
||||
Processa un allarme ricevuto via MQTT
|
||||
|
||||
Steps:
|
||||
1. Valida il payload
|
||||
2. Salva l'allarme nel database
|
||||
3. Recupera gli utenti del cliente associato al sito
|
||||
4. Invia notifiche push tramite FCM
|
||||
|
||||
Args:
|
||||
payload: Dizionario con i dati dell'allarme
|
||||
"""
|
||||
db = self._get_db()
|
||||
|
||||
try:
|
||||
# 1. Validazione
|
||||
sito_id = payload.get("sito_id")
|
||||
if not sito_id:
|
||||
logger.error("Payload senza sito_id")
|
||||
return
|
||||
|
||||
# Verifica che il sito esista
|
||||
sito = db.query(Sito).filter(Sito.id == sito_id).first()
|
||||
if not sito:
|
||||
logger.error(f"Sito {sito_id} non trovato")
|
||||
return
|
||||
|
||||
# 2. Salva allarme nel database
|
||||
timestamp_str = payload.get("timestamp")
|
||||
timestamp = datetime.fromisoformat(
|
||||
timestamp_str.replace("Z", "+00:00")
|
||||
) if timestamp_str else datetime.now(timezone.utc)
|
||||
|
||||
allarme = Allarme(
|
||||
sito_id=sito_id,
|
||||
tipo=payload.get("tipo"),
|
||||
severita=payload.get("severita"),
|
||||
titolo=payload.get("titolo"),
|
||||
descrizione=payload.get("descrizione"),
|
||||
messaggio=payload.get("messaggio"),
|
||||
valore_rilevato=payload.get("valore_rilevato"),
|
||||
valore_soglia=payload.get("valore_soglia"),
|
||||
unita_misura=payload.get("unita_misura"),
|
||||
dati_sensori=payload.get("dati_sensori"),
|
||||
timestamp_rilevamento=timestamp,
|
||||
timestamp_notifica=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
db.add(allarme)
|
||||
db.commit()
|
||||
db.refresh(allarme)
|
||||
|
||||
logger.info(f"Allarme {allarme.id} salvato per sito {sito.nome}")
|
||||
|
||||
# 3. Recupera utenti del cliente
|
||||
utenti = (
|
||||
db.query(Utente)
|
||||
.filter(
|
||||
Utente.cliente_id == sito.cliente_id,
|
||||
Utente.attivo == True,
|
||||
Utente.fcm_token.isnot(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
if not utenti:
|
||||
logger.warning(
|
||||
f"Nessun utente attivo con FCM token per cliente {sito.cliente_id}"
|
||||
)
|
||||
return
|
||||
|
||||
# 4. Invia notifiche push
|
||||
self._send_notifications(allarme, sito, utenti)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Errore nel processamento dell'allarme: {e}")
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def _send_notifications(self, allarme: Allarme, sito: Sito, utenti: list[Utente]):
|
||||
"""Invia notifiche push agli utenti"""
|
||||
|
||||
# Determina priorità basata sulla severità
|
||||
priority = "high" if allarme.severita == "critical" else "normal"
|
||||
|
||||
# Prepara dati per la notifica
|
||||
notification_data = {
|
||||
"alarm_id": str(allarme.id),
|
||||
"sito_id": str(sito.id),
|
||||
"sito_nome": sito.nome,
|
||||
"tipo": allarme.tipo,
|
||||
"severita": allarme.severita,
|
||||
"timestamp": allarme.timestamp_rilevamento.isoformat(),
|
||||
}
|
||||
|
||||
# Prepara titolo e messaggio
|
||||
title = f"{allarme.severita.upper()}: {sito.nome}"
|
||||
body = allarme.titolo or allarme.descrizione or "Nuovo allarme rilevato"
|
||||
|
||||
# Raccogli tutti i token
|
||||
tokens = [u.fcm_token for u in utenti if u.fcm_token]
|
||||
|
||||
if not tokens:
|
||||
logger.warning("Nessun token FCM valido trovato")
|
||||
return
|
||||
|
||||
logger.info(f"Invio notifica a {len(tokens)} dispositivi")
|
||||
|
||||
# Invia notifica multicast
|
||||
result = firebase_service.send_multicast(
|
||||
tokens=tokens,
|
||||
title=title,
|
||||
body=body,
|
||||
data=notification_data,
|
||||
priority=priority,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Notifiche inviate per allarme {allarme.id}: "
|
||||
f"{result['success']} successi, {result['failure']} fallimenti"
|
||||
)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
alarm_handler = AlarmHandler()
|
||||
110
app/mqtt/topics.py
Normal file
110
app/mqtt/topics.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
Helper per costruire topic MQTT con la struttura standardizzata
|
||||
|
||||
Topic structure:
|
||||
- terrain/{cliente_id}/{sito_id}/alarms - Allarmi critici/warning/info
|
||||
- terrain/{cliente_id}/{sito_id}/telemetry - Dati sensori periodici
|
||||
- terrain/{cliente_id}/{sito_id}/status - Stato gateway/sensori
|
||||
"""
|
||||
from typing import Literal
|
||||
|
||||
TopicType = Literal["alarms", "telemetry", "status"]
|
||||
|
||||
|
||||
class MQTTTopics:
|
||||
"""Helper per costruire topic MQTT standardizzati"""
|
||||
|
||||
BASE = "terrain"
|
||||
|
||||
@staticmethod
|
||||
def build_topic(
|
||||
cliente_id: int,
|
||||
sito_id: int,
|
||||
topic_type: TopicType = "alarms"
|
||||
) -> str:
|
||||
"""
|
||||
Costruisce un topic MQTT standardizzato
|
||||
|
||||
Args:
|
||||
cliente_id: ID del cliente
|
||||
sito_id: ID del sito
|
||||
topic_type: Tipo di topic (alarms, telemetry, status)
|
||||
|
||||
Returns:
|
||||
Topic MQTT formattato
|
||||
|
||||
Examples:
|
||||
>>> MQTTTopics.build_topic(5, 17, "alarms")
|
||||
'terrain/5/17/alarms'
|
||||
|
||||
>>> MQTTTopics.build_topic(5, 17, "telemetry")
|
||||
'terrain/5/17/telemetry'
|
||||
"""
|
||||
return f"{MQTTTopics.BASE}/{cliente_id}/{sito_id}/{topic_type}"
|
||||
|
||||
@staticmethod
|
||||
def parse_topic(topic: str) -> dict:
|
||||
"""
|
||||
Parse un topic MQTT ed estrae le informazioni
|
||||
|
||||
Args:
|
||||
topic: Topic MQTT da parsare
|
||||
|
||||
Returns:
|
||||
Dict con cliente_id, sito_id, topic_type
|
||||
|
||||
Raises:
|
||||
ValueError: Se il topic è malformato
|
||||
|
||||
Examples:
|
||||
>>> MQTTTopics.parse_topic("terrain/5/17/alarms")
|
||||
{'cliente_id': 5, 'sito_id': 17, 'topic_type': 'alarms'}
|
||||
"""
|
||||
parts = topic.split('/')
|
||||
|
||||
if len(parts) < 4:
|
||||
raise ValueError(
|
||||
f"Topic malformato: {topic}. "
|
||||
f"Atteso: terrain/{{cliente_id}}/{{sito_id}}/{{type}}"
|
||||
)
|
||||
|
||||
if parts[0] != MQTTTopics.BASE:
|
||||
raise ValueError(
|
||||
f"Topic base errato: {parts[0]}. "
|
||||
f"Atteso: {MQTTTopics.BASE}"
|
||||
)
|
||||
|
||||
try:
|
||||
return {
|
||||
'cliente_id': int(parts[1]),
|
||||
'sito_id': int(parts[2]),
|
||||
'topic_type': parts[3]
|
||||
}
|
||||
except (ValueError, IndexError) as e:
|
||||
raise ValueError(
|
||||
f"Errore nel parsing del topic {topic}: {e}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_subscription_pattern(topic_type: TopicType | None = None) -> str:
|
||||
"""
|
||||
Ottiene il pattern di sottoscrizione MQTT
|
||||
|
||||
Args:
|
||||
topic_type: Se specificato, sottoscrive solo a quel tipo
|
||||
|
||||
Returns:
|
||||
Pattern MQTT con wildcards
|
||||
|
||||
Examples:
|
||||
>>> MQTTTopics.get_subscription_pattern()
|
||||
'terrain/+/+/#'
|
||||
|
||||
>>> MQTTTopics.get_subscription_pattern("alarms")
|
||||
'terrain/+/+/alarms'
|
||||
"""
|
||||
if topic_type:
|
||||
return f"{MQTTTopics.BASE}/+/+/{topic_type}"
|
||||
else:
|
||||
# Sottoscrivi a tutti i tipi
|
||||
return f"{MQTTTopics.BASE}/+/+/#"
|
||||
0
app/schemas/__init__.py
Normal file
0
app/schemas/__init__.py
Normal file
56
app/schemas/allarme.py
Normal file
56
app/schemas/allarme.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.models.allarme import SeveritaAllarme, StatoAllarme, TipoAllarme
|
||||
|
||||
|
||||
class AllarmeBase(BaseModel):
|
||||
"""Schema base per allarmi"""
|
||||
tipo: TipoAllarme
|
||||
severita: SeveritaAllarme
|
||||
titolo: str = Field(..., max_length=255)
|
||||
descrizione: Optional[str] = None
|
||||
messaggio: Optional[str] = None
|
||||
valore_rilevato: Optional[float] = None
|
||||
valore_soglia: Optional[float] = None
|
||||
unita_misura: Optional[str] = Field(None, max_length=20)
|
||||
dati_sensori: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class AllarmeCreate(AllarmeBase):
|
||||
"""Schema per creazione allarme"""
|
||||
sito_id: int
|
||||
timestamp_rilevamento: datetime
|
||||
|
||||
|
||||
class AllarmeUpdate(BaseModel):
|
||||
"""Schema per aggiornamento allarme"""
|
||||
stato: Optional[StatoAllarme] = None
|
||||
note: Optional[str] = None
|
||||
risolto_da: Optional[str] = None
|
||||
|
||||
|
||||
class AllarmeResponse(AllarmeBase):
|
||||
"""Schema per risposta API con allarme"""
|
||||
id: int
|
||||
sito_id: int
|
||||
stato: StatoAllarme
|
||||
timestamp_rilevamento: datetime
|
||||
timestamp_notifica: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
note: Optional[str] = None
|
||||
risolto_da: Optional[str] = None
|
||||
timestamp_risoluzione: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AllarmeList(BaseModel):
|
||||
"""Schema per lista paginata di allarmi"""
|
||||
total: int
|
||||
items: list[AllarmeResponse]
|
||||
page: int
|
||||
page_size: int
|
||||
25
app/schemas/auth.py
Normal file
25
app/schemas/auth.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
"""Schema per token JWT"""
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class TokenData(BaseModel):
|
||||
"""Schema per dati contenuti nel token"""
|
||||
email: Optional[str] = None
|
||||
cliente_id: Optional[int] = None
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
"""Schema per richiesta di login"""
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class RegisterFCMToken(BaseModel):
|
||||
"""Schema per registrazione FCM token"""
|
||||
fcm_token: str
|
||||
36
app/schemas/sito.py
Normal file
36
app/schemas/sito.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.models.sito import TipoSito
|
||||
|
||||
|
||||
class SitoBase(BaseModel):
|
||||
"""Schema base per siti"""
|
||||
nome: str
|
||||
tipo: TipoSito
|
||||
descrizione: Optional[str] = None
|
||||
latitudine: Optional[float] = None
|
||||
longitudine: Optional[float] = None
|
||||
altitudine: Optional[float] = None
|
||||
comune: Optional[str] = None
|
||||
provincia: Optional[str] = None
|
||||
regione: Optional[str] = None
|
||||
codice_identificativo: Optional[str] = None
|
||||
|
||||
|
||||
class SitoResponse(SitoBase):
|
||||
"""Schema per risposta API con sito"""
|
||||
id: int
|
||||
cliente_id: int
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class SitoListResponse(BaseModel):
|
||||
"""Schema per lista siti"""
|
||||
total: int
|
||||
items: list[SitoResponse]
|
||||
39
app/schemas/statistiche.py
Normal file
39
app/schemas/statistiche.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import List
|
||||
|
||||
|
||||
class StatisticheResponse(BaseModel):
|
||||
"""Schema per statistiche dashboard"""
|
||||
totale_allarmi: int
|
||||
totale_siti: int
|
||||
allarmi_aperti: int
|
||||
allarmi_recenti_7gg: int
|
||||
|
||||
# Per severità
|
||||
allarmi_critical: int
|
||||
allarmi_warning: int
|
||||
allarmi_info: int
|
||||
|
||||
# Per stato
|
||||
allarmi_nuovo: int
|
||||
allarmi_in_gestione: int
|
||||
allarmi_risolto: int
|
||||
|
||||
# Siti per tipo
|
||||
siti_ponte: int
|
||||
siti_galleria: int
|
||||
siti_diga: int
|
||||
siti_frana: int
|
||||
siti_versante: int
|
||||
siti_edificio: int
|
||||
|
||||
|
||||
class AllarmePerGiornoItem(BaseModel):
|
||||
"""Item per grafico allarmi per giorno"""
|
||||
data: str # ISO format date
|
||||
count: int
|
||||
|
||||
|
||||
class AllarmiPerGiornoResponse(BaseModel):
|
||||
"""Schema per allarmi raggruppati per giorno"""
|
||||
dati: List[AllarmePerGiornoItem]
|
||||
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
176
app/services/firebase.py
Normal file
176
app/services/firebase.py
Normal file
@@ -0,0 +1,176 @@
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
import firebase_admin
|
||||
from firebase_admin import credentials, messaging
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FirebaseService:
|
||||
"""Servizio per gestire le notifiche push tramite Firebase Cloud Messaging"""
|
||||
|
||||
_instance = None
|
||||
_initialized = False
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super(FirebaseService, cls).__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if not self._initialized:
|
||||
self._initialize_firebase()
|
||||
self.__class__._initialized = True
|
||||
|
||||
def _initialize_firebase(self):
|
||||
"""Inizializza Firebase Admin SDK"""
|
||||
try:
|
||||
cred = credentials.Certificate(settings.FIREBASE_CREDENTIALS_PATH)
|
||||
firebase_admin.initialize_app(cred)
|
||||
logger.info("Firebase Admin SDK inizializzato con successo")
|
||||
except Exception as e:
|
||||
logger.error(f"Errore nell'inizializzazione di Firebase: {e}")
|
||||
raise
|
||||
|
||||
def send_notification(
|
||||
self,
|
||||
token: str,
|
||||
title: str,
|
||||
body: str,
|
||||
data: Optional[dict] = None,
|
||||
priority: str = "high"
|
||||
) -> bool:
|
||||
"""
|
||||
Invia una notifica push a un singolo dispositivo
|
||||
|
||||
Args:
|
||||
token: FCM token del dispositivo
|
||||
title: Titolo della notifica
|
||||
body: Corpo della notifica
|
||||
data: Dati aggiuntivi da inviare
|
||||
priority: Priorità della notifica (high/normal)
|
||||
|
||||
Returns:
|
||||
True se inviata con successo, False altrimenti
|
||||
"""
|
||||
try:
|
||||
message = messaging.Message(
|
||||
notification=messaging.Notification(
|
||||
title=title,
|
||||
body=body,
|
||||
),
|
||||
data=data or {},
|
||||
token=token,
|
||||
android=messaging.AndroidConfig(
|
||||
priority=priority,
|
||||
notification=messaging.AndroidNotification(
|
||||
sound="default",
|
||||
priority="max" if priority == "high" else "default",
|
||||
),
|
||||
),
|
||||
apns=messaging.APNSConfig(
|
||||
payload=messaging.APNSPayload(
|
||||
aps=messaging.Aps(
|
||||
sound="default",
|
||||
badge=1,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
response = messaging.send(message)
|
||||
logger.info(f"Notifica inviata con successo: {response}")
|
||||
return True
|
||||
|
||||
except messaging.UnregisteredError:
|
||||
logger.warning(f"Token non registrato o scaduto: {token}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Errore nell'invio della notifica: {e}")
|
||||
return False
|
||||
|
||||
def send_multicast(
|
||||
self,
|
||||
tokens: List[str],
|
||||
title: str,
|
||||
body: str,
|
||||
data: Optional[dict] = None,
|
||||
priority: str = "high"
|
||||
) -> dict:
|
||||
"""
|
||||
Invia una notifica push a più dispositivi
|
||||
|
||||
Args:
|
||||
tokens: Lista di FCM tokens
|
||||
title: Titolo della notifica
|
||||
body: Corpo della notifica
|
||||
data: Dati aggiuntivi da inviare
|
||||
priority: Priorità della notifica
|
||||
|
||||
Returns:
|
||||
Dizionario con statistiche di invio
|
||||
"""
|
||||
if not tokens:
|
||||
return {"success": 0, "failure": 0}
|
||||
|
||||
try:
|
||||
# Crea messaggio base
|
||||
base_message = messaging.Message(
|
||||
notification=messaging.Notification(
|
||||
title=title,
|
||||
body=body,
|
||||
),
|
||||
data=data or {},
|
||||
android=messaging.AndroidConfig(
|
||||
priority=priority,
|
||||
notification=messaging.AndroidNotification(
|
||||
sound="default",
|
||||
priority="max" if priority == "high" else "default",
|
||||
),
|
||||
),
|
||||
apns=messaging.APNSConfig(
|
||||
payload=messaging.APNSPayload(
|
||||
aps=messaging.Aps(
|
||||
sound="default",
|
||||
badge=1,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# Crea un multicast message
|
||||
multicast_message = messaging.MulticastMessage(
|
||||
notification=base_message.notification,
|
||||
data=base_message.data,
|
||||
android=base_message.android,
|
||||
apns=base_message.apns,
|
||||
tokens=tokens,
|
||||
)
|
||||
|
||||
# Usa send_each_for_multicast invece di send_multicast
|
||||
response = messaging.send_each_for_multicast(multicast_message)
|
||||
|
||||
success_count = response.success_count
|
||||
failure_count = response.failure_count
|
||||
|
||||
logger.info(
|
||||
f"Notifiche inviate: {success_count} successi, "
|
||||
f"{failure_count} fallimenti"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": success_count,
|
||||
"failure": failure_count,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Errore nell'invio multicast: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {"success": 0, "failure": len(tokens)}
|
||||
|
||||
|
||||
# Singleton instance
|
||||
firebase_service = FirebaseService()
|
||||
48
docker-compose.yml
Normal file
48
docker-compose.yml
Normal file
@@ -0,0 +1,48 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# Database PostgreSQL
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: terrain_monitor_db
|
||||
environment:
|
||||
POSTGRES_USER: terrain_user
|
||||
POSTGRES_PASSWORD: terrain_pass
|
||||
POSTGRES_DB: terrain_monitor
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U terrain_user"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# MQTT Broker (Mosquitto)
|
||||
mosquitto:
|
||||
image: eclipse-mosquitto:2
|
||||
container_name: terrain_monitor_mqtt
|
||||
ports:
|
||||
- "1883:1883"
|
||||
- "9001:9001"
|
||||
volumes:
|
||||
- ./mosquitto/config:/mosquitto/config
|
||||
- ./mosquitto/data:/mosquitto/data
|
||||
- ./mosquitto/log:/mosquitto/log
|
||||
command: mosquitto -c /mosquitto/config/mosquitto.conf
|
||||
|
||||
# pgAdmin (opzionale - UI per PostgreSQL)
|
||||
pgadmin:
|
||||
image: dpage/pgadmin4:latest
|
||||
container_name: terrain_monitor_pgadmin
|
||||
environment:
|
||||
PGADMIN_DEFAULT_EMAIL: admin@aseltd.eu
|
||||
PGADMIN_DEFAULT_PASSWORD: admin
|
||||
ports:
|
||||
- "5050:80"
|
||||
depends_on:
|
||||
- postgres
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
20
main.py
Normal file
20
main.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
Entry point per l'applicazione Terrain Monitor
|
||||
|
||||
Per avviare il server:
|
||||
python main.py
|
||||
|
||||
Oppure con uvicorn:
|
||||
uvicorn app.main:app --reload
|
||||
"""
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
from app.core.config import settings
|
||||
|
||||
uvicorn.run(
|
||||
"app.main:app",
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
reload=settings.DEBUG,
|
||||
)
|
||||
29
pyproject.toml
Normal file
29
pyproject.toml
Normal file
@@ -0,0 +1,29 @@
|
||||
[project]
|
||||
name = "terrain-monitor-backend"
|
||||
version = "0.1.0"
|
||||
description = "Sistema di monitoraggio terreni con notifiche push per allarmi real-time"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn[standard]>=0.32.0",
|
||||
"sqlalchemy>=2.0.35",
|
||||
"alembic>=1.13.3",
|
||||
"psycopg2-binary>=2.9.10",
|
||||
"pydantic[email]>=2.9.2",
|
||||
"pydantic-settings>=2.6.0",
|
||||
"python-jose[cryptography]>=3.3.0",
|
||||
"passlib[bcrypt]>=1.7.4",
|
||||
"bcrypt>=4.0.0,<5.0.0",
|
||||
"python-multipart>=0.0.12",
|
||||
"paho-mqtt>=2.1.0",
|
||||
"firebase-admin>=6.5.0",
|
||||
"python-dotenv>=1.0.1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.3.3",
|
||||
"pytest-asyncio>=0.24.0",
|
||||
"httpx>=0.27.2",
|
||||
]
|
||||
18
run_with_cloud_env.sh
Executable file
18
run_with_cloud_env.sh
Executable file
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
# Script per eseguire comandi Python con configurazione cloud
|
||||
|
||||
export DATABASE_URL="postgresql://terrain_user:terrain_pass@94.177.199.207:5432/terrain_monitor"
|
||||
export MQTT_BROKER_HOST="94.177.199.207"
|
||||
export MQTT_BROKER_PORT=1883
|
||||
export SECRET_KEY="batt1l0ngs3cur3k3y"
|
||||
export MQTT_TOPIC_ALARMS="terrain/+/+/alarms"
|
||||
export MQTT_TOPIC_TELEMETRY="terrain/+/+/telemetry"
|
||||
export MQTT_TOPIC_STATUS="terrain/+/+/status"
|
||||
export DEBUG=True
|
||||
export FIREBASE_CREDENTIALS_PATH="./firebase-credentials.json"
|
||||
|
||||
# Attiva virtual environment
|
||||
source .venv/bin/activate
|
||||
|
||||
# Esegui il comando passato come argomento
|
||||
"$@"
|
||||
191
scripts/init_db.py
Normal file
191
scripts/init_db.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""
|
||||
Script per inizializzare il database con dati di esempio
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 1. Ottieni il percorso assoluto della directory corrente dello script
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# 2. Risali di un livello per raggiungere la directory radice (quella che contiene 'app' e 'scripts')
|
||||
# Esempio: da /percorso/al/progetto/scripts a /percorso/al/progetto
|
||||
project_root = os.path.join(current_dir, '..')
|
||||
|
||||
# 3. Aggiungi la directory radice al path di ricerca dei moduli di Python
|
||||
sys.path.append(project_root)
|
||||
|
||||
# 4. Carica variabili d'ambiente dal file .env
|
||||
from dotenv import load_dotenv
|
||||
env_path = os.path.join(project_root, '.env')
|
||||
load_dotenv(env_path)
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from app.core.database import SessionLocal, engine, Base
|
||||
from app.models import Cliente, Sito, Utente, TipoSito, RuoloUtente
|
||||
from app.core.security import get_password_hash
|
||||
|
||||
|
||||
def init_database():
|
||||
"""Crea le tabelle e inserisce dati di esempio"""
|
||||
|
||||
# Crea tutte le tabelle
|
||||
print("Creazione tabelle...")
|
||||
Base.metadata.create_all(bind=engine)
|
||||
print("✓ Tabelle create")
|
||||
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
# Verifica se esistono già dati
|
||||
existing_cliente = db.query(Cliente).first()
|
||||
if existing_cliente:
|
||||
print("⚠ Database già inizializzato. Skip.")
|
||||
return
|
||||
|
||||
# 1. Crea cliente di esempio
|
||||
print("\nCreazione cliente di esempio...")
|
||||
cliente = Cliente(
|
||||
nome="Azienda Monitoraggio SRL",
|
||||
ragione_sociale="Azienda Monitoraggio SRL",
|
||||
partita_iva="12345678901",
|
||||
email="info@aziendamonitoraggio.it",
|
||||
telefono="+39 02 1234567",
|
||||
indirizzo="Via Roma 1, 20100 Milano",
|
||||
attivo=True,
|
||||
)
|
||||
db.add(cliente)
|
||||
db.flush()
|
||||
print(f"✓ Cliente creato: {cliente.nome} (ID: {cliente.id})")
|
||||
|
||||
# 2. Crea siti di esempio
|
||||
print("\nCreazione siti monitorati...")
|
||||
siti = [
|
||||
Sito(
|
||||
cliente_id=cliente.id,
|
||||
nome="Ponte Morandi",
|
||||
tipo=TipoSito.PONTE,
|
||||
descrizione="Ponte autostradale sulla A10",
|
||||
latitudine=44.4268,
|
||||
longitudine=8.9137,
|
||||
comune="Genova",
|
||||
provincia="GE",
|
||||
regione="Liguria",
|
||||
codice_identificativo="PON-001",
|
||||
),
|
||||
Sito(
|
||||
cliente_id=cliente.id,
|
||||
nome="Galleria San Boldo",
|
||||
tipo=TipoSito.GALLERIA,
|
||||
descrizione="Galleria stradale sul passo San Boldo",
|
||||
latitudine=45.9811,
|
||||
longitudine=12.1064,
|
||||
comune="Cison di Valmarino",
|
||||
provincia="TV",
|
||||
regione="Veneto",
|
||||
codice_identificativo="GAL-002",
|
||||
),
|
||||
Sito(
|
||||
cliente_id=cliente.id,
|
||||
nome="Diga del Vajont",
|
||||
tipo=TipoSito.DIGA,
|
||||
descrizione="Diga sul torrente Vajont",
|
||||
latitudine=46.2691,
|
||||
longitudine=12.3281,
|
||||
comune="Erto e Casso",
|
||||
provincia="PN",
|
||||
regione="Friuli-Venezia Giulia",
|
||||
codice_identificativo="DIG-003",
|
||||
),
|
||||
Sito(
|
||||
cliente_id=cliente.id,
|
||||
nome="Versante Cà di Sotto",
|
||||
tipo=TipoSito.FRANA,
|
||||
descrizione="Area soggetta a movimento franoso",
|
||||
latitudine=44.3521,
|
||||
longitudine=10.8464,
|
||||
comune="Corniglio",
|
||||
provincia="PR",
|
||||
regione="Emilia-Romagna",
|
||||
codice_identificativo="FRA-004",
|
||||
),
|
||||
]
|
||||
|
||||
for sito in siti:
|
||||
db.add(sito)
|
||||
print(f"✓ Sito creato: {sito.nome} ({sito.tipo})")
|
||||
|
||||
db.flush()
|
||||
|
||||
# 3. Crea utenti di esempio
|
||||
print("\nCreazione utenti...")
|
||||
utenti = [
|
||||
Utente(
|
||||
cliente_id=cliente.id,
|
||||
email="admin@azienda.it",
|
||||
password_hash=get_password_hash("admin123"),
|
||||
nome="Mario",
|
||||
cognome="Rossi",
|
||||
telefono="+39 333 1234567",
|
||||
ruolo=RuoloUtente.ADMIN,
|
||||
attivo=True,
|
||||
email_verificata=True,
|
||||
),
|
||||
Utente(
|
||||
cliente_id=cliente.id,
|
||||
email="operatore@azienda.it",
|
||||
password_hash=get_password_hash("operatore123"),
|
||||
nome="Luca",
|
||||
cognome="Bianchi",
|
||||
telefono="+39 333 7654321",
|
||||
ruolo=RuoloUtente.OPERATORE,
|
||||
attivo=True,
|
||||
email_verificata=True,
|
||||
),
|
||||
Utente(
|
||||
cliente_id=cliente.id,
|
||||
email="viewer@azienda.it",
|
||||
password_hash=get_password_hash("viewer123"),
|
||||
nome="Anna",
|
||||
cognome="Verdi",
|
||||
telefono="+39 333 9876543",
|
||||
ruolo=RuoloUtente.VISUALIZZATORE,
|
||||
attivo=True,
|
||||
email_verificata=True,
|
||||
),
|
||||
]
|
||||
|
||||
for utente in utenti:
|
||||
db.add(utente)
|
||||
print(f"✓ Utente creato: {utente.email} ({utente.ruolo})")
|
||||
|
||||
# Commit finale
|
||||
db.commit()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("✓ Database inizializzato con successo!")
|
||||
print("=" * 60)
|
||||
print("\nCredenziali di accesso:")
|
||||
print("-" * 60)
|
||||
for utente in utenti:
|
||||
pwd = "admin123" if utente.ruolo == RuoloUtente.ADMIN else \
|
||||
"operatore123" if utente.ruolo == RuoloUtente.OPERATORE else "viewer123"
|
||||
print(f"Email: {utente.email:25} | Password: {pwd:15} | Ruolo: {utente.ruolo}")
|
||||
print("-" * 60)
|
||||
print(f"\nSiti monitorati creati: {len(siti)}")
|
||||
for i, sito in enumerate(siti, 1):
|
||||
print(f" {i}. {sito.nome} (ID: {sito.id}) - {sito.tipo}")
|
||||
print()
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n✗ Errore durante l'inizializzazione: {e}")
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=" * 60)
|
||||
print("INIZIALIZZAZIONE DATABASE - Terrain Monitor")
|
||||
print("=" * 60)
|
||||
init_database()
|
||||
121
scripts/test_mqtt_improved.py
Normal file
121
scripts/test_mqtt_improved.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
Script per testare l'invio di un allarme via MQTT con nuova struttura topic
|
||||
|
||||
Topic pattern: terrain/{cliente_id}/{sito_id}/alarms
|
||||
|
||||
Questo script usa la nuova architettura MQTT migliorata dove:
|
||||
- cliente_id e sito_id sono nel topic (non nel payload)
|
||||
- Supporto per diversi tipi di messaggi (alarms, telemetry, status)
|
||||
- QoS 1 per garantire delivery
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
# Configurazione (modifica secondo il tuo setup)
|
||||
MQTT_BROKER = "94.177.199.207"
|
||||
MQTT_PORT = 1883
|
||||
|
||||
# ID dal database (verifica con: SELECT id, cliente_id, nome FROM siti;)
|
||||
CLIENTE_ID = 1 # Cliente ID del sito
|
||||
SITO_ID = 1 # ID del sito "Ponte Morandi"
|
||||
|
||||
# Topic con nuova struttura: terrain/{cliente_id}/{sito_id}/alarms
|
||||
MQTT_TOPIC = f"terrain/{CLIENTE_ID}/{SITO_ID}/alarms"
|
||||
|
||||
# Allarme di test (sito_id e cliente_id vengono estratti dal topic)
|
||||
alarm_data = {
|
||||
"tipo": "movimento_terreno",
|
||||
"severita": "critical", # critical, warning, info
|
||||
"titolo": "🚨 TEST: Movimento terreno critico",
|
||||
"descrizione": "Allarme di test per verificare notifiche push - Sistema funzionante!",
|
||||
"messaggio": "Rilevato movimento anomalo superiore alla soglia di sicurezza",
|
||||
"valore_rilevato": 25.5,
|
||||
"valore_soglia": 10.0,
|
||||
"unita_misura": "mm",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"dati_sensori": {
|
||||
"sensore_inclinometro_1": 25.5,
|
||||
"sensore_inclinometro_2": 18.3,
|
||||
"temperatura": 15.5,
|
||||
"umidita": 45.0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def on_connect(client, userdata, flags, rc):
|
||||
"""Callback quando il client si connette"""
|
||||
if rc == 0:
|
||||
print("✓ Connesso al broker MQTT")
|
||||
else:
|
||||
print(f"✗ Connessione fallita con codice: {rc}")
|
||||
|
||||
|
||||
def on_publish(client, userdata, mid):
|
||||
"""Callback quando il messaggio viene pubblicato"""
|
||||
print(f"✓ Messaggio pubblicato (message_id: {mid})")
|
||||
|
||||
|
||||
def send_test_alarm():
|
||||
"""Invia un allarme di test"""
|
||||
print("=" * 70)
|
||||
print("TEST INVIO ALLARME VIA MQTT - NUOVA ARCHITETTURA")
|
||||
print("=" * 70)
|
||||
|
||||
# Crea client MQTT
|
||||
client = mqtt.Client(client_id="test_alarm_sender_v2")
|
||||
client.on_connect = on_connect
|
||||
client.on_publish = on_publish
|
||||
|
||||
try:
|
||||
# Connetti al broker
|
||||
print(f"\nConnessione a {MQTT_BROKER}:{MQTT_PORT}...")
|
||||
client.connect(MQTT_BROKER, MQTT_PORT, keepalive=60)
|
||||
|
||||
# Start loop
|
||||
client.loop_start()
|
||||
time.sleep(1) # Aspetta la connessione
|
||||
|
||||
# Pubblica l'allarme
|
||||
print(f"\nInvio allarme:")
|
||||
print(f" Topic: {MQTT_TOPIC}")
|
||||
print(f" Cliente ID: {CLIENTE_ID}")
|
||||
print(f" Sito ID: {SITO_ID}")
|
||||
print("\nPayload:")
|
||||
print(json.dumps(alarm_data, indent=2, ensure_ascii=False))
|
||||
print()
|
||||
|
||||
result = client.publish(
|
||||
MQTT_TOPIC,
|
||||
json.dumps(alarm_data),
|
||||
qos=1 # QoS 1 per garantire delivery
|
||||
)
|
||||
|
||||
# Aspetta che il messaggio sia pubblicato
|
||||
result.wait_for_publish()
|
||||
|
||||
print("\n✓ Allarme inviato con successo!")
|
||||
print("\nControlla:")
|
||||
print(" 1. I log del backend per vedere se è stato ricevuto")
|
||||
print(" 2. Il database per verificare che l'allarme sia stato salvato")
|
||||
print(" 3. L'app mobile per verificare la ricezione della notifica push")
|
||||
print("\nComandi utili:")
|
||||
print(" - Logs backend: tail -f logs/app.log")
|
||||
print(" - DB query: SELECT * FROM allarmi ORDER BY created_at DESC LIMIT 5;")
|
||||
|
||||
# Cleanup
|
||||
time.sleep(1)
|
||||
client.loop_stop()
|
||||
client.disconnect()
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n✗ Errore: {e}")
|
||||
client.loop_stop()
|
||||
raise
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
send_test_alarm()
|
||||
Reference in New Issue
Block a user