Initial
This commit is contained in:
53
.gitignore
vendored
Normal file
53
.gitignore
vendored
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Python bytecode
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
.hypothesis/
|
||||||
|
pytest_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
|
||||||
|
# IDE specific files
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Project specific
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
temp/
|
||||||
|
tmp/
|
||||||
|
|
||||||
|
# Local configuration
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite3
|
||||||
158
README.md
Normal file
158
README.md
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
# Watermaker PLC API
|
||||||
|
|
||||||
|
RESTful API for monitoring and controlling watermaker PLC systems. Provides access to sensors, timers, controls, and watermaker operation sequences.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Real-time Data Monitoring**: Sensors, timers, outputs, runtime hours, water counters
|
||||||
|
- **Selective Data Retrieval**: Bandwidth-optimized API for specific data groups/keys
|
||||||
|
- **Asynchronous Control Operations**: DTS start/stop/skip sequences with progress tracking
|
||||||
|
- **Background Data Updates**: Continuous PLC monitoring with configurable intervals
|
||||||
|
- **Thread-safe Data Caching**: Centralized cache with concurrent access support
|
||||||
|
- **Comprehensive Error Handling**: Structured error responses and logging
|
||||||
|
- **Modular Architecture**: Clean separation of concerns with service layers
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone repository
|
||||||
|
git clone <repository-url>
|
||||||
|
cd watermaker-plc-api
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Install package
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Set environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PLC_IP=192.168.1.15
|
||||||
|
export PLC_PORT=502
|
||||||
|
export DATA_UPDATE_INTERVAL=5
|
||||||
|
export LOG_LEVEL=INFO
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running the API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Using the command line entry point
|
||||||
|
watermaker-api --host 0.0.0.0 --port 5000
|
||||||
|
|
||||||
|
# Or run directly
|
||||||
|
python -m watermaker_plc_api.main
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Data Monitoring
|
||||||
|
- `GET /api/status` - Connection and system status
|
||||||
|
- `GET /api/all` - All PLC data in one response
|
||||||
|
- `GET /api/select` - Selective data retrieval (bandwidth optimized)
|
||||||
|
- `GET /api/sensors` - All sensor data
|
||||||
|
- `GET /api/timers` - All timer data
|
||||||
|
- `GET /api/outputs` - Output control data
|
||||||
|
- `GET /api/runtime` - Runtime hours data
|
||||||
|
- `GET /api/water_counters` - Water production counters
|
||||||
|
|
||||||
|
### Control Operations
|
||||||
|
- `POST /api/dts/start` - Start DTS watermaker sequence (async)
|
||||||
|
- `POST /api/dts/stop` - Stop watermaker sequence (async)
|
||||||
|
- `POST /api/dts/skip` - Skip current step (async)
|
||||||
|
- `GET /api/dts/status` - Get DTS operation status
|
||||||
|
- `POST /api/write/register` - Write single register
|
||||||
|
|
||||||
|
### Selective API Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get temperature and pressure sensors only
|
||||||
|
curl "http://localhost:5000/api/select?groups=temperature,pressure"
|
||||||
|
|
||||||
|
# Get specific registers
|
||||||
|
curl "http://localhost:5000/api/select?keys=1036,1003,1017,1121"
|
||||||
|
|
||||||
|
# Combined approach
|
||||||
|
curl "http://localhost:5000/api/select?groups=dts_timer&keys=1036"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Control Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start DTS sequence (returns immediately with task_id)
|
||||||
|
curl -X POST http://localhost:5000/api/dts/start
|
||||||
|
|
||||||
|
# Poll for progress
|
||||||
|
curl http://localhost:5000/api/dts/status/abc12345
|
||||||
|
|
||||||
|
# Stop watermaker
|
||||||
|
curl -X POST http://localhost:5000/api/dts/stop
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
watermaker_plc_api/
|
||||||
|
├── app.py # Flask application factory
|
||||||
|
├── config.py # Configuration management
|
||||||
|
├── controllers/ # API route handlers
|
||||||
|
├── services/ # Business logic layer
|
||||||
|
├── models/ # Data models and mappings
|
||||||
|
└── utils/ # Utilities and helpers
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Components
|
||||||
|
|
||||||
|
- **PLCConnection**: Thread-safe Modbus TCP communication
|
||||||
|
- **DataCache**: Centralized, thread-safe data storage
|
||||||
|
- **RegisterReader**: Service for reading PLC registers
|
||||||
|
- **RegisterWriter**: Service for writing PLC registers
|
||||||
|
- **BackgroundTaskManager**: Continuous data updates
|
||||||
|
- **Controllers**: RESTful API endpoints
|
||||||
|
|
||||||
|
## Variable Groups
|
||||||
|
|
||||||
|
| Group | Description | Count |
|
||||||
|
|-------|-------------|-------|
|
||||||
|
| system | System status and mode | 2 |
|
||||||
|
| pressure | Water pressure sensors | 3 |
|
||||||
|
| temperature | Temperature monitoring | 2 |
|
||||||
|
| flow | Flow rate meters | 3 |
|
||||||
|
| quality | Water quality (TDS) sensors | 2 |
|
||||||
|
| fwf_timer | Fresh water flush timers | 1 |
|
||||||
|
| dts_timer | DTS process step timers | 6 |
|
||||||
|
| rtc | Real-time clock data | 6 |
|
||||||
|
| outputs | Digital output controls | 6 |
|
||||||
|
| runtime | System runtime hours | 1 |
|
||||||
|
| water_counters | Water production counters | 4 |
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development environment
|
||||||
|
export FLASK_ENV=development
|
||||||
|
export DEBUG=true
|
||||||
|
export PLC_IP=127.0.0.1 # For simulator
|
||||||
|
|
||||||
|
# Production environment
|
||||||
|
export FLASK_ENV=production
|
||||||
|
export SECRET_KEY=your-secret-key
|
||||||
|
export PLC_IP=192.168.1.15
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License - see LICENSE file for details.
|
||||||
13
pytest.ini
Normal file
13
pytest.ini
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[tool:pytest]
|
||||||
|
testpaths = tests
|
||||||
|
python_files = test_*.py
|
||||||
|
python_classes = Test*
|
||||||
|
python_functions = test_*
|
||||||
|
addopts =
|
||||||
|
-v
|
||||||
|
--tb=short
|
||||||
|
--strict-markers
|
||||||
|
--disable-warnings
|
||||||
|
filterwarnings =
|
||||||
|
ignore::DeprecationWarning
|
||||||
|
ignore::PendingDeprecationWarning
|
||||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
Flask==2.3.3
|
||||||
|
Flask-CORS==4.0.0
|
||||||
|
pymodbus==3.4.1
|
||||||
85
run_server.py
Executable file
85
run_server.py
Executable file
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Standalone script to run the Watermaker PLC API server.
|
||||||
|
This can be used when the package installation has issues.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
# Add the package directory to Python path
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
from watermaker_plc_api.app import create_app
|
||||||
|
from watermaker_plc_api.config import Config
|
||||||
|
from watermaker_plc_api.utils.logger import get_logger
|
||||||
|
from watermaker_plc_api.services.background_tasks import start_background_updates
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
"""Parse command line arguments"""
|
||||||
|
parser = argparse.ArgumentParser(description='Watermaker PLC API Server')
|
||||||
|
parser.add_argument('--host', default='0.0.0.0',
|
||||||
|
help='Host to bind to (default: 0.0.0.0)')
|
||||||
|
parser.add_argument('--port', type=int, default=5000,
|
||||||
|
help='Port to bind to (default: 5000)')
|
||||||
|
parser.add_argument('--plc-ip', default='192.168.1.15',
|
||||||
|
help='PLC IP address (default: 192.168.1.15)')
|
||||||
|
parser.add_argument('--plc-port', type=int, default=502,
|
||||||
|
help='PLC Modbus port (default: 502)')
|
||||||
|
parser.add_argument('--debug', action='store_true',
|
||||||
|
help='Enable debug mode')
|
||||||
|
parser.add_argument('--no-background-updates', action='store_true',
|
||||||
|
help='Disable background data updates')
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main application entry point"""
|
||||||
|
args = parse_args()
|
||||||
|
|
||||||
|
# Update config with command line arguments
|
||||||
|
Config.PLC_IP = args.plc_ip
|
||||||
|
Config.PLC_PORT = args.plc_port
|
||||||
|
Config.DEBUG = args.debug
|
||||||
|
|
||||||
|
logger.info("Starting Watermaker PLC API Server (Standalone)")
|
||||||
|
logger.info(f"API Version: 1.1.0")
|
||||||
|
logger.info(f"PLC Target: {Config.PLC_IP}:{Config.PLC_PORT}")
|
||||||
|
logger.info(f"Server: http://{args.host}:{args.port}")
|
||||||
|
|
||||||
|
# Create Flask application
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
# Start background data updates (unless disabled)
|
||||||
|
if not args.no_background_updates:
|
||||||
|
start_background_updates()
|
||||||
|
logger.info("Background data update thread started")
|
||||||
|
else:
|
||||||
|
logger.warning("Background data updates disabled")
|
||||||
|
|
||||||
|
logger.info("🚀 Server starting...")
|
||||||
|
logger.info("📊 API Documentation: http://localhost:5000/api/config")
|
||||||
|
logger.info("❤️ Health Check: http://localhost:5000/api/status")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Start the Flask server
|
||||||
|
app.run(
|
||||||
|
host=args.host,
|
||||||
|
port=args.port,
|
||||||
|
debug=args.debug,
|
||||||
|
threaded=True
|
||||||
|
)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Server shutdown requested by user")
|
||||||
|
sys.exit(0)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to start server: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
37
setup.py
Normal file
37
setup.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from setuptools import setup, find_packages
|
||||||
|
|
||||||
|
with open("README.md", "r", encoding="utf-8") as fh:
|
||||||
|
long_description = fh.read()
|
||||||
|
|
||||||
|
with open("requirements.txt", "r", encoding="utf-8") as fh:
|
||||||
|
requirements = [line.strip() for line in fh if line.strip() and not line.startswith("#")]
|
||||||
|
|
||||||
|
setup(
|
||||||
|
name="watermaker-plc-api",
|
||||||
|
version="1.1.0",
|
||||||
|
author="Your Name",
|
||||||
|
author_email="paul@golownia.com",
|
||||||
|
description="RESTful API for Watermaker PLC monitoring and control",
|
||||||
|
long_description=long_description,
|
||||||
|
long_description_content_type="text/markdown",
|
||||||
|
url="https://github.com/terbonium/watermaker-plc-api.git",
|
||||||
|
packages=find_packages(),
|
||||||
|
classifiers=[
|
||||||
|
"Development Status :: 4 - Beta",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Operating System :: OS Independent",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.8",
|
||||||
|
"Programming Language :: Python :: 3.9",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
],
|
||||||
|
python_requires=">=3.8",
|
||||||
|
install_requires=requirements,
|
||||||
|
entry_points={
|
||||||
|
"console_scripts": [
|
||||||
|
"watermaker-api=watermaker_plc_api.main:main",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
5
tests/__init__.py
Normal file
5
tests/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""
|
||||||
|
Test suite for the Watermaker PLC API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Test configuration and utilities can be placed here
|
||||||
304
tests/test_controllers.py
Normal file
304
tests/test_controllers.py
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
"""
|
||||||
|
Tests for API controllers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import json
|
||||||
|
from unittest.mock import Mock, patch, MagicMock
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Add the parent directory to the path so we can import the package
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
|
||||||
|
class TestControllers:
|
||||||
|
"""Test cases for API controllers"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app(self):
|
||||||
|
"""Create test Flask application"""
|
||||||
|
# Mock the services to avoid actual PLC connections during testing
|
||||||
|
with patch('watermaker_plc_api.services.plc_connection.get_plc_connection'), \
|
||||||
|
patch('watermaker_plc_api.services.data_cache.get_data_cache'), \
|
||||||
|
patch('watermaker_plc_api.services.background_tasks.start_background_updates'):
|
||||||
|
|
||||||
|
from watermaker_plc_api.app import create_app
|
||||||
|
from watermaker_plc_api.config import TestingConfig
|
||||||
|
|
||||||
|
app = create_app(TestingConfig)
|
||||||
|
app.config['TESTING'] = True
|
||||||
|
return app
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(self, app):
|
||||||
|
"""Create test client"""
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
def test_status_endpoint(self, client):
|
||||||
|
"""Test /api/status endpoint"""
|
||||||
|
with patch('watermaker_plc_api.controllers.system_controller.cache') as mock_cache, \
|
||||||
|
patch('watermaker_plc_api.controllers.system_controller.plc') as mock_plc:
|
||||||
|
|
||||||
|
mock_cache.get_connection_status.return_value = "connected"
|
||||||
|
mock_cache.get_last_update.return_value = "2025-06-03T12:00:00"
|
||||||
|
mock_plc.get_connection_status.return_value = {
|
||||||
|
"ip_address": "127.0.0.1",
|
||||||
|
"port": 502,
|
||||||
|
"connected": True
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.get('/api/status')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert 'connection_status' in data
|
||||||
|
assert 'last_update' in data
|
||||||
|
assert 'plc_config' in data
|
||||||
|
assert 'timestamp' in data
|
||||||
|
|
||||||
|
def test_config_endpoint(self, client):
|
||||||
|
"""Test /api/config endpoint"""
|
||||||
|
response = client.get('/api/config')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert 'api_version' in data
|
||||||
|
assert 'endpoints' in data
|
||||||
|
assert 'variable_groups' in data
|
||||||
|
assert 'total_variables' in data
|
||||||
|
|
||||||
|
def test_sensors_endpoint(self, client):
|
||||||
|
"""Test /api/sensors endpoint"""
|
||||||
|
with patch('watermaker_plc_api.controllers.sensors_controller.cache') as mock_cache:
|
||||||
|
# Mock cache data
|
||||||
|
mock_cache.get_sensors.return_value = {
|
||||||
|
"1000": {
|
||||||
|
"name": "System Mode",
|
||||||
|
"raw_value": 5,
|
||||||
|
"scaled_value": 5,
|
||||||
|
"unit": "",
|
||||||
|
"category": "system"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mock_cache.get_last_update.return_value = "2025-06-03T12:00:00"
|
||||||
|
|
||||||
|
response = client.get('/api/sensors')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert 'sensors' in data
|
||||||
|
assert 'last_update' in data
|
||||||
|
assert 'count' in data
|
||||||
|
assert data['count'] == 1
|
||||||
|
|
||||||
|
def test_sensors_category_endpoint(self, client):
|
||||||
|
"""Test /api/sensors/category/<category> endpoint"""
|
||||||
|
with patch('watermaker_plc_api.controllers.sensors_controller.cache') as mock_cache:
|
||||||
|
mock_cache.get_sensors_by_category.return_value = {}
|
||||||
|
mock_cache.get_last_update.return_value = "2025-06-03T12:00:00"
|
||||||
|
|
||||||
|
# Test valid category
|
||||||
|
response = client.get('/api/sensors/category/system')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# Test invalid category
|
||||||
|
response = client.get('/api/sensors/category/invalid')
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
def test_timers_endpoint(self, client):
|
||||||
|
"""Test /api/timers endpoint"""
|
||||||
|
with patch('watermaker_plc_api.controllers.timers_controller.cache') as mock_cache:
|
||||||
|
# Mock cache data
|
||||||
|
mock_cache.get_timers.return_value = {
|
||||||
|
"136": {
|
||||||
|
"name": "FWF Timer",
|
||||||
|
"raw_value": 0,
|
||||||
|
"scaled_value": 0,
|
||||||
|
"active": False
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mock_cache.get_active_timers.return_value = []
|
||||||
|
mock_cache.get_last_update.return_value = "2025-06-03T12:00:00"
|
||||||
|
|
||||||
|
response = client.get('/api/timers')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert 'timers' in data
|
||||||
|
assert 'active_timers' in data
|
||||||
|
assert 'total_count' in data
|
||||||
|
assert 'active_count' in data
|
||||||
|
|
||||||
|
def test_outputs_endpoint(self, client):
|
||||||
|
"""Test /api/outputs endpoint"""
|
||||||
|
with patch('watermaker_plc_api.controllers.outputs_controller.cache') as mock_cache:
|
||||||
|
# Mock cache data
|
||||||
|
mock_cache.get_outputs.return_value = {
|
||||||
|
"40017": {
|
||||||
|
"register": 40017,
|
||||||
|
"value": 0,
|
||||||
|
"binary": "0000000000000000",
|
||||||
|
"bits": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mock_cache.get_last_update.return_value = "2025-06-03T12:00:00"
|
||||||
|
|
||||||
|
response = client.get('/api/outputs')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert 'outputs' in data
|
||||||
|
assert 'last_update' in data
|
||||||
|
assert 'count' in data
|
||||||
|
|
||||||
|
def test_write_register_endpoint(self, client):
|
||||||
|
"""Test /api/write/register endpoint"""
|
||||||
|
# Test missing content-type (no JSON)
|
||||||
|
response = client.post('/api/write/register')
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert "Request must be JSON" in data['message']
|
||||||
|
|
||||||
|
# Test missing data with proper content-type
|
||||||
|
response = client.post('/api/write/register',
|
||||||
|
data=json.dumps({}),
|
||||||
|
content_type='application/json')
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
# Test missing fields
|
||||||
|
response = client.post('/api/write/register',
|
||||||
|
data=json.dumps({"address": 1000}),
|
||||||
|
content_type='application/json')
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
# Test invalid values
|
||||||
|
response = client.post('/api/write/register',
|
||||||
|
data=json.dumps({"address": -1, "value": 5}),
|
||||||
|
content_type='application/json')
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
def test_select_endpoint_no_params(self, client):
|
||||||
|
"""Test /api/select endpoint without parameters"""
|
||||||
|
response = client.get('/api/select')
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert 'usage' in data['details']
|
||||||
|
|
||||||
|
def test_dts_start_endpoint(self, client):
|
||||||
|
"""Test /api/dts/start endpoint"""
|
||||||
|
with patch('watermaker_plc_api.controllers.dts_controller.start_dts_sequence_async') as mock_start:
|
||||||
|
# Mock successful start
|
||||||
|
mock_start.return_value = (True, "DTS sequence started", {"task_id": "abc12345"})
|
||||||
|
|
||||||
|
response = client.post('/api/dts/start')
|
||||||
|
assert response.status_code == 202
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['success'] is True
|
||||||
|
assert 'task_id' in data
|
||||||
|
assert 'status_endpoint' in data
|
||||||
|
|
||||||
|
def test_dts_start_conflict(self, client):
|
||||||
|
"""Test /api/dts/start endpoint with conflict"""
|
||||||
|
with patch('watermaker_plc_api.controllers.dts_controller.start_dts_sequence_async') as mock_start:
|
||||||
|
# Mock operation already running
|
||||||
|
mock_start.return_value = (False, "Operation already in progress", {"existing_task_id": "def67890"})
|
||||||
|
|
||||||
|
response = client.post('/api/dts/start')
|
||||||
|
assert response.status_code == 409
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['success'] is False
|
||||||
|
|
||||||
|
def test_dts_status_endpoint_not_found(self, client):
|
||||||
|
"""Test /api/dts/status/<task_id> endpoint with non-existent task"""
|
||||||
|
response = client.get('/api/dts/status/nonexistent')
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert 'available_tasks' in data['details']
|
||||||
|
|
||||||
|
def test_dts_cancel_endpoint_not_found(self, client):
|
||||||
|
"""Test /api/dts/cancel/<task_id> endpoint with non-existent task"""
|
||||||
|
response = client.post('/api/dts/cancel/nonexistent')
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['success'] is False
|
||||||
|
|
||||||
|
def test_dts_cancel_endpoint_success(self, client):
|
||||||
|
"""Test successful task cancellation"""
|
||||||
|
with patch('watermaker_plc_api.controllers.dts_controller.dts_operations') as mock_operations:
|
||||||
|
# Mock existing running task
|
||||||
|
mock_task = {
|
||||||
|
"task_id": "abc12345",
|
||||||
|
"status": "running",
|
||||||
|
"current_step": "waiting_for_valves"
|
||||||
|
}
|
||||||
|
mock_operations.get.return_value = mock_task
|
||||||
|
|
||||||
|
response = client.post('/api/dts/cancel/abc12345')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['success'] is True
|
||||||
|
|
||||||
|
def test_dts_cancel_endpoint_not_running(self, client):
|
||||||
|
"""Test cancelling non-running task"""
|
||||||
|
with patch('watermaker_plc_api.controllers.dts_controller.dts_operations') as mock_operations:
|
||||||
|
# Mock existing completed task
|
||||||
|
mock_operations.get.return_value = {
|
||||||
|
"task_id": "abc12345",
|
||||||
|
"status": "completed",
|
||||||
|
"current_step": "completed"
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post('/api/dts/cancel/abc12345')
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['success'] is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestErrorHandling:
|
||||||
|
"""Test cases for error handling across controllers"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app(self):
|
||||||
|
"""Create test Flask application"""
|
||||||
|
# Mock the services to avoid actual PLC connections during testing
|
||||||
|
with patch('watermaker_plc_api.services.plc_connection.get_plc_connection'), \
|
||||||
|
patch('watermaker_plc_api.services.data_cache.get_data_cache'), \
|
||||||
|
patch('watermaker_plc_api.services.background_tasks.start_background_updates'):
|
||||||
|
|
||||||
|
from watermaker_plc_api.app import create_app
|
||||||
|
from watermaker_plc_api.config import TestingConfig
|
||||||
|
|
||||||
|
app = create_app(TestingConfig)
|
||||||
|
app.config['TESTING'] = True
|
||||||
|
return app
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(self, app):
|
||||||
|
"""Create test client"""
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
def test_404_error(self, client):
|
||||||
|
"""Test 404 error handling"""
|
||||||
|
response = client.get('/api/nonexistent')
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['success'] is False
|
||||||
|
assert data['error'] == 'Not Found'
|
||||||
|
|
||||||
|
def test_405_method_not_allowed(self, client):
|
||||||
|
"""Test 405 method not allowed error"""
|
||||||
|
response = client.delete('/api/status') # DELETE not allowed on status endpoint
|
||||||
|
assert response.status_code == 405
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['success'] is False
|
||||||
|
assert data['error'] == 'Method Not Allowed'
|
||||||
130
tests/test_data_conversion.py
Normal file
130
tests/test_data_conversion.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
"""
|
||||||
|
Tests for data conversion utilities.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Add the parent directory to the path so we can import the package
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from watermaker_plc_api.utils.data_conversion import (
|
||||||
|
scale_value,
|
||||||
|
convert_ieee754_float,
|
||||||
|
convert_gallon_counter,
|
||||||
|
get_descriptive_value,
|
||||||
|
validate_register_value,
|
||||||
|
format_binary_string
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDataConversion:
|
||||||
|
"""Test cases for data conversion utilities"""
|
||||||
|
|
||||||
|
def test_scale_value_direct(self):
|
||||||
|
"""Test direct scaling (no change)"""
|
||||||
|
assert scale_value(100, "direct") == 100
|
||||||
|
assert scale_value(0, "direct") == 0
|
||||||
|
|
||||||
|
def test_scale_value_divide(self):
|
||||||
|
"""Test division scaling"""
|
||||||
|
assert scale_value(100, "÷10") == 10.0
|
||||||
|
assert scale_value(250, "÷10") == 25.0
|
||||||
|
assert scale_value(1000, "÷100") == 10.0
|
||||||
|
|
||||||
|
def test_scale_value_multiply(self):
|
||||||
|
"""Test multiplication scaling"""
|
||||||
|
assert scale_value(10, "×10") == 100.0
|
||||||
|
assert scale_value(5, "×2") == 10.0
|
||||||
|
|
||||||
|
def test_scale_value_invalid(self):
|
||||||
|
"""Test invalid scaling types"""
|
||||||
|
# Should return original value for invalid scale types
|
||||||
|
assert scale_value(100, "invalid") == 100
|
||||||
|
assert scale_value(100, "÷0") == 100 # Division by zero
|
||||||
|
assert scale_value(100, "×abc") == 100 # Invalid multiplier
|
||||||
|
|
||||||
|
def test_convert_ieee754_float(self):
|
||||||
|
"""Test IEEE 754 float conversion"""
|
||||||
|
# Test known values
|
||||||
|
# 1.0 in IEEE 754: 0x3F800000
|
||||||
|
high = 0x3F80
|
||||||
|
low = 0x0000
|
||||||
|
result = convert_ieee754_float(high, low)
|
||||||
|
assert result == 1.0
|
||||||
|
|
||||||
|
# Test another known value
|
||||||
|
# 3.14159 in IEEE 754: approximately 0x40490FD0
|
||||||
|
high = 0x4049
|
||||||
|
low = 0x0FD0
|
||||||
|
result = convert_ieee754_float(high, low)
|
||||||
|
assert abs(result - 3.14) < 0.01 # Allow small floating point differences
|
||||||
|
|
||||||
|
def test_convert_gallon_counter(self):
|
||||||
|
"""Test gallon counter conversion (same as IEEE 754)"""
|
||||||
|
high = 0x3F80
|
||||||
|
low = 0x0000
|
||||||
|
result = convert_gallon_counter(high, low)
|
||||||
|
assert result == 1.0
|
||||||
|
|
||||||
|
def test_get_descriptive_value_with_mapping(self):
|
||||||
|
"""Test getting descriptive value with value mapping"""
|
||||||
|
config = {
|
||||||
|
"values": {
|
||||||
|
"0": "Standby",
|
||||||
|
"5": "Running",
|
||||||
|
"7": "Service"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert get_descriptive_value(0, config) == "Standby"
|
||||||
|
assert get_descriptive_value(5, config) == "Running"
|
||||||
|
assert get_descriptive_value(7, config) == "Service"
|
||||||
|
assert get_descriptive_value(99, config) == "Unknown (99)"
|
||||||
|
|
||||||
|
def test_get_descriptive_value_without_mapping(self):
|
||||||
|
"""Test getting descriptive value without value mapping"""
|
||||||
|
config = {}
|
||||||
|
assert get_descriptive_value(100, config) == 100
|
||||||
|
|
||||||
|
def test_validate_register_value(self):
|
||||||
|
"""Test register value validation"""
|
||||||
|
# Valid values
|
||||||
|
assert validate_register_value(0) is True
|
||||||
|
assert validate_register_value(1000) is True
|
||||||
|
assert validate_register_value(65533) is True
|
||||||
|
|
||||||
|
# Invalid values
|
||||||
|
assert validate_register_value(None) is False
|
||||||
|
assert validate_register_value(-1) is False
|
||||||
|
assert validate_register_value(65534) is False
|
||||||
|
assert validate_register_value(65535) is False
|
||||||
|
assert validate_register_value("string") is False
|
||||||
|
|
||||||
|
def test_validate_register_value_custom_max(self):
|
||||||
|
"""Test register value validation with custom maximum"""
|
||||||
|
assert validate_register_value(100, max_value=1000) is True
|
||||||
|
assert validate_register_value(1000, max_value=1000) is False
|
||||||
|
assert validate_register_value(999, max_value=1000) is True
|
||||||
|
|
||||||
|
def test_format_binary_string(self):
|
||||||
|
"""Test binary string formatting"""
|
||||||
|
assert format_binary_string(5) == "0000000000000101"
|
||||||
|
assert format_binary_string(255) == "0000000011111111"
|
||||||
|
assert format_binary_string(0) == "0000000000000000"
|
||||||
|
|
||||||
|
# Test custom width
|
||||||
|
assert format_binary_string(5, width=8) == "00000101"
|
||||||
|
assert format_binary_string(15, width=4) == "1111"
|
||||||
|
|
||||||
|
def test_ieee754_edge_cases(self):
|
||||||
|
"""Test IEEE 754 conversion edge cases"""
|
||||||
|
# Test with None return on error
|
||||||
|
result = convert_ieee754_float(None, 0)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
# Test zero
|
||||||
|
result = convert_ieee754_float(0, 0)
|
||||||
|
assert result == 0.0
|
||||||
125
tests/test_plc_connection.py
Normal file
125
tests/test_plc_connection.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
"""
|
||||||
|
Tests for PLC connection functionality.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
# Add the parent directory to the path so we can import the package
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from watermaker_plc_api.services.plc_connection import PLCConnection, get_plc_connection
|
||||||
|
from watermaker_plc_api.utils.error_handler import PLCConnectionError
|
||||||
|
|
||||||
|
|
||||||
|
class TestPLCConnection:
|
||||||
|
"""Test cases for PLCConnection class"""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
"""Reset the global connection instance before each test"""
|
||||||
|
# Clear the global singleton for clean tests
|
||||||
|
import watermaker_plc_api.services.plc_connection
|
||||||
|
watermaker_plc_api.services.plc_connection._plc_connection = None
|
||||||
|
|
||||||
|
def test_singleton_pattern(self):
|
||||||
|
"""Test that get_plc_connection returns the same instance"""
|
||||||
|
conn1 = get_plc_connection()
|
||||||
|
conn2 = get_plc_connection()
|
||||||
|
assert conn1 is conn2
|
||||||
|
|
||||||
|
@patch('watermaker_plc_api.services.plc_connection.ModbusTcpClient')
|
||||||
|
def test_successful_connection(self, mock_client_class):
|
||||||
|
"""Test successful PLC connection"""
|
||||||
|
# Setup mock
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_client.connect.return_value = True
|
||||||
|
mock_client_class.return_value = mock_client
|
||||||
|
|
||||||
|
# Test connection
|
||||||
|
plc = PLCConnection()
|
||||||
|
result = plc.connect()
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert plc.is_connected is True
|
||||||
|
mock_client.connect.assert_called_once()
|
||||||
|
|
||||||
|
@patch('watermaker_plc_api.services.plc_connection.ModbusTcpClient')
|
||||||
|
def test_failed_connection(self, mock_client_class):
|
||||||
|
"""Test failed PLC connection"""
|
||||||
|
# Setup mock
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_client.connect.return_value = False
|
||||||
|
mock_client_class.return_value = mock_client
|
||||||
|
|
||||||
|
# Test connection
|
||||||
|
plc = PLCConnection()
|
||||||
|
result = plc.connect()
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
assert plc.is_connected is False
|
||||||
|
|
||||||
|
@patch('watermaker_plc_api.services.plc_connection.ModbusTcpClient')
|
||||||
|
def test_read_input_register(self, mock_client_class):
|
||||||
|
"""Test reading input register"""
|
||||||
|
# Setup mock
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_client.connect.return_value = True
|
||||||
|
mock_result = Mock()
|
||||||
|
mock_result.registers = [1234]
|
||||||
|
mock_result.isError.return_value = False
|
||||||
|
mock_client.read_input_registers.return_value = mock_result
|
||||||
|
mock_client_class.return_value = mock_client
|
||||||
|
|
||||||
|
# Test read
|
||||||
|
plc = PLCConnection()
|
||||||
|
plc.connect()
|
||||||
|
value = plc.read_input_register(1000)
|
||||||
|
|
||||||
|
assert value == 1234
|
||||||
|
mock_client.read_input_registers.assert_called_with(1000, 1, slave=1)
|
||||||
|
|
||||||
|
@patch('watermaker_plc_api.services.plc_connection.ModbusTcpClient')
|
||||||
|
def test_write_holding_register(self, mock_client_class):
|
||||||
|
"""Test writing holding register"""
|
||||||
|
# Setup mock
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_client.connect.return_value = True
|
||||||
|
mock_result = Mock()
|
||||||
|
mock_result.isError.return_value = False
|
||||||
|
mock_client.write_register.return_value = mock_result
|
||||||
|
mock_client_class.return_value = mock_client
|
||||||
|
|
||||||
|
# Test write
|
||||||
|
plc = PLCConnection()
|
||||||
|
plc.connect()
|
||||||
|
success = plc.write_holding_register(1000, 5)
|
||||||
|
|
||||||
|
assert success is True
|
||||||
|
mock_client.write_register.assert_called_with(1000, 5, slave=1)
|
||||||
|
|
||||||
|
def test_write_without_connection(self):
|
||||||
|
"""Test writing register without PLC connection"""
|
||||||
|
with patch('watermaker_plc_api.services.plc_connection.ModbusTcpClient') as mock_client_class:
|
||||||
|
# Setup mock to fail connection
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_client.connect.return_value = False
|
||||||
|
mock_client_class.return_value = mock_client
|
||||||
|
|
||||||
|
plc = PLCConnection()
|
||||||
|
|
||||||
|
with pytest.raises(PLCConnectionError):
|
||||||
|
plc.write_holding_register(1000, 5)
|
||||||
|
|
||||||
|
def test_get_connection_status(self):
|
||||||
|
"""Test getting connection status information"""
|
||||||
|
plc = PLCConnection()
|
||||||
|
status = plc.get_connection_status()
|
||||||
|
|
||||||
|
assert isinstance(status, dict)
|
||||||
|
assert "connected" in status
|
||||||
|
assert "ip_address" in status
|
||||||
|
assert "port" in status
|
||||||
|
assert "unit_id" in status
|
||||||
|
assert "timeout" in status
|
||||||
140
tests/test_register_reader.py
Normal file
140
tests/test_register_reader.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
"""
|
||||||
|
Tests for register reader service.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
# Add the parent directory to the path so we can import the package
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from watermaker_plc_api.services.register_reader import RegisterReader
|
||||||
|
|
||||||
|
|
||||||
|
class TestRegisterReader:
|
||||||
|
"""Test cases for RegisterReader service"""
|
||||||
|
|
||||||
|
@patch('watermaker_plc_api.services.register_reader.get_plc_connection')
|
||||||
|
@patch('watermaker_plc_api.services.register_reader.get_data_cache')
|
||||||
|
def test_update_sensors_success(self, mock_cache, mock_plc):
|
||||||
|
"""Test successful sensor data update"""
|
||||||
|
# Setup mocks
|
||||||
|
mock_plc_instance = Mock()
|
||||||
|
mock_plc_instance.read_input_register.return_value = 1000
|
||||||
|
mock_plc.return_value = mock_plc_instance
|
||||||
|
|
||||||
|
mock_cache_instance = Mock()
|
||||||
|
mock_cache.return_value = mock_cache_instance
|
||||||
|
|
||||||
|
# Test update
|
||||||
|
reader = RegisterReader()
|
||||||
|
result = reader.update_sensors()
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
mock_cache_instance.set_sensors.assert_called_once()
|
||||||
|
|
||||||
|
@patch('watermaker_plc_api.services.register_reader.get_plc_connection')
|
||||||
|
@patch('watermaker_plc_api.services.register_reader.get_data_cache')
|
||||||
|
def test_update_sensors_read_failure(self, mock_cache, mock_plc):
|
||||||
|
"""Test sensor update with read failure"""
|
||||||
|
# Setup mocks
|
||||||
|
mock_plc_instance = Mock()
|
||||||
|
mock_plc_instance.read_input_register.return_value = None # Read failure
|
||||||
|
mock_plc.return_value = mock_plc_instance
|
||||||
|
|
||||||
|
mock_cache_instance = Mock()
|
||||||
|
mock_cache.return_value = mock_cache_instance
|
||||||
|
|
||||||
|
# Test update
|
||||||
|
reader = RegisterReader()
|
||||||
|
result = reader.update_sensors()
|
||||||
|
|
||||||
|
assert result is True # Should still succeed even if no valid reads
|
||||||
|
mock_cache_instance.set_sensors.assert_called_once()
|
||||||
|
|
||||||
|
@patch('watermaker_plc_api.services.register_reader.get_plc_connection')
|
||||||
|
@patch('watermaker_plc_api.services.register_reader.get_data_cache')
|
||||||
|
def test_update_timers_success(self, mock_cache, mock_plc):
|
||||||
|
"""Test successful timer data update"""
|
||||||
|
# Setup mocks
|
||||||
|
mock_plc_instance = Mock()
|
||||||
|
mock_plc_instance.read_holding_register.return_value = 100
|
||||||
|
mock_plc.return_value = mock_plc_instance
|
||||||
|
|
||||||
|
mock_cache_instance = Mock()
|
||||||
|
mock_cache.return_value = mock_cache_instance
|
||||||
|
|
||||||
|
# Test update
|
||||||
|
reader = RegisterReader()
|
||||||
|
result = reader.update_timers()
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
mock_cache_instance.set_timers.assert_called_once()
|
||||||
|
|
||||||
|
@patch('watermaker_plc_api.services.register_reader.get_plc_connection')
|
||||||
|
@patch('watermaker_plc_api.services.register_reader.get_data_cache')
|
||||||
|
def test_read_register_pair_success(self, mock_cache, mock_plc):
|
||||||
|
"""Test successful register pair reading"""
|
||||||
|
# Setup mocks
|
||||||
|
mock_plc_instance = Mock()
|
||||||
|
mock_plc_instance.read_holding_register.side_effect = [0x3F80, 0x0000] # IEEE 754 for 1.0
|
||||||
|
mock_plc.return_value = mock_plc_instance
|
||||||
|
|
||||||
|
mock_cache_instance = Mock()
|
||||||
|
mock_cache.return_value = mock_cache_instance
|
||||||
|
|
||||||
|
# Test read
|
||||||
|
reader = RegisterReader()
|
||||||
|
success, converted, high, low = reader.read_register_pair(5014, 5015, "ieee754")
|
||||||
|
|
||||||
|
assert success is True
|
||||||
|
assert converted == 1.0
|
||||||
|
assert high == 0x3F80
|
||||||
|
assert low == 0x0000
|
||||||
|
|
||||||
|
@patch('watermaker_plc_api.services.register_reader.get_plc_connection')
|
||||||
|
@patch('watermaker_plc_api.services.register_reader.get_data_cache')
|
||||||
|
def test_read_register_pair_failure(self, mock_cache, mock_plc):
|
||||||
|
"""Test register pair reading with failure"""
|
||||||
|
# Setup mocks
|
||||||
|
mock_plc_instance = Mock()
|
||||||
|
mock_plc_instance.read_holding_register.return_value = None # Read failure
|
||||||
|
mock_plc.return_value = mock_plc_instance
|
||||||
|
|
||||||
|
mock_cache_instance = Mock()
|
||||||
|
mock_cache.return_value = mock_cache_instance
|
||||||
|
|
||||||
|
# Test read
|
||||||
|
reader = RegisterReader()
|
||||||
|
success, converted, high, low = reader.read_register_pair(5014, 5015, "ieee754")
|
||||||
|
|
||||||
|
assert success is False
|
||||||
|
assert converted is None
|
||||||
|
|
||||||
|
@patch('watermaker_plc_api.services.register_reader.get_plc_connection')
|
||||||
|
@patch('watermaker_plc_api.services.register_reader.get_data_cache')
|
||||||
|
def test_read_selective_data(self, mock_cache, mock_plc):
|
||||||
|
"""Test selective data reading"""
|
||||||
|
# Setup mocks
|
||||||
|
mock_plc_instance = Mock()
|
||||||
|
mock_plc_instance.read_input_register.return_value = 500
|
||||||
|
mock_plc_instance.read_holding_register.return_value = 100
|
||||||
|
mock_plc.return_value = mock_plc_instance
|
||||||
|
|
||||||
|
mock_cache_instance = Mock()
|
||||||
|
mock_cache.return_value = mock_cache_instance
|
||||||
|
|
||||||
|
# Test selective read
|
||||||
|
reader = RegisterReader()
|
||||||
|
result = reader.read_selective_data(["temperature"], ["1036"])
|
||||||
|
|
||||||
|
assert "sensors" in result
|
||||||
|
assert "timers" in result
|
||||||
|
assert "requested_groups" in result
|
||||||
|
assert "requested_keys" in result
|
||||||
|
assert "summary" in result
|
||||||
|
|
||||||
|
assert result["requested_groups"] == ["temperature"]
|
||||||
|
assert result["requested_keys"] == ["1036"]
|
||||||
12
watermaker_plc_api.code-workspace
Normal file
12
watermaker_plc_api.code-workspace
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": "/root/FCI/api"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {
|
||||||
|
"python.defaultInterpreterPath": "/root/FCI/api/venv/bin/python",
|
||||||
|
"python.terminal.activateEnvironment": true,
|
||||||
|
"terminal.integrated.cwd": "/root/FCI/api"
|
||||||
|
}
|
||||||
|
}
|
||||||
15
watermaker_plc_api/__init__.py
Normal file
15
watermaker_plc_api/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
"""
|
||||||
|
Watermaker PLC API Package
|
||||||
|
|
||||||
|
RESTful API for monitoring and controlling watermaker PLC systems.
|
||||||
|
Provides access to sensors, timers, controls, and watermaker operation sequences.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__version__ = "1.1.0"
|
||||||
|
__author__ = "Your Name"
|
||||||
|
__email__ = "your.email@example.com"
|
||||||
|
|
||||||
|
from .app import create_app
|
||||||
|
from .config import Config
|
||||||
|
|
||||||
|
__all__ = ['create_app', 'Config']
|
||||||
100
watermaker_plc_api/__main__.py
Normal file
100
watermaker_plc_api/__main__.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Main entry point for the Watermaker PLC API server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from .app import create_app
|
||||||
|
from .config import Config
|
||||||
|
from .utils.logger import get_logger
|
||||||
|
from .services.background_tasks import start_background_updates
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
"""Parse command line arguments"""
|
||||||
|
parser = argparse.ArgumentParser(description='Watermaker PLC API Server')
|
||||||
|
parser.add_argument('--host', default='0.0.0.0',
|
||||||
|
help='Host to bind to (default: 0.0.0.0)')
|
||||||
|
parser.add_argument('--port', type=int, default=5000,
|
||||||
|
help='Port to bind to (default: 5000)')
|
||||||
|
parser.add_argument('--plc-ip', default='192.168.1.15',
|
||||||
|
help='PLC IP address (default: 192.168.1.15)')
|
||||||
|
parser.add_argument('--plc-port', type=int, default=502,
|
||||||
|
help='PLC Modbus port (default: 502)')
|
||||||
|
parser.add_argument('--debug', action='store_true',
|
||||||
|
help='Enable debug mode')
|
||||||
|
parser.add_argument('--no-background-updates', action='store_true',
|
||||||
|
help='Disable background data updates')
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main application entry point"""
|
||||||
|
args = parse_args()
|
||||||
|
|
||||||
|
# Update config with command line arguments
|
||||||
|
Config.PLC_IP = args.plc_ip
|
||||||
|
Config.PLC_PORT = args.plc_port
|
||||||
|
Config.DEBUG = args.debug
|
||||||
|
|
||||||
|
logger.info("Starting Watermaker PLC API Server")
|
||||||
|
logger.info(f"API Version: 1.1.0")
|
||||||
|
logger.info(f"PLC Target: {Config.PLC_IP}:{Config.PLC_PORT}")
|
||||||
|
logger.info(f"Server: http://{args.host}:{args.port}")
|
||||||
|
|
||||||
|
# Create Flask application
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
# Start background data updates (unless disabled)
|
||||||
|
if not args.no_background_updates:
|
||||||
|
start_background_updates()
|
||||||
|
logger.info("Background data update thread started")
|
||||||
|
else:
|
||||||
|
logger.warning("Background data updates disabled")
|
||||||
|
|
||||||
|
# Log available endpoints
|
||||||
|
logger.info("Available endpoints:")
|
||||||
|
logger.info(" Data Monitoring:")
|
||||||
|
logger.info(" GET /api/all - All PLC data")
|
||||||
|
logger.info(" GET /api/select - Selective data (bandwidth optimized)")
|
||||||
|
logger.info(" GET /api/sensors - All sensors")
|
||||||
|
logger.info(" GET /api/timers - All timers")
|
||||||
|
logger.info(" GET /api/outputs - Output controls")
|
||||||
|
logger.info(" GET /api/runtime - Runtime hours")
|
||||||
|
logger.info(" GET /api/water_counters - Water production counters")
|
||||||
|
logger.info(" GET /api/status - Connection status")
|
||||||
|
logger.info(" GET /api/config - API configuration")
|
||||||
|
logger.info("")
|
||||||
|
logger.info(" Control Operations:")
|
||||||
|
logger.info(" POST /api/dts/start - Start DTS watermaker sequence")
|
||||||
|
logger.info(" POST /api/dts/stop - Stop watermaker sequence")
|
||||||
|
logger.info(" POST /api/dts/skip - Skip current step")
|
||||||
|
logger.info(" GET /api/dts/status - Get DTS operation status")
|
||||||
|
logger.info(" POST /api/write/register - Write single register")
|
||||||
|
logger.info("")
|
||||||
|
logger.info(" Examples:")
|
||||||
|
logger.info(" /api/select?groups=temperature,pressure")
|
||||||
|
logger.info(" /api/select?keys=1036,1003,1017")
|
||||||
|
logger.info(" curl -X POST http://localhost:5000/api/dts/start")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Start the Flask server
|
||||||
|
app.run(
|
||||||
|
host=args.host,
|
||||||
|
port=args.port,
|
||||||
|
debug=args.debug,
|
||||||
|
threaded=True
|
||||||
|
)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Server shutdown requested by user")
|
||||||
|
sys.exit(0)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to start server: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
79
watermaker_plc_api/app.py
Normal file
79
watermaker_plc_api/app.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"""
|
||||||
|
Flask application factory and setup.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Flask
|
||||||
|
from flask_cors import CORS
|
||||||
|
from .config import Config
|
||||||
|
from .utils.logger import get_logger
|
||||||
|
from .utils.error_handler import setup_error_handlers
|
||||||
|
|
||||||
|
# Import controllers
|
||||||
|
from .controllers.system_controller import system_bp
|
||||||
|
from .controllers.sensors_controller import sensors_bp
|
||||||
|
from .controllers.timers_controller import timers_bp
|
||||||
|
from .controllers.outputs_controller import outputs_bp
|
||||||
|
from .controllers.dts_controller import dts_bp
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(config_object=None):
|
||||||
|
"""
|
||||||
|
Application factory pattern for creating Flask app.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_object: Configuration class or object to use
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Flask: Configured Flask application
|
||||||
|
"""
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# Configure the app
|
||||||
|
if config_object is None:
|
||||||
|
config_object = Config
|
||||||
|
|
||||||
|
app.config.from_object(config_object)
|
||||||
|
|
||||||
|
# Enable CORS for web-based control panels
|
||||||
|
if config_object.CORS_ENABLED:
|
||||||
|
CORS(app)
|
||||||
|
logger.info("CORS enabled for web applications")
|
||||||
|
|
||||||
|
# Setup error handlers
|
||||||
|
setup_error_handlers(app)
|
||||||
|
|
||||||
|
# Register blueprints
|
||||||
|
register_blueprints(app)
|
||||||
|
|
||||||
|
# Log application startup
|
||||||
|
logger.info(f"Flask application created")
|
||||||
|
logger.info(f"Debug mode: {app.config.get('DEBUG', False)}")
|
||||||
|
logger.info(f"PLC target: {config_object.PLC_IP}:{config_object.PLC_PORT}")
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def register_blueprints(app):
|
||||||
|
"""Register all route blueprints with the Flask app"""
|
||||||
|
|
||||||
|
# System and status endpoints
|
||||||
|
app.register_blueprint(system_bp, url_prefix='/api')
|
||||||
|
|
||||||
|
# Data monitoring endpoints
|
||||||
|
app.register_blueprint(sensors_bp, url_prefix='/api')
|
||||||
|
app.register_blueprint(timers_bp, url_prefix='/api')
|
||||||
|
app.register_blueprint(outputs_bp, url_prefix='/api')
|
||||||
|
|
||||||
|
# Control endpoints
|
||||||
|
app.register_blueprint(dts_bp, url_prefix='/api')
|
||||||
|
|
||||||
|
logger.info("All blueprints registered successfully")
|
||||||
|
|
||||||
|
# Log registered routes
|
||||||
|
if app.config.get('DEBUG', False):
|
||||||
|
logger.debug("Registered routes:")
|
||||||
|
for rule in app.url_map.iter_rules():
|
||||||
|
methods = ','.join(rule.methods - {'OPTIONS', 'HEAD'})
|
||||||
|
logger.debug(f" {rule.rule} [{methods}] -> {rule.endpoint}")
|
||||||
94
watermaker_plc_api/config.py
Normal file
94
watermaker_plc_api/config.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
"""
|
||||||
|
Configuration settings for the Watermaker PLC API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
"""Application configuration"""
|
||||||
|
|
||||||
|
# Flask Settings
|
||||||
|
DEBUG = os.getenv('DEBUG', 'False').lower() == 'true'
|
||||||
|
SECRET_KEY = os.getenv('SECRET_KEY', 'watermaker-plc-api-dev-key')
|
||||||
|
|
||||||
|
# PLC Connection Settings
|
||||||
|
PLC_IP = os.getenv('PLC_IP', '192.168.1.15')
|
||||||
|
PLC_PORT = int(os.getenv('PLC_PORT', '502'))
|
||||||
|
PLC_UNIT_ID = int(os.getenv('PLC_UNIT_ID', '1'))
|
||||||
|
PLC_TIMEOUT = int(os.getenv('PLC_TIMEOUT', '3'))
|
||||||
|
PLC_CONNECTION_RETRY_INTERVAL = int(os.getenv('PLC_CONNECTION_RETRY_INTERVAL', '30'))
|
||||||
|
|
||||||
|
# API Settings
|
||||||
|
API_VERSION = "1.1"
|
||||||
|
CORS_ENABLED = True
|
||||||
|
|
||||||
|
# Background Task Settings
|
||||||
|
DATA_UPDATE_INTERVAL = int(os.getenv('DATA_UPDATE_INTERVAL', '5')) # seconds
|
||||||
|
ERROR_RETRY_INTERVAL = int(os.getenv('ERROR_RETRY_INTERVAL', '10')) # seconds
|
||||||
|
MAX_CACHED_ERRORS = int(os.getenv('MAX_CACHED_ERRORS', '10'))
|
||||||
|
|
||||||
|
# Logging Settings
|
||||||
|
LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO').upper()
|
||||||
|
LOG_FORMAT = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_plc_config(cls) -> Dict[str, Any]:
|
||||||
|
"""Get PLC connection configuration"""
|
||||||
|
return {
|
||||||
|
"ip_address": cls.PLC_IP,
|
||||||
|
"port": cls.PLC_PORT,
|
||||||
|
"unit_id": cls.PLC_UNIT_ID,
|
||||||
|
"timeout": cls.PLC_TIMEOUT,
|
||||||
|
"connected": False,
|
||||||
|
"client": None,
|
||||||
|
"last_connection_attempt": 0,
|
||||||
|
"connection_retry_interval": cls.PLC_CONNECTION_RETRY_INTERVAL
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_api_info(cls) -> Dict[str, Any]:
|
||||||
|
"""Get API information"""
|
||||||
|
return {
|
||||||
|
"version": cls.API_VERSION,
|
||||||
|
"debug": cls.DEBUG,
|
||||||
|
"plc_target": f"{cls.PLC_IP}:{cls.PLC_PORT}",
|
||||||
|
"update_interval": f"{cls.DATA_UPDATE_INTERVAL} seconds"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DevelopmentConfig(Config):
|
||||||
|
"""Development configuration"""
|
||||||
|
DEBUG = True
|
||||||
|
PLC_IP = '127.0.0.1' # Simulator for development
|
||||||
|
|
||||||
|
|
||||||
|
class ProductionConfig(Config):
|
||||||
|
"""Production configuration"""
|
||||||
|
DEBUG = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def SECRET_KEY(self):
|
||||||
|
"""Get SECRET_KEY from environment, required in production"""
|
||||||
|
secret_key = os.getenv('SECRET_KEY')
|
||||||
|
if not secret_key:
|
||||||
|
raise ValueError("SECRET_KEY environment variable must be set in production")
|
||||||
|
return secret_key
|
||||||
|
|
||||||
|
|
||||||
|
class TestingConfig(Config):
|
||||||
|
"""Testing configuration"""
|
||||||
|
TESTING = True
|
||||||
|
DEBUG = True
|
||||||
|
PLC_IP = '127.0.0.1'
|
||||||
|
DATA_UPDATE_INTERVAL = 1 # Faster updates for testing
|
||||||
|
|
||||||
|
|
||||||
|
# Configuration mapping
|
||||||
|
config_map = {
|
||||||
|
'development': DevelopmentConfig,
|
||||||
|
'production': ProductionConfig,
|
||||||
|
'testing': TestingConfig,
|
||||||
|
'default': Config
|
||||||
|
}
|
||||||
17
watermaker_plc_api/controllers/__init__.py
Normal file
17
watermaker_plc_api/controllers/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
"""
|
||||||
|
Controller modules for handling API routes and business logic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .system_controller import system_bp
|
||||||
|
from .sensors_controller import sensors_bp
|
||||||
|
from .timers_controller import timers_bp
|
||||||
|
from .outputs_controller import outputs_bp
|
||||||
|
from .dts_controller import dts_bp
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'system_bp',
|
||||||
|
'sensors_bp',
|
||||||
|
'timers_bp',
|
||||||
|
'outputs_bp',
|
||||||
|
'dts_bp'
|
||||||
|
]
|
||||||
732
watermaker_plc_api/controllers/dts_controller.py
Normal file
732
watermaker_plc_api/controllers/dts_controller.py
Normal file
@@ -0,0 +1,732 @@
|
|||||||
|
"""
|
||||||
|
DTS controller for watermaker control operations and task management.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
from datetime import datetime
|
||||||
|
from flask import Blueprint, jsonify
|
||||||
|
from ..services.plc_connection import get_plc_connection
|
||||||
|
from ..services.register_writer import RegisterWriter
|
||||||
|
from ..services.data_cache import get_data_cache
|
||||||
|
from ..utils.logger import get_logger
|
||||||
|
from ..utils.error_handler import create_error_response, create_success_response, DTSOperationError
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# Create blueprint
|
||||||
|
dts_bp = Blueprint('dts', __name__)
|
||||||
|
|
||||||
|
# Initialize services
|
||||||
|
plc = get_plc_connection()
|
||||||
|
writer = RegisterWriter()
|
||||||
|
cache = get_data_cache()
|
||||||
|
|
||||||
|
# DTS operation tracking with task management
|
||||||
|
dts_operations = {} # Track multiple operations by task_id
|
||||||
|
|
||||||
|
|
||||||
|
def create_dts_task():
|
||||||
|
"""Create a new DTS task with unique ID"""
|
||||||
|
task_id = str(uuid.uuid4())[:8] # Short UUID for easy reference
|
||||||
|
dts_operations[task_id] = {
|
||||||
|
"task_id": task_id,
|
||||||
|
"status": "pending", # pending, running, completed, failed, cancelled
|
||||||
|
"progress_percent": 0,
|
||||||
|
"current_step": "",
|
||||||
|
"start_time": None,
|
||||||
|
"end_time": None,
|
||||||
|
"steps_completed": [],
|
||||||
|
"last_error": None,
|
||||||
|
"step_descriptions": {
|
||||||
|
"checking_system_mode": "Checking current system mode",
|
||||||
|
"setting_prep_mode": "Setting preparation mode",
|
||||||
|
"setting_r71_256": "Activating valve sequence",
|
||||||
|
"setting_r71_0": "Completing valve command",
|
||||||
|
"waiting_for_valves": "Waiting for valve positioning",
|
||||||
|
"starting_dts_mode": "Starting DTS operation",
|
||||||
|
"completed": "DTS sequence completed",
|
||||||
|
"failed": "DTS sequence failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return task_id
|
||||||
|
|
||||||
|
|
||||||
|
def get_latest_dts_task():
|
||||||
|
"""Get the most recent DTS task"""
|
||||||
|
if not dts_operations:
|
||||||
|
return None
|
||||||
|
return max(dts_operations.values(), key=lambda x: x.get("start_time", ""))
|
||||||
|
|
||||||
|
|
||||||
|
def execute_dts_sequence(task_id):
|
||||||
|
"""
|
||||||
|
Execute the DTS sequence in background thread.
|
||||||
|
Updates task status as it progresses.
|
||||||
|
"""
|
||||||
|
task = dts_operations[task_id]
|
||||||
|
|
||||||
|
try:
|
||||||
|
task["status"] = "running"
|
||||||
|
task["start_time"] = datetime.now().isoformat()
|
||||||
|
task["progress_percent"] = 5
|
||||||
|
|
||||||
|
if not plc.connect():
|
||||||
|
raise DTSOperationError("Failed to connect to PLC")
|
||||||
|
|
||||||
|
# Step 1: Check value of R1000
|
||||||
|
task["current_step"] = "checking_system_mode"
|
||||||
|
task["progress_percent"] = 10
|
||||||
|
logger.info("DTS Start: Checking system mode (R1000)")
|
||||||
|
|
||||||
|
current_mode = plc.read_holding_register(1000)
|
||||||
|
if current_mode is None:
|
||||||
|
raise DTSOperationError("Failed to read system mode register R1000")
|
||||||
|
|
||||||
|
logger.info(f"DTS Start: Current system mode = {current_mode}")
|
||||||
|
task["steps_completed"].append({
|
||||||
|
"step": "read_r1000",
|
||||||
|
"value": current_mode,
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
task["progress_percent"] = 20
|
||||||
|
|
||||||
|
# Step 2: If not 34, set R1000=34
|
||||||
|
if current_mode != 34:
|
||||||
|
task["current_step"] = "setting_prep_mode"
|
||||||
|
task["progress_percent"] = 25
|
||||||
|
logger.info("DTS Start: Setting system mode to 34 (prep mode)")
|
||||||
|
|
||||||
|
if not writer.write_holding_register(1000, 34):
|
||||||
|
raise DTSOperationError("Failed to write R1000=34")
|
||||||
|
|
||||||
|
task["steps_completed"].append({
|
||||||
|
"step": "write_r1000_34",
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
# Wait 2 seconds
|
||||||
|
logger.info("DTS Start: Waiting 2 seconds after mode change")
|
||||||
|
time.sleep(2)
|
||||||
|
else:
|
||||||
|
logger.info("DTS Start: System already in prep mode (34)")
|
||||||
|
task["steps_completed"].append({
|
||||||
|
"step": "r1000_already_34",
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
task["progress_percent"] = 35
|
||||||
|
|
||||||
|
# Step 3: Set R71=256
|
||||||
|
task["current_step"] = "setting_r71_256"
|
||||||
|
task["progress_percent"] = 40
|
||||||
|
logger.info("DTS Start: Setting R71=256")
|
||||||
|
|
||||||
|
if not writer.write_holding_register(71, 256):
|
||||||
|
raise DTSOperationError("Failed to write R71=256")
|
||||||
|
|
||||||
|
task["steps_completed"].append({
|
||||||
|
"step": "write_r71_256",
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
# Wait 2 seconds
|
||||||
|
logger.info("DTS Start: Waiting 2 seconds after R71=256")
|
||||||
|
time.sleep(2)
|
||||||
|
task["progress_percent"] = 50
|
||||||
|
|
||||||
|
# Step 4: Set R71=0
|
||||||
|
task["current_step"] = "setting_r71_0"
|
||||||
|
task["progress_percent"] = 55
|
||||||
|
logger.info("DTS Start: Setting R71=0")
|
||||||
|
|
||||||
|
if not writer.write_holding_register(71, 0):
|
||||||
|
raise DTSOperationError("Failed to write R71=0")
|
||||||
|
|
||||||
|
task["steps_completed"].append({
|
||||||
|
"step": "write_r71_0",
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
task["progress_percent"] = 60
|
||||||
|
|
||||||
|
# Step 5: Wait for R138 to count down (valves moving into position)
|
||||||
|
task["current_step"] = "waiting_for_valves"
|
||||||
|
task["progress_percent"] = 65
|
||||||
|
logger.info("DTS Start: Waiting for valve positioning (monitoring R138)")
|
||||||
|
|
||||||
|
# Monitor R138 for up to 15 seconds
|
||||||
|
valve_timeout = time.time() + 15
|
||||||
|
r138_initial = None
|
||||||
|
|
||||||
|
while time.time() < valve_timeout:
|
||||||
|
r138_value = plc.read_holding_register(138)
|
||||||
|
if r138_value is None:
|
||||||
|
logger.warning("Failed to read R138 during valve wait")
|
||||||
|
time.sleep(0.5)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if r138_initial is None:
|
||||||
|
r138_initial = r138_value
|
||||||
|
logger.info(f"DTS Start: R138 initial value = {r138_value}")
|
||||||
|
|
||||||
|
# Update progress based on R138 countdown
|
||||||
|
if r138_initial and r138_initial > 0:
|
||||||
|
valve_progress = max(0, (r138_initial - r138_value) / r138_initial)
|
||||||
|
task["progress_percent"] = 65 + int(valve_progress * 20) # 65% to 85%
|
||||||
|
|
||||||
|
# When R138 reaches 0, valves are positioned
|
||||||
|
if r138_value == 0:
|
||||||
|
logger.info("DTS Start: Valve positioning complete (R138 = 0)")
|
||||||
|
task["steps_completed"].append({
|
||||||
|
"step": "valves_positioned",
|
||||||
|
"r138_initial": r138_initial,
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
time.sleep(0.2) # Check every 200ms
|
||||||
|
else:
|
||||||
|
# Timeout occurred
|
||||||
|
raise DTSOperationError("Timeout waiting for valve positioning (R138 did not reach 0)")
|
||||||
|
|
||||||
|
task["progress_percent"] = 90
|
||||||
|
|
||||||
|
# Step 6: Set R1000=5 (start DTS operation)
|
||||||
|
task["current_step"] = "starting_dts_mode"
|
||||||
|
task["progress_percent"] = 95
|
||||||
|
logger.info("DTS Start: Setting R1000=5 (starting DTS mode)")
|
||||||
|
|
||||||
|
if not writer.write_holding_register(1000, 5):
|
||||||
|
raise DTSOperationError("Failed to write R1000=5")
|
||||||
|
|
||||||
|
task["steps_completed"].append({
|
||||||
|
"step": "write_r1000_5",
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sequence completed successfully
|
||||||
|
task["current_step"] = "completed"
|
||||||
|
task["status"] = "completed"
|
||||||
|
task["progress_percent"] = 100
|
||||||
|
task["end_time"] = datetime.now().isoformat()
|
||||||
|
|
||||||
|
logger.info("DTS Start: Sequence completed successfully")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Error occurred during sequence
|
||||||
|
error_msg = str(e)
|
||||||
|
logger.error(f"DTS Start: Error during sequence: {error_msg}")
|
||||||
|
|
||||||
|
task["last_error"] = {
|
||||||
|
"message": error_msg,
|
||||||
|
"step": task["current_step"],
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
task["status"] = "failed"
|
||||||
|
task["current_step"] = "failed"
|
||||||
|
task["end_time"] = datetime.now().isoformat()
|
||||||
|
|
||||||
|
cache.add_error(f"DTS Start Failed: {error_msg}")
|
||||||
|
|
||||||
|
|
||||||
|
def execute_stop_sequence(task_id):
|
||||||
|
"""
|
||||||
|
Execute the watermaker stop sequence in background thread.
|
||||||
|
Stop sequence varies based on current system mode (R1000).
|
||||||
|
"""
|
||||||
|
task = dts_operations[task_id]
|
||||||
|
|
||||||
|
try:
|
||||||
|
task["status"] = "running"
|
||||||
|
task["start_time"] = datetime.now().isoformat()
|
||||||
|
task["progress_percent"] = 5
|
||||||
|
|
||||||
|
if not plc.connect():
|
||||||
|
raise DTSOperationError("Failed to connect to PLC")
|
||||||
|
|
||||||
|
# Step 1: Read current system mode
|
||||||
|
task["current_step"] = "reading_system_mode"
|
||||||
|
task["progress_percent"] = 10
|
||||||
|
logger.info("Stop: Reading current system mode (R1000)")
|
||||||
|
|
||||||
|
current_mode = plc.read_holding_register(1000)
|
||||||
|
if current_mode is None:
|
||||||
|
raise DTSOperationError("Failed to read system mode register R1000")
|
||||||
|
|
||||||
|
logger.info(f"Stop: Current system mode = {current_mode}")
|
||||||
|
task["steps_completed"].append({
|
||||||
|
"step": "read_current_mode",
|
||||||
|
"value": current_mode,
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
task["progress_percent"] = 20
|
||||||
|
|
||||||
|
# Step 2: Execute appropriate stop sequence based on mode
|
||||||
|
if current_mode == 7:
|
||||||
|
# Mode 7 stop sequence
|
||||||
|
task["current_step"] = "stopping_mode_7"
|
||||||
|
task["progress_percent"] = 30
|
||||||
|
logger.info("Stop: Executing mode 7 stop sequence (R71=513)")
|
||||||
|
|
||||||
|
if not writer.write_holding_register(71, 513):
|
||||||
|
raise DTSOperationError("Failed to write R71=513")
|
||||||
|
task["steps_completed"].append({
|
||||||
|
"step": "write_r71_513",
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
task["progress_percent"] = 60
|
||||||
|
|
||||||
|
if not writer.write_holding_register(71, 0):
|
||||||
|
raise DTSOperationError("Failed to write R71=0 after 513")
|
||||||
|
task["steps_completed"].append({
|
||||||
|
"step": "write_r71_0_after_513",
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
task["progress_percent"] = 80
|
||||||
|
|
||||||
|
if not writer.write_holding_register(1000, 8):
|
||||||
|
raise DTSOperationError("Failed to write R1000=8")
|
||||||
|
task["steps_completed"].append({
|
||||||
|
"step": "write_r1000_8",
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
elif current_mode == 5:
|
||||||
|
# Mode 5 stop sequence
|
||||||
|
task["current_step"] = "stopping_mode_5"
|
||||||
|
task["progress_percent"] = 30
|
||||||
|
logger.info("Stop: Executing mode 5 stop sequence (R71=512)")
|
||||||
|
|
||||||
|
if not writer.write_holding_register(71, 512):
|
||||||
|
raise DTSOperationError("Failed to write R71=512")
|
||||||
|
task["steps_completed"].append({
|
||||||
|
"step": "write_r71_512",
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
task["progress_percent"] = 60
|
||||||
|
|
||||||
|
if not writer.write_holding_register(71, 0):
|
||||||
|
raise DTSOperationError("Failed to write R71=0 after 512")
|
||||||
|
task["steps_completed"].append({
|
||||||
|
"step": "write_r71_0_after_512",
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
task["progress_percent"] = 80
|
||||||
|
|
||||||
|
if not writer.write_holding_register(1000, 8):
|
||||||
|
raise DTSOperationError("Failed to write R1000=8")
|
||||||
|
task["steps_completed"].append({
|
||||||
|
"step": "write_r1000_8",
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
elif current_mode == 8:
|
||||||
|
# Mode 8 stop sequence (flush screen)
|
||||||
|
task["current_step"] = "stopping_mode_8_flush"
|
||||||
|
task["progress_percent"] = 30
|
||||||
|
logger.info("Stop: Executing mode 8 flush stop sequence (R71=1024)")
|
||||||
|
|
||||||
|
if not writer.write_holding_register(71, 1024):
|
||||||
|
raise DTSOperationError("Failed to write R71=1024")
|
||||||
|
task["steps_completed"].append({
|
||||||
|
"step": "write_r71_1024",
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
task["progress_percent"] = 60
|
||||||
|
|
||||||
|
if not writer.write_holding_register(71, 0):
|
||||||
|
raise DTSOperationError("Failed to write R71=0 after 1024")
|
||||||
|
task["steps_completed"].append({
|
||||||
|
"step": "write_r71_0_after_1024",
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
task["progress_percent"] = 80
|
||||||
|
|
||||||
|
if not writer.write_holding_register(1000, 2):
|
||||||
|
raise DTSOperationError("Failed to write R1000=2")
|
||||||
|
task["steps_completed"].append({
|
||||||
|
"step": "write_r1000_2",
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise DTSOperationError(f"Cannot stop from current mode {current_mode}. Valid modes: 5, 7, 8")
|
||||||
|
|
||||||
|
# Sequence completed successfully
|
||||||
|
task["current_step"] = "completed"
|
||||||
|
task["status"] = "completed"
|
||||||
|
task["progress_percent"] = 100
|
||||||
|
task["end_time"] = datetime.now().isoformat()
|
||||||
|
|
||||||
|
logger.info(f"Stop: Sequence completed successfully from mode {current_mode}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Error occurred during sequence
|
||||||
|
error_msg = str(e)
|
||||||
|
logger.error(f"Stop: Error during sequence: {error_msg}")
|
||||||
|
|
||||||
|
task["last_error"] = {
|
||||||
|
"message": error_msg,
|
||||||
|
"step": task["current_step"],
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
task["status"] = "failed"
|
||||||
|
task["current_step"] = "failed"
|
||||||
|
task["end_time"] = datetime.now().isoformat()
|
||||||
|
|
||||||
|
cache.add_error(f"Stop Failed: {error_msg}")
|
||||||
|
|
||||||
|
|
||||||
|
def execute_skip_sequence(task_id):
|
||||||
|
"""
|
||||||
|
Execute step skip sequence in background thread.
|
||||||
|
Automatically determines next step based on current mode.
|
||||||
|
"""
|
||||||
|
task = dts_operations[task_id]
|
||||||
|
|
||||||
|
try:
|
||||||
|
task["status"] = "running"
|
||||||
|
task["start_time"] = datetime.now().isoformat()
|
||||||
|
task["progress_percent"] = 5
|
||||||
|
|
||||||
|
if not plc.connect():
|
||||||
|
raise DTSOperationError("Failed to connect to PLC")
|
||||||
|
|
||||||
|
# Step 1: Read current system mode
|
||||||
|
task["current_step"] = "reading_system_mode"
|
||||||
|
task["progress_percent"] = 10
|
||||||
|
logger.info("Skip: Reading current system mode (R1000)")
|
||||||
|
|
||||||
|
current_mode = plc.read_holding_register(1000)
|
||||||
|
if current_mode is None:
|
||||||
|
raise DTSOperationError("Failed to read system mode register R1000")
|
||||||
|
|
||||||
|
logger.info(f"Skip: Current system mode = {current_mode}")
|
||||||
|
task["steps_completed"].append({
|
||||||
|
"step": "read_current_mode",
|
||||||
|
"value": current_mode,
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
task["progress_percent"] = 20
|
||||||
|
|
||||||
|
# Step 2: Execute appropriate skip sequence
|
||||||
|
if current_mode == 5:
|
||||||
|
# Skip step 2: Mode 5 -> step 3 (Mode 6)
|
||||||
|
target_step = 3
|
||||||
|
task["current_step"] = "skipping_step_2"
|
||||||
|
task["progress_percent"] = 40
|
||||||
|
logger.info("Skip: Skipping step 2 from mode 5 to step 3 (R67=32841)")
|
||||||
|
|
||||||
|
if not writer.write_holding_register(67, 32841):
|
||||||
|
raise DTSOperationError("Failed to write R67=32841")
|
||||||
|
task["steps_completed"].append({
|
||||||
|
"step": "write_r67_32841",
|
||||||
|
"from_mode": 5,
|
||||||
|
"to_step": 3,
|
||||||
|
"note": "PLC will advance to R1000=6",
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
task["progress_percent"] = 80
|
||||||
|
|
||||||
|
elif current_mode == 6:
|
||||||
|
# Skip step 3: Mode 6 -> step 4 (Mode 7)
|
||||||
|
target_step = 4
|
||||||
|
task["current_step"] = "skipping_step_3"
|
||||||
|
task["progress_percent"] = 40
|
||||||
|
logger.info("Skip: Skipping step 3 from mode 6 to step 4 (R67=32968)")
|
||||||
|
|
||||||
|
if not writer.write_holding_register(67, 32968):
|
||||||
|
raise DTSOperationError("Failed to write R67=32968")
|
||||||
|
task["steps_completed"].append({
|
||||||
|
"step": "write_r67_32968",
|
||||||
|
"from_mode": 6,
|
||||||
|
"to_step": 4,
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
task["progress_percent"] = 70
|
||||||
|
|
||||||
|
if not writer.write_holding_register(1000, 7):
|
||||||
|
raise DTSOperationError("Failed to write R1000=7")
|
||||||
|
task["steps_completed"].append({
|
||||||
|
"step": "write_r1000_7",
|
||||||
|
"from_mode": 6,
|
||||||
|
"to_step": 4,
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
task["progress_percent"] = 80
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise DTSOperationError(f"Cannot skip from current mode {current_mode}. Skip only available from modes 5 (step 2) or 6 (step 3)")
|
||||||
|
|
||||||
|
# Sequence completed successfully
|
||||||
|
task["current_step"] = "completed"
|
||||||
|
task["status"] = "completed"
|
||||||
|
task["progress_percent"] = 100
|
||||||
|
task["end_time"] = datetime.now().isoformat()
|
||||||
|
|
||||||
|
logger.info(f"Skip: Sequence completed successfully from mode {current_mode} to step {target_step}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Error occurred during sequence
|
||||||
|
error_msg = str(e)
|
||||||
|
logger.error(f"Skip: Error during sequence: {error_msg}")
|
||||||
|
|
||||||
|
task["last_error"] = {
|
||||||
|
"message": error_msg,
|
||||||
|
"step": task["current_step"],
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
task["status"] = "failed"
|
||||||
|
task["current_step"] = "failed"
|
||||||
|
task["end_time"] = datetime.now().isoformat()
|
||||||
|
|
||||||
|
cache.add_error(f"Skip Failed: {error_msg}")
|
||||||
|
|
||||||
|
|
||||||
|
def start_dts_sequence_async():
|
||||||
|
"""Start DTS sequence asynchronously"""
|
||||||
|
# Check if another operation is running
|
||||||
|
for task in dts_operations.values():
|
||||||
|
if task["status"] == "running":
|
||||||
|
return False, "Operation already in progress", {"existing_task_id": task["task_id"]}
|
||||||
|
|
||||||
|
# Create new task
|
||||||
|
task_id = create_dts_task()
|
||||||
|
|
||||||
|
# Start background thread
|
||||||
|
thread = threading.Thread(target=execute_dts_sequence, args=(task_id,), daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
logger.info(f"DTS Start: Started async operation with task_id {task_id}")
|
||||||
|
return True, "DTS sequence started", {"task_id": task_id}
|
||||||
|
|
||||||
|
|
||||||
|
def start_stop_sequence_async():
|
||||||
|
"""Start watermaker stop sequence asynchronously"""
|
||||||
|
# Check if another operation is running
|
||||||
|
for task in dts_operations.values():
|
||||||
|
if task["status"] == "running":
|
||||||
|
return False, "Operation already in progress", {"existing_task_id": task["task_id"]}
|
||||||
|
|
||||||
|
# Create new task
|
||||||
|
task_id = create_dts_task()
|
||||||
|
task = dts_operations[task_id]
|
||||||
|
task["step_descriptions"].update({
|
||||||
|
"reading_system_mode": "Reading current system mode",
|
||||||
|
"stopping_mode_5": "Stopping from DTS mode (5)",
|
||||||
|
"stopping_mode_7": "Stopping from service mode (7)",
|
||||||
|
"stopping_mode_8_flush": "Stopping from flush mode (8)",
|
||||||
|
"completed": "Stop sequence completed",
|
||||||
|
"failed": "Stop sequence failed"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Start background thread
|
||||||
|
thread = threading.Thread(target=execute_stop_sequence, args=(task_id,), daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
logger.info(f"Stop: Started async operation with task_id {task_id}")
|
||||||
|
return True, "Stop sequence started", {"task_id": task_id}
|
||||||
|
|
||||||
|
|
||||||
|
def start_skip_sequence_async():
|
||||||
|
"""Start step skip sequence asynchronously"""
|
||||||
|
# Check if another operation is running
|
||||||
|
for task in dts_operations.values():
|
||||||
|
if task["status"] == "running":
|
||||||
|
return False, "Operation already in progress", {"existing_task_id": task["task_id"]}
|
||||||
|
|
||||||
|
# Create new task
|
||||||
|
task_id = create_dts_task()
|
||||||
|
task = dts_operations[task_id]
|
||||||
|
task["step_descriptions"].update({
|
||||||
|
"reading_system_mode": "Reading current system mode",
|
||||||
|
"skipping_step_2": "Skipping step 2 (mode 5 → step 3)",
|
||||||
|
"skipping_step_3": "Skipping step 3 (mode 6 → step 4)",
|
||||||
|
"completed": "Skip sequence completed",
|
||||||
|
"failed": "Skip sequence failed"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Start background thread
|
||||||
|
thread = threading.Thread(target=execute_skip_sequence, args=(task_id,), daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
logger.info(f"Skip: Started async operation with task_id {task_id}")
|
||||||
|
return True, "Skip sequence started", {"task_id": task_id}
|
||||||
|
|
||||||
|
|
||||||
|
# DTS Control Endpoints
|
||||||
|
|
||||||
|
@dts_bp.route('/dts/start', methods=['POST'])
|
||||||
|
def start_dts():
|
||||||
|
"""Start DTS watermaker sequence asynchronously"""
|
||||||
|
success, message, details = start_dts_sequence_async()
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return create_success_response(
|
||||||
|
message,
|
||||||
|
{
|
||||||
|
"task_id": details["task_id"],
|
||||||
|
"status_endpoint": f"/api/dts/status/{details['task_id']}",
|
||||||
|
"polling_info": {
|
||||||
|
"recommended_interval": "1 second",
|
||||||
|
"check_status_at": f"/api/dts/status/{details['task_id']}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
202 # 202 Accepted (async operation started)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return create_error_response(
|
||||||
|
"Conflict",
|
||||||
|
message,
|
||||||
|
409, # 409 Conflict (operation already running)
|
||||||
|
details
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dts_bp.route('/dts/stop', methods=['POST'])
|
||||||
|
def stop_watermaker():
|
||||||
|
"""Stop watermaker sequence (mode-dependent)"""
|
||||||
|
success, message, details = start_stop_sequence_async()
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return create_success_response(
|
||||||
|
message,
|
||||||
|
{
|
||||||
|
"task_id": details["task_id"],
|
||||||
|
"status_endpoint": f"/api/dts/status/{details['task_id']}",
|
||||||
|
"polling_info": {
|
||||||
|
"recommended_interval": "1 second",
|
||||||
|
"check_status_at": f"/api/dts/status/{details['task_id']}"
|
||||||
|
},
|
||||||
|
"note": "Stop sequence varies by current mode (5, 7, or 8)"
|
||||||
|
},
|
||||||
|
202 # 202 Accepted (async operation started)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return create_error_response(
|
||||||
|
"Conflict",
|
||||||
|
message,
|
||||||
|
409, # 409 Conflict (operation already running)
|
||||||
|
details
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dts_bp.route('/dts/skip', methods=['POST'])
|
||||||
|
def skip_step():
|
||||||
|
"""Skip current step automatically (determines next step based on current mode)"""
|
||||||
|
success, message, details = start_skip_sequence_async()
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return create_success_response(
|
||||||
|
message,
|
||||||
|
{
|
||||||
|
"task_id": details["task_id"],
|
||||||
|
"status_endpoint": f"/api/dts/status/{details['task_id']}",
|
||||||
|
"polling_info": {
|
||||||
|
"recommended_interval": "1 second",
|
||||||
|
"check_status_at": f"/api/dts/status/{details['task_id']}"
|
||||||
|
},
|
||||||
|
"note": "Auto-skip: Mode 5 → Step 3, Mode 6 → Step 4",
|
||||||
|
"valid_from_modes": [5, 6]
|
||||||
|
},
|
||||||
|
202 # 202 Accepted (async operation started)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return create_error_response(
|
||||||
|
"Conflict",
|
||||||
|
message,
|
||||||
|
409, # 409 Conflict (operation already running)
|
||||||
|
details
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dts_bp.route('/dts/status')
|
||||||
|
@dts_bp.route('/dts/status/<task_id>')
|
||||||
|
def get_dts_status(task_id=None):
|
||||||
|
"""Get DTS operation status for specific task or latest task"""
|
||||||
|
if task_id:
|
||||||
|
# Get specific task
|
||||||
|
task = dts_operations.get(task_id)
|
||||||
|
if not task:
|
||||||
|
return create_error_response(
|
||||||
|
"Not Found",
|
||||||
|
f"Task {task_id} not found",
|
||||||
|
404,
|
||||||
|
{"available_tasks": list(dts_operations.keys())}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add user-friendly progress info
|
||||||
|
task_response = dict(task)
|
||||||
|
task_response["step_description"] = task["step_descriptions"].get(task["current_step"], task["current_step"])
|
||||||
|
task_response["is_complete"] = task["status"] in ["completed", "failed", "cancelled"]
|
||||||
|
task_response["is_running"] = task["status"] == "running"
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"task": task_response,
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# Get latest task or all tasks summary
|
||||||
|
latest_task = get_latest_dts_task()
|
||||||
|
|
||||||
|
if latest_task:
|
||||||
|
latest_task["step_description"] = latest_task["step_descriptions"].get(
|
||||||
|
latest_task["current_step"], latest_task["current_step"]
|
||||||
|
)
|
||||||
|
latest_task["is_complete"] = latest_task["status"] in ["completed", "failed", "cancelled"]
|
||||||
|
latest_task["is_running"] = latest_task["status"] == "running"
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"latest_task": latest_task,
|
||||||
|
"total_tasks": len(dts_operations),
|
||||||
|
"active_tasks": [t["task_id"] for t in dts_operations.values() if t["status"] == "running"],
|
||||||
|
"all_task_ids": list(dts_operations.keys())[-5:], # Last 5 task IDs
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@dts_bp.route('/dts/cancel/<task_id>', methods=['POST'])
|
||||||
|
def cancel_dts_task(task_id):
|
||||||
|
"""Cancel a running DTS task (if possible)"""
|
||||||
|
task = dts_operations.get(task_id)
|
||||||
|
if not task:
|
||||||
|
return create_error_response(
|
||||||
|
"Not Found",
|
||||||
|
f"Task {task_id} not found",
|
||||||
|
404
|
||||||
|
)
|
||||||
|
|
||||||
|
if task["status"] != "running":
|
||||||
|
return create_error_response(
|
||||||
|
"Bad Request",
|
||||||
|
f"Task {task_id} is not running (status: {task['status']})",
|
||||||
|
400
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mark task as cancelled
|
||||||
|
task["status"] = "cancelled"
|
||||||
|
task["current_step"] = "cancelled"
|
||||||
|
task["end_time"] = datetime.now().isoformat()
|
||||||
|
task["last_error"] = {
|
||||||
|
"message": "Operation cancelled by user",
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
return create_success_response(
|
||||||
|
f"Task {task_id} marked as cancelled",
|
||||||
|
{"note": "Background operation may continue briefly"}
|
||||||
|
)
|
||||||
46
watermaker_plc_api/controllers/outputs_controller.py
Normal file
46
watermaker_plc_api/controllers/outputs_controller.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""
|
||||||
|
Outputs controller for digital output control endpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify
|
||||||
|
from ..services.data_cache import get_data_cache
|
||||||
|
from ..utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# Create blueprint
|
||||||
|
outputs_bp = Blueprint('outputs', __name__)
|
||||||
|
|
||||||
|
# Initialize services
|
||||||
|
cache = get_data_cache()
|
||||||
|
|
||||||
|
|
||||||
|
@outputs_bp.route('/outputs')
|
||||||
|
def get_outputs():
|
||||||
|
"""Get output control data"""
|
||||||
|
outputs = cache.get_outputs()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"outputs": outputs,
|
||||||
|
"last_update": cache.get_last_update(),
|
||||||
|
"count": len(outputs)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@outputs_bp.route('/outputs/active')
|
||||||
|
def get_active_outputs():
|
||||||
|
"""Get only active output controls"""
|
||||||
|
active_outputs = cache.get_active_outputs()
|
||||||
|
|
||||||
|
# Calculate total active outputs across all registers
|
||||||
|
total_active = sum(
|
||||||
|
len(output.get("active_bits", []))
|
||||||
|
for output in active_outputs.values()
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"active_outputs": active_outputs,
|
||||||
|
"total_active": total_active,
|
||||||
|
"register_count": len(active_outputs),
|
||||||
|
"last_update": cache.get_last_update()
|
||||||
|
})
|
||||||
75
watermaker_plc_api/controllers/sensors_controller.py
Normal file
75
watermaker_plc_api/controllers/sensors_controller.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"""
|
||||||
|
Sensors controller for sensor data and runtime/water counter endpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify
|
||||||
|
from ..services.data_cache import get_data_cache
|
||||||
|
from ..utils.logger import get_logger
|
||||||
|
from ..utils.error_handler import create_error_response
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# Create blueprint
|
||||||
|
sensors_bp = Blueprint('sensors', __name__)
|
||||||
|
|
||||||
|
# Initialize services
|
||||||
|
cache = get_data_cache()
|
||||||
|
|
||||||
|
|
||||||
|
@sensors_bp.route('/sensors')
|
||||||
|
def get_sensors():
|
||||||
|
"""Get all sensor data"""
|
||||||
|
sensors = cache.get_sensors()
|
||||||
|
last_update = cache.get_last_update()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"sensors": sensors,
|
||||||
|
"last_update": last_update,
|
||||||
|
"count": len(sensors)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@sensors_bp.route('/sensors/category/<category>')
|
||||||
|
def get_sensors_by_category(category):
|
||||||
|
"""Get sensors filtered by category"""
|
||||||
|
valid_categories = ['system', 'pressure', 'temperature', 'flow', 'quality']
|
||||||
|
|
||||||
|
if category not in valid_categories:
|
||||||
|
return create_error_response(
|
||||||
|
"Bad Request",
|
||||||
|
f"Invalid category '{category}'. Valid categories: {', '.join(valid_categories)}",
|
||||||
|
400
|
||||||
|
)
|
||||||
|
|
||||||
|
filtered_sensors = cache.get_sensors_by_category(category)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"category": category,
|
||||||
|
"sensors": filtered_sensors,
|
||||||
|
"count": len(filtered_sensors),
|
||||||
|
"last_update": cache.get_last_update()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@sensors_bp.route('/runtime')
|
||||||
|
def get_runtime():
|
||||||
|
"""Get runtime hours data (IEEE 754 float)"""
|
||||||
|
runtime_data = cache.get_runtime()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"runtime": runtime_data,
|
||||||
|
"last_update": cache.get_last_update(),
|
||||||
|
"count": len(runtime_data)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@sensors_bp.route('/water_counters')
|
||||||
|
def get_water_counters():
|
||||||
|
"""Get water production counter data (gallon totals)"""
|
||||||
|
water_counter_data = cache.get_water_counters()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"water_counters": water_counter_data,
|
||||||
|
"last_update": cache.get_last_update(),
|
||||||
|
"count": len(water_counter_data)
|
||||||
|
})
|
||||||
359
watermaker_plc_api/controllers/system_controller.py
Normal file
359
watermaker_plc_api/controllers/system_controller.py
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
"""
|
||||||
|
System controller for status, configuration, and general API endpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
from datetime import datetime
|
||||||
|
from ..config import Config
|
||||||
|
from ..services.data_cache import get_data_cache
|
||||||
|
from ..services.plc_connection import get_plc_connection
|
||||||
|
from ..services.register_reader import RegisterReader
|
||||||
|
from ..services.register_writer import RegisterWriter
|
||||||
|
from ..utils.logger import get_logger
|
||||||
|
from ..utils.error_handler import create_error_response, create_success_response, RegisterWriteError, PLCConnectionError
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# Create blueprint
|
||||||
|
system_bp = Blueprint('system', __name__)
|
||||||
|
|
||||||
|
# Initialize services
|
||||||
|
cache = get_data_cache()
|
||||||
|
plc = get_plc_connection()
|
||||||
|
reader = RegisterReader()
|
||||||
|
writer = RegisterWriter()
|
||||||
|
|
||||||
|
|
||||||
|
@system_bp.route('/status')
|
||||||
|
def get_status():
|
||||||
|
"""Get connection and system status"""
|
||||||
|
plc_status = plc.get_connection_status()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"connection_status": cache.get_connection_status(),
|
||||||
|
"last_update": cache.get_last_update(),
|
||||||
|
"plc_config": {
|
||||||
|
"ip": plc_status["ip_address"],
|
||||||
|
"port": plc_status["port"],
|
||||||
|
"connected": plc_status["connected"]
|
||||||
|
},
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@system_bp.route('/all')
|
||||||
|
def get_all_data():
|
||||||
|
"""Get all PLC data in one response"""
|
||||||
|
all_data = cache.get_all_data()
|
||||||
|
summary = cache.get_summary_stats()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"status": {
|
||||||
|
"connection_status": all_data["connection_status"],
|
||||||
|
"last_update": all_data["last_update"],
|
||||||
|
"connected": plc.is_connected
|
||||||
|
},
|
||||||
|
"sensors": all_data["sensors"],
|
||||||
|
"timers": all_data["timers"],
|
||||||
|
"rtc": all_data["rtc"],
|
||||||
|
"outputs": all_data["outputs"],
|
||||||
|
"runtime": all_data["runtime"],
|
||||||
|
"water_counters": all_data["water_counters"],
|
||||||
|
"summary": summary
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@system_bp.route('/select')
|
||||||
|
def get_selected_data():
|
||||||
|
"""Get only selected variables by groups and/or keys to reduce bandwidth and PLC traffic"""
|
||||||
|
# Get query parameters
|
||||||
|
groups_param = request.args.get('groups', '')
|
||||||
|
keys_param = request.args.get('keys', '')
|
||||||
|
|
||||||
|
# Parse groups and keys
|
||||||
|
requested_groups = [g.strip() for g in groups_param.split(',') if g.strip()] if groups_param else []
|
||||||
|
requested_keys = [k.strip() for k in keys_param.split(',') if k.strip()] if keys_param else []
|
||||||
|
|
||||||
|
if not requested_groups and not requested_keys:
|
||||||
|
return create_error_response(
|
||||||
|
"Bad Request",
|
||||||
|
"Must specify either 'groups' or 'keys' parameter",
|
||||||
|
400,
|
||||||
|
{
|
||||||
|
"usage": {
|
||||||
|
"groups": "Comma-separated list: system,pressure,temperature,flow,quality,fwf_timer,dts_timer,rtc,outputs,runtime,water_counters",
|
||||||
|
"keys": "Comma-separated list of register numbers: 1000,1003,1017,136,138,5014,5024",
|
||||||
|
"examples": [
|
||||||
|
"/api/select?groups=temperature,pressure",
|
||||||
|
"/api/select?keys=1036,1003,1017",
|
||||||
|
"/api/select?groups=dts_timer&keys=1036",
|
||||||
|
"/api/select?groups=runtime,water_counters"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check PLC connection
|
||||||
|
if not plc.is_connected:
|
||||||
|
if not plc.connect():
|
||||||
|
return create_error_response(
|
||||||
|
"Service Unavailable",
|
||||||
|
"PLC connection failed",
|
||||||
|
503,
|
||||||
|
{"connection_status": cache.get_connection_status()}
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Read selective data
|
||||||
|
result = reader.read_selective_data(requested_groups, requested_keys)
|
||||||
|
result["timestamp"] = datetime.now().isoformat()
|
||||||
|
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error reading selective data: {e}")
|
||||||
|
return create_error_response(
|
||||||
|
"Internal Server Error",
|
||||||
|
f"Failed to read selective data: {str(e)}",
|
||||||
|
500
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@system_bp.route('/errors')
|
||||||
|
def get_errors():
|
||||||
|
"""Get recent errors"""
|
||||||
|
errors = cache.get_errors(limit=10)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"errors": errors,
|
||||||
|
"count": len(errors)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@system_bp.route('/write/register', methods=['POST'])
|
||||||
|
def write_register():
|
||||||
|
"""Write to a single holding register"""
|
||||||
|
try:
|
||||||
|
# Check if request has JSON data
|
||||||
|
if not request.is_json:
|
||||||
|
return create_error_response(
|
||||||
|
"Bad Request",
|
||||||
|
"Request must be JSON with Content-Type: application/json",
|
||||||
|
400
|
||||||
|
)
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
if not data or 'address' not in data or 'value' not in data:
|
||||||
|
return create_error_response(
|
||||||
|
"Bad Request",
|
||||||
|
"Must provide 'address' and 'value' in JSON body",
|
||||||
|
400
|
||||||
|
)
|
||||||
|
|
||||||
|
address = int(data['address'])
|
||||||
|
value = int(data['value'])
|
||||||
|
|
||||||
|
# Validate the write operation
|
||||||
|
is_valid, error_msg = writer.validate_write_operation(address, value)
|
||||||
|
if not is_valid:
|
||||||
|
return create_error_response("Bad Request", error_msg, 400)
|
||||||
|
|
||||||
|
# Perform the write
|
||||||
|
success = writer.write_holding_register(address, value)
|
||||||
|
|
||||||
|
return create_success_response(
|
||||||
|
f"Successfully wrote {value} to register {address}",
|
||||||
|
{
|
||||||
|
"address": address,
|
||||||
|
"value": value,
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
return create_error_response(
|
||||||
|
"Bad Request",
|
||||||
|
f"Invalid address or value: {e}",
|
||||||
|
400
|
||||||
|
)
|
||||||
|
except (RegisterWriteError, PLCConnectionError) as e:
|
||||||
|
return create_error_response(
|
||||||
|
"Service Unavailable",
|
||||||
|
str(e),
|
||||||
|
503
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error in write_register: {e}")
|
||||||
|
return create_error_response(
|
||||||
|
"Internal Server Error",
|
||||||
|
"An unexpected error occurred",
|
||||||
|
500
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@system_bp.route('/config')
|
||||||
|
def get_config():
|
||||||
|
"""Get API configuration and available endpoints"""
|
||||||
|
return jsonify({
|
||||||
|
"api_version": Config.API_VERSION,
|
||||||
|
"endpoints": {
|
||||||
|
"/api/status": "Connection and system status",
|
||||||
|
"/api/sensors": "All sensor data",
|
||||||
|
"/api/sensors/category/<category>": "Sensors by category (system, pressure, temperature, flow, quality)",
|
||||||
|
"/api/timers": "All timer data",
|
||||||
|
"/api/timers/dts": "DTS timer data",
|
||||||
|
"/api/timers/fwf": "FWF timer data",
|
||||||
|
"/api/rtc": "Real-time clock data",
|
||||||
|
"/api/outputs": "Output control data",
|
||||||
|
"/api/outputs/active": "Active output controls only",
|
||||||
|
"/api/runtime": "Runtime hours data (IEEE 754 float)",
|
||||||
|
"/api/water_counters": "Water production counters (gallon totals)",
|
||||||
|
"/api/all": "All data in one response",
|
||||||
|
"/api/select": "Selective data retrieval (groups and/or keys) - BANDWIDTH OPTIMIZED",
|
||||||
|
"/api/errors": "Recent errors",
|
||||||
|
"/api/config": "This configuration",
|
||||||
|
"/api/dts/start": "POST - Start DTS watermaker sequence (async)",
|
||||||
|
"/api/dts/stop": "POST - Stop watermaker sequence (async, mode-dependent)",
|
||||||
|
"/api/dts/skip": "POST - Skip current step automatically (async)",
|
||||||
|
"/api/dts/status": "Get latest DTS operation status",
|
||||||
|
"/api/dts/status/<task_id>": "Get specific DTS task status",
|
||||||
|
"/api/dts/cancel/<task_id>": "POST - Cancel running DTS task",
|
||||||
|
"/api/write/register": "POST - Write single holding register"
|
||||||
|
},
|
||||||
|
"control_endpoints": {
|
||||||
|
"/api/dts/start": {
|
||||||
|
"method": "POST",
|
||||||
|
"description": "Start DTS watermaker sequence (ASYNC)",
|
||||||
|
"parameters": "None required",
|
||||||
|
"returns": "task_id for status polling",
|
||||||
|
"response_time": "< 100ms (immediate)",
|
||||||
|
"sequence": [
|
||||||
|
"Check R1000 value",
|
||||||
|
"Set R1000=34 if not already",
|
||||||
|
"Wait 2 seconds",
|
||||||
|
"Set R71=256",
|
||||||
|
"Wait 2 seconds",
|
||||||
|
"Set R71=0",
|
||||||
|
"Monitor R138 for valve positioning",
|
||||||
|
"Set R1000=5 to start DTS mode"
|
||||||
|
],
|
||||||
|
"polling": {
|
||||||
|
"status_endpoint": "/api/dts/status/{task_id}",
|
||||||
|
"recommended_interval": "1 second",
|
||||||
|
"total_duration": "~10 seconds"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/dts/stop": {
|
||||||
|
"method": "POST",
|
||||||
|
"description": "Stop watermaker sequence (ASYNC, mode-dependent)",
|
||||||
|
"parameters": "None required",
|
||||||
|
"returns": "task_id for status polling",
|
||||||
|
"response_time": "< 100ms (immediate)",
|
||||||
|
"mode_sequences": {
|
||||||
|
"mode_5_dts": "R71=512, wait 1s, R71=0, R1000=8",
|
||||||
|
"mode_7_service": "R71=513, wait 1s, R71=0, R1000=8",
|
||||||
|
"mode_8_flush": "R71=1024, wait 1s, R71=0, R1000=2"
|
||||||
|
},
|
||||||
|
"note": "Watermaker always ends with flush screen (mode 8)"
|
||||||
|
},
|
||||||
|
"/api/dts/skip": {
|
||||||
|
"method": "POST",
|
||||||
|
"description": "Skip current step automatically (ASYNC)",
|
||||||
|
"parameters": "None required - auto-determines next step",
|
||||||
|
"returns": "task_id for status polling",
|
||||||
|
"response_time": "< 100ms (immediate)",
|
||||||
|
"auto_logic": {
|
||||||
|
"from_mode_5": "Skip step 2 → step 3: R67=32841 (PLC advances to mode 6)",
|
||||||
|
"from_mode_6": "Skip step 3 → step 4: R67=32968, wait 1s, R1000=7"
|
||||||
|
},
|
||||||
|
"valid_from_modes": [5, 6],
|
||||||
|
"example": "/api/dts/skip"
|
||||||
|
},
|
||||||
|
"/api/write/register": {
|
||||||
|
"method": "POST",
|
||||||
|
"description": "Write single holding register",
|
||||||
|
"body": {"address": "register_number", "value": "value_to_write"},
|
||||||
|
"example": {"address": 1000, "value": 5}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"variable_groups": {
|
||||||
|
"system": {
|
||||||
|
"description": "System status and operational mode",
|
||||||
|
"keys": ["1000", "1036"],
|
||||||
|
"count": 2
|
||||||
|
},
|
||||||
|
"pressure": {
|
||||||
|
"description": "Water pressure sensors",
|
||||||
|
"keys": ["1003", "1007", "1008"],
|
||||||
|
"count": 3
|
||||||
|
},
|
||||||
|
"temperature": {
|
||||||
|
"description": "Temperature monitoring",
|
||||||
|
"keys": ["1017", "1125"],
|
||||||
|
"count": 2
|
||||||
|
},
|
||||||
|
"flow": {
|
||||||
|
"description": "Flow rate meters",
|
||||||
|
"keys": ["1120", "1121", "1122"],
|
||||||
|
"count": 3
|
||||||
|
},
|
||||||
|
"quality": {
|
||||||
|
"description": "Water quality (TDS) sensors",
|
||||||
|
"keys": ["1123", "1124"],
|
||||||
|
"count": 2
|
||||||
|
},
|
||||||
|
"fwf_timer": {
|
||||||
|
"description": "Fresh water flush timers",
|
||||||
|
"keys": ["136"],
|
||||||
|
"count": 1
|
||||||
|
},
|
||||||
|
"dts_timer": {
|
||||||
|
"description": "DTS process step timers",
|
||||||
|
"keys": ["138", "128", "129", "133", "135", "139"],
|
||||||
|
"count": 6
|
||||||
|
},
|
||||||
|
"rtc": {
|
||||||
|
"description": "Real-time clock registers",
|
||||||
|
"keys": ["513", "514", "516", "517", "518", "519"],
|
||||||
|
"count": 6
|
||||||
|
},
|
||||||
|
"outputs": {
|
||||||
|
"description": "Digital output controls",
|
||||||
|
"keys": ["257", "258", "259", "260", "264", "265"],
|
||||||
|
"count": 6
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"description": "System runtime hours (IEEE 754 float)",
|
||||||
|
"keys": ["5014"],
|
||||||
|
"count": 1,
|
||||||
|
"note": "32-bit float from register pairs R5014+R5015"
|
||||||
|
},
|
||||||
|
"water_counters": {
|
||||||
|
"description": "Water production counters (gallon totals)",
|
||||||
|
"keys": ["5024", "5028", "5032", "5034"],
|
||||||
|
"count": 4,
|
||||||
|
"note": "32-bit floats from register pairs (Single/Double/DTS Total/Since Last)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selective_api_usage": {
|
||||||
|
"endpoint": "/api/select",
|
||||||
|
"description": "Retrieve only specified variables to reduce bandwidth and PLC traffic",
|
||||||
|
"parameters": {
|
||||||
|
"groups": "Comma-separated group names (system,pressure,temperature,flow,quality,fwf_timer,dts_timer,rtc,outputs,runtime,water_counters)",
|
||||||
|
"keys": "Comma-separated register numbers (1000,1003,1017,136,etc.)"
|
||||||
|
},
|
||||||
|
"examples": {
|
||||||
|
"temperature_and_pressure": "/api/select?groups=temperature,pressure",
|
||||||
|
"specific_sensors": "/api/select?keys=1036,1003,1017,1121",
|
||||||
|
"dts_monitoring": "/api/select?groups=dts_timer&keys=1036",
|
||||||
|
"critical_only": "/api/select?keys=1036,1003,1121,1123",
|
||||||
|
"runtime_and_counters": "/api/select?groups=runtime,water_counters"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"total_variables": 36,
|
||||||
|
"update_interval": f"{Config.DATA_UPDATE_INTERVAL} seconds (full scan) / on-demand (selective)",
|
||||||
|
"plc_config": {
|
||||||
|
"ip": Config.PLC_IP,
|
||||||
|
"port": Config.PLC_PORT
|
||||||
|
}
|
||||||
|
})
|
||||||
78
watermaker_plc_api/controllers/timers_controller.py
Normal file
78
watermaker_plc_api/controllers/timers_controller.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""
|
||||||
|
Timers controller for timer and RTC data endpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify
|
||||||
|
from ..services.data_cache import get_data_cache
|
||||||
|
from ..utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# Create blueprint
|
||||||
|
timers_bp = Blueprint('timers', __name__)
|
||||||
|
|
||||||
|
# Initialize services
|
||||||
|
cache = get_data_cache()
|
||||||
|
|
||||||
|
|
||||||
|
@timers_bp.route('/timers')
|
||||||
|
def get_timers():
|
||||||
|
"""Get all timer data"""
|
||||||
|
timers = cache.get_timers()
|
||||||
|
active_timers = cache.get_active_timers()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"timers": timers,
|
||||||
|
"last_update": cache.get_last_update(),
|
||||||
|
"active_timers": active_timers,
|
||||||
|
"total_count": len(timers),
|
||||||
|
"active_count": len(active_timers)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@timers_bp.route('/timers/dts')
|
||||||
|
def get_dts_timers():
|
||||||
|
"""Get DTS timer data"""
|
||||||
|
dts_timers = cache.get_timers_by_category("dts_timer")
|
||||||
|
active_dts_timers = [
|
||||||
|
addr for addr, timer in dts_timers.items()
|
||||||
|
if timer.get("active", False)
|
||||||
|
]
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"dts_timers": dts_timers,
|
||||||
|
"active_timers": active_dts_timers,
|
||||||
|
"total_count": len(dts_timers),
|
||||||
|
"active_count": len(active_dts_timers),
|
||||||
|
"last_update": cache.get_last_update()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@timers_bp.route('/timers/fwf')
|
||||||
|
def get_fwf_timers():
|
||||||
|
"""Get Fresh Water Flush timer data"""
|
||||||
|
fwf_timers = cache.get_timers_by_category("fwf_timer")
|
||||||
|
active_fwf_timers = [
|
||||||
|
addr for addr, timer in fwf_timers.items()
|
||||||
|
if timer.get("active", False)
|
||||||
|
]
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"fwf_timers": fwf_timers,
|
||||||
|
"active_timers": active_fwf_timers,
|
||||||
|
"total_count": len(fwf_timers),
|
||||||
|
"active_count": len(active_fwf_timers),
|
||||||
|
"last_update": cache.get_last_update()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@timers_bp.route('/rtc')
|
||||||
|
def get_rtc():
|
||||||
|
"""Get real-time clock data"""
|
||||||
|
rtc_data = cache.get_rtc()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"rtc": rtc_data,
|
||||||
|
"last_update": cache.get_last_update(),
|
||||||
|
"count": len(rtc_data)
|
||||||
|
})
|
||||||
20
watermaker_plc_api/models/__init__.py
Normal file
20
watermaker_plc_api/models/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"""
|
||||||
|
Data models and register mappings for PLC variables.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .sensor_mappings import KNOWN_SENSORS, get_sensor_by_category
|
||||||
|
from .timer_mappings import TIMER_REGISTERS, RTC_REGISTERS, get_timer_by_category
|
||||||
|
from .output_mappings import OUTPUT_CONTROLS, get_output_controls
|
||||||
|
from .runtime_mappings import RUNTIME_REGISTERS, WATER_COUNTER_REGISTERS
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'KNOWN_SENSORS',
|
||||||
|
'TIMER_REGISTERS',
|
||||||
|
'RTC_REGISTERS',
|
||||||
|
'OUTPUT_CONTROLS',
|
||||||
|
'RUNTIME_REGISTERS',
|
||||||
|
'WATER_COUNTER_REGISTERS',
|
||||||
|
'get_sensor_by_category',
|
||||||
|
'get_timer_by_category',
|
||||||
|
'get_output_controls'
|
||||||
|
]
|
||||||
151
watermaker_plc_api/models/output_mappings.py
Normal file
151
watermaker_plc_api/models/output_mappings.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
"""
|
||||||
|
Output control register mappings and configuration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, List, Any
|
||||||
|
|
||||||
|
# Output control mappings
|
||||||
|
OUTPUT_CONTROLS = {
|
||||||
|
257: {"name": "Low Pressure Pump", "register": 40017, "bit": 0},
|
||||||
|
258: {"name": "High Pressure Pump", "register": 40017, "bit": 1},
|
||||||
|
259: {"name": "Product Divert Valve", "register": 40017, "bit": 2},
|
||||||
|
260: {"name": "Flush solenoid", "register": 40017, "bit": 3},
|
||||||
|
264: {"name": "Double Pass Solenoid", "register": 40017, "bit": 7},
|
||||||
|
265: {"name": "Shore Feed Solenoid", "register": 40017, "bit": 8}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_output_controls() -> Dict[int, Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get all output control configurations.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict of output control configurations
|
||||||
|
"""
|
||||||
|
return OUTPUT_CONTROLS.copy()
|
||||||
|
|
||||||
|
|
||||||
|
def get_output_registers() -> List[int]:
|
||||||
|
"""
|
||||||
|
Get list of unique output register addresses.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of register addresses (e.g., [40017, 40018, ...])
|
||||||
|
"""
|
||||||
|
registers = set()
|
||||||
|
for config in OUTPUT_CONTROLS.values():
|
||||||
|
registers.add(config["register"])
|
||||||
|
return sorted(list(registers))
|
||||||
|
|
||||||
|
|
||||||
|
def get_output_addresses_by_group(group: str) -> List[int]:
|
||||||
|
"""
|
||||||
|
Get output addresses for the outputs group.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
group: Group name ("outputs")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of output control addresses
|
||||||
|
"""
|
||||||
|
if group == "outputs":
|
||||||
|
return list(OUTPUT_CONTROLS.keys())
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def get_controls_by_register(register: int) -> Dict[int, Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get all output controls for a specific register.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
register: Register address (e.g., 40017)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict of controls mapped to that register
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
addr: config for addr, config in OUTPUT_CONTROLS.items()
|
||||||
|
if config["register"] == register
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_output_address(address: int) -> bool:
|
||||||
|
"""
|
||||||
|
Check if an address is a known output control.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
address: Output control address to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if address is a known output control
|
||||||
|
"""
|
||||||
|
return address in OUTPUT_CONTROLS
|
||||||
|
|
||||||
|
|
||||||
|
def get_output_info(address: int) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get configuration info for a specific output control.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
address: Output control address
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Output configuration dict or empty dict if not found
|
||||||
|
"""
|
||||||
|
return OUTPUT_CONTROLS.get(address, {})
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_modbus_address(register: int) -> int:
|
||||||
|
"""
|
||||||
|
Convert holding register address to Modbus address.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
register: Holding register address (e.g., 40017)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Modbus address (e.g., 16)
|
||||||
|
"""
|
||||||
|
return register - 40001
|
||||||
|
|
||||||
|
|
||||||
|
def extract_bit_value(register_value: int, bit_position: int) -> int:
|
||||||
|
"""
|
||||||
|
Extract a specific bit value from a register.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
register_value: Full register value
|
||||||
|
bit_position: Bit position (0-15)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Bit value (0 or 1)
|
||||||
|
"""
|
||||||
|
return (register_value >> bit_position) & 1
|
||||||
|
|
||||||
|
|
||||||
|
def create_output_bit_info(register: int, register_value: int) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Create bit information for all outputs in a register.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
register: Register address
|
||||||
|
register_value: Current register value
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of bit information dicts
|
||||||
|
"""
|
||||||
|
bits = []
|
||||||
|
|
||||||
|
for bit in range(16):
|
||||||
|
bit_value = extract_bit_value(register_value, bit)
|
||||||
|
output_addr = ((register - 40017) * 16) + (bit + 1) + 256
|
||||||
|
|
||||||
|
control_info = OUTPUT_CONTROLS.get(output_addr, {})
|
||||||
|
bits.append({
|
||||||
|
"bit": bit,
|
||||||
|
"address": output_addr,
|
||||||
|
"value": bit_value,
|
||||||
|
"name": control_info.get("name", f"Output {output_addr}"),
|
||||||
|
"active": bit_value == 1
|
||||||
|
})
|
||||||
|
|
||||||
|
return bits
|
||||||
165
watermaker_plc_api/models/runtime_mappings.py
Normal file
165
watermaker_plc_api/models/runtime_mappings.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
"""
|
||||||
|
Runtime and water counter register mappings (32-bit values from register pairs).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, List, Any
|
||||||
|
|
||||||
|
# Runtime register mappings (32-bit IEEE 754 float pairs)
|
||||||
|
RUNTIME_REGISTERS = {
|
||||||
|
5014: {"name": "Runtime Hours", "scale": "ieee754", "unit": "hours", "category": "runtime",
|
||||||
|
"pair_register": 5015, "description": "Total system runtime"}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Water counter register mappings (32-bit gallon counters)
|
||||||
|
WATER_COUNTER_REGISTERS = {
|
||||||
|
5024: {"name": "Single-Pass Total Gallons", "scale": "gallon_counter", "unit": "gallons", "category": "water_counters",
|
||||||
|
"pair_register": 5025, "description": "Total single-pass water produced"},
|
||||||
|
5026: {"name": "Single-Pass Total Gallons since last", "scale": "gallon_counter", "unit": "gallons", "category": "water_counters",
|
||||||
|
"pair_register": 5027, "description": "Total single-pass water produced since last"},
|
||||||
|
5028: {"name": "Double-Pass Total Gallons", "scale": "gallon_counter", "unit": "gallons", "category": "water_counters",
|
||||||
|
"pair_register": 5029, "description": "Total double-pass water produced"},
|
||||||
|
5030: {"name": "Double-Pass Total Gallons since last", "scale": "gallon_counter", "unit": "gallons", "category": "water_counters",
|
||||||
|
"pair_register": 5031, "description": "Total double-pass water produced since last"},
|
||||||
|
5032: {"name": "DTS Total Gallons", "scale": "gallon_counter", "unit": "gallons", "category": "water_counters",
|
||||||
|
"pair_register": 5033, "description": "Total DTS water produced"},
|
||||||
|
5034: {"name": "DTS Since Last Gallons", "scale": "gallon_counter", "unit": "gallons", "category": "water_counters",
|
||||||
|
"pair_register": 5035, "description": "DTS water since last reset"}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_runtime_registers() -> Dict[int, Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get all runtime register configurations.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict of runtime register configurations
|
||||||
|
"""
|
||||||
|
return RUNTIME_REGISTERS.copy()
|
||||||
|
|
||||||
|
|
||||||
|
def get_water_counter_registers() -> Dict[int, Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get all water counter register configurations.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict of water counter register configurations
|
||||||
|
"""
|
||||||
|
return WATER_COUNTER_REGISTERS.copy()
|
||||||
|
|
||||||
|
|
||||||
|
def get_runtime_addresses_by_group(group: str) -> List[int]:
|
||||||
|
"""
|
||||||
|
Get register addresses for runtime-related groups.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
group: Group name (runtime, water_counters)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of register addresses
|
||||||
|
"""
|
||||||
|
if group == "runtime":
|
||||||
|
return list(RUNTIME_REGISTERS.keys())
|
||||||
|
elif group == "water_counters":
|
||||||
|
return list(WATER_COUNTER_REGISTERS.keys())
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def validate_runtime_address(address: int) -> bool:
|
||||||
|
"""
|
||||||
|
Check if an address is a known runtime register.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
address: Register address to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if address is a known runtime register
|
||||||
|
"""
|
||||||
|
return address in RUNTIME_REGISTERS
|
||||||
|
|
||||||
|
|
||||||
|
def validate_water_counter_address(address: int) -> bool:
|
||||||
|
"""
|
||||||
|
Check if an address is a known water counter register.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
address: Register address to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if address is a known water counter register
|
||||||
|
"""
|
||||||
|
return address in WATER_COUNTER_REGISTERS
|
||||||
|
|
||||||
|
|
||||||
|
def get_runtime_info(address: int) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get configuration info for a specific runtime register.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
address: Runtime register address
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Runtime configuration dict or empty dict if not found
|
||||||
|
"""
|
||||||
|
return RUNTIME_REGISTERS.get(address, {})
|
||||||
|
|
||||||
|
|
||||||
|
def get_water_counter_info(address: int) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get configuration info for a specific water counter register.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
address: Water counter register address
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Water counter configuration dict or empty dict if not found
|
||||||
|
"""
|
||||||
|
return WATER_COUNTER_REGISTERS.get(address, {})
|
||||||
|
|
||||||
|
|
||||||
|
def get_register_pair(address: int) -> tuple:
|
||||||
|
"""
|
||||||
|
Get the register pair (high, low) for a given address.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
address: Primary register address
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (high_register, low_register) or (None, None) if not found
|
||||||
|
"""
|
||||||
|
# Check runtime registers
|
||||||
|
if address in RUNTIME_REGISTERS:
|
||||||
|
config = RUNTIME_REGISTERS[address]
|
||||||
|
return (address, config["pair_register"])
|
||||||
|
|
||||||
|
# Check water counter registers
|
||||||
|
if address in WATER_COUNTER_REGISTERS:
|
||||||
|
config = WATER_COUNTER_REGISTERS[address]
|
||||||
|
return (address, config["pair_register"])
|
||||||
|
|
||||||
|
return (None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_32bit_addresses() -> List[int]:
|
||||||
|
"""
|
||||||
|
Get all addresses that use 32-bit register pairs.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of primary register addresses
|
||||||
|
"""
|
||||||
|
addresses = []
|
||||||
|
addresses.extend(RUNTIME_REGISTERS.keys())
|
||||||
|
addresses.extend(WATER_COUNTER_REGISTERS.keys())
|
||||||
|
return sorted(addresses)
|
||||||
|
|
||||||
|
|
||||||
|
def is_32bit_register(address: int) -> bool:
|
||||||
|
"""
|
||||||
|
Check if an address represents a 32-bit register pair.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
address: Register address to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if address is part of a 32-bit register pair
|
||||||
|
"""
|
||||||
|
return address in RUNTIME_REGISTERS or address in WATER_COUNTER_REGISTERS
|
||||||
126
watermaker_plc_api/models/sensor_mappings.py
Normal file
126
watermaker_plc_api/models/sensor_mappings.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
"""
|
||||||
|
Sensor register mappings and configuration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, List, Any
|
||||||
|
|
||||||
|
# Known sensor mappings with categorization
|
||||||
|
KNOWN_SENSORS = {
|
||||||
|
# System Status & Control
|
||||||
|
1000: {"name": "System Mode", "scale": "direct", "unit": "", "category": "system",
|
||||||
|
"values": {
|
||||||
|
"65535": "Standby / Screen Saver",
|
||||||
|
"2": "Idle / home",
|
||||||
|
"3": "alarm list",
|
||||||
|
"34": "DTS requested - step 1 press and hold DTS to START",
|
||||||
|
"5": "DTS mode startup - step 2, flush with shore pressure - 180 second timer",
|
||||||
|
"6": "DTS mode startup - step 3, high pressure pump on, 60 second timer product valve divert",
|
||||||
|
"7": "DTS mode running - step 4 high pressure pump on - water flowing to tank",
|
||||||
|
"8": "fresh water flush",
|
||||||
|
"9": "setup screen",
|
||||||
|
"15": "toggle flush valve , quality valve, low pressure pump, high pressure pump, enable/disable service mode",
|
||||||
|
"16": "toggle double pass / feed valve (from service menu)",
|
||||||
|
"17": "needle valve control (from service menu)",
|
||||||
|
"18": "sensor reading overview (from service menu)",
|
||||||
|
"31": "Overview System diagram Map",
|
||||||
|
"32": "contact support screen",
|
||||||
|
"33": "seawater home - pick single or double"
|
||||||
|
}},
|
||||||
|
1036: {"name": "System Status", "scale": "direct", "unit": "", "category": "system",
|
||||||
|
"values": {"0": "Standby", "5": "FWF", "7": "Service Mode"}},
|
||||||
|
|
||||||
|
# Pressure Sensors
|
||||||
|
1003: {"name": "Feed Pressure", "scale": "direct", "unit": "PSI", "category": "pressure"},
|
||||||
|
1007: {"name": "High Pressure #2", "scale": "direct", "unit": "PSI", "category": "pressure"},
|
||||||
|
1008: {"name": "High Pressure #1", "scale": "direct", "unit": "PSI", "category": "pressure"},
|
||||||
|
|
||||||
|
# Temperature Sensors
|
||||||
|
1017: {"name": "Water Temperature", "scale": "÷10", "unit": "°F", "category": "temperature"},
|
||||||
|
1125: {"name": "System Temperature", "scale": "÷10", "unit": "°F", "category": "temperature"},
|
||||||
|
|
||||||
|
# Flow Meters
|
||||||
|
1120: {"name": "Brine Flowmeter", "scale": "÷10", "unit": "GPM", "category": "flow"},
|
||||||
|
1121: {"name": "1st Pass Product Flowmeter", "scale": "÷10", "unit": "GPM", "category": "flow"},
|
||||||
|
1122: {"name": "2nd Pass Product Flowmeter", "scale": "÷10", "unit": "GPM", "category": "flow"},
|
||||||
|
|
||||||
|
# Water Quality
|
||||||
|
1123: {"name": "Product TDS #1", "scale": "direct", "unit": "PPM", "category": "quality"},
|
||||||
|
1124: {"name": "Product TDS #2", "scale": "direct", "unit": "PPM", "category": "quality"}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_sensor_by_category(category: str) -> Dict[int, Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get sensors filtered by category.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
category: Sensor category (system, pressure, temperature, flow, quality)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict of sensors in the specified category
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
addr: config for addr, config in KNOWN_SENSORS.items()
|
||||||
|
if config.get("category") == category
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_sensor_categories() -> List[str]:
|
||||||
|
"""
|
||||||
|
Get list of all available sensor categories.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of category names
|
||||||
|
"""
|
||||||
|
categories = set()
|
||||||
|
for config in KNOWN_SENSORS.values():
|
||||||
|
if "category" in config:
|
||||||
|
categories.add(config["category"])
|
||||||
|
return sorted(list(categories))
|
||||||
|
|
||||||
|
|
||||||
|
def get_sensor_addresses_by_group(group: str) -> List[int]:
|
||||||
|
"""
|
||||||
|
Get sensor addresses for a specific group.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
group: Group name (system, pressure, temperature, flow, quality)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of register addresses
|
||||||
|
"""
|
||||||
|
group_mappings = {
|
||||||
|
"system": [1000, 1036],
|
||||||
|
"pressure": [1003, 1007, 1008],
|
||||||
|
"temperature": [1017, 1125],
|
||||||
|
"flow": [1120, 1121, 1122],
|
||||||
|
"quality": [1123, 1124]
|
||||||
|
}
|
||||||
|
|
||||||
|
return group_mappings.get(group, [])
|
||||||
|
|
||||||
|
|
||||||
|
def validate_sensor_address(address: int) -> bool:
|
||||||
|
"""
|
||||||
|
Check if an address is a known sensor register.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
address: Register address to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if address is a known sensor
|
||||||
|
"""
|
||||||
|
return address in KNOWN_SENSORS
|
||||||
|
|
||||||
|
|
||||||
|
def get_sensor_info(address: int) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get configuration info for a specific sensor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
address: Sensor register address
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sensor configuration dict or empty dict if not found
|
||||||
|
"""
|
||||||
|
return KNOWN_SENSORS.get(address, {})
|
||||||
148
watermaker_plc_api/models/timer_mappings.py
Normal file
148
watermaker_plc_api/models/timer_mappings.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
"""
|
||||||
|
Timer and RTC register mappings and configuration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, List, Any
|
||||||
|
|
||||||
|
# Timer register mappings
|
||||||
|
TIMER_REGISTERS = {
|
||||||
|
# FWF Mode Timer
|
||||||
|
136: {"name": "FWF Flush Timer", "scale": "÷10", "unit": "sec", "category": "fwf_timer"},
|
||||||
|
|
||||||
|
# DTS Mode Timers
|
||||||
|
138: {"name": "DTS Step 1 Timer", "scale": "÷10", "unit": "sec", "category": "dts_timer"},
|
||||||
|
128: {"name": "DTS Step 2 Priming Timer", "scale": "÷10", "unit": "sec", "category": "dts_timer"},
|
||||||
|
129: {"name": "DTS Step 3 Init Timer", "scale": "÷10", "unit": "sec", "category": "dts_timer"},
|
||||||
|
133: {"name": "DTS Step 4 Timer", "scale": "÷10", "unit": "sec", "category": "dts_timer"},
|
||||||
|
135: {"name": "DTS Step 5 Stop Timer", "scale": "÷10", "unit": "sec", "category": "dts_timer"},
|
||||||
|
139: {"name": "DTS Step 6 Flush Timer", "scale": "÷10", "unit": "sec", "category": "dts_timer"}
|
||||||
|
}
|
||||||
|
|
||||||
|
# RTC register mappings
|
||||||
|
RTC_REGISTERS = {
|
||||||
|
513: {"name": "RTC Minutes", "scale": "direct", "unit": "min", "category": "rtc"},
|
||||||
|
514: {"name": "RTC Seconds", "scale": "direct", "unit": "sec", "category": "rtc"},
|
||||||
|
516: {"name": "RTC Year", "scale": "direct", "unit": "", "category": "rtc"},
|
||||||
|
517: {"name": "RTC Month", "scale": "direct", "unit": "", "category": "rtc"},
|
||||||
|
518: {"name": "RTC Day", "scale": "direct", "unit": "", "category": "rtc"},
|
||||||
|
519: {"name": "RTC Month (Alt)", "scale": "direct", "unit": "", "category": "rtc"}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_timer_by_category(category: str) -> Dict[int, Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get timers filtered by category.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
category: Timer category (fwf_timer, dts_timer)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict of timers in the specified category
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
addr: config for addr, config in TIMER_REGISTERS.items()
|
||||||
|
if config.get("category") == category
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_rtc_registers() -> Dict[int, Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get all RTC register configurations.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict of RTC register configurations
|
||||||
|
"""
|
||||||
|
return RTC_REGISTERS.copy()
|
||||||
|
|
||||||
|
|
||||||
|
def get_timer_addresses_by_group(group: str) -> List[int]:
|
||||||
|
"""
|
||||||
|
Get timer addresses for a specific group.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
group: Group name (fwf_timer, dts_timer, rtc)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of register addresses
|
||||||
|
"""
|
||||||
|
group_mappings = {
|
||||||
|
"fwf_timer": [136],
|
||||||
|
"dts_timer": [138, 128, 129, 133, 135, 139],
|
||||||
|
"rtc": [513, 514, 516, 517, 518, 519]
|
||||||
|
}
|
||||||
|
|
||||||
|
return group_mappings.get(group, [])
|
||||||
|
|
||||||
|
|
||||||
|
def get_dts_timer_addresses() -> List[int]:
|
||||||
|
"""
|
||||||
|
Get all DTS timer register addresses.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of DTS timer addresses
|
||||||
|
"""
|
||||||
|
return [addr for addr, config in TIMER_REGISTERS.items()
|
||||||
|
if config.get("category") == "dts_timer"]
|
||||||
|
|
||||||
|
|
||||||
|
def get_fwf_timer_addresses() -> List[int]:
|
||||||
|
"""
|
||||||
|
Get FWF timer register addresses.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of FWF timer addresses
|
||||||
|
"""
|
||||||
|
return [addr for addr, config in TIMER_REGISTERS.items()
|
||||||
|
if config.get("category") == "fwf_timer"]
|
||||||
|
|
||||||
|
|
||||||
|
def validate_timer_address(address: int) -> bool:
|
||||||
|
"""
|
||||||
|
Check if an address is a known timer register.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
address: Register address to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if address is a known timer
|
||||||
|
"""
|
||||||
|
return address in TIMER_REGISTERS
|
||||||
|
|
||||||
|
|
||||||
|
def validate_rtc_address(address: int) -> bool:
|
||||||
|
"""
|
||||||
|
Check if an address is a known RTC register.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
address: Register address to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if address is a known RTC register
|
||||||
|
"""
|
||||||
|
return address in RTC_REGISTERS
|
||||||
|
|
||||||
|
|
||||||
|
def get_timer_info(address: int) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get configuration info for a specific timer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
address: Timer register address
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Timer configuration dict or empty dict if not found
|
||||||
|
"""
|
||||||
|
return TIMER_REGISTERS.get(address, {})
|
||||||
|
|
||||||
|
|
||||||
|
def get_rtc_info(address: int) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get configuration info for a specific RTC register.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
address: RTC register address
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
RTC configuration dict or empty dict if not found
|
||||||
|
"""
|
||||||
|
return RTC_REGISTERS.get(address, {})
|
||||||
20
watermaker_plc_api/services/__init__.py
Normal file
20
watermaker_plc_api/services/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"""
|
||||||
|
Service layer for PLC communication, data caching, and background tasks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .plc_connection import PLCConnection, get_plc_connection
|
||||||
|
from .data_cache import DataCache, get_data_cache
|
||||||
|
from .register_reader import RegisterReader
|
||||||
|
from .register_writer import RegisterWriter
|
||||||
|
from .background_tasks import start_background_updates, BackgroundTaskManager
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'PLCConnection',
|
||||||
|
'get_plc_connection',
|
||||||
|
'DataCache',
|
||||||
|
'get_data_cache',
|
||||||
|
'RegisterReader',
|
||||||
|
'RegisterWriter',
|
||||||
|
'start_background_updates',
|
||||||
|
'BackgroundTaskManager'
|
||||||
|
]
|
||||||
111
watermaker_plc_api/services/background_tasks.py
Normal file
111
watermaker_plc_api/services/background_tasks.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
"""
|
||||||
|
Background task management for continuous data updates.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
from ..config import Config
|
||||||
|
from ..utils.logger import get_logger
|
||||||
|
from .register_reader import RegisterReader
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class BackgroundTaskManager:
|
||||||
|
"""Manages background tasks for PLC data updates"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.reader = RegisterReader()
|
||||||
|
self._update_thread: Optional[threading.Thread] = None
|
||||||
|
self._running = False
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
|
||||||
|
def start_data_updates(self):
|
||||||
|
"""Start the background data update thread"""
|
||||||
|
if self._running:
|
||||||
|
logger.warning("Background data updates already running")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
self._stop_event.clear()
|
||||||
|
self._update_thread = threading.Thread(
|
||||||
|
target=self._data_update_loop,
|
||||||
|
daemon=True,
|
||||||
|
name="PLCDataUpdater"
|
||||||
|
)
|
||||||
|
self._update_thread.start()
|
||||||
|
logger.info("Background data update thread started")
|
||||||
|
|
||||||
|
def stop_data_updates(self):
|
||||||
|
"""Stop the background data update thread"""
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = False
|
||||||
|
self._stop_event.set()
|
||||||
|
|
||||||
|
if self._update_thread and self._update_thread.is_alive():
|
||||||
|
self._update_thread.join(timeout=5)
|
||||||
|
if self._update_thread.is_alive():
|
||||||
|
logger.warning("Background thread did not stop gracefully")
|
||||||
|
|
||||||
|
logger.info("Background data updates stopped")
|
||||||
|
|
||||||
|
def _data_update_loop(self):
|
||||||
|
"""Main data update loop running in background thread"""
|
||||||
|
logger.info("Starting PLC data update loop")
|
||||||
|
|
||||||
|
while self._running and not self._stop_event.is_set():
|
||||||
|
try:
|
||||||
|
# Update all PLC data
|
||||||
|
self.reader.update_all_data()
|
||||||
|
|
||||||
|
# Wait for next update cycle
|
||||||
|
self._stop_event.wait(Config.DATA_UPDATE_INTERVAL)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in data update loop: {e}")
|
||||||
|
# Wait longer on error to avoid rapid retries
|
||||||
|
self._stop_event.wait(Config.ERROR_RETRY_INTERVAL)
|
||||||
|
|
||||||
|
logger.info("PLC data update loop ended")
|
||||||
|
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
"""Check if background updates are running"""
|
||||||
|
return self._running and self._update_thread is not None and self._update_thread.is_alive()
|
||||||
|
|
||||||
|
|
||||||
|
# Global background task manager instance
|
||||||
|
_task_manager: Optional[BackgroundTaskManager] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_task_manager() -> BackgroundTaskManager:
|
||||||
|
"""
|
||||||
|
Get the global background task manager instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BackgroundTaskManager instance
|
||||||
|
"""
|
||||||
|
global _task_manager
|
||||||
|
if _task_manager is None:
|
||||||
|
_task_manager = BackgroundTaskManager()
|
||||||
|
return _task_manager
|
||||||
|
|
||||||
|
|
||||||
|
def start_background_updates():
|
||||||
|
"""Start background data updates using the global task manager"""
|
||||||
|
manager = get_task_manager()
|
||||||
|
manager.start_data_updates()
|
||||||
|
|
||||||
|
|
||||||
|
def stop_background_updates():
|
||||||
|
"""Stop background data updates using the global task manager"""
|
||||||
|
manager = get_task_manager()
|
||||||
|
manager.stop_data_updates()
|
||||||
|
|
||||||
|
|
||||||
|
def is_background_updates_running() -> bool:
|
||||||
|
"""Check if background updates are currently running"""
|
||||||
|
manager = get_task_manager()
|
||||||
|
return manager.is_running()
|
||||||
249
watermaker_plc_api/services/data_cache.py
Normal file
249
watermaker_plc_api/services/data_cache.py
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
"""
|
||||||
|
Centralized data cache for PLC sensor data, timers, and status.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, List, Any, Optional
|
||||||
|
from ..config import Config
|
||||||
|
from ..utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class DataCache:
|
||||||
|
"""Thread-safe data cache for PLC data"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._data = {
|
||||||
|
"sensors": {},
|
||||||
|
"timers": {},
|
||||||
|
"rtc": {},
|
||||||
|
"outputs": {},
|
||||||
|
"runtime": {},
|
||||||
|
"water_counters": {},
|
||||||
|
"last_update": None,
|
||||||
|
"connection_status": "disconnected",
|
||||||
|
"errors": []
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_all_data(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get all cached data (thread-safe).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Copy of all cached data
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
return {
|
||||||
|
"sensors": self._data["sensors"].copy(),
|
||||||
|
"timers": self._data["timers"].copy(),
|
||||||
|
"rtc": self._data["rtc"].copy(),
|
||||||
|
"outputs": self._data["outputs"].copy(),
|
||||||
|
"runtime": self._data["runtime"].copy(),
|
||||||
|
"water_counters": self._data["water_counters"].copy(),
|
||||||
|
"last_update": self._data["last_update"],
|
||||||
|
"connection_status": self._data["connection_status"],
|
||||||
|
"errors": self._data["errors"].copy()
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_sensors(self) -> Dict[str, Any]:
|
||||||
|
"""Get all sensor data"""
|
||||||
|
with self._lock:
|
||||||
|
return self._data["sensors"].copy()
|
||||||
|
|
||||||
|
def get_sensors_by_category(self, category: str) -> Dict[str, Any]:
|
||||||
|
"""Get sensors filtered by category"""
|
||||||
|
with self._lock:
|
||||||
|
return {
|
||||||
|
addr: sensor for addr, sensor in self._data["sensors"].items()
|
||||||
|
if sensor.get("category") == category
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_timers(self) -> Dict[str, Any]:
|
||||||
|
"""Get all timer data"""
|
||||||
|
with self._lock:
|
||||||
|
return self._data["timers"].copy()
|
||||||
|
|
||||||
|
def get_active_timers(self) -> List[str]:
|
||||||
|
"""Get list of active timer addresses"""
|
||||||
|
with self._lock:
|
||||||
|
return [
|
||||||
|
addr for addr, timer in self._data["timers"].items()
|
||||||
|
if timer.get("active", False)
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_timers_by_category(self, category: str) -> Dict[str, Any]:
|
||||||
|
"""Get timers filtered by category"""
|
||||||
|
with self._lock:
|
||||||
|
return {
|
||||||
|
addr: timer for addr, timer in self._data["timers"].items()
|
||||||
|
if timer.get("category") == category
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_rtc(self) -> Dict[str, Any]:
|
||||||
|
"""Get RTC data"""
|
||||||
|
with self._lock:
|
||||||
|
return self._data["rtc"].copy()
|
||||||
|
|
||||||
|
def get_outputs(self) -> Dict[str, Any]:
|
||||||
|
"""Get output data"""
|
||||||
|
with self._lock:
|
||||||
|
return self._data["outputs"].copy()
|
||||||
|
|
||||||
|
def get_active_outputs(self) -> Dict[str, Any]:
|
||||||
|
"""Get only active output controls"""
|
||||||
|
with self._lock:
|
||||||
|
active_outputs = {}
|
||||||
|
for reg, output in self._data["outputs"].items():
|
||||||
|
active_bits = [bit for bit in output.get("bits", []) if bit.get("active", False)]
|
||||||
|
if active_bits:
|
||||||
|
active_outputs[reg] = {
|
||||||
|
**output,
|
||||||
|
"active_bits": active_bits
|
||||||
|
}
|
||||||
|
return active_outputs
|
||||||
|
|
||||||
|
def get_runtime(self) -> Dict[str, Any]:
|
||||||
|
"""Get runtime data"""
|
||||||
|
with self._lock:
|
||||||
|
return self._data["runtime"].copy()
|
||||||
|
|
||||||
|
def get_water_counters(self) -> Dict[str, Any]:
|
||||||
|
"""Get water counter data"""
|
||||||
|
with self._lock:
|
||||||
|
return self._data["water_counters"].copy()
|
||||||
|
|
||||||
|
def set_sensors(self, sensors: Dict[str, Any]):
|
||||||
|
"""Update sensor data"""
|
||||||
|
with self._lock:
|
||||||
|
self._data["sensors"] = sensors
|
||||||
|
self._data["last_update"] = datetime.now().isoformat()
|
||||||
|
|
||||||
|
def set_timers(self, timers: Dict[str, Any]):
|
||||||
|
"""Update timer data"""
|
||||||
|
with self._lock:
|
||||||
|
self._data["timers"] = timers
|
||||||
|
self._data["last_update"] = datetime.now().isoformat()
|
||||||
|
|
||||||
|
def set_rtc(self, rtc: Dict[str, Any]):
|
||||||
|
"""Update RTC data"""
|
||||||
|
with self._lock:
|
||||||
|
self._data["rtc"] = rtc
|
||||||
|
self._data["last_update"] = datetime.now().isoformat()
|
||||||
|
|
||||||
|
def set_outputs(self, outputs: Dict[str, Any]):
|
||||||
|
"""Update output data"""
|
||||||
|
with self._lock:
|
||||||
|
self._data["outputs"] = outputs
|
||||||
|
self._data["last_update"] = datetime.now().isoformat()
|
||||||
|
|
||||||
|
def set_runtime(self, runtime: Dict[str, Any]):
|
||||||
|
"""Update runtime data"""
|
||||||
|
with self._lock:
|
||||||
|
self._data["runtime"] = runtime
|
||||||
|
self._data["last_update"] = datetime.now().isoformat()
|
||||||
|
|
||||||
|
def set_water_counters(self, water_counters: Dict[str, Any]):
|
||||||
|
"""Update water counter data"""
|
||||||
|
with self._lock:
|
||||||
|
self._data["water_counters"] = water_counters
|
||||||
|
self._data["last_update"] = datetime.now().isoformat()
|
||||||
|
|
||||||
|
def set_connection_status(self, status: str):
|
||||||
|
"""Update connection status"""
|
||||||
|
with self._lock:
|
||||||
|
self._data["connection_status"] = status
|
||||||
|
|
||||||
|
def add_error(self, error: str):
|
||||||
|
"""Add error to error list (thread-safe)"""
|
||||||
|
with self._lock:
|
||||||
|
self._data["errors"].append({
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"error": error
|
||||||
|
})
|
||||||
|
# Keep only last N errors
|
||||||
|
max_errors = Config.MAX_CACHED_ERRORS
|
||||||
|
if len(self._data["errors"]) > max_errors:
|
||||||
|
self._data["errors"] = self._data["errors"][-max_errors:]
|
||||||
|
|
||||||
|
def get_errors(self, limit: int = 10) -> List[Dict[str, Any]]:
|
||||||
|
"""Get recent errors"""
|
||||||
|
with self._lock:
|
||||||
|
return self._data["errors"][-limit:]
|
||||||
|
|
||||||
|
def clear_errors(self):
|
||||||
|
"""Clear all errors"""
|
||||||
|
with self._lock:
|
||||||
|
self._data["errors"] = []
|
||||||
|
|
||||||
|
def get_last_update(self) -> Optional[str]:
|
||||||
|
"""Get last update timestamp"""
|
||||||
|
with self._lock:
|
||||||
|
return self._data["last_update"]
|
||||||
|
|
||||||
|
def get_connection_status(self) -> str:
|
||||||
|
"""Get current connection status"""
|
||||||
|
with self._lock:
|
||||||
|
return self._data["connection_status"]
|
||||||
|
|
||||||
|
def get_summary_stats(self) -> Dict[str, int]:
|
||||||
|
"""Get summary statistics"""
|
||||||
|
with self._lock:
|
||||||
|
return {
|
||||||
|
"sensor_count": len(self._data["sensors"]),
|
||||||
|
"active_timer_count": len([
|
||||||
|
t for t in self._data["timers"].values()
|
||||||
|
if t.get("active", False)
|
||||||
|
]),
|
||||||
|
"active_output_count": sum(
|
||||||
|
len([b for b in output.get("bits", []) if b.get("active", False)])
|
||||||
|
for output in self._data["outputs"].values()
|
||||||
|
),
|
||||||
|
"runtime_count": len(self._data["runtime"]),
|
||||||
|
"water_counter_count": len(self._data["water_counters"]),
|
||||||
|
"error_count": len(self._data["errors"])
|
||||||
|
}
|
||||||
|
|
||||||
|
def update_sensor(self, address: str, sensor_data: Dict[str, Any]):
|
||||||
|
"""Update single sensor (thread-safe)"""
|
||||||
|
with self._lock:
|
||||||
|
self._data["sensors"][address] = sensor_data
|
||||||
|
self._data["last_update"] = datetime.now().isoformat()
|
||||||
|
|
||||||
|
def update_timer(self, address: str, timer_data: Dict[str, Any]):
|
||||||
|
"""Update single timer (thread-safe)"""
|
||||||
|
with self._lock:
|
||||||
|
self._data["timers"][address] = timer_data
|
||||||
|
self._data["last_update"] = datetime.now().isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
# Global data cache instance
|
||||||
|
_data_cache: Optional[DataCache] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_data_cache() -> DataCache:
|
||||||
|
"""
|
||||||
|
Get the global data cache instance (singleton pattern).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DataCache instance
|
||||||
|
"""
|
||||||
|
global _data_cache
|
||||||
|
if _data_cache is None:
|
||||||
|
_data_cache = DataCache()
|
||||||
|
logger.info("Data cache initialized")
|
||||||
|
return _data_cache
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_data_cache() -> DataCache:
|
||||||
|
"""
|
||||||
|
Initialize and return the data cache.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DataCache instance
|
||||||
|
"""
|
||||||
|
cache = get_data_cache()
|
||||||
|
logger.info("Data cache ready")
|
||||||
|
return cache
|
||||||
216
watermaker_plc_api/services/plc_connection.py
Normal file
216
watermaker_plc_api/services/plc_connection.py
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
"""
|
||||||
|
PLC connection management and Modbus communication.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
from pymodbus.client import ModbusTcpClient
|
||||||
|
from ..config import Config
|
||||||
|
from ..utils.logger import get_logger
|
||||||
|
from ..utils.error_handler import PLCConnectionError
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PLCConnection:
|
||||||
|
"""Manages PLC connection and provides thread-safe access"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.config = Config.get_plc_config()
|
||||||
|
self.client: Optional[ModbusTcpClient] = None
|
||||||
|
self._is_connected = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_connected(self) -> bool:
|
||||||
|
"""Check if PLC is currently connected"""
|
||||||
|
return self._is_connected and self.client is not None
|
||||||
|
|
||||||
|
def connect(self) -> bool:
|
||||||
|
"""
|
||||||
|
Establish connection to PLC with retry logic.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if connected successfully, False otherwise
|
||||||
|
"""
|
||||||
|
current_time = time.time()
|
||||||
|
|
||||||
|
# Check if we should retry connection
|
||||||
|
if (self.config["last_connection_attempt"] +
|
||||||
|
self.config["connection_retry_interval"]) > current_time:
|
||||||
|
return self._is_connected
|
||||||
|
|
||||||
|
self.config["last_connection_attempt"] = current_time
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Close existing connection if any
|
||||||
|
if self.client:
|
||||||
|
self.client.close()
|
||||||
|
|
||||||
|
# Create new client
|
||||||
|
self.client = ModbusTcpClient(
|
||||||
|
host=self.config["ip_address"],
|
||||||
|
port=self.config["port"],
|
||||||
|
timeout=self.config["timeout"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Attempt connection
|
||||||
|
self._is_connected = self.client.connect()
|
||||||
|
self.config["connected"] = self._is_connected
|
||||||
|
|
||||||
|
if self._is_connected:
|
||||||
|
logger.info(f"Connected to PLC at {self.config['ip_address']}:{self.config['port']}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to connect to PLC at {self.config['ip_address']}:{self.config['port']}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error connecting to PLC: {e}")
|
||||||
|
self._is_connected = False
|
||||||
|
self.config["connected"] = False
|
||||||
|
return False
|
||||||
|
|
||||||
|
def disconnect(self):
|
||||||
|
"""Disconnect from PLC"""
|
||||||
|
if self.client:
|
||||||
|
try:
|
||||||
|
self.client.close()
|
||||||
|
logger.info("Disconnected from PLC")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error during PLC disconnect: {e}")
|
||||||
|
finally:
|
||||||
|
self.client = None
|
||||||
|
self._is_connected = False
|
||||||
|
self.config["connected"] = False
|
||||||
|
|
||||||
|
def read_input_register(self, address: int) -> Optional[int]:
|
||||||
|
"""
|
||||||
|
Read input register (function code 4).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
address: Register address
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Register value or None if read failed
|
||||||
|
"""
|
||||||
|
if not self.is_connected:
|
||||||
|
if not self.connect():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = self.client.read_input_registers(
|
||||||
|
address, 1, slave=self.config["unit_id"]
|
||||||
|
)
|
||||||
|
if hasattr(result, 'registers') and not result.isError():
|
||||||
|
return result.registers[0]
|
||||||
|
else:
|
||||||
|
logger.warning(f"Failed to read input register {address}: {result}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error reading input register {address}: {e}")
|
||||||
|
self._is_connected = False
|
||||||
|
return None
|
||||||
|
|
||||||
|
def read_holding_register(self, address: int) -> Optional[int]:
|
||||||
|
"""
|
||||||
|
Read holding register (function code 3).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
address: Register address
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Register value or None if read failed
|
||||||
|
"""
|
||||||
|
if not self.is_connected:
|
||||||
|
if not self.connect():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = self.client.read_holding_registers(
|
||||||
|
address, 1, slave=self.config["unit_id"]
|
||||||
|
)
|
||||||
|
if hasattr(result, 'registers') and not result.isError():
|
||||||
|
return result.registers[0]
|
||||||
|
else:
|
||||||
|
logger.warning(f"Failed to read holding register {address}: {result}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error reading holding register {address}: {e}")
|
||||||
|
self._is_connected = False
|
||||||
|
return None
|
||||||
|
|
||||||
|
def write_holding_register(self, address: int, value: int) -> bool:
|
||||||
|
"""
|
||||||
|
Write single holding register (function code 6).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
address: Register address
|
||||||
|
value: Value to write
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if write successful, False otherwise
|
||||||
|
"""
|
||||||
|
if not self.is_connected:
|
||||||
|
if not self.connect():
|
||||||
|
raise PLCConnectionError("Cannot write register - PLC not connected")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = self.client.write_register(
|
||||||
|
address, value, slave=self.config["unit_id"]
|
||||||
|
)
|
||||||
|
if not result.isError():
|
||||||
|
logger.info(f"Successfully wrote value {value} to register {address}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.error(f"Error writing register {address}: {result}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Exception writing register {address}: {e}")
|
||||||
|
self._is_connected = False
|
||||||
|
raise PLCConnectionError(f"Failed to write register {address}: {e}")
|
||||||
|
|
||||||
|
def get_connection_status(self) -> dict:
|
||||||
|
"""
|
||||||
|
Get current connection status information.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with connection status details
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"connected": self.is_connected,
|
||||||
|
"ip_address": self.config["ip_address"],
|
||||||
|
"port": self.config["port"],
|
||||||
|
"unit_id": self.config["unit_id"],
|
||||||
|
"timeout": self.config["timeout"],
|
||||||
|
"last_connection_attempt": self.config["last_connection_attempt"],
|
||||||
|
"retry_interval": self.config["connection_retry_interval"]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Global PLC connection instance
|
||||||
|
_plc_connection: Optional[PLCConnection] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_plc_connection() -> PLCConnection:
|
||||||
|
"""
|
||||||
|
Get the global PLC connection instance (singleton pattern).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PLCConnection instance
|
||||||
|
"""
|
||||||
|
global _plc_connection
|
||||||
|
if _plc_connection is None:
|
||||||
|
_plc_connection = PLCConnection()
|
||||||
|
return _plc_connection
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_plc_connection() -> PLCConnection:
|
||||||
|
"""
|
||||||
|
Initialize and return the PLC connection.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PLCConnection instance
|
||||||
|
"""
|
||||||
|
connection = get_plc_connection()
|
||||||
|
connection.connect()
|
||||||
|
return connection
|
||||||
487
watermaker_plc_api/services/register_reader.py
Normal file
487
watermaker_plc_api/services/register_reader.py
Normal file
@@ -0,0 +1,487 @@
|
|||||||
|
"""
|
||||||
|
Service for reading PLC registers and updating the data cache.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional, Tuple, Dict, Any, List
|
||||||
|
from ..models import (
|
||||||
|
KNOWN_SENSORS, TIMER_REGISTERS, RTC_REGISTERS,
|
||||||
|
RUNTIME_REGISTERS, WATER_COUNTER_REGISTERS
|
||||||
|
)
|
||||||
|
from ..models.output_mappings import get_output_registers, create_output_bit_info
|
||||||
|
from ..utils.data_conversion import (
|
||||||
|
scale_value, get_descriptive_value, validate_register_value,
|
||||||
|
convert_ieee754_float, convert_gallon_counter, format_binary_string
|
||||||
|
)
|
||||||
|
from ..utils.logger import get_logger
|
||||||
|
from .plc_connection import get_plc_connection
|
||||||
|
from .data_cache import get_data_cache
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterReader:
|
||||||
|
"""Service for reading PLC registers and updating cache"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.plc = get_plc_connection()
|
||||||
|
self.cache = get_data_cache()
|
||||||
|
|
||||||
|
def read_register_pair(self, high_address: int, low_address: int, conversion_type: str) -> Tuple[bool, Optional[float], Optional[int], Optional[int]]:
|
||||||
|
"""
|
||||||
|
Read a pair of registers and convert them based on type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
high_address: High register address
|
||||||
|
low_address: Low register address
|
||||||
|
conversion_type: Conversion type (ieee754, gallon_counter)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, converted_value, raw_high, raw_low)
|
||||||
|
"""
|
||||||
|
high_value = self.plc.read_holding_register(high_address)
|
||||||
|
low_value = self.plc.read_holding_register(low_address)
|
||||||
|
|
||||||
|
if not validate_register_value(high_value) or not validate_register_value(low_value):
|
||||||
|
return False, None, high_value, low_value
|
||||||
|
|
||||||
|
if conversion_type == "ieee754":
|
||||||
|
converted = convert_ieee754_float(high_value, low_value)
|
||||||
|
elif conversion_type == "gallon_counter":
|
||||||
|
converted = convert_gallon_counter(high_value, low_value)
|
||||||
|
else:
|
||||||
|
converted = None
|
||||||
|
|
||||||
|
return True, converted, high_value, low_value
|
||||||
|
|
||||||
|
def update_sensors(self) -> bool:
|
||||||
|
"""
|
||||||
|
Update all sensor data in cache.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
sensors = {}
|
||||||
|
|
||||||
|
for address, config in KNOWN_SENSORS.items():
|
||||||
|
raw_value = self.plc.read_input_register(address)
|
||||||
|
|
||||||
|
if validate_register_value(raw_value):
|
||||||
|
scaled_value = scale_value(raw_value, config["scale"])
|
||||||
|
descriptive_value = get_descriptive_value(raw_value, config)
|
||||||
|
|
||||||
|
sensors[str(address)] = {
|
||||||
|
"name": config["name"],
|
||||||
|
"raw_value": raw_value,
|
||||||
|
"scaled_value": scaled_value,
|
||||||
|
"descriptive_value": descriptive_value if isinstance(descriptive_value, str) else scaled_value,
|
||||||
|
"unit": config["unit"],
|
||||||
|
"category": config["category"],
|
||||||
|
"scale": config["scale"]
|
||||||
|
}
|
||||||
|
|
||||||
|
self.cache.set_sensors(sensors)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating sensors: {e}")
|
||||||
|
self.cache.add_error(f"Sensor update failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def update_timers(self) -> bool:
|
||||||
|
"""
|
||||||
|
Update all timer data in cache.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
timers = {}
|
||||||
|
|
||||||
|
for address, config in TIMER_REGISTERS.items():
|
||||||
|
raw_value = self.plc.read_holding_register(address)
|
||||||
|
|
||||||
|
if validate_register_value(raw_value):
|
||||||
|
scaled_value = scale_value(raw_value, config["scale"])
|
||||||
|
|
||||||
|
timers[str(address)] = {
|
||||||
|
"name": config["name"],
|
||||||
|
"raw_value": raw_value,
|
||||||
|
"scaled_value": scaled_value,
|
||||||
|
"unit": config["unit"],
|
||||||
|
"category": config["category"],
|
||||||
|
"active": raw_value > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
self.cache.set_timers(timers)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating timers: {e}")
|
||||||
|
self.cache.add_error(f"Timer update failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def update_rtc(self) -> bool:
|
||||||
|
"""
|
||||||
|
Update RTC data in cache.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
rtc_data = {}
|
||||||
|
|
||||||
|
for address, config in RTC_REGISTERS.items():
|
||||||
|
raw_value = self.plc.read_holding_register(address)
|
||||||
|
|
||||||
|
if validate_register_value(raw_value):
|
||||||
|
rtc_data[str(address)] = {
|
||||||
|
"name": config["name"],
|
||||||
|
"value": raw_value,
|
||||||
|
"unit": config["unit"]
|
||||||
|
}
|
||||||
|
|
||||||
|
self.cache.set_rtc(rtc_data)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating RTC: {e}")
|
||||||
|
self.cache.add_error(f"RTC update failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def update_runtime(self) -> bool:
|
||||||
|
"""
|
||||||
|
Update runtime data in cache.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
runtime_data = {}
|
||||||
|
|
||||||
|
for address, config in RUNTIME_REGISTERS.items():
|
||||||
|
success, converted_value, high_raw, low_raw = self.read_register_pair(
|
||||||
|
address, config["pair_register"], "ieee754"
|
||||||
|
)
|
||||||
|
|
||||||
|
if success and converted_value is not None:
|
||||||
|
runtime_data[str(address)] = {
|
||||||
|
"name": config["name"],
|
||||||
|
"value": converted_value,
|
||||||
|
"unit": config["unit"],
|
||||||
|
"category": config["category"],
|
||||||
|
"description": config["description"],
|
||||||
|
"raw_high": high_raw,
|
||||||
|
"raw_low": low_raw,
|
||||||
|
"high_register": address,
|
||||||
|
"low_register": config["pair_register"]
|
||||||
|
}
|
||||||
|
|
||||||
|
self.cache.set_runtime(runtime_data)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating runtime: {e}")
|
||||||
|
self.cache.add_error(f"Runtime update failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def update_water_counters(self) -> bool:
|
||||||
|
"""
|
||||||
|
Update water counter data in cache.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
water_counter_data = {}
|
||||||
|
|
||||||
|
for address, config in WATER_COUNTER_REGISTERS.items():
|
||||||
|
success, converted_value, high_raw, low_raw = self.read_register_pair(
|
||||||
|
address, config["pair_register"], "gallon_counter"
|
||||||
|
)
|
||||||
|
|
||||||
|
if success and converted_value is not None:
|
||||||
|
water_counter_data[str(address)] = {
|
||||||
|
"name": config["name"],
|
||||||
|
"value": converted_value,
|
||||||
|
"unit": config["unit"],
|
||||||
|
"category": config["category"],
|
||||||
|
"description": config["description"],
|
||||||
|
"raw_high": high_raw,
|
||||||
|
"raw_low": low_raw,
|
||||||
|
"high_register": address,
|
||||||
|
"low_register": config["pair_register"]
|
||||||
|
}
|
||||||
|
|
||||||
|
self.cache.set_water_counters(water_counter_data)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating water counters: {e}")
|
||||||
|
self.cache.add_error(f"Water counter update failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def update_outputs(self) -> bool:
|
||||||
|
"""
|
||||||
|
Update output control data in cache.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
outputs = {}
|
||||||
|
output_registers = get_output_registers()
|
||||||
|
|
||||||
|
for reg in output_registers:
|
||||||
|
modbus_addr = reg - 40001
|
||||||
|
raw_value = self.plc.read_holding_register(modbus_addr)
|
||||||
|
|
||||||
|
if validate_register_value(raw_value):
|
||||||
|
outputs[str(reg)] = {
|
||||||
|
"register": reg,
|
||||||
|
"value": raw_value,
|
||||||
|
"binary": format_binary_string(raw_value),
|
||||||
|
"bits": create_output_bit_info(reg, raw_value)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.cache.set_outputs(outputs)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating outputs: {e}")
|
||||||
|
self.cache.add_error(f"Output update failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def update_all_data(self) -> bool:
|
||||||
|
"""
|
||||||
|
Update all PLC data in cache.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if all updates successful, False if any failed
|
||||||
|
"""
|
||||||
|
success = True
|
||||||
|
|
||||||
|
# Update connection status
|
||||||
|
if self.plc.is_connected:
|
||||||
|
self.cache.set_connection_status("connected")
|
||||||
|
else:
|
||||||
|
self.cache.set_connection_status("disconnected")
|
||||||
|
if not self.plc.connect():
|
||||||
|
self.cache.set_connection_status("connection_failed")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Update all data types
|
||||||
|
success &= self.update_sensors()
|
||||||
|
success &= self.update_timers()
|
||||||
|
success &= self.update_rtc()
|
||||||
|
success &= self.update_runtime()
|
||||||
|
success &= self.update_water_counters()
|
||||||
|
success &= self.update_outputs()
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.debug("All PLC data updated successfully")
|
||||||
|
else:
|
||||||
|
logger.warning("Some PLC data updates failed")
|
||||||
|
|
||||||
|
return success
|
||||||
|
|
||||||
|
def read_selective_data(self, groups: List[str], keys: List[str]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Read only selected variables by groups and/or keys.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
groups: List of group names
|
||||||
|
keys: List of register keys
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict containing selected data
|
||||||
|
"""
|
||||||
|
result = {
|
||||||
|
"sensors": {},
|
||||||
|
"timers": {},
|
||||||
|
"rtc": {},
|
||||||
|
"outputs": {},
|
||||||
|
"runtime": {},
|
||||||
|
"water_counters": {},
|
||||||
|
"requested_groups": groups,
|
||||||
|
"requested_keys": keys
|
||||||
|
}
|
||||||
|
|
||||||
|
# Collect addresses to read based on groups and keys
|
||||||
|
sensor_addresses = set()
|
||||||
|
timer_addresses = set()
|
||||||
|
output_registers = set()
|
||||||
|
runtime_addresses = set()
|
||||||
|
water_counter_addresses = set()
|
||||||
|
|
||||||
|
# Add addresses by groups
|
||||||
|
group_mappings = {
|
||||||
|
"system": [1000, 1036],
|
||||||
|
"pressure": [1003, 1007, 1008],
|
||||||
|
"temperature": [1017, 1125],
|
||||||
|
"flow": [1120, 1121, 1122],
|
||||||
|
"quality": [1123, 1124],
|
||||||
|
"fwf_timer": [136],
|
||||||
|
"dts_timer": [138, 128, 129, 133, 135, 139],
|
||||||
|
"rtc": [513, 514, 516, 517, 518, 519],
|
||||||
|
"outputs": [40017, 40018, 40019, 40020, 40021, 40022],
|
||||||
|
"runtime": list(RUNTIME_REGISTERS.keys()),
|
||||||
|
"water_counters": list(WATER_COUNTER_REGISTERS.keys())
|
||||||
|
}
|
||||||
|
|
||||||
|
for group in groups:
|
||||||
|
if group in group_mappings:
|
||||||
|
addresses = group_mappings[group]
|
||||||
|
if group == "outputs":
|
||||||
|
output_registers.update(addresses)
|
||||||
|
elif group == "runtime":
|
||||||
|
runtime_addresses.update(addresses)
|
||||||
|
elif group == "water_counters":
|
||||||
|
water_counter_addresses.update(addresses)
|
||||||
|
elif group in ["fwf_timer", "dts_timer", "rtc"]:
|
||||||
|
timer_addresses.update(addresses)
|
||||||
|
else:
|
||||||
|
sensor_addresses.update(addresses)
|
||||||
|
|
||||||
|
# Add specific requested keys
|
||||||
|
for key in keys:
|
||||||
|
try:
|
||||||
|
addr = int(key)
|
||||||
|
if addr in KNOWN_SENSORS:
|
||||||
|
sensor_addresses.add(addr)
|
||||||
|
elif addr in TIMER_REGISTERS or addr in RTC_REGISTERS:
|
||||||
|
timer_addresses.add(addr)
|
||||||
|
elif addr in RUNTIME_REGISTERS:
|
||||||
|
runtime_addresses.add(addr)
|
||||||
|
elif addr in WATER_COUNTER_REGISTERS:
|
||||||
|
water_counter_addresses.add(addr)
|
||||||
|
elif addr >= 40017 and addr <= 40022:
|
||||||
|
output_registers.add(addr)
|
||||||
|
else:
|
||||||
|
sensor_addresses.add(addr)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Read and populate selected data
|
||||||
|
total_reads = 0
|
||||||
|
|
||||||
|
# Read sensors
|
||||||
|
for address in sensor_addresses:
|
||||||
|
raw_value = self.plc.read_input_register(address)
|
||||||
|
if validate_register_value(raw_value):
|
||||||
|
config = KNOWN_SENSORS.get(address, {
|
||||||
|
"name": f"Register {address}",
|
||||||
|
"scale": "direct",
|
||||||
|
"unit": "",
|
||||||
|
"category": "unknown"
|
||||||
|
})
|
||||||
|
|
||||||
|
scaled_value = scale_value(raw_value, config["scale"])
|
||||||
|
descriptive_value = get_descriptive_value(raw_value, config)
|
||||||
|
|
||||||
|
result["sensors"][str(address)] = {
|
||||||
|
"name": config["name"],
|
||||||
|
"raw_value": raw_value,
|
||||||
|
"scaled_value": scaled_value,
|
||||||
|
"descriptive_value": descriptive_value if isinstance(descriptive_value, str) else scaled_value,
|
||||||
|
"unit": config["unit"],
|
||||||
|
"category": config.get("category", "unknown"),
|
||||||
|
"scale": config["scale"]
|
||||||
|
}
|
||||||
|
total_reads += 1
|
||||||
|
|
||||||
|
# Read timers/RTC
|
||||||
|
for address in timer_addresses:
|
||||||
|
raw_value = self.plc.read_holding_register(address)
|
||||||
|
if validate_register_value(raw_value):
|
||||||
|
if address in TIMER_REGISTERS:
|
||||||
|
config = TIMER_REGISTERS[address]
|
||||||
|
scaled_value = scale_value(raw_value, config["scale"])
|
||||||
|
|
||||||
|
result["timers"][str(address)] = {
|
||||||
|
"name": config["name"],
|
||||||
|
"raw_value": raw_value,
|
||||||
|
"scaled_value": scaled_value,
|
||||||
|
"unit": config["unit"],
|
||||||
|
"category": config["category"],
|
||||||
|
"active": raw_value > 0
|
||||||
|
}
|
||||||
|
elif address in RTC_REGISTERS:
|
||||||
|
config = RTC_REGISTERS[address]
|
||||||
|
|
||||||
|
result["rtc"][str(address)] = {
|
||||||
|
"name": config["name"],
|
||||||
|
"value": raw_value,
|
||||||
|
"unit": config["unit"]
|
||||||
|
}
|
||||||
|
total_reads += 1
|
||||||
|
|
||||||
|
# Read runtime registers
|
||||||
|
for address in runtime_addresses:
|
||||||
|
if address in RUNTIME_REGISTERS:
|
||||||
|
config = RUNTIME_REGISTERS[address]
|
||||||
|
success, converted_value, high_raw, low_raw = self.read_register_pair(
|
||||||
|
address, config["pair_register"], "ieee754"
|
||||||
|
)
|
||||||
|
|
||||||
|
if success and converted_value is not None:
|
||||||
|
result["runtime"][str(address)] = {
|
||||||
|
"name": config["name"],
|
||||||
|
"value": converted_value,
|
||||||
|
"unit": config["unit"],
|
||||||
|
"category": config["category"],
|
||||||
|
"description": config["description"],
|
||||||
|
"raw_high": high_raw,
|
||||||
|
"raw_low": low_raw,
|
||||||
|
"high_register": address,
|
||||||
|
"low_register": config["pair_register"]
|
||||||
|
}
|
||||||
|
total_reads += 2 # Register pair
|
||||||
|
|
||||||
|
# Read water counter registers
|
||||||
|
for address in water_counter_addresses:
|
||||||
|
if address in WATER_COUNTER_REGISTERS:
|
||||||
|
config = WATER_COUNTER_REGISTERS[address]
|
||||||
|
success, converted_value, high_raw, low_raw = self.read_register_pair(
|
||||||
|
address, config["pair_register"], "gallon_counter"
|
||||||
|
)
|
||||||
|
|
||||||
|
if success and converted_value is not None:
|
||||||
|
result["water_counters"][str(address)] = {
|
||||||
|
"name": config["name"],
|
||||||
|
"value": converted_value,
|
||||||
|
"unit": config["unit"],
|
||||||
|
"category": config["category"],
|
||||||
|
"description": config["description"],
|
||||||
|
"raw_high": high_raw,
|
||||||
|
"raw_low": low_raw,
|
||||||
|
"high_register": address,
|
||||||
|
"low_register": config["pair_register"]
|
||||||
|
}
|
||||||
|
total_reads += 2 # Register pair
|
||||||
|
|
||||||
|
# Read outputs
|
||||||
|
for reg in output_registers:
|
||||||
|
modbus_addr = reg - 40001
|
||||||
|
raw_value = self.plc.read_holding_register(modbus_addr)
|
||||||
|
if validate_register_value(raw_value):
|
||||||
|
result["outputs"][str(reg)] = {
|
||||||
|
"register": reg,
|
||||||
|
"value": raw_value,
|
||||||
|
"binary": format_binary_string(raw_value),
|
||||||
|
"bits": create_output_bit_info(reg, raw_value)
|
||||||
|
}
|
||||||
|
total_reads += 1
|
||||||
|
|
||||||
|
# Add summary
|
||||||
|
result["summary"] = {
|
||||||
|
"sensors_read": len(result["sensors"]),
|
||||||
|
"timers_read": len(result["timers"]),
|
||||||
|
"rtc_read": len(result["rtc"]),
|
||||||
|
"outputs_read": len(result["outputs"]),
|
||||||
|
"runtime_read": len(result["runtime"]),
|
||||||
|
"water_counters_read": len(result["water_counters"]),
|
||||||
|
"total_plc_reads": total_reads
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
111
watermaker_plc_api/services/register_writer.py
Normal file
111
watermaker_plc_api/services/register_writer.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
"""
|
||||||
|
Service for writing to PLC registers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, Any
|
||||||
|
from ..utils.logger import get_logger
|
||||||
|
from ..utils.error_handler import RegisterWriteError, PLCConnectionError
|
||||||
|
from .plc_connection import get_plc_connection
|
||||||
|
from .data_cache import get_data_cache
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterWriter:
|
||||||
|
"""Service for writing to PLC registers"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.plc = get_plc_connection()
|
||||||
|
self.cache = get_data_cache()
|
||||||
|
|
||||||
|
def write_holding_register(self, address: int, value: int) -> bool:
|
||||||
|
"""
|
||||||
|
Write a single holding register.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
address: Register address
|
||||||
|
value: Value to write
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if write successful
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RegisterWriteError: If write operation fails
|
||||||
|
PLCConnectionError: If PLC connection fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Validate inputs
|
||||||
|
if not isinstance(address, int) or address < 0:
|
||||||
|
raise RegisterWriteError(f"Invalid register address: {address}")
|
||||||
|
|
||||||
|
if not isinstance(value, int) or value < 0 or value > 65535:
|
||||||
|
raise RegisterWriteError(f"Invalid register value: {value}. Must be 0-65535")
|
||||||
|
|
||||||
|
# Ensure PLC connection
|
||||||
|
if not self.plc.is_connected:
|
||||||
|
if not self.plc.connect():
|
||||||
|
raise PLCConnectionError("Failed to connect to PLC")
|
||||||
|
|
||||||
|
# Perform write operation
|
||||||
|
success = self.plc.write_holding_register(address, value)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info(f"Successfully wrote {value} to register {address}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
raise RegisterWriteError(f"Failed to write register {address}")
|
||||||
|
|
||||||
|
except (RegisterWriteError, PLCConnectionError):
|
||||||
|
# Re-raise our custom exceptions
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Unexpected error writing register {address}: {e}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
self.cache.add_error(error_msg)
|
||||||
|
raise RegisterWriteError(error_msg)
|
||||||
|
|
||||||
|
def write_multiple_registers(self, writes: Dict[int, int]) -> Dict[int, bool]:
|
||||||
|
"""
|
||||||
|
Write multiple holding registers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
writes: Dict mapping addresses to values
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping addresses to success status
|
||||||
|
"""
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
for address, value in writes.items():
|
||||||
|
try:
|
||||||
|
results[address] = self.write_holding_register(address, value)
|
||||||
|
except (RegisterWriteError, PLCConnectionError) as e:
|
||||||
|
logger.error(f"Failed to write register {address}: {e}")
|
||||||
|
results[address] = False
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def validate_write_operation(self, address: int, value: int) -> tuple:
|
||||||
|
"""
|
||||||
|
Validate a write operation before execution.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
address: Register address
|
||||||
|
value: Value to write
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, error_message)
|
||||||
|
"""
|
||||||
|
if not isinstance(address, int):
|
||||||
|
return False, "Address must be an integer"
|
||||||
|
|
||||||
|
if address < 0:
|
||||||
|
return False, "Address must be non-negative"
|
||||||
|
|
||||||
|
if not isinstance(value, int):
|
||||||
|
return False, "Value must be an integer"
|
||||||
|
|
||||||
|
if value < 0 or value > 65535:
|
||||||
|
return False, "Value must be between 0 and 65535"
|
||||||
|
|
||||||
|
return True, ""
|
||||||
15
watermaker_plc_api/utils/__init__.py
Normal file
15
watermaker_plc_api/utils/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
"""
|
||||||
|
Utility modules for data conversion, logging, and error handling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .logger import get_logger
|
||||||
|
from .data_conversion import scale_value, convert_ieee754_float, convert_gallon_counter
|
||||||
|
from .error_handler import setup_error_handlers
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'get_logger',
|
||||||
|
'scale_value',
|
||||||
|
'convert_ieee754_float',
|
||||||
|
'convert_gallon_counter',
|
||||||
|
'setup_error_handlers'
|
||||||
|
]
|
||||||
144
watermaker_plc_api/utils/data_conversion.py
Normal file
144
watermaker_plc_api/utils/data_conversion.py
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
"""
|
||||||
|
Data conversion utilities for PLC register values.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import struct
|
||||||
|
from typing import Union, Optional
|
||||||
|
from .logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def scale_value(value: Union[int, float], scale_type: str) -> Union[int, float]:
|
||||||
|
"""
|
||||||
|
Apply scaling to sensor values based on scale type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: Raw register value
|
||||||
|
scale_type: Scaling type (e.g., "direct", "÷10", "×100")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Scaled value
|
||||||
|
"""
|
||||||
|
if scale_type == "direct":
|
||||||
|
return value
|
||||||
|
elif scale_type.startswith("÷"):
|
||||||
|
try:
|
||||||
|
divisor = float(scale_type[1:])
|
||||||
|
return value / divisor
|
||||||
|
except (ValueError, ZeroDivisionError):
|
||||||
|
logger.warning(f"Invalid divisor in scale_type: {scale_type}")
|
||||||
|
return value
|
||||||
|
elif scale_type.startswith("×"):
|
||||||
|
try:
|
||||||
|
multiplier = float(scale_type[1:])
|
||||||
|
return value * multiplier
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(f"Invalid multiplier in scale_type: {scale_type}")
|
||||||
|
return value
|
||||||
|
else:
|
||||||
|
logger.warning(f"Unknown scale_type: {scale_type}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def convert_ieee754_float(high_register: int, low_register: int) -> Optional[float]:
|
||||||
|
"""
|
||||||
|
Convert two 16-bit registers to IEEE 754 32-bit float.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
high_register: High 16 bits
|
||||||
|
low_register: Low 16 bits
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Float value or None if conversion fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Combine registers into 32-bit value (big-endian)
|
||||||
|
combined_32bit = (high_register << 16) | low_register
|
||||||
|
|
||||||
|
# Convert to bytes and then to IEEE 754 float
|
||||||
|
bytes_value = struct.pack('>I', combined_32bit) # Big-endian unsigned int
|
||||||
|
float_value = struct.unpack('>f', bytes_value)[0] # Big-endian float
|
||||||
|
|
||||||
|
return round(float_value, 2) # Round to 2 decimal places like HMI
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error converting IEEE 754 float: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def convert_gallon_counter(high_register: int, low_register: int) -> Optional[float]:
|
||||||
|
"""
|
||||||
|
Convert two 16-bit registers to gallon counter value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
high_register: High 16 bits
|
||||||
|
low_register: Low 16 bits
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Gallon count as float or None if conversion fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Combine registers into 32-bit value
|
||||||
|
combined_32bit = (high_register << 16) | low_register
|
||||||
|
|
||||||
|
# Convert to IEEE 754 float (same as runtime hours)
|
||||||
|
bytes_value = struct.pack('>I', combined_32bit)
|
||||||
|
float_value = struct.unpack('>f', bytes_value)[0]
|
||||||
|
|
||||||
|
return round(float_value, 2)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error converting gallon counter: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_descriptive_value(value: Union[int, float], sensor_config: dict) -> Union[str, int, float]:
|
||||||
|
"""
|
||||||
|
Convert numeric values to descriptive text where applicable.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: Numeric register value
|
||||||
|
sensor_config: Sensor configuration containing value mappings
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Descriptive string or original value
|
||||||
|
"""
|
||||||
|
if "values" in sensor_config and isinstance(sensor_config["values"], dict):
|
||||||
|
return sensor_config["values"].get(str(value), f"Unknown ({value})")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def validate_register_value(value: Optional[int], max_value: int = 65536) -> bool:
|
||||||
|
"""
|
||||||
|
Validate that a register value is within acceptable range.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: Register value to validate
|
||||||
|
max_value: Maximum acceptable value (default: 65536)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if value is valid, False otherwise
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not isinstance(value, (int, float)):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if value < 0 or value >= max_value:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def format_binary_string(value: int, width: int = 16) -> str:
|
||||||
|
"""
|
||||||
|
Format an integer as a binary string with specified width.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: Integer value to format
|
||||||
|
width: Number of bits to display (default: 16)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Binary string representation
|
||||||
|
"""
|
||||||
|
return format(value, f'0{width}b')
|
||||||
167
watermaker_plc_api/utils/error_handler.py
Normal file
167
watermaker_plc_api/utils/error_handler.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
"""
|
||||||
|
Centralized error handling for the Flask application.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Flask, jsonify, request
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, Any
|
||||||
|
from .logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_error_handlers(app: Flask):
|
||||||
|
"""
|
||||||
|
Setup error handlers for the Flask application.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app: Flask application instance
|
||||||
|
"""
|
||||||
|
|
||||||
|
@app.errorhandler(400)
|
||||||
|
def bad_request(error):
|
||||||
|
"""Handle 400 Bad Request errors"""
|
||||||
|
logger.warning(f"Bad request: {request.url} - {error.description}")
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": "Bad Request",
|
||||||
|
"message": error.description or "Invalid request parameters",
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
@app.errorhandler(404)
|
||||||
|
def not_found(error):
|
||||||
|
"""Handle 404 Not Found errors"""
|
||||||
|
logger.warning(f"Not found: {request.url}")
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": "Not Found",
|
||||||
|
"message": f"Resource not found: {request.path}",
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
@app.errorhandler(405)
|
||||||
|
def method_not_allowed(error):
|
||||||
|
"""Handle 405 Method Not Allowed errors"""
|
||||||
|
logger.warning(f"Method not allowed: {request.method} {request.url}")
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": "Method Not Allowed",
|
||||||
|
"message": f"Method {request.method} not allowed for {request.path}",
|
||||||
|
"allowed_methods": list(error.valid_methods) if hasattr(error, 'valid_methods') else [],
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
}), 405
|
||||||
|
|
||||||
|
@app.errorhandler(409)
|
||||||
|
def conflict(error):
|
||||||
|
"""Handle 409 Conflict errors"""
|
||||||
|
logger.warning(f"Conflict: {request.url} - {error.description}")
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": "Conflict",
|
||||||
|
"message": error.description or "Request conflicts with current state",
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
}), 409
|
||||||
|
|
||||||
|
@app.errorhandler(500)
|
||||||
|
def internal_error(error):
|
||||||
|
"""Handle 500 Internal Server Error"""
|
||||||
|
logger.error(f"Internal server error: {request.url} - {str(error)}")
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": "Internal Server Error",
|
||||||
|
"message": "An unexpected error occurred",
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
@app.errorhandler(503)
|
||||||
|
def service_unavailable(error):
|
||||||
|
"""Handle 503 Service Unavailable errors"""
|
||||||
|
logger.error(f"Service unavailable: {request.url} - {error.description}")
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": "Service Unavailable",
|
||||||
|
"message": error.description or "PLC connection unavailable",
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
}), 503
|
||||||
|
|
||||||
|
logger.info("Error handlers configured")
|
||||||
|
|
||||||
|
|
||||||
|
def create_error_response(
|
||||||
|
error_type: str,
|
||||||
|
message: str,
|
||||||
|
status_code: int = 400,
|
||||||
|
details: Dict[str, Any] = None
|
||||||
|
) -> tuple:
|
||||||
|
"""
|
||||||
|
Create a standardized error response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
error_type: Type of error (e.g., "Bad Request", "PLC Error")
|
||||||
|
message: Error message
|
||||||
|
status_code: HTTP status code
|
||||||
|
details: Optional additional error details
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (response_dict, status_code)
|
||||||
|
"""
|
||||||
|
response = {
|
||||||
|
"success": False,
|
||||||
|
"error": error_type,
|
||||||
|
"message": message,
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
if details:
|
||||||
|
response["details"] = details
|
||||||
|
|
||||||
|
return jsonify(response), status_code
|
||||||
|
|
||||||
|
|
||||||
|
def create_success_response(
|
||||||
|
message: str = "Success",
|
||||||
|
data: Dict[str, Any] = None,
|
||||||
|
status_code: int = 200
|
||||||
|
) -> tuple:
|
||||||
|
"""
|
||||||
|
Create a standardized success response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Success message
|
||||||
|
data: Optional response data
|
||||||
|
status_code: HTTP status code
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (response_dict, status_code)
|
||||||
|
"""
|
||||||
|
response = {
|
||||||
|
"success": True,
|
||||||
|
"message": message,
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
if data:
|
||||||
|
response.update(data)
|
||||||
|
|
||||||
|
return jsonify(response), status_code
|
||||||
|
|
||||||
|
|
||||||
|
class PLCConnectionError(Exception):
|
||||||
|
"""Exception raised when PLC connection fails"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterReadError(Exception):
|
||||||
|
"""Exception raised when register read operation fails"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterWriteError(Exception):
|
||||||
|
"""Exception raised when register write operation fails"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DTSOperationError(Exception):
|
||||||
|
"""Exception raised when DTS operation fails"""
|
||||||
|
pass
|
||||||
68
watermaker_plc_api/utils/logger.py
Normal file
68
watermaker_plc_api/utils/logger.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"""
|
||||||
|
Centralized logging configuration for the application.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from typing import Optional
|
||||||
|
from ..config import Config
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name: str, level: Optional[str] = None) -> logging.Logger:
|
||||||
|
"""
|
||||||
|
Get a configured logger instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Logger name (typically __name__)
|
||||||
|
level: Optional log level override
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
logging.Logger: Configured logger instance
|
||||||
|
"""
|
||||||
|
logger = logging.getLogger(name)
|
||||||
|
|
||||||
|
# Only configure if not already configured
|
||||||
|
if not logger.handlers:
|
||||||
|
# Set log level
|
||||||
|
log_level = level or Config.LOG_LEVEL
|
||||||
|
logger.setLevel(getattr(logging, log_level, logging.INFO))
|
||||||
|
|
||||||
|
# Create console handler
|
||||||
|
handler = logging.StreamHandler(sys.stdout)
|
||||||
|
handler.setLevel(logger.level)
|
||||||
|
|
||||||
|
# Create formatter
|
||||||
|
formatter = logging.Formatter(Config.LOG_FORMAT)
|
||||||
|
handler.setFormatter(formatter)
|
||||||
|
|
||||||
|
# Add handler to logger
|
||||||
|
logger.addHandler(handler)
|
||||||
|
|
||||||
|
# Prevent duplicate logs from parent loggers
|
||||||
|
logger.propagate = False
|
||||||
|
|
||||||
|
return logger
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(level: Optional[str] = None):
|
||||||
|
"""
|
||||||
|
Setup application-wide logging configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
level: Optional log level to set globally
|
||||||
|
"""
|
||||||
|
log_level = level or Config.LOG_LEVEL
|
||||||
|
|
||||||
|
# Configure root logger
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, log_level, logging.INFO),
|
||||||
|
format=Config.LOG_FORMAT,
|
||||||
|
handlers=[logging.StreamHandler(sys.stdout)]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Suppress verbose logs from external libraries
|
||||||
|
logging.getLogger('urllib3').setLevel(logging.WARNING)
|
||||||
|
logging.getLogger('pymodbus').setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
logger.info(f"Logging configured at {log_level} level")
|
||||||
Reference in New Issue
Block a user