Compare commits

...

4 Commits

Author SHA1 Message Date
39a86ee2f9 Merge pull request 'connect-to-hanchuess' (#5) from connect-to-hanchuess into main
Reviewed-on: #5
2026-01-13 14:39:11 -05:00
Preteck
d073456c2f Update README.md 2026-01-13 19:37:22 +00:00
Luke Calladine
680335fba1 Initial HanchuESS services 2026-01-13 16:14:20 +00:00
Luke Calladine
5cf970df62 Add simple HanchuESS Helper 2026-01-12 20:27:00 +00:00
6 changed files with 380 additions and 3 deletions

24
.env.example Normal file
View 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=

View File

@@ -8,4 +8,4 @@ The aims of this project are to:
- [ ] Scrape historical data of a HanchuESS station. - [ ] Scrape historical data of a HanchuESS station.
- [ ] Scrape current data of a HanchuESS station. - [ ] Scrape current data of a HanchuESS station.
- [ ] Send commands to the HanchuESS station. - [ ] Send commands to the HanchuESS station.
- [ ] Create an integration API that provides QOL improvements to the HanchuESS station. - [ ] Create an integration API that provides QOL improvements to the HanchuESS station.

View File

@@ -1,2 +1,5 @@
fastapi fastapi
uvicorn[standard] uvicorn[standard]
requests>=2.31.0
pycryptodome>=3.20.0
python-dotenv>=1.0.1

View File

@@ -1,11 +1,84 @@
from fastapi import FastAPI from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI(title="HanchuESS Solar Backend API") app = FastAPI(title="HanchuESS Solar Backend API")
class DecryptRequest(BaseModel):
encrypted_payload: str
@app.get("/", tags=["Root"]) @app.get("/", tags=["Root"])
def root(): def root():
return {"message": "Welcome to the HanchuESS Solar Backend API!"} return {"message": "Welcome to the HanchuESS Solar Backend API!"}
@app.get("/get_access_token", tags=["HanchuESS"])
def get_access_token():
"""Get access token by logging into HanchuESS"""
from service.hanchu_service import HanchuESSService
hanchu_service = HanchuESSService()
try:
access_token = hanchu_service.get_access_token()
return {"access_token": access_token}
except Exception as e:
return {"error": str(e)}
@app.post("/decrypt_payload", tags=["Payload"])
def decrypt_payload(request: DecryptRequest):
"""Decrypt an AES-encrypted HanchuESS payload"""
from service.hanchu_service import HanchuESSService
try:
hanchu_service = HanchuESSService()
decrypted_data = hanchu_service.decrypt_payload(request.encrypted_payload)
return {
"decrypted_data": decrypted_data,
"data_type": type(decrypted_data).__name__
}
except Exception as e:
import traceback
return {"error": str(e), "traceback": traceback.format_exc()}
@app.get("/get_power_chart", tags=["HanchuESS"])
def get_power_chart():
"""Get 65-second power chart data from HanchuESS"""
from service.hanchu_service import HanchuESSService
try:
hanchu_service = HanchuESSService()
# Get power chart data (will automatically handle authentication)
power_data = hanchu_service.get_power_chart()
return power_data
except Exception as e:
import traceback
return {"error": str(e), "traceback": traceback.format_exc()}
@app.get("/get_power_minute_chart", tags=["HanchuESS"])
def get_power_minute_chart(start_ts: int = None, end_ts: int = None):
"""Get minute-by-minute power chart data from HanchuESS
Args:
start_ts: Optional start timestamp in milliseconds
end_ts: Optional end timestamp in milliseconds
"""
from service.hanchu_service import HanchuESSService
try:
hanchu_service = HanchuESSService()
# Get minute chart data (will automatically handle authentication)
chart_data = hanchu_service.get_power_minute_chart(start_ts=start_ts, end_ts=end_ts)
return chart_data
except Exception as e:
import traceback
return {"error": str(e), "traceback": traceback.format_exc()}
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8050) uvicorn.run(app, host="0.0.0.0", port=8050)

View File

@@ -0,0 +1,266 @@
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:
# 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):
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.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"
self.hanchu_username = os.getenv("HANCHU_USERNAME", "")
self.hanchu_password = os.getenv("HANCHU_PASSWORD", "")
self.base_serial_number = os.getenv("BASE_SERIAL_NUMBER", "")
# 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.
"""
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.
"""
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).
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.
"""
# 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 get_power_chart(self, access_token: str = None) -> dict:
"""
Get 65-second power chart data from HanchuESS.
Args:
access_token: Optional JWT token from login. If not provided, will get one automatically.
Returns:
Power chart data from the API
"""
# Get access token if not provided
if not access_token:
# Check if we have a cached token first
if self._access_token:
access_token = self._access_token
else:
access_token = self.get_access_token()
# Build payload with serial number
payload = {"sn": self.base_serial_number}
# AES encrypt the payload
encrypted_payload = self.encrypt_payload(payload)
# Send to API with access token
url = "https://iess3.hanchuess.com/gateway/platform/pcs/powerChart"
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/",
}
response = requests.post(
url,
data=encrypted_payload,
headers=headers,
timeout=self.timeout,
verify=self.verify_ssl,
)
response.raise_for_status()
return response.json()
def get_power_minute_chart(self, access_token: str = None, start_ts: int = None, end_ts: int = None) -> dict:
"""
Get minute-by-minute power chart data from HanchuESS.
Args:
access_token: Optional JWT token from login. If not provided, will get one automatically.
start_ts: Start timestamp in milliseconds. If not provided, defaults to start of today.
end_ts: End timestamp in milliseconds. If not provided, defaults to end of today.
Returns:
Power minute chart data from the API
"""
# Get access token if not provided
if not access_token:
# Check if we have a cached token first
if self._access_token:
access_token = self._access_token
else:
access_token = self.get_access_token()
# Set default timestamps if not provided (today's data)
if not start_ts or not end_ts:
from datetime import datetime, timezone, timedelta
now = datetime.now(timezone.utc)
# 30 minutes ago
start_time = now - timedelta(minutes=30)
start_ts = int(start_time.timestamp() * 1000)
# Current time
end_ts = int(now.timestamp() * 1000)
# Build payload
payload = {
"sn": self.base_serial_number,
"devType": "2",
"maxCount": 1440, # 24 hours * 60 minutes
"dataTimeTsEnd": end_ts,
"dataTimeTsStart": start_ts,
"masterSum": True
}
# AES encrypt the payload
encrypted_payload = self.encrypt_payload(payload)
# Send to API with access token
url = "https://iess3.hanchuess.com/gateway/platform/pcs/powerMinuteChart"
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/",
}
response = requests.post(
url,
data=encrypted_payload,
headers=headers,
timeout=self.timeout,
verify=self.verify_ssl,
)
response.raise_for_status()
return response.json()

View File

@@ -5,4 +5,15 @@ services:
dockerfile: docker/Dockerfile dockerfile: docker/Dockerfile
container_name: hanchuess-solar-backend container_name: hanchuess-solar-backend
ports: ports:
- "8050:8050" - "8050:8050"
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:-ACCOUNT}
- HANCHU_HTTP_TIMEOUT=${HANCHU_HTTP_TIMEOUT:-10}
- HANCHU_VERIFY_SSL=${HANCHU_VERIFY_SSL:-true}
- HANCHU_USERNAME=${HANCHU_USERNAME:-}
- HANCHU_PASSWORD=${HANCHU_PASSWORD:-}