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.
- aionepviewer/__init__.py +115 -0
- aionepviewer/auth.py +171 -0
- aionepviewer/cli/__init__.py +270 -0
- aionepviewer/cli/api.py +276 -0
- aionepviewer/cli/chart.py +255 -0
- aionepviewer/cli/dashboard.py +360 -0
- aionepviewer/const.py +18 -0
- aionepviewer/exceptions.py +28 -0
- aionepviewer/models/__init__.py +83 -0
- aionepviewer/models/alert.py +39 -0
- aionepviewer/models/auth.py +201 -0
- aionepviewer/models/chart.py +92 -0
- aionepviewer/models/device.py +447 -0
- aionepviewer/models/energy.py +185 -0
- aionepviewer/models/module.py +146 -0
- aionepviewer/models/overview.py +38 -0
- aionepviewer/models/playback.py +52 -0
- aionepviewer/models/product.py +63 -0
- aionepviewer/models/report.py +44 -0
- aionepviewer/models/site.py +451 -0
- aionepviewer/models/weather.py +59 -0
- aionepviewer/nepviewer.py +615 -0
- aionepviewer/py.typed +0 -0
- aionepviewer-0.1.0.dist-info/METADATA +366 -0
- aionepviewer-0.1.0.dist-info/RECORD +28 -0
- aionepviewer-0.1.0.dist-info/WHEEL +4 -0
- aionepviewer-0.1.0.dist-info/entry_points.txt +2 -0
- aionepviewer-0.1.0.dist-info/licenses/LICENSE +21 -0
aionepviewer/__init__.py
ADDED
|
@@ -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()
|