Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b35ff74cdb |
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