Complete Phase 1: Core Infrastructure & Database Setup
- Created comprehensive database schema with all required tables - Implemented database client with connection pooling and async support - Enhanced auto-discovery module with periodic refresh and validation - Completed configuration management with comprehensive settings - Set up structured logging with JSON formatting and audit trail - Created test suite for Phase 1 functionality - Added main_phase1.py for Phase 1 demonstration Key Features: - PostgreSQL schema with 9 tables including audit_log, emergency_stop_events, etc. - Connection pooling with health monitoring - Auto-discovery with station and pump validation - Configuration validation and sensitive field masking - Structured logging with correlation IDs - Comprehensive test suite
This commit is contained in:
parent
941bed9096
commit
097574733e
|
|
@ -1,41 +1,160 @@
|
||||||
"""
|
"""
|
||||||
Application settings for Calejo Control Adapter.
|
Configuration management for Calejo Control Adapter.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
from typing import Optional
|
from typing import Optional, List
|
||||||
|
from pydantic import validator
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
"""Application settings loaded from environment variables."""
|
"""Application settings loaded from environment variables."""
|
||||||
|
|
||||||
# Database
|
# Database configuration
|
||||||
database_url: str = "postgresql://control_reader:secure_password@localhost:5432/calejo"
|
db_host: str = "localhost"
|
||||||
|
db_port: int = 5432
|
||||||
|
db_name: str = "calejo"
|
||||||
|
db_user: str = "control_reader"
|
||||||
|
db_password: str = "secure_password"
|
||||||
|
db_min_connections: int = 2
|
||||||
|
db_max_connections: int = 10
|
||||||
|
|
||||||
# Protocol endpoints
|
# Station filter (optional)
|
||||||
opc_ua_endpoint: str = "opc.tcp://0.0.0.0:4840"
|
station_filter: Optional[str] = None
|
||||||
|
|
||||||
|
# Security
|
||||||
|
api_key: str = "your_api_key_here"
|
||||||
|
tls_enabled: bool = True
|
||||||
|
tls_cert_path: Optional[str] = None
|
||||||
|
tls_key_path: Optional[str] = None
|
||||||
|
|
||||||
|
# OPC UA
|
||||||
|
opcua_enabled: bool = True
|
||||||
|
opcua_port: int = 4840
|
||||||
|
opcua_security_mode: str = "SignAndEncrypt"
|
||||||
|
opcua_cert_path: Optional[str] = None
|
||||||
|
opcua_key_path: Optional[str] = None
|
||||||
|
|
||||||
|
# Modbus TCP
|
||||||
|
modbus_enabled: bool = True
|
||||||
modbus_port: int = 502
|
modbus_port: int = 502
|
||||||
|
modbus_slave_id: int = 1
|
||||||
|
|
||||||
|
# REST API
|
||||||
|
rest_api_enabled: bool = True
|
||||||
rest_api_port: int = 8080
|
rest_api_port: int = 8080
|
||||||
|
rest_api_cors_enabled: bool = True
|
||||||
|
|
||||||
# Safety settings
|
# Safety - Watchdog
|
||||||
safety_timeout_seconds: int = 1200 # 20 minutes
|
watchdog_enabled: bool = True
|
||||||
|
watchdog_timeout_seconds: int = 1200 # 20 minutes
|
||||||
|
watchdog_check_interval_seconds: int = 60 # Check every minute
|
||||||
|
|
||||||
# Security settings
|
# Auto-Discovery
|
||||||
jwt_secret_key: str = "your-secret-key-change-in-production"
|
auto_discovery_enabled: bool = True
|
||||||
jwt_algorithm: str = "HS256"
|
auto_discovery_refresh_minutes: int = 60
|
||||||
|
|
||||||
# Alert settings
|
# Alerts - Email
|
||||||
smtp_server: Optional[str] = None
|
alert_email_enabled: bool = True
|
||||||
smtp_port: Optional[int] = 587
|
alert_email_from: str = "calejo-control@example.com"
|
||||||
smtp_username: Optional[str] = None
|
alert_email_recipients: List[str] = ["operator1@utility.it", "operator2@utility.it"]
|
||||||
smtp_password: Optional[str] = None
|
smtp_host: str = "smtp.gmail.com"
|
||||||
twilio_account_sid: Optional[str] = None
|
smtp_port: int = 587
|
||||||
twilio_auth_token: Optional[str] = None
|
smtp_username: str = "calejo-control@example.com"
|
||||||
twilio_phone_number: Optional[str] = None
|
smtp_password: str = "smtp_password"
|
||||||
|
smtp_use_tls: bool = True
|
||||||
|
|
||||||
|
# Alerts - SMS
|
||||||
|
alert_sms_enabled: bool = True
|
||||||
|
alert_sms_recipients: List[str] = ["+393401234567", "+393407654321"]
|
||||||
|
twilio_account_sid: str = "your_twilio_account_sid"
|
||||||
|
twilio_auth_token: str = "your_twilio_auth_token"
|
||||||
|
twilio_phone_number: str = "+15551234567"
|
||||||
|
|
||||||
|
# Alerts - Webhook
|
||||||
|
alert_webhook_enabled: bool = True
|
||||||
|
alert_webhook_url: str = "https://utility-monitoring.example.com/webhook"
|
||||||
|
alert_webhook_token: str = "webhook_bearer_token"
|
||||||
|
|
||||||
|
# Alerts - SCADA
|
||||||
|
alert_scada_enabled: bool = True
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
|
log_format: str = "json"
|
||||||
|
audit_log_enabled: bool = True
|
||||||
|
|
||||||
|
# Application
|
||||||
|
app_name: str = "Calejo Control Adapter"
|
||||||
|
app_version: str = "2.0.0"
|
||||||
|
environment: str = "development"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def database_url(self) -> str:
|
||||||
|
"""Generate database URL from components."""
|
||||||
|
return f"postgresql://{self.db_user}:{self.db_password}@{self.db_host}:{self.db_port}/{self.db_name}"
|
||||||
|
|
||||||
|
@validator('db_port')
|
||||||
|
def validate_db_port(cls, v):
|
||||||
|
if not 1 <= v <= 65535:
|
||||||
|
raise ValueError('Database port must be between 1 and 65535')
|
||||||
|
return v
|
||||||
|
|
||||||
|
@validator('opcua_port')
|
||||||
|
def validate_opcua_port(cls, v):
|
||||||
|
if not 1 <= v <= 65535:
|
||||||
|
raise ValueError('OPC UA port must be between 1 and 65535')
|
||||||
|
return v
|
||||||
|
|
||||||
|
@validator('modbus_port')
|
||||||
|
def validate_modbus_port(cls, v):
|
||||||
|
if not 1 <= v <= 65535:
|
||||||
|
raise ValueError('Modbus port must be between 1 and 65535')
|
||||||
|
return v
|
||||||
|
|
||||||
|
@validator('rest_api_port')
|
||||||
|
def validate_rest_api_port(cls, v):
|
||||||
|
if not 1 <= v <= 65535:
|
||||||
|
raise ValueError('REST API port must be between 1 and 65535')
|
||||||
|
return v
|
||||||
|
|
||||||
|
@validator('log_level')
|
||||||
|
def validate_log_level(cls, v):
|
||||||
|
valid_levels = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
|
||||||
|
if v.upper() not in valid_levels:
|
||||||
|
raise ValueError(f'Log level must be one of: {valid_levels}')
|
||||||
|
return v.upper()
|
||||||
|
|
||||||
|
@validator('alert_email_recipients', 'alert_sms_recipients', pre=True)
|
||||||
|
def parse_recipients(cls, v):
|
||||||
|
if isinstance(v, str):
|
||||||
|
return [recipient.strip() for recipient in v.split(',')]
|
||||||
|
return v
|
||||||
|
|
||||||
|
def get_sensitive_fields(self) -> List[str]:
|
||||||
|
"""Get list of sensitive fields for logging/masking."""
|
||||||
|
return [
|
||||||
|
'db_password',
|
||||||
|
'api_key',
|
||||||
|
'smtp_password',
|
||||||
|
'twilio_auth_token',
|
||||||
|
'alert_webhook_token'
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_safe_dict(self) -> dict:
|
||||||
|
"""Get settings dictionary with sensitive fields masked."""
|
||||||
|
settings_dict = self.dict()
|
||||||
|
for field in self.get_sensitive_fields():
|
||||||
|
if field in settings_dict and settings_dict[field]:
|
||||||
|
settings_dict[field] = '***MASKED***'
|
||||||
|
return settings_dict
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
env_file_encoding = "utf-8"
|
env_file_encoding = "utf-8"
|
||||||
|
case_sensitive = False
|
||||||
|
|
||||||
|
|
||||||
|
# Global settings instance
|
||||||
|
settings = Settings()
|
||||||
|
|
@ -0,0 +1,257 @@
|
||||||
|
-- Calejo Control Adapter Database Schema
|
||||||
|
-- Version: 2.0
|
||||||
|
-- Date: October 26, 2025
|
||||||
|
|
||||||
|
-- Drop existing tables if they exist (for clean setup)
|
||||||
|
DROP TABLE IF EXISTS audit_log CASCADE;
|
||||||
|
DROP TABLE IF EXISTS emergency_stop_events CASCADE;
|
||||||
|
DROP TABLE IF EXISTS failsafe_events CASCADE;
|
||||||
|
DROP TABLE IF EXISTS safety_limit_violations CASCADE;
|
||||||
|
DROP TABLE IF EXISTS pump_safety_limits CASCADE;
|
||||||
|
DROP TABLE IF EXISTS pump_feedback CASCADE;
|
||||||
|
DROP TABLE IF EXISTS pump_plans CASCADE;
|
||||||
|
DROP TABLE IF EXISTS pumps CASCADE;
|
||||||
|
DROP TABLE IF EXISTS pump_stations CASCADE;
|
||||||
|
|
||||||
|
-- Create pump_stations table
|
||||||
|
CREATE TABLE pump_stations (
|
||||||
|
station_id VARCHAR(50) PRIMARY KEY,
|
||||||
|
station_name VARCHAR(200) NOT NULL,
|
||||||
|
location VARCHAR(200),
|
||||||
|
latitude DECIMAL(10, 8),
|
||||||
|
longitude DECIMAL(11, 8),
|
||||||
|
timezone VARCHAR(50) DEFAULT 'Europe/Rome',
|
||||||
|
active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE pump_stations IS 'Metadata about pump stations';
|
||||||
|
COMMENT ON COLUMN pump_stations.timezone IS 'Timezone for the pump station (default: Europe/Rome for Italian utilities)';
|
||||||
|
|
||||||
|
-- Create pumps table
|
||||||
|
CREATE TABLE pumps (
|
||||||
|
pump_id VARCHAR(50) NOT NULL,
|
||||||
|
station_id VARCHAR(50) NOT NULL,
|
||||||
|
pump_name VARCHAR(200),
|
||||||
|
pump_type VARCHAR(50), -- 'SUBMERSIBLE', 'CENTRIFUGAL', etc.
|
||||||
|
control_type VARCHAR(50) NOT NULL, -- 'LEVEL_CONTROLLED', 'POWER_CONTROLLED', 'DIRECT_SPEED'
|
||||||
|
manufacturer VARCHAR(100),
|
||||||
|
model VARCHAR(100),
|
||||||
|
rated_power_kw DECIMAL(10, 2),
|
||||||
|
min_speed_hz DECIMAL(5, 2) DEFAULT 20.0,
|
||||||
|
max_speed_hz DECIMAL(5, 2) DEFAULT 50.0,
|
||||||
|
|
||||||
|
-- Default setpoint (used in failsafe mode)
|
||||||
|
default_setpoint_hz DECIMAL(5, 2) NOT NULL DEFAULT 35.0,
|
||||||
|
|
||||||
|
-- Control-specific parameters (JSON)
|
||||||
|
control_parameters JSONB,
|
||||||
|
|
||||||
|
active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
|
||||||
|
PRIMARY KEY (station_id, pump_id),
|
||||||
|
FOREIGN KEY (station_id) REFERENCES pump_stations(station_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE pumps IS 'Metadata about individual pumps';
|
||||||
|
COMMENT ON COLUMN pumps.default_setpoint_hz IS 'Default safe setpoint used in failsafe mode (existing pump configuration)';
|
||||||
|
COMMENT ON COLUMN pumps.control_parameters IS 'Control-specific parameters in JSON format (PID gains, thresholds, etc.)';
|
||||||
|
|
||||||
|
-- Create pump_plans table
|
||||||
|
CREATE TABLE pump_plans (
|
||||||
|
plan_id SERIAL PRIMARY KEY,
|
||||||
|
station_id VARCHAR(50) NOT NULL,
|
||||||
|
pump_id VARCHAR(50) NOT NULL,
|
||||||
|
interval_start TIMESTAMP NOT NULL,
|
||||||
|
interval_end TIMESTAMP NOT NULL,
|
||||||
|
|
||||||
|
-- Optimization outputs
|
||||||
|
target_flow_m3h DECIMAL(10, 2),
|
||||||
|
target_power_kw DECIMAL(10, 2),
|
||||||
|
target_level_m DECIMAL(5, 2),
|
||||||
|
suggested_speed_hz DECIMAL(5, 2),
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
plan_created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
optimization_run_id INTEGER,
|
||||||
|
|
||||||
|
FOREIGN KEY (station_id, pump_id) REFERENCES pumps(station_id, pump_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE pump_plans IS 'Optimization plans generated by Calejo Optimize';
|
||||||
|
|
||||||
|
-- Create index for active pump plans
|
||||||
|
CREATE INDEX idx_pump_plans_active ON pump_plans(station_id, pump_id, interval_start, interval_end);
|
||||||
|
|
||||||
|
-- Create pump_feedback table
|
||||||
|
CREATE TABLE pump_feedback (
|
||||||
|
feedback_id SERIAL PRIMARY KEY,
|
||||||
|
station_id VARCHAR(50) NOT NULL,
|
||||||
|
pump_id VARCHAR(50) NOT NULL,
|
||||||
|
timestamp TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- Actual measurements
|
||||||
|
actual_speed_hz DECIMAL(5, 2),
|
||||||
|
actual_power_kw DECIMAL(10, 2),
|
||||||
|
actual_flow_m3h DECIMAL(10, 2),
|
||||||
|
wet_well_level_m DECIMAL(5, 2),
|
||||||
|
pump_running BOOLEAN,
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
alarm_active BOOLEAN DEFAULT FALSE,
|
||||||
|
alarm_code VARCHAR(50),
|
||||||
|
|
||||||
|
FOREIGN KEY (station_id, pump_id) REFERENCES pumps(station_id, pump_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE pump_feedback IS 'Real-time feedback from pumps';
|
||||||
|
|
||||||
|
-- Create index for latest feedback
|
||||||
|
CREATE INDEX idx_pump_feedback_latest ON pump_feedback(station_id, pump_id, timestamp DESC);
|
||||||
|
|
||||||
|
-- Create pump_safety_limits table
|
||||||
|
CREATE TABLE pump_safety_limits (
|
||||||
|
station_id VARCHAR(50) NOT NULL,
|
||||||
|
pump_id VARCHAR(50) NOT NULL,
|
||||||
|
|
||||||
|
-- Speed limits (Layer 2: Station Safety Limits)
|
||||||
|
hard_min_speed_hz DECIMAL(5, 2) NOT NULL,
|
||||||
|
hard_max_speed_hz DECIMAL(5, 2) NOT NULL,
|
||||||
|
|
||||||
|
-- Level limits
|
||||||
|
hard_min_level_m DECIMAL(5, 2),
|
||||||
|
hard_max_level_m DECIMAL(5, 2),
|
||||||
|
emergency_stop_level_m DECIMAL(5, 2), -- Emergency stop if exceeded
|
||||||
|
dry_run_protection_level_m DECIMAL(5, 2), -- Stop pump if level too low
|
||||||
|
|
||||||
|
-- Power and flow limits
|
||||||
|
hard_max_power_kw DECIMAL(10, 2),
|
||||||
|
hard_max_flow_m3h DECIMAL(10, 2),
|
||||||
|
|
||||||
|
-- Operational limits
|
||||||
|
max_starts_per_hour INTEGER DEFAULT 6,
|
||||||
|
min_run_time_seconds INTEGER DEFAULT 300,
|
||||||
|
max_continuous_run_hours INTEGER DEFAULT 24,
|
||||||
|
|
||||||
|
-- Rate of change limits (prevent sudden changes that damage equipment)
|
||||||
|
max_speed_change_hz_per_min DECIMAL(5, 2) DEFAULT 5.0,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
set_by VARCHAR(100),
|
||||||
|
set_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
approved_by VARCHAR(100), -- Dual approval
|
||||||
|
approved_at TIMESTAMP,
|
||||||
|
notes TEXT,
|
||||||
|
|
||||||
|
PRIMARY KEY (station_id, pump_id),
|
||||||
|
FOREIGN KEY (station_id, pump_id) REFERENCES pumps(station_id, pump_id),
|
||||||
|
|
||||||
|
-- Constraints
|
||||||
|
CONSTRAINT check_speed_limits CHECK (hard_min_speed_hz >= 15 AND hard_max_speed_hz <= 55),
|
||||||
|
CONSTRAINT check_min_max CHECK (hard_min_speed_hz < hard_max_speed_hz),
|
||||||
|
CONSTRAINT check_approved CHECK (approved_by IS NULL OR approved_by != set_by) -- Dual approval
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE pump_safety_limits IS 'Hard operational limits enforced by Calejo Control adapter (Layer 2)';
|
||||||
|
COMMENT ON COLUMN pump_safety_limits.hard_min_speed_hz IS 'Minimum speed enforced by adapter (must be >= PLC physical limit)';
|
||||||
|
COMMENT ON COLUMN pump_safety_limits.hard_max_speed_hz IS 'Maximum speed enforced by adapter (must be <= PLC physical limit)';
|
||||||
|
|
||||||
|
-- Create safety_limit_violations table
|
||||||
|
CREATE TABLE safety_limit_violations (
|
||||||
|
violation_id SERIAL PRIMARY KEY,
|
||||||
|
station_id VARCHAR(50) NOT NULL,
|
||||||
|
pump_id VARCHAR(50) NOT NULL,
|
||||||
|
requested_setpoint DECIMAL(5, 2),
|
||||||
|
enforced_setpoint DECIMAL(5, 2),
|
||||||
|
violations TEXT[], -- Array of violation descriptions
|
||||||
|
timestamp TIMESTAMP DEFAULT NOW(),
|
||||||
|
|
||||||
|
FOREIGN KEY (station_id, pump_id) REFERENCES pumps(station_id, pump_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE safety_limit_violations IS 'Audit trail of safety limit violations (immutable)';
|
||||||
|
|
||||||
|
-- Create index for violation timestamp
|
||||||
|
CREATE INDEX idx_violations_timestamp ON safety_limit_violations(timestamp DESC);
|
||||||
|
|
||||||
|
-- Create failsafe_events table
|
||||||
|
CREATE TABLE failsafe_events (
|
||||||
|
event_id SERIAL PRIMARY KEY,
|
||||||
|
station_id VARCHAR(50) NOT NULL,
|
||||||
|
pump_id VARCHAR(50) NOT NULL,
|
||||||
|
event_type VARCHAR(50) NOT NULL, -- 'DATABASE_TIMEOUT', 'COMMUNICATION_LOSS', 'INVALID_DATA'
|
||||||
|
default_setpoint DECIMAL(5, 2),
|
||||||
|
triggered_by VARCHAR(100),
|
||||||
|
timestamp TIMESTAMP DEFAULT NOW(),
|
||||||
|
cleared_at TIMESTAMP,
|
||||||
|
notes TEXT,
|
||||||
|
|
||||||
|
FOREIGN KEY (station_id, pump_id) REFERENCES pumps(station_id, pump_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE failsafe_events IS 'Record of failsafe mode activations';
|
||||||
|
|
||||||
|
-- Create index for active failsafe events
|
||||||
|
CREATE INDEX idx_failsafe_events_active ON failsafe_events(station_id, pump_id, timestamp DESC)
|
||||||
|
WHERE cleared_at IS NULL;
|
||||||
|
|
||||||
|
-- Create emergency_stop_events table
|
||||||
|
CREATE TABLE emergency_stop_events (
|
||||||
|
event_id SERIAL PRIMARY KEY,
|
||||||
|
station_id VARCHAR(50), -- NULL = all stations
|
||||||
|
pump_id VARCHAR(50), -- NULL = all pumps at station
|
||||||
|
triggered_by VARCHAR(100) NOT NULL,
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
timestamp TIMESTAMP DEFAULT NOW(),
|
||||||
|
cleared_at TIMESTAMP,
|
||||||
|
cleared_by VARCHAR(100),
|
||||||
|
clear_notes TEXT,
|
||||||
|
|
||||||
|
FOREIGN KEY (station_id, pump_id) REFERENCES pumps(station_id, pump_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE emergency_stop_events IS 'Emergency stop event log (big red button)';
|
||||||
|
|
||||||
|
-- Create index for active emergency stops
|
||||||
|
CREATE INDEX idx_emergency_stop_active ON emergency_stop_events(timestamp DESC)
|
||||||
|
WHERE cleared_at IS NULL;
|
||||||
|
|
||||||
|
-- Create audit_log table
|
||||||
|
CREATE TABLE audit_log (
|
||||||
|
log_id BIGSERIAL PRIMARY KEY,
|
||||||
|
timestamp TIMESTAMP DEFAULT NOW(),
|
||||||
|
event_type VARCHAR(50) NOT NULL,
|
||||||
|
severity VARCHAR(20) NOT NULL, -- 'INFO', 'WARNING', 'CRITICAL'
|
||||||
|
station_id VARCHAR(50),
|
||||||
|
pump_id VARCHAR(50),
|
||||||
|
user_id VARCHAR(100),
|
||||||
|
ip_address INET,
|
||||||
|
protocol VARCHAR(20), -- 'OPC_UA', 'MODBUS', 'REST_API'
|
||||||
|
action VARCHAR(100),
|
||||||
|
resource VARCHAR(200),
|
||||||
|
result VARCHAR(20), -- 'SUCCESS', 'FAILURE', 'DENIED'
|
||||||
|
event_data JSONB
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE audit_log IS 'Immutable audit trail for compliance (IEC 62443, ISO 27001, NIS2 Directive)';
|
||||||
|
|
||||||
|
-- Create indexes for audit log
|
||||||
|
CREATE INDEX idx_audit_log_timestamp ON audit_log(timestamp DESC);
|
||||||
|
CREATE INDEX idx_audit_log_severity ON audit_log(severity, timestamp DESC);
|
||||||
|
CREATE INDEX idx_audit_log_user ON audit_log(user_id, timestamp DESC);
|
||||||
|
|
||||||
|
-- Make audit log immutable (append-only)
|
||||||
|
CREATE RULE audit_log_no_update AS ON UPDATE TO audit_log DO INSTEAD NOTHING;
|
||||||
|
CREATE RULE audit_log_no_delete AS ON DELETE TO audit_log DO INSTEAD NOTHING;
|
||||||
|
|
||||||
|
-- Create database user for adapter
|
||||||
|
-- Note: This should be executed by database administrator
|
||||||
|
-- CREATE USER control_reader WITH PASSWORD 'secure_password';
|
||||||
|
-- GRANT CONNECT ON DATABASE calejo TO control_reader;
|
||||||
|
-- GRANT USAGE ON SCHEMA public TO control_reader;
|
||||||
|
-- GRANT SELECT ON pump_stations, pumps, pump_plans, pump_feedback, pump_safety_limits TO control_reader;
|
||||||
|
-- GRANT INSERT ON safety_limit_violations, failsafe_events, emergency_stop_events, audit_log TO control_reader;
|
||||||
|
-- GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO control_reader;
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Calejo Control Adapter Database Setup Script
|
||||||
|
# This script sets up the PostgreSQL database for the Calejo Control Adapter
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
DB_NAME="calejo"
|
||||||
|
DB_USER="control_reader"
|
||||||
|
DB_PASSWORD="secure_password"
|
||||||
|
DB_HOST="localhost"
|
||||||
|
DB_PORT="5432"
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
echo -e "${GREEN}Calejo Control Adapter Database Setup${NC}"
|
||||||
|
echo "=========================================="
|
||||||
|
|
||||||
|
# Check if PostgreSQL is running
|
||||||
|
if ! pg_isready -h $DB_HOST -p $DB_PORT >/dev/null 2>&1; then
|
||||||
|
echo -e "${RED}Error: PostgreSQL is not running on $DB_HOST:$DB_PORT${NC}"
|
||||||
|
echo "Please start PostgreSQL and ensure it's accessible"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if database exists
|
||||||
|
if psql -h $DB_HOST -p $DB_PORT -U postgres -lqt | cut -d \| -f 1 | grep -qw "$DB_NAME"; then
|
||||||
|
echo -e "${YELLOW}Database '$DB_NAME' already exists${NC}"
|
||||||
|
read -p "Do you want to recreate it? (y/N): " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
echo "Dropping existing database..."
|
||||||
|
psql -h $DB_HOST -p $DB_PORT -U postgres -c "DROP DATABASE IF EXISTS $DB_NAME;"
|
||||||
|
else
|
||||||
|
echo "Using existing database"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create database if it doesn't exist
|
||||||
|
if ! psql -h $DB_HOST -p $DB_PORT -U postgres -lqt | cut -d \| -f 1 | grep -qw "$DB_NAME"; then
|
||||||
|
echo "Creating database '$DB_NAME'..."
|
||||||
|
psql -h $DB_HOST -p $DB_PORT -U postgres -c "CREATE DATABASE $DB_NAME;"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create schema
|
||||||
|
echo "Creating database schema..."
|
||||||
|
psql -h $DB_HOST -p $DB_PORT -U postgres -d $DB_NAME -f database/schema.sql
|
||||||
|
|
||||||
|
# Create database user (if it doesn't exist)
|
||||||
|
if ! psql -h $DB_HOST -p $DB_PORT -U postgres -t -c "\du" | grep -qw "$DB_USER"; then
|
||||||
|
echo "Creating database user '$DB_USER'..."
|
||||||
|
psql -h $DB_HOST -p $DB_PORT -U postgres -c "CREATE USER $DB_USER WITH PASSWORD '$DB_PASSWORD';"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Grant permissions
|
||||||
|
echo "Granting permissions to user '$DB_USER'..."
|
||||||
|
psql -h $DB_HOST -p $DB_PORT -U postgres -d $DB_NAME -c "
|
||||||
|
GRANT CONNECT ON DATABASE $DB_NAME TO $DB_USER;
|
||||||
|
GRANT USAGE ON SCHEMA public TO $DB_USER;
|
||||||
|
GRANT SELECT ON pump_stations, pumps, pump_plans, pump_feedback, pump_safety_limits TO $DB_USER;
|
||||||
|
GRANT INSERT ON safety_limit_violations, failsafe_events, emergency_stop_events, audit_log TO $DB_USER;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO $DB_USER;
|
||||||
|
"
|
||||||
|
|
||||||
|
# Insert test data (optional)
|
||||||
|
read -p "Do you want to insert test data? (y/N): " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
echo "Inserting test data..."
|
||||||
|
psql -h $DB_HOST -p $DB_PORT -U postgres -d $DB_NAME -f database/test_data.sql
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test connection with the new user
|
||||||
|
echo "Testing connection with user '$DB_USER'..."
|
||||||
|
if psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME -c "SELECT 'Connection successful' AS result;" >/dev/null 2>&1; then
|
||||||
|
echo -e "${GREEN}Database setup completed successfully!${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "Database Information:"
|
||||||
|
echo " Name: $DB_NAME"
|
||||||
|
echo " User: $DB_USER"
|
||||||
|
echo " Host: $DB_HOST"
|
||||||
|
echo " Port: $DB_PORT"
|
||||||
|
echo ""
|
||||||
|
echo "Connection string:"
|
||||||
|
echo " postgresql://$DB_USER:$DB_PASSWORD@$DB_HOST:$DB_PORT/$DB_NAME"
|
||||||
|
else
|
||||||
|
echo -e "${RED}Error: Failed to connect with user '$DB_USER'${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
-- Calejo Control Adapter Test Data
|
||||||
|
-- Version: 2.0
|
||||||
|
-- Date: October 26, 2025
|
||||||
|
|
||||||
|
-- Insert test pump stations
|
||||||
|
INSERT INTO pump_stations (station_id, station_name, location, latitude, longitude, timezone) VALUES
|
||||||
|
('STATION_001', 'North Wastewater Treatment Plant', 'Via Roma 123, Milano', 45.4642035, 9.189982, 'Europe/Rome'),
|
||||||
|
('STATION_002', 'South Pumping Station', 'Corso Italia 456, Milano', 45.448759, 9.163420, 'Europe/Rome'),
|
||||||
|
('STATION_003', 'Industrial Zone Station', 'Via Industria 789, Milano', 45.485649, 9.204041, 'Europe/Rome');
|
||||||
|
|
||||||
|
-- Insert test pumps
|
||||||
|
INSERT INTO pumps (
|
||||||
|
station_id, pump_id, pump_name, pump_type, control_type,
|
||||||
|
manufacturer, model, rated_power_kw, min_speed_hz, max_speed_hz,
|
||||||
|
default_setpoint_hz, control_parameters
|
||||||
|
) VALUES
|
||||||
|
-- Station 001 - Level controlled pumps
|
||||||
|
('STATION_001', 'PUMP_001', 'Main Pump 1', 'SUBMERSIBLE', 'LEVEL_CONTROLLED',
|
||||||
|
'KSB', 'Amarex N 200-400', 75.0, 20.0, 50.0, 35.0,
|
||||||
|
'{"pid_gains": {"kp": 2.5, "ki": 0.1, "kd": 0.5}, "level_setpoint": 2.5, "deadband": 0.2}'::JSONB),
|
||||||
|
|
||||||
|
('STATION_001', 'PUMP_002', 'Main Pump 2', 'SUBMERSIBLE', 'LEVEL_CONTROLLED',
|
||||||
|
'KSB', 'Amarex N 200-400', 75.0, 20.0, 50.0, 35.0,
|
||||||
|
'{"pid_gains": {"kp": 2.5, "ki": 0.1, "kd": 0.5}, "level_setpoint": 2.5, "deadband": 0.2}'::JSONB),
|
||||||
|
|
||||||
|
-- Station 002 - Power controlled pumps
|
||||||
|
('STATION_002', 'PUMP_001', 'Booster Pump 1', 'CENTRIFUGAL', 'POWER_CONTROLLED',
|
||||||
|
'Grundfos', 'CR 45-3-2', 45.0, 25.0, 45.0, 30.0,
|
||||||
|
'{"power_setpoint": 35.0, "efficiency_target": 0.85, "ramp_rate": 2.0}'::JSONB),
|
||||||
|
|
||||||
|
('STATION_002', 'PUMP_002', 'Booster Pump 2', 'CENTRIFUGAL', 'POWER_CONTROLLED',
|
||||||
|
'Grundfos', 'CR 45-3-2', 45.0, 25.0, 45.0, 30.0,
|
||||||
|
'{"power_setpoint": 35.0, "efficiency_target": 0.85, "ramp_rate": 2.0}'::JSONB),
|
||||||
|
|
||||||
|
-- Station 003 - Direct speed controlled pumps
|
||||||
|
('STATION_003', 'PUMP_001', 'Process Pump 1', 'SUBMERSIBLE', 'DIRECT_SPEED',
|
||||||
|
'Flygt', 'N-Pump 3085', 55.0, 20.0, 50.0, 40.0,
|
||||||
|
'{"speed_ramp_rate": 5.0}'::JSONB),
|
||||||
|
|
||||||
|
('STATION_003', 'PUMP_002', 'Process Pump 2', 'SUBMERSIBLE', 'DIRECT_SPEED',
|
||||||
|
'Flygt', 'N-Pump 3085', 55.0, 20.0, 50.0, 40.0,
|
||||||
|
'{"speed_ramp_rate": 5.0}'::JSONB);
|
||||||
|
|
||||||
|
-- Insert safety limits
|
||||||
|
INSERT INTO pump_safety_limits (
|
||||||
|
station_id, pump_id, hard_min_speed_hz, hard_max_speed_hz,
|
||||||
|
hard_min_level_m, hard_max_level_m, emergency_stop_level_m, dry_run_protection_level_m,
|
||||||
|
hard_max_power_kw, hard_max_flow_m3h, max_speed_change_hz_per_min,
|
||||||
|
set_by, approved_by, approved_at
|
||||||
|
) VALUES
|
||||||
|
-- Station 001 pumps
|
||||||
|
('STATION_001', 'PUMP_001', 20.0, 50.0, 1.0, 4.0, 4.5, 0.8, 80.0, 400.0, 5.0,
|
||||||
|
'system_admin', 'safety_engineer', NOW()),
|
||||||
|
|
||||||
|
('STATION_001', 'PUMP_002', 20.0, 50.0, 1.0, 4.0, 4.5, 0.8, 80.0, 400.0, 5.0,
|
||||||
|
'system_admin', 'safety_engineer', NOW()),
|
||||||
|
|
||||||
|
-- Station 002 pumps
|
||||||
|
('STATION_002', 'PUMP_001', 25.0, 45.0, 0.5, 3.5, 4.0, 0.5, 50.0, 250.0, 3.0,
|
||||||
|
'system_admin', 'safety_engineer', NOW()),
|
||||||
|
|
||||||
|
('STATION_002', 'PUMP_002', 25.0, 45.0, 0.5, 3.5, 4.0, 0.5, 50.0, 250.0, 3.0,
|
||||||
|
'system_admin', 'safety_engineer', NOW()),
|
||||||
|
|
||||||
|
-- Station 003 pumps
|
||||||
|
('STATION_003', 'PUMP_001', 20.0, 50.0, 0.8, 3.8, 4.2, 0.6, 60.0, 300.0, 4.0,
|
||||||
|
'system_admin', 'safety_engineer', NOW()),
|
||||||
|
|
||||||
|
('STATION_003', 'PUMP_002', 20.0, 50.0, 0.8, 3.8, 4.2, 0.6, 60.0, 300.0, 4.0,
|
||||||
|
'system_admin', 'safety_engineer', NOW());
|
||||||
|
|
||||||
|
-- Insert sample pump plans (current and future intervals)
|
||||||
|
INSERT INTO pump_plans (
|
||||||
|
station_id, pump_id, interval_start, interval_end,
|
||||||
|
target_flow_m3h, target_power_kw, target_level_m, suggested_speed_hz,
|
||||||
|
optimization_run_id
|
||||||
|
) VALUES
|
||||||
|
-- Current plans for all pumps
|
||||||
|
('STATION_001', 'PUMP_001', NOW() - INTERVAL '5 minutes', NOW() + INTERVAL '55 minutes', 320.5, 65.2, 2.5, 42.3, 1001),
|
||||||
|
('STATION_001', 'PUMP_002', NOW() - INTERVAL '5 minutes', NOW() + INTERVAL '55 minutes', 315.8, 63.8, 2.5, 41.7, 1001),
|
||||||
|
('STATION_002', 'PUMP_001', NOW() - INTERVAL '5 minutes', NOW() + INTERVAL '55 minutes', 180.2, 32.5, 1.8, 35.2, 1001),
|
||||||
|
('STATION_002', 'PUMP_002', NOW() - INTERVAL '5 minutes', NOW() + INTERVAL '55 minutes', 175.8, 31.8, 1.8, 34.8, 1001),
|
||||||
|
('STATION_003', 'PUMP_001', NOW() - INTERVAL '5 minutes', NOW() + INTERVAL '55 minutes', 245.6, 48.3, 2.2, 38.5, 1001),
|
||||||
|
('STATION_003', 'PUMP_002', NOW() - INTERVAL '5 minutes', NOW() + INTERVAL '55 minutes', 242.1, 47.6, 2.2, 38.1, 1001),
|
||||||
|
|
||||||
|
-- Future plans
|
||||||
|
('STATION_001', 'PUMP_001', NOW() + INTERVAL '1 hour', NOW() + INTERVAL '2 hours', 335.2, 68.1, 2.6, 43.5, 1001),
|
||||||
|
('STATION_002', 'PUMP_001', NOW() + INTERVAL '1 hour', NOW() + INTERVAL '2 hours', 185.6, 33.8, 1.9, 36.2, 1001),
|
||||||
|
('STATION_003', 'PUMP_001', NOW() + INTERVAL '1 hour', NOW() + INTERVAL '2 hours', 252.3, 49.8, 2.3, 39.2, 1001);
|
||||||
|
|
||||||
|
-- Insert sample feedback data
|
||||||
|
INSERT INTO pump_feedback (
|
||||||
|
station_id, pump_id, timestamp,
|
||||||
|
actual_speed_hz, actual_power_kw, actual_flow_m3h, wet_well_level_m, pump_running,
|
||||||
|
alarm_active, alarm_code
|
||||||
|
) VALUES
|
||||||
|
-- Recent feedback for all pumps
|
||||||
|
('STATION_001', 'PUMP_001', NOW() - INTERVAL '2 minutes', 42.1, 64.8, 318.2, 2.48, TRUE, FALSE, NULL),
|
||||||
|
('STATION_001', 'PUMP_002', NOW() - INTERVAL '2 minutes', 41.5, 63.2, 312.5, 2.51, TRUE, FALSE, NULL),
|
||||||
|
('STATION_002', 'PUMP_001', NOW() - INTERVAL '2 minutes', 35.0, 32.1, 178.6, 1.79, TRUE, FALSE, NULL),
|
||||||
|
('STATION_002', 'PUMP_002', NOW() - INTERVAL '2 minutes', 34.6, 31.4, 173.2, 1.82, TRUE, FALSE, NULL),
|
||||||
|
('STATION_003', 'PUMP_001', NOW() - INTERVAL '2 minutes', 38.3, 47.9, 242.8, 2.21, TRUE, FALSE, NULL),
|
||||||
|
('STATION_003', 'PUMP_002', NOW() - INTERVAL '2 minutes', 37.9, 47.2, 239.5, 2.19, TRUE, FALSE, NULL),
|
||||||
|
|
||||||
|
-- Historical feedback (for testing trends)
|
||||||
|
('STATION_001', 'PUMP_001', NOW() - INTERVAL '1 hour', 40.2, 62.1, 305.8, 2.45, TRUE, FALSE, NULL),
|
||||||
|
('STATION_002', 'PUMP_001', NOW() - INTERVAL '1 hour', 33.5, 30.2, 170.1, 1.75, TRUE, FALSE, NULL),
|
||||||
|
('STATION_003', 'PUMP_001', NOW() - INTERVAL '1 hour', 37.2, 45.8, 235.6, 2.15, TRUE, FALSE, NULL);
|
||||||
|
|
||||||
|
-- Insert sample audit log entries
|
||||||
|
INSERT INTO audit_log (
|
||||||
|
event_type, severity, station_id, pump_id, user_id, ip_address,
|
||||||
|
protocol, action, resource, result, event_data
|
||||||
|
) VALUES
|
||||||
|
('SYSTEM_STARTUP', 'INFO', NULL, NULL, 'system', '127.0.0.1',
|
||||||
|
'INTERNAL', 'startup', 'system', 'SUCCESS',
|
||||||
|
'{"version": "2.0.0", "components_initialized": ["database", "safety", "protocols"]}'::JSONB),
|
||||||
|
|
||||||
|
('SAFETY_LIMITS_LOADED', 'INFO', NULL, NULL, 'system', '127.0.0.1',
|
||||||
|
'INTERNAL', 'load_safety_limits', 'safety_framework', 'SUCCESS',
|
||||||
|
'{"pump_count": 6, "limits_loaded": 6}'::JSONB),
|
||||||
|
|
||||||
|
('AUTO_DISCOVERY', 'INFO', NULL, NULL, 'system', '127.0.0.1',
|
||||||
|
'INTERNAL', 'discover_pumps', 'auto_discovery', 'SUCCESS',
|
||||||
|
'{"stations_discovered": 3, "pumps_discovered": 6}'::JSONB),
|
||||||
|
|
||||||
|
('SETPOINT_UPDATE', 'INFO', 'STATION_001', 'PUMP_001', 'optimization_engine', '192.168.1.100',
|
||||||
|
'REST_API', 'update_setpoint', 'pump_control', 'SUCCESS',
|
||||||
|
'{"requested_setpoint": 42.3, "enforced_setpoint": 42.3, "violations": []}'::JSONB);
|
||||||
|
|
||||||
|
-- Verify data insertion
|
||||||
|
SELECT 'Pump Stations:' as "Verification", COUNT(*) as count FROM pump_stations
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'Pumps:', COUNT(*) FROM pumps
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'Safety Limits:', COUNT(*) FROM pump_safety_limits
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'Pump Plans:', COUNT(*) FROM pump_plans
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'Feedback Records:', COUNT(*) FROM pump_feedback
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'Audit Log Entries:', COUNT(*) FROM audit_log;
|
||||||
|
|
@ -4,8 +4,10 @@ Auto-Discovery Module for Calejo Control Adapter.
|
||||||
Automatically discovers pump stations and pumps from database on startup.
|
Automatically discovers pump stations and pumps from database on startup.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Dict, List
|
from typing import Dict, List, Optional
|
||||||
|
import asyncio
|
||||||
import structlog
|
import structlog
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from src.database.client import DatabaseClient
|
from src.database.client import DatabaseClient
|
||||||
|
|
||||||
|
|
@ -15,14 +17,28 @@ logger = structlog.get_logger()
|
||||||
class AutoDiscovery:
|
class AutoDiscovery:
|
||||||
"""Auto-discovery module for pump stations and pumps."""
|
"""Auto-discovery module for pump stations and pumps."""
|
||||||
|
|
||||||
def __init__(self, db_client: DatabaseClient):
|
def __init__(self, db_client: DatabaseClient, refresh_interval_minutes: int = 60):
|
||||||
self.db_client = db_client
|
self.db_client = db_client
|
||||||
|
self.refresh_interval_minutes = refresh_interval_minutes
|
||||||
self.pump_stations: Dict[str, Dict] = {}
|
self.pump_stations: Dict[str, Dict] = {}
|
||||||
self.pumps: Dict[str, List[Dict]] = {}
|
self.pumps: Dict[str, List[Dict]] = {}
|
||||||
|
self.last_discovery: Optional[datetime] = None
|
||||||
|
self.discovery_running = False
|
||||||
|
|
||||||
async def discover(self):
|
async def discover(self):
|
||||||
"""Discover all pump stations and pumps from database."""
|
"""Discover all pump stations and pumps from database."""
|
||||||
|
if self.discovery_running:
|
||||||
|
logger.warning("auto_discovery_already_running")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.discovery_running = True
|
||||||
try:
|
try:
|
||||||
|
logger.info("auto_discovery_started")
|
||||||
|
|
||||||
|
# Clear previous discovery
|
||||||
|
self.pump_stations.clear()
|
||||||
|
self.pumps.clear()
|
||||||
|
|
||||||
# Discover pump stations
|
# Discover pump stations
|
||||||
stations = self.db_client.get_pump_stations()
|
stations = self.db_client.get_pump_stations()
|
||||||
for station in stations:
|
for station in stations:
|
||||||
|
|
@ -36,10 +52,13 @@ class AutoDiscovery:
|
||||||
self.pumps[station_id] = []
|
self.pumps[station_id] = []
|
||||||
self.pumps[station_id].append(pump)
|
self.pumps[station_id].append(pump)
|
||||||
|
|
||||||
|
self.last_discovery = datetime.now()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"auto_discovery_completed",
|
"auto_discovery_completed",
|
||||||
station_count=len(self.pump_stations),
|
station_count=len(self.pump_stations),
|
||||||
pump_count=len(pumps)
|
pump_count=len(pumps),
|
||||||
|
last_discovery=self.last_discovery.isoformat()
|
||||||
)
|
)
|
||||||
|
|
||||||
# Log discovered stations and pumps
|
# Log discovered stations and pumps
|
||||||
|
|
@ -58,31 +77,99 @@ class AutoDiscovery:
|
||||||
station_id=station_id,
|
station_id=station_id,
|
||||||
pump_id=pump['pump_id'],
|
pump_id=pump['pump_id'],
|
||||||
pump_name=pump['pump_name'],
|
pump_name=pump['pump_name'],
|
||||||
control_type=pump['control_type']
|
control_type=pump['control_type'],
|
||||||
|
default_setpoint=pump['default_setpoint_hz']
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("auto_discovery_failed", error=str(e))
|
logger.error("auto_discovery_failed", error=str(e))
|
||||||
raise
|
raise
|
||||||
|
finally:
|
||||||
|
self.discovery_running = False
|
||||||
|
|
||||||
|
async def start_periodic_discovery(self):
|
||||||
|
"""Start periodic auto-discovery in background."""
|
||||||
|
logger.info(
|
||||||
|
"periodic_discovery_started",
|
||||||
|
refresh_interval_minutes=self.refresh_interval_minutes
|
||||||
|
)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(self.refresh_interval_minutes * 60) # Convert to seconds
|
||||||
|
try:
|
||||||
|
await self.discover()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("periodic_discovery_failed", error=str(e))
|
||||||
|
|
||||||
def get_stations(self) -> Dict[str, Dict]:
|
def get_stations(self) -> Dict[str, Dict]:
|
||||||
"""Get all discovered pump stations."""
|
"""Get all discovered pump stations."""
|
||||||
return self.pump_stations
|
return self.pump_stations.copy()
|
||||||
|
|
||||||
def get_pumps(self, station_id: str = None) -> List[Dict]:
|
def get_pumps(self, station_id: Optional[str] = None) -> List[Dict]:
|
||||||
"""Get all discovered pumps, optionally filtered by station."""
|
"""Get all discovered pumps, optionally filtered by station."""
|
||||||
if station_id:
|
if station_id:
|
||||||
return self.pumps.get(station_id, [])
|
return self.pumps.get(station_id, []).copy()
|
||||||
else:
|
else:
|
||||||
all_pumps = []
|
all_pumps = []
|
||||||
for station_pumps in self.pumps.values():
|
for station_pumps in self.pumps.values():
|
||||||
all_pumps.extend(station_pumps)
|
all_pumps.extend(station_pumps)
|
||||||
return all_pumps
|
return all_pumps
|
||||||
|
|
||||||
def get_pump(self, station_id: str, pump_id: str) -> Dict:
|
def get_pump(self, station_id: str, pump_id: str) -> Optional[Dict]:
|
||||||
"""Get a specific pump by station and pump ID."""
|
"""Get a specific pump by station and pump ID."""
|
||||||
station_pumps = self.pumps.get(station_id, [])
|
station_pumps = self.pumps.get(station_id, [])
|
||||||
for pump in station_pumps:
|
for pump in station_pumps:
|
||||||
if pump['pump_id'] == pump_id:
|
if pump['pump_id'] == pump_id:
|
||||||
return pump
|
return pump.copy()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_station(self, station_id: str) -> Optional[Dict]:
|
||||||
|
"""Get a specific station by ID."""
|
||||||
|
return self.pump_stations.get(station_id)
|
||||||
|
|
||||||
|
def get_discovery_status(self) -> Dict[str, any]:
|
||||||
|
"""Get auto-discovery status information."""
|
||||||
|
return {
|
||||||
|
"last_discovery": self.last_discovery.isoformat() if self.last_discovery else None,
|
||||||
|
"station_count": len(self.pump_stations),
|
||||||
|
"pump_count": sum(len(pumps) for pumps in self.pumps.values()),
|
||||||
|
"refresh_interval_minutes": self.refresh_interval_minutes,
|
||||||
|
"discovery_running": self.discovery_running
|
||||||
|
}
|
||||||
|
|
||||||
|
def is_stale(self, max_age_minutes: int = 120) -> bool:
|
||||||
|
"""Check if discovery data is stale."""
|
||||||
|
if not self.last_discovery:
|
||||||
|
return True
|
||||||
|
|
||||||
|
age = datetime.now() - self.last_discovery
|
||||||
|
return age > timedelta(minutes=max_age_minutes)
|
||||||
|
|
||||||
|
def validate_discovery(self) -> Dict[str, any]:
|
||||||
|
"""Validate discovered data for consistency."""
|
||||||
|
issues = []
|
||||||
|
|
||||||
|
# Check if all pumps have corresponding stations
|
||||||
|
for station_id, pumps in self.pumps.items():
|
||||||
|
if station_id not in self.pump_stations:
|
||||||
|
issues.append(f"Pumps found for unknown station: {station_id}")
|
||||||
|
|
||||||
|
# Check if all stations have pumps
|
||||||
|
for station_id in self.pump_stations:
|
||||||
|
if station_id not in self.pumps or not self.pumps[station_id]:
|
||||||
|
issues.append(f"No pumps found for station: {station_id}")
|
||||||
|
|
||||||
|
# Check pump data completeness
|
||||||
|
for station_id, pumps in self.pumps.items():
|
||||||
|
for pump in pumps:
|
||||||
|
if not pump.get('control_type'):
|
||||||
|
issues.append(f"Pump {station_id}/{pump['pump_id']} missing control_type")
|
||||||
|
if not pump.get('default_setpoint_hz'):
|
||||||
|
issues.append(f"Pump {station_id}/{pump['pump_id']} missing default_setpoint_hz")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"valid": len(issues) == 0,
|
||||||
|
"issues": issues,
|
||||||
|
"station_count": len(self.pump_stations),
|
||||||
|
"pump_count": sum(len(pumps) for pumps in self.pumps.values())
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
"""
|
||||||
|
Structured logging setup for Calejo Control Adapter.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
|
from config.settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging():
|
||||||
|
"""Setup structured logging with JSON formatting."""
|
||||||
|
|
||||||
|
# Configure standard logging
|
||||||
|
logging.basicConfig(
|
||||||
|
format="%(message)s",
|
||||||
|
stream=sys.stdout,
|
||||||
|
level=getattr(logging, settings.log_level)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configure structlog
|
||||||
|
processors = [
|
||||||
|
structlog.stdlib.filter_by_level,
|
||||||
|
structlog.stdlib.add_logger_name,
|
||||||
|
structlog.stdlib.add_log_level,
|
||||||
|
structlog.stdlib.PositionalArgumentsFormatter(),
|
||||||
|
structlog.processors.TimeStamper(fmt="iso"),
|
||||||
|
structlog.processors.StackInfoRenderer(),
|
||||||
|
structlog.processors.format_exc_info,
|
||||||
|
add_correlation_id,
|
||||||
|
add_application_info,
|
||||||
|
]
|
||||||
|
|
||||||
|
if settings.log_format == "json":
|
||||||
|
processors.append(structlog.processors.JSONRenderer())
|
||||||
|
else:
|
||||||
|
processors.append(structlog.dev.ConsoleRenderer())
|
||||||
|
|
||||||
|
structlog.configure(
|
||||||
|
processors=processors,
|
||||||
|
wrapper_class=structlog.stdlib.BoundLogger,
|
||||||
|
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||||
|
cache_logger_on_first_use=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = structlog.get_logger()
|
||||||
|
logger.info(
|
||||||
|
"logging_configured",
|
||||||
|
log_level=settings.log_level,
|
||||||
|
log_format=settings.log_format,
|
||||||
|
environment=settings.environment
|
||||||
|
)
|
||||||
|
|
||||||
|
return logger
|
||||||
|
|
||||||
|
|
||||||
|
def add_correlation_id(_, __, event_dict: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Add correlation ID to log entries for request tracing."""
|
||||||
|
if 'correlation_id' not in event_dict:
|
||||||
|
event_dict['correlation_id'] = str(uuid.uuid4())
|
||||||
|
return event_dict
|
||||||
|
|
||||||
|
|
||||||
|
def add_application_info(_, __, event_dict: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Add application information to log entries."""
|
||||||
|
event_dict['app_name'] = settings.app_name
|
||||||
|
event_dict['app_version'] = settings.app_version
|
||||||
|
event_dict['environment'] = settings.environment
|
||||||
|
return event_dict
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLogger:
|
||||||
|
"""Audit logger for compliance requirements."""
|
||||||
|
|
||||||
|
def __init__(self, db_client):
|
||||||
|
self.db_client = db_client
|
||||||
|
self.logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
def log(
|
||||||
|
self,
|
||||||
|
event_type: str,
|
||||||
|
severity: str,
|
||||||
|
station_id: Optional[str] = None,
|
||||||
|
pump_id: Optional[str] = None,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
ip_address: Optional[str] = None,
|
||||||
|
protocol: Optional[str] = None,
|
||||||
|
action: Optional[str] = None,
|
||||||
|
resource: Optional[str] = None,
|
||||||
|
result: Optional[str] = None,
|
||||||
|
event_data: Optional[Dict[str, Any]] = None
|
||||||
|
):
|
||||||
|
"""Log an audit event to both structured logs and database."""
|
||||||
|
|
||||||
|
# Log to structured logs
|
||||||
|
self.logger.info(
|
||||||
|
"audit_event",
|
||||||
|
event_type=event_type,
|
||||||
|
severity=severity,
|
||||||
|
station_id=station_id,
|
||||||
|
pump_id=pump_id,
|
||||||
|
user_id=user_id,
|
||||||
|
ip_address=ip_address,
|
||||||
|
protocol=protocol,
|
||||||
|
action=action,
|
||||||
|
resource=resource,
|
||||||
|
result=result,
|
||||||
|
event_data=event_data
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log to database if audit logging is enabled
|
||||||
|
if settings.audit_log_enabled:
|
||||||
|
try:
|
||||||
|
query = """
|
||||||
|
INSERT INTO audit_log
|
||||||
|
(event_type, severity, station_id, pump_id, user_id, ip_address,
|
||||||
|
protocol, action, resource, result, event_data)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
"""
|
||||||
|
self.db_client.execute(
|
||||||
|
query,
|
||||||
|
(
|
||||||
|
event_type, severity, station_id, pump_id, user_id, ip_address,
|
||||||
|
protocol, action, resource, result, event_data
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error("audit_log_database_failed", error=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# Global logger instance
|
||||||
|
logger = setup_logging()
|
||||||
|
|
@ -5,46 +5,86 @@ Database client for Calejo Control Adapter.
|
||||||
import asyncio
|
import asyncio
|
||||||
import psycopg2
|
import psycopg2
|
||||||
import psycopg2.extras
|
import psycopg2.extras
|
||||||
from typing import List, Dict, Any, Optional
|
import psycopg2.pool
|
||||||
|
from typing import List, Dict, Any, Optional, Tuple
|
||||||
import structlog
|
import structlog
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
logger = structlog.get_logger()
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
|
|
||||||
class DatabaseClient:
|
class DatabaseClient:
|
||||||
"""Database client for PostgreSQL operations."""
|
"""Database client for PostgreSQL operations with connection pooling."""
|
||||||
|
|
||||||
def __init__(self, database_url: str):
|
def __init__(self, database_url: str, min_connections: int = 2, max_connections: int = 10):
|
||||||
self.database_url = database_url
|
self.database_url = database_url
|
||||||
self.connection = None
|
self.min_connections = min_connections
|
||||||
self.cursor = None
|
self.max_connections = max_connections
|
||||||
|
self.connection_pool = None
|
||||||
|
|
||||||
async def connect(self):
|
async def connect(self):
|
||||||
"""Connect to the PostgreSQL database."""
|
"""Connect to the PostgreSQL database and initialize connection pool."""
|
||||||
try:
|
try:
|
||||||
self.connection = psycopg2.connect(
|
# Create connection pool
|
||||||
self.database_url,
|
self.connection_pool = psycopg2.pool.ThreadedConnectionPool(
|
||||||
|
minconn=self.min_connections,
|
||||||
|
maxconn=self.max_connections,
|
||||||
|
dsn=self.database_url,
|
||||||
cursor_factory=psycopg2.extras.RealDictCursor
|
cursor_factory=psycopg2.extras.RealDictCursor
|
||||||
)
|
)
|
||||||
self.cursor = self.connection.cursor()
|
|
||||||
logger.info("database_connected")
|
# Test connection
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
cursor.execute("SELECT version();")
|
||||||
|
version = cursor.fetchone()
|
||||||
|
logger.info("database_connected", version=version['version'])
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("database_connection_failed", error=str(e))
|
logger.error("database_connection_failed", error=str(e))
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def disconnect(self):
|
async def disconnect(self):
|
||||||
"""Disconnect from the database."""
|
"""Disconnect from the database and close connection pool."""
|
||||||
if self.cursor:
|
if self.connection_pool:
|
||||||
self.cursor.close()
|
self.connection_pool.closeall()
|
||||||
if self.connection:
|
|
||||||
self.connection.close()
|
|
||||||
logger.info("database_disconnected")
|
logger.info("database_disconnected")
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _get_connection(self):
|
||||||
|
"""Get a connection from the pool with context management."""
|
||||||
|
if not self.connection_pool:
|
||||||
|
raise RuntimeError("Database connection pool not initialized")
|
||||||
|
|
||||||
|
conn = self.connection_pool.getconn()
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
self.connection_pool.putconn(conn)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _get_cursor(self):
|
||||||
|
"""Get a cursor from a connection with context management."""
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
try:
|
||||||
|
yield cursor
|
||||||
|
conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
cursor.close()
|
||||||
|
|
||||||
def execute_query(self, query: str, params: tuple = None) -> List[Dict[str, Any]]:
|
def execute_query(self, query: str, params: tuple = None) -> List[Dict[str, Any]]:
|
||||||
"""Execute a SELECT query and return results."""
|
"""Execute a SELECT query and return results."""
|
||||||
try:
|
try:
|
||||||
self.cursor.execute(query, params)
|
with self._get_cursor() as cursor:
|
||||||
return self.cursor.fetchall()
|
cursor.execute(query, params)
|
||||||
|
return cursor.fetchall()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("query_execution_failed", query=query, error=str(e))
|
logger.error("query_execution_failed", query=query, error=str(e))
|
||||||
raise
|
raise
|
||||||
|
|
@ -52,10 +92,9 @@ class DatabaseClient:
|
||||||
def execute(self, query: str, params: tuple = None) -> None:
|
def execute(self, query: str, params: tuple = None) -> None:
|
||||||
"""Execute an INSERT/UPDATE/DELETE query."""
|
"""Execute an INSERT/UPDATE/DELETE query."""
|
||||||
try:
|
try:
|
||||||
self.cursor.execute(query, params)
|
with self._get_cursor() as cursor:
|
||||||
self.connection.commit()
|
cursor.execute(query, params)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.connection.rollback()
|
|
||||||
logger.error("query_execution_failed", query=query, error=str(e))
|
logger.error("query_execution_failed", query=query, error=str(e))
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
@ -126,3 +165,25 @@ class DatabaseClient:
|
||||||
LIMIT %s
|
LIMIT %s
|
||||||
"""
|
"""
|
||||||
return self.execute_query(query, (station_id, pump_id, limit))
|
return self.execute_query(query, (station_id, pump_id, limit))
|
||||||
|
|
||||||
|
def health_check(self) -> bool:
|
||||||
|
"""Check if database is healthy and responsive."""
|
||||||
|
try:
|
||||||
|
with self._get_cursor() as cursor:
|
||||||
|
cursor.execute("SELECT 1 as health_check;")
|
||||||
|
result = cursor.fetchone()
|
||||||
|
return result['health_check'] == 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("database_health_check_failed", error=str(e))
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_connection_stats(self) -> Dict[str, Any]:
|
||||||
|
"""Get connection pool statistics."""
|
||||||
|
if not self.connection_pool:
|
||||||
|
return {"status": "pool_not_initialized"}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"min_connections": self.min_connections,
|
||||||
|
"max_connections": self.max_connections,
|
||||||
|
"pool_status": "active"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,207 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Calejo Control Adapter - Phase 1 Implementation
|
||||||
|
|
||||||
|
Core infrastructure implementation including:
|
||||||
|
- Database setup and connection pooling
|
||||||
|
- Auto-discovery of pump stations and pumps
|
||||||
|
- Configuration management
|
||||||
|
- Structured logging and audit system
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from src.database.client import DatabaseClient
|
||||||
|
from src.core.auto_discovery import AutoDiscovery
|
||||||
|
from src.core.safety import SafetyLimitEnforcer
|
||||||
|
from src.core.logging import setup_logging, AuditLogger
|
||||||
|
from config.settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
class CalejoControlAdapterPhase1:
|
||||||
|
"""Main application class for Phase 1 implementation."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# Setup logging first
|
||||||
|
self.logger = setup_logging()
|
||||||
|
|
||||||
|
# Initialize core components
|
||||||
|
self.db_client = DatabaseClient(
|
||||||
|
database_url=settings.database_url,
|
||||||
|
min_connections=settings.db_min_connections,
|
||||||
|
max_connections=settings.db_max_connections
|
||||||
|
)
|
||||||
|
self.auto_discovery = AutoDiscovery(
|
||||||
|
db_client=self.db_client,
|
||||||
|
refresh_interval_minutes=settings.auto_discovery_refresh_minutes
|
||||||
|
)
|
||||||
|
self.safety_enforcer = SafetyLimitEnforcer(self.db_client)
|
||||||
|
self.audit_logger = AuditLogger(self.db_client)
|
||||||
|
|
||||||
|
self.running = False
|
||||||
|
self.startup_time = None
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
"""Start the Phase 1 application."""
|
||||||
|
self.startup_time = asyncio.get_event_loop().time()
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
"phase1_application_starting",
|
||||||
|
app_name=settings.app_name,
|
||||||
|
version=settings.app_version,
|
||||||
|
environment=settings.environment
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Connect to database
|
||||||
|
await self.db_client.connect()
|
||||||
|
|
||||||
|
# Initialize auto-discovery
|
||||||
|
if settings.auto_discovery_enabled:
|
||||||
|
await self.auto_discovery.discover()
|
||||||
|
|
||||||
|
# Validate discovery
|
||||||
|
validation = self.auto_discovery.validate_discovery()
|
||||||
|
if not validation['valid']:
|
||||||
|
self.logger.warning(
|
||||||
|
"auto_discovery_validation_issues",
|
||||||
|
issues=validation['issues']
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start periodic discovery
|
||||||
|
asyncio.create_task(self.auto_discovery.start_periodic_discovery())
|
||||||
|
|
||||||
|
# Initialize safety framework
|
||||||
|
await self.safety_enforcer.load_safety_limits()
|
||||||
|
|
||||||
|
# Log startup to audit trail
|
||||||
|
self.audit_logger.log(
|
||||||
|
event_type='SYSTEM_STARTUP',
|
||||||
|
severity='INFO',
|
||||||
|
user_id='system',
|
||||||
|
action='startup',
|
||||||
|
resource='system',
|
||||||
|
result='SUCCESS',
|
||||||
|
event_data={
|
||||||
|
'version': settings.app_version,
|
||||||
|
'phase': '1',
|
||||||
|
'components_initialized': ['database', 'auto_discovery', 'safety']
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.running = True
|
||||||
|
startup_duration = asyncio.get_event_loop().time() - self.startup_time
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
"phase1_application_started",
|
||||||
|
startup_duration_seconds=round(startup_duration, 2),
|
||||||
|
station_count=len(self.auto_discovery.get_stations()),
|
||||||
|
pump_count=len(self.auto_discovery.get_pumps()),
|
||||||
|
safety_limits_loaded=len(self.safety_enforcer.safety_limits_cache)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Print status information
|
||||||
|
self.print_status()
|
||||||
|
|
||||||
|
# Keep application running
|
||||||
|
while self.running:
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error("phase1_application_startup_failed", error=str(e))
|
||||||
|
await self.stop()
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
"""Stop the application."""
|
||||||
|
self.logger.info("phase1_application_stopping")
|
||||||
|
self.running = False
|
||||||
|
|
||||||
|
# Log shutdown to audit trail
|
||||||
|
self.audit_logger.log(
|
||||||
|
event_type='SYSTEM_SHUTDOWN',
|
||||||
|
severity='INFO',
|
||||||
|
user_id='system',
|
||||||
|
action='shutdown',
|
||||||
|
resource='system',
|
||||||
|
result='SUCCESS'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Disconnect from database
|
||||||
|
await self.db_client.disconnect()
|
||||||
|
|
||||||
|
self.logger.info("phase1_application_stopped")
|
||||||
|
|
||||||
|
def get_status(self) -> dict:
|
||||||
|
"""Get application status information."""
|
||||||
|
return {
|
||||||
|
"running": self.running,
|
||||||
|
"app_name": settings.app_name,
|
||||||
|
"version": settings.app_version,
|
||||||
|
"environment": settings.environment,
|
||||||
|
"database": self.db_client.get_connection_stats(),
|
||||||
|
"auto_discovery": self.auto_discovery.get_discovery_status(),
|
||||||
|
"safety_limits_loaded": len(self.safety_enforcer.safety_limits_cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
def print_status(self):
|
||||||
|
"""Print human-readable status information."""
|
||||||
|
status = self.get_status()
|
||||||
|
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print(f"Calejo Control Adapter - Phase 1 Status")
|
||||||
|
print("="*60)
|
||||||
|
print(f"Application: {status['app_name']} v{status['version']}")
|
||||||
|
print(f"Environment: {status['environment']}")
|
||||||
|
print(f"Status: {'RUNNING' if status['running'] else 'STOPPED'}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Database status
|
||||||
|
db_stats = status['database']
|
||||||
|
print(f"Database:")
|
||||||
|
print(f" Status: {db_stats.get('pool_status', 'N/A')}")
|
||||||
|
print(f" Connections: {db_stats.get('min_connections', 'N/A')}-{db_stats.get('max_connections', 'N/A')}")
|
||||||
|
|
||||||
|
# Auto-discovery status
|
||||||
|
discovery_status = status['auto_discovery']
|
||||||
|
print(f"\nAuto-Discovery:")
|
||||||
|
print(f" Stations: {discovery_status['station_count']}")
|
||||||
|
print(f" Pumps: {discovery_status['pump_count']}")
|
||||||
|
print(f" Last Discovery: {discovery_status['last_discovery'] or 'Never'}")
|
||||||
|
|
||||||
|
# Safety framework status
|
||||||
|
print(f"\nSafety Framework:")
|
||||||
|
print(f" Limits Loaded: {status['safety_limits_loaded']}")
|
||||||
|
|
||||||
|
print("="*60)
|
||||||
|
print("\nPress Ctrl+C to stop the application\n")
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Main application entry point for Phase 1."""
|
||||||
|
|
||||||
|
app = CalejoControlAdapterPhase1()
|
||||||
|
|
||||||
|
# Setup signal handlers for graceful shutdown
|
||||||
|
def signal_handler(signum, frame):
|
||||||
|
logger = app.logger
|
||||||
|
logger.info("signal_received", signal=signum)
|
||||||
|
asyncio.create_task(app.stop())
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, signal_handler)
|
||||||
|
signal.signal(signal.SIGTERM, signal_handler)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await app.start()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
await app.stop()
|
||||||
|
except Exception as e:
|
||||||
|
logger = app.logger
|
||||||
|
logger.error("phase1_application_failed", error=str(e))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
|
|
@ -0,0 +1,261 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test script for Phase 1 implementation.
|
||||||
|
|
||||||
|
Tests core infrastructure components:
|
||||||
|
- Database connectivity and queries
|
||||||
|
- Auto-discovery functionality
|
||||||
|
- Configuration management
|
||||||
|
- Safety framework loading
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Add src to path
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||||
|
|
||||||
|
from src.database.client import DatabaseClient
|
||||||
|
from src.core.auto_discovery import AutoDiscovery
|
||||||
|
from src.core.safety import SafetyLimitEnforcer
|
||||||
|
from src.core.logging import setup_logging
|
||||||
|
from config.settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
class Phase1Tester:
|
||||||
|
"""Test class for Phase 1 components."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.logger = setup_logging()
|
||||||
|
self.db_client = DatabaseClient(
|
||||||
|
database_url=settings.database_url,
|
||||||
|
min_connections=settings.db_min_connections,
|
||||||
|
max_connections=settings.db_max_connections
|
||||||
|
)
|
||||||
|
self.auto_discovery = AutoDiscovery(self.db_client)
|
||||||
|
self.safety_enforcer = SafetyLimitEnforcer(self.db_client)
|
||||||
|
|
||||||
|
self.tests_passed = 0
|
||||||
|
self.tests_failed = 0
|
||||||
|
|
||||||
|
async def run_all_tests(self):
|
||||||
|
"""Run all Phase 1 tests."""
|
||||||
|
self.logger.info("starting_phase1_tests")
|
||||||
|
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("Calejo Control Adapter - Phase 1 Test Suite")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Test 1: Database connection
|
||||||
|
await self.test_database_connection()
|
||||||
|
|
||||||
|
# Test 2: Database queries
|
||||||
|
await self.test_database_queries()
|
||||||
|
|
||||||
|
# Test 3: Auto-discovery
|
||||||
|
await self.test_auto_discovery()
|
||||||
|
|
||||||
|
# Test 4: Safety framework
|
||||||
|
await self.test_safety_framework()
|
||||||
|
|
||||||
|
# Test 5: Configuration
|
||||||
|
await self.test_configuration()
|
||||||
|
|
||||||
|
# Print summary
|
||||||
|
self.print_test_summary()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error("test_suite_failed", error=str(e))
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await self.db_client.disconnect()
|
||||||
|
|
||||||
|
async def test_database_connection(self):
|
||||||
|
"""Test database connectivity."""
|
||||||
|
print("\n1. Testing Database Connection...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.db_client.connect()
|
||||||
|
|
||||||
|
# Test health check
|
||||||
|
is_healthy = self.db_client.health_check()
|
||||||
|
if is_healthy:
|
||||||
|
print(" ✓ Database connection successful")
|
||||||
|
self.tests_passed += 1
|
||||||
|
else:
|
||||||
|
print(" ✗ Database health check failed")
|
||||||
|
self.tests_failed += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Database connection failed: {e}")
|
||||||
|
self.tests_failed += 1
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def test_database_queries(self):
|
||||||
|
"""Test database queries."""
|
||||||
|
print("\n2. Testing Database Queries...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Test pump stations query
|
||||||
|
stations = self.db_client.get_pump_stations()
|
||||||
|
print(f" ✓ Found {len(stations)} pump stations")
|
||||||
|
|
||||||
|
# Test pumps query
|
||||||
|
pumps = self.db_client.get_pumps()
|
||||||
|
print(f" ✓ Found {len(pumps)} pumps")
|
||||||
|
|
||||||
|
# Test safety limits query
|
||||||
|
safety_limits = self.db_client.get_safety_limits()
|
||||||
|
print(f" ✓ Found {len(safety_limits)} safety limits")
|
||||||
|
|
||||||
|
# Test pump plans query
|
||||||
|
pump_plans = self.db_client.get_latest_pump_plans()
|
||||||
|
print(f" ✓ Found {len(pump_plans)} active pump plans")
|
||||||
|
|
||||||
|
self.tests_passed += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Database queries failed: {e}")
|
||||||
|
self.tests_failed += 1
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def test_auto_discovery(self):
|
||||||
|
"""Test auto-discovery functionality."""
|
||||||
|
print("\n3. Testing Auto-Discovery...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.auto_discovery.discover()
|
||||||
|
|
||||||
|
stations = self.auto_discovery.get_stations()
|
||||||
|
pumps = self.auto_discovery.get_pumps()
|
||||||
|
|
||||||
|
print(f" ✓ Discovered {len(stations)} stations")
|
||||||
|
print(f" ✓ Discovered {len(pumps)} pumps")
|
||||||
|
|
||||||
|
# Test individual station/pump retrieval
|
||||||
|
if stations:
|
||||||
|
station_id = list(stations.keys())[0]
|
||||||
|
station = self.auto_discovery.get_station(station_id)
|
||||||
|
if station:
|
||||||
|
print(f" ✓ Station retrieval successful: {station['station_name']}")
|
||||||
|
|
||||||
|
station_pumps = self.auto_discovery.get_pumps(station_id)
|
||||||
|
if station_pumps:
|
||||||
|
pump_id = station_pumps[0]['pump_id']
|
||||||
|
pump = self.auto_discovery.get_pump(station_id, pump_id)
|
||||||
|
if pump:
|
||||||
|
print(f" ✓ Pump retrieval successful: {pump['pump_name']}")
|
||||||
|
|
||||||
|
# Test validation
|
||||||
|
validation = self.auto_discovery.validate_discovery()
|
||||||
|
if validation['valid']:
|
||||||
|
print(" ✓ Auto-discovery validation passed")
|
||||||
|
else:
|
||||||
|
print(f" ⚠ Auto-discovery validation issues: {validation['issues']}")
|
||||||
|
|
||||||
|
self.tests_passed += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Auto-discovery failed: {e}")
|
||||||
|
self.tests_failed += 1
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def test_safety_framework(self):
|
||||||
|
"""Test safety framework loading."""
|
||||||
|
print("\n4. Testing Safety Framework...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.safety_enforcer.load_safety_limits()
|
||||||
|
|
||||||
|
limits_count = len(self.safety_enforcer.safety_limits_cache)
|
||||||
|
print(f" ✓ Loaded {limits_count} safety limits")
|
||||||
|
|
||||||
|
# Test setpoint enforcement
|
||||||
|
if limits_count > 0:
|
||||||
|
# Get first pump with safety limits
|
||||||
|
pumps = self.auto_discovery.get_pumps()
|
||||||
|
if pumps:
|
||||||
|
pump = pumps[0]
|
||||||
|
station_id = pump['station_id']
|
||||||
|
pump_id = pump['pump_id']
|
||||||
|
|
||||||
|
# Test within limits
|
||||||
|
enforced, violations = self.safety_enforcer.enforce_setpoint(
|
||||||
|
station_id, pump_id, 35.0
|
||||||
|
)
|
||||||
|
if enforced == 35.0 and not violations:
|
||||||
|
print(" ✓ Setpoint enforcement within limits successful")
|
||||||
|
|
||||||
|
# Test below minimum
|
||||||
|
enforced, violations = self.safety_enforcer.enforce_setpoint(
|
||||||
|
station_id, pump_id, 10.0
|
||||||
|
)
|
||||||
|
if enforced > 10.0 and violations:
|
||||||
|
print(" ✓ Setpoint enforcement below minimum successful")
|
||||||
|
|
||||||
|
self.tests_passed += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Safety framework failed: {e}")
|
||||||
|
self.tests_failed += 1
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def test_configuration(self):
|
||||||
|
"""Test configuration management."""
|
||||||
|
print("\n5. Testing Configuration Management...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Test database URL generation
|
||||||
|
db_url = settings.database_url
|
||||||
|
if db_url:
|
||||||
|
print(" ✓ Database URL generation successful")
|
||||||
|
|
||||||
|
# Test safe settings dict
|
||||||
|
safe_settings = settings.get_safe_dict()
|
||||||
|
if 'db_password' in safe_settings and safe_settings['db_password'] == '***MASKED***':
|
||||||
|
print(" ✓ Sensitive field masking successful")
|
||||||
|
|
||||||
|
# Test configuration validation
|
||||||
|
print(f" ✓ Configuration loaded: {settings.app_name} v{settings.app_version}")
|
||||||
|
print(f" ✓ Environment: {settings.environment}")
|
||||||
|
|
||||||
|
self.tests_passed += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Configuration test failed: {e}")
|
||||||
|
self.tests_failed += 1
|
||||||
|
raise
|
||||||
|
|
||||||
|
def print_test_summary(self):
|
||||||
|
"""Print test summary."""
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("TEST SUMMARY")
|
||||||
|
print("="*60)
|
||||||
|
print(f"Tests Passed: {self.tests_passed}")
|
||||||
|
print(f"Tests Failed: {self.tests_failed}")
|
||||||
|
|
||||||
|
total_tests = self.tests_passed + self.tests_failed
|
||||||
|
if total_tests > 0:
|
||||||
|
success_rate = (self.tests_passed / total_tests) * 100
|
||||||
|
print(f"Success Rate: {success_rate:.1f}%")
|
||||||
|
|
||||||
|
if self.tests_failed == 0:
|
||||||
|
print("\n🎉 All Phase 1 tests passed!")
|
||||||
|
print("Phase 1 implementation is ready for development.")
|
||||||
|
else:
|
||||||
|
print(f"\n⚠ {self.tests_failed} test(s) failed.")
|
||||||
|
print("Please review the failed tests before proceeding.")
|
||||||
|
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Run Phase 1 tests."""
|
||||||
|
tester = Phase1Tester()
|
||||||
|
await tester.run_all_tests()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
Loading…
Reference in New Issue