quilt-hp-python 0.1.1__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.
- quilt_hp/__init__.py +22 -0
- quilt_hp/_paths.py +26 -0
- quilt_hp/_proto/__init__.py +0 -0
- quilt_hp/_proto/quilt_device_pairing_pb2.py +56 -0
- quilt_hp/_proto/quilt_device_pairing_pb2.pyi +317 -0
- quilt_hp/_proto/quilt_device_pairing_pb2_grpc.py +24 -0
- quilt_hp/_proto/quilt_hds_pb2.py +292 -0
- quilt_hp/_proto/quilt_hds_pb2.pyi +3947 -0
- quilt_hp/_proto/quilt_hds_pb2_grpc.py +1732 -0
- quilt_hp/_proto/quilt_notifier_pb2.py +55 -0
- quilt_hp/_proto/quilt_notifier_pb2.pyi +258 -0
- quilt_hp/_proto/quilt_notifier_pb2_grpc.py +97 -0
- quilt_hp/_proto/quilt_services_pb2.py +171 -0
- quilt_hp/_proto/quilt_services_pb2.pyi +1320 -0
- quilt_hp/_proto/quilt_services_pb2_grpc.py +1188 -0
- quilt_hp/_proto/quilt_system_pb2.py +53 -0
- quilt_hp/_proto/quilt_system_pb2.pyi +164 -0
- quilt_hp/_proto/quilt_system_pb2_grpc.py +270 -0
- quilt_hp/auth.py +244 -0
- quilt_hp/cli/__init__.py +1 -0
- quilt_hp/cli/main.py +770 -0
- quilt_hp/cli/settings.py +123 -0
- quilt_hp/cli/store.py +105 -0
- quilt_hp/cli/tui.py +2677 -0
- quilt_hp/client.py +616 -0
- quilt_hp/const.py +57 -0
- quilt_hp/exceptions.py +23 -0
- quilt_hp/models/__init__.py +85 -0
- quilt_hp/models/comfort.py +47 -0
- quilt_hp/models/controller.py +135 -0
- quilt_hp/models/energy.py +31 -0
- quilt_hp/models/enums.py +298 -0
- quilt_hp/models/indoor_unit.py +412 -0
- quilt_hp/models/outdoor_unit.py +71 -0
- quilt_hp/models/qsm.py +105 -0
- quilt_hp/models/schedule.py +98 -0
- quilt_hp/models/sensor.py +92 -0
- quilt_hp/models/software_update.py +74 -0
- quilt_hp/models/space.py +177 -0
- quilt_hp/models/system.py +451 -0
- quilt_hp/py.typed +1 -0
- quilt_hp/services/__init__.py +1 -0
- quilt_hp/services/hds.py +480 -0
- quilt_hp/services/streaming.py +561 -0
- quilt_hp/services/system.py +95 -0
- quilt_hp/services/user.py +143 -0
- quilt_hp/tokens.py +119 -0
- quilt_hp/transport.py +192 -0
- quilt_hp_python-0.1.1.dist-info/METADATA +172 -0
- quilt_hp_python-0.1.1.dist-info/RECORD +53 -0
- quilt_hp_python-0.1.1.dist-info/WHEEL +4 -0
- quilt_hp_python-0.1.1.dist-info/entry_points.txt +2 -0
- quilt_hp_python-0.1.1.dist-info/licenses/LICENSE +21 -0
quilt_hp/cli/settings.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Settings persistence for non-secret CLI/TUI preferences."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import asdict, dataclass
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from uuid import uuid4
|
|
11
|
+
|
|
12
|
+
from quilt_hp._paths import app_config_dir
|
|
13
|
+
|
|
14
|
+
_SETTINGS_SCHEMA_VERSION = 1
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(slots=True)
|
|
18
|
+
class Settings:
|
|
19
|
+
"""Non-secret user preferences shared by CLI and TUI."""
|
|
20
|
+
|
|
21
|
+
email: str | None = None
|
|
22
|
+
home: str | None = None
|
|
23
|
+
use_fahrenheit: bool = False
|
|
24
|
+
dark: bool | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SettingsStore:
|
|
28
|
+
"""Platform-aware settings storage with migration and recovery."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, path: Path | None = None) -> None:
|
|
31
|
+
self._path = path
|
|
32
|
+
|
|
33
|
+
def _settings_path(self) -> Path:
|
|
34
|
+
if self._path is not None:
|
|
35
|
+
return self._path
|
|
36
|
+
return app_config_dir() / "settings.json"
|
|
37
|
+
|
|
38
|
+
def load(self) -> Settings:
|
|
39
|
+
"""Load settings, migrating legacy files and recovering corruption."""
|
|
40
|
+
path = self._settings_path()
|
|
41
|
+
try:
|
|
42
|
+
payload = json.loads(path.read_text())
|
|
43
|
+
except FileNotFoundError:
|
|
44
|
+
return Settings()
|
|
45
|
+
except Exception:
|
|
46
|
+
return self._recover_corruption("invalid-json")
|
|
47
|
+
|
|
48
|
+
if not isinstance(payload, dict):
|
|
49
|
+
return self._recover_corruption("invalid-shape")
|
|
50
|
+
|
|
51
|
+
schema_version = payload.get("schema_version")
|
|
52
|
+
if schema_version is None:
|
|
53
|
+
settings = self._from_legacy(payload)
|
|
54
|
+
self.save(settings)
|
|
55
|
+
return settings
|
|
56
|
+
|
|
57
|
+
if schema_version != _SETTINGS_SCHEMA_VERSION:
|
|
58
|
+
return self._recover_corruption("unsupported-schema")
|
|
59
|
+
|
|
60
|
+
prefs = payload.get("preferences")
|
|
61
|
+
if not isinstance(prefs, dict):
|
|
62
|
+
return self._recover_corruption("invalid-preferences")
|
|
63
|
+
return self._coerce(prefs)
|
|
64
|
+
|
|
65
|
+
def save(self, settings: Settings) -> None:
|
|
66
|
+
"""Persist settings using versioned schema and atomic replace."""
|
|
67
|
+
payload = {
|
|
68
|
+
"schema_version": _SETTINGS_SCHEMA_VERSION,
|
|
69
|
+
"preferences": asdict(settings),
|
|
70
|
+
}
|
|
71
|
+
self._atomic_write(payload)
|
|
72
|
+
|
|
73
|
+
def update(
|
|
74
|
+
self,
|
|
75
|
+
*,
|
|
76
|
+
email: str | None = None,
|
|
77
|
+
home: str | None = None,
|
|
78
|
+
use_fahrenheit: bool | None = None,
|
|
79
|
+
dark: bool | None = None,
|
|
80
|
+
) -> Settings:
|
|
81
|
+
"""Update selected settings fields and persist."""
|
|
82
|
+
settings = self.load()
|
|
83
|
+
if email is not None:
|
|
84
|
+
settings.email = email
|
|
85
|
+
if home is not None:
|
|
86
|
+
settings.home = home
|
|
87
|
+
if use_fahrenheit is not None:
|
|
88
|
+
settings.use_fahrenheit = use_fahrenheit
|
|
89
|
+
if dark is not None:
|
|
90
|
+
settings.dark = dark
|
|
91
|
+
self.save(settings)
|
|
92
|
+
return settings
|
|
93
|
+
|
|
94
|
+
def _from_legacy(self, payload: dict[str, object]) -> Settings:
|
|
95
|
+
return self._coerce(payload)
|
|
96
|
+
|
|
97
|
+
def _coerce(self, payload: dict[str, object]) -> Settings:
|
|
98
|
+
email = payload.get("email")
|
|
99
|
+
home = payload.get("home")
|
|
100
|
+
dark = payload.get("dark")
|
|
101
|
+
return Settings(
|
|
102
|
+
email=email if isinstance(email, str) else None,
|
|
103
|
+
home=home if isinstance(home, str) else None,
|
|
104
|
+
use_fahrenheit=bool(payload.get("use_fahrenheit", False)),
|
|
105
|
+
dark=dark if isinstance(dark, bool) else None,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def _atomic_write(self, payload: dict[str, object]) -> None:
|
|
109
|
+
path = self._settings_path()
|
|
110
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
tmp = path.with_name(f"{path.name}.{os.getpid()}.{uuid4().hex}.tmp")
|
|
112
|
+
tmp.write_text(json.dumps(payload, indent=2))
|
|
113
|
+
os.replace(tmp, path)
|
|
114
|
+
|
|
115
|
+
def _recover_corruption(self, reason: str) -> Settings:
|
|
116
|
+
path = self._settings_path()
|
|
117
|
+
settings = Settings()
|
|
118
|
+
if path.exists():
|
|
119
|
+
timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
|
120
|
+
backup = path.with_name(f"{path.name}.corrupt-{timestamp}-{reason}")
|
|
121
|
+
path.replace(backup)
|
|
122
|
+
self.save(settings)
|
|
123
|
+
return settings
|
quilt_hp/cli/store.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Token persistence store for CLI/TUI authentication state.
|
|
2
|
+
|
|
3
|
+
Token persistence remains intentionally separate from general user
|
|
4
|
+
preferences. ``FileStore`` implements the core ``TokenStore`` protocol
|
|
5
|
+
and can be passed directly to ``QuiltClient(token_store=store)``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from dataclasses import asdict
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from quilt_hp._paths import app_config_dir
|
|
17
|
+
from quilt_hp.exceptions import QuiltAuthError
|
|
18
|
+
from quilt_hp.tokens import CachedTokens
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FileStore:
|
|
22
|
+
"""Filesystem-backed token persistence."""
|
|
23
|
+
|
|
24
|
+
# ------------------------------------------------------------------ tokens
|
|
25
|
+
|
|
26
|
+
def _token_path(self) -> Path:
|
|
27
|
+
return app_config_dir() / "tokens.json"
|
|
28
|
+
|
|
29
|
+
async def load(self, email: str) -> CachedTokens | None:
|
|
30
|
+
"""TokenStore.load — return cached tokens for *email* or None."""
|
|
31
|
+
return await asyncio.to_thread(self._load_sync, email)
|
|
32
|
+
|
|
33
|
+
def _load_sync(self, email: str) -> CachedTokens | None:
|
|
34
|
+
try:
|
|
35
|
+
data = json.loads(self._token_path().read_text())
|
|
36
|
+
except FileNotFoundError:
|
|
37
|
+
return None
|
|
38
|
+
except json.JSONDecodeError as exc:
|
|
39
|
+
raise QuiltAuthError("Token store contains invalid JSON.") from exc
|
|
40
|
+
except OSError as exc:
|
|
41
|
+
raise QuiltAuthError("Failed to read token store.") from exc
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
entry = data[email]
|
|
45
|
+
return CachedTokens(
|
|
46
|
+
id_token=entry["id_token"],
|
|
47
|
+
refresh_token=entry["refresh_token"],
|
|
48
|
+
expires_at=entry["expires_at"],
|
|
49
|
+
)
|
|
50
|
+
except KeyError:
|
|
51
|
+
return None
|
|
52
|
+
except (TypeError, ValueError) as exc:
|
|
53
|
+
raise QuiltAuthError(f"Malformed token entry for {email!r}.") from exc
|
|
54
|
+
|
|
55
|
+
async def save(self, email: str, tokens: CachedTokens) -> None:
|
|
56
|
+
"""TokenStore.save — persist tokens for *email*."""
|
|
57
|
+
await asyncio.to_thread(self._save_sync, email, tokens)
|
|
58
|
+
|
|
59
|
+
def _save_sync(self, email: str, tokens: CachedTokens) -> None:
|
|
60
|
+
path = self._token_path()
|
|
61
|
+
try:
|
|
62
|
+
data = json.loads(path.read_text())
|
|
63
|
+
except FileNotFoundError:
|
|
64
|
+
data = {}
|
|
65
|
+
except json.JSONDecodeError as exc:
|
|
66
|
+
raise QuiltAuthError("Token store contains invalid JSON.") from exc
|
|
67
|
+
except OSError as exc:
|
|
68
|
+
raise QuiltAuthError("Failed to read token store.") from exc
|
|
69
|
+
data[email] = asdict(tokens)
|
|
70
|
+
try:
|
|
71
|
+
path.write_text(json.dumps(data, indent=2))
|
|
72
|
+
os.chmod(path, 0o600)
|
|
73
|
+
except OSError as exc:
|
|
74
|
+
raise QuiltAuthError("Failed to persist token store.") from exc
|
|
75
|
+
|
|
76
|
+
def clear_tokens(self, email: str) -> None:
|
|
77
|
+
"""Remove cached tokens for *email*."""
|
|
78
|
+
path = self._token_path()
|
|
79
|
+
try:
|
|
80
|
+
data = json.loads(path.read_text())
|
|
81
|
+
except FileNotFoundError:
|
|
82
|
+
return
|
|
83
|
+
except json.JSONDecodeError as exc:
|
|
84
|
+
raise QuiltAuthError("Token store contains invalid JSON.") from exc
|
|
85
|
+
except OSError as exc:
|
|
86
|
+
raise QuiltAuthError("Failed to read token store.") from exc
|
|
87
|
+
|
|
88
|
+
data.pop(email, None)
|
|
89
|
+
try:
|
|
90
|
+
path.write_text(json.dumps(data, indent=2))
|
|
91
|
+
os.chmod(path, 0o600)
|
|
92
|
+
except OSError as exc:
|
|
93
|
+
raise QuiltAuthError("Failed to persist token store.") from exc
|
|
94
|
+
|
|
95
|
+
def list_emails(self) -> list[str]:
|
|
96
|
+
"""All email addresses that have cached tokens."""
|
|
97
|
+
try:
|
|
98
|
+
data = json.loads(self._token_path().read_text())
|
|
99
|
+
except FileNotFoundError:
|
|
100
|
+
return []
|
|
101
|
+
except json.JSONDecodeError as exc:
|
|
102
|
+
raise QuiltAuthError("Token store contains invalid JSON.") from exc
|
|
103
|
+
except OSError as exc:
|
|
104
|
+
raise QuiltAuthError("Failed to read token store.") from exc
|
|
105
|
+
return [k for k in data if isinstance(k, str)]
|