sig-cloud-control 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sig_cloud_control/__init__.py +14 -0
- sig_cloud_control/cli_app/__init__.py +249 -0
- sig_cloud_control/client.py +328 -0
- sig_cloud_control/models.py +141 -0
- sig_cloud_control-0.1.0.dist-info/METADATA +251 -0
- sig_cloud_control-0.1.0.dist-info/RECORD +9 -0
- sig_cloud_control-0.1.0.dist-info/WHEEL +4 -0
- sig_cloud_control-0.1.0.dist-info/entry_points.txt +2 -0
- sig_cloud_control-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Sigenergy Cloud Control Library."""
|
|
2
|
+
|
|
3
|
+
from .client import APIError, AuthenticationError, SigCloudClient, SigCloudError, StationError
|
|
4
|
+
from .models import Config, OperationMode
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"APIError",
|
|
8
|
+
"AuthenticationError",
|
|
9
|
+
"Config",
|
|
10
|
+
"OperationMode",
|
|
11
|
+
"SigCloudClient",
|
|
12
|
+
"SigCloudError",
|
|
13
|
+
"StationError",
|
|
14
|
+
]
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import tomllib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import tomli_w
|
|
10
|
+
import typer
|
|
11
|
+
from platformdirs import user_config_path
|
|
12
|
+
from pydantic import ValidationError
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
|
|
15
|
+
from sig_cloud_control.client import SigCloudClient, SigCloudError
|
|
16
|
+
from sig_cloud_control.models import Config
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(help="Control a Sigen solar/battery station.")
|
|
19
|
+
|
|
20
|
+
console = Console()
|
|
21
|
+
|
|
22
|
+
_DEFAULT_CONFIG_PATH: Path = user_config_path("sig-cloud-control") / "config.toml"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _resolve_config_path(config_opt: str | None) -> Path:
|
|
26
|
+
"""Resolve config path: explicit CLI arg > ./config.toml > platform default."""
|
|
27
|
+
if config_opt is not None:
|
|
28
|
+
return Path(config_opt)
|
|
29
|
+
local = Path("config.toml")
|
|
30
|
+
if local.exists():
|
|
31
|
+
return local
|
|
32
|
+
return _DEFAULT_CONFIG_PATH
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.callback(invoke_without_command=True)
|
|
36
|
+
def main(ctx: typer.Context) -> None:
|
|
37
|
+
"""Control a Sigen solar/battery station."""
|
|
38
|
+
if ctx.invoked_subcommand is None:
|
|
39
|
+
typer.secho("Missing command.", fg=typer.colors.RED, err=True)
|
|
40
|
+
typer.echo(ctx.get_help())
|
|
41
|
+
raise typer.Exit(code=1)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def perform_setup(config_path: Path) -> Config:
|
|
45
|
+
"""Interactively prompt for credentials and save to file."""
|
|
46
|
+
typer.echo(f"Setting up new configuration at '{config_path}'...")
|
|
47
|
+
username = typer.prompt("Sigen Cloud login name (eg. user@example.com)")
|
|
48
|
+
password = typer.prompt("Sigen Cloud Password", hide_input=True)
|
|
49
|
+
station_id_str = typer.prompt("Station ID (optional, press Enter to skip)", default="")
|
|
50
|
+
|
|
51
|
+
password_encoded = SigCloudClient.encrypt_password(password)
|
|
52
|
+
|
|
53
|
+
station_id = int(station_id_str) if station_id_str.strip().isdigit() else None
|
|
54
|
+
|
|
55
|
+
config_data: dict[str, object] = {
|
|
56
|
+
"username": username,
|
|
57
|
+
"password_encoded": password_encoded,
|
|
58
|
+
}
|
|
59
|
+
if station_id:
|
|
60
|
+
config_data["station_id"] = station_id
|
|
61
|
+
|
|
62
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
fd = os.open(config_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
64
|
+
with os.fdopen(fd, "wb") as f:
|
|
65
|
+
tomli_w.dump(config_data, f)
|
|
66
|
+
|
|
67
|
+
typer.secho("✔︎ ", fg=typer.colors.GREEN, bold=True, nl=False)
|
|
68
|
+
typer.secho(f"Success! Configuration saved to {config_path}", fg=typer.colors.GREEN)
|
|
69
|
+
typer.echo("Note: Your password has been encrypted for storage.")
|
|
70
|
+
|
|
71
|
+
return Config(username=username, password_encoded=password_encoded, station_id=station_id)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def load_config(config_path: Path) -> Config:
|
|
75
|
+
"""Load configuration from environment variables and/or TOML file.
|
|
76
|
+
|
|
77
|
+
Precedence:
|
|
78
|
+
1. SIGEN_* Environment Variables
|
|
79
|
+
2. TOML file at config_path
|
|
80
|
+
3. Interactive setup (if both are missing)
|
|
81
|
+
"""
|
|
82
|
+
typer.echo("Loading configuration...")
|
|
83
|
+
|
|
84
|
+
# Load from environment variables first
|
|
85
|
+
try:
|
|
86
|
+
# Config.model_validate({}) triggers environment variable loading
|
|
87
|
+
# If it returns a valid config, then environment variables have everything we need.
|
|
88
|
+
return Config.model_validate({})
|
|
89
|
+
except ValidationError:
|
|
90
|
+
# Not enough in env vars, we'll try to supplement with the file
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
if not config_path.exists():
|
|
94
|
+
typer.secho(f"⚠️ Config file not found at '{config_path}'", fg=typer.colors.YELLOW)
|
|
95
|
+
return perform_setup(config_path)
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
with open(config_path, "rb") as f:
|
|
99
|
+
file_data = tomllib.load(f)
|
|
100
|
+
|
|
101
|
+
# Get what we have from environment variables (even if it's incomplete)
|
|
102
|
+
env_vars: dict[str, object] = {
|
|
103
|
+
"username": os.environ.get("SIGEN_USERNAME"),
|
|
104
|
+
"password": os.environ.get("SIGEN_PASSWORD"),
|
|
105
|
+
"password_encoded": os.environ.get("SIGEN_PASSWORD_ENCODED"),
|
|
106
|
+
"station_id": os.environ.get("SIGEN_STATION_ID"),
|
|
107
|
+
}
|
|
108
|
+
# Filter out None values and convert types
|
|
109
|
+
env_vars = {k: v for k, v in env_vars.items() if v is not None}
|
|
110
|
+
if "station_id" in env_vars and isinstance(env_vars["station_id"], str) and env_vars["station_id"].isdigit():
|
|
111
|
+
env_vars["station_id"] = int(env_vars["station_id"])
|
|
112
|
+
|
|
113
|
+
# Merge: Environment Variables OVER file data
|
|
114
|
+
merged_data = {**file_data, **env_vars}
|
|
115
|
+
return Config.model_validate(merged_data)
|
|
116
|
+
except ValidationError as e:
|
|
117
|
+
typer.secho(
|
|
118
|
+
f"❌ Error: Invalid configuration format in '{config_path}':\n{e}",
|
|
119
|
+
fg=typer.colors.RED,
|
|
120
|
+
err=True,
|
|
121
|
+
)
|
|
122
|
+
raise typer.Exit(code=1) from e
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def execute_action(
|
|
126
|
+
config: Config,
|
|
127
|
+
action: str,
|
|
128
|
+
duration: int = 0,
|
|
129
|
+
power: float | None = None,
|
|
130
|
+
verbose: bool = False,
|
|
131
|
+
) -> None:
|
|
132
|
+
"""Internal helper to run the async client logic."""
|
|
133
|
+
if verbose:
|
|
134
|
+
logging.basicConfig(
|
|
135
|
+
level=logging.DEBUG,
|
|
136
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
137
|
+
stream=sys.stderr,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
async with SigCloudClient(config) as client:
|
|
141
|
+
typer.echo("Logging in to Sigen Cloud...")
|
|
142
|
+
await client.login()
|
|
143
|
+
|
|
144
|
+
with console.status(f"Executing '{action}' action...", spinner="dots"):
|
|
145
|
+
if action == "charge":
|
|
146
|
+
await client.charge_battery(duration, power)
|
|
147
|
+
elif action == "discharge":
|
|
148
|
+
await client.discharge_battery(duration, power)
|
|
149
|
+
elif action == "hold":
|
|
150
|
+
await client.hold_battery(duration)
|
|
151
|
+
elif action == "self-consumption":
|
|
152
|
+
await client.self_consumption(duration)
|
|
153
|
+
elif action == "cancel":
|
|
154
|
+
await client.cancel_self_control()
|
|
155
|
+
|
|
156
|
+
console.print(f"[bold green]✔︎ [/bold green]Executing '{action}' action... [bold green]Done.[/bold green]")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# Common types for CLI arguments and options to reduce duplication
|
|
160
|
+
DurationArg = Annotated[int, typer.Argument(help="Duration in minutes (1-1440)", min=1, max=1440)]
|
|
161
|
+
PowerOpt = Annotated[float | None, typer.Option(help="Charge/discharge rate limit for the battery in kW")]
|
|
162
|
+
ConfigOpt = Annotated[str | None, typer.Option(help="Path to the TOML configuration file")]
|
|
163
|
+
VerboseOpt = Annotated[bool, typer.Option("--verbose", "-v", help="Enable verbose logging")]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _run_command_action(
|
|
167
|
+
action: str,
|
|
168
|
+
duration: int = 0,
|
|
169
|
+
power: float | None = None,
|
|
170
|
+
config: str | None = None,
|
|
171
|
+
verbose: bool = False,
|
|
172
|
+
) -> None:
|
|
173
|
+
"""Internal helper to load config and run an action."""
|
|
174
|
+
conf = load_config(_resolve_config_path(config))
|
|
175
|
+
try:
|
|
176
|
+
asyncio.run(execute_action(conf, action, duration, power, verbose))
|
|
177
|
+
except SigCloudError as e:
|
|
178
|
+
typer.secho(f"❌ Sigen API Error: {e}", fg=typer.colors.RED, err=True)
|
|
179
|
+
raise typer.Exit(code=1) from e
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@app.command()
|
|
183
|
+
def charge(
|
|
184
|
+
duration: DurationArg,
|
|
185
|
+
power: PowerOpt = None,
|
|
186
|
+
config: ConfigOpt = None,
|
|
187
|
+
verbose: VerboseOpt = False,
|
|
188
|
+
) -> None:
|
|
189
|
+
"""Charge battery from the grid for a specified duration."""
|
|
190
|
+
_run_command_action("charge", duration, power, config, verbose)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@app.command()
|
|
194
|
+
def discharge(
|
|
195
|
+
duration: DurationArg,
|
|
196
|
+
power: PowerOpt = None,
|
|
197
|
+
config: ConfigOpt = None,
|
|
198
|
+
verbose: VerboseOpt = False,
|
|
199
|
+
) -> None:
|
|
200
|
+
"""Discharge battery for a specified duration."""
|
|
201
|
+
_run_command_action("discharge", duration, power, config, verbose)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@app.command()
|
|
205
|
+
def hold(
|
|
206
|
+
duration: DurationArg,
|
|
207
|
+
config: ConfigOpt = None,
|
|
208
|
+
verbose: VerboseOpt = False,
|
|
209
|
+
) -> None:
|
|
210
|
+
"""Hold battery at its current state of charge for a specified duration."""
|
|
211
|
+
_run_command_action("hold", duration, config=config, verbose=verbose)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@app.command(name="self-consumption")
|
|
215
|
+
def self_consumption(
|
|
216
|
+
duration: DurationArg,
|
|
217
|
+
config: ConfigOpt = None,
|
|
218
|
+
verbose: VerboseOpt = False,
|
|
219
|
+
) -> None:
|
|
220
|
+
"""Enable self-consumption mode for a specified duration."""
|
|
221
|
+
_run_command_action("self-consumption", duration, config=config, verbose=verbose)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@app.command()
|
|
225
|
+
def cancel(
|
|
226
|
+
config: ConfigOpt = None,
|
|
227
|
+
verbose: VerboseOpt = False,
|
|
228
|
+
) -> None:
|
|
229
|
+
"""Return the battery to its configured default mode."""
|
|
230
|
+
_run_command_action(action="cancel", config=config, verbose=verbose)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@app.command()
|
|
234
|
+
def setup(
|
|
235
|
+
config: Annotated[str | None, typer.Option(help="Path to the TOML configuration file to create")] = None,
|
|
236
|
+
) -> None:
|
|
237
|
+
"""Interactively setup credentials and save to config file."""
|
|
238
|
+
config_path = Path(config) if config else _DEFAULT_CONFIG_PATH
|
|
239
|
+
if config_path.exists():
|
|
240
|
+
typer.secho(
|
|
241
|
+
f"⚠️ Warning: Configuration file '{config_path}' already exists and will be overwritten.",
|
|
242
|
+
fg=typer.colors.YELLOW,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
perform_setup(config_path)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
if __name__ == "__main__":
|
|
249
|
+
app()
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import base64
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from http import HTTPStatus
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Final
|
|
9
|
+
from uuid import uuid4
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
from cryptography.hazmat.primitives import padding
|
|
13
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
14
|
+
from platformdirs import user_cache_path
|
|
15
|
+
from pydantic import ValidationError
|
|
16
|
+
|
|
17
|
+
from .models import (
|
|
18
|
+
MAX_DURATION_MINS,
|
|
19
|
+
Config,
|
|
20
|
+
LoginResponse,
|
|
21
|
+
OperationMode,
|
|
22
|
+
SetModeRequest,
|
|
23
|
+
TokenCache,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
_DEFAULT_CACHE_PATH: Final[Path] = user_cache_path("sig-cloud-control") / "token-cache.json"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SigCloudError(Exception):
|
|
32
|
+
"""Base exception for SigCloudClient."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AuthenticationError(SigCloudError):
|
|
36
|
+
"""Raised when login fails (bad credentials or token error)."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class StationError(SigCloudError):
|
|
40
|
+
"""Raised when the station ID cannot be resolved or is unknown."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class APIError(SigCloudError):
|
|
44
|
+
"""Raised when the Sigen API returns an unexpected or unparseable response."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class SigCloudClient:
|
|
48
|
+
"""Client for interacting with Sigen Cloud API."""
|
|
49
|
+
|
|
50
|
+
# TODO: Support additional regions (currently only Australian data centre)
|
|
51
|
+
_BASE_URL: Final[str] = "https://api-aus.sigencloud.com"
|
|
52
|
+
_AUTH_URL: Final[str] = f"{_BASE_URL}/auth/oauth/token"
|
|
53
|
+
_MANUAL_MODE_URL: Final[str] = f"{_BASE_URL}/device/energy-profile/instant/manunal"
|
|
54
|
+
_STATION_INFO_URL: Final[str] = f"{_BASE_URL}/device/owner/station/home"
|
|
55
|
+
|
|
56
|
+
# Fixed key and IV used by Sigen Cloud
|
|
57
|
+
_ENCRYPT_KEY: Final[bytes] = (b"s" + b"i" + b"g" + b"e" + b"n") * 3 + b"p"
|
|
58
|
+
_ENCRYPT_IV: Final[bytes] = (b"s" + b"i" + b"g" + b"e" + b"n") * 3 + b"p"
|
|
59
|
+
_CIPHER: Final[Cipher] = Cipher(
|
|
60
|
+
algorithms.AES(_ENCRYPT_KEY),
|
|
61
|
+
modes.CBC(_ENCRYPT_IV),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def __init__(self, config: Config, cache_path: Path | None = _DEFAULT_CACHE_PATH) -> None:
|
|
65
|
+
"""Initialize the client with configuration."""
|
|
66
|
+
self.config = config
|
|
67
|
+
self.cache_path = cache_path
|
|
68
|
+
self.client = httpx.AsyncClient()
|
|
69
|
+
self.access_token: str | None = None
|
|
70
|
+
self._station_id: int | None = config.station_id
|
|
71
|
+
|
|
72
|
+
# Setup base headers mimicking the app
|
|
73
|
+
self._session_id = str(uuid4())
|
|
74
|
+
self.client.headers.update(
|
|
75
|
+
{
|
|
76
|
+
"accept": "*/*",
|
|
77
|
+
"accept-language": "en-GB,en-US;q=0.9,en;q=0.8",
|
|
78
|
+
"auth-client-id": "sigen",
|
|
79
|
+
"client-server": "aus",
|
|
80
|
+
"lang": "en_US",
|
|
81
|
+
"origin": "https://app-aus.sigencloud.com",
|
|
82
|
+
"referer": "https://app-aus.sigencloud.com/",
|
|
83
|
+
"sec-ch-ua": '"Not:A-Brand";v="99", "Google Chrome";v="145", "Chromium";v="145"',
|
|
84
|
+
"sec-ch-ua-mobile": "?0",
|
|
85
|
+
"sec-ch-ua-platform": '"macOS"',
|
|
86
|
+
"sec-fetch-dest": "empty",
|
|
87
|
+
"sec-fetch-mode": "cors",
|
|
88
|
+
"sec-fetch-site": "same-site",
|
|
89
|
+
"sg-bui": "1",
|
|
90
|
+
"sg-env": "1",
|
|
91
|
+
"sg-pkg": "sigen_app",
|
|
92
|
+
"sg-session": self._session_id,
|
|
93
|
+
"sg-v": "3.4.0",
|
|
94
|
+
"user-agent": (
|
|
95
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
96
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
97
|
+
"Chrome/145.0.0.0 Safari/537.36"
|
|
98
|
+
),
|
|
99
|
+
}
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
async def __aenter__(self) -> "SigCloudClient":
|
|
103
|
+
"""Enter the async context manager."""
|
|
104
|
+
return self
|
|
105
|
+
|
|
106
|
+
async def __aexit__(self, *args: object) -> None:
|
|
107
|
+
"""Exit the async context manager and close the HTTP client."""
|
|
108
|
+
await self.aclose()
|
|
109
|
+
|
|
110
|
+
def _get_ts_headers(self) -> dict[str, str]:
|
|
111
|
+
return {
|
|
112
|
+
"sg-log-id": str(uuid4()),
|
|
113
|
+
"sg-ts": str(int(time.time() * 1_000_000)),
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async def _try_login_from_cache(self) -> bool:
|
|
117
|
+
"""Attempt to load credentials from the local cache. Returns True if successful."""
|
|
118
|
+
if self.cache_path is None:
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
cache = await self._load_cache()
|
|
122
|
+
if not cache:
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
# Recover station_id from cache if we don't have it yet
|
|
126
|
+
if self._station_id is None:
|
|
127
|
+
self._station_id = cache.station_id
|
|
128
|
+
|
|
129
|
+
if cache.expires_at > time.time() + 60: # Valid for at least another minute
|
|
130
|
+
logger.debug("Using cached token")
|
|
131
|
+
self.access_token = cache.access_token
|
|
132
|
+
self.client.headers["authorization"] = f"bearer {self.access_token}"
|
|
133
|
+
return True
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
def _get_login_payload(self) -> dict[str, str]:
|
|
137
|
+
"""Prepare the payload for the login request."""
|
|
138
|
+
if self.config.password_encoded:
|
|
139
|
+
password_to_send = self.config.password_encoded
|
|
140
|
+
elif self.config.password:
|
|
141
|
+
password_to_send = self.encrypt_password(self.config.password)
|
|
142
|
+
else:
|
|
143
|
+
# Unreachable due to Config validation
|
|
144
|
+
msg = "Neither password nor password_encoded provided"
|
|
145
|
+
raise SigCloudError(msg)
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
"scope": "server",
|
|
149
|
+
"grant_type": "password",
|
|
150
|
+
"userDeviceId": str(int(time.time() * 1000)),
|
|
151
|
+
"username": self.config.username,
|
|
152
|
+
"password": password_to_send,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def encrypt_password(password: str) -> str:
|
|
157
|
+
"""Encrypt a plaintext password using Sigen's AES-128-CBC logic."""
|
|
158
|
+
padder = padding.PKCS7(128).padder()
|
|
159
|
+
padded_data = padder.update(password.encode()) + padder.finalize()
|
|
160
|
+
|
|
161
|
+
encryptor = SigCloudClient._CIPHER.encryptor()
|
|
162
|
+
ct = encryptor.update(padded_data) + encryptor.finalize()
|
|
163
|
+
|
|
164
|
+
return base64.b64encode(ct).decode()
|
|
165
|
+
|
|
166
|
+
async def login(self, use_cache: bool = True) -> None:
|
|
167
|
+
"""Authenticate with the Sigen API and store the access token. Checks cache first."""
|
|
168
|
+
if use_cache and await self._try_login_from_cache():
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
logger.info("Logging in to Sigen Cloud as %s", self.config.username)
|
|
172
|
+
headers = self._get_ts_headers()
|
|
173
|
+
headers["authorization"] = "Basic c2lnZW46c2lnZW4="
|
|
174
|
+
headers["content-type"] = "application/x-www-form-urlencoded"
|
|
175
|
+
|
|
176
|
+
data = self._get_login_payload()
|
|
177
|
+
|
|
178
|
+
response = await self.client.post(self._AUTH_URL, headers=headers, data=data)
|
|
179
|
+
|
|
180
|
+
if response.status_code != HTTPStatus.OK:
|
|
181
|
+
logger.error("Login failed with status %s: %s", response.status_code, response.text)
|
|
182
|
+
raise AuthenticationError(f"Login failed with status {response.status_code}: {response.text}")
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
payload = response.json()
|
|
186
|
+
# Handle potential wrapping or direct payload
|
|
187
|
+
token_data = payload.get("data", payload) if isinstance(payload, dict) else payload
|
|
188
|
+
login_response = LoginResponse.model_validate(token_data)
|
|
189
|
+
except ValidationError as e:
|
|
190
|
+
logger.error("Failed to parse login response: %s", response.text)
|
|
191
|
+
raise APIError(f"Failed to parse login response. Raw payload: {response.text}. Error: {e}") from e
|
|
192
|
+
except Exception as e:
|
|
193
|
+
logger.error("Unexpected error parsing login response: %s", response.text)
|
|
194
|
+
raise APIError(f"Unexpected error parsing login response: {e}. Raw payload: {response.text}") from e
|
|
195
|
+
|
|
196
|
+
self.access_token = login_response.access_token
|
|
197
|
+
self.client.headers["authorization"] = f"bearer {self.access_token}"
|
|
198
|
+
|
|
199
|
+
# If station_id wasn't provided in config, fetch it
|
|
200
|
+
if self._station_id is None:
|
|
201
|
+
await self._fetch_station_id()
|
|
202
|
+
|
|
203
|
+
await self._save_cache(login_response.expires_in_secs)
|
|
204
|
+
logger.info("Successfully logged in and cached token")
|
|
205
|
+
|
|
206
|
+
async def _load_cache(self) -> TokenCache | None:
|
|
207
|
+
"""Load the token from the cache file if it exists and is valid."""
|
|
208
|
+
if self.cache_path is None:
|
|
209
|
+
return None
|
|
210
|
+
try:
|
|
211
|
+
content = await asyncio.to_thread(self.cache_path.read_text)
|
|
212
|
+
return TokenCache.model_validate_json(content)
|
|
213
|
+
except (FileNotFoundError, ValidationError):
|
|
214
|
+
return None
|
|
215
|
+
except Exception:
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
def _write_cache_file(self, content: str) -> None:
|
|
219
|
+
"""Helper to write the cache file securely with restricted permissions."""
|
|
220
|
+
if self.cache_path is None:
|
|
221
|
+
return
|
|
222
|
+
self.cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
223
|
+
fd = os.open(self.cache_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
224
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
225
|
+
f.write(content)
|
|
226
|
+
|
|
227
|
+
async def _save_cache(self, expires_in: int) -> None:
|
|
228
|
+
"""Save the current token and station ID to the cache file."""
|
|
229
|
+
if self.cache_path is None or self.access_token is None:
|
|
230
|
+
return
|
|
231
|
+
cache = TokenCache(
|
|
232
|
+
access_token=self.access_token,
|
|
233
|
+
expires_at=time.time() + expires_in,
|
|
234
|
+
station_id=self._station_id,
|
|
235
|
+
)
|
|
236
|
+
content = cache.model_dump_json()
|
|
237
|
+
await asyncio.to_thread(self._write_cache_file, content)
|
|
238
|
+
|
|
239
|
+
async def _fetch_station_id(self) -> None:
|
|
240
|
+
"""Fetch the station ID from the home info endpoint."""
|
|
241
|
+
logger.debug("Fetching station ID from %s", self._STATION_INFO_URL)
|
|
242
|
+
headers = self._get_ts_headers()
|
|
243
|
+
response = await self.client.get(self._STATION_INFO_URL, headers=headers)
|
|
244
|
+
response.raise_for_status()
|
|
245
|
+
|
|
246
|
+
data = response.json()
|
|
247
|
+
if data.get("code") == 0 and "data" in data:
|
|
248
|
+
self._station_id = data["data"].get("stationId")
|
|
249
|
+
|
|
250
|
+
if self._station_id is None:
|
|
251
|
+
logger.error("Could not retrieve station ID. Response: %s", response.text)
|
|
252
|
+
raise StationError("Could not retrieve station ID from Sigen Cloud.")
|
|
253
|
+
logger.debug("Fetched station ID: %s", self._station_id)
|
|
254
|
+
|
|
255
|
+
async def _set_mode_raw(
|
|
256
|
+
self,
|
|
257
|
+
mode: OperationMode,
|
|
258
|
+
duration: int | None = None,
|
|
259
|
+
power_limitation: float | None = None,
|
|
260
|
+
) -> None:
|
|
261
|
+
"""Directly set the manual mode on the station."""
|
|
262
|
+
if not self.access_token:
|
|
263
|
+
raise SigCloudError("Not logged in. Call login() first.")
|
|
264
|
+
|
|
265
|
+
if self._station_id is None:
|
|
266
|
+
raise StationError("Station ID unknown. Login may have failed to retrieve it.")
|
|
267
|
+
|
|
268
|
+
request_data = SetModeRequest(
|
|
269
|
+
station_id=self._station_id,
|
|
270
|
+
mode=mode,
|
|
271
|
+
duration=duration,
|
|
272
|
+
power_limitation=power_limitation,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
logger.debug("Sending mode update: %s", request_data.model_dump(by_alias=True))
|
|
276
|
+
headers = self._get_ts_headers()
|
|
277
|
+
headers["content-type"] = "application/json; charset=utf-8"
|
|
278
|
+
|
|
279
|
+
response = await self.client.put(
|
|
280
|
+
self._MANUAL_MODE_URL,
|
|
281
|
+
headers=headers,
|
|
282
|
+
json=request_data.model_dump(mode="json", by_alias=True),
|
|
283
|
+
)
|
|
284
|
+
response.raise_for_status()
|
|
285
|
+
logger.info("Successfully updated mode to %s", mode.name)
|
|
286
|
+
|
|
287
|
+
async def _start_mode(
|
|
288
|
+
self,
|
|
289
|
+
mode: OperationMode,
|
|
290
|
+
duration_min: int,
|
|
291
|
+
power_kw: float | None = None,
|
|
292
|
+
) -> None:
|
|
293
|
+
"""Execute the full sequence to start a manual mode."""
|
|
294
|
+
if duration_min <= 0 or duration_min > MAX_DURATION_MINS:
|
|
295
|
+
raise SigCloudError(f"Duration must be between 1 and {MAX_DURATION_MINS} minutes (24 hours).")
|
|
296
|
+
|
|
297
|
+
# UI always sends a cancel before starting a new mode
|
|
298
|
+
await self.cancel_self_control()
|
|
299
|
+
|
|
300
|
+
await self._set_mode_raw(
|
|
301
|
+
mode=mode,
|
|
302
|
+
duration=duration_min,
|
|
303
|
+
power_limitation=power_kw,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
async def charge_battery(self, duration_min: int, power_kw: float | None = None) -> None:
|
|
307
|
+
"""Force charge the battery from the grid."""
|
|
308
|
+
await self._start_mode(OperationMode.CHARGE, duration_min, power_kw)
|
|
309
|
+
|
|
310
|
+
async def discharge_battery(self, duration_min: int, power_kw: float | None = None) -> None:
|
|
311
|
+
"""Force discharge the battery."""
|
|
312
|
+
await self._start_mode(OperationMode.DISCHARGE, duration_min, power_kw)
|
|
313
|
+
|
|
314
|
+
async def hold_battery(self, duration_min: int) -> None:
|
|
315
|
+
"""Hold the battery at its current SOC."""
|
|
316
|
+
await self._start_mode(OperationMode.HOLD, duration_min)
|
|
317
|
+
|
|
318
|
+
async def self_consumption(self, duration_min: int) -> None:
|
|
319
|
+
"""Set to self-consumption mode."""
|
|
320
|
+
await self._start_mode(OperationMode.SELF_CONSUMPTION, duration_min)
|
|
321
|
+
|
|
322
|
+
async def cancel_self_control(self) -> None:
|
|
323
|
+
"""Stop any active manual control."""
|
|
324
|
+
await self._set_mode_raw(mode=OperationMode.CANCEL)
|
|
325
|
+
|
|
326
|
+
async def aclose(self) -> None:
|
|
327
|
+
"""Close the underlying HTTP client."""
|
|
328
|
+
await self.client.aclose()
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
from enum import StrEnum
|
|
3
|
+
from typing import Final, Self
|
|
4
|
+
|
|
5
|
+
from pydantic import (
|
|
6
|
+
BaseModel,
|
|
7
|
+
ConfigDict,
|
|
8
|
+
EmailStr,
|
|
9
|
+
Field,
|
|
10
|
+
field_serializer,
|
|
11
|
+
field_validator,
|
|
12
|
+
model_validator,
|
|
13
|
+
)
|
|
14
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
15
|
+
|
|
16
|
+
PASSWORD_LEN_BYTES: Final[int] = 16
|
|
17
|
+
MAX_DURATION_MINS: Final[int] = 1440
|
|
18
|
+
MAX_POWER_LIMIT_KW: Final[float] = 100.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Config(BaseSettings):
|
|
22
|
+
model_config = SettingsConfigDict(
|
|
23
|
+
strict=True,
|
|
24
|
+
env_prefix="SIGEN_",
|
|
25
|
+
case_sensitive=False,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
username: EmailStr
|
|
29
|
+
"""The user's Sigen Cloud email address."""
|
|
30
|
+
|
|
31
|
+
password: str | None = None
|
|
32
|
+
"""The user's plaintext password (will be encoded automatically)."""
|
|
33
|
+
|
|
34
|
+
password_encoded: str | None = None
|
|
35
|
+
"""The base64 encoded/encrypted password from the browser."""
|
|
36
|
+
|
|
37
|
+
station_id: int | None = Field(default=None, gt=0)
|
|
38
|
+
"""Optional station ID. Must be positive if provided."""
|
|
39
|
+
|
|
40
|
+
@model_validator(mode="after")
|
|
41
|
+
def validate_password_source(self) -> Self:
|
|
42
|
+
if self.password is None and self.password_encoded is None:
|
|
43
|
+
raise ValueError("Either 'password' or 'password_encoded' must be provided")
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
@field_validator("password_encoded")
|
|
47
|
+
@classmethod
|
|
48
|
+
def validate_password_encoded(cls, v: str | None) -> str | None:
|
|
49
|
+
if v is None:
|
|
50
|
+
return None
|
|
51
|
+
try:
|
|
52
|
+
decoded = base64.b64decode(v, validate=True)
|
|
53
|
+
if len(decoded) == 0 or len(decoded) % PASSWORD_LEN_BYTES != 0:
|
|
54
|
+
raise ValueError(
|
|
55
|
+
f"Decoded password length ({len(decoded)}) must be a positive multiple of {PASSWORD_LEN_BYTES}"
|
|
56
|
+
)
|
|
57
|
+
except Exception as e:
|
|
58
|
+
if isinstance(e, ValueError) and f"multiple of {PASSWORD_LEN_BYTES}" in str(e):
|
|
59
|
+
raise
|
|
60
|
+
raise ValueError("password_encoded must be a valid base64 string") from e
|
|
61
|
+
return v
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class LoginResponse(BaseModel):
|
|
65
|
+
"""Standard OAuth 2.0 Access Token Response (RFC 6749, Section 5.1)."""
|
|
66
|
+
|
|
67
|
+
access_token: str
|
|
68
|
+
"""The access token issued by the authorisation server."""
|
|
69
|
+
|
|
70
|
+
refresh_token: str | None = None
|
|
71
|
+
"""The refresh token, which can be used to obtain new access tokens."""
|
|
72
|
+
|
|
73
|
+
token_type: str
|
|
74
|
+
"""The type of the token issued, e.g., 'bearer'."""
|
|
75
|
+
|
|
76
|
+
expires_in_secs: int = Field(alias="expires_in")
|
|
77
|
+
"""The lifetime in seconds of the access token."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class TokenCache(BaseModel):
|
|
81
|
+
access_token: str
|
|
82
|
+
expires_at: float # Absolute timestamp
|
|
83
|
+
station_id: int | None = Field(default=None, gt=0)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class OperationMode(StrEnum):
|
|
87
|
+
CHARGE = "0"
|
|
88
|
+
DISCHARGE = "1"
|
|
89
|
+
HOLD = "2"
|
|
90
|
+
SELF_CONSUMPTION = "3"
|
|
91
|
+
CANCEL = ""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class SetModeRequest(BaseModel):
|
|
95
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
96
|
+
|
|
97
|
+
mode: OperationMode
|
|
98
|
+
station_id: int = Field(validation_alias="stationId", serialization_alias="stationId", gt=0)
|
|
99
|
+
duration: int | None = None
|
|
100
|
+
power_limitation: float | None = Field(
|
|
101
|
+
default=None, validation_alias="powerLimitation", serialization_alias="powerLimitation"
|
|
102
|
+
)
|
|
103
|
+
enable: bool = False
|
|
104
|
+
|
|
105
|
+
@field_serializer("duration", "power_limitation")
|
|
106
|
+
def serialise_to_str(self, v: int | float | None) -> str:
|
|
107
|
+
return str(v) if v is not None else ""
|
|
108
|
+
|
|
109
|
+
@model_validator(mode="after")
|
|
110
|
+
def validate_duration_and_power(self) -> Self:
|
|
111
|
+
self.enable = self.mode != OperationMode.CANCEL
|
|
112
|
+
|
|
113
|
+
match self.mode:
|
|
114
|
+
case OperationMode.CANCEL:
|
|
115
|
+
if self.duration is not None or self.power_limitation is not None:
|
|
116
|
+
raise ValueError("duration and power_limitation must be null/None when mode is CANCEL")
|
|
117
|
+
|
|
118
|
+
case OperationMode.CHARGE | OperationMode.DISCHARGE:
|
|
119
|
+
self._validate_duration()
|
|
120
|
+
if self.power_limitation is not None:
|
|
121
|
+
if self.power_limitation <= 0:
|
|
122
|
+
raise ValueError("power_limitation must be a positive number (> 0)")
|
|
123
|
+
if self.power_limitation > MAX_POWER_LIMIT_KW:
|
|
124
|
+
msg = (
|
|
125
|
+
f"power_limitation {self.power_limitation} kW "
|
|
126
|
+
f"exceeds sanity limit of {MAX_POWER_LIMIT_KW} kW"
|
|
127
|
+
)
|
|
128
|
+
raise ValueError(msg)
|
|
129
|
+
|
|
130
|
+
case OperationMode.HOLD | OperationMode.SELF_CONSUMPTION:
|
|
131
|
+
self._validate_duration()
|
|
132
|
+
if self.power_limitation is not None:
|
|
133
|
+
raise ValueError(f"power_limitation is not supported for mode {self.mode.name}")
|
|
134
|
+
return self
|
|
135
|
+
|
|
136
|
+
def _validate_duration(self) -> None:
|
|
137
|
+
"""Shared helper to validate mandatory duration."""
|
|
138
|
+
if self.duration is None:
|
|
139
|
+
raise ValueError(f"duration is required for mode {self.mode.name}")
|
|
140
|
+
if not (1 <= self.duration <= MAX_DURATION_MINS):
|
|
141
|
+
raise ValueError(f"duration must be between 1 and {MAX_DURATION_MINS} minutes")
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sig-cloud-control
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Control Sigenergy battery via Sigen Cloud
|
|
5
|
+
Project-URL: Homepage, https://github.com/lawther/sig-cloud-control
|
|
6
|
+
Project-URL: Repository, https://github.com/lawther/sig-cloud-control
|
|
7
|
+
Project-URL: Issues, https://github.com/lawther/sig-cloud-control/issues
|
|
8
|
+
Author-email: Mike Lawther <mike.lawther@gmail.com>
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: battery,energy-storage,iot,sigen,sigenergy,solar
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
15
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Home Automation
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Requires-Python: >=3.12
|
|
23
|
+
Requires-Dist: cryptography~=46.0
|
|
24
|
+
Requires-Dist: httpx~=0.28
|
|
25
|
+
Requires-Dist: platformdirs>=4.0
|
|
26
|
+
Requires-Dist: pydantic-settings>=2.14.0
|
|
27
|
+
Requires-Dist: pydantic[email]~=2.12
|
|
28
|
+
Provides-Extra: cli
|
|
29
|
+
Requires-Dist: tomli-w>=1.2.0; extra == 'cli'
|
|
30
|
+
Requires-Dist: typer~=0.24; extra == 'cli'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# sig-cloud-control
|
|
34
|
+
|
|
35
|
+
A Python library and CLI for controlling Sigenergy (Sigen Cloud) solar and battery systems.
|
|
36
|
+
|
|
37
|
+
> [⚠️ CAUTION]
|
|
38
|
+
> **Non-Affiliation & Warning**
|
|
39
|
+
> - **Not Official:** This project is not affiliated with, authorized by, or endorsed by Sigenergy. Use of this tool is at your own risk.
|
|
40
|
+
> - **Warranty Warning:** Using this tool may violate your Sigenergy Terms of Service and could potentially void your hardware warranty. The author is not responsible for any loss of warranty or damage to equipment.
|
|
41
|
+
> - **Reverse Engineering:** This tool was developed for the purpose of interoperability between Sigenergy batteries and third-party automation systems.
|
|
42
|
+
|
|
43
|
+
## Compatibility
|
|
44
|
+
|
|
45
|
+
> [⚠️ NOTE]
|
|
46
|
+
> This tool currently only supports the **Australian** Sigen Cloud data centre (`api-aus.sigencloud.com`). Other regions are not yet supported.
|
|
47
|
+
|
|
48
|
+
## Operations
|
|
49
|
+
|
|
50
|
+
- `charge`: Force charge the battery from the grid for a specified duration.
|
|
51
|
+
- `discharge`: Force discharge the battery for a specified duration.
|
|
52
|
+
- `hold`: Hold the battery at its current state of charge for a specified duration.
|
|
53
|
+
- `self-consumption`: Enable self-consumption mode for a specified duration. The battery prioritises consuming solar generation.
|
|
54
|
+
- `cancel`: Immediately return the battery to its configured default mode (which may be self-consumption, VPP control, or another mode set by your installer).
|
|
55
|
+
|
|
56
|
+
> **`self-consumption` vs `cancel`:** Use `self-consumption` to explicitly activate solar-first behaviour for a set period. Use `cancel` to immediately hand control back to the battery's configured default, whatever that may be.
|
|
57
|
+
|
|
58
|
+
## Installation
|
|
59
|
+
|
|
60
|
+
### From PyPI (Recommended)
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install sig-cloud-control
|
|
64
|
+
# or using uv
|
|
65
|
+
uv tool install sig-cloud-control
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### From Source (Development)
|
|
69
|
+
|
|
70
|
+
This project uses `uv` for dependency management.
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
# Clone the repository
|
|
74
|
+
git clone https://github.com/lawther/sig-cloud-control.git
|
|
75
|
+
cd sig-cloud-control
|
|
76
|
+
|
|
77
|
+
# Install dependencies
|
|
78
|
+
uv sync
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Configuration
|
|
82
|
+
|
|
83
|
+
### Interactive Setup (Recommended for local use)
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
sig-cloud-control setup
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
This prompts for your credentials, encrypts your password, and saves a config file to the platform default location:
|
|
90
|
+
|
|
91
|
+
- **macOS/Linux:** `~/.config/sig-cloud-control/config.toml`
|
|
92
|
+
- **Windows:** `%LOCALAPPDATA%\sig-cloud-control\sig-cloud-control\config.toml`
|
|
93
|
+
|
|
94
|
+
### Config File Discovery
|
|
95
|
+
|
|
96
|
+
When no `--config` flag is provided, the tool searches for a config file in this order:
|
|
97
|
+
|
|
98
|
+
1. `./config.toml` in the current directory (for project-local use)
|
|
99
|
+
2. The platform default location above
|
|
100
|
+
|
|
101
|
+
### Environment Variables (Recommended for Docker/CI)
|
|
102
|
+
|
|
103
|
+
Environment variables take precedence over the configuration file:
|
|
104
|
+
|
|
105
|
+
- `SIGEN_USERNAME`: Your Sigen Cloud email address.
|
|
106
|
+
- `SIGEN_PASSWORD_ENCODED`: Your encrypted password (generate with `sig-cloud-control setup`).
|
|
107
|
+
- `SIGEN_PASSWORD`: Your plaintext password (convenient for CI secrets).
|
|
108
|
+
- `SIGEN_STATION_ID`: Your Station ID (optional).
|
|
109
|
+
|
|
110
|
+
### Config File Format
|
|
111
|
+
|
|
112
|
+
```toml
|
|
113
|
+
username = "example@example.com"
|
|
114
|
+
|
|
115
|
+
# Use the encrypted password (generated by `sig-cloud-control setup`):
|
|
116
|
+
password_encoded = "..."
|
|
117
|
+
|
|
118
|
+
# OR use a plaintext password:
|
|
119
|
+
# password = "your_plaintext_password"
|
|
120
|
+
|
|
121
|
+
# station_id is optional and will be fetched automatically if omitted.
|
|
122
|
+
# Find it in the Sigen app under Settings -> System Settings -> About.
|
|
123
|
+
# station_id = 12345
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## CLI Usage
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
# Setup credentials interactively (saves to platform default config location)
|
|
130
|
+
sig-cloud-control setup
|
|
131
|
+
|
|
132
|
+
# Charge battery for 60 minutes at 2.5 kW (charge rate limit)
|
|
133
|
+
sig-cloud-control charge 60 --power 2.5
|
|
134
|
+
|
|
135
|
+
# Discharge battery for 30 minutes at maximum rate
|
|
136
|
+
sig-cloud-control discharge 30
|
|
137
|
+
|
|
138
|
+
# Hold battery at current state of charge for 60 minutes
|
|
139
|
+
sig-cloud-control hold 60
|
|
140
|
+
|
|
141
|
+
# Enable self-consumption mode for 30 minutes
|
|
142
|
+
sig-cloud-control self-consumption 30
|
|
143
|
+
|
|
144
|
+
# Return battery to its default mode immediately
|
|
145
|
+
sig-cloud-control cancel
|
|
146
|
+
|
|
147
|
+
# Use a specific config file
|
|
148
|
+
sig-cloud-control charge 60 --config /path/to/config.toml
|
|
149
|
+
|
|
150
|
+
# Enable verbose logging for debugging
|
|
151
|
+
sig-cloud-control charge 60 --verbose
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Options
|
|
155
|
+
|
|
156
|
+
| Option | Description |
|
|
157
|
+
|--------|-------------|
|
|
158
|
+
| `--power` | Charge/discharge rate limit in kW (supported by `charge` and `discharge` only) |
|
|
159
|
+
| `--config` | Path to the TOML configuration file |
|
|
160
|
+
| `--verbose`, `-v` | Enable verbose debug logging to stderr |
|
|
161
|
+
|
|
162
|
+
## API Usage
|
|
163
|
+
|
|
164
|
+
You can use `sig-cloud-control` as a library in your own asynchronous Python applications. The public API is exposed at the root level:
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
import asyncio
|
|
168
|
+
from sig_cloud_control import SigCloudClient, Config
|
|
169
|
+
|
|
170
|
+
async def main():
|
|
171
|
+
config = Config(
|
|
172
|
+
username="user@example.com",
|
|
173
|
+
password="my_secret_password"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# Use as an async context manager — handles cleanup automatically
|
|
177
|
+
async with SigCloudClient(config) as client:
|
|
178
|
+
await client.login()
|
|
179
|
+
await client.charge_battery(duration_min=60, power_kw=5.0)
|
|
180
|
+
print("Charge command issued successfully.")
|
|
181
|
+
|
|
182
|
+
if __name__ == "__main__":
|
|
183
|
+
asyncio.run(main())
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Token Cache
|
|
187
|
+
|
|
188
|
+
By default, `SigCloudClient` caches authentication tokens at the platform cache directory (e.g. `~/.cache/sig-cloud-control/token-cache.json` on Linux). To disable caching, pass `cache_path=None`:
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
client = SigCloudClient(config, cache_path=None)
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Error Handling
|
|
195
|
+
|
|
196
|
+
All errors are subclasses of `SigCloudError`. Import the specific types for finer-grained handling:
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
from sig_cloud_control import AuthenticationError, StationError, APIError, SigCloudError
|
|
200
|
+
|
|
201
|
+
async with SigCloudClient(config) as client:
|
|
202
|
+
try:
|
|
203
|
+
await client.login()
|
|
204
|
+
except AuthenticationError:
|
|
205
|
+
print("Bad credentials — check username/password.")
|
|
206
|
+
except StationError:
|
|
207
|
+
print("Could not resolve station ID.")
|
|
208
|
+
except APIError:
|
|
209
|
+
print("Unexpected API response.")
|
|
210
|
+
except SigCloudError:
|
|
211
|
+
print("Other Sigen Cloud error.")
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
| Exception | When raised |
|
|
215
|
+
|-----------|-------------|
|
|
216
|
+
| `AuthenticationError` | Login failed (bad credentials or token error) |
|
|
217
|
+
| `StationError` | Station ID could not be resolved or is unknown |
|
|
218
|
+
| `APIError` | Sigen API returned an unexpected or unparseable response |
|
|
219
|
+
| `SigCloudError` | Base class; also raised for pre-condition failures (e.g. invalid duration) |
|
|
220
|
+
|
|
221
|
+
## Development
|
|
222
|
+
|
|
223
|
+
This project uses `just` to manage development tasks. The `Justfile` is the Single Source Of Truth (SSOT) for all pre-commit checks and development workflows. No additional linting or testing logic should be added anywhere else (e.g. CI configs).
|
|
224
|
+
|
|
225
|
+
### Help
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
just
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### Running Pre-commit Checks (Lint + Test)
|
|
232
|
+
|
|
233
|
+
```bash
|
|
234
|
+
just precommit
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### Running Tests
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
just test
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### Linting and Formatting
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
just lint
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
## License
|
|
250
|
+
|
|
251
|
+
Apache 2.0
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
sig_cloud_control/__init__.py,sha256=8OY4qn7gU7OOaBnbhDbRvt9W1sAFCsG846WJcS63RYk,333
|
|
2
|
+
sig_cloud_control/client.py,sha256=o3D0-Oj8_fo5y8dk_W0GmmmlZ5qkcQCG4h-E0ZsGBRw,12811
|
|
3
|
+
sig_cloud_control/models.py,sha256=U1HJiV1ICif9yPbYi0DRQbQJqdJdNwecB5h1nwRBBac,4986
|
|
4
|
+
sig_cloud_control/cli_app/__init__.py,sha256=OWDYcxuAEAyn2LUFMlYabaHbXDtFvV3K9qryu10Bln0,8651
|
|
5
|
+
sig_cloud_control-0.1.0.dist-info/METADATA,sha256=Ey35GKAR444o1HPObfE955F9bAXrFkwIgakxiynHSXw,8124
|
|
6
|
+
sig_cloud_control-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
7
|
+
sig_cloud_control-0.1.0.dist-info/entry_points.txt,sha256=Hv5fP9tcb7X8yQ1Pq_JY2niIpDOhohPSYPAU-SvuHi0,68
|
|
8
|
+
sig_cloud_control-0.1.0.dist-info/licenses/LICENSE,sha256=EEtzlSNOipzD2g_imt3vkGsjZ2DidDtxsP0nj_Yjpsc,11342
|
|
9
|
+
sig_cloud_control-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright 2026 Mike Lawther
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|