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