aionepviewer 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,115 @@
1
+ """aionepviewer - Async Python library for the NEP solar inverter cloud API."""
2
+
3
+ from .auth import NepAuth
4
+ from .const import DEFAULT_HOST
5
+ from .exceptions import (
6
+ NepApiError,
7
+ NepAuthError,
8
+ NepConnectionError,
9
+ NepError,
10
+ NepTimeoutError,
11
+ )
12
+ from .models.alert import AlertInfo, DeviceStatus
13
+ from .models.auth import AccountInfo, AuthData, TokenInfo, UserInfo
14
+ from .models.chart import (
15
+ ChartData,
16
+ ChartSeries,
17
+ ChartType,
18
+ DateStatistics,
19
+ DateStatisticsType,
20
+ )
21
+ from .models.device import (
22
+ Device,
23
+ DeviceDetail,
24
+ DeviceOverviewItem,
25
+ DeviceStatisticsOverview,
26
+ DeviceWifiOta,
27
+ PowerParameter,
28
+ )
29
+ from .models.energy import (
30
+ EnergyFlow,
31
+ EnergySource,
32
+ EnvironmentalBenefit,
33
+ ProductionStatistics,
34
+ )
35
+ from .models.module import DeviceModules, Module, SiteModulesData
36
+ from .models.overview import OverviewData
37
+ from .models.playback import PlaybackData, PlaybackModule, PlaybackType
38
+ from .models.product import ProductFunction, ProductInfo
39
+ from .models.report import ReportSettings
40
+ from .models.site import (
41
+ Site,
42
+ SiteDetail,
43
+ SiteDeviceSummary,
44
+ SiteLayout,
45
+ SiteOverview,
46
+ SiteStatusCounts,
47
+ )
48
+ from .models.weather import Weather, WeatherDay
49
+ from .nepviewer import NepViewer
50
+
51
+ __all__ = [
52
+ # Client
53
+ "NepViewer",
54
+ "NepAuth",
55
+ # Constants
56
+ "DEFAULT_HOST",
57
+ # Exceptions
58
+ "NepError",
59
+ "NepAuthError",
60
+ "NepConnectionError",
61
+ "NepApiError",
62
+ "NepTimeoutError",
63
+ # Enums
64
+ "ChartType",
65
+ "DateStatisticsType",
66
+ "DeviceStatus",
67
+ "PlaybackType",
68
+ # Auth models
69
+ "AuthData",
70
+ "UserInfo",
71
+ "TokenInfo",
72
+ "AccountInfo",
73
+ # Energy
74
+ "EnergyFlow",
75
+ "EnergySource",
76
+ "ProductionStatistics",
77
+ "EnvironmentalBenefit",
78
+ # Alert
79
+ "AlertInfo",
80
+ # Device models
81
+ "Device",
82
+ "DeviceDetail",
83
+ "DeviceStatisticsOverview",
84
+ "DeviceOverviewItem",
85
+ "DeviceModules",
86
+ "DeviceWifiOta",
87
+ "PowerParameter",
88
+ # Product models
89
+ "ProductFunction",
90
+ "ProductInfo",
91
+ # Module models
92
+ "Module",
93
+ "SiteModulesData",
94
+ # Site models
95
+ "Site",
96
+ "SiteDetail",
97
+ "SiteOverview",
98
+ "SiteDeviceSummary",
99
+ "SiteLayout",
100
+ "SiteStatusCounts",
101
+ # Chart models
102
+ "ChartData",
103
+ "ChartSeries",
104
+ "DateStatistics",
105
+ # Playback
106
+ "PlaybackData",
107
+ "PlaybackModule",
108
+ # Weather
109
+ "Weather",
110
+ "WeatherDay",
111
+ # Overview
112
+ "OverviewData",
113
+ # Report
114
+ "ReportSettings",
115
+ ]
aionepviewer/auth.py ADDED
@@ -0,0 +1,171 @@
1
+ """Authentication layer for the NEP API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import time
8
+ from typing import Any
9
+
10
+ import aiohttp
11
+
12
+ from .const import API_BASE_PATH, DEFAULT_HEADERS, DEFAULT_HOST, TOKEN_EXPIRY_MARGIN
13
+ from .exceptions import (
14
+ NepApiError,
15
+ NepAuthError,
16
+ NepConnectionError,
17
+ NepTimeoutError,
18
+ )
19
+ from .models.auth import AuthData, TokenInfo
20
+
21
+
22
+ class NepAuth:
23
+ """Handles authentication and signed requests to the NEP API.
24
+
25
+ This class manages the JWT token lifecycle and provides the low-level
26
+ ``request`` method used by :class:`~aionepviewer.nepviewer.NepViewer`.
27
+
28
+ The ``request`` method returns a raw :class:`aiohttp.ClientResponse` so
29
+ that response parsing stays in the client layer, following Home Assistant
30
+ API library best practices.
31
+
32
+ Parameters
33
+ ----------
34
+ session:
35
+ An ``aiohttp.ClientSession`` instance. The caller owns the session
36
+ and is responsible for closing it.
37
+ email:
38
+ NEP account email address.
39
+ password:
40
+ NEP account password.
41
+ host:
42
+ Base URL for the API (default: ``https://api.nepviewer.net``).
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ session: aiohttp.ClientSession,
48
+ email: str,
49
+ password: str,
50
+ host: str = DEFAULT_HOST,
51
+ ) -> None:
52
+ self._session = session
53
+ self._email = email
54
+ self._password = password
55
+ self._host = host.rstrip("/")
56
+ self._token_info: TokenInfo | None = None
57
+
58
+ @property
59
+ def token(self) -> str | None:
60
+ """The current JWT token, or ``None`` if not authenticated."""
61
+ if self._token_info is None:
62
+ return None
63
+ return self._token_info.token
64
+
65
+ @property
66
+ def is_token_valid(self) -> bool:
67
+ """Return ``True`` if the token exists and has not expired."""
68
+ if self._token_info is None:
69
+ return False
70
+ return time.time() < (self._token_info.expires_at - TOKEN_EXPIRY_MARGIN)
71
+
72
+ # ------------------------------------------------------------------
73
+ # Public API
74
+ # ------------------------------------------------------------------
75
+
76
+ async def sign_in(self) -> AuthData:
77
+ """Authenticate with the NEP API and store the token.
78
+
79
+ Raises
80
+ ------
81
+ NepAuthError
82
+ If the credentials are rejected.
83
+ """
84
+ body = {"account": self._email, "password": self._password}
85
+ resp = await self._raw_request("POST", "/sign-in", json_body=body, auth=False)
86
+
87
+ # Sign-in is auth-specific; parse the response inline.
88
+ if resp.status in (401, 403):
89
+ raise NepAuthError(f"Authentication failed (HTTP {resp.status})")
90
+
91
+ try:
92
+ result = await resp.json(content_type=None)
93
+ except Exception as err:
94
+ text = await resp.text()
95
+ raise NepApiError(resp.status, f"Invalid JSON response: {text}") from err
96
+
97
+ code = result.get("code", 0)
98
+ msg = result.get("msg", "")
99
+
100
+ if code in (401, 403):
101
+ raise NepAuthError(f"Authentication failed: {msg}")
102
+
103
+ if code != 200:
104
+ raise NepApiError(code, msg)
105
+
106
+ data: dict[str, Any] = result.get("data", {})
107
+ auth_data = AuthData(data)
108
+ self._token_info = auth_data.token_info
109
+ return auth_data
110
+
111
+ async def request(
112
+ self,
113
+ method: str,
114
+ path: str,
115
+ *,
116
+ json_body: dict[str, Any] | None = None,
117
+ ) -> aiohttp.ClientResponse:
118
+ """Make an authenticated API request, refreshing the token if needed.
119
+
120
+ Returns the raw :class:`aiohttp.ClientResponse`. The caller is
121
+ responsible for parsing the response body.
122
+ """
123
+ if not self.is_token_valid:
124
+ await self.sign_in()
125
+ return await self._raw_request(method, path, json_body=json_body, auth=True)
126
+
127
+ def clear_token(self) -> None:
128
+ """Invalidate the stored token."""
129
+ self._token_info = None
130
+
131
+ # ------------------------------------------------------------------
132
+ # Internal
133
+ # ------------------------------------------------------------------
134
+
135
+ @staticmethod
136
+ def _compute_sign(body_bytes: bytes) -> str:
137
+ """Compute the ``sign`` header value: MD5 of the raw request body."""
138
+ return hashlib.md5(body_bytes).hexdigest().upper()
139
+
140
+ async def _raw_request(
141
+ self,
142
+ method: str,
143
+ path: str,
144
+ *,
145
+ json_body: dict[str, Any] | None = None,
146
+ auth: bool = True,
147
+ ) -> aiohttp.ClientResponse:
148
+ url = f"{self._host}{API_BASE_PATH}{path}"
149
+
150
+ body_bytes = (
151
+ json.dumps(json_body, separators=(",", ":")).encode() if json_body else b""
152
+ )
153
+
154
+ headers = dict(DEFAULT_HEADERS)
155
+ headers["sign"] = self._compute_sign(body_bytes)
156
+ if auth and self._token_info is not None:
157
+ headers["authorization"] = self._token_info.token
158
+ else:
159
+ headers["authorization"] = ""
160
+
161
+ try:
162
+ return await self._session.request(
163
+ method,
164
+ url,
165
+ data=body_bytes if body_bytes else None,
166
+ headers=headers,
167
+ )
168
+ except aiohttp.ClientError as err:
169
+ raise NepConnectionError(f"Connection error: {err}") from err
170
+ except TimeoutError as err:
171
+ raise NepTimeoutError(f"Request timed out: {err}") from err
@@ -0,0 +1,270 @@
1
+ """Command-line interface for aionepviewer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import json
8
+ import os
9
+ import sys
10
+ import tomllib
11
+ from getpass import getpass
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import aiohttp
16
+ import tomli_w
17
+ from platformdirs import user_config_path
18
+
19
+ from ..const import DEFAULT_HOST
20
+ from ..exceptions import NepAuthError, NepError
21
+ from ..nepviewer import NepViewer
22
+
23
+ APP_NAME = "aionepviewer"
24
+
25
+
26
+ # ── configuration ──────────────────────────────────────────────────────
27
+
28
+
29
+ def config_path() -> Path:
30
+ """Return the path to the user config file."""
31
+ return user_config_path(APP_NAME, appauthor=False, roaming=True) / "config.toml"
32
+
33
+
34
+ def load_config() -> dict[str, Any]:
35
+ """Load the user config file. Returns empty dict if it doesn't exist."""
36
+ path = config_path()
37
+ if not path.exists():
38
+ return {}
39
+ with open(path, "rb") as f:
40
+ return tomllib.load(f)
41
+
42
+
43
+ def save_config(cfg: dict[str, Any]) -> Path:
44
+ """Write *cfg* to the user config file. Returns the path written."""
45
+ path = config_path()
46
+ path.parent.mkdir(parents=True, exist_ok=True)
47
+ with open(path, "wb") as f:
48
+ tomli_w.dump(cfg, f)
49
+ return path
50
+
51
+
52
+ # ── credential resolution ─────────────────────────────────────────────
53
+ # Precedence: CLI flags > env vars > config file > interactive prompt
54
+
55
+
56
+ def _resolve_credentials(args: argparse.Namespace) -> tuple[str, str, str]:
57
+ """Resolve host, email, and password using the full precedence chain.
58
+
59
+ Returns ``(host, email, password)``. Falls back to interactive prompts
60
+ for any value that cannot be determined from flags / env / config.
61
+ """
62
+ cfg = load_config()
63
+
64
+ # Host: CLI flag (non-default) > env > config > default
65
+ if args.host != DEFAULT_HOST:
66
+ host = str(args.host)
67
+ else:
68
+ host = (
69
+ os.environ.get("AIONEPVIEWER_HOST", "")
70
+ or str(cfg.get("host", ""))
71
+ or DEFAULT_HOST
72
+ )
73
+
74
+ email = (
75
+ args.email
76
+ or os.environ.get("AIONEPVIEWER_EMAIL", "")
77
+ or str(cfg.get("email", ""))
78
+ )
79
+ password = (
80
+ args.password
81
+ or os.environ.get("AIONEPVIEWER_PASSWORD", "")
82
+ or str(cfg.get("password", ""))
83
+ )
84
+
85
+ if not host:
86
+ host = input(f"API host [{DEFAULT_HOST}]: ").strip() or DEFAULT_HOST
87
+ if not email:
88
+ email = input("Email: ").strip()
89
+ if not password:
90
+ password = getpass("Password: ")
91
+ return host, email, password
92
+
93
+
94
+ def dump_json(obj: Any) -> str:
95
+ """Serialize a model (or list of models) to indented JSON via raw_data."""
96
+ if hasattr(obj, "raw_data"):
97
+ return json.dumps(obj.raw_data, indent=2, default=str)
98
+ if isinstance(obj, list):
99
+ items = [o.raw_data if hasattr(o, "raw_data") else o for o in obj]
100
+ return json.dumps(items, indent=2, default=str)
101
+ return json.dumps(obj, indent=2, default=str)
102
+
103
+
104
+ async def resolve_sid(client: NepViewer, sid: str | None) -> str:
105
+ """If *sid* is None, auto-discover the first site."""
106
+ if sid:
107
+ return sid
108
+ sites = await client.get_sites()
109
+ if not sites:
110
+ print("No sites found on this account.", file=sys.stderr)
111
+ sys.exit(1)
112
+ return sites[0].sid
113
+
114
+
115
+ async def resolve_sn(client: NepViewer, sn: str | None) -> str:
116
+ """If *sn* is None, auto-discover the first device serial number."""
117
+ if sn:
118
+ return sn
119
+ devices = await client.get_devices()
120
+ if not devices:
121
+ print("No devices found on this account.", file=sys.stderr)
122
+ sys.exit(1)
123
+ return devices[0].sn
124
+
125
+
126
+ # ── parser construction ───────────────────────────────────────────────
127
+
128
+
129
+ def _build_parser() -> argparse.ArgumentParser:
130
+ from .api import register_api_commands
131
+ from .chart import register_chart_commands
132
+ from .dashboard import register_dashboard_commands
133
+
134
+ parser = argparse.ArgumentParser(
135
+ prog="aionepviewer",
136
+ description="CLI for the NEP solar inverter cloud API (nepviewer.net)",
137
+ )
138
+ parser.add_argument(
139
+ "--email", "-e", help="NEP account email (overrides config/env)"
140
+ )
141
+ parser.add_argument(
142
+ "--password", "-p", help="NEP account password (overrides config/env)"
143
+ )
144
+ parser.add_argument(
145
+ "--host", default=DEFAULT_HOST, help=f"API host (default: {DEFAULT_HOST})"
146
+ )
147
+
148
+ sub = parser.add_subparsers(dest="command", required=True)
149
+
150
+ # configure
151
+ p = sub.add_parser(
152
+ "configure",
153
+ help="Set up credentials and save to config file",
154
+ )
155
+ p.set_defaults(func=_cmd_configure, needs_client=False)
156
+
157
+ # config-path
158
+ p = sub.add_parser(
159
+ "config-path",
160
+ help="Print the config file path",
161
+ )
162
+ p.set_defaults(func=_cmd_config_path, needs_client=False)
163
+
164
+ # Register command groups
165
+ register_api_commands(sub)
166
+ register_chart_commands(sub)
167
+ register_dashboard_commands(sub)
168
+
169
+ return parser
170
+
171
+
172
+ # ── configure commands ─────────────────────────────────────────────────
173
+
174
+
175
+ async def _cmd_configure(_client: Any, _args: argparse.Namespace) -> None:
176
+ """Interactive config setup with credential verification."""
177
+ from rich.console import Console
178
+
179
+ console = Console()
180
+ cfg = load_config()
181
+
182
+ console.print("\n[bold]NEP Viewer CLI Configuration[/bold]\n")
183
+ console.print(f" Config file: [cyan]{config_path()}[/cyan]\n")
184
+
185
+ current_host = str(cfg.get("host", DEFAULT_HOST))
186
+ current_email = str(cfg.get("email", ""))
187
+
188
+ host = input(f"API host [{current_host}]: ").strip() or current_host
189
+
190
+ prompt_email = f"Email [{current_email}]: " if current_email else "Email: "
191
+ email = input(prompt_email).strip() or current_email
192
+
193
+ password = getpass("Password (input hidden): ")
194
+ if not password and cfg.get("password"):
195
+ password = str(cfg["password"])
196
+ console.print(" [dim](kept existing password)[/dim]")
197
+
198
+ if not email or not password:
199
+ console.print("\n [red]Email and password are required.[/red]\n")
200
+ sys.exit(1)
201
+
202
+ # Verify credentials before saving
203
+ console.print("\n Verifying credentials...", end="")
204
+ async with aiohttp.ClientSession() as session:
205
+ client = NepViewer(session, email, password, host)
206
+ try:
207
+ auth = await client.authenticate()
208
+ except NepError as exc:
209
+ console.print(f" [red]FAILED[/red]\n {exc}\n")
210
+ sys.exit(1)
211
+ console.print(f" [green]OK[/green] (logged in as {auth.user_info.email})")
212
+
213
+ cfg["host"] = host
214
+ cfg["email"] = email
215
+ cfg["password"] = password
216
+
217
+ path = save_config(cfg)
218
+ console.print(f"\n [green]Configuration saved to {path}[/green]\n")
219
+
220
+
221
+ async def _cmd_config_path(_client: Any, _args: argparse.Namespace) -> None:
222
+ """Print config file location."""
223
+ path = config_path()
224
+ exists = path.exists()
225
+ print(str(path))
226
+ if not exists:
227
+ print(
228
+ "(file does not exist yet; run 'aionepviewer configure' to create it)",
229
+ file=sys.stderr,
230
+ )
231
+
232
+
233
+ # ── main entry ─────────────────────────────────────────────────────────
234
+
235
+
236
+ async def _async_main(args: argparse.Namespace) -> None:
237
+ # Commands that don't need a client connection
238
+ if getattr(args, "needs_client", True) is False:
239
+ await args.func(None, args)
240
+ return
241
+
242
+ host, email, password = _resolve_credentials(args)
243
+ async with aiohttp.ClientSession() as session:
244
+ client = NepViewer(session, email, password, host)
245
+ await args.func(client, args)
246
+
247
+
248
+ def main() -> None:
249
+ # Ensure UTF-8 output on Windows (needed for termgraph block chars and rich)
250
+ if hasattr(sys.stdout, "reconfigure"):
251
+ sys.stdout.reconfigure(encoding="utf-8")
252
+ if hasattr(sys.stderr, "reconfigure"):
253
+ sys.stderr.reconfigure(encoding="utf-8")
254
+
255
+ parser = _build_parser()
256
+ args = parser.parse_args()
257
+ try:
258
+ asyncio.run(_async_main(args))
259
+ except NepAuthError as exc:
260
+ print(f"Authentication failed: {exc}", file=sys.stderr)
261
+ sys.exit(1)
262
+ except NepError as exc:
263
+ print(f"API error: {exc}", file=sys.stderr)
264
+ sys.exit(1)
265
+ except KeyboardInterrupt:
266
+ sys.exit(130)
267
+
268
+
269
+ if __name__ == "__main__":
270
+ main()