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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sig-cloud-control = sig_cloud_control.cli_app:app
@@ -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.