sig-cloud-control 0.3.0__tar.gz → 0.3.2__tar.gz
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-0.3.0 → sig_cloud_control-0.3.2}/.github/workflows/release.yml +27 -0
- {sig_cloud_control-0.3.0 → sig_cloud_control-0.3.2}/CHANGELOG.md +14 -0
- {sig_cloud_control-0.3.0 → sig_cloud_control-0.3.2}/Justfile +1 -0
- {sig_cloud_control-0.3.0 → sig_cloud_control-0.3.2}/PKG-INFO +3 -3
- {sig_cloud_control-0.3.0 → sig_cloud_control-0.3.2}/README.md +2 -2
- {sig_cloud_control-0.3.0 → sig_cloud_control-0.3.2}/pyproject.toml +1 -1
- sig_cloud_control-0.3.2/src/sig_cloud_control/_encryption.py +24 -0
- sig_cloud_control-0.3.2/src/sig_cloud_control/cli_app/__init__.py +3 -0
- sig_cloud_control-0.3.2/src/sig_cloud_control/cli_app/actions.py +63 -0
- sig_cloud_control-0.3.2/src/sig_cloud_control/cli_app/config.py +97 -0
- sig_cloud_control-0.3.2/src/sig_cloud_control/cli_app/constants.py +5 -0
- sig_cloud_control-0.3.2/src/sig_cloud_control/cli_app/main.py +152 -0
- sig_cloud_control-0.3.2/src/sig_cloud_control/cli_app/setup.py +54 -0
- sig_cloud_control-0.3.2/src/sig_cloud_control/client/__init__.py +15 -0
- sig_cloud_control-0.3.2/src/sig_cloud_control/client/cache.py +51 -0
- sig_cloud_control-0.3.0/src/sig_cloud_control/client.py → sig_cloud_control-0.3.2/src/sig_cloud_control/client/core.py +12 -82
- sig_cloud_control-0.3.2/src/sig_cloud_control/client/exceptions.py +14 -0
- {sig_cloud_control-0.3.0 → sig_cloud_control-0.3.2}/tests/test_cli.py +2 -2
- {sig_cloud_control-0.3.0 → sig_cloud_control-0.3.2}/tests/test_client.py +22 -9
- {sig_cloud_control-0.3.0 → sig_cloud_control-0.3.2}/tests/test_config_loading.py +2 -2
- sig_cloud_control-0.3.2/tests/test_meta.py +94 -0
- {sig_cloud_control-0.3.0 → sig_cloud_control-0.3.2}/tests/test_show_config.py +31 -28
- {sig_cloud_control-0.3.0 → sig_cloud_control-0.3.2}/tests/test_station_id.py +5 -5
- {sig_cloud_control-0.3.0 → sig_cloud_control-0.3.2}/uv.lock +1 -1
- sig_cloud_control-0.3.0/src/sig_cloud_control/cli_app/__init__.py +0 -342
- sig_cloud_control-0.3.0/tests/test_meta.py +0 -25
- {sig_cloud_control-0.3.0 → sig_cloud_control-0.3.2}/.github/workflows/ci.yml +0 -0
- {sig_cloud_control-0.3.0 → sig_cloud_control-0.3.2}/.gitignore +0 -0
- {sig_cloud_control-0.3.0 → sig_cloud_control-0.3.2}/.python-version +0 -0
- {sig_cloud_control-0.3.0 → sig_cloud_control-0.3.2}/CLAUDE.md +0 -0
- {sig_cloud_control-0.3.0 → sig_cloud_control-0.3.2}/GEMINI.md +0 -0
- {sig_cloud_control-0.3.0 → sig_cloud_control-0.3.2}/LICENSE +0 -0
- {sig_cloud_control-0.3.0 → sig_cloud_control-0.3.2}/config.sample.toml +0 -0
- {sig_cloud_control-0.3.0 → sig_cloud_control-0.3.2}/src/sig_cloud_control/__init__.py +0 -0
- {sig_cloud_control-0.3.0 → sig_cloud_control-0.3.2}/src/sig_cloud_control/models.py +0 -0
- {sig_cloud_control-0.3.0 → sig_cloud_control-0.3.2}/tests/__init__.py +0 -0
- {sig_cloud_control-0.3.0 → sig_cloud_control-0.3.2}/tests/test_models.py +0 -0
|
@@ -14,6 +14,7 @@ jobs:
|
|
|
14
14
|
runs-on: ubuntu-latest
|
|
15
15
|
outputs:
|
|
16
16
|
release_created: ${{ steps.release.outputs.release_created }}
|
|
17
|
+
pr: ${{ steps.release.outputs.pr }}
|
|
17
18
|
steps:
|
|
18
19
|
- uses: googleapis/release-please-action@v4
|
|
19
20
|
id: release
|
|
@@ -23,6 +24,32 @@ jobs:
|
|
|
23
24
|
release-type: python
|
|
24
25
|
token: ${{ secrets.GITHUB_TOKEN }}
|
|
25
26
|
|
|
27
|
+
update-lockfile:
|
|
28
|
+
runs-on: ubuntu-latest
|
|
29
|
+
needs: release-please
|
|
30
|
+
if: ${{ needs.release-please.outputs.pr }}
|
|
31
|
+
permissions:
|
|
32
|
+
contents: write
|
|
33
|
+
steps:
|
|
34
|
+
- uses: actions/checkout@v6
|
|
35
|
+
with:
|
|
36
|
+
ref: release-please--branches--main
|
|
37
|
+
token: ${{ secrets.GITHUB_TOKEN }}
|
|
38
|
+
|
|
39
|
+
- name: Install uv
|
|
40
|
+
uses: astral-sh/setup-uv@v8.0.0
|
|
41
|
+
|
|
42
|
+
- name: Update lock file
|
|
43
|
+
run: uv lock
|
|
44
|
+
|
|
45
|
+
- name: Commit updated lock file
|
|
46
|
+
run: |
|
|
47
|
+
git config user.name "github-actions[bot]"
|
|
48
|
+
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
49
|
+
git add uv.lock
|
|
50
|
+
git diff --staged --quiet || git commit -m "chore: update uv.lock for version bump"
|
|
51
|
+
git push
|
|
52
|
+
|
|
26
53
|
publish:
|
|
27
54
|
runs-on: ubuntu-latest
|
|
28
55
|
needs: release-please
|
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.3.2](https://github.com/lawther/sig-cloud-control/compare/v0.3.1...v0.3.2) (2026-04-24)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **cli:** correct path highlighting in show-config command ([d915a55](https://github.com/lawther/sig-cloud-control/commit/d915a55141b8852aeb6ff9973c4e887d9d05d9f5))
|
|
9
|
+
|
|
10
|
+
## [0.3.1](https://github.com/lawther/sig-cloud-control/compare/v0.3.0...v0.3.1) (2026-04-22)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Documentation
|
|
14
|
+
|
|
15
|
+
* update disclaimer wording and fix spelling in README ([77d4f40](https://github.com/lawther/sig-cloud-control/commit/77d4f4049a505b57ce07bb3b562ad57a4220c782))
|
|
16
|
+
|
|
3
17
|
## [0.3.0](https://github.com/lawther/sig-cloud-control/compare/v0.2.0...v0.3.0) (2026-04-22)
|
|
4
18
|
|
|
5
19
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sig-cloud-control
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: Control Sigenergy battery via Sigen Cloud
|
|
5
5
|
Project-URL: Homepage, https://github.com/lawther/sig-cloud-control
|
|
6
6
|
Project-URL: Repository, https://github.com/lawther/sig-cloud-control
|
|
@@ -34,8 +34,8 @@ Description-Content-Type: text/markdown
|
|
|
34
34
|
A Python library and CLI for controlling Sigenergy (Sigen Cloud) solar and battery systems.
|
|
35
35
|
|
|
36
36
|
> [⚠️ CAUTION]
|
|
37
|
-
> **Non-Affiliation
|
|
38
|
-
> - **Not Official:** This project is not affiliated with,
|
|
37
|
+
> **Non-Affiliation**
|
|
38
|
+
> - **Not Official:** This project is not affiliated with, authorised by, or endorsed by Sigenergy. Use of this tool is at your own risk.
|
|
39
39
|
> - **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.
|
|
40
40
|
> - **Reverse Engineering:** This tool was developed for the purpose of interoperability between Sigenergy batteries and third-party automation systems.
|
|
41
41
|
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
A Python library and CLI for controlling Sigenergy (Sigen Cloud) solar and battery systems.
|
|
4
4
|
|
|
5
5
|
> [⚠️ CAUTION]
|
|
6
|
-
> **Non-Affiliation
|
|
7
|
-
> - **Not Official:** This project is not affiliated with,
|
|
6
|
+
> **Non-Affiliation**
|
|
7
|
+
> - **Not Official:** This project is not affiliated with, authorised by, or endorsed by Sigenergy. Use of this tool is at your own risk.
|
|
8
8
|
> - **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.
|
|
9
9
|
> - **Reverse Engineering:** This tool was developed for the purpose of interoperability between Sigenergy batteries and third-party automation systems.
|
|
10
10
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
from typing import Final
|
|
3
|
+
|
|
4
|
+
from cryptography.hazmat.primitives import padding
|
|
5
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
6
|
+
|
|
7
|
+
# Fixed key and IV used by Sigen Cloud
|
|
8
|
+
_ENCRYPT_KEY: Final[bytes] = (b"s" + b"i" + b"g" + b"e" + b"n") * 3 + b"p"
|
|
9
|
+
_ENCRYPT_IV: Final[bytes] = (b"s" + b"i" + b"g" + b"e" + b"n") * 3 + b"p"
|
|
10
|
+
_CIPHER: Final[Cipher] = Cipher(
|
|
11
|
+
algorithms.AES(_ENCRYPT_KEY),
|
|
12
|
+
modes.CBC(_ENCRYPT_IV),
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def encrypt_password(password: str) -> str:
|
|
17
|
+
"""Encrypt a plaintext password using Sigen's AES-128-CBC logic."""
|
|
18
|
+
padder = padding.PKCS7(128).padder()
|
|
19
|
+
padded_data = padder.update(password.encode()) + padder.finalize()
|
|
20
|
+
|
|
21
|
+
encryptor = _CIPHER.encryptor()
|
|
22
|
+
ct = encryptor.update(padded_data) + encryptor.finalize()
|
|
23
|
+
|
|
24
|
+
return base64.b64encode(ct).decode()
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from sig_cloud_control.client import SigCloudClient, SigCloudError
|
|
9
|
+
from sig_cloud_control.models import Config
|
|
10
|
+
|
|
11
|
+
from .config import _resolve_config_path, load_config
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def execute_action(
|
|
17
|
+
config: Config,
|
|
18
|
+
action: str,
|
|
19
|
+
duration: int = 0,
|
|
20
|
+
power: float | None = None,
|
|
21
|
+
verbose: bool = False,
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Internal helper to run the async client logic."""
|
|
24
|
+
if verbose:
|
|
25
|
+
logging.basicConfig(
|
|
26
|
+
level=logging.DEBUG,
|
|
27
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
28
|
+
stream=sys.stderr,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
async with SigCloudClient(config) as client:
|
|
32
|
+
typer.echo("Logging in to Sigen Cloud...")
|
|
33
|
+
await client.login()
|
|
34
|
+
|
|
35
|
+
with console.status(f"Executing '{action}' action...", spinner="dots"):
|
|
36
|
+
if action == "charge":
|
|
37
|
+
await client.charge_battery(duration, power)
|
|
38
|
+
elif action == "discharge":
|
|
39
|
+
await client.discharge_battery(duration, power)
|
|
40
|
+
elif action == "hold":
|
|
41
|
+
await client.hold_battery(duration)
|
|
42
|
+
elif action == "self-consumption":
|
|
43
|
+
await client.self_consumption(duration)
|
|
44
|
+
elif action == "cancel":
|
|
45
|
+
await client.cancel_self_control()
|
|
46
|
+
|
|
47
|
+
console.print(f"[bold green]✔︎ [/bold green]Executing '{action}' action... [bold green]Done.[/bold green]")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _run_command_action(
|
|
51
|
+
action: str,
|
|
52
|
+
duration: int = 0,
|
|
53
|
+
power: float | None = None,
|
|
54
|
+
config: str | None = None,
|
|
55
|
+
verbose: bool = False,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Internal helper to load config and run an action."""
|
|
58
|
+
conf = load_config(_resolve_config_path(config))
|
|
59
|
+
try:
|
|
60
|
+
asyncio.run(execute_action(conf, action, duration, power, verbose))
|
|
61
|
+
except SigCloudError as e:
|
|
62
|
+
typer.secho(f"❌ Sigen API Error: {e}", fg=typer.colors.RED, err=True)
|
|
63
|
+
raise typer.Exit(code=1) from e
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import tomllib
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import NamedTuple
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from pydantic import ValidationError
|
|
9
|
+
|
|
10
|
+
from sig_cloud_control.models import Config, Region
|
|
11
|
+
|
|
12
|
+
from .constants import _DEFAULT_CONFIG_PATH
|
|
13
|
+
from .setup import perform_setup
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SigenEnvVar(StrEnum):
|
|
17
|
+
"""Sigen environment variable names.
|
|
18
|
+
|
|
19
|
+
Each member name (lowercased) is the corresponding Config field name,
|
|
20
|
+
and each value is the environment variable name.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
USERNAME = "SIGEN_USERNAME"
|
|
24
|
+
PASSWORD = "SIGEN_PASSWORD" # noqa: S105
|
|
25
|
+
PASSWORD_ENCODED = "SIGEN_PASSWORD_ENCODED" # noqa: S105
|
|
26
|
+
STATION_ID = "SIGEN_STATION_ID"
|
|
27
|
+
REGION = "SIGEN_REGION"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ConfigSources(NamedTuple):
|
|
31
|
+
"""The resolved configuration and the sources it was loaded from."""
|
|
32
|
+
|
|
33
|
+
env_vars_present: frozenset[SigenEnvVar]
|
|
34
|
+
config_file: Path | None
|
|
35
|
+
config: Config
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _resolve_config_path(config_opt: str | None) -> Path:
|
|
39
|
+
"""Resolve config path: explicit CLI arg > ./config.toml > platform default."""
|
|
40
|
+
if config_opt is not None:
|
|
41
|
+
return Path(config_opt)
|
|
42
|
+
local = Path("config.toml")
|
|
43
|
+
if local.exists():
|
|
44
|
+
return local
|
|
45
|
+
return _DEFAULT_CONFIG_PATH
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _try_load_config(config_path: Path) -> ConfigSources | None:
|
|
49
|
+
"""Attempt to load config from env vars and/or a TOML file.
|
|
50
|
+
|
|
51
|
+
Returns None if no valid config could be assembled (without triggering interactive setup).
|
|
52
|
+
Precedence: SIGEN_* environment variables override file values.
|
|
53
|
+
"""
|
|
54
|
+
env_vars_present = frozenset(v for v in SigenEnvVar if v in os.environ)
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
config = Config.model_validate({})
|
|
58
|
+
return ConfigSources(env_vars_present=env_vars_present, config_file=None, config=config)
|
|
59
|
+
except ValidationError:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
if not config_path.exists():
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
with open(config_path, "rb") as f:
|
|
67
|
+
file_data: dict[str, object] = tomllib.load(f)
|
|
68
|
+
|
|
69
|
+
# TOML parses region as a plain string; coerce to Region for strict-mode validation.
|
|
70
|
+
# (env_settings handles this automatically; init_settings does not.)
|
|
71
|
+
if "region" in file_data and isinstance(file_data["region"], str):
|
|
72
|
+
file_data["region"] = Region(file_data["region"])
|
|
73
|
+
|
|
74
|
+
# env_settings overrides init_settings per Config.settings_customise_sources.
|
|
75
|
+
config = Config(**file_data)
|
|
76
|
+
return ConfigSources(env_vars_present=env_vars_present, config_file=config_path, config=config)
|
|
77
|
+
except ValidationError as e:
|
|
78
|
+
typer.secho(
|
|
79
|
+
f"❌ Error: Invalid configuration format in '{config_path}':\n{e}",
|
|
80
|
+
fg=typer.colors.RED,
|
|
81
|
+
err=True,
|
|
82
|
+
)
|
|
83
|
+
raise typer.Exit(code=1) from e
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def load_config(config_path: Path) -> Config:
|
|
87
|
+
"""Load configuration from environment variables and/or TOML file, prompting for setup if missing."""
|
|
88
|
+
typer.echo("Loading configuration...")
|
|
89
|
+
sources = _try_load_config(config_path)
|
|
90
|
+
if sources is None:
|
|
91
|
+
typer.secho(f"⚠️ Config file not found at '{config_path}'", fg=typer.colors.YELLOW)
|
|
92
|
+
perform_setup(config_path)
|
|
93
|
+
sources = _try_load_config(config_path)
|
|
94
|
+
if sources is None:
|
|
95
|
+
typer.secho("❌ Configuration unavailable after setup.", fg=typer.colors.RED, err=True)
|
|
96
|
+
raise typer.Exit(code=1)
|
|
97
|
+
return sources.config
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.markup import escape
|
|
7
|
+
|
|
8
|
+
from .actions import _run_command_action, console
|
|
9
|
+
from .config import SigenEnvVar, _resolve_config_path, _try_load_config
|
|
10
|
+
from .constants import _DEFAULT_CONFIG_PATH
|
|
11
|
+
from .setup import perform_setup
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(help="Control a Sigen solar/battery station.")
|
|
14
|
+
|
|
15
|
+
# Common types for CLI arguments and options to reduce duplication
|
|
16
|
+
DurationArg = Annotated[int, typer.Argument(help="Duration in minutes (1-1440)", min=1, max=1440)]
|
|
17
|
+
PowerOpt = Annotated[float | None, typer.Option(help="Charge/discharge rate limit for the battery in kW")]
|
|
18
|
+
ConfigOpt = Annotated[str | None, typer.Option(help="Path to the TOML configuration file")]
|
|
19
|
+
VerboseOpt = Annotated[bool, typer.Option("--verbose", "-v", help="Enable verbose logging")]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@app.callback(invoke_without_command=True)
|
|
23
|
+
def main(ctx: typer.Context) -> None:
|
|
24
|
+
"""Control a Sigen solar/battery station."""
|
|
25
|
+
if ctx.invoked_subcommand is None:
|
|
26
|
+
typer.secho("Missing command.", fg=typer.colors.RED, err=True)
|
|
27
|
+
typer.echo(ctx.get_help())
|
|
28
|
+
raise typer.Exit(code=1)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.command()
|
|
32
|
+
def charge(
|
|
33
|
+
duration: DurationArg,
|
|
34
|
+
power: PowerOpt = None,
|
|
35
|
+
config: ConfigOpt = None,
|
|
36
|
+
verbose: VerboseOpt = False,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Charge battery from the grid for a specified duration."""
|
|
39
|
+
_run_command_action("charge", duration, power, config, verbose)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@app.command()
|
|
43
|
+
def discharge(
|
|
44
|
+
duration: DurationArg,
|
|
45
|
+
power: PowerOpt = None,
|
|
46
|
+
config: ConfigOpt = None,
|
|
47
|
+
verbose: VerboseOpt = False,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Discharge battery for a specified duration."""
|
|
50
|
+
_run_command_action("discharge", duration, power, config, verbose)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@app.command()
|
|
54
|
+
def hold(
|
|
55
|
+
duration: DurationArg,
|
|
56
|
+
config: ConfigOpt = None,
|
|
57
|
+
verbose: VerboseOpt = False,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Hold battery at its current state of charge for a specified duration."""
|
|
60
|
+
_run_command_action("hold", duration, config=config, verbose=verbose)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@app.command(name="self-consumption")
|
|
64
|
+
def self_consumption(
|
|
65
|
+
duration: DurationArg,
|
|
66
|
+
config: ConfigOpt = None,
|
|
67
|
+
verbose: VerboseOpt = False,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Enable self-consumption mode for a specified duration."""
|
|
70
|
+
_run_command_action("self-consumption", duration, config=config, verbose=verbose)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@app.command()
|
|
74
|
+
def cancel(
|
|
75
|
+
config: ConfigOpt = None,
|
|
76
|
+
verbose: VerboseOpt = False,
|
|
77
|
+
) -> None:
|
|
78
|
+
"""Return the battery to its configured default mode."""
|
|
79
|
+
_run_command_action(action="cancel", config=config, verbose=verbose)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@app.command()
|
|
83
|
+
def setup(
|
|
84
|
+
config: Annotated[str | None, typer.Option(help="Path to the TOML configuration file to create")] = None,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Interactively setup credentials and save to config file."""
|
|
87
|
+
config_path = Path(config) if config else _DEFAULT_CONFIG_PATH
|
|
88
|
+
if config_path.exists():
|
|
89
|
+
typer.secho(
|
|
90
|
+
f"⚠️ Warning: Configuration file '{config_path}' already exists and will be overwritten.",
|
|
91
|
+
fg=typer.colors.YELLOW,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
perform_setup(config_path)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@app.command(name="show-config")
|
|
98
|
+
def show_config(
|
|
99
|
+
config: ConfigOpt = None,
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Show the current configuration and where it was loaded from."""
|
|
102
|
+
config_path = _resolve_config_path(config)
|
|
103
|
+
sources = _try_load_config(config_path)
|
|
104
|
+
|
|
105
|
+
if sources is None:
|
|
106
|
+
env_vars_present = frozenset(v for v in SigenEnvVar if v in os.environ)
|
|
107
|
+
local_config = Path("config.toml")
|
|
108
|
+
console.print("\n[bold yellow]⚠️ No configuration found.[/bold yellow]")
|
|
109
|
+
console.print("\n[bold]Locations checked:[/bold]")
|
|
110
|
+
if not env_vars_present:
|
|
111
|
+
all_vars = ", ".join(sorted(SigenEnvVar))
|
|
112
|
+
console.print(f" Environment vars: {all_vars} — [yellow]none set[/yellow]")
|
|
113
|
+
else:
|
|
114
|
+
set_vars = ", ".join(sorted(env_vars_present))
|
|
115
|
+
console.print(f" Environment vars: {set_vars} set, but insufficient")
|
|
116
|
+
local_status = "[yellow]not found[/yellow]" if not local_config.exists() else "found"
|
|
117
|
+
console.print(f" Local config: [repr.path]./config.toml[/repr.path] — {local_status}", highlight=False)
|
|
118
|
+
default_status = "[yellow]not found[/yellow]" if not _DEFAULT_CONFIG_PATH.exists() else "found"
|
|
119
|
+
console.print(
|
|
120
|
+
f" Default config: [repr.path]{escape(str(_DEFAULT_CONFIG_PATH))}[/repr.path] — {default_status}",
|
|
121
|
+
highlight=False,
|
|
122
|
+
)
|
|
123
|
+
console.print("\nRun [bold]sig-cloud-control setup[/bold] to create a configuration file.")
|
|
124
|
+
raise typer.Exit(code=1)
|
|
125
|
+
|
|
126
|
+
console.print("\n[bold]Sources:[/bold]")
|
|
127
|
+
if sources.config_file is not None:
|
|
128
|
+
console.print(
|
|
129
|
+
f" Config file: [repr.path]{escape(str(sources.config_file))}[/repr.path]", highlight=False
|
|
130
|
+
)
|
|
131
|
+
else:
|
|
132
|
+
console.print(" Config file: [dim](not used — env vars were sufficient)[/dim]")
|
|
133
|
+
|
|
134
|
+
if sources.env_vars_present:
|
|
135
|
+
console.print(f" Environment vars: {', '.join(sorted(sources.env_vars_present))}")
|
|
136
|
+
else:
|
|
137
|
+
console.print(" Environment vars: [dim](none)[/dim]")
|
|
138
|
+
|
|
139
|
+
console.print("\n[bold]Configuration:[/bold]")
|
|
140
|
+
console.print(f" Username: {sources.config.username}")
|
|
141
|
+
|
|
142
|
+
if sources.config.password is not None:
|
|
143
|
+
console.print(" Password: [dim]*** (plaintext)[/dim]")
|
|
144
|
+
elif sources.config.password_encoded is not None:
|
|
145
|
+
console.print(" Password: [dim]*** (encoded)[/dim]")
|
|
146
|
+
|
|
147
|
+
if sources.config.station_id is not None:
|
|
148
|
+
console.print(f" Station ID: {sources.config.station_id}")
|
|
149
|
+
else:
|
|
150
|
+
console.print(" Station ID: [dim](auto-detect)[/dim]")
|
|
151
|
+
|
|
152
|
+
console.print(f" Region: {sources.config.region.value}")
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import tomli_w
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from sig_cloud_control._encryption import encrypt_password
|
|
8
|
+
from sig_cloud_control.models import Region
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def perform_setup(config_path: Path) -> None:
|
|
12
|
+
"""Interactively prompt for credentials and save to file."""
|
|
13
|
+
typer.echo(f"Setting up new configuration at '{config_path}'...")
|
|
14
|
+
username = typer.prompt("Sigen Cloud login name (eg. user@example.com)")
|
|
15
|
+
password = typer.prompt("Sigen Cloud Password", hide_input=True)
|
|
16
|
+
_region_labels: dict[Region, str] = {
|
|
17
|
+
Region.AUS: "Australia / New Zealand",
|
|
18
|
+
Region.APAC: "Asia-Pacific",
|
|
19
|
+
Region.EU: "Europe",
|
|
20
|
+
Region.CN: "China",
|
|
21
|
+
Region.US: "United States",
|
|
22
|
+
}
|
|
23
|
+
_regions = list(Region)
|
|
24
|
+
typer.echo("Select your region (check your Sigenergy app Settings->System Settings->About):")
|
|
25
|
+
for i, r in enumerate(_regions, 1):
|
|
26
|
+
typer.echo(f" {i}. {r.value:<6} {_region_labels[r]}")
|
|
27
|
+
while True:
|
|
28
|
+
_choice = typer.prompt(f"Region [1-{len(_regions)}]", type=int)
|
|
29
|
+
if 1 <= _choice <= len(_regions):
|
|
30
|
+
region = _regions[_choice - 1]
|
|
31
|
+
break
|
|
32
|
+
typer.secho(f"Please enter a number between 1 and {len(_regions)}.", fg=typer.colors.RED)
|
|
33
|
+
station_id_str = typer.prompt("Station ID (optional, press Enter to skip)", default="")
|
|
34
|
+
|
|
35
|
+
password_encoded = encrypt_password(password)
|
|
36
|
+
|
|
37
|
+
station_id = int(station_id_str) if station_id_str.strip().isdigit() else None
|
|
38
|
+
|
|
39
|
+
config_data: dict[str, object] = {
|
|
40
|
+
"username": username,
|
|
41
|
+
"password_encoded": password_encoded,
|
|
42
|
+
"region": region.value,
|
|
43
|
+
}
|
|
44
|
+
if station_id:
|
|
45
|
+
config_data["station_id"] = station_id
|
|
46
|
+
|
|
47
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
fd = os.open(config_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
49
|
+
with os.fdopen(fd, "wb") as f:
|
|
50
|
+
tomli_w.dump(config_data, f)
|
|
51
|
+
|
|
52
|
+
typer.secho("✔︎ ", fg=typer.colors.GREEN, bold=True, nl=False)
|
|
53
|
+
typer.secho(f"Success! Configuration saved to {config_path}", fg=typer.colors.GREEN)
|
|
54
|
+
typer.echo("Note: Your password has been encrypted for storage.")
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from .core import SigCloudClient
|
|
2
|
+
from .exceptions import (
|
|
3
|
+
APIError,
|
|
4
|
+
AuthenticationError,
|
|
5
|
+
SigCloudError,
|
|
6
|
+
StationError,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"APIError",
|
|
11
|
+
"AuthenticationError",
|
|
12
|
+
"SigCloudClient",
|
|
13
|
+
"SigCloudError",
|
|
14
|
+
"StationError",
|
|
15
|
+
]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Final
|
|
6
|
+
|
|
7
|
+
from platformdirs import user_cache_path
|
|
8
|
+
from pydantic import ValidationError
|
|
9
|
+
|
|
10
|
+
from ..models import TokenCache
|
|
11
|
+
|
|
12
|
+
_DEFAULT_CACHE_PATH: Final[Path] = user_cache_path("sig-cloud-control") / "token-cache.json"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def load_cache(cache_path: Path | None) -> TokenCache | None:
|
|
16
|
+
"""Load the token from the cache file if it exists and is valid."""
|
|
17
|
+
if cache_path is None:
|
|
18
|
+
return None
|
|
19
|
+
try:
|
|
20
|
+
content = await asyncio.to_thread(cache_path.read_text)
|
|
21
|
+
return TokenCache.model_validate_json(content)
|
|
22
|
+
except (FileNotFoundError, ValidationError):
|
|
23
|
+
return None
|
|
24
|
+
except Exception:
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _write_cache_file(cache_path: Path, content: str) -> None:
|
|
29
|
+
"""Helper to write the cache file securely with restricted permissions."""
|
|
30
|
+
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
fd = os.open(cache_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
32
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
33
|
+
f.write(content)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def save_cache(
|
|
37
|
+
cache_path: Path | None,
|
|
38
|
+
access_token: str | None,
|
|
39
|
+
expires_in_secs: int,
|
|
40
|
+
station_id: int | None,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Save the current token and station ID to the cache file."""
|
|
43
|
+
if cache_path is None or access_token is None:
|
|
44
|
+
return
|
|
45
|
+
cache = TokenCache(
|
|
46
|
+
access_token=access_token,
|
|
47
|
+
expires_at=time.time() + expires_in_secs,
|
|
48
|
+
station_id=station_id,
|
|
49
|
+
)
|
|
50
|
+
content = cache.model_dump_json()
|
|
51
|
+
await asyncio.to_thread(_write_cache_file, cache_path, content)
|