Initial commit
This commit is contained in:
30
.env.example
Normal file
30
.env.example
Normal file
@@ -0,0 +1,30 @@
|
||||
# MQTT Configuration
|
||||
MQTT_HOST=ikarus.egfh.internal
|
||||
MQTT_PORT=1883
|
||||
MQTT_USERNAME=
|
||||
MQTT_PASSWORD=
|
||||
MQTT_TOPIC=weather/+
|
||||
MQTT_CLIENT_ID=wxconnect
|
||||
|
||||
# Database Configuration
|
||||
DB_TYPE=oracle # oracle, mssql, postgresql, mysql
|
||||
DB_HOST=localhost
|
||||
DB_PORT=1521
|
||||
DB_NAME=xe
|
||||
DB_USERNAME=weather
|
||||
DB_PASSWORD=password
|
||||
DB_SCHEMA=weather
|
||||
|
||||
# Oracle specific (if using Oracle)
|
||||
ORACLE_SID=xe
|
||||
|
||||
# MS SQL specific (if using MS SQL)
|
||||
MSSQL_DRIVER=ODBC Driver 17 for SQL Server
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# Application Settings
|
||||
RECONNECT_INTERVAL=30
|
||||
BATCH_SIZE=10
|
||||
BATCH_TIMEOUT=60
|
||||
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.so
|
||||
.coverage
|
||||
.pytest_cache/
|
||||
.vscode/
|
||||
.idea/
|
||||
*.log
|
||||
logs/
|
||||
data/
|
||||
60
Dockerfile
Normal file
60
Dockerfile
Normal file
@@ -0,0 +1,60 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONPATH=/app/src
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
g++ \
|
||||
unixodbc-dev \
|
||||
curl \
|
||||
unzip \
|
||||
libaio-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Oracle Instant Client for cx_Oracle
|
||||
RUN mkdir -p /opt/oracle && \
|
||||
cd /opt/oracle && \
|
||||
curl -o instantclient-basic-linux.x64-21.9.0.0.0dbru.zip \
|
||||
https://download.oracle.com/otn_software/linux/instantclient/219000/instantclient-basic-linux.x64-21.9.0.0.0dbru.zip && \
|
||||
unzip instantclient-basic-linux.x64-21.9.0.0.0dbru.zip && \
|
||||
rm instantclient-basic-linux.x64-21.9.0.0.0dbru.zip && \
|
||||
cd instantclient_21_9 && \
|
||||
mkdir -p lib && \
|
||||
ln -s ../libclntsh.so.21.1 lib/libclntsh.so && \
|
||||
ln -s ../libnnz21.so lib/libnnz21.so && \
|
||||
ln -s ../libociicus.so lib/libociicus.so && \
|
||||
echo /opt/oracle/instantclient_21_9 > /etc/ld.so.conf.d/oracle-instantclient.conf && \
|
||||
ldconfig
|
||||
|
||||
# Set Oracle environment variables
|
||||
ENV ORACLE_HOME=/opt/oracle/instantclient_21_9
|
||||
ENV LD_LIBRARY_PATH=/opt/oracle/instantclient_21_9:$LD_LIBRARY_PATH
|
||||
ENV PATH=/opt/oracle/instantclient_21_9:$PATH
|
||||
|
||||
# Create app directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy requirements and install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY src/ ./src/
|
||||
|
||||
# Create logs directory
|
||||
RUN mkdir -p /app/logs
|
||||
|
||||
# Create non-root user
|
||||
RUN groupadd -r wxconnect && useradd -r -g wxconnect wxconnect
|
||||
RUN chown -R wxconnect:wxconnect /app
|
||||
USER wxconnect
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD python -c "import sys; sys.path.insert(0, '/app/src'); from wxconnect.config import Config; c = Config(); print('OK' if c.validate() else 'FAIL')" || exit 1
|
||||
|
||||
# Entry point
|
||||
CMD ["python", "-m", "wxconnect.main"]
|
||||
102
docker-compose.yml
Normal file
102
docker-compose.yml
Normal file
@@ -0,0 +1,102 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
wxconnect:
|
||||
build: .
|
||||
container_name: wxconnect
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# MQTT Configuration
|
||||
MQTT_HOST: ikarus.egfh.internal
|
||||
MQTT_PORT: 1883
|
||||
MQTT_TOPIC: weather/loop
|
||||
MQTT_CLIENT_ID: wxconnect-docker
|
||||
|
||||
# Database Configuration - Oracle on pve-ora19c-1
|
||||
DB_TYPE: oracle
|
||||
DB_HOST: pve-ora19c-1
|
||||
DB_PORT: 1521
|
||||
DB_NAME: shed.pattinson.org
|
||||
DB_USERNAME: C##WEATHER
|
||||
DB_PASSWORD: weather123
|
||||
DB_SCHEMA: C##WEATHER
|
||||
|
||||
# Oracle specific
|
||||
ORACLE_SID: shed.pattinson.org
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL: DEBUG
|
||||
|
||||
# Application Settings
|
||||
RECONNECT_INTERVAL: 30
|
||||
BATCH_SIZE: 1
|
||||
BATCH_TIMEOUT: 60
|
||||
|
||||
volumes:
|
||||
- ./logs:/app/logs
|
||||
- ./src:/app/src
|
||||
- ./.env:/app/.env:ro # Mount environment file if it exists
|
||||
|
||||
networks:
|
||||
- wxconnect-network
|
||||
|
||||
# Remove dependency on local Oracle container since using external Oracle
|
||||
# depends_on:
|
||||
# - oracle-db
|
||||
|
||||
# Example Oracle database service (commented out - using external Oracle)
|
||||
# oracle-db:
|
||||
# image: gvenzl/oracle-xe:21-slim
|
||||
# container_name: oracle-wxconnect
|
||||
# restart: unless-stopped
|
||||
# environment:
|
||||
# ORACLE_PASSWORD: password
|
||||
# APP_USER: weather
|
||||
# APP_USER_PASSWORD: password
|
||||
# ports:
|
||||
# - "1521:1521"
|
||||
# volumes:
|
||||
# - oracle_data:/opt/oracle/oradata
|
||||
# networks:
|
||||
# - wxconnect-network
|
||||
|
||||
# Example PostgreSQL database service (alternative to Oracle)
|
||||
# postgres-db:
|
||||
# image: postgres:15
|
||||
# container_name: postgres-wxconnect
|
||||
# restart: unless-stopped
|
||||
# environment:
|
||||
# POSTGRES_DB: weather
|
||||
# POSTGRES_USER: weather
|
||||
# POSTGRES_PASSWORD: password
|
||||
# ports:
|
||||
# - "5432:5432"
|
||||
# volumes:
|
||||
# - postgres_data:/var/lib/postgresql/data
|
||||
# networks:
|
||||
# - wxconnect-network
|
||||
|
||||
# Example MS SQL Server database service (alternative)
|
||||
# mssql-db:
|
||||
# image: mcr.microsoft.com/mssql/server:2022-latest
|
||||
# container_name: mssql-wxconnect
|
||||
# restart: unless-stopped
|
||||
# environment:
|
||||
# SA_PASSWORD: "YourStrong@Passw0rd"
|
||||
# ACCEPT_EULA: "Y"
|
||||
# MSSQL_DB: weather
|
||||
# ports:
|
||||
# - "1433:1433"
|
||||
# volumes:
|
||||
# - mssql_data:/var/opt/mssql
|
||||
# networks:
|
||||
# - wxconnect-network
|
||||
|
||||
volumes:
|
||||
oracle_data:
|
||||
# postgres_data:
|
||||
# mssql_data:
|
||||
|
||||
networks:
|
||||
wxconnect-network:
|
||||
driver: bridge
|
||||
45
oracle-setup/01_create_user_common.sql
Normal file
45
oracle-setup/01_create_user_common.sql
Normal file
@@ -0,0 +1,45 @@
|
||||
-- Oracle Common User Setup for Weather Connect
|
||||
-- Run this as SYSTEM or SYSDBA user
|
||||
-- This creates a common user that works across all containers
|
||||
|
||||
-- 1. Check current environment
|
||||
COLUMN is_cdb FORMAT A10
|
||||
SELECT DECODE(cdb, 'YES', 'CDB', 'Regular') as is_cdb FROM v$database;
|
||||
SHOW CON_NAME;
|
||||
|
||||
-- 2. Create common user (prefixed with C##)
|
||||
CREATE USER C##weather IDENTIFIED BY weather123
|
||||
DEFAULT TABLESPACE USERS
|
||||
TEMPORARY TABLESPACE TEMP
|
||||
QUOTA UNLIMITED ON USERS
|
||||
CONTAINER=ALL;
|
||||
|
||||
-- 3. Grant necessary privileges
|
||||
GRANT CONNECT TO C##weather CONTAINER=ALL;
|
||||
GRANT RESOURCE TO C##weather CONTAINER=ALL;
|
||||
GRANT CREATE SESSION TO C##weather CONTAINER=ALL;
|
||||
GRANT CREATE TABLE TO C##weather CONTAINER=ALL;
|
||||
GRANT CREATE SEQUENCE TO C##weather CONTAINER=ALL;
|
||||
GRANT CREATE VIEW TO C##weather CONTAINER=ALL;
|
||||
-- Note: CREATE INDEX privilege is included in RESOURCE role
|
||||
-- GRANT CREATE INDEX TO C##weather CONTAINER=ALL; -- This is redundant with RESOURCE
|
||||
|
||||
-- Optional: Grant additional privileges if needed
|
||||
-- GRANT CREATE PROCEDURE TO C##weather CONTAINER=ALL;
|
||||
-- GRANT CREATE TRIGGER TO C##weather CONTAINER=ALL;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- 4. Verify user creation
|
||||
SELECT username, common, default_tablespace, temporary_tablespace, account_status
|
||||
FROM dba_users
|
||||
WHERE username = 'C##WEATHER';
|
||||
|
||||
-- 5. Display granted privileges
|
||||
SELECT grantee, privilege
|
||||
FROM dba_sys_privs
|
||||
WHERE grantee = 'C##WEATHER'
|
||||
ORDER BY privilege;
|
||||
|
||||
-- 6. Test connection as common user
|
||||
-- CONNECT C##weather/weather123@pve-ora19c-1:1521/SHED;
|
||||
52
oracle-setup/01_create_user_pdb.sql
Normal file
52
oracle-setup/01_create_user_pdb.sql
Normal file
@@ -0,0 +1,52 @@
|
||||
-- Oracle CDB/PDB Setup for Weather Connect
|
||||
-- Run this as SYSTEM or SYSDBA user
|
||||
|
||||
-- 1. Check the current environment
|
||||
COLUMN is_cdb FORMAT A10
|
||||
COLUMN con_name FORMAT A20
|
||||
SELECT DECODE(cdb, 'YES', 'CDB', 'Regular') as is_cdb FROM v$database;
|
||||
SHOW CON_NAME;
|
||||
|
||||
-- 2. Show available PDBs (if in CDB)
|
||||
SELECT name, open_mode FROM v$pdbs;
|
||||
|
||||
-- 3. Connect to the SHED PDB (assuming SHED is your PDB name)
|
||||
-- If SHED is the PDB name, connect to it:
|
||||
ALTER SESSION SET CONTAINER = SHDPDB1;
|
||||
SHOW CON_NAME;
|
||||
|
||||
-- 4. Now create the regular user in the PDB
|
||||
CREATE USER weather IDENTIFIED BY weather123
|
||||
DEFAULT TABLESPACE USERS
|
||||
TEMPORARY TABLESPACE TEMP
|
||||
QUOTA UNLIMITED ON USERS;
|
||||
|
||||
-- 5. Grant necessary privileges
|
||||
GRANT CONNECT TO weather;
|
||||
GRANT RESOURCE TO weather;
|
||||
GRANT CREATE SESSION TO weather;
|
||||
GRANT CREATE TABLE TO weather;
|
||||
GRANT CREATE SEQUENCE TO weather;
|
||||
GRANT CREATE VIEW TO weather;
|
||||
-- Note: CREATE INDEX privilege is included in RESOURCE role
|
||||
-- GRANT CREATE INDEX TO weather; -- This is redundant with RESOURCE
|
||||
|
||||
-- Optional: Grant additional privileges if needed
|
||||
-- GRANT CREATE PROCEDURE TO weather;
|
||||
-- GRANT CREATE TRIGGER TO weather;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- 6. Verify user creation
|
||||
SELECT username, default_tablespace, temporary_tablespace, account_status
|
||||
FROM dba_users
|
||||
WHERE username = 'WEATHER';
|
||||
|
||||
-- 7. Display granted privileges
|
||||
SELECT grantee, privilege
|
||||
FROM dba_sys_privs
|
||||
WHERE grantee = 'WEATHER'
|
||||
ORDER BY privilege;
|
||||
|
||||
-- 8. Test connection as weather user
|
||||
-- CONNECT weather/weather123@pve-ora19c-1:1521/SHDPDB1;
|
||||
10
requirements.txt
Normal file
10
requirements.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
paho-mqtt==1.6.1
|
||||
oracledb==2.0.1
|
||||
pyodbc==4.0.39
|
||||
psycopg2-binary==2.9.7
|
||||
PyMySQL==1.1.0
|
||||
sqlalchemy==2.0.23
|
||||
python-dotenv==1.0.0
|
||||
pydantic==2.5.0
|
||||
PyYAML==6.0.1
|
||||
structlog==23.2.0
|
||||
5
src/wxconnect/__init__.py
Normal file
5
src/wxconnect/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Weather Connect - MQTT to Database Bridge"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__author__ = "Your Name"
|
||||
__description__ = "A Docker-based Python application that subscribes to MQTT weather data and inserts it into various databases."
|
||||
86
src/wxconnect/config.py
Normal file
86
src/wxconnect/config.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Configuration management for Weather Connect."""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
@dataclass
|
||||
class MQTTConfig:
|
||||
"""MQTT connection configuration."""
|
||||
host: str
|
||||
port: int
|
||||
username: Optional[str]
|
||||
password: Optional[str]
|
||||
topic: str
|
||||
client_id: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class DatabaseConfig:
|
||||
"""Database connection configuration."""
|
||||
db_type: str
|
||||
host: str
|
||||
port: int
|
||||
name: str
|
||||
username: str
|
||||
password: str
|
||||
schema: Optional[str] = None
|
||||
oracle_sid: Optional[str] = None
|
||||
mssql_driver: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppConfig:
|
||||
"""Application configuration."""
|
||||
log_level: str
|
||||
reconnect_interval: int
|
||||
batch_size: int
|
||||
batch_timeout: int
|
||||
|
||||
|
||||
class Config:
|
||||
"""Main configuration class."""
|
||||
|
||||
def __init__(self, env_file: str = ".env"):
|
||||
"""Initialize configuration from environment variables."""
|
||||
load_dotenv(env_file)
|
||||
|
||||
self.mqtt = MQTTConfig(
|
||||
host=os.getenv("MQTT_HOST", "localhost"),
|
||||
port=int(os.getenv("MQTT_PORT", "1883")),
|
||||
username=os.getenv("MQTT_USERNAME"),
|
||||
password=os.getenv("MQTT_PASSWORD"),
|
||||
topic=os.getenv("MQTT_TOPIC", "weather/+"),
|
||||
client_id=os.getenv("MQTT_CLIENT_ID", "wxconnect")
|
||||
)
|
||||
|
||||
self.database = DatabaseConfig(
|
||||
db_type=os.getenv("DB_TYPE", "oracle"),
|
||||
host=os.getenv("DB_HOST", "localhost"),
|
||||
port=int(os.getenv("DB_PORT", "1521")),
|
||||
name=os.getenv("DB_NAME", "xe"),
|
||||
username=os.getenv("DB_USERNAME", "weather"),
|
||||
password=os.getenv("DB_PASSWORD", "password"),
|
||||
schema=os.getenv("DB_SCHEMA"),
|
||||
oracle_sid=os.getenv("ORACLE_SID"),
|
||||
mssql_driver=os.getenv("MSSQL_DRIVER", "ODBC Driver 17 for SQL Server")
|
||||
)
|
||||
|
||||
self.app = AppConfig(
|
||||
log_level=os.getenv("LOG_LEVEL", "INFO"),
|
||||
reconnect_interval=int(os.getenv("RECONNECT_INTERVAL", "30")),
|
||||
batch_size=int(os.getenv("BATCH_SIZE", "10")),
|
||||
batch_timeout=int(os.getenv("BATCH_TIMEOUT", "60"))
|
||||
)
|
||||
|
||||
def validate(self) -> bool:
|
||||
"""Validate configuration."""
|
||||
if not self.mqtt.host:
|
||||
return False
|
||||
if not self.database.host:
|
||||
return False
|
||||
if self.database.db_type not in ["oracle", "mssql", "postgresql", "mysql"]:
|
||||
return False
|
||||
return True
|
||||
417
src/wxconnect/data_processor.py
Normal file
417
src/wxconnect/data_processor.py
Normal file
@@ -0,0 +1,417 @@
|
||||
"""Data processing logic for weather data."""
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
import structlog
|
||||
from .database.base import WeatherData
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class WeatherDataProcessor:
|
||||
"""Processes MQTT weather data and converts it to database format."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the weather data processor."""
|
||||
self.field_mappings = {
|
||||
# Common field name variations
|
||||
'temp': 'temperature',
|
||||
'temperature_c': 'temperature',
|
||||
'temperature_f': 'temperature',
|
||||
'temp_c': 'temperature',
|
||||
'temp_f': 'temperature',
|
||||
'outTemp_C': 'temperature',
|
||||
'humid': 'humidity',
|
||||
'humidity_pct': 'humidity',
|
||||
'rh': 'humidity',
|
||||
'press': 'pressure',
|
||||
'pressure_hpa': 'pressure',
|
||||
'pressure_mbar': 'pressure',
|
||||
'barometric_pressure': 'pressure',
|
||||
'wind_speed_ms': 'wind_speed',
|
||||
'wind_speed_kmh': 'wind_speed',
|
||||
'wind_speed_mph': 'wind_speed',
|
||||
'wind_speed_knot': 'wind_speed',
|
||||
'windSpeed_knot': 'wind_speed',
|
||||
'windspeed': 'wind_speed',
|
||||
'wind_dir': 'wind_direction',
|
||||
'wind_dir': 'wind_direction',
|
||||
'windDir': 'wind_direction',
|
||||
'wind_direction_deg': 'wind_direction',
|
||||
'winddirection': 'wind_direction',
|
||||
'rain': 'rainfall',
|
||||
'rainfall_mm': 'rainfall',
|
||||
'precipitation': 'rainfall',
|
||||
'precip': 'rainfall',
|
||||
'rain_cm': 'rainfall',
|
||||
'hourRain_cm': 'rainfall',
|
||||
'rain24_cm': 'rainfall',
|
||||
'dayRain_cm': 'rainfall',
|
||||
'rainRate_cm_per_hour': 'rainfall',
|
||||
'maxSolarRad_Wpm2': 'solar_radiation',
|
||||
# Note: heatingTemp_hertz, heatingVoltage_hertz, and hail_hertz are frequency readings, not temperature
|
||||
}
|
||||
|
||||
def process_message(self, topic: str, data: Any) -> Optional[WeatherData]:
|
||||
"""Process an MQTT message and convert to WeatherData.
|
||||
|
||||
Args:
|
||||
topic: MQTT topic
|
||||
data: Message payload (dict or string)
|
||||
|
||||
Returns:
|
||||
WeatherData object or None if processing fails
|
||||
"""
|
||||
try:
|
||||
# Convert data to dict if it's a string
|
||||
if isinstance(data, str):
|
||||
try:
|
||||
data = json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Failed to parse string data as JSON", topic=topic, data=data[:100])
|
||||
# Create minimal record with raw data
|
||||
return WeatherData(
|
||||
timestamp=datetime.now(),
|
||||
topic=topic,
|
||||
raw_data=data
|
||||
)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
logger.warning("Data is not a dictionary", topic=topic, data_type=type(data))
|
||||
return WeatherData(
|
||||
timestamp=datetime.now(),
|
||||
topic=topic,
|
||||
raw_data=str(data)
|
||||
)
|
||||
|
||||
# Extract timestamp
|
||||
timestamp = self._extract_timestamp(data)
|
||||
|
||||
# Extract location from topic or data
|
||||
location = self._extract_location(topic, data)
|
||||
|
||||
# Extract weather fields
|
||||
weather_fields = self._extract_weather_fields(data)
|
||||
|
||||
# Create WeatherData object
|
||||
weather_data = WeatherData(
|
||||
timestamp=timestamp,
|
||||
topic=topic,
|
||||
location=location,
|
||||
temperature=weather_fields.get('temperature'),
|
||||
humidity=weather_fields.get('humidity'),
|
||||
pressure=weather_fields.get('pressure'),
|
||||
wind_speed=weather_fields.get('wind_speed'),
|
||||
wind_direction=weather_fields.get('wind_direction'),
|
||||
rainfall=weather_fields.get('rainfall'),
|
||||
raw_data=json.dumps(data) if isinstance(data, dict) else str(data)
|
||||
)
|
||||
|
||||
logger.debug("Processed weather data", topic=topic, location=location, fields=len(weather_fields))
|
||||
return weather_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to process weather data", topic=topic, error=str(e))
|
||||
return None
|
||||
|
||||
def _extract_timestamp(self, data: Dict[str, Any]) -> datetime:
|
||||
"""Extract timestamp from data or use current time.
|
||||
|
||||
Args:
|
||||
data: Message data
|
||||
|
||||
Returns:
|
||||
Datetime object
|
||||
"""
|
||||
timestamp_fields = ['timestamp', 'time', 'datetime', 'ts', 'date_time']
|
||||
|
||||
for field in timestamp_fields:
|
||||
if field in data:
|
||||
try:
|
||||
value = data[field]
|
||||
if isinstance(value, str):
|
||||
# Try different timestamp formats
|
||||
formats = [
|
||||
'%Y-%m-%dT%H:%M:%S.%fZ',
|
||||
'%Y-%m-%dT%H:%M:%SZ',
|
||||
'%Y-%m-%d %H:%M:%S.%f',
|
||||
'%Y-%m-%d %H:%M:%S',
|
||||
'%Y/%m/%d %H:%M:%S',
|
||||
'%d/%m/%Y %H:%M:%S',
|
||||
]
|
||||
|
||||
for fmt in formats:
|
||||
try:
|
||||
return datetime.strptime(value, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
elif isinstance(value, (int, float)):
|
||||
# Unix timestamp
|
||||
return datetime.fromtimestamp(value)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug("Failed to parse timestamp field", field=field, value=value, error=str(e))
|
||||
continue
|
||||
|
||||
# Default to current time
|
||||
return datetime.now()
|
||||
|
||||
def _extract_location(self, topic: str, data: Dict[str, Any]) -> Optional[str]:
|
||||
"""Extract location from topic or data.
|
||||
|
||||
Args:
|
||||
topic: MQTT topic
|
||||
data: Message data
|
||||
|
||||
Returns:
|
||||
Location string or None
|
||||
"""
|
||||
# Check data fields first
|
||||
location_fields = ['location', 'station', 'site', 'sensor_id', 'device_id', 'name']
|
||||
|
||||
for field in location_fields:
|
||||
if field in data and data[field]:
|
||||
return str(data[field])
|
||||
|
||||
# Extract from topic (e.g., weather/garden/temp -> garden)
|
||||
topic_parts = topic.split('/')
|
||||
if len(topic_parts) >= 2:
|
||||
# Skip 'weather' and take next part as location
|
||||
for part in topic_parts[1:]:
|
||||
if part and part not in ['temp', 'humidity', 'pressure', 'wind', 'rain']:
|
||||
return part
|
||||
|
||||
return None
|
||||
|
||||
def _extract_weather_fields(self, data: Dict[str, Any]) -> Dict[str, Optional[float]]:
|
||||
"""Extract weather measurement fields from data.
|
||||
|
||||
Args:
|
||||
data: Message data
|
||||
|
||||
Returns:
|
||||
Dictionary of weather fields
|
||||
"""
|
||||
weather_fields = {
|
||||
'temperature': None,
|
||||
'humidity': None,
|
||||
'pressure': None,
|
||||
'wind_speed': None,
|
||||
'wind_direction': None,
|
||||
'rainfall': None,
|
||||
'solar_radiation': None
|
||||
}
|
||||
|
||||
# Direct field mapping
|
||||
for field_name, canonical_name in self.field_mappings.items():
|
||||
if field_name in data:
|
||||
value = self._convert_to_float(data[field_name])
|
||||
if value is not None:
|
||||
weather_fields[canonical_name] = value
|
||||
|
||||
# Check for exact matches
|
||||
for canonical_name in weather_fields.keys():
|
||||
if canonical_name in data:
|
||||
value = self._convert_to_float(data[canonical_name])
|
||||
if value is not None:
|
||||
weather_fields[canonical_name] = value
|
||||
|
||||
# Special handling for temperature units
|
||||
if weather_fields['temperature'] is not None:
|
||||
temp_unit = self._detect_temperature_unit(data)
|
||||
if temp_unit == 'F':
|
||||
# Convert Fahrenheit to Celsius
|
||||
weather_fields['temperature'] = (weather_fields['temperature'] - 32) * 5/9
|
||||
|
||||
# Special handling for pressure units
|
||||
if weather_fields['pressure'] is not None:
|
||||
pressure_unit = self._detect_pressure_unit(data)
|
||||
if pressure_unit == 'inHg':
|
||||
# Convert inches of mercury to hPa
|
||||
weather_fields['pressure'] = weather_fields['pressure'] * 33.8639
|
||||
elif pressure_unit == 'mmHg':
|
||||
# Convert mmHg to hPa
|
||||
weather_fields['pressure'] = weather_fields['pressure'] * 1.33322
|
||||
|
||||
# Special handling for wind speed units
|
||||
if weather_fields['wind_speed'] is not None:
|
||||
wind_unit = self._detect_wind_speed_unit(data)
|
||||
if wind_unit == 'knot':
|
||||
# Convert knots to m/s (1 knot = 0.514444 m/s)
|
||||
weather_fields['wind_speed'] = weather_fields['wind_speed'] * 0.514444
|
||||
elif wind_unit == 'mph':
|
||||
# Convert mph to m/s
|
||||
weather_fields['wind_speed'] = weather_fields['wind_speed'] * 0.44704
|
||||
elif wind_unit == 'kmh':
|
||||
# Convert km/h to m/s
|
||||
weather_fields['wind_speed'] = weather_fields['wind_speed'] * 0.277778
|
||||
|
||||
# Validate and clamp values to database constraints
|
||||
weather_fields = self._validate_field_ranges(weather_fields, data)
|
||||
|
||||
return weather_fields
|
||||
|
||||
def _convert_to_float(self, value: Any) -> Optional[float]:
|
||||
"""Convert value to float if possible.
|
||||
|
||||
Args:
|
||||
value: Value to convert
|
||||
|
||||
Returns:
|
||||
Float value or None
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
elif isinstance(value, str):
|
||||
# Remove common non-numeric suffixes
|
||||
cleaned = re.sub(r'[°%\s]*[CFcf°]?$', '', value.strip())
|
||||
cleaned = re.sub(r'[°%\s]*$', '', cleaned)
|
||||
return float(cleaned)
|
||||
else:
|
||||
return float(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
def _detect_temperature_unit(self, data: Dict[str, Any]) -> str:
|
||||
"""Detect temperature unit from data.
|
||||
|
||||
Args:
|
||||
data: Message data
|
||||
|
||||
Returns:
|
||||
'C' for Celsius, 'F' for Fahrenheit
|
||||
"""
|
||||
# Check for unit fields
|
||||
if 'temp_unit' in data:
|
||||
unit = str(data['temp_unit']).upper()
|
||||
if 'F' in unit:
|
||||
return 'F'
|
||||
|
||||
# Check field names for unit indicators
|
||||
for key in data.keys():
|
||||
key_lower = key.lower()
|
||||
if 'temp' in key_lower:
|
||||
if '_f' in key_lower or 'fahrenheit' in key_lower:
|
||||
return 'F'
|
||||
elif '_c' in key_lower or 'celsius' in key_lower:
|
||||
return 'C'
|
||||
|
||||
# Default to Celsius
|
||||
return 'C'
|
||||
|
||||
def _detect_pressure_unit(self, data: Dict[str, Any]) -> str:
|
||||
"""Detect pressure unit from data.
|
||||
|
||||
Args:
|
||||
data: Message data
|
||||
|
||||
Returns:
|
||||
'hPa', 'inHg', or 'mmHg'
|
||||
"""
|
||||
# Check for unit fields
|
||||
if 'pressure_unit' in data:
|
||||
unit = str(data['pressure_unit']).lower()
|
||||
if 'inhg' in unit or 'inch' in unit:
|
||||
return 'inHg'
|
||||
elif 'mmhg' in unit:
|
||||
return 'mmHg'
|
||||
|
||||
# Check field names for unit indicators
|
||||
for key in data.keys():
|
||||
key_lower = key.lower()
|
||||
if 'pressure' in key_lower or 'press' in key_lower:
|
||||
if 'inhg' in key_lower or 'inch' in key_lower:
|
||||
return 'inHg'
|
||||
elif 'mmhg' in key_lower:
|
||||
return 'mmHg'
|
||||
elif 'hpa' in key_lower or 'mbar' in key_lower:
|
||||
return 'hPa'
|
||||
|
||||
# Default to hPa
|
||||
return 'hPa'
|
||||
|
||||
def _detect_wind_speed_unit(self, data: Dict[str, Any]) -> str:
|
||||
"""Detect wind speed unit from data.
|
||||
|
||||
Args:
|
||||
data: Message data
|
||||
|
||||
Returns:
|
||||
'ms', 'kmh', 'mph', or 'knot'
|
||||
"""
|
||||
# Check for unit fields
|
||||
if 'wind_speed_unit' in data:
|
||||
unit = str(data['wind_speed_unit']).lower()
|
||||
if 'knot' in unit or 'kt' in unit:
|
||||
return 'knot'
|
||||
elif 'mph' in unit:
|
||||
return 'mph'
|
||||
elif 'kmh' in unit or 'km/h' in unit:
|
||||
return 'kmh'
|
||||
|
||||
# Check field names for unit indicators
|
||||
for key in data.keys():
|
||||
key_lower = key.lower()
|
||||
if 'wind' in key_lower and 'speed' in key_lower:
|
||||
if 'knot' in key_lower or '_kt' in key_lower:
|
||||
return 'knot'
|
||||
elif 'mph' in key_lower:
|
||||
return 'mph'
|
||||
elif 'kmh' in key_lower or 'km_h' in key_lower:
|
||||
return 'kmh'
|
||||
elif 'ms' in key_lower or 'm_s' in key_lower:
|
||||
return 'ms'
|
||||
|
||||
# Default to m/s
|
||||
return 'ms'
|
||||
|
||||
def _validate_field_ranges(self, fields: Dict[str, Optional[float]], raw_data: Dict[str, Any]) -> Dict[str, Optional[float]]:
|
||||
"""Validate and clamp field values to database constraints.
|
||||
|
||||
Args:
|
||||
fields: Weather fields dictionary
|
||||
raw_data: Original MQTT message data
|
||||
|
||||
Returns:
|
||||
Validated fields dictionary
|
||||
"""
|
||||
# Database column constraints (precision, scale)
|
||||
constraints = {
|
||||
'temperature': {'max': 999.99, 'min': -999.99},
|
||||
'humidity': {'max': 100.00, 'min': 0.00},
|
||||
'pressure': {'max': 9999.99, 'min': 0.00},
|
||||
'wind_speed': {'max': 999.99, 'min': 0.00},
|
||||
'wind_direction': {'max': 360.0, 'min': 0.0},
|
||||
'rainfall': {'max': 9999.99, 'min': 0.00}
|
||||
}
|
||||
|
||||
validated_fields = {}
|
||||
|
||||
for field_name, value in fields.items():
|
||||
if value is None:
|
||||
validated_fields[field_name] = None
|
||||
continue
|
||||
|
||||
constraint = constraints.get(field_name)
|
||||
if constraint:
|
||||
# Check if value is out of range
|
||||
if value > constraint['max'] or value < constraint['min']:
|
||||
logger.warning(f"Clamping {field_name} value {value} to range [{constraint['min']}, {constraint['max']}]")
|
||||
|
||||
if value > constraint['max']:
|
||||
validated_fields[field_name] = constraint['max']
|
||||
else:
|
||||
validated_fields[field_name] = constraint['min']
|
||||
else:
|
||||
validated_fields[field_name] = value
|
||||
else:
|
||||
validated_fields[field_name] = value
|
||||
|
||||
return validated_fields
|
||||
15
src/wxconnect/database/__init__.py
Normal file
15
src/wxconnect/database/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""Database abstraction layer for Weather Connect."""
|
||||
|
||||
from .base import DatabaseInterface
|
||||
from .oracle_db import OracleDatabase
|
||||
from .mssql_db import MSSQLDatabase
|
||||
from .postgresql_db import PostgreSQLDatabase
|
||||
from .mysql_db import MySQLDatabase
|
||||
|
||||
__all__ = [
|
||||
"DatabaseInterface",
|
||||
"OracleDatabase",
|
||||
"MSSQLDatabase",
|
||||
"PostgreSQLDatabase",
|
||||
"MySQLDatabase"
|
||||
]
|
||||
121
src/wxconnect/database/base.py
Normal file
121
src/wxconnect/database/base.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""Base database interface and common functionality."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, List, Any, Optional
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WeatherData:
|
||||
"""Weather data structure for database storage."""
|
||||
timestamp: datetime
|
||||
topic: str
|
||||
location: Optional[str] = None
|
||||
temperature: Optional[float] = None
|
||||
humidity: Optional[float] = None
|
||||
pressure: Optional[float] = None
|
||||
wind_speed: Optional[float] = None
|
||||
wind_direction: Optional[float] = None
|
||||
rainfall: Optional[float] = None
|
||||
raw_data: Optional[str] = None
|
||||
|
||||
|
||||
class DatabaseInterface(ABC):
|
||||
"""Abstract base class for database implementations."""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
"""Initialize database connection.
|
||||
|
||||
Args:
|
||||
config: Database configuration dictionary
|
||||
"""
|
||||
self.config = config
|
||||
self.connection = None
|
||||
self.schema = config.get('schema')
|
||||
|
||||
@abstractmethod
|
||||
def connect(self) -> bool:
|
||||
"""Establish database connection.
|
||||
|
||||
Returns:
|
||||
True if connection successful, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def disconnect(self):
|
||||
"""Close database connection."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def create_tables(self) -> bool:
|
||||
"""Create necessary database tables.
|
||||
|
||||
Returns:
|
||||
True if tables created successfully, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def insert_weather_data(self, data: WeatherData) -> bool:
|
||||
"""Insert weather data into the database.
|
||||
|
||||
Args:
|
||||
data: Weather data to insert
|
||||
|
||||
Returns:
|
||||
True if insertion successful, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def insert_weather_data_batch(self, data_list: List[WeatherData]) -> bool:
|
||||
"""Insert multiple weather data records into the database.
|
||||
|
||||
Args:
|
||||
data_list: List of weather data to insert
|
||||
|
||||
Returns:
|
||||
True if all insertions successful, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def is_connected(self) -> bool:
|
||||
"""Check if database connection is active.
|
||||
|
||||
Returns:
|
||||
True if connected, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_table_name(self, base_name: str) -> str:
|
||||
"""Get fully qualified table name with schema if specified.
|
||||
|
||||
Args:
|
||||
base_name: Base table name
|
||||
|
||||
Returns:
|
||||
Fully qualified table name
|
||||
"""
|
||||
if self.schema:
|
||||
return f"{self.schema}.{base_name}"
|
||||
return base_name
|
||||
|
||||
def log_error(self, operation: str, error: Exception):
|
||||
"""Log database errors consistently.
|
||||
|
||||
Args:
|
||||
operation: The operation that failed
|
||||
error: The exception that occurred
|
||||
"""
|
||||
logger.error(
|
||||
"Database operation failed",
|
||||
operation=operation,
|
||||
error=str(error),
|
||||
db_type=self.__class__.__name__
|
||||
)
|
||||
47
src/wxconnect/database/factory.py
Normal file
47
src/wxconnect/database/factory.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Database factory for creating database instances."""
|
||||
|
||||
from typing import Dict, Any
|
||||
from .base import DatabaseInterface
|
||||
from .oracle_db import OracleDatabase
|
||||
from .mssql_db import MSSQLDatabase
|
||||
from .postgresql_db import PostgreSQLDatabase
|
||||
from .mysql_db import MySQLDatabase
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class DatabaseFactory:
|
||||
"""Factory class for creating database instances."""
|
||||
|
||||
@staticmethod
|
||||
def create_database(db_type: str, config: Dict[str, Any]) -> DatabaseInterface:
|
||||
"""Create a database instance based on the type.
|
||||
|
||||
Args:
|
||||
db_type: Type of database (oracle, mssql, postgresql, mysql)
|
||||
config: Database configuration
|
||||
|
||||
Returns:
|
||||
Database instance
|
||||
|
||||
Raises:
|
||||
ValueError: If database type is not supported
|
||||
"""
|
||||
db_type = db_type.lower()
|
||||
|
||||
if db_type == "oracle":
|
||||
return OracleDatabase(config)
|
||||
elif db_type == "mssql":
|
||||
return MSSQLDatabase(config)
|
||||
elif db_type == "postgresql":
|
||||
return PostgreSQLDatabase(config)
|
||||
elif db_type == "mysql":
|
||||
return MySQLDatabase(config)
|
||||
else:
|
||||
raise ValueError(f"Unsupported database type: {db_type}")
|
||||
|
||||
@staticmethod
|
||||
def get_supported_databases() -> list:
|
||||
"""Get list of supported database types."""
|
||||
return ["oracle", "mssql", "postgresql", "mysql"]
|
||||
202
src/wxconnect/database/mssql_db.py
Normal file
202
src/wxconnect/database/mssql_db.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""Microsoft SQL Server database implementation."""
|
||||
|
||||
import pyodbc
|
||||
from typing import Dict, List, Any
|
||||
import structlog
|
||||
from .base import DatabaseInterface, WeatherData
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class MSSQLDatabase(DatabaseInterface):
|
||||
"""Microsoft SQL Server database implementation."""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
"""Initialize MS SQL database connection."""
|
||||
super().__init__(config)
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""Establish MS SQL database connection."""
|
||||
try:
|
||||
driver = self.config.get('mssql_driver', 'ODBC Driver 17 for SQL Server')
|
||||
|
||||
connection_string = (
|
||||
f"DRIVER={{{driver}}};"
|
||||
f"SERVER={self.config['host']},{self.config['port']};"
|
||||
f"DATABASE={self.config['name']};"
|
||||
f"UID={self.config['username']};"
|
||||
f"PWD={self.config['password']};"
|
||||
"TrustServerCertificate=yes;"
|
||||
)
|
||||
|
||||
logger.info("Connecting to MS SQL database", host=self.config['host'], port=self.config['port'])
|
||||
|
||||
self.connection = pyodbc.connect(connection_string)
|
||||
self.connection.autocommit = False
|
||||
|
||||
# Test the connection
|
||||
cursor = self.connection.cursor()
|
||||
cursor.execute("SELECT 1")
|
||||
cursor.close()
|
||||
|
||||
logger.info("Successfully connected to MS SQL database")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.log_error("connect", e)
|
||||
return False
|
||||
|
||||
def disconnect(self):
|
||||
"""Close MS SQL database connection."""
|
||||
if self.connection:
|
||||
try:
|
||||
self.connection.close()
|
||||
logger.info("Disconnected from MS SQL database")
|
||||
except Exception as e:
|
||||
self.log_error("disconnect", e)
|
||||
finally:
|
||||
self.connection = None
|
||||
|
||||
def create_tables(self) -> bool:
|
||||
"""Create MS SQL database tables."""
|
||||
if not self.is_connected():
|
||||
return False
|
||||
|
||||
try:
|
||||
cursor = self.connection.cursor()
|
||||
|
||||
# Create weather_data table
|
||||
table_name = self.get_table_name("weather_data")
|
||||
create_sql = f"""
|
||||
IF NOT EXISTS (SELECT * FROM sysobjects WHERE name='{table_name.split('.')[-1]}' AND xtype='U')
|
||||
CREATE TABLE {table_name} (
|
||||
id BIGINT IDENTITY(1,1) PRIMARY KEY,
|
||||
timestamp DATETIME2(6) NOT NULL,
|
||||
topic NVARCHAR(255) NOT NULL,
|
||||
location NVARCHAR(100),
|
||||
temperature DECIMAL(5,2),
|
||||
humidity DECIMAL(5,2),
|
||||
pressure DECIMAL(7,2),
|
||||
wind_speed DECIMAL(5,2),
|
||||
wind_direction DECIMAL(5,2),
|
||||
rainfall DECIMAL(6,2),
|
||||
raw_data NVARCHAR(MAX),
|
||||
created_at DATETIME2(6) DEFAULT GETDATE()
|
||||
)
|
||||
"""
|
||||
|
||||
cursor.execute(create_sql)
|
||||
|
||||
# Create index on timestamp and topic
|
||||
index_name = f"idx_{table_name.replace('.', '_')}_ts_topic"
|
||||
index_sql = f"""
|
||||
IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = '{index_name}')
|
||||
CREATE INDEX {index_name} ON {table_name} (timestamp, topic)
|
||||
"""
|
||||
cursor.execute(index_sql)
|
||||
|
||||
self.connection.commit()
|
||||
cursor.close()
|
||||
|
||||
logger.info("Successfully created MS SQL tables", table=table_name)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.log_error("create_tables", e)
|
||||
return False
|
||||
|
||||
def insert_weather_data(self, data: WeatherData) -> bool:
|
||||
"""Insert weather data into MS SQL database."""
|
||||
if not self.is_connected():
|
||||
return False
|
||||
|
||||
try:
|
||||
cursor = self.connection.cursor()
|
||||
|
||||
table_name = self.get_table_name("weather_data")
|
||||
insert_sql = f"""
|
||||
INSERT INTO {table_name}
|
||||
(timestamp, topic, location, temperature, humidity, pressure,
|
||||
wind_speed, wind_direction, rainfall, raw_data)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"""
|
||||
|
||||
cursor.execute(insert_sql, (
|
||||
data.timestamp,
|
||||
data.topic,
|
||||
data.location,
|
||||
data.temperature,
|
||||
data.humidity,
|
||||
data.pressure,
|
||||
data.wind_speed,
|
||||
data.wind_direction,
|
||||
data.rainfall,
|
||||
data.raw_data
|
||||
))
|
||||
|
||||
self.connection.commit()
|
||||
cursor.close()
|
||||
|
||||
logger.debug("Successfully inserted weather data into MS SQL", topic=data.topic)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.log_error("insert_weather_data", e)
|
||||
return False
|
||||
|
||||
def insert_weather_data_batch(self, data_list: List[WeatherData]) -> bool:
|
||||
"""Insert multiple weather data records into MS SQL database."""
|
||||
if not self.is_connected() or not data_list:
|
||||
return False
|
||||
|
||||
try:
|
||||
cursor = self.connection.cursor()
|
||||
|
||||
table_name = self.get_table_name("weather_data")
|
||||
insert_sql = f"""
|
||||
INSERT INTO {table_name}
|
||||
(timestamp, topic, location, temperature, humidity, pressure,
|
||||
wind_speed, wind_direction, rainfall, raw_data)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"""
|
||||
|
||||
# Prepare data for batch insert
|
||||
batch_data = [
|
||||
(
|
||||
data.timestamp,
|
||||
data.topic,
|
||||
data.location,
|
||||
data.temperature,
|
||||
data.humidity,
|
||||
data.pressure,
|
||||
data.wind_speed,
|
||||
data.wind_direction,
|
||||
data.rainfall,
|
||||
data.raw_data
|
||||
)
|
||||
for data in data_list
|
||||
]
|
||||
|
||||
cursor.executemany(insert_sql, batch_data)
|
||||
self.connection.commit()
|
||||
cursor.close()
|
||||
|
||||
logger.info("Successfully inserted weather data batch into MS SQL", count=len(data_list))
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.log_error("insert_weather_data_batch", e)
|
||||
return False
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
"""Check if MS SQL database connection is active."""
|
||||
if not self.connection:
|
||||
return False
|
||||
|
||||
try:
|
||||
cursor = self.connection.cursor()
|
||||
cursor.execute("SELECT 1")
|
||||
cursor.close()
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
189
src/wxconnect/database/mysql_db.py
Normal file
189
src/wxconnect/database/mysql_db.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""MySQL database implementation."""
|
||||
|
||||
import pymysql
|
||||
from typing import Dict, List, Any
|
||||
import structlog
|
||||
from .base import DatabaseInterface, WeatherData
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class MySQLDatabase(DatabaseInterface):
|
||||
"""MySQL database implementation."""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
"""Initialize MySQL database connection."""
|
||||
super().__init__(config)
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""Establish MySQL database connection."""
|
||||
try:
|
||||
logger.info("Connecting to MySQL database", host=self.config['host'], port=self.config['port'])
|
||||
|
||||
self.connection = pymysql.connect(
|
||||
host=self.config['host'],
|
||||
port=self.config['port'],
|
||||
database=self.config['name'],
|
||||
user=self.config['username'],
|
||||
password=self.config['password'],
|
||||
charset='utf8mb4',
|
||||
autocommit=False
|
||||
)
|
||||
|
||||
# Test the connection
|
||||
cursor = self.connection.cursor()
|
||||
cursor.execute("SELECT 1")
|
||||
cursor.close()
|
||||
|
||||
logger.info("Successfully connected to MySQL database")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.log_error("connect", e)
|
||||
return False
|
||||
|
||||
def disconnect(self):
|
||||
"""Close MySQL database connection."""
|
||||
if self.connection:
|
||||
try:
|
||||
self.connection.close()
|
||||
logger.info("Disconnected from MySQL database")
|
||||
except Exception as e:
|
||||
self.log_error("disconnect", e)
|
||||
finally:
|
||||
self.connection = None
|
||||
|
||||
def create_tables(self) -> bool:
|
||||
"""Create MySQL database tables."""
|
||||
if not self.is_connected():
|
||||
return False
|
||||
|
||||
try:
|
||||
cursor = self.connection.cursor()
|
||||
|
||||
# Create weather_data table
|
||||
table_name = self.get_table_name("weather_data")
|
||||
create_sql = f"""
|
||||
CREATE TABLE IF NOT EXISTS {table_name} (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
timestamp DATETIME(6) NOT NULL,
|
||||
topic VARCHAR(255) NOT NULL,
|
||||
location VARCHAR(100),
|
||||
temperature DECIMAL(5,2),
|
||||
humidity DECIMAL(5,2),
|
||||
pressure DECIMAL(7,2),
|
||||
wind_speed DECIMAL(5,2),
|
||||
wind_direction DECIMAL(5,2),
|
||||
rainfall DECIMAL(6,2),
|
||||
raw_data TEXT,
|
||||
created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
|
||||
INDEX idx_timestamp_topic (timestamp, topic)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
"""
|
||||
|
||||
cursor.execute(create_sql)
|
||||
self.connection.commit()
|
||||
cursor.close()
|
||||
|
||||
logger.info("Successfully created MySQL tables", table=table_name)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.log_error("create_tables", e)
|
||||
return False
|
||||
|
||||
def insert_weather_data(self, data: WeatherData) -> bool:
|
||||
"""Insert weather data into MySQL database."""
|
||||
if not self.is_connected():
|
||||
return False
|
||||
|
||||
try:
|
||||
cursor = self.connection.cursor()
|
||||
|
||||
table_name = self.get_table_name("weather_data")
|
||||
insert_sql = f"""
|
||||
INSERT INTO {table_name}
|
||||
(timestamp, topic, location, temperature, humidity, pressure,
|
||||
wind_speed, wind_direction, rainfall, raw_data)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
"""
|
||||
|
||||
cursor.execute(insert_sql, (
|
||||
data.timestamp,
|
||||
data.topic,
|
||||
data.location,
|
||||
data.temperature,
|
||||
data.humidity,
|
||||
data.pressure,
|
||||
data.wind_speed,
|
||||
data.wind_direction,
|
||||
data.rainfall,
|
||||
data.raw_data
|
||||
))
|
||||
|
||||
self.connection.commit()
|
||||
cursor.close()
|
||||
|
||||
logger.debug("Successfully inserted weather data into MySQL", topic=data.topic)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.log_error("insert_weather_data", e)
|
||||
return False
|
||||
|
||||
def insert_weather_data_batch(self, data_list: List[WeatherData]) -> bool:
|
||||
"""Insert multiple weather data records into MySQL database."""
|
||||
if not self.is_connected() or not data_list:
|
||||
return False
|
||||
|
||||
try:
|
||||
cursor = self.connection.cursor()
|
||||
|
||||
table_name = self.get_table_name("weather_data")
|
||||
insert_sql = f"""
|
||||
INSERT INTO {table_name}
|
||||
(timestamp, topic, location, temperature, humidity, pressure,
|
||||
wind_speed, wind_direction, rainfall, raw_data)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
"""
|
||||
|
||||
# Prepare data for batch insert
|
||||
batch_data = [
|
||||
(
|
||||
data.timestamp,
|
||||
data.topic,
|
||||
data.location,
|
||||
data.temperature,
|
||||
data.humidity,
|
||||
data.pressure,
|
||||
data.wind_speed,
|
||||
data.wind_direction,
|
||||
data.rainfall,
|
||||
data.raw_data
|
||||
)
|
||||
for data in data_list
|
||||
]
|
||||
|
||||
cursor.executemany(insert_sql, batch_data)
|
||||
self.connection.commit()
|
||||
cursor.close()
|
||||
|
||||
logger.info("Successfully inserted weather data batch into MySQL", count=len(data_list))
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.log_error("insert_weather_data_batch", e)
|
||||
return False
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
"""Check if MySQL database connection is active."""
|
||||
if not self.connection:
|
||||
return False
|
||||
|
||||
try:
|
||||
cursor = self.connection.cursor()
|
||||
cursor.execute("SELECT 1")
|
||||
cursor.close()
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
252
src/wxconnect/database/oracle_db.py
Normal file
252
src/wxconnect/database/oracle_db.py
Normal file
@@ -0,0 +1,252 @@
|
||||
"""Oracle database implementation."""
|
||||
|
||||
import oracledb
|
||||
from typing import Dict, List, Any
|
||||
import structlog
|
||||
from .base import DatabaseInterface, WeatherData
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class OracleDatabase(DatabaseInterface):
|
||||
"""Oracle database implementation."""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
"""Initialize Oracle database connection."""
|
||||
super().__init__(config)
|
||||
self.dsn = None
|
||||
|
||||
def get_table_name(self, base_name: str) -> str:
|
||||
"""Get table name for Oracle (no schema prefix needed when connected as owner)."""
|
||||
return base_name
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""Establish Oracle database connection."""
|
||||
try:
|
||||
# Use the working connection string we validated
|
||||
connect_string = f"{self.config['host']}:{self.config['port']}/{self.config['name']}"
|
||||
|
||||
logger.info("Attempting Oracle database connection",
|
||||
host=self.config['host'],
|
||||
port=self.config['port'])
|
||||
|
||||
logger.info("Connecting to Oracle", connect_string=connect_string)
|
||||
|
||||
self.connection = oracledb.connect(
|
||||
user=self.config['username'],
|
||||
password=self.config['password'],
|
||||
dsn=connect_string
|
||||
)
|
||||
|
||||
# Test the connection
|
||||
cursor = self.connection.cursor()
|
||||
cursor.execute("SELECT 1 FROM DUAL")
|
||||
result = cursor.fetchone()
|
||||
cursor.close()
|
||||
|
||||
logger.info("Successfully connected to Oracle database")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.log_error("connect", e)
|
||||
return False
|
||||
|
||||
def disconnect(self):
|
||||
"""Close Oracle database connection."""
|
||||
if self.connection:
|
||||
try:
|
||||
self.connection.close()
|
||||
logger.info("Disconnected from Oracle database")
|
||||
except Exception as e:
|
||||
self.log_error("disconnect", e)
|
||||
finally:
|
||||
self.connection = None
|
||||
|
||||
def create_tables(self) -> bool:
|
||||
"""Create Oracle database tables."""
|
||||
if not self.is_connected():
|
||||
return False
|
||||
|
||||
try:
|
||||
cursor = self.connection.cursor()
|
||||
|
||||
# Create sequence for weather_data id
|
||||
seq_sql = "CREATE SEQUENCE weather_data_seq START WITH 1 INCREMENT BY 1"
|
||||
cursor.execute(seq_sql)
|
||||
|
||||
# Create weather_data table
|
||||
create_sql = """
|
||||
CREATE TABLE weather_data (
|
||||
id NUMBER PRIMARY KEY,
|
||||
timestamp TIMESTAMP(6) NOT NULL,
|
||||
topic VARCHAR2(255) NOT NULL,
|
||||
location VARCHAR2(100),
|
||||
temperature NUMBER(5,2),
|
||||
humidity NUMBER(5,2),
|
||||
pressure NUMBER(7,2),
|
||||
wind_speed NUMBER(5,2),
|
||||
wind_direction NUMBER(5,2),
|
||||
rainfall NUMBER(6,2),
|
||||
created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
|
||||
cursor.execute(create_sql)
|
||||
|
||||
# Create raw_data table
|
||||
raw_create_sql = """
|
||||
CREATE TABLE raw_data (
|
||||
weather_data_id NUMBER PRIMARY KEY,
|
||||
raw_data CLOB,
|
||||
CONSTRAINT fk_raw_data_weather_data
|
||||
FOREIGN KEY (weather_data_id) REFERENCES weather_data (id) ON DELETE CASCADE,
|
||||
CONSTRAINT chk_raw_data_json CHECK (raw_data IS JSON)
|
||||
)
|
||||
"""
|
||||
|
||||
cursor.execute(raw_create_sql)
|
||||
|
||||
# Create index on timestamp and topic
|
||||
index_sql = "CREATE INDEX idx_weather_data_ts_topic ON weather_data (timestamp, topic)"
|
||||
cursor.execute(index_sql)
|
||||
|
||||
self.connection.commit()
|
||||
cursor.close()
|
||||
|
||||
logger.info("Successfully created Oracle tables")
|
||||
return True
|
||||
|
||||
except oracledb.DatabaseError as e:
|
||||
error, = e.args
|
||||
if error.code == 955: # Table/sequence already exists
|
||||
logger.info("Oracle tables already exist")
|
||||
return True
|
||||
else:
|
||||
self.log_error("create_tables", e)
|
||||
return False
|
||||
except Exception as e:
|
||||
self.log_error("create_tables", e)
|
||||
return False
|
||||
|
||||
def insert_weather_data(self, data: WeatherData) -> bool:
|
||||
"""Insert weather data into Oracle database."""
|
||||
if not self.is_connected():
|
||||
return False
|
||||
|
||||
try:
|
||||
cursor = self.connection.cursor()
|
||||
|
||||
table_name = "weather_data"
|
||||
seq_name = "weather_data_seq"
|
||||
raw_table_name = "raw_data"
|
||||
|
||||
# Insert into weather_data first
|
||||
insert_sql = f"""
|
||||
INSERT INTO {table_name}
|
||||
(id, timestamp, topic, location, temperature, humidity, pressure,
|
||||
wind_speed, wind_direction, rainfall)
|
||||
VALUES ({seq_name}.NEXTVAL, :timestamp, :topic, :location, :temperature,
|
||||
:humidity, :pressure, :wind_speed, :wind_direction, :rainfall)
|
||||
"""
|
||||
|
||||
cursor.execute(insert_sql, {
|
||||
'timestamp': data.timestamp,
|
||||
'topic': data.topic,
|
||||
'location': data.location,
|
||||
'temperature': data.temperature,
|
||||
'humidity': data.humidity,
|
||||
'pressure': data.pressure,
|
||||
'wind_speed': data.wind_speed,
|
||||
'wind_direction': data.wind_direction,
|
||||
'rainfall': data.rainfall
|
||||
})
|
||||
|
||||
# Get the inserted ID
|
||||
cursor.execute(f"SELECT {seq_name}.CURRVAL FROM DUAL")
|
||||
weather_id = cursor.fetchone()[0]
|
||||
|
||||
# Insert into raw_data if raw_data is provided
|
||||
if data.raw_data is not None:
|
||||
raw_insert_sql = f"""
|
||||
INSERT INTO {raw_table_name} (weather_data_id, raw_data)
|
||||
VALUES (:1, :2)
|
||||
"""
|
||||
cursor.execute(raw_insert_sql, (weather_id, data.raw_data))
|
||||
|
||||
self.connection.commit()
|
||||
cursor.close()
|
||||
|
||||
logger.debug("Successfully inserted weather data into Oracle", topic=data.topic)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.log_error("insert_weather_data", e)
|
||||
return False
|
||||
|
||||
def insert_weather_data_batch(self, data_list: List[WeatherData]) -> bool:
|
||||
"""Insert multiple weather data records into Oracle database."""
|
||||
if not self.is_connected() or not data_list:
|
||||
return False
|
||||
|
||||
try:
|
||||
cursor = self.connection.cursor()
|
||||
|
||||
table_name = "weather_data"
|
||||
seq_name = "weather_data_seq"
|
||||
raw_table_name = "raw_data"
|
||||
|
||||
weather_insert_sql = f"""
|
||||
INSERT INTO {table_name}
|
||||
(id, timestamp, topic, location, temperature, humidity, pressure,
|
||||
wind_speed, wind_direction, rainfall)
|
||||
VALUES ({seq_name}.NEXTVAL, :timestamp, :topic, :location, :temperature,
|
||||
:humidity, :pressure, :wind_speed, :wind_direction, :rainfall)
|
||||
"""
|
||||
|
||||
raw_insert_sql = f"""
|
||||
INSERT INTO {raw_table_name} (weather_data_id, raw_data)
|
||||
VALUES (:id, :raw_data)
|
||||
"""
|
||||
|
||||
for data in data_list:
|
||||
cursor.execute(weather_insert_sql, {
|
||||
'timestamp': data.timestamp,
|
||||
'topic': data.topic,
|
||||
'location': data.location,
|
||||
'temperature': data.temperature,
|
||||
'humidity': data.humidity,
|
||||
'pressure': data.pressure,
|
||||
'wind_speed': data.wind_speed,
|
||||
'wind_direction': data.wind_direction,
|
||||
'rainfall': data.rainfall
|
||||
})
|
||||
|
||||
# Get the inserted ID
|
||||
cursor.execute(f"SELECT {seq_name}.CURRVAL FROM DUAL")
|
||||
weather_id = cursor.fetchone()[0]
|
||||
|
||||
if data.raw_data is not None:
|
||||
cursor.execute(raw_insert_sql, {'id': weather_id, 'raw_data': data.raw_data})
|
||||
|
||||
self.connection.commit()
|
||||
cursor.close()
|
||||
|
||||
logger.info("Successfully inserted weather data batch into Oracle", count=len(data_list))
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.log_error("insert_weather_data_batch", e)
|
||||
return False
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
"""Check if Oracle database connection is active."""
|
||||
if not self.connection:
|
||||
return False
|
||||
|
||||
try:
|
||||
cursor = self.connection.cursor()
|
||||
cursor.execute("SELECT 1 FROM DUAL")
|
||||
cursor.close()
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
194
src/wxconnect/database/postgresql_db.py
Normal file
194
src/wxconnect/database/postgresql_db.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""PostgreSQL database implementation."""
|
||||
|
||||
import psycopg2
|
||||
from typing import Dict, List, Any
|
||||
import structlog
|
||||
from .base import DatabaseInterface, WeatherData
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class PostgreSQLDatabase(DatabaseInterface):
|
||||
"""PostgreSQL database implementation."""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
"""Initialize PostgreSQL database connection."""
|
||||
super().__init__(config)
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""Establish PostgreSQL database connection."""
|
||||
try:
|
||||
logger.info("Connecting to PostgreSQL database", host=self.config['host'], port=self.config['port'])
|
||||
|
||||
self.connection = psycopg2.connect(
|
||||
host=self.config['host'],
|
||||
port=self.config['port'],
|
||||
database=self.config['name'],
|
||||
user=self.config['username'],
|
||||
password=self.config['password']
|
||||
)
|
||||
|
||||
self.connection.autocommit = False
|
||||
|
||||
# Test the connection
|
||||
cursor = self.connection.cursor()
|
||||
cursor.execute("SELECT 1")
|
||||
cursor.close()
|
||||
|
||||
logger.info("Successfully connected to PostgreSQL database")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.log_error("connect", e)
|
||||
return False
|
||||
|
||||
def disconnect(self):
|
||||
"""Close PostgreSQL database connection."""
|
||||
if self.connection:
|
||||
try:
|
||||
self.connection.close()
|
||||
logger.info("Disconnected from PostgreSQL database")
|
||||
except Exception as e:
|
||||
self.log_error("disconnect", e)
|
||||
finally:
|
||||
self.connection = None
|
||||
|
||||
def create_tables(self) -> bool:
|
||||
"""Create PostgreSQL database tables."""
|
||||
if not self.is_connected():
|
||||
return False
|
||||
|
||||
try:
|
||||
cursor = self.connection.cursor()
|
||||
|
||||
# Create weather_data table
|
||||
table_name = self.get_table_name("weather_data")
|
||||
create_sql = f"""
|
||||
CREATE TABLE IF NOT EXISTS {table_name} (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
timestamp TIMESTAMP(6) NOT NULL,
|
||||
topic VARCHAR(255) NOT NULL,
|
||||
location VARCHAR(100),
|
||||
temperature DECIMAL(5,2),
|
||||
humidity DECIMAL(5,2),
|
||||
pressure DECIMAL(7,2),
|
||||
wind_speed DECIMAL(5,2),
|
||||
wind_direction DECIMAL(5,2),
|
||||
rainfall DECIMAL(6,2),
|
||||
raw_data TEXT,
|
||||
created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
|
||||
cursor.execute(create_sql)
|
||||
|
||||
# Create index on timestamp and topic
|
||||
index_name = f"idx_{table_name.replace('.', '_')}_ts_topic"
|
||||
index_sql = f"CREATE INDEX IF NOT EXISTS {index_name} ON {table_name} (timestamp, topic)"
|
||||
cursor.execute(index_sql)
|
||||
|
||||
self.connection.commit()
|
||||
cursor.close()
|
||||
|
||||
logger.info("Successfully created PostgreSQL tables", table=table_name)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.log_error("create_tables", e)
|
||||
return False
|
||||
|
||||
def insert_weather_data(self, data: WeatherData) -> bool:
|
||||
"""Insert weather data into PostgreSQL database."""
|
||||
if not self.is_connected():
|
||||
return False
|
||||
|
||||
try:
|
||||
cursor = self.connection.cursor()
|
||||
|
||||
table_name = self.get_table_name("weather_data")
|
||||
insert_sql = f"""
|
||||
INSERT INTO {table_name}
|
||||
(timestamp, topic, location, temperature, humidity, pressure,
|
||||
wind_speed, wind_direction, rainfall, raw_data)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
"""
|
||||
|
||||
cursor.execute(insert_sql, (
|
||||
data.timestamp,
|
||||
data.topic,
|
||||
data.location,
|
||||
data.temperature,
|
||||
data.humidity,
|
||||
data.pressure,
|
||||
data.wind_speed,
|
||||
data.wind_direction,
|
||||
data.rainfall,
|
||||
data.raw_data
|
||||
))
|
||||
|
||||
self.connection.commit()
|
||||
cursor.close()
|
||||
|
||||
logger.debug("Successfully inserted weather data into PostgreSQL", topic=data.topic)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.log_error("insert_weather_data", e)
|
||||
return False
|
||||
|
||||
def insert_weather_data_batch(self, data_list: List[WeatherData]) -> bool:
|
||||
"""Insert multiple weather data records into PostgreSQL database."""
|
||||
if not self.is_connected() or not data_list:
|
||||
return False
|
||||
|
||||
try:
|
||||
cursor = self.connection.cursor()
|
||||
|
||||
table_name = self.get_table_name("weather_data")
|
||||
insert_sql = f"""
|
||||
INSERT INTO {table_name}
|
||||
(timestamp, topic, location, temperature, humidity, pressure,
|
||||
wind_speed, wind_direction, rainfall, raw_data)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
"""
|
||||
|
||||
# Prepare data for batch insert
|
||||
batch_data = [
|
||||
(
|
||||
data.timestamp,
|
||||
data.topic,
|
||||
data.location,
|
||||
data.temperature,
|
||||
data.humidity,
|
||||
data.pressure,
|
||||
data.wind_speed,
|
||||
data.wind_direction,
|
||||
data.rainfall,
|
||||
data.raw_data
|
||||
)
|
||||
for data in data_list
|
||||
]
|
||||
|
||||
cursor.executemany(insert_sql, batch_data)
|
||||
self.connection.commit()
|
||||
cursor.close()
|
||||
|
||||
logger.info("Successfully inserted weather data batch into PostgreSQL", count=len(data_list))
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.log_error("insert_weather_data_batch", e)
|
||||
return False
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
"""Check if PostgreSQL database connection is active."""
|
||||
if not self.connection:
|
||||
return False
|
||||
|
||||
try:
|
||||
cursor = self.connection.cursor()
|
||||
cursor.execute("SELECT 1")
|
||||
cursor.close()
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
286
src/wxconnect/main.py
Normal file
286
src/wxconnect/main.py
Normal file
@@ -0,0 +1,286 @@
|
||||
"""Main application entry point."""
|
||||
|
||||
import sys
|
||||
import signal
|
||||
import time
|
||||
from queue import Queue, Empty
|
||||
from threading import Thread, Event
|
||||
import structlog
|
||||
from .config import Config
|
||||
from .mqtt_client import MQTTClient
|
||||
from .data_processor import WeatherDataProcessor
|
||||
from .database.factory import DatabaseFactory
|
||||
|
||||
# Configure structured logging
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.stdlib.filter_by_level,
|
||||
structlog.stdlib.add_logger_name,
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
structlog.processors.format_exc_info,
|
||||
structlog.processors.JSONRenderer()
|
||||
],
|
||||
context_class=dict,
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class WeatherConnectApp:
|
||||
"""Main application class for Weather Connect."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the application."""
|
||||
self.config = None
|
||||
self.mqtt_client = None
|
||||
self.database = None
|
||||
self.data_processor = WeatherDataProcessor()
|
||||
self.message_queue = Queue()
|
||||
self.shutdown_event = Event()
|
||||
self.database_thread = None
|
||||
|
||||
def load_config(self, config_file: str = ".env") -> bool:
|
||||
"""Load application configuration.
|
||||
|
||||
Args:
|
||||
config_file: Path to configuration file
|
||||
|
||||
Returns:
|
||||
True if configuration loaded successfully
|
||||
"""
|
||||
try:
|
||||
self.config = Config(config_file)
|
||||
|
||||
if not self.config.validate():
|
||||
logger.error("Configuration validation failed")
|
||||
return False
|
||||
|
||||
# Set log level
|
||||
import logging
|
||||
log_level = getattr(logging, self.config.app.log_level.upper(), logging.INFO)
|
||||
logging.basicConfig(level=log_level)
|
||||
|
||||
logger.info("Configuration loaded successfully",
|
||||
mqtt_host=self.config.mqtt.host,
|
||||
db_type=self.config.database.db_type)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to load configuration", error=str(e))
|
||||
return False
|
||||
|
||||
def setup_database(self):
|
||||
"""Initialize database connection with retry logic."""
|
||||
logger.info("Setting up database connection")
|
||||
|
||||
max_retries = 10
|
||||
retry_delay = 30 # 30 seconds between retries
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
import dataclasses
|
||||
config_dict = dataclasses.asdict(self.config.database)
|
||||
self.db = DatabaseFactory.create_database(
|
||||
self.config.database.db_type,
|
||||
config_dict
|
||||
)
|
||||
if self.db.connect():
|
||||
logger.info("Database connection established")
|
||||
self.db.create_tables()
|
||||
logger.info("Database tables ready")
|
||||
self.database = self.db
|
||||
return True
|
||||
else:
|
||||
raise Exception("Failed to connect to database")
|
||||
except Exception as e:
|
||||
logger.warning(f"Database connection attempt {attempt + 1}/{max_retries} failed",
|
||||
error=str(e))
|
||||
if attempt < max_retries - 1:
|
||||
logger.info(f"Retrying in {retry_delay} seconds...")
|
||||
time.sleep(retry_delay)
|
||||
else:
|
||||
logger.error("All database connection attempts exhausted")
|
||||
return False
|
||||
|
||||
def initialize_mqtt(self) -> bool:
|
||||
"""Initialize MQTT client.
|
||||
|
||||
Returns:
|
||||
True if MQTT client initialized successfully
|
||||
"""
|
||||
try:
|
||||
self.mqtt_client = MQTTClient(
|
||||
self.config.mqtt,
|
||||
self._on_mqtt_message
|
||||
)
|
||||
|
||||
logger.info("MQTT client initialized successfully")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to initialize MQTT client", error=str(e))
|
||||
return False
|
||||
|
||||
def _on_mqtt_message(self, topic: str, data: any):
|
||||
"""Handle incoming MQTT messages.
|
||||
|
||||
Args:
|
||||
topic: MQTT topic
|
||||
data: Message data
|
||||
"""
|
||||
try:
|
||||
# Put message in queue for processing
|
||||
self.message_queue.put((topic, data), block=False)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to queue MQTT message", topic=topic, error=str(e))
|
||||
|
||||
def _database_worker(self):
|
||||
"""Database worker thread for processing messages."""
|
||||
batch_data = []
|
||||
last_batch_time = time.time()
|
||||
|
||||
logger.info("Database worker started")
|
||||
|
||||
while not self.shutdown_event.is_set():
|
||||
try:
|
||||
# Try to get a message from the queue
|
||||
try:
|
||||
topic, data = self.message_queue.get(timeout=1.0)
|
||||
except Empty:
|
||||
# Check if we should flush the batch due to timeout
|
||||
if batch_data and (time.time() - last_batch_time) > self.config.app.batch_timeout:
|
||||
self._flush_batch(batch_data)
|
||||
batch_data = []
|
||||
last_batch_time = time.time()
|
||||
continue
|
||||
|
||||
# Process the message
|
||||
weather_data = self.data_processor.process_message(topic, data)
|
||||
if weather_data:
|
||||
batch_data.append(weather_data)
|
||||
|
||||
# Check if batch is full
|
||||
if len(batch_data) >= self.config.app.batch_size:
|
||||
self._flush_batch(batch_data)
|
||||
batch_data = []
|
||||
last_batch_time = time.time()
|
||||
|
||||
self.message_queue.task_done()
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error in database worker", error=str(e))
|
||||
time.sleep(1)
|
||||
|
||||
# Flush remaining data on shutdown
|
||||
if batch_data:
|
||||
self._flush_batch(batch_data)
|
||||
|
||||
logger.info("Database worker stopped")
|
||||
|
||||
def _flush_batch(self, batch_data):
|
||||
"""Flush batch data to database.
|
||||
|
||||
Args:
|
||||
batch_data: List of WeatherData objects
|
||||
"""
|
||||
if not batch_data or not self.database:
|
||||
return
|
||||
|
||||
try:
|
||||
if len(batch_data) == 1:
|
||||
success = self.database.insert_weather_data(batch_data[0])
|
||||
else:
|
||||
success = self.database.insert_weather_data_batch(batch_data)
|
||||
|
||||
if success:
|
||||
logger.info("Successfully inserted weather data batch", count=len(batch_data))
|
||||
else:
|
||||
logger.error("Failed to insert weather data batch", count=len(batch_data))
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error flushing batch to database", error=str(e), count=len(batch_data))
|
||||
|
||||
def run(self):
|
||||
"""Run the main application."""
|
||||
logger.info("Starting Weather Connect application")
|
||||
|
||||
# Set up signal handlers
|
||||
signal.signal(signal.SIGINT, self._signal_handler)
|
||||
signal.signal(signal.SIGTERM, self._signal_handler)
|
||||
|
||||
try:
|
||||
# Load configuration
|
||||
if not self.load_config():
|
||||
return 1
|
||||
|
||||
# Initialize database
|
||||
if not self.setup_database():
|
||||
return 1
|
||||
|
||||
# Initialize MQTT
|
||||
if not self.initialize_mqtt():
|
||||
return 1
|
||||
|
||||
# Start database worker thread
|
||||
self.database_thread = Thread(target=self._database_worker, daemon=True)
|
||||
self.database_thread.start()
|
||||
|
||||
# Start MQTT client (blocking)
|
||||
logger.info("Starting MQTT client loop")
|
||||
self.mqtt_client.start_loop(self.config.app.reconnect_interval)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Received keyboard interrupt")
|
||||
except Exception as e:
|
||||
logger.error("Application error", error=str(e))
|
||||
return 1
|
||||
finally:
|
||||
self.shutdown()
|
||||
|
||||
logger.info("Weather Connect application stopped")
|
||||
return 0
|
||||
|
||||
def _signal_handler(self, signum, frame):
|
||||
"""Handle shutdown signals.
|
||||
|
||||
Args:
|
||||
signum: Signal number
|
||||
frame: Current stack frame
|
||||
"""
|
||||
logger.info("Received shutdown signal", signal=signum)
|
||||
self.shutdown()
|
||||
|
||||
def shutdown(self):
|
||||
"""Shutdown the application gracefully."""
|
||||
logger.info("Shutting down application")
|
||||
|
||||
# Set shutdown event
|
||||
self.shutdown_event.set()
|
||||
|
||||
# Stop MQTT client
|
||||
if self.mqtt_client:
|
||||
self.mqtt_client.stop()
|
||||
|
||||
# Wait for database thread to finish
|
||||
if self.database_thread and self.database_thread.is_alive():
|
||||
logger.info("Waiting for database worker to finish")
|
||||
self.database_thread.join(timeout=5)
|
||||
|
||||
# Close database connection
|
||||
if self.database:
|
||||
self.database.disconnect()
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
app = WeatherConnectApp()
|
||||
return app.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
182
src/wxconnect/mqtt_client.py
Normal file
182
src/wxconnect/mqtt_client.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""MQTT client for weather data subscription."""
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Callable, Any, Optional
|
||||
import paho.mqtt.client as mqtt
|
||||
import structlog
|
||||
from .config import MQTTConfig
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class MQTTClient:
|
||||
"""MQTT client for subscribing to weather data."""
|
||||
|
||||
def __init__(self, config: MQTTConfig, message_callback: Callable[[str, Any], None]):
|
||||
"""Initialize MQTT client.
|
||||
|
||||
Args:
|
||||
config: MQTT configuration
|
||||
message_callback: Callback function for processing messages
|
||||
"""
|
||||
self.config = config
|
||||
self.message_callback = message_callback
|
||||
self.client = mqtt.Client(client_id=config.client_id)
|
||||
self.connected = False
|
||||
self.running = False
|
||||
|
||||
# Set up callbacks
|
||||
self.client.on_connect = self._on_connect
|
||||
self.client.on_disconnect = self._on_disconnect
|
||||
self.client.on_message = self._on_message
|
||||
self.client.on_subscribe = self._on_subscribe
|
||||
self.client.on_log = self._on_log
|
||||
|
||||
# Set credentials if provided
|
||||
if config.username and config.password:
|
||||
self.client.username_pw_set(config.username, config.password)
|
||||
|
||||
def _on_connect(self, client, userdata, flags, rc):
|
||||
"""Callback for when the client connects to the broker."""
|
||||
if rc == 0:
|
||||
self.connected = True
|
||||
logger.info("Connected to MQTT broker", host=self.config.host, port=self.config.port)
|
||||
|
||||
# Subscribe to the weather topic
|
||||
result = client.subscribe(self.config.topic)
|
||||
if result[0] == mqtt.MQTT_ERR_SUCCESS:
|
||||
logger.info("Subscribed to topic", topic=self.config.topic)
|
||||
else:
|
||||
logger.error("Failed to subscribe to topic", topic=self.config.topic, error=result[0])
|
||||
else:
|
||||
self.connected = False
|
||||
logger.error("Failed to connect to MQTT broker", rc=rc, host=self.config.host)
|
||||
|
||||
def _on_disconnect(self, client, userdata, rc):
|
||||
"""Callback for when the client disconnects from the broker."""
|
||||
self.connected = False
|
||||
if rc != 0:
|
||||
logger.warning("Unexpected disconnection from MQTT broker", rc=rc)
|
||||
else:
|
||||
logger.info("Disconnected from MQTT broker")
|
||||
|
||||
def _on_subscribe(self, client, userdata, mid, granted_qos):
|
||||
"""Callback for successful subscription."""
|
||||
logger.info("*** SUBSCRIPTION ACKNOWLEDGED ***", mid=mid, granted_qos=granted_qos)
|
||||
|
||||
def _on_message(self, client, userdata, msg):
|
||||
"""Callback for when a message is received."""
|
||||
try:
|
||||
topic = msg.topic
|
||||
payload = msg.payload.decode('utf-8')
|
||||
|
||||
logger.info("*** RECEIVED MQTT MESSAGE ***", topic=topic, payload_size=len(payload))
|
||||
|
||||
# Try to parse as JSON, fall back to string if it fails
|
||||
try:
|
||||
data = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Failed to parse message as JSON, treating as string", topic=topic)
|
||||
data = payload
|
||||
|
||||
# Call the message callback
|
||||
self.message_callback(topic, data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error processing MQTT message", error=str(e), topic=msg.topic)
|
||||
|
||||
def _on_log(self, client, userdata, level, buf):
|
||||
"""Callback for MQTT client logging."""
|
||||
if level == mqtt.MQTT_LOG_ERR:
|
||||
logger.error("MQTT client error", message=buf)
|
||||
elif level == mqtt.MQTT_LOG_WARNING:
|
||||
logger.warning("MQTT client warning", message=buf)
|
||||
else:
|
||||
logger.debug("MQTT client log", level=level, message=buf)
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""Connect to the MQTT broker.
|
||||
|
||||
Returns:
|
||||
True if connection successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
logger.info("Connecting to MQTT broker", host=self.config.host, port=self.config.port)
|
||||
result = self.client.connect(self.config.host, self.config.port, 60)
|
||||
|
||||
if result == mqtt.MQTT_ERR_SUCCESS:
|
||||
return True
|
||||
else:
|
||||
logger.error("Failed to connect to MQTT broker", error=result)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Exception while connecting to MQTT broker", error=str(e))
|
||||
return False
|
||||
|
||||
def disconnect(self):
|
||||
"""Disconnect from the MQTT broker."""
|
||||
if self.connected:
|
||||
logger.info("Disconnecting from MQTT broker")
|
||||
self.client.disconnect()
|
||||
|
||||
def stop(self):
|
||||
"""Stop the MQTT client loop."""
|
||||
logger.info("Stopping MQTT client")
|
||||
self.running = False
|
||||
if self.connected:
|
||||
self.client.disconnect()
|
||||
# Stop the MQTT loop
|
||||
try:
|
||||
self.client.loop_stop()
|
||||
except:
|
||||
pass # Ignore errors if loop is already stopped
|
||||
|
||||
def start_loop(self, reconnect_interval: int = 30):
|
||||
"""Start the MQTT client loop with reconnection logic.
|
||||
|
||||
Args:
|
||||
reconnect_interval: Seconds to wait before attempting reconnection
|
||||
"""
|
||||
import threading
|
||||
|
||||
def mqtt_loop():
|
||||
"""Run the MQTT loop in a separate thread."""
|
||||
self.running = True
|
||||
while self.running:
|
||||
try:
|
||||
if not self.connected:
|
||||
if self.connect():
|
||||
# Wait a bit for connection to establish
|
||||
time.sleep(1)
|
||||
else:
|
||||
logger.warning("Failed to connect, retrying in {} seconds".format(reconnect_interval))
|
||||
time.sleep(reconnect_interval)
|
||||
continue
|
||||
|
||||
# Run the MQTT loop (blocking)
|
||||
logger.info("Starting MQTT loop_forever")
|
||||
self.client.loop_forever()
|
||||
|
||||
# If we get here, the loop exited
|
||||
if self.running: # Only log if we're still supposed to be running
|
||||
logger.warning("MQTT loop exited, attempting to reconnect in {} seconds".format(reconnect_interval))
|
||||
time.sleep(reconnect_interval)
|
||||
|
||||
except Exception as e:
|
||||
if self.running: # Only log if we're still supposed to be running
|
||||
logger.error("Unexpected error in MQTT loop", error=str(e))
|
||||
time.sleep(reconnect_interval)
|
||||
|
||||
# Start the MQTT loop in a separate thread
|
||||
self.mqtt_thread = threading.Thread(target=mqtt_loop, daemon=True)
|
||||
self.mqtt_thread.start()
|
||||
|
||||
# Keep the main thread alive until stopped
|
||||
while self.running:
|
||||
time.sleep(0.1)
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
"""Check if the client is connected to the broker."""
|
||||
return self.connected
|
||||
Reference in New Issue
Block a user