python-aidot-cameras 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.
aidot/const.py ADDED
@@ -0,0 +1,241 @@
1
+ from enum import StrEnum, IntEnum
2
+
3
+ SUPPORTED_COUNTRYS = [
4
+ {"_id": "1-0", "id": "AL", "name": "Albania", "ext": "", "region": "EU"},
5
+ {
6
+ "_id": "1-1",
7
+ "id": "AG",
8
+ "name": "Antigua and Barbuda",
9
+ "ext": "",
10
+ "region": "US",
11
+ },
12
+ {"_id": "1-2", "id": "AR", "name": "Argentina", "ext": "", "region": "US"},
13
+ {"_id": "1-3", "id": "AT", "name": "Austria", "ext": "", "region": "EU"},
14
+ {"_id": "1-4", "id": "AI", "name": "Anguilla", "ext": "", "region": "US"},
15
+ {"_id": "1-5", "id": "AF", "name": "Afghanistan", "ext": "", "region": "JP"},
16
+ {"_id": "1-6", "id": "AM", "name": "Armenia", "ext": "", "region": "JP"},
17
+ {"_id": "1-7", "id": "AZ", "name": "Azerbaijan", "ext": "", "region": "JP"},
18
+ {"_id": "1-8", "id": "AU", "name": "Australia", "ext": "", "region": "JP"},
19
+ {"_id": "2-0", "id": "BO", "name": "Bolivia", "ext": "", "region": "US"},
20
+ {"_id": "2-1", "id": "BR", "name": "Brazil", "ext": "", "region": "US"},
21
+ {"_id": "2-2", "id": "BG", "name": "Bulgaria", "ext": "", "region": "EU"},
22
+ {"_id": "2-3", "id": "BS", "name": "Bahamas", "ext": "", "region": "US"},
23
+ {"_id": "2-4", "id": "BE", "name": "Belgium", "ext": "", "region": "EU"},
24
+ {"_id": "2-5", "id": "BZ", "name": "Belize", "ext": "", "region": "US"},
25
+ {"_id": "2-6", "id": "BB", "name": "Barbados", "ext": "", "region": "US"},
26
+ {"_id": "2-7", "id": "BM", "name": "Bermuda", "ext": "", "region": "US"},
27
+ {"_id": "2-8", "id": "BN", "name": "Brunei", "ext": "", "region": "JP"},
28
+ {"_id": "2-9", "id": "BH", "name": "Bahrain", "ext": "", "region": "JP"},
29
+ {"_id": "2-10", "id": "BD", "name": "Bangladesh", "ext": "", "region": "JP"},
30
+ {"_id": "2-11", "id": "BT", "name": "Bhutan", "ext": "", "region": "JP"},
31
+ {"_id": "3-0", "id": "CA", "name": "Canada", "ext": "", "region": "US"},
32
+ {"_id": "3-1", "id": "CL", "name": "Chile", "ext": "", "region": "US"},
33
+ {"_id": "3-2", "id": "CO", "name": "Colombia", "ext": "", "region": "US"},
34
+ {"_id": "3-3", "id": "CR", "name": "Costa Rica", "ext": "", "region": "US"},
35
+ {"_id": "3-4", "id": "CU", "name": "Cuba", "ext": "", "region": "US"},
36
+ {"_id": "3-5", "id": "CZ", "name": "Czech Republic", "ext": "", "region": "EU"},
37
+ {"_id": "3-6", "id": "HR", "name": "Croatia", "ext": "", "region": "EU"},
38
+ {"_id": "3-7", "id": "KY", "name": "Cayman Islands", "ext": "", "region": "US"},
39
+ {"_id": "3-8", "id": "KH", "name": "Cambodia", "ext": "", "region": "JP"},
40
+ {"_id": "3-9", "id": "CY", "name": "Cyprus", "ext": "", "region": "JP"},
41
+ {"_id": "4-0", "id": "DK", "name": "Denmark", "ext": "", "region": "EU"},
42
+ {"_id": "4-1", "id": "DO", "name": "Dominican Republic", "ext": "", "region": "US"},
43
+ {"_id": "5-0", "id": "EC", "name": "Ecuador", "ext": "", "region": "US"},
44
+ {"_id": "5-1", "id": "SV", "name": "El Salvador", "ext": "", "region": "US"},
45
+ {"_id": "5-2", "id": "EE", "name": "Estonia", "ext": "", "region": "EU"},
46
+ {"_id": "6-0", "id": "FI", "name": "Finland", "ext": "", "region": "EU"},
47
+ {"_id": "6-1", "id": "FR", "name": "France", "ext": "", "region": "EU"},
48
+ {"_id": "6-2", "id": "GF", "name": "French Guiana", "ext": "", "region": "US"},
49
+ {"_id": "7-0", "id": "GR", "name": "Greece", "ext": "", "region": "EU"},
50
+ {"_id": "7-1", "id": "GT", "name": "Guatemala", "ext": "", "region": "US"},
51
+ {"_id": "7-2", "id": "GY", "name": "Guyana", "ext": "", "region": "US"},
52
+ {"_id": "7-3", "id": "DE", "name": "Germany", "ext": "", "region": "EU"},
53
+ {"_id": "7-4", "id": "GD", "name": "Grenada", "ext": "", "region": "US"},
54
+ {"_id": "7-5", "id": "GE", "name": "Georgia", "ext": "", "region": "JP"},
55
+ {"_id": "8-0", "id": "HT", "name": "Haiti", "ext": "", "region": "US"},
56
+ {"_id": "8-1", "id": "HN", "name": "Honduras", "ext": "", "region": "US"},
57
+ {"_id": "8-2", "id": "HU", "name": "Hungary", "ext": "", "region": "EU"},
58
+ {"_id": "9-0", "id": "IS", "name": "Iceland", "ext": "", "region": "EU"},
59
+ {"_id": "9-1", "id": "IE", "name": "Ireland", "ext": "", "region": "EU"},
60
+ {"_id": "9-2", "id": "IT", "name": "Italy", "ext": "", "region": "EU"},
61
+ {"_id": "9-3", "id": "IN", "name": "India", "ext": "", "region": "JP"},
62
+ {"_id": "9-4", "id": "ID", "name": "Indonesia", "ext": "", "region": "JP"},
63
+ {"_id": "9-5", "id": "IR", "name": "Iran", "ext": "", "region": "JP"},
64
+ {"_id": "9-6", "id": "IQ", "name": "Iraq", "ext": "", "region": "JP"},
65
+ {"_id": "9-7", "id": "IL", "name": "Israel", "ext": "", "region": "EU"},
66
+ {"_id": "10-0", "id": "JM", "name": "Jamaica", "ext": "", "region": "US"},
67
+ {"_id": "10-1", "id": "JP", "name": "Japan", "ext": "", "region": "JP"},
68
+ {"_id": "10-2", "id": "JO", "name": "Jordan", "ext": "", "region": "JP"},
69
+ {"_id": "11-0", "id": "KZ", "name": "Kazakhstan", "ext": "", "region": "JP"},
70
+ {"_id": "11-1", "id": "KR", "name": "Korea", "ext": "", "region": "JP"},
71
+ {"_id": "11-2", "id": "KW", "name": "Kuwait", "ext": "", "region": "JP"},
72
+ {"_id": "11-3", "id": "KG", "name": "Kyrgyzstan", "ext": "", "region": "JP"},
73
+ {"_id": "12-0", "id": "LV", "name": "Latvia", "ext": "", "region": "EU"},
74
+ {"_id": "12-1", "id": "LT", "name": "Lithuania", "ext": "", "region": "EU"},
75
+ {"_id": "12-2", "id": "LU", "name": "Luxembourg", "ext": "", "region": "EU"},
76
+ {"_id": "12-3", "id": "LI", "name": "Liechtenstein", "ext": "", "region": "EU"},
77
+ {"_id": "12-4", "id": "LA", "name": "Laos", "ext": "", "region": "JP"},
78
+ {"_id": "12-5", "id": "LB", "name": "Lebanon", "ext": "", "region": "JP"},
79
+ {"_id": "13-0", "id": "MT", "name": "Malta", "ext": "", "region": "EU"},
80
+ {"_id": "13-1", "id": "MX", "name": "Mexico", "ext": "", "region": "US"},
81
+ {"_id": "13-2", "id": "MD", "name": "Moldova", "ext": "", "region": "EU"},
82
+ {"_id": "13-3", "id": "MC", "name": "Monaco", "ext": "", "region": "EU"},
83
+ {"_id": "13-4", "id": "MS", "name": "Montserrat", "ext": "", "region": "US"},
84
+ {"_id": "13-5", "id": "ME", "name": "Montenegro", "ext": "", "region": "EU"},
85
+ {"_id": "13-6", "id": "MY", "name": "Malaysia", "ext": "", "region": "JP"},
86
+ {"_id": "13-7", "id": "MV", "name": "Maldives", "ext": "", "region": "JP"},
87
+ {"_id": "13-8", "id": "MN", "name": "Mongolia", "ext": "", "region": "JP"},
88
+ {"_id": "13-9", "id": "MM", "name": "Myanmar", "ext": "", "region": "JP"},
89
+ {"_id": "14-0", "id": "NL", "name": "Netherlands", "ext": "", "region": "EU"},
90
+ {"_id": "14-1", "id": "NI", "name": "Nicaragua", "ext": "", "region": "US"},
91
+ {"_id": "14-2", "id": "NO", "name": "Norway", "ext": "", "region": "EU"},
92
+ {"_id": "14-3", "id": "MK", "name": "North Macedonia", "ext": "", "region": "EU"},
93
+ {"_id": "14-4", "id": "NZ", "name": "New Zealand", "ext": "", "region": "JP"},
94
+ {"_id": "14-5", "id": "NP", "name": "Nepal", "ext": "", "region": "JP"},
95
+ {"_id": "14-6", "id": "KP", "name": "North Korea", "ext": "", "region": "JP"},
96
+ {"_id": "15-0", "id": "OM", "name": "Oman", "ext": "", "region": "JP"},
97
+ {"_id": "16-0", "id": "PA", "name": "Panama", "ext": "", "region": "US"},
98
+ {"_id": "16-1", "id": "PY", "name": "Paraguay", "ext": "", "region": "US"},
99
+ {"_id": "16-2", "id": "PE", "name": "Peru", "ext": "", "region": "US"},
100
+ {"_id": "16-3", "id": "PT", "name": "Portugal", "ext": "", "region": "EU"},
101
+ {"_id": "16-4", "id": "PK", "name": "Pakistan", "ext": "", "region": "JP"},
102
+ {"_id": "16-5", "id": "PS", "name": "Palestine", "ext": "", "region": "JP"},
103
+ {"_id": "16-6", "id": "PH", "name": "Philippines", "ext": "", "region": "JP"},
104
+ {"_id": "17-0", "id": "QA", "name": "Qatar", "ext": "", "region": "JP"},
105
+ {"_id": "18-0", "id": "RO", "name": "Romania", "ext": "", "region": "EU"},
106
+ {"_id": "18-1", "id": "RU", "name": "Russia", "ext": "", "region": "EU"},
107
+ {"_id": "19-0", "id": "SM", "name": "San Marino", "ext": "", "region": "EU"},
108
+ {"_id": "19-1", "id": "SI", "name": "Slovenia", "ext": "", "region": "EU"},
109
+ {"_id": "19-2", "id": "ES", "name": "Spain", "ext": "", "region": "EU"},
110
+ {"_id": "19-3", "id": "LC", "name": "St.Lucia", "ext": "", "region": "US"},
111
+ {"_id": "19-4", "id": "SR", "name": "Suriname", "ext": "", "region": "US"},
112
+ {"_id": "19-5", "id": "SE", "name": "Sweden", "ext": "", "region": "EU"},
113
+ {"_id": "19-6", "id": "SK", "name": "Slovakia", "ext": "", "region": "EU"},
114
+ {"_id": "19-7", "id": "RS", "name": "Serbia", "ext": "", "region": "EU"},
115
+ {
116
+ "_id": "19-8",
117
+ "id": "KN",
118
+ "name": "St.Kitts and Nevis",
119
+ "ext": "",
120
+ "region": "US",
121
+ },
122
+ {
123
+ "_id": "19-9",
124
+ "id": "VC",
125
+ "name": "St.Vincent and the Grenadines",
126
+ "ext": "",
127
+ "region": "US",
128
+ },
129
+ {"_id": "19-10", "id": "SA", "name": "Saudi Arabia", "ext": "", "region": "JP"},
130
+ {"_id": "19-11", "id": "SG", "name": "Singapore", "ext": "", "region": "JP"},
131
+ {"_id": "19-12", "id": "LK", "name": "Sri Lanka", "ext": "", "region": "JP"},
132
+ {"_id": "19-13", "id": "SY", "name": "Syria", "ext": "", "region": "JP"},
133
+ {"_id": "19-14", "id": "CH", "name": "Switzerland", "ext": "", "region": "EU"},
134
+ {
135
+ "_id": "20-0",
136
+ "id": "TT",
137
+ "name": "Trinidad and Tobago",
138
+ "ext": "",
139
+ "region": "US",
140
+ },
141
+ {"_id": "20-1", "id": "TC", "name": "Turks & Caicos", "ext": "", "region": "US"},
142
+ {"_id": "20-2", "id": "TJ", "name": "Tajikistan", "ext": "", "region": "JP"},
143
+ {"_id": "20-3", "id": "TH", "name": "Thailand", "ext": "", "region": "JP"},
144
+ {"_id": "20-4", "id": "TG", "name": "Togo", "ext": "", "region": "JP"},
145
+ {"_id": "20-5", "id": "TR", "name": "Turkey", "ext": "", "region": "EU"},
146
+ {"_id": "20-6", "id": "TM", "name": "Turkmenistan", "ext": "", "region": "JP"},
147
+ {"_id": "21-0", "id": "UA", "name": "Ukraine", "ext": "", "region": "EU"},
148
+ {"_id": "21-1", "id": "GB", "name": "United Kingdom", "ext": "", "region": "EU"},
149
+ {"_id": "21-2", "id": "US", "name": "United States", "ext": "", "region": "US"},
150
+ {"_id": "21-3", "id": "UY", "name": "Uruguay", "ext": "", "region": "US"},
151
+ {
152
+ "_id": "21-4",
153
+ "id": "AE",
154
+ "name": "United Arab Emirates",
155
+ "ext": "",
156
+ "region": "JP",
157
+ },
158
+ {"_id": "21-5", "id": "UZ", "name": "Uzbekistan", "ext": "", "region": "JP"},
159
+ {"_id": "22-0", "id": "VE", "name": "Venezuela", "ext": "", "region": "US"},
160
+ {"_id": "22-1", "id": "VG", "name": "Virgin Islands", "ext": "", "region": "US"},
161
+ {"_id": "22-2", "id": "VN", "name": "Vietnam", "ext": "", "region": "JP"},
162
+ {"_id": "23-0", "id": "YE", "name": "Yemen", "ext": "", "region": "JP"},
163
+ ]
164
+ SUPPORTED_COUNTRY_CODES = [item["id"] for item in SUPPORTED_COUNTRYS]
165
+ SUPPORTED_COUNTRY_NAMES = [item["name"] for item in SUPPORTED_COUNTRYS]
166
+ DEFAULT_COUNTRY_NAME = "United States"
167
+ DEFAULT_COUNTRY_CODE = "US"
168
+ CONF_APP_ID = "Appid"
169
+ CONF_TERMINAL = "Terminal"
170
+ CONF_LOGIN_RESPONSE = "login_response"
171
+ CONF_LOGIN_INFO = "login_info"
172
+ CONF_SELECTED_HOUSE = "selected_house"
173
+ CONF_DEVICE_LIST = "device_list"
174
+ CONF_PRODUCT_LIST = "product_list"
175
+ CONF_ACCESS_TOKEN = "accessToken"
176
+ CONF_REFRESH_TOKEN = "refreshToken"
177
+ CONF_TOKEN = "Token"
178
+ CONF_USERNAME = "username"
179
+ CONF_PASSWORD = "password"
180
+ CONF_REGION = "region"
181
+ CONF_COUNTRY = "country"
182
+ CONF_ID = "id"
183
+ CONF_NAME = "name"
184
+ CONF_PRODUCT_ID = "productId"
185
+ CONF_PRODUCT = "product"
186
+ CONF_IS_DEFAULT = "isDefault"
187
+ CONF_TYPE = "type"
188
+ CONF_MODEL_ID = "modelId"
189
+ CONF_MAC = "mac"
190
+ CONF_AES_KEY = "aesKey"
191
+ CONF_HARDWARE_VERSION = "hardwareVersion"
192
+ CONF_SERVICE_MODULES = "serviceModules"
193
+ CONF_IDENTITY = "identity"
194
+ CONF_PROPERTIES = "properties"
195
+ CONF_MINVALUE = "minValue"
196
+ CONF_MAXVALUE = "maxValue"
197
+ CONF_IPADDRESS = "ipAddress"
198
+ CONF_CODE = "code"
199
+ CONF_PAYLOAD = "payload"
200
+ CONF_ASCNUMBER = "ascNumber"
201
+ CONF_ATTR = "attr"
202
+ CONF_ON_OFF = "OnOff"
203
+ CONF_DIMMING = "Dimming"
204
+ CONF_RGBW = "RGBW"
205
+ CONF_CCT = "CCT"
206
+ CONF_ACK = "ack"
207
+ CONF_IS_OWNER = "isOwner"
208
+
209
+
210
+ class Identity(StrEnum):
211
+ """Available entity identity."""
212
+
213
+ RGBW = "control.light.rgbw"
214
+ CCT = "control.light.cct"
215
+
216
+
217
+ class Attribute(StrEnum):
218
+ """Available entity attributes."""
219
+
220
+ RGBW = "rgbw"
221
+ CCT = "cct"
222
+
223
+
224
+ class ServerErrorCode(IntEnum):
225
+ TOKEN_EXPIRED = 21026
226
+ LOGIN_INVALID = 21025
227
+ USER_PWD_INCORRECT = 560080
228
+
229
+
230
+ # ── Cloud API credentials ──────────────────────────────────────────────────── #
231
+
232
+ APP_ID = "1383974540041977857"
233
+ BASE_URL = "https://prod-us-api.arnoo.com/v17"
234
+
235
+ PUBLIC_KEY_PEM = b"""-----BEGIN PUBLIC KEY-----
236
+ MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCtQAnPCi8ksPnS1Du6z96PsKfN
237
+ p2Gp/f/bHwlrAdplbX3p7/TnGpnbJGkLq8uRxf6cw+vOthTsZjkPCF7CatRvRnTj
238
+ c9fcy7yE0oXa5TloYyXD6GkxgftBbN/movkJJGQCc7gFavuYoAdTRBOyQoXBtm0m
239
+ kXMSjXOldI/290b9BQIDAQAB
240
+ -----END PUBLIC KEY-----
241
+ """
aidot/credentials.py ADDED
@@ -0,0 +1,158 @@
1
+ """Credential storage for AiDot CLI tools.
2
+
3
+ Priority order:
4
+ 1. Environment variables: AIDOT_USERNAME, AIDOT_PASSWORD, AIDOT_COUNTRY
5
+ Works in Docker containers, Home Assistant addons, CI systems.
6
+ 2. Fernet-encrypted file pair at ~/.config/aidot/:
7
+ credentials.enc - ciphertext (0600)
8
+ .key - random symmetric key (0600)
9
+ Cross-platform, no OS-specific APIs required.
10
+ 3. Plain JSON file at ~/.config/aidot/credentials.json (0600)
11
+ Legacy fallback; auto-migrated to encrypted on first load.
12
+ """
13
+
14
+ import json
15
+ import os
16
+ import stat
17
+
18
+ _CONFIG_DIR = os.path.expanduser("~/.config/aidot")
19
+ _DEFAULT_CREDS_FILE = os.path.join(_CONFIG_DIR, "credentials.json")
20
+ _ENC_FILE = os.path.join(_CONFIG_DIR, "credentials.enc")
21
+ _KEY_FILE = os.path.join(_CONFIG_DIR, ".key")
22
+
23
+
24
+ # ── Public API ───────────────────────────────────────────────────────────────
25
+
26
+ def load_credentials(creds_path: str | None = None) -> dict:
27
+ """Return {"username": ..., "password": ..., "country": ...}.
28
+
29
+ Checks environment variables first, then encrypted file, then plain JSON.
30
+ Raises FileNotFoundError / ValueError if nothing is found.
31
+ """
32
+ # Priority 1: environment variables
33
+ env_user = os.environ.get("AIDOT_USERNAME")
34
+ env_pass = os.environ.get("AIDOT_PASSWORD")
35
+ if env_user and env_pass:
36
+ return {
37
+ "username": env_user,
38
+ "password": env_pass,
39
+ "country": os.environ.get("AIDOT_COUNTRY", "US"),
40
+ }
41
+
42
+ # Priority 2: Fernet-encrypted file
43
+ enc_path = (creds_path + ".enc") if creds_path else _ENC_FILE
44
+ key_path = (creds_path + ".key") if creds_path else _KEY_FILE
45
+ if os.path.exists(enc_path) and os.path.exists(key_path):
46
+ try:
47
+ return _load_encrypted(enc_path, key_path)
48
+ except Exception as exc:
49
+ raise ValueError(f"Failed to decrypt {enc_path}: {exc}") from exc
50
+
51
+ # Priority 3: plain JSON (legacy)
52
+ plain = creds_path or _DEFAULT_CREDS_FILE
53
+ if os.path.exists(plain):
54
+ data = _load_plain(plain)
55
+ # Auto-migrate to encrypted storage
56
+ try:
57
+ _save_encrypted(data["username"], data["password"],
58
+ data.get("country", "US"), enc_path, key_path)
59
+ os.unlink(plain)
60
+ except Exception:
61
+ pass
62
+ return data
63
+
64
+ raise FileNotFoundError(
65
+ "No credentials found. Options:\n"
66
+ " • Set AIDOT_USERNAME / AIDOT_PASSWORD / AIDOT_COUNTRY env vars\n"
67
+ " • Run with --save-credentials to store them encrypted\n"
68
+ f" • Place credentials.json in {_CONFIG_DIR}"
69
+ )
70
+
71
+
72
+ def save_credentials(
73
+ username: str,
74
+ password: str,
75
+ country: str = "US",
76
+ creds_path: str | None = None,
77
+ ) -> str:
78
+ """Encrypt and store credentials. Returns the path where they were saved."""
79
+ enc_path = (creds_path + ".enc") if creds_path else _ENC_FILE
80
+ key_path = (creds_path + ".key") if creds_path else _KEY_FILE
81
+ _save_encrypted(username, password, country, enc_path, key_path)
82
+ return enc_path
83
+
84
+
85
+ def delete_credentials(creds_path: str | None = None) -> None:
86
+ """Remove stored credentials (encrypted and/or plain JSON)."""
87
+ enc_path = (creds_path + ".enc") if creds_path else _ENC_FILE
88
+ key_path = (creds_path + ".key") if creds_path else _KEY_FILE
89
+ plain = creds_path or _DEFAULT_CREDS_FILE
90
+ for path in (enc_path, key_path, plain):
91
+ if os.path.exists(path):
92
+ try:
93
+ os.unlink(path)
94
+ except Exception:
95
+ pass
96
+
97
+
98
+ # ── Internal helpers ─────────────────────────────────────────────────────────
99
+
100
+ def _fernet():
101
+ try:
102
+ from cryptography.fernet import Fernet
103
+ return Fernet
104
+ except ImportError as exc:
105
+ raise ImportError(
106
+ "The 'cryptography' package is required for encrypted credentials. "
107
+ "Install it with: pip install cryptography"
108
+ ) from exc
109
+
110
+
111
+ def _load_encrypted(enc_path: str, key_path: str) -> dict:
112
+ Fernet = _fernet()
113
+ with open(key_path, "rb") as f:
114
+ key = f.read().strip()
115
+ with open(enc_path, "rb") as f:
116
+ token = f.read().strip()
117
+ plaintext = Fernet(key).decrypt(token)
118
+ data = json.loads(plaintext)
119
+ for field in ("username", "password"):
120
+ if field not in data:
121
+ raise ValueError(f"Encrypted credentials missing field {field!r}")
122
+ return data
123
+
124
+
125
+ def _save_encrypted(
126
+ username: str, password: str, country: str,
127
+ enc_path: str, key_path: str,
128
+ ) -> None:
129
+ Fernet = _fernet()
130
+ # Generate or reuse key - _write_secret handles makedirs
131
+ if os.path.exists(key_path):
132
+ with open(key_path, "rb") as f:
133
+ key = f.read().strip()
134
+ else:
135
+ key = Fernet.generate_key()
136
+ _write_secret(key_path, key)
137
+
138
+ payload = json.dumps({"username": username, "password": password,
139
+ "country": country}).encode()
140
+ _write_secret(enc_path, Fernet(key).encrypt(payload))
141
+
142
+
143
+ def _load_plain(path: str) -> dict:
144
+ with open(path) as f:
145
+ data = json.load(f)
146
+ for field in ("username", "password"):
147
+ if field not in data:
148
+ raise ValueError(f"Credentials file {path!r} missing {field!r}")
149
+ return data
150
+
151
+
152
+ def _write_secret(path: str, data: bytes) -> None:
153
+ """Write binary data to path with 0600 permissions."""
154
+ os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
155
+ with open(path, "wb") as f:
156
+ f.write(data)
157
+ f.write(b"\n")
158
+ os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)