Initial V2 setup
This commit is contained in:
0
.gitignore → v1/.gitignore
vendored
0
.gitignore → v1/.gitignore
vendored
24
v2/.env.example
Normal file
24
v2/.env.example
Normal file
@@ -0,0 +1,24 @@
|
||||
# HanchuESS API Configuration
|
||||
# Copy this file to .env and fill in your actual values
|
||||
|
||||
# Required: AES encryption key (must be 16, 24, or 32 bytes)
|
||||
HANCHU_AES_KEY=your_aes_key_here
|
||||
|
||||
# Required: AES initialization vector (must be 16 bytes)
|
||||
HANCHU_AES_IV=your_aes_iv_here
|
||||
|
||||
# Required: Login URL for the HanchuESS API
|
||||
HANCHU_LOGIN_URL=https://api.example.com/login
|
||||
|
||||
# Optional: Login type (default: ACCOUNT)
|
||||
HANCHU_LOGIN_TYPE=ACCOUNT
|
||||
|
||||
# Optional: HTTP timeout in seconds (default: 10)
|
||||
HANCHU_HTTP_TIMEOUT=10
|
||||
|
||||
# Optional: Verify SSL certificates (default: true, set to false for self-signed certs)
|
||||
HANCHU_VERIFY_SSL=true
|
||||
|
||||
# Optional: Username and password
|
||||
HANCHU_USERNAME=
|
||||
HANCHU_PASSWORD=
|
||||
176
v2/.gitignore
vendored
Normal file
176
v2/.gitignore
vendored
Normal file
@@ -0,0 +1,176 @@
|
||||
# ---> Python
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
#uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
12
v2/backend/docker/Dockerfile
Normal file
12
v2/backend/docker/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM python:3.13.7-slim-bookworm
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy requirements and install Python dependencies
|
||||
COPY ./requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy the app code and tests
|
||||
COPY ../src ./src
|
||||
|
||||
CMD ["python", "src/main.py"]
|
||||
8
v2/backend/requirements.txt
Normal file
8
v2/backend/requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
pycryptodome>=3.20.0
|
||||
python-dotenv>=1.0.1
|
||||
requests>=2.31.0
|
||||
bcrypt>=4.0.1
|
||||
python-jose[cryptography]>=3.3.0
|
||||
cryptography>=41.0.0
|
||||
143
v2/backend/src/auth.py
Normal file
143
v2/backend/src/auth.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
Authentication utilities for user management and JWT tokens.
|
||||
"""
|
||||
import os
|
||||
import bcrypt
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from jose import JWTError, jwt
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# JWT settings
|
||||
SECRET_KEY = os.getenv("JWT_SECRET_KEY", "your-secret-key-change-this-in-production")
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 15 # Short-lived access tokens
|
||||
REFRESH_TOKEN_EXPIRE_DAYS = 7 # Long-lived refresh tokens
|
||||
|
||||
# Security scheme
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a password using bcrypt."""
|
||||
# Bcrypt has a 72 byte limit, truncate if necessary
|
||||
if len(password.encode('utf-8')) > 72:
|
||||
password = password[:72]
|
||||
|
||||
password_bytes = password.encode('utf-8')
|
||||
salt = bcrypt.gensalt()
|
||||
hashed = bcrypt.hashpw(password_bytes, salt)
|
||||
|
||||
return hashed.decode('utf-8')
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a password against a hash."""
|
||||
# Bcrypt has a 72 byte limit, truncate if necessary
|
||||
if len(plain_password.encode('utf-8')) > 72:
|
||||
plain_password = plain_password[:72]
|
||||
|
||||
password_bytes = plain_password.encode('utf-8')
|
||||
hashed_bytes = hashed_password.encode('utf-8')
|
||||
|
||||
return bcrypt.checkpw(password_bytes, hashed_bytes)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""
|
||||
Create a JWT access token.
|
||||
|
||||
Args:
|
||||
data: Dictionary to encode in the token
|
||||
expires_delta: Optional expiration time delta
|
||||
|
||||
Returns:
|
||||
Encoded JWT token string
|
||||
"""
|
||||
to_encode = data.copy()
|
||||
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
|
||||
to_encode.update({"exp": expire, "type": "access"})
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def create_refresh_token(data: dict) -> str:
|
||||
"""
|
||||
Create a JWT refresh token.
|
||||
|
||||
Args:
|
||||
data: Dictionary to encode in the token
|
||||
|
||||
Returns:
|
||||
Encoded JWT refresh token string
|
||||
"""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
|
||||
to_encode.update({"exp": expire, "type": "refresh"})
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict:
|
||||
"""
|
||||
Decode and verify a JWT access token.
|
||||
|
||||
Args:
|
||||
token: JWT token string
|
||||
|
||||
Returns:
|
||||
Decoded token payload
|
||||
|
||||
Raises:
|
||||
HTTPException: If token is invalid or expired
|
||||
"""
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
return payload
|
||||
except JWTError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> dict:
|
||||
"""
|
||||
Dependency to get current authenticated user from JWT token.
|
||||
|
||||
Args:
|
||||
credentials: HTTP Bearer credentials from request
|
||||
|
||||
Returns:
|
||||
User data from token
|
||||
|
||||
Raises:
|
||||
HTTPException: If authentication fails
|
||||
"""
|
||||
token = credentials.credentials
|
||||
payload = decode_access_token(token)
|
||||
|
||||
username = payload.get("sub")
|
||||
if username is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
return {"username": username}
|
||||
722
v2/backend/src/database/db.py
Normal file
722
v2/backend/src/database/db.py
Normal file
@@ -0,0 +1,722 @@
|
||||
"""
|
||||
Database module for storing HanchuESS station data.
|
||||
Uses SQLite with relational structure for station info and related devices.
|
||||
"""
|
||||
import os
|
||||
import sqlite3
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
from contextlib import contextmanager
|
||||
|
||||
|
||||
class UserDB:
|
||||
"""Database manager for user authentication."""
|
||||
|
||||
def __init__(self, db_path: str = None):
|
||||
"""
|
||||
Initialize database connection.
|
||||
|
||||
Args:
|
||||
db_path: Path to SQLite database file. If None, reads from SQLITE_DB_PATH env var.
|
||||
"""
|
||||
if db_path is None:
|
||||
db_path = os.getenv("SQLITE_DB_PATH", "./hanchuess_solar.db")
|
||||
|
||||
self.db_path = db_path
|
||||
self._init_database()
|
||||
|
||||
@contextmanager
|
||||
def _get_connection(self):
|
||||
"""Context manager for database connections."""
|
||||
conn = sqlite3.connect(self.db_path, check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
raise e
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _init_database(self):
|
||||
"""Create users table if it doesn't exist."""
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
hashed_password TEXT NOT NULL,
|
||||
hanchu_username TEXT NOT NULL,
|
||||
hanchu_password TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
)
|
||||
""")
|
||||
|
||||
cursor.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_username
|
||||
ON users(username)
|
||||
""")
|
||||
|
||||
# Refresh tokens table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
token TEXT UNIQUE NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
revoked BOOLEAN DEFAULT 0,
|
||||
|
||||
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
|
||||
cursor.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_token
|
||||
ON refresh_tokens(token)
|
||||
""")
|
||||
|
||||
cursor.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_username
|
||||
ON refresh_tokens(username)
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
|
||||
def store_refresh_token(self, token: str, username: str, expires_at: str) -> int:
|
||||
"""
|
||||
Store a refresh token.
|
||||
|
||||
Args:
|
||||
token: Refresh token string
|
||||
username: Username the token belongs to
|
||||
expires_at: ISO format expiration datetime
|
||||
|
||||
Returns:
|
||||
Token ID
|
||||
"""
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO refresh_tokens (token, username, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""", (token, username, now, expires_at))
|
||||
|
||||
return cursor.lastrowid
|
||||
|
||||
def get_refresh_token(self, token: str) -> Optional[Dict]:
|
||||
"""
|
||||
Get refresh token details.
|
||||
|
||||
Args:
|
||||
token: Refresh token string
|
||||
|
||||
Returns:
|
||||
Token dictionary or None if not found
|
||||
"""
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT * FROM refresh_tokens
|
||||
WHERE token = ? AND revoked = 0
|
||||
""", (token,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
return dict(row)
|
||||
return None
|
||||
|
||||
def revoke_refresh_token(self, token: str) -> bool:
|
||||
"""
|
||||
Revoke a refresh token.
|
||||
|
||||
Args:
|
||||
token: Refresh token string
|
||||
|
||||
Returns:
|
||||
True if revoked, False if token not found
|
||||
"""
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
UPDATE refresh_tokens
|
||||
SET revoked = 1
|
||||
WHERE token = ?
|
||||
""", (token,))
|
||||
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def revoke_all_user_tokens(self, username: str) -> int:
|
||||
"""
|
||||
Revoke all refresh tokens for a user.
|
||||
|
||||
Args:
|
||||
username: Username
|
||||
|
||||
Returns:
|
||||
Number of tokens revoked
|
||||
"""
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
UPDATE refresh_tokens
|
||||
SET revoked = 1
|
||||
WHERE username = ? AND revoked = 0
|
||||
""", (username,))
|
||||
|
||||
return cursor.rowcount
|
||||
|
||||
def cleanup_expired_tokens(self) -> int:
|
||||
"""
|
||||
Delete expired refresh tokens.
|
||||
|
||||
Returns:
|
||||
Number of tokens deleted
|
||||
"""
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
cursor.execute("""
|
||||
DELETE FROM refresh_tokens
|
||||
WHERE expires_at < ?
|
||||
""", (now,))
|
||||
|
||||
return cursor.rowcount
|
||||
|
||||
def create_user(self, username: str, hashed_password: str,
|
||||
hanchu_username: str, hanchu_password: str) -> int:
|
||||
"""
|
||||
Create a new user.
|
||||
|
||||
Args:
|
||||
username: User's login username
|
||||
hashed_password: Bcrypt hashed password
|
||||
hanchu_username: HanchuESS API username
|
||||
hanchu_password: HanchuESS API password
|
||||
|
||||
Returns:
|
||||
User ID
|
||||
|
||||
Raises:
|
||||
sqlite3.IntegrityError: If username already exists
|
||||
"""
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO users (username, hashed_password, hanchu_username,
|
||||
hanchu_password, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""", (username, hashed_password, hanchu_username, hanchu_password, now, now))
|
||||
|
||||
return cursor.lastrowid
|
||||
|
||||
def get_user_by_username(self, username: str) -> Optional[Dict]:
|
||||
"""
|
||||
Get user by username.
|
||||
|
||||
Args:
|
||||
username: Username to look up
|
||||
|
||||
Returns:
|
||||
User dictionary or None if not found
|
||||
"""
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM users WHERE username = ?", (username,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
return dict(row)
|
||||
return None
|
||||
|
||||
def update_hanchu_credentials(self, username: str, hanchu_username: str,
|
||||
hanchu_password: str) -> bool:
|
||||
"""
|
||||
Update HanchuESS credentials for a user.
|
||||
|
||||
Args:
|
||||
username: User's login username
|
||||
hanchu_username: New HanchuESS username
|
||||
hanchu_password: New HanchuESS password
|
||||
|
||||
Returns:
|
||||
True if updated, False if user not found
|
||||
"""
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE users
|
||||
SET hanchu_username = ?, hanchu_password = ?, updated_at = ?
|
||||
WHERE username = ?
|
||||
""", (hanchu_username, hanchu_password, now, username))
|
||||
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
class StationDB:
|
||||
"""Database manager for HanchuESS station data."""
|
||||
|
||||
def __init__(self, db_path: str = None):
|
||||
"""
|
||||
Initialize database connection.
|
||||
|
||||
Args:
|
||||
db_path: Path to SQLite database file. If None, reads from SQLITE_DB_PATH env var.
|
||||
"""
|
||||
if db_path is None:
|
||||
db_path = os.getenv("SQLITE_DB_PATH", "./hanchuess_solar.db")
|
||||
|
||||
self.db_path = db_path
|
||||
self._init_database()
|
||||
|
||||
@contextmanager
|
||||
def _get_connection(self):
|
||||
"""Context manager for database connections."""
|
||||
conn = sqlite3.connect(self.db_path, check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
raise e
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _init_database(self):
|
||||
"""Create tables if they don't exist."""
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if station_data table exists and if it has added_by_username column
|
||||
cursor.execute("""
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name='station_data'
|
||||
""")
|
||||
table_exists = cursor.fetchone() is not None
|
||||
|
||||
if table_exists:
|
||||
# Check if added_by_username column exists
|
||||
cursor.execute("PRAGMA table_info(station_data)")
|
||||
columns = [row[1] for row in cursor.fetchall()]
|
||||
|
||||
if 'added_by_username' not in columns:
|
||||
# Add the new column with a default value for existing rows
|
||||
cursor.execute("""
|
||||
ALTER TABLE station_data
|
||||
ADD COLUMN added_by_username TEXT NOT NULL DEFAULT 'unknown'
|
||||
""")
|
||||
|
||||
# Main station data table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS station_data (
|
||||
-- Primary identification
|
||||
station_id TEXT PRIMARY KEY,
|
||||
station_name TEXT,
|
||||
station_user_name TEXT,
|
||||
|
||||
-- User who added this station
|
||||
added_by_username TEXT NOT NULL,
|
||||
|
||||
-- Owner information
|
||||
owner_id TEXT,
|
||||
owner_name TEXT,
|
||||
|
||||
-- Location
|
||||
belonging_country_code TEXT,
|
||||
postal_code TEXT,
|
||||
coordinates TEXT, -- Stored as "longitude,latitude"
|
||||
|
||||
-- Power specifications
|
||||
pv_power REAL, -- Total PV power
|
||||
rated_power REAL, -- Rated power
|
||||
capacity_pv1 REAL, -- PV capacity
|
||||
|
||||
-- Pricing information
|
||||
price_type TEXT,
|
||||
buy_product_code TEXT,
|
||||
sell_product_code TEXT,
|
||||
price_platform_code TEXT,
|
||||
price_product_code TEXT,
|
||||
|
||||
-- Arrays stored as JSON
|
||||
buy_prices TEXT, -- JSON array
|
||||
sell_prices TEXT, -- JSON array
|
||||
|
||||
-- Metadata
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
)
|
||||
""")
|
||||
|
||||
# BMS (Battery Management System) table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS station_bms (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
station_id TEXT NOT NULL,
|
||||
|
||||
-- BMS specific fields
|
||||
adr TEXT, -- Address
|
||||
search_type TEXT,
|
||||
dtu_software_ver TEXT,
|
||||
dev_id TEXT,
|
||||
|
||||
-- Store full JSON for all other fields
|
||||
raw_data TEXT,
|
||||
|
||||
created_at DATETIME NOT NULL,
|
||||
|
||||
FOREIGN KEY (station_id) REFERENCES station_data(station_id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
|
||||
# PCS (Power Conversion System) table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS station_pcs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
station_id TEXT NOT NULL,
|
||||
|
||||
-- PCS specific fields
|
||||
adr TEXT, -- Address
|
||||
parallel TEXT,
|
||||
create_timestamp BIGINT,
|
||||
update_timestamp BIGINT,
|
||||
|
||||
-- Store full JSON for all other fields
|
||||
raw_data TEXT,
|
||||
|
||||
created_at DATETIME NOT NULL,
|
||||
|
||||
FOREIGN KEY (station_id) REFERENCES station_data(station_id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
|
||||
# MT (Meter) table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS station_mt (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
station_id TEXT NOT NULL,
|
||||
|
||||
-- Store full JSON
|
||||
raw_data TEXT,
|
||||
|
||||
created_at DATETIME NOT NULL,
|
||||
|
||||
FOREIGN KEY (station_id) REFERENCES station_data(station_id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
|
||||
# EV Charger table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS station_ev_charge (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
station_id TEXT NOT NULL,
|
||||
|
||||
-- Store full JSON
|
||||
raw_data TEXT,
|
||||
|
||||
created_at DATETIME NOT NULL,
|
||||
|
||||
FOREIGN KEY (station_id) REFERENCES station_data(station_id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
|
||||
# Heat system table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS station_heat (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
station_id TEXT NOT NULL,
|
||||
|
||||
-- Store full JSON
|
||||
raw_data TEXT,
|
||||
|
||||
created_at DATETIME NOT NULL,
|
||||
|
||||
FOREIGN KEY (station_id) REFERENCES station_data(station_id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
|
||||
# Create indexes
|
||||
cursor.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_station_id
|
||||
ON station_data(station_id)
|
||||
""")
|
||||
|
||||
cursor.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_station_added_by
|
||||
ON station_data(added_by_username)
|
||||
""")
|
||||
|
||||
cursor.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_bms_station_id
|
||||
ON station_bms(station_id)
|
||||
""")
|
||||
|
||||
cursor.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_pcs_station_id
|
||||
ON station_pcs(station_id)
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
|
||||
def upsert_station_data(self, data: Dict, username: str) -> str:
|
||||
"""
|
||||
Insert or update station data along with all related devices.
|
||||
|
||||
Args:
|
||||
data: Dictionary containing the station API response
|
||||
username: Username of the user adding/updating the station
|
||||
|
||||
Returns:
|
||||
The station_id that was inserted/updated
|
||||
"""
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
station_id = data.get('stationId')
|
||||
if not station_id:
|
||||
raise ValueError("stationId is required")
|
||||
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
# Check if station exists
|
||||
cursor.execute("SELECT station_id, added_by_username FROM station_data WHERE station_id = ?", (station_id,))
|
||||
existing = cursor.fetchone()
|
||||
|
||||
if existing:
|
||||
# Update existing station (keep original added_by_username)
|
||||
cursor.execute("""
|
||||
UPDATE station_data SET
|
||||
station_name = ?,
|
||||
station_user_name = ?,
|
||||
owner_id = ?,
|
||||
owner_name = ?,
|
||||
belonging_country_code = ?,
|
||||
postal_code = ?,
|
||||
coordinates = ?,
|
||||
pv_power = ?,
|
||||
rated_power = ?,
|
||||
capacity_pv1 = ?,
|
||||
price_type = ?,
|
||||
buy_product_code = ?,
|
||||
sell_product_code = ?,
|
||||
price_platform_code = ?,
|
||||
price_product_code = ?,
|
||||
buy_prices = ?,
|
||||
sell_prices = ?,
|
||||
updated_at = ?
|
||||
WHERE station_id = ?
|
||||
""", (
|
||||
data.get('stationName'),
|
||||
data.get('stationUserName'),
|
||||
data.get('ownerId'),
|
||||
data.get('ownerName'),
|
||||
data.get('belongingCountryCode'),
|
||||
data.get('postalCode'),
|
||||
data.get('coordinates'),
|
||||
self._to_float(data.get('pvPower')),
|
||||
self._to_float(data.get('ratedPower')),
|
||||
self._to_float(data.get('capacityPv1')),
|
||||
data.get('priceType'),
|
||||
data.get('buyProductCode'),
|
||||
data.get('sellProductCode'),
|
||||
data.get('pricePlatformCode'),
|
||||
data.get('priceProductCode'),
|
||||
json.dumps(data.get('buyPrices', [])),
|
||||
json.dumps(data.get('sellPrices', [])),
|
||||
now,
|
||||
station_id
|
||||
))
|
||||
|
||||
# Delete existing related records (we'll re-insert)
|
||||
cursor.execute("DELETE FROM station_bms WHERE station_id = ?", (station_id,))
|
||||
cursor.execute("DELETE FROM station_pcs WHERE station_id = ?", (station_id,))
|
||||
cursor.execute("DELETE FROM station_mt WHERE station_id = ?", (station_id,))
|
||||
cursor.execute("DELETE FROM station_ev_charge WHERE station_id = ?", (station_id,))
|
||||
cursor.execute("DELETE FROM station_heat WHERE station_id = ?", (station_id,))
|
||||
|
||||
else:
|
||||
# Insert new station
|
||||
cursor.execute("""
|
||||
INSERT INTO station_data (
|
||||
station_id, added_by_username, station_name, station_user_name,
|
||||
owner_id, owner_name,
|
||||
belonging_country_code, postal_code, coordinates,
|
||||
pv_power, rated_power, capacity_pv1,
|
||||
price_type, buy_product_code, sell_product_code,
|
||||
price_platform_code, price_product_code,
|
||||
buy_prices, sell_prices,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
station_id,
|
||||
username,
|
||||
data.get('stationName'),
|
||||
data.get('stationUserName'),
|
||||
data.get('ownerId'),
|
||||
data.get('ownerName'),
|
||||
data.get('belongingCountryCode'),
|
||||
data.get('postalCode'),
|
||||
data.get('coordinates'),
|
||||
self._to_float(data.get('pvPower')),
|
||||
self._to_float(data.get('ratedPower')),
|
||||
self._to_float(data.get('capacityPv1')),
|
||||
data.get('priceType'),
|
||||
data.get('buyProductCode'),
|
||||
data.get('sellProductCode'),
|
||||
data.get('pricePlatformCode'),
|
||||
data.get('priceProductCode'),
|
||||
json.dumps(data.get('buyPrices', [])),
|
||||
json.dumps(data.get('sellPrices', [])),
|
||||
now,
|
||||
now
|
||||
))
|
||||
|
||||
# Insert BMS devices
|
||||
for bms in data.get('bmsList', []):
|
||||
cursor.execute("""
|
||||
INSERT INTO station_bms (
|
||||
station_id, adr, search_type, dtu_software_ver, dev_id,
|
||||
raw_data, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
station_id,
|
||||
bms.get('adr'),
|
||||
bms.get('searchType'),
|
||||
bms.get('dtuSoftwareVer'),
|
||||
bms.get('devId'),
|
||||
json.dumps(bms),
|
||||
now
|
||||
))
|
||||
|
||||
# Insert PCS devices
|
||||
for pcs in data.get('pcsList', []):
|
||||
cursor.execute("""
|
||||
INSERT INTO station_pcs (
|
||||
station_id, adr, parallel, create_timestamp, update_timestamp,
|
||||
raw_data, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
station_id,
|
||||
pcs.get('adr'),
|
||||
pcs.get('parallel'),
|
||||
pcs.get('createTimestamp'),
|
||||
pcs.get('updateTimestamp'),
|
||||
json.dumps(pcs),
|
||||
now
|
||||
))
|
||||
|
||||
# Insert MT devices
|
||||
for mt in data.get('mtList', []):
|
||||
cursor.execute("""
|
||||
INSERT INTO station_mt (station_id, raw_data, created_at)
|
||||
VALUES (?, ?, ?)
|
||||
""", (station_id, json.dumps(mt), now))
|
||||
|
||||
# Insert EV chargers
|
||||
for ev in data.get('evChargeList', []):
|
||||
cursor.execute("""
|
||||
INSERT INTO station_ev_charge (station_id, raw_data, created_at)
|
||||
VALUES (?, ?, ?)
|
||||
""", (station_id, json.dumps(ev), now))
|
||||
|
||||
# Insert heat systems
|
||||
for heat in data.get('heatList', []):
|
||||
cursor.execute("""
|
||||
INSERT INTO station_heat (station_id, raw_data, created_at)
|
||||
VALUES (?, ?, ?)
|
||||
""", (station_id, json.dumps(heat), now))
|
||||
|
||||
return station_id
|
||||
|
||||
def get_station_data(self, station_id: str, username: str) -> Optional[Dict]:
|
||||
"""
|
||||
Get station data with all related devices for a specific user.
|
||||
|
||||
Args:
|
||||
station_id: Station identifier
|
||||
username: Username to verify ownership
|
||||
|
||||
Returns:
|
||||
Dictionary with station data and related devices, or None if not found or not owned by user
|
||||
"""
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get main station data, verifying ownership
|
||||
cursor.execute(
|
||||
"SELECT * FROM station_data WHERE station_id = ? AND added_by_username = ?",
|
||||
(station_id, username)
|
||||
)
|
||||
station = cursor.fetchone()
|
||||
|
||||
if not station:
|
||||
return None
|
||||
|
||||
result = dict(station)
|
||||
|
||||
# Parse JSON fields
|
||||
result['buyPrices'] = json.loads(result.pop('buy_prices', '[]'))
|
||||
result['sellPrices'] = json.loads(result.pop('sell_prices', '[]'))
|
||||
|
||||
# Get BMS devices
|
||||
cursor.execute("SELECT * FROM station_bms WHERE station_id = ?", (station_id,))
|
||||
result['bmsList'] = [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
# Get PCS devices
|
||||
cursor.execute("SELECT * FROM station_pcs WHERE station_id = ?", (station_id,))
|
||||
result['pcsList'] = [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
# Get MT devices
|
||||
cursor.execute("SELECT * FROM station_mt WHERE station_id = ?", (station_id,))
|
||||
result['mtList'] = [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
# Get EV chargers
|
||||
cursor.execute("SELECT * FROM station_ev_charge WHERE station_id = ?", (station_id,))
|
||||
result['evChargeList'] = [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
# Get heat systems
|
||||
cursor.execute("SELECT * FROM station_heat WHERE station_id = ?", (station_id,))
|
||||
result['heatList'] = [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
return result
|
||||
|
||||
def list_stations(self, username: str) -> List[Dict]:
|
||||
"""
|
||||
Get list of stations for a specific user.
|
||||
|
||||
Args:
|
||||
username: Username to filter stations by
|
||||
|
||||
Returns:
|
||||
List of station data dictionaries owned by the user
|
||||
"""
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT * FROM station_data WHERE added_by_username = ? ORDER BY station_name",
|
||||
(username,)
|
||||
)
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
@staticmethod
|
||||
def _to_float(value) -> Optional[float]:
|
||||
"""Convert string or numeric value to float, handling None."""
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
47
v2/backend/src/encryption.py
Normal file
47
v2/backend/src/encryption.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
Encryption utilities for sensitive data storage.
|
||||
"""
|
||||
import os
|
||||
from cryptography.fernet import Fernet
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Get or generate encryption key
|
||||
ENCRYPTION_KEY = os.getenv("ENCRYPTION_KEY")
|
||||
if not ENCRYPTION_KEY:
|
||||
# Generate a key if not provided (for development)
|
||||
# In production, this should be set in .env
|
||||
ENCRYPTION_KEY = Fernet.generate_key().decode()
|
||||
print(f"WARNING: No ENCRYPTION_KEY in .env. Generated: {ENCRYPTION_KEY}")
|
||||
print("Add this to your .env file!")
|
||||
|
||||
_fernet = Fernet(ENCRYPTION_KEY.encode() if isinstance(ENCRYPTION_KEY, str) else ENCRYPTION_KEY)
|
||||
|
||||
|
||||
def encrypt_string(plain_text: str) -> str:
|
||||
"""
|
||||
Encrypt a string using Fernet symmetric encryption.
|
||||
|
||||
Args:
|
||||
plain_text: String to encrypt
|
||||
|
||||
Returns:
|
||||
Base64-encoded encrypted string
|
||||
"""
|
||||
encrypted = _fernet.encrypt(plain_text.encode('utf-8'))
|
||||
return encrypted.decode('utf-8')
|
||||
|
||||
|
||||
def decrypt_string(encrypted_text: str) -> str:
|
||||
"""
|
||||
Decrypt a Fernet-encrypted string.
|
||||
|
||||
Args:
|
||||
encrypted_text: Base64-encoded encrypted string
|
||||
|
||||
Returns:
|
||||
Decrypted plain text string
|
||||
"""
|
||||
decrypted = _fernet.decrypt(encrypted_text.encode('utf-8'))
|
||||
return decrypted.decode('utf-8')
|
||||
406
v2/backend/src/main.py
Normal file
406
v2/backend/src/main.py
Normal file
@@ -0,0 +1,406 @@
|
||||
from fastapi import FastAPI, HTTPException, Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
from service.hanchu_service import HanchuESSService
|
||||
from service.auth_service import get_auth_service
|
||||
from database.db import StationDB
|
||||
from auth import create_access_token, create_refresh_token, decode_access_token, get_current_user
|
||||
|
||||
app = FastAPI(title="HanchuESS Solar Backend API v2")
|
||||
|
||||
# Initialize services
|
||||
station_db = StationDB()
|
||||
auth_service = get_auth_service()
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
"""Request model for user registration."""
|
||||
username: str
|
||||
password: str
|
||||
hanchu_username: str
|
||||
hanchu_password: str
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
"""Request model for user login."""
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class DecryptRequest(BaseModel):
|
||||
"""Request model for decrypting a payload."""
|
||||
encrypted_payload: str
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
"""Request model for refreshing access token."""
|
||||
refresh_token: str
|
||||
|
||||
|
||||
@app.get("/", tags=["Root"])
|
||||
def root():
|
||||
return {"message": "Welcome to HanchuESS Solar Backend API v2"}
|
||||
|
||||
|
||||
@app.get("/health", tags=["Root"])
|
||||
def health_check():
|
||||
return {"status": "healthy"}
|
||||
|
||||
|
||||
@app.post("/auth/register", tags=["Authentication"])
|
||||
def register(request: RegisterRequest):
|
||||
"""
|
||||
Register a new user with their HanchuESS credentials.
|
||||
|
||||
Args:
|
||||
request: Contains username, password, and HanchuESS API credentials
|
||||
|
||||
Returns:
|
||||
Success message
|
||||
"""
|
||||
try:
|
||||
user_id = auth_service.register_user(
|
||||
username=request.username,
|
||||
password=request.password,
|
||||
hanchu_username=request.hanchu_username,
|
||||
hanchu_password=request.hanchu_password
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "User registered successfully",
|
||||
"user_id": user_id
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/auth/login", tags=["Authentication"])
|
||||
def login(request: LoginRequest):
|
||||
"""
|
||||
Login and receive a JWT access token.
|
||||
|
||||
Args:
|
||||
request: Contains username and password
|
||||
|
||||
Returns:
|
||||
JWT access token
|
||||
"""
|
||||
try:
|
||||
# Authenticate user
|
||||
user = auth_service.authenticate_user(request.username, request.password)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Incorrect username or password"
|
||||
)
|
||||
|
||||
# Create access token
|
||||
access_token = create_access_token(data={"sub": user["username"]})
|
||||
|
||||
# Create refresh token
|
||||
refresh_token = create_refresh_token(data={"sub": user["username"]})
|
||||
|
||||
# Store refresh token in database
|
||||
auth_service.store_refresh_token(refresh_token, user["username"])
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": 900 # 15 minutes in seconds
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/auth/refresh", tags=["Authentication"])
|
||||
def refresh_access_token(request: RefreshTokenRequest):
|
||||
"""
|
||||
Get a new access token using a refresh token.
|
||||
|
||||
Args:
|
||||
request: Contains refresh_token
|
||||
|
||||
Returns:
|
||||
New access token
|
||||
"""
|
||||
try:
|
||||
# Validate refresh token
|
||||
username = auth_service.validate_refresh_token(request.refresh_token)
|
||||
if not username:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Invalid or expired refresh token"
|
||||
)
|
||||
|
||||
# Create new access token
|
||||
access_token = create_access_token(data={"sub": username})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": 900 # 15 minutes in seconds
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/auth/logout", tags=["Authentication"])
|
||||
def logout(request: RefreshTokenRequest, current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
Logout by revoking the refresh token.
|
||||
|
||||
Args:
|
||||
request: Contains refresh_token to revoke
|
||||
current_user: Current authenticated user
|
||||
|
||||
Returns:
|
||||
Success message
|
||||
"""
|
||||
try:
|
||||
# Revoke the refresh token
|
||||
revoked = auth_service.revoke_refresh_token(request.refresh_token)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Logged out successfully" if revoked else "Token already revoked"
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/auth/logout-all", tags=["Authentication"])
|
||||
def logout_all(current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
Logout from all devices by revoking all refresh tokens.
|
||||
|
||||
Args:
|
||||
current_user: Current authenticated user
|
||||
|
||||
Returns:
|
||||
Success message with count of revoked tokens
|
||||
"""
|
||||
try:
|
||||
# Revoke all refresh tokens for the user
|
||||
count = auth_service.revoke_all_user_tokens(current_user["username"])
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Logged out from {count} device(s)"
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
def get_user_service(current_user: dict = Depends(get_current_user)) -> HanchuESSService:
|
||||
"""
|
||||
Get HanchuESS service instance configured for the current user.
|
||||
|
||||
Args:
|
||||
current_user: Current authenticated user from JWT token
|
||||
|
||||
Returns:
|
||||
HanchuESSService instance with user's credentials
|
||||
"""
|
||||
# Get user's HanchuESS credentials from auth service
|
||||
credentials = auth_service.get_user_hanchu_credentials(current_user["username"])
|
||||
if not credentials:
|
||||
raise HTTPException(status_code=401, detail="User not found")
|
||||
|
||||
# Create service instance with user's credentials
|
||||
return HanchuESSService(
|
||||
hanchu_username=credentials["hanchu_username"],
|
||||
hanchu_password=credentials["hanchu_password"]
|
||||
)
|
||||
|
||||
|
||||
@app.get("/hanchu/stations/list", tags=["HanchuESS API"])
|
||||
def query_stations_from_api(
|
||||
current: int = 1,
|
||||
size: int = 100,
|
||||
hanchu_service: HanchuESSService = Depends(get_user_service)
|
||||
):
|
||||
"""
|
||||
Query list of stations from HanchuESS API.
|
||||
|
||||
Args:
|
||||
current: Page number (default 1)
|
||||
size: Page size (default 100)
|
||||
|
||||
Returns:
|
||||
Paginated list of stations with their IDs and names
|
||||
"""
|
||||
try:
|
||||
result = hanchu_service.query_station_list(current=current, size=size)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": result
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/utils/decrypt", tags=["Utilities"])
|
||||
def decrypt_payload(
|
||||
request: DecryptRequest,
|
||||
hanchu_service: HanchuESSService = Depends(get_user_service)
|
||||
):
|
||||
"""
|
||||
Decrypt an AES-encrypted HanchuESS payload.
|
||||
|
||||
Args:
|
||||
request: Contains encrypted_payload (base64-encoded encrypted string)
|
||||
|
||||
Returns:
|
||||
Decrypted data and its type
|
||||
"""
|
||||
try:
|
||||
decrypted_data = hanchu_service.decrypt_payload(request.encrypted_payload)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"decrypted_data": decrypted_data,
|
||||
"data_type": type(decrypted_data).__name__
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Decryption failed: {str(e)}")
|
||||
|
||||
|
||||
@app.post("/db/stations/sync", tags=["Database"])
|
||||
def sync_stations(
|
||||
hanchu_service: HanchuESSService = Depends(get_user_service),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Fetch all stations from HanchuESS API and store them in database.
|
||||
|
||||
Returns:
|
||||
Summary of synced stations
|
||||
"""
|
||||
try:
|
||||
# Get list of stations from API
|
||||
station_list = hanchu_service.query_station_list(current=1, size=100)
|
||||
records = station_list.get('records', [])
|
||||
|
||||
if not records:
|
||||
return {
|
||||
"success": True,
|
||||
"message": "No stations found",
|
||||
"synced": 0,
|
||||
"failed": 0,
|
||||
"stations": []
|
||||
}
|
||||
|
||||
synced = []
|
||||
failed = []
|
||||
|
||||
# Fetch and store each station
|
||||
for record in records:
|
||||
station_id = record.get('stationId')
|
||||
if not station_id:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Fetch full station info
|
||||
station_data = hanchu_service.get_station_info(station_id)
|
||||
|
||||
# Check if we got real data
|
||||
has_real_data = any(
|
||||
key in station_data and station_data[key] not in [None, [], "", {}]
|
||||
for key in ['stationName', 'pcsList', 'bmsList', 'coordinates', 'ownerName']
|
||||
)
|
||||
|
||||
if has_real_data:
|
||||
# Store in database with username
|
||||
station_db.upsert_station_data(station_data, current_user["username"])
|
||||
synced.append({
|
||||
"stationId": station_id,
|
||||
"stationName": station_data.get('stationName')
|
||||
})
|
||||
else:
|
||||
failed.append({
|
||||
"stationId": station_id,
|
||||
"reason": "No data available"
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
failed.append({
|
||||
"stationId": station_id,
|
||||
"reason": str(e)
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Synced {len(synced)} stations, {len(failed)} failed",
|
||||
"synced": len(synced),
|
||||
"failed": len(failed),
|
||||
"stations": synced,
|
||||
"errors": failed
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/db/stations/{station_id}", tags=["Database"])
|
||||
def get_station_from_db(
|
||||
station_id: str,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get station data from database.
|
||||
|
||||
Args:
|
||||
station_id: Station ID to retrieve
|
||||
|
||||
Returns:
|
||||
Station data with all devices
|
||||
"""
|
||||
try:
|
||||
station = station_db.get_station_data(station_id, current_user["username"])
|
||||
|
||||
if not station:
|
||||
raise HTTPException(status_code=404, detail="Station not found or access denied")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": station
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/db/stations", tags=["Database"])
|
||||
def list_stations_from_db(current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
List all stations from local database for the current user.
|
||||
|
||||
Returns:
|
||||
List of user's stations
|
||||
"""
|
||||
try:
|
||||
stations = station_db.list_stations(current_user["username"])
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"count": len(stations),
|
||||
"data": stations
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8050)
|
||||
1
v2/backend/src/service/__init__.py
Normal file
1
v2/backend/src/service/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Service layer for HanchuESS API integration."""
|
||||
177
v2/backend/src/service/auth_service.py
Normal file
177
v2/backend/src/service/auth_service.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
Authentication service for user management.
|
||||
"""
|
||||
import bcrypt
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from database.db import UserDB
|
||||
from encryption import encrypt_string, decrypt_string
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""Service for user authentication operations."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize authentication service with user database."""
|
||||
self.user_db = UserDB()
|
||||
|
||||
def hash_password(self, password: str) -> str:
|
||||
"""Hash a password using bcrypt."""
|
||||
# Bcrypt has a 72 byte limit, truncate if necessary
|
||||
if len(password.encode('utf-8')) > 72:
|
||||
password = password[:72]
|
||||
|
||||
password_bytes = password.encode('utf-8')
|
||||
salt = bcrypt.gensalt()
|
||||
hashed = bcrypt.hashpw(password_bytes, salt)
|
||||
|
||||
return hashed.decode('utf-8')
|
||||
|
||||
def verify_password(self, plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a password against a hash."""
|
||||
# Bcrypt has a 72 byte limit, truncate if necessary
|
||||
if len(plain_password.encode('utf-8')) > 72:
|
||||
plain_password = plain_password[:72]
|
||||
|
||||
password_bytes = plain_password.encode('utf-8')
|
||||
hashed_bytes = hashed_password.encode('utf-8')
|
||||
|
||||
return bcrypt.checkpw(password_bytes, hashed_bytes)
|
||||
|
||||
def register_user(self, username: str, password: str,
|
||||
hanchu_username: str, hanchu_password: str) -> int:
|
||||
"""
|
||||
Register a new user.
|
||||
|
||||
Args:
|
||||
username: User's login username
|
||||
password: User's login password (will be hashed)
|
||||
hanchu_username: HanchuESS API username
|
||||
hanchu_password: HanchuESS API password (will be encrypted)
|
||||
|
||||
Returns:
|
||||
User ID
|
||||
|
||||
Raises:
|
||||
ValueError: If username already exists
|
||||
"""
|
||||
# Check if username already exists
|
||||
existing_user = self.user_db.get_user_by_username(username)
|
||||
if existing_user:
|
||||
raise ValueError("Username already exists")
|
||||
|
||||
# Hash the password
|
||||
hashed_password = self.hash_password(password)
|
||||
|
||||
# Encrypt HanchuESS credentials before storing
|
||||
encrypted_hanchu_password = encrypt_string(hanchu_password)
|
||||
|
||||
# Create user
|
||||
user_id = self.user_db.create_user(
|
||||
username=username,
|
||||
hashed_password=hashed_password,
|
||||
hanchu_username=hanchu_username,
|
||||
hanchu_password=encrypted_hanchu_password
|
||||
)
|
||||
|
||||
return user_id
|
||||
|
||||
def authenticate_user(self, username: str, password: str) -> Optional[dict]:
|
||||
"""
|
||||
Authenticate a user with username and password.
|
||||
|
||||
Args:
|
||||
username: User's login username
|
||||
password: User's login password
|
||||
|
||||
Returns:
|
||||
User dictionary if authentication successful, None otherwise
|
||||
"""
|
||||
# Get user from database
|
||||
user = self.user_db.get_user_by_username(username)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
# Verify password
|
||||
if not self.verify_password(password, user["hashed_password"]):
|
||||
return None
|
||||
|
||||
return user
|
||||
|
||||
def get_user_hanchu_credentials(self, username: str) -> Optional[dict]:
|
||||
"""
|
||||
Get user's HanchuESS credentials.
|
||||
|
||||
Args:
|
||||
username: User's login username
|
||||
|
||||
Returns:
|
||||
Dictionary with hanchu_username and decrypted hanchu_password, or None if not found
|
||||
"""
|
||||
user = self.user_db.get_user_by_username(username)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
# Decrypt the HanchuESS password
|
||||
decrypted_password = decrypt_string(user["hanchu_password"])
|
||||
|
||||
return {
|
||||
"hanchu_username": user["hanchu_username"],
|
||||
"hanchu_password": decrypted_password
|
||||
}
|
||||
|
||||
def store_refresh_token(self, token: str, username: str, expires_in_days: int = 7) -> int:
|
||||
"""
|
||||
Store a refresh token.
|
||||
|
||||
Args:
|
||||
token: Refresh token string
|
||||
username: Username
|
||||
expires_in_days: Days until expiration
|
||||
|
||||
Returns:
|
||||
Token ID
|
||||
"""
|
||||
expires_at = (datetime.now() + timedelta(days=expires_in_days)).isoformat()
|
||||
return self.user_db.store_refresh_token(token, username, expires_at)
|
||||
|
||||
def validate_refresh_token(self, token: str) -> Optional[str]:
|
||||
"""
|
||||
Validate a refresh token and return the username if valid.
|
||||
|
||||
Args:
|
||||
token: Refresh token string
|
||||
|
||||
Returns:
|
||||
Username if valid, None otherwise
|
||||
"""
|
||||
token_data = self.user_db.get_refresh_token(token)
|
||||
if not token_data:
|
||||
return None
|
||||
|
||||
# Check if expired
|
||||
expires_at = datetime.fromisoformat(token_data["expires_at"])
|
||||
if datetime.now() > expires_at:
|
||||
return None
|
||||
|
||||
return token_data["username"]
|
||||
|
||||
def revoke_refresh_token(self, token: str) -> bool:
|
||||
"""Revoke a refresh token."""
|
||||
return self.user_db.revoke_refresh_token(token)
|
||||
|
||||
def revoke_all_user_tokens(self, username: str) -> int:
|
||||
"""Revoke all refresh tokens for a user."""
|
||||
return self.user_db.revoke_all_user_tokens(username)
|
||||
|
||||
|
||||
# Global service instance
|
||||
_auth_service = None
|
||||
|
||||
|
||||
def get_auth_service() -> AuthService:
|
||||
"""Get or create the global auth service instance."""
|
||||
global _auth_service
|
||||
if _auth_service is None:
|
||||
_auth_service = AuthService()
|
||||
return _auth_service
|
||||
315
v2/backend/src/service/hanchu_service.py
Normal file
315
v2/backend/src/service/hanchu_service.py
Normal file
@@ -0,0 +1,315 @@
|
||||
"""
|
||||
HanchuESS API service for authentication and API calls.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import requests
|
||||
|
||||
from Crypto.Cipher import AES, PKCS1_v1_5
|
||||
from Crypto.PublicKey import RSA
|
||||
from Crypto.Util.Padding import pad, unpad
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
class HanchuESSService:
|
||||
"""Service for interacting with HanchuESS solar API."""
|
||||
|
||||
# RSA public key from the JavaScript code
|
||||
RSA_PUBLIC_KEY = """-----BEGIN PUBLIC KEY-----
|
||||
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCVg7RFDLMGM4O98d1zWKI5RQan
|
||||
jci3iY4qlpgsH76fUn3GnZtqjbRk37lCQDv6AhgPNXRPpty81+g909/c4yzySKaP
|
||||
CcDZv7KdCRB1mVxkq+0z4EtKx9EoTXKnFSDBaYi2srdal1tM3gGOsNTDN58CzYPX
|
||||
nDGPX7+EHS1Mm4aVDQIDAQAB
|
||||
-----END PUBLIC KEY-----"""
|
||||
|
||||
def __init__(self, hanchu_username: str = None, hanchu_password: str = None):
|
||||
"""
|
||||
Initialize service with configuration from environment or parameters.
|
||||
|
||||
Args:
|
||||
hanchu_username: Optional HanchuESS username (overrides env var)
|
||||
hanchu_password: Optional HanchuESS password (overrides env var)
|
||||
"""
|
||||
self.name = "HanchuESS Service"
|
||||
|
||||
# Load config from environment
|
||||
self.aes_key = os.environ["HANCHU_AES_KEY"].encode("utf-8")
|
||||
self.aes_iv = os.environ["HANCHU_AES_IV"].encode("utf-8")
|
||||
self.login_url = os.environ["HANCHU_LOGIN_URL"]
|
||||
self.base_url = os.getenv("HANCHU_BASE_URL", "https://iess3.hanchuess.com/gateway/")
|
||||
self.login_type = os.getenv("HANCHU_LOGIN_TYPE", "ACCOUNT")
|
||||
self.timeout = int(os.getenv("HANCHU_HTTP_TIMEOUT", "10"))
|
||||
self.verify_ssl = os.getenv("HANCHU_VERIFY_SSL", "true").lower() == "true"
|
||||
|
||||
# Use provided credentials or fall back to environment
|
||||
self.hanchu_username = hanchu_username or os.getenv("HANCHU_USERNAME", "")
|
||||
self.hanchu_password = hanchu_password or os.getenv("HANCHU_PASSWORD", "")
|
||||
|
||||
# Cache for access token
|
||||
self._access_token = None
|
||||
|
||||
# Safety checks
|
||||
if len(self.aes_key) not in (16, 24, 32):
|
||||
raise ValueError("AES key must be 16, 24, or 32 bytes")
|
||||
if len(self.aes_iv) != 16:
|
||||
raise ValueError("AES IV must be exactly 16 bytes")
|
||||
|
||||
def encrypt_payload(self, data: dict | str) -> str:
|
||||
"""
|
||||
Encrypt payload using AES-CBC and return base64 string.
|
||||
|
||||
Args:
|
||||
data: Dictionary or string to encrypt
|
||||
|
||||
Returns:
|
||||
Base64-encoded encrypted payload
|
||||
"""
|
||||
if not isinstance(data, str):
|
||||
data = json.dumps(data, separators=(",", ":"))
|
||||
|
||||
cipher = AES.new(self.aes_key, AES.MODE_CBC, self.aes_iv)
|
||||
ciphertext = cipher.encrypt(pad(data.encode("utf-8"), AES.block_size))
|
||||
return base64.b64encode(ciphertext).decode("utf-8")
|
||||
|
||||
def decrypt_payload(self, encrypted_data: str) -> dict | str:
|
||||
"""
|
||||
Decrypt base64-encoded AES-CBC payload and return the original data.
|
||||
|
||||
Args:
|
||||
encrypted_data: Base64-encoded encrypted string
|
||||
|
||||
Returns:
|
||||
Decrypted data (dict if valid JSON, string otherwise)
|
||||
"""
|
||||
ciphertext = base64.b64decode(encrypted_data)
|
||||
cipher = AES.new(self.aes_key, AES.MODE_CBC, self.aes_iv)
|
||||
decrypted = unpad(cipher.decrypt(ciphertext), AES.block_size)
|
||||
decrypted_str = decrypted.decode("utf-8")
|
||||
|
||||
# Try to parse as JSON, return string if it fails
|
||||
try:
|
||||
return json.loads(decrypted_str)
|
||||
except json.JSONDecodeError:
|
||||
return decrypted_str
|
||||
|
||||
def encrypt_password_rsa(self, password: str) -> str:
|
||||
"""
|
||||
Encrypt password using RSA public key (matches JavaScript GO function).
|
||||
|
||||
Args:
|
||||
password: Plain text password
|
||||
|
||||
Returns:
|
||||
Base64-encoded encrypted password
|
||||
"""
|
||||
public_key = RSA.import_key(self.RSA_PUBLIC_KEY)
|
||||
cipher = PKCS1_v1_5.new(public_key)
|
||||
encrypted = cipher.encrypt(password.encode('utf-8'))
|
||||
return base64.b64encode(encrypted).decode('utf-8')
|
||||
|
||||
def get_access_token(self) -> str:
|
||||
"""
|
||||
Authenticate with Hanchu ESS and return access token.
|
||||
Uses double encryption: RSA for password, then AES for entire payload.
|
||||
Caches the token to avoid unnecessary logins.
|
||||
|
||||
Returns:
|
||||
JWT access token
|
||||
"""
|
||||
# Return cached token if available
|
||||
if self._access_token:
|
||||
return self._access_token
|
||||
|
||||
# Step 1: RSA encrypt the password
|
||||
encrypted_password = self.encrypt_password_rsa(self.hanchu_password)
|
||||
|
||||
# Step 2: Build payload with encrypted password
|
||||
payload = {
|
||||
"account": self.hanchu_username,
|
||||
"pwd": encrypted_password,
|
||||
"loginType": self.login_type,
|
||||
}
|
||||
|
||||
# Step 3: AES encrypt the entire payload
|
||||
encrypted_payload = self.encrypt_payload(payload)
|
||||
|
||||
# Step 4: Send to API with correct headers
|
||||
headers = {
|
||||
"Content-Type": "text/plain",
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
"appplat": "iess",
|
||||
"locale": "en",
|
||||
"timezone": "Africa/Accra",
|
||||
"timeselected": "GMT",
|
||||
"version": "1.0",
|
||||
"crypto-version": "1.0.0",
|
||||
"Origin": "https://iess3.hanchuess.com",
|
||||
"Referer": "https://iess3.hanchuess.com/login",
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
self.login_url,
|
||||
data=encrypted_payload,
|
||||
headers=headers,
|
||||
timeout=self.timeout,
|
||||
verify=self.verify_ssl,
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
try:
|
||||
# The token is directly in the 'data' field as a JWT string
|
||||
if result.get("success") and result.get("data"):
|
||||
self._access_token = result["data"]
|
||||
return self._access_token
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"Hanchu login failed: {json.dumps(result, ensure_ascii=False)}"
|
||||
)
|
||||
except (KeyError, TypeError):
|
||||
raise RuntimeError(
|
||||
f"Hanchu login failed: {json.dumps(result, ensure_ascii=False)}"
|
||||
)
|
||||
|
||||
def query_station_list(self, current: int = 1, size: int = 100, access_token: str = None) -> dict:
|
||||
"""
|
||||
Query list of stations from HanchuESS API.
|
||||
|
||||
Args:
|
||||
current: Page number (default 1)
|
||||
size: Page size (default 100)
|
||||
access_token: Optional JWT token. If not provided, will get one automatically.
|
||||
|
||||
Returns:
|
||||
Dictionary containing station list with pagination info
|
||||
"""
|
||||
# Get token if not provided
|
||||
if not access_token:
|
||||
access_token = self.get_access_token()
|
||||
|
||||
# Build payload
|
||||
payload = {
|
||||
"current": current,
|
||||
"size": size
|
||||
}
|
||||
|
||||
# Encrypt payload
|
||||
encrypted_payload = self.encrypt_payload(payload)
|
||||
|
||||
# Build headers
|
||||
headers = {
|
||||
"Content-Type": "text/plain",
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
"appplat": "iess",
|
||||
"locale": "en",
|
||||
"timezone": "Africa/Accra",
|
||||
"timeselected": "GMT",
|
||||
"version": "1.0",
|
||||
"crypto-version": "1.0.0",
|
||||
"access-token": access_token,
|
||||
"Origin": "https://iess3.hanchuess.com",
|
||||
"Referer": "https://iess3.hanchuess.com/",
|
||||
}
|
||||
|
||||
# Make request
|
||||
url = f"{self.base_url}platform/station/queryList"
|
||||
response = requests.post(
|
||||
url,
|
||||
data=encrypted_payload,
|
||||
headers=headers,
|
||||
timeout=self.timeout,
|
||||
verify=self.verify_ssl,
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
if not result.get("success"):
|
||||
raise RuntimeError(
|
||||
f"Failed to query station list: {json.dumps(result, ensure_ascii=False)}"
|
||||
)
|
||||
|
||||
return result.get("data", {})
|
||||
|
||||
def get_station_info(self, station_id: str, access_token: str = None) -> dict:
|
||||
"""
|
||||
Get station information from HanchuESS API.
|
||||
|
||||
Args:
|
||||
station_id: Station ID to query
|
||||
access_token: Optional JWT token. If not provided, will get one automatically.
|
||||
|
||||
Returns:
|
||||
Station information dictionary
|
||||
"""
|
||||
# Get token if not provided
|
||||
if not access_token:
|
||||
access_token = self.get_access_token()
|
||||
|
||||
# Build payload
|
||||
payload = {
|
||||
"stationId": station_id
|
||||
}
|
||||
|
||||
# Encrypt payload
|
||||
encrypted_payload = self.encrypt_payload(payload)
|
||||
|
||||
# Build headers
|
||||
headers = {
|
||||
"Content-Type": "text/plain",
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
"appplat": "iess",
|
||||
"locale": "en",
|
||||
"timezone": "Africa/Accra",
|
||||
"timeselected": "GMT",
|
||||
"version": "1.0",
|
||||
"crypto-version": "1.0.0",
|
||||
"access-token": access_token,
|
||||
"Origin": "https://iess3.hanchuess.com",
|
||||
"Referer": "https://iess3.hanchuess.com/",
|
||||
}
|
||||
|
||||
# Make request
|
||||
url = f"{self.base_url}platform/homePage/stationInfo"
|
||||
response = requests.post(
|
||||
url,
|
||||
data=encrypted_payload,
|
||||
headers=headers,
|
||||
timeout=self.timeout,
|
||||
verify=self.verify_ssl,
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
if not result.get("success"):
|
||||
raise RuntimeError(
|
||||
f"Failed to get station info: {json.dumps(result, ensure_ascii=False)}"
|
||||
)
|
||||
|
||||
data = result.get("data", {})
|
||||
# Add the station_id to the data since it's in our request but might not be in response
|
||||
if "stationId" not in data:
|
||||
data["stationId"] = station_id
|
||||
|
||||
return data
|
||||
|
||||
|
||||
# Global service instance
|
||||
_service_instance = None
|
||||
|
||||
|
||||
def get_service() -> HanchuESSService:
|
||||
"""Get or create the global service instance."""
|
||||
global _service_instance
|
||||
if _service_instance is None:
|
||||
_service_instance = HanchuESSService()
|
||||
return _service_instance
|
||||
3
v2/data/.gitignore
vendored
Normal file
3
v2/data/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# This directory contains the SQLite database
|
||||
# It's mounted from the Docker container
|
||||
*.db
|
||||
25
v2/docker-compose.yml
Normal file
25
v2/docker-compose.yml
Normal file
@@ -0,0 +1,25 @@
|
||||
services:
|
||||
hanchuess-solar-backend:
|
||||
build:
|
||||
context: backend
|
||||
dockerfile: docker/Dockerfile
|
||||
container_name: hanchuess-solar-backend
|
||||
ports:
|
||||
- "8050:8050"
|
||||
volumes:
|
||||
- sqlite_data:/data
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- HANCHU_AES_KEY=${HANCHU_AES_KEY}
|
||||
- HANCHU_AES_IV=${HANCHU_AES_IV}
|
||||
- HANCHU_LOGIN_URL=${HANCHU_LOGIN_URL}
|
||||
- HANCHU_LOGIN_TYPE=${HANCHU_LOGIN_TYPE}
|
||||
- HANCHU_HTTP_TIMEOUT=${HANCHU_HTTP_TIMEOUT}
|
||||
- HANCHU_VERIFY_SSL=${HANCHU_VERIFY_SSL}
|
||||
- HANCHU_USERNAME=${HANCHU_USERNAME}
|
||||
- HANCHU_PASSWORD=${HANCHU_PASSWORD}
|
||||
- SQLITE_DB_PATH=${SQLITE_DB_PATH}
|
||||
|
||||
volumes:
|
||||
sqlite_data:
|
||||
Reference in New Issue
Block a user