ha-voice-label-sync 0.2.0.dev0__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.
- ha_voice_label_sync-0.2.0.dev0.dist-info/METADATA +246 -0
- ha_voice_label_sync-0.2.0.dev0.dist-info/RECORD +16 -0
- ha_voice_label_sync-0.2.0.dev0.dist-info/WHEEL +5 -0
- ha_voice_label_sync-0.2.0.dev0.dist-info/entry_points.txt +2 -0
- ha_voice_label_sync-0.2.0.dev0.dist-info/licenses/LICENSE +21 -0
- ha_voice_label_sync-0.2.0.dev0.dist-info/top_level.txt +1 -0
- hvls/__init__.py +45 -0
- hvls/backends/__init__.py +8 -0
- hvls/backends/google.py +87 -0
- hvls/cli.py +170 -0
- hvls/engine.py +178 -0
- hvls/models.py +90 -0
- hvls/orchestrator.py +92 -0
- hvls/registry.py +91 -0
- hvls/writers/__init__.py +17 -0
- hvls/writers/filesystem.py +183 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ha-voice-label-sync
|
|
3
|
+
Version: 0.2.0.dev0
|
|
4
|
+
Summary: Generate voice assistant configuration from Home Assistant labels.
|
|
5
|
+
Author: Philippe Le Bonhomme
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/flimo44/ha-voice-label-sync
|
|
8
|
+
Project-URL: Documentation, https://github.com/flimo44/ha-voice-label-sync#readme
|
|
9
|
+
Project-URL: Issues, https://github.com/flimo44/ha-voice-label-sync/issues
|
|
10
|
+
Project-URL: Source, https://github.com/flimo44/ha-voice-label-sync
|
|
11
|
+
Keywords: home-assistant,google-assistant,voice-assistant,automation,yaml
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Home Automation
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
<img width="1024" height="559" alt="image" src="https://github.com/user-attachments/assets/6918c5ae-b95b-4d81-9108-7b2cd8acd0fd" />
|
|
23
|
+
|
|
24
|
+
### Automatically generate your voice assistant configuration from Home Assistant labels.
|
|
25
|
+
|
|
26
|
+
[](https://github.com/flimo44/ha-voice-label-sync/actions/workflows/python-check.yml)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
> 🚧 Early development project
|
|
30
|
+
>
|
|
31
|
+
> Feedback and ideas are welcome.
|
|
32
|
+
|
|
33
|
+
## Why?
|
|
34
|
+
|
|
35
|
+
Do you maintain dozens of Google Assistant entities manually?
|
|
36
|
+
|
|
37
|
+
Managing voice assistant configurations manually quickly becomes difficult.
|
|
38
|
+
|
|
39
|
+
HA Voice Label Sync automatically generates the required configuration from Home Assistant labels, making your labels the single source of truth.
|
|
40
|
+
|
|
41
|
+
With the current release you can:
|
|
42
|
+
|
|
43
|
+
Stop maintaining large YAML files manually.
|
|
44
|
+
Avoid duplicated configuration.
|
|
45
|
+
Keep Home Assistant as the only place where entities are managed.
|
|
46
|
+
Generate your configuration in seconds.
|
|
47
|
+
|
|
48
|
+
## Current Status
|
|
49
|
+
|
|
50
|
+
## Project status
|
|
51
|
+
|
|
52
|
+
HVLS is currently under active development.
|
|
53
|
+
|
|
54
|
+
Latest stable release: `v0.1.2`
|
|
55
|
+
|
|
56
|
+
Current development version: `0.2.0.dev0`
|
|
57
|
+
|
|
58
|
+
Current capabilities:
|
|
59
|
+
|
|
60
|
+
- Standalone CLI
|
|
61
|
+
- Reusable Python core
|
|
62
|
+
- Google Assistant YAML generation
|
|
63
|
+
- Dry-run support
|
|
64
|
+
- Safe atomic file writing
|
|
65
|
+
- Automatic backups and retention
|
|
66
|
+
- Initial Home Assistant integration skeleton
|
|
67
|
+
|
|
68
|
+
Not yet available:
|
|
69
|
+
|
|
70
|
+
- Full Home Assistant configuration UI
|
|
71
|
+
- Home Assistant actions and buttons
|
|
72
|
+
- Backup restoration from the UI
|
|
73
|
+
- HACS installation
|
|
74
|
+
|
|
75
|
+
HA Voice Label Sync has been designed to support multiple voice assistants.
|
|
76
|
+
|
|
77
|
+
- Currently supported :
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
✅ Google Assistant
|
|
81
|
+
|
|
82
|
+
- Planned :
|
|
83
|
+
|
|
84
|
+
Amazon Alexa
|
|
85
|
+
Apple HomeKit
|
|
86
|
+
|
|
87
|
+
Instead of maintaining a long list of exposed entities manually, simply assign a label in Home Assistant, run the script, and let HA Voice Label Sync generate the configuration for you.
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
## Features
|
|
91
|
+
|
|
92
|
+
- Select entities using a Home Assistant label
|
|
93
|
+
- Generate `entity_config` for Google Assistant
|
|
94
|
+
- Use Home Assistant friendly names
|
|
95
|
+
- Use Home Assistant areas as Google rooms
|
|
96
|
+
- Ignore disabled or hidden entities
|
|
97
|
+
- Dry-run mode before writing the file
|
|
98
|
+
|
|
99
|
+
## How it works
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
Home Assistant
|
|
104
|
+
│
|
|
105
|
+
▼
|
|
106
|
+
Labels
|
|
107
|
+
│
|
|
108
|
+
▼
|
|
109
|
+
Voice Assistant Sync
|
|
110
|
+
│
|
|
111
|
+
┌──────┴───────┐
|
|
112
|
+
│ │
|
|
113
|
+
Google Assistant Amazon Alexa
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Generated entity_config
|
|
118
|
+
|
|
119
|
+
```yaml
|
|
120
|
+
switch.prise_pompe:
|
|
121
|
+
expose: true
|
|
122
|
+
name: Pompe piscine
|
|
123
|
+
room: Piscine
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
Home Assistant configuration
|
|
128
|
+
|
|
129
|
+
```yaml
|
|
130
|
+
google_assistant:
|
|
131
|
+
project_id: YOUR_PROJECT_ID
|
|
132
|
+
service_account: !include google_key.json
|
|
133
|
+
report_state: true
|
|
134
|
+
expose_by_default: false
|
|
135
|
+
entity_config: !include google_assistant_entities.yaml
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Quick start
|
|
139
|
+
|
|
140
|
+
Installation
|
|
141
|
+
|
|
142
|
+
Option 1 – Download the script
|
|
143
|
+
|
|
144
|
+
Download the latest version of ga_label_sync.py from the repository.
|
|
145
|
+
|
|
146
|
+
Copy it to your Home Assistant scripts directory.
|
|
147
|
+
|
|
148
|
+
Typical locations are:
|
|
149
|
+
|
|
150
|
+
```text
|
|
151
|
+
/config/scripts/
|
|
152
|
+
|
|
153
|
+
or
|
|
154
|
+
|
|
155
|
+
<home-assistant-config>/scripts/
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
depending on your Home Assistant installation.
|
|
159
|
+
|
|
160
|
+
Option 2 – Clone the repository
|
|
161
|
+
```bash
|
|
162
|
+
git clone https://github.com/flimo44/ha-voice-label-sync.git
|
|
163
|
+
```
|
|
164
|
+
The script is located in:
|
|
165
|
+
```text
|
|
166
|
+
scripts/ga_label_sync.py
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
```Tip
|
|
170
|
+
|
|
171
|
+
If you simply want to use the script, downloading scripts/ga_label_sync.py is enough.
|
|
172
|
+
|
|
173
|
+
Cloning the repository is recommended only if you plan to follow development or contribute to the project.
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Usage :
|
|
177
|
+
|
|
178
|
+
### Step 1 — Add the label
|
|
179
|
+
|
|
180
|
+
<img width="455" height="375" alt="Capture d'écran 2026-06-26 201643" src="https://github.com/user-attachments/assets/a262942a-5fb1-4a84-b88e-5db12eb67be8" />
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
### Step 2 — Preview the generated configuration
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
<img width="555" height="172" alt="Capture d'écran 2026-06-26 203136" src="https://github.com/user-attachments/assets/66ea6d63-983b-467b-9870-198d64b027bd" />
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
python3 /config/scripts/ga_label_sync.py --label "google_assistant" --dry-run
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
```
|
|
193
|
+
# --- Couloir ---
|
|
194
|
+
lock.serrure_maison:
|
|
195
|
+
expose: true
|
|
196
|
+
name: Serrure porte entrée
|
|
197
|
+
room: Couloir
|
|
198
|
+
|
|
199
|
+
# --- Jardin ---
|
|
200
|
+
switch.portail:
|
|
201
|
+
expose: true
|
|
202
|
+
name: Portail
|
|
203
|
+
room: Jardin
|
|
204
|
+
|
|
205
|
+
# --- Piscine ---
|
|
206
|
+
input_select.piscine_mode_gestion:
|
|
207
|
+
expose: true
|
|
208
|
+
name: "Piscine - Mode Gestion"
|
|
209
|
+
room: Piscine
|
|
210
|
+
|
|
211
|
+
switch.piscine_chauffage:
|
|
212
|
+
expose: true
|
|
213
|
+
name: Pac Piscine
|
|
214
|
+
room: Piscine
|
|
215
|
+
|
|
216
|
+
switch.prise_pompe2:
|
|
217
|
+
expose: true
|
|
218
|
+
name: Pompe piscine
|
|
219
|
+
room: Piscine
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
### Step 3 — Generate the file
|
|
224
|
+
```bash
|
|
225
|
+
python3 /config/scripts/ga_label_sync.py --label "google_assistant"
|
|
226
|
+
```
|
|
227
|
+
<img width="454" height="446" alt="Capture d'écran 2026-06-26 200340" src="https://github.com/user-attachments/assets/6f0938ae-13e9-4856-9b4f-7db5e8290171" />
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
### Step 4 — Restart Home Assistant
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
### Step 5 — Synchronize Google Home
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
Google Home before synchronization
|
|
238
|
+
|
|
239
|
+
<img width="1080" height="2400" alt="Screenshot_2026-06-26-19-44-33-60_2d2bd67b5e15ae98c151ac739cd6881e" src="https://github.com/user-attachments/assets/a03d1005-fde8-4186-b8ea-da2f426c5a84" />
|
|
240
|
+
|
|
241
|
+
Google Home after synchronization
|
|
242
|
+
|
|
243
|
+
<img width="1080" height="2400" alt="Screenshot_2026-06-26-19-45-48-86_2d2bd67b5e15ae98c151ac739cd6881e" src="https://github.com/user-attachments/assets/5b4d96cc-9da9-4419-9d1f-f5eac4fbec8c" />
|
|
244
|
+
|
|
245
|
+
License
|
|
246
|
+
MIT
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
ha_voice_label_sync-0.2.0.dev0.dist-info/licenses/LICENSE,sha256=ynISXJIpaGnb6casTM1HdJvEB5xdu2vqT9NBVEZKmHI,1064
|
|
2
|
+
hvls/__init__.py,sha256=C3jbWnJlif0RF2f1Rmj0gyno2-POrO2DO759NmJnEUw,1108
|
|
3
|
+
hvls/cli.py,sha256=taIaKg0938Cm1gWNKDGuA-PbUpNILE4kdMYnWOLTDwk,4063
|
|
4
|
+
hvls/engine.py,sha256=kAndRxaYbv1ynl2XoEY1jcdm7qvyevVO9j7dP-C-5Qc,5040
|
|
5
|
+
hvls/models.py,sha256=hxYqYjb1jtGPFvqV3G3xeCb-Gn_c9QPgIrWvAMZnNLY,2416
|
|
6
|
+
hvls/orchestrator.py,sha256=W4RZvJjpiRDFFUev9MmlxwDJebiyyXL4nSUBfdOWoBc,2598
|
|
7
|
+
hvls/registry.py,sha256=zp5NHRVVnDEP7q7vfOWBz0-vDViJDKqUTcfuTt9Ju-8,2422
|
|
8
|
+
hvls/backends/__init__.py,sha256=WOFwd9yzl4wO4T6P-hxawN9hOAkY2duTk5Quit0dgUQ,172
|
|
9
|
+
hvls/backends/google.py,sha256=8CQ5zyIxS-ahMSOxCgSAVQzKrsFSblC_urQmQR-nLBI,2122
|
|
10
|
+
hvls/writers/__init__.py,sha256=X-4Cfb-BYzPG8cQb8z5a3RWLSPmyB28SsxeNnMHQPbw,311
|
|
11
|
+
hvls/writers/filesystem.py,sha256=_aEYY4rqts-A-1Y9rInLBYorjoiVjEBksgaSuD9RzlU,4691
|
|
12
|
+
ha_voice_label_sync-0.2.0.dev0.dist-info/METADATA,sha256=U0hl8nsQSsyNda_nv4vumSBidCwSXvVSOJo9lS-8UBI,6466
|
|
13
|
+
ha_voice_label_sync-0.2.0.dev0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
14
|
+
ha_voice_label_sync-0.2.0.dev0.dist-info/entry_points.txt,sha256=_hB7iAHYgG1zZS_LQhVakxjohB5aer31FQyUEK3DJZA,39
|
|
15
|
+
ha_voice_label_sync-0.2.0.dev0.dist-info/top_level.txt,sha256=i0BQbMR6mHoKNWt8Cpjjf0aA0JDeWw6sxIx-1I8yhjs,5
|
|
16
|
+
ha_voice_label_sync-0.2.0.dev0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 flimo44
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hvls
|
hvls/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Reusable core package for HA Voice Label Sync."""
|
|
2
|
+
|
|
3
|
+
from .backends import render_google_assistant, yaml_escape
|
|
4
|
+
from .engine import resolve_label_ids, select_entities, slugify
|
|
5
|
+
from .models import (
|
|
6
|
+
FileWriteResult,
|
|
7
|
+
GenerationRequest,
|
|
8
|
+
GenerationResult,
|
|
9
|
+
SelectedEntity,
|
|
10
|
+
SelectionResult,
|
|
11
|
+
WorkflowResult,
|
|
12
|
+
)
|
|
13
|
+
from .orchestrator import WorkflowError, run_google_assistant_workflow
|
|
14
|
+
from .registry import RegistryError, get_registry_items, load_json
|
|
15
|
+
from .writers import (
|
|
16
|
+
FileWriterError,
|
|
17
|
+
apply_backup_retention,
|
|
18
|
+
atomic_write,
|
|
19
|
+
create_backup,
|
|
20
|
+
write_with_backup,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"GenerationRequest",
|
|
25
|
+
"GenerationResult",
|
|
26
|
+
"RegistryError",
|
|
27
|
+
"SelectedEntity",
|
|
28
|
+
"SelectionResult",
|
|
29
|
+
"get_registry_items",
|
|
30
|
+
"load_json",
|
|
31
|
+
"resolve_label_ids",
|
|
32
|
+
"select_entities",
|
|
33
|
+
"slugify",
|
|
34
|
+
"render_google_assistant",
|
|
35
|
+
"yaml_escape",
|
|
36
|
+
"FileWriteResult",
|
|
37
|
+
"FileWriterError",
|
|
38
|
+
"apply_backup_retention",
|
|
39
|
+
"atomic_write",
|
|
40
|
+
"create_backup",
|
|
41
|
+
"write_with_backup",
|
|
42
|
+
"WorkflowError",
|
|
43
|
+
"WorkflowResult",
|
|
44
|
+
"run_google_assistant_workflow",
|
|
45
|
+
]
|
hvls/backends/google.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Google Assistant YAML backend for HA Voice Label Sync."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
from ..models import GenerationResult, SelectionResult
|
|
9
|
+
|
|
10
|
+
YAML_RESERVED_WORDS = {
|
|
11
|
+
"y",
|
|
12
|
+
"yes",
|
|
13
|
+
"n",
|
|
14
|
+
"no",
|
|
15
|
+
"true",
|
|
16
|
+
"false",
|
|
17
|
+
"on",
|
|
18
|
+
"off",
|
|
19
|
+
"null",
|
|
20
|
+
"~",
|
|
21
|
+
"none",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
YAML_QUOTING_PATTERN = r'[:#{}\[\],&*?|\-<>=!%@`"\']'
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def yaml_escape(value: object) -> str:
|
|
28
|
+
"""Return a value safe for use as a YAML string scalar."""
|
|
29
|
+
text = str(value).strip()
|
|
30
|
+
|
|
31
|
+
if not text:
|
|
32
|
+
return '""'
|
|
33
|
+
|
|
34
|
+
if text.lower() in YAML_RESERVED_WORDS:
|
|
35
|
+
return json.dumps(text, ensure_ascii=False)
|
|
36
|
+
|
|
37
|
+
if re.fullmatch(r"[+-]?\d+(\.\d+)?", text):
|
|
38
|
+
return json.dumps(text, ensure_ascii=False)
|
|
39
|
+
|
|
40
|
+
if re.search(YAML_QUOTING_PATTERN, text):
|
|
41
|
+
return json.dumps(text, ensure_ascii=False)
|
|
42
|
+
|
|
43
|
+
return text
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def render_google_assistant(
|
|
47
|
+
selection: SelectionResult,
|
|
48
|
+
) -> GenerationResult:
|
|
49
|
+
"""Render selected entities as Google Assistant entity configuration.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
selection: Neutral entities selected by the HVLS engine.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Generated YAML and structured generation metadata.
|
|
56
|
+
"""
|
|
57
|
+
lines = [
|
|
58
|
+
"# Fichier généré automatiquement.",
|
|
59
|
+
"# Ne pas modifier à la main.",
|
|
60
|
+
"# Ajouter/retirer l'étiquette HA pour gérer l'exposition Google Assistant.",
|
|
61
|
+
"",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
current_room: str | None | object = object()
|
|
65
|
+
|
|
66
|
+
for entity in selection.entities:
|
|
67
|
+
if entity.room != current_room:
|
|
68
|
+
current_room = entity.room
|
|
69
|
+
lines.append(f"# --- {entity.room or 'Sans pièce'} ---")
|
|
70
|
+
|
|
71
|
+
lines.append(f"{entity.entity_id}:")
|
|
72
|
+
lines.append(" expose: true")
|
|
73
|
+
lines.append(f" name: {yaml_escape(entity.name)}")
|
|
74
|
+
|
|
75
|
+
if entity.room:
|
|
76
|
+
lines.append(f" room: {yaml_escape(entity.room)}")
|
|
77
|
+
|
|
78
|
+
lines.append("")
|
|
79
|
+
|
|
80
|
+
content = "\n".join(lines).rstrip() + "\n"
|
|
81
|
+
|
|
82
|
+
return GenerationResult(
|
|
83
|
+
content=content,
|
|
84
|
+
entities=selection.entities,
|
|
85
|
+
domain_counts=dict(selection.domain_counts),
|
|
86
|
+
warnings=selection.warnings,
|
|
87
|
+
)
|
hvls/cli.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Command-line interface for HA Voice Label Sync."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import ( # noqa: E402
|
|
11
|
+
FileWriterError,
|
|
12
|
+
GenerationRequest,
|
|
13
|
+
RegistryError,
|
|
14
|
+
WorkflowError,
|
|
15
|
+
run_google_assistant_workflow,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
CONFIG_DIRECTORY = Path("/config")
|
|
19
|
+
STORAGE_DIRECTORY = CONFIG_DIRECTORY / ".storage"
|
|
20
|
+
DEFAULT_OUTPUT_FILE = CONFIG_DIRECTORY / "google_assistant_entities.yaml"
|
|
21
|
+
|
|
22
|
+
DEFAULT_DOMAINS = {
|
|
23
|
+
"light",
|
|
24
|
+
"switch",
|
|
25
|
+
"cover",
|
|
26
|
+
"climate",
|
|
27
|
+
"fan",
|
|
28
|
+
"input_boolean",
|
|
29
|
+
"input_select",
|
|
30
|
+
"scene",
|
|
31
|
+
"script",
|
|
32
|
+
"lock",
|
|
33
|
+
"vacuum",
|
|
34
|
+
"select",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def build_argument_parser() -> argparse.ArgumentParser:
|
|
39
|
+
"""Build and return the command-line argument parser."""
|
|
40
|
+
parser = argparse.ArgumentParser(
|
|
41
|
+
description=(
|
|
42
|
+
"Generate Google Assistant entity configuration from "
|
|
43
|
+
"Home Assistant labels."
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"--label",
|
|
49
|
+
default="google_assistant",
|
|
50
|
+
help="Home Assistant label used to select exposed entities.",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
parser.add_argument(
|
|
54
|
+
"--output",
|
|
55
|
+
type=Path,
|
|
56
|
+
default=DEFAULT_OUTPUT_FILE,
|
|
57
|
+
help="Path of the generated YAML file.",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
parser.add_argument(
|
|
61
|
+
"--storage",
|
|
62
|
+
type=Path,
|
|
63
|
+
default=STORAGE_DIRECTORY,
|
|
64
|
+
help="Path to the Home Assistant .storage directory.",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
parser.add_argument(
|
|
68
|
+
"--domains",
|
|
69
|
+
nargs="*",
|
|
70
|
+
default=sorted(DEFAULT_DOMAINS),
|
|
71
|
+
help="Home Assistant domains to include.",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
parser.add_argument(
|
|
75
|
+
"--backup-directory",
|
|
76
|
+
type=Path,
|
|
77
|
+
default=None,
|
|
78
|
+
help=(
|
|
79
|
+
"Directory used for backups. "
|
|
80
|
+
"Defaults to .hvls_backups beside the output file."
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
parser.add_argument(
|
|
85
|
+
"--backup-retention",
|
|
86
|
+
type=int,
|
|
87
|
+
default=5,
|
|
88
|
+
help="Maximum number of backups to keep.",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
parser.add_argument(
|
|
92
|
+
"--dry-run",
|
|
93
|
+
action="store_true",
|
|
94
|
+
help="Display the generated YAML without writing any file.",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
return parser
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def print_summary(result: object) -> None:
|
|
101
|
+
"""Display a human-readable workflow summary."""
|
|
102
|
+
generation = result.generation
|
|
103
|
+
|
|
104
|
+
print(f"Total: {generation.entity_count} entités")
|
|
105
|
+
|
|
106
|
+
if generation.domain_counts:
|
|
107
|
+
detail = ", ".join(
|
|
108
|
+
f"{domain}: {count}"
|
|
109
|
+
for domain, count in sorted(generation.domain_counts.items())
|
|
110
|
+
)
|
|
111
|
+
print(f"Détail par domaine -> {detail}")
|
|
112
|
+
|
|
113
|
+
for warning in generation.warnings:
|
|
114
|
+
print(f"Attention : {warning}", file=sys.stderr)
|
|
115
|
+
|
|
116
|
+
if result.dry_run:
|
|
117
|
+
print()
|
|
118
|
+
print(generation.content, end="")
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
write_result = result.write_result
|
|
122
|
+
|
|
123
|
+
if write_result is None:
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
print(f"OK: fichier écrit dans {write_result.output_path}")
|
|
127
|
+
print(f"Octets écrits: {write_result.bytes_written}")
|
|
128
|
+
|
|
129
|
+
if write_result.backup_path is not None:
|
|
130
|
+
print(f"Sauvegarde créée: {write_result.backup_path}")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def main() -> int:
|
|
134
|
+
"""Run the HVLS command-line interface."""
|
|
135
|
+
parser = build_argument_parser()
|
|
136
|
+
args = parser.parse_args()
|
|
137
|
+
|
|
138
|
+
if args.backup_retention < 0:
|
|
139
|
+
parser.error("--backup-retention ne peut pas être négatif.")
|
|
140
|
+
|
|
141
|
+
request = GenerationRequest(
|
|
142
|
+
label=args.label,
|
|
143
|
+
domains=frozenset(args.domains),
|
|
144
|
+
output_path=args.output,
|
|
145
|
+
dry_run=args.dry_run,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
result = run_google_assistant_workflow(
|
|
150
|
+
request=request,
|
|
151
|
+
storage_path=args.storage,
|
|
152
|
+
backup_directory=args.backup_directory,
|
|
153
|
+
backup_retention=args.backup_retention,
|
|
154
|
+
)
|
|
155
|
+
except (
|
|
156
|
+
RegistryError,
|
|
157
|
+
FileWriterError,
|
|
158
|
+
WorkflowError,
|
|
159
|
+
OSError,
|
|
160
|
+
ValueError,
|
|
161
|
+
) as exc:
|
|
162
|
+
print(f"Erreur : {exc}", file=sys.stderr)
|
|
163
|
+
return 1
|
|
164
|
+
|
|
165
|
+
print_summary(result)
|
|
166
|
+
return 0
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
if __name__ == "__main__":
|
|
170
|
+
raise SystemExit(main())
|
hvls/engine.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Core entity selection engine for HA Voice Label Sync."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import unicodedata
|
|
7
|
+
from collections.abc import Collection
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .models import GenerationRequest, SelectedEntity, SelectionResult
|
|
11
|
+
from .registry import get_registry_items
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def slugify(value: str) -> str:
|
|
15
|
+
"""Normalize a Home Assistant label for comparison."""
|
|
16
|
+
normalized = unicodedata.normalize("NFKD", value.strip().lower())
|
|
17
|
+
ascii_value = normalized.encode("ascii", "ignore").decode("ascii")
|
|
18
|
+
ascii_value = ascii_value.replace(" ", "_").replace("-", "_")
|
|
19
|
+
ascii_value = re.sub(r"[^a-z0-9_]", "", ascii_value)
|
|
20
|
+
return re.sub(r"_+", "_", ascii_value).strip("_")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def resolve_label_ids(
|
|
24
|
+
labels: Collection[dict[str, Any]],
|
|
25
|
+
label_name: str,
|
|
26
|
+
) -> tuple[frozenset[str], tuple[str, ...]]:
|
|
27
|
+
"""Resolve a label name or normalized label identifier.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
labels: Home Assistant label registry entries.
|
|
31
|
+
label_name: Label name or identifier requested by the user.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Matching label identifiers and any generated warnings.
|
|
35
|
+
"""
|
|
36
|
+
wanted_slug = slugify(label_name)
|
|
37
|
+
identifiers = {label_name, wanted_slug}
|
|
38
|
+
found = False
|
|
39
|
+
|
|
40
|
+
for label in labels:
|
|
41
|
+
name = str(label.get("name") or "")
|
|
42
|
+
label_id = str(label.get("label_id") or "")
|
|
43
|
+
|
|
44
|
+
if (
|
|
45
|
+
label_id == label_name
|
|
46
|
+
or name == label_name
|
|
47
|
+
or slugify(name) == wanted_slug
|
|
48
|
+
):
|
|
49
|
+
if label_id:
|
|
50
|
+
identifiers.add(label_id)
|
|
51
|
+
found = True
|
|
52
|
+
|
|
53
|
+
warnings: tuple[str, ...] = ()
|
|
54
|
+
|
|
55
|
+
if not found:
|
|
56
|
+
warnings = (
|
|
57
|
+
f"No Home Assistant label matches '{label_name}'.",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
return frozenset(identifiers), warnings
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def select_entities(
|
|
64
|
+
request: GenerationRequest,
|
|
65
|
+
entity_registry: dict[str, Any],
|
|
66
|
+
device_registry: dict[str, Any],
|
|
67
|
+
area_registry: dict[str, Any],
|
|
68
|
+
label_registry: dict[str, Any],
|
|
69
|
+
) -> SelectionResult:
|
|
70
|
+
"""Select Home Assistant entities matching a generation request.
|
|
71
|
+
|
|
72
|
+
This function performs no file writing and produces no backend-specific
|
|
73
|
+
configuration.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
request: Selection and generation parameters.
|
|
77
|
+
entity_registry: Parsed Home Assistant entity registry.
|
|
78
|
+
device_registry: Parsed Home Assistant device registry.
|
|
79
|
+
area_registry: Parsed Home Assistant area registry.
|
|
80
|
+
label_registry: Parsed Home Assistant label registry.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
A structured selection result.
|
|
84
|
+
"""
|
|
85
|
+
entity_entries = get_registry_items(entity_registry, "entities")
|
|
86
|
+
device_entries = get_registry_items(device_registry, "devices")
|
|
87
|
+
area_entries = get_registry_items(area_registry, "areas")
|
|
88
|
+
label_entries = get_registry_items(label_registry, "labels")
|
|
89
|
+
|
|
90
|
+
label_ids, warnings = resolve_label_ids(
|
|
91
|
+
label_entries,
|
|
92
|
+
request.label,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
devices = {
|
|
96
|
+
str(device["id"]): device
|
|
97
|
+
for device in device_entries
|
|
98
|
+
if device.get("id")
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
areas: dict[str, str] = {}
|
|
102
|
+
|
|
103
|
+
for area in area_entries:
|
|
104
|
+
area_id = area.get("area_id") or area.get("id")
|
|
105
|
+
|
|
106
|
+
if not area_id:
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
area_id = str(area_id)
|
|
110
|
+
areas[area_id] = str(area.get("name") or area_id)
|
|
111
|
+
|
|
112
|
+
selected: list[SelectedEntity] = []
|
|
113
|
+
domain_counts: dict[str, int] = {}
|
|
114
|
+
|
|
115
|
+
for entity in entity_entries:
|
|
116
|
+
entity_id = entity.get("entity_id")
|
|
117
|
+
|
|
118
|
+
if not isinstance(entity_id, str) or "." not in entity_id:
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
domain, object_id = entity_id.split(".", 1)
|
|
122
|
+
|
|
123
|
+
if domain not in request.domains:
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
if entity.get("disabled_by") or entity.get("hidden_by"):
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
if entity.get("entity_category") in {"diagnostic", "config"}:
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
entity_labels = set(entity.get("labels") or [])
|
|
133
|
+
|
|
134
|
+
if not entity_labels.intersection(label_ids):
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
device_id = entity.get("device_id")
|
|
138
|
+
device = devices.get(str(device_id)) if device_id else None
|
|
139
|
+
|
|
140
|
+
name = (
|
|
141
|
+
entity.get("name")
|
|
142
|
+
or entity.get("original_name")
|
|
143
|
+
or object_id.replace("_", " ").title()
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
area_id = entity.get("area_id") or (device or {}).get("area_id")
|
|
147
|
+
room = areas.get(str(area_id)) if area_id else None
|
|
148
|
+
|
|
149
|
+
selected.append(
|
|
150
|
+
SelectedEntity(
|
|
151
|
+
entity_id=entity_id,
|
|
152
|
+
domain=domain,
|
|
153
|
+
name=str(name),
|
|
154
|
+
room=room,
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
domain_counts[domain] = domain_counts.get(domain, 0) + 1
|
|
159
|
+
|
|
160
|
+
selected.sort(
|
|
161
|
+
key=lambda item: (
|
|
162
|
+
item.room or "",
|
|
163
|
+
item.domain,
|
|
164
|
+
item.name,
|
|
165
|
+
item.entity_id,
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if not selected:
|
|
170
|
+
warnings += (
|
|
171
|
+
"No entity matched the requested label and domains.",
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
return SelectionResult(
|
|
175
|
+
entities=tuple(selected),
|
|
176
|
+
domain_counts=domain_counts,
|
|
177
|
+
warnings=warnings,
|
|
178
|
+
)
|
hvls/models.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Internal data models used by the HVLS core engine."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class SelectedEntity:
|
|
10
|
+
"""Home Assistant entity selected for a voice assistant backend."""
|
|
11
|
+
|
|
12
|
+
entity_id: str
|
|
13
|
+
domain: str
|
|
14
|
+
name: str
|
|
15
|
+
room: str | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class GenerationRequest:
|
|
20
|
+
"""Input parameters required to generate a backend configuration."""
|
|
21
|
+
|
|
22
|
+
label: str
|
|
23
|
+
domains: frozenset[str]
|
|
24
|
+
output_path: Path
|
|
25
|
+
dry_run: bool = False
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class SelectionResult:
|
|
29
|
+
"""Structured result returned by the entity selection engine."""
|
|
30
|
+
|
|
31
|
+
entities: tuple[SelectedEntity, ...]
|
|
32
|
+
domain_counts: dict[str, int] = field(default_factory=dict)
|
|
33
|
+
warnings: tuple[str, ...] = ()
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def entity_count(self) -> int:
|
|
37
|
+
"""Return the number of selected entities."""
|
|
38
|
+
return len(self.entities)
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def success(self) -> bool:
|
|
42
|
+
"""Return whether at least one entity was selected."""
|
|
43
|
+
return self.entity_count > 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class GenerationResult:
|
|
48
|
+
"""Structured result returned by the HVLS generation engine."""
|
|
49
|
+
|
|
50
|
+
content: str
|
|
51
|
+
entities: tuple[SelectedEntity, ...]
|
|
52
|
+
domain_counts: dict[str, int] = field(default_factory=dict)
|
|
53
|
+
warnings: tuple[str, ...] = ()
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def entity_count(self) -> int:
|
|
57
|
+
"""Return the number of selected entities."""
|
|
58
|
+
return len(self.entities)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def success(self) -> bool:
|
|
62
|
+
"""Return whether generation produced at least one entity."""
|
|
63
|
+
return self.entity_count > 0
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class FileWriteResult:
|
|
67
|
+
"""Structured result returned after writing generated configuration."""
|
|
68
|
+
|
|
69
|
+
output_path: Path
|
|
70
|
+
backup_path: Path | None
|
|
71
|
+
bytes_written: int
|
|
72
|
+
written_at: datetime
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class WorkflowResult:
|
|
76
|
+
"""Structured result returned by an HVLS workflow execution."""
|
|
77
|
+
|
|
78
|
+
generation: GenerationResult
|
|
79
|
+
write_result: FileWriteResult | None
|
|
80
|
+
dry_run: bool
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def entity_count(self) -> int:
|
|
84
|
+
"""Return the number of selected entities."""
|
|
85
|
+
return self.generation.entity_count
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def written(self) -> bool:
|
|
89
|
+
"""Return whether generated content was written to disk."""
|
|
90
|
+
return self.write_result is not None
|
hvls/orchestrator.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Workflow orchestration for HA Voice Label Sync."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .backends import render_google_assistant
|
|
8
|
+
from .engine import select_entities
|
|
9
|
+
from .models import GenerationRequest, WorkflowResult
|
|
10
|
+
from .registry import load_json
|
|
11
|
+
from .writers import write_with_backup
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class WorkflowError(Exception):
|
|
15
|
+
"""Raised when an HVLS workflow cannot be completed safely."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run_google_assistant_workflow(
|
|
19
|
+
request: GenerationRequest,
|
|
20
|
+
storage_path: Path,
|
|
21
|
+
backup_directory: Path | None = None,
|
|
22
|
+
backup_retention: int = 5,
|
|
23
|
+
) -> WorkflowResult:
|
|
24
|
+
"""Run the complete Google Assistant generation workflow.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
request: Generation parameters.
|
|
28
|
+
storage_path: Home Assistant ``.storage`` directory.
|
|
29
|
+
backup_directory: Optional directory used for backups.
|
|
30
|
+
backup_retention: Maximum number of backups to retain.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Structured workflow result.
|
|
34
|
+
|
|
35
|
+
Raises:
|
|
36
|
+
WorkflowError: If writing is requested but no entity matches.
|
|
37
|
+
"""
|
|
38
|
+
entity_registry = load_json(
|
|
39
|
+
storage_path / "core.entity_registry"
|
|
40
|
+
)
|
|
41
|
+
device_registry = load_json(
|
|
42
|
+
storage_path / "core.device_registry"
|
|
43
|
+
)
|
|
44
|
+
area_registry = load_json(
|
|
45
|
+
storage_path / "core.area_registry"
|
|
46
|
+
)
|
|
47
|
+
label_registry = load_json(
|
|
48
|
+
storage_path / "core.label_registry"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
selection = select_entities(
|
|
52
|
+
request=request,
|
|
53
|
+
entity_registry=entity_registry,
|
|
54
|
+
device_registry=device_registry,
|
|
55
|
+
area_registry=area_registry,
|
|
56
|
+
label_registry=label_registry,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
generation = render_google_assistant(selection)
|
|
60
|
+
|
|
61
|
+
if request.dry_run:
|
|
62
|
+
return WorkflowResult(
|
|
63
|
+
generation=generation,
|
|
64
|
+
write_result=None,
|
|
65
|
+
dry_run=True,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Safety rule: an incorrect label must never silently replace an existing
|
|
69
|
+
# configuration with an empty generated file.
|
|
70
|
+
if generation.entity_count == 0:
|
|
71
|
+
raise WorkflowError(
|
|
72
|
+
"Generation returned no entity; output file was not modified."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
resolved_backup_directory = (
|
|
76
|
+
backup_directory
|
|
77
|
+
if backup_directory is not None
|
|
78
|
+
else request.output_path.parent / ".hvls_backups"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
write_result = write_with_backup(
|
|
82
|
+
output_path=request.output_path,
|
|
83
|
+
content=generation.content,
|
|
84
|
+
backup_directory=resolved_backup_directory,
|
|
85
|
+
retention=backup_retention,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
return WorkflowResult(
|
|
89
|
+
generation=generation,
|
|
90
|
+
write_result=write_result,
|
|
91
|
+
dry_run=False,
|
|
92
|
+
)
|
hvls/registry.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Home Assistant registry reading utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RegistryError(Exception):
|
|
11
|
+
"""Raised when a Home Assistant registry cannot be read."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_json(path: Path) -> dict[str, Any]:
|
|
15
|
+
"""Load and parse a Home Assistant JSON registry.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
path: Path to the registry file.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
Parsed registry content.
|
|
22
|
+
|
|
23
|
+
Raises:
|
|
24
|
+
RegistryError: If the file is missing, unreadable or contains
|
|
25
|
+
invalid JSON.
|
|
26
|
+
"""
|
|
27
|
+
if not path.exists():
|
|
28
|
+
raise RegistryError(f"Registry file not found: {path}")
|
|
29
|
+
|
|
30
|
+
if not path.is_file():
|
|
31
|
+
raise RegistryError(f"Registry path is not a file: {path}")
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
with path.open("r", encoding="utf-8") as file:
|
|
35
|
+
data = json.load(file)
|
|
36
|
+
except json.JSONDecodeError as exc:
|
|
37
|
+
raise RegistryError(
|
|
38
|
+
f"Registry contains invalid JSON: {path} ({exc})"
|
|
39
|
+
) from exc
|
|
40
|
+
except (OSError, UnicodeError) as exc:
|
|
41
|
+
raise RegistryError(
|
|
42
|
+
f"Unable to read registry: {path} ({exc})"
|
|
43
|
+
) from exc
|
|
44
|
+
|
|
45
|
+
if not isinstance(data, dict):
|
|
46
|
+
raise RegistryError(
|
|
47
|
+
f"Registry root must be a JSON object: {path}"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
return data
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_registry_items(
|
|
54
|
+
registry: dict[str, Any],
|
|
55
|
+
collection_name: str,
|
|
56
|
+
) -> list[dict[str, Any]]:
|
|
57
|
+
"""Return a named collection from a Home Assistant registry.
|
|
58
|
+
|
|
59
|
+
Home Assistant registry collections are normally stored under:
|
|
60
|
+
|
|
61
|
+
data -> collection_name
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
registry: Parsed Home Assistant registry.
|
|
65
|
+
collection_name: Name of the requested collection.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Registry entries represented as dictionaries.
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
RegistryError: If the collection exists but is not a list, or if
|
|
72
|
+
an entry is not a JSON object.
|
|
73
|
+
"""
|
|
74
|
+
data = registry.get("data", {})
|
|
75
|
+
|
|
76
|
+
if not isinstance(data, dict):
|
|
77
|
+
raise RegistryError("Registry 'data' field must be an object")
|
|
78
|
+
|
|
79
|
+
items = data.get(collection_name, [])
|
|
80
|
+
|
|
81
|
+
if not isinstance(items, list):
|
|
82
|
+
raise RegistryError(
|
|
83
|
+
f"Registry collection '{collection_name}' must be a list"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if not all(isinstance(item, dict) for item in items):
|
|
87
|
+
raise RegistryError(
|
|
88
|
+
f"Registry collection '{collection_name}' contains invalid entries"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
return items
|
hvls/writers/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Output writers provided by HVLS."""
|
|
2
|
+
|
|
3
|
+
from .filesystem import (
|
|
4
|
+
FileWriterError,
|
|
5
|
+
apply_backup_retention,
|
|
6
|
+
atomic_write,
|
|
7
|
+
create_backup,
|
|
8
|
+
write_with_backup,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"FileWriterError",
|
|
13
|
+
"apply_backup_retention",
|
|
14
|
+
"atomic_write",
|
|
15
|
+
"create_backup",
|
|
16
|
+
"write_with_backup",
|
|
17
|
+
]
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Safe filesystem writer for generated HVLS configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import tempfile
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from ..models import FileWriteResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FileWriterError(Exception):
|
|
15
|
+
"""Raised when generated configuration cannot be written safely."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def create_backup(
|
|
19
|
+
output_path: Path,
|
|
20
|
+
backup_directory: Path,
|
|
21
|
+
) -> Path | None:
|
|
22
|
+
"""Create a timestamped backup of an existing output file.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
output_path: Generated configuration file to back up.
|
|
26
|
+
backup_directory: Directory where backups are stored.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Path of the created backup, or ``None`` if the output file does
|
|
30
|
+
not exist yet.
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
FileWriterError: If the backup cannot be created.
|
|
34
|
+
"""
|
|
35
|
+
if not output_path.exists():
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
if not output_path.is_file():
|
|
39
|
+
raise FileWriterError(
|
|
40
|
+
f"Output path is not a regular file: {output_path}"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
44
|
+
backup_directory.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
|
|
46
|
+
backup_path = backup_directory / (
|
|
47
|
+
f"{output_path.name}.{timestamp}.bak"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
shutil.copy2(output_path, backup_path)
|
|
52
|
+
except OSError as exc:
|
|
53
|
+
raise FileWriterError(
|
|
54
|
+
f"Unable to create backup {backup_path}: {exc}"
|
|
55
|
+
) from exc
|
|
56
|
+
|
|
57
|
+
return backup_path
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def apply_backup_retention(
|
|
61
|
+
output_path: Path,
|
|
62
|
+
backup_directory: Path,
|
|
63
|
+
retention: int,
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Keep only the newest backups for an output file.
|
|
66
|
+
|
|
67
|
+
A retention value of zero removes all matching backups.
|
|
68
|
+
"""
|
|
69
|
+
if retention < 0:
|
|
70
|
+
raise ValueError("Backup retention cannot be negative")
|
|
71
|
+
|
|
72
|
+
if not backup_directory.exists():
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
pattern = f"{output_path.name}.*.bak"
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
backups = sorted(
|
|
79
|
+
backup_directory.glob(pattern),
|
|
80
|
+
key=lambda path: path.stat().st_mtime,
|
|
81
|
+
reverse=True,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
for backup in backups[retention:]:
|
|
85
|
+
backup.unlink()
|
|
86
|
+
except OSError as exc:
|
|
87
|
+
raise FileWriterError(
|
|
88
|
+
f"Unable to apply backup retention: {exc}"
|
|
89
|
+
) from exc
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def atomic_write(
|
|
93
|
+
output_path: Path,
|
|
94
|
+
content: str,
|
|
95
|
+
) -> int:
|
|
96
|
+
"""Write text using a temporary file and atomic replacement.
|
|
97
|
+
|
|
98
|
+
The temporary file is created in the destination directory so that
|
|
99
|
+
the final ``os.replace`` operation remains atomic.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
output_path: Destination file.
|
|
103
|
+
content: UTF-8 text to write.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
Number of UTF-8 bytes written.
|
|
107
|
+
|
|
108
|
+
Raises:
|
|
109
|
+
FileWriterError: If the destination cannot be written safely.
|
|
110
|
+
"""
|
|
111
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
|
|
113
|
+
encoded_content = content.encode("utf-8")
|
|
114
|
+
temporary_path: Path | None = None
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
with tempfile.NamedTemporaryFile(
|
|
118
|
+
mode="wb",
|
|
119
|
+
dir=output_path.parent,
|
|
120
|
+
prefix=f".{output_path.name}.",
|
|
121
|
+
suffix=".tmp",
|
|
122
|
+
delete=False,
|
|
123
|
+
) as temporary_file:
|
|
124
|
+
temporary_path = Path(temporary_file.name)
|
|
125
|
+
temporary_file.write(encoded_content)
|
|
126
|
+
temporary_file.flush()
|
|
127
|
+
os.fsync(temporary_file.fileno())
|
|
128
|
+
|
|
129
|
+
os.replace(temporary_path, output_path)
|
|
130
|
+
|
|
131
|
+
except OSError as exc:
|
|
132
|
+
if temporary_path is not None:
|
|
133
|
+
try:
|
|
134
|
+
temporary_path.unlink(missing_ok=True)
|
|
135
|
+
except OSError:
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
raise FileWriterError(
|
|
139
|
+
f"Unable to write {output_path}: {exc}"
|
|
140
|
+
) from exc
|
|
141
|
+
|
|
142
|
+
return len(encoded_content)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def write_with_backup(
|
|
146
|
+
output_path: Path,
|
|
147
|
+
content: str,
|
|
148
|
+
backup_directory: Path,
|
|
149
|
+
retention: int = 5,
|
|
150
|
+
) -> FileWriteResult:
|
|
151
|
+
"""Back up the current file and atomically write new content.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
output_path: Destination configuration file.
|
|
155
|
+
content: Generated configuration.
|
|
156
|
+
backup_directory: Backup storage directory.
|
|
157
|
+
retention: Maximum number of backups to retain.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Structured information about the completed operation.
|
|
161
|
+
"""
|
|
162
|
+
backup_path = create_backup(
|
|
163
|
+
output_path=output_path,
|
|
164
|
+
backup_directory=backup_directory,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
bytes_written = atomic_write(
|
|
168
|
+
output_path=output_path,
|
|
169
|
+
content=content,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
apply_backup_retention(
|
|
173
|
+
output_path=output_path,
|
|
174
|
+
backup_directory=backup_directory,
|
|
175
|
+
retention=retention,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
return FileWriteResult(
|
|
179
|
+
output_path=output_path,
|
|
180
|
+
backup_path=backup_path,
|
|
181
|
+
bytes_written=bytes_written,
|
|
182
|
+
written_at=datetime.now(),
|
|
183
|
+
)
|