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.
Files changed (53) hide show
  1. quilt_hp/__init__.py +22 -0
  2. quilt_hp/_paths.py +26 -0
  3. quilt_hp/_proto/__init__.py +0 -0
  4. quilt_hp/_proto/quilt_device_pairing_pb2.py +56 -0
  5. quilt_hp/_proto/quilt_device_pairing_pb2.pyi +317 -0
  6. quilt_hp/_proto/quilt_device_pairing_pb2_grpc.py +24 -0
  7. quilt_hp/_proto/quilt_hds_pb2.py +292 -0
  8. quilt_hp/_proto/quilt_hds_pb2.pyi +3947 -0
  9. quilt_hp/_proto/quilt_hds_pb2_grpc.py +1732 -0
  10. quilt_hp/_proto/quilt_notifier_pb2.py +55 -0
  11. quilt_hp/_proto/quilt_notifier_pb2.pyi +258 -0
  12. quilt_hp/_proto/quilt_notifier_pb2_grpc.py +97 -0
  13. quilt_hp/_proto/quilt_services_pb2.py +171 -0
  14. quilt_hp/_proto/quilt_services_pb2.pyi +1320 -0
  15. quilt_hp/_proto/quilt_services_pb2_grpc.py +1188 -0
  16. quilt_hp/_proto/quilt_system_pb2.py +53 -0
  17. quilt_hp/_proto/quilt_system_pb2.pyi +164 -0
  18. quilt_hp/_proto/quilt_system_pb2_grpc.py +270 -0
  19. quilt_hp/auth.py +244 -0
  20. quilt_hp/cli/__init__.py +1 -0
  21. quilt_hp/cli/main.py +770 -0
  22. quilt_hp/cli/settings.py +123 -0
  23. quilt_hp/cli/store.py +105 -0
  24. quilt_hp/cli/tui.py +2677 -0
  25. quilt_hp/client.py +616 -0
  26. quilt_hp/const.py +57 -0
  27. quilt_hp/exceptions.py +23 -0
  28. quilt_hp/models/__init__.py +85 -0
  29. quilt_hp/models/comfort.py +47 -0
  30. quilt_hp/models/controller.py +135 -0
  31. quilt_hp/models/energy.py +31 -0
  32. quilt_hp/models/enums.py +298 -0
  33. quilt_hp/models/indoor_unit.py +412 -0
  34. quilt_hp/models/outdoor_unit.py +71 -0
  35. quilt_hp/models/qsm.py +105 -0
  36. quilt_hp/models/schedule.py +98 -0
  37. quilt_hp/models/sensor.py +92 -0
  38. quilt_hp/models/software_update.py +74 -0
  39. quilt_hp/models/space.py +177 -0
  40. quilt_hp/models/system.py +451 -0
  41. quilt_hp/py.typed +1 -0
  42. quilt_hp/services/__init__.py +1 -0
  43. quilt_hp/services/hds.py +480 -0
  44. quilt_hp/services/streaming.py +561 -0
  45. quilt_hp/services/system.py +95 -0
  46. quilt_hp/services/user.py +143 -0
  47. quilt_hp/tokens.py +119 -0
  48. quilt_hp/transport.py +192 -0
  49. quilt_hp_python-0.1.1.dist-info/METADATA +172 -0
  50. quilt_hp_python-0.1.1.dist-info/RECORD +53 -0
  51. quilt_hp_python-0.1.1.dist-info/WHEEL +4 -0
  52. quilt_hp_python-0.1.1.dist-info/entry_points.txt +2 -0
  53. quilt_hp_python-0.1.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,451 @@
1
+ """System-level models — system info and full snapshot."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, cast
7
+
8
+ from quilt_hp.models.comfort import ComfortSetting
9
+ from quilt_hp.models.controller import Controller
10
+ from quilt_hp.models.enums import (
11
+ ComfortSettingType,
12
+ HVACMode,
13
+ LightState,
14
+ LouverMode,
15
+ RemoteSensorControlMode,
16
+ )
17
+ from quilt_hp.models.indoor_unit import IndoorUnit
18
+ from quilt_hp.models.outdoor_unit import OutdoorUnit
19
+ from quilt_hp.models.qsm import QuiltSmartModule
20
+ from quilt_hp.models.schedule import ScheduleDay, ScheduleWeek
21
+ from quilt_hp.models.sensor import ControllerRemoteSensor, RemoteSensor
22
+ from quilt_hp.models.software_update import SoftwareUpdateInfo
23
+ from quilt_hp.models.space import Space
24
+
25
+
26
+ @dataclass(slots=True)
27
+ class Location:
28
+ """A Quilt location with global settings like schedule execution state."""
29
+
30
+ id: str
31
+ name: str
32
+ system_id: str
33
+ timezone: str
34
+ schedule_paused: bool # True when SCHEDULE_EXECUTION_PAUSED
35
+
36
+ @classmethod
37
+ def from_proto(cls, proto: object) -> Location:
38
+ """Construct from a protobuf Location message."""
39
+ from quilt_hp._proto import quilt_hds_pb2 as hds
40
+
41
+ return cls(
42
+ id=proto.header.object_id, # type: ignore[attr-defined]
43
+ name=proto.attributes.name, # type: ignore[attr-defined]
44
+ system_id=proto.header.system_id, # type: ignore[attr-defined]
45
+ timezone=proto.attributes.tz_identifier, # type: ignore[attr-defined]
46
+ schedule_paused=(
47
+ proto.controls.schedule_execution # type: ignore[attr-defined]
48
+ == hds.SCHEDULE_EXECUTION_PAUSED
49
+ ),
50
+ )
51
+
52
+
53
+ @dataclass(slots=True)
54
+ class SystemInfo:
55
+ """Basic system metadata from ListSystems."""
56
+
57
+ id: str
58
+ name: str
59
+ timezone: str
60
+
61
+
62
+ @dataclass(slots=True)
63
+ class SystemSnapshot:
64
+ """Full system state from GetHomeDatastoreSystem."""
65
+
66
+ spaces: list[Space]
67
+ indoor_units: list[IndoorUnit]
68
+ outdoor_units: list[OutdoorUnit]
69
+ controllers: list[Controller]
70
+ quilt_smart_modules: list[QuiltSmartModule]
71
+ comfort_settings: list[ComfortSetting]
72
+ schedule_weeks: list[ScheduleWeek]
73
+ schedule_days: list[ScheduleDay]
74
+ remote_sensors: list[RemoteSensor]
75
+ controller_remote_sensors: list[ControllerRemoteSensor]
76
+ software_update_infos: list[SoftwareUpdateInfo]
77
+ locations: list[Location]
78
+ timezone: str | None
79
+
80
+ @property
81
+ def rooms(self) -> list[Space]:
82
+ """Return only leaf/room spaces (those with a parent)."""
83
+ return [s for s in self.spaces if s.is_room]
84
+
85
+ @property
86
+ def primary_location(self) -> Location | None:
87
+ """The first (and typically only) Location for this system."""
88
+ return self.locations[0] if self.locations else None
89
+
90
+ def space_by_name(self, name: str) -> Space | None:
91
+ """Find a space by name (case-insensitive)."""
92
+ name_lower = name.lower()
93
+ for s in self.spaces:
94
+ if s.name.lower() == name_lower:
95
+ return s
96
+ return None
97
+
98
+ def enrich_space(self, space: Space) -> Space:
99
+ """Resolve active_comfort_setting_type on a stream-updated Space.
100
+
101
+ Stream updates deliver individual Space protos without the comfort
102
+ settings context. Call this before using space.is_away / space.is_off
103
+ on a space received from the NotifierStream.
104
+ """
105
+ cs_id = space.controls.comfort_setting_id
106
+ for cs in self.comfort_settings:
107
+ if cs.id == cs_id:
108
+ space.active_comfort_setting_type = cs.type
109
+ return space
110
+ space.active_comfort_setting_type = None
111
+ return space
112
+
113
+ def apply_space(self, space: Space) -> Space:
114
+ """Enrich and patch a stream-updated Space into the snapshot.
115
+
116
+ Resolves comfort-setting type (needed for is_away / is_off) then
117
+ merges the diff into the existing Space, preserving sub-messages that
118
+ were absent from the sparse proto diff. Returns the merged space.
119
+
120
+ Proto3 stream diffs are sparse — only changed fields are sent. A
121
+ settings-only diff produces a Space with
122
+ ``controls.hvac_mode=UNSPECIFIED`` and
123
+ ``state.ambient_temperature_c=None``. Without merging, those defaults
124
+ would overwrite real data.
125
+ """
126
+ self.enrich_space(space)
127
+ for i, s in enumerate(self.spaces):
128
+ if s.id == space.id:
129
+ from dataclasses import replace
130
+
131
+ updates: dict[str, Any] = {}
132
+ # state absent: ambient_temperature_c is None
133
+ # (set only when updated_ts present)
134
+ if (
135
+ space.state.ambient_temperature_c is None
136
+ and s.state.ambient_temperature_c is not None
137
+ ):
138
+ updates["state"] = s.state
139
+ # controls absent: hvac_mode is UNSPECIFIED and existing mode
140
+ # is real
141
+ if (
142
+ space.controls.hvac_mode == HVACMode.UNSPECIFIED
143
+ and s.controls.hvac_mode != HVACMode.UNSPECIFIED
144
+ ):
145
+ updates["controls"] = s.controls
146
+ # settings absent: name is empty (settings.name is always
147
+ # non-empty in a real Space)
148
+ if not space.settings.name and s.settings.name:
149
+ updates["settings"] = s.settings
150
+ updates["name"] = s.name
151
+ if updates:
152
+ space = replace(space, **updates)
153
+ self.spaces[i] = space
154
+ return space
155
+ self.spaces.append(space)
156
+ return space
157
+
158
+ def apply_indoor_unit(self, idu: IndoorUnit) -> IndoorUnit:
159
+ """Patch a stream-updated IndoorUnit into the snapshot.
160
+
161
+ Stream protos are partial — ``qsm_id``, ``outdoor_unit_id``, the
162
+ ``hvac_inputs``/``conditions`` sub-messages, and the ``state``
163
+ sub-message may all be absent in a diff. Preserve existing values so
164
+ stream updates don't erase them.
165
+
166
+ In particular, a controls-only diff (e.g. LED toggle) omits ``state``,
167
+ which would make ``state.updated_at=None`` and therefore
168
+ ``is_online=False``, causing ``led_on`` and
169
+ ``effective_occupancy_state`` to report stale data.
170
+ """
171
+ for i, u in enumerate(self.indoor_units):
172
+ if u.id == idu.id:
173
+ from dataclasses import replace
174
+
175
+ updates: dict[str, Any] = {}
176
+ if idu.qsm_id is None and u.qsm_id:
177
+ updates["qsm_id"] = u.qsm_id
178
+ if idu.outdoor_unit_id is None and u.outdoor_unit_id:
179
+ updates["outdoor_unit_id"] = u.outdoor_unit_id
180
+ if idu.hvac_inputs is None and u.hvac_inputs is not None:
181
+ updates["hvac_inputs"] = u.hvac_inputs
182
+ if idu.conditions is None and u.conditions is not None:
183
+ updates["conditions"] = u.conditions
184
+ # state absent from diff: updated_at is None — preserve
185
+ # existing so is_online stays valid
186
+ if idu.state.updated_at is None and u.state.updated_at is not None:
187
+ updates["state"] = u.state
188
+ # controls absent detection: state IS in diff
189
+ # (timestamped state-only update), and all control sentinel
190
+ # fields are at proto3 defaults. When controls are genuinely
191
+ # sent: led_color_code is non-zero, OR led_state is explicit
192
+ # ON/OFF, OR louver_mode is set. Brightness alone is not a
193
+ # safe sentinel because mobile_led_scheduling_enabled preserves
194
+ # brightness when LED is OFF.
195
+ if (
196
+ idu.state.updated_at is not None
197
+ and idu.controls.fan_speed_mode_raw == 0
198
+ and idu.controls.louver_mode == LouverMode.UNSPECIFIED
199
+ and idu.controls.led_color_code == 0
200
+ and idu.controls.led_state == LightState.UNSPECIFIED
201
+ and idu.controls.led_brightness == 0.0
202
+ ):
203
+ updates["controls"] = u.controls
204
+ # | None sub-messages: absent from diff → parsed as None;
205
+ # preserve existing data
206
+ if idu.performance_data is None and u.performance_data is not None:
207
+ updates["performance_data"] = u.performance_data
208
+ if idu.performance_metrics is None and u.performance_metrics is not None:
209
+ updates["performance_metrics"] = u.performance_metrics
210
+ if idu.presence is None and u.presence is not None:
211
+ updates["presence"] = u.presence
212
+ if idu.occupancy is None and u.occupancy is not None:
213
+ updates["occupancy"] = u.occupancy
214
+ if updates:
215
+ idu = replace(idu, **updates)
216
+ self.indoor_units[i] = idu
217
+ return idu
218
+ self.indoor_units.append(idu)
219
+ return idu
220
+
221
+ def apply_outdoor_unit(self, odu: OutdoorUnit) -> OutdoorUnit:
222
+ """Patch a stream-updated OutdoorUnit into the snapshot.
223
+
224
+ Stream diffs may omit ``state`` (only performance_data changed) or
225
+ lack hardware info (no hw_map available at parse time). Preserve any
226
+ existing non-default values so partial updates don't erase them.
227
+ """
228
+ from dataclasses import replace
229
+
230
+ for i, u in enumerate(self.outdoor_units):
231
+ if u.id == odu.id:
232
+ # Preserve hvac_state when stream diff has a default-zero state
233
+ if not odu.hvac_state and u.hvac_state:
234
+ odu = replace(odu, hvac_state=u.hvac_state)
235
+ # Preserve hardware info — stream diffs are parsed without hw_map
236
+ if odu.model_sku is None and u.model_sku is not None:
237
+ odu = replace(
238
+ odu,
239
+ model_sku=u.model_sku,
240
+ serial_number=u.serial_number,
241
+ firmware_version=u.firmware_version,
242
+ )
243
+ self.outdoor_units[i] = odu
244
+ return odu
245
+ self.outdoor_units.append(odu)
246
+ return odu
247
+
248
+ def apply_controller(self, ctrl: Controller) -> Controller:
249
+ """Patch a stream-updated Controller into the snapshot.
250
+
251
+ Stream diffs are sparse — a temperature state diff omits settings (name)
252
+ and hosted_wifi_state. Preserve existing non-empty values.
253
+ Hardware info (serial, model_sku, firmware_version) is only populated at
254
+ initial snapshot load and is never in stream diffs; always preserve it.
255
+ """
256
+ from dataclasses import replace
257
+
258
+ for i, c in enumerate(self.controllers):
259
+ if c.id == ctrl.id:
260
+ updates: dict[str, Any] = {}
261
+ if not ctrl.name and c.name:
262
+ updates["name"] = c.name
263
+ if ctrl.wifi_ssid is None and c.wifi_ssid is not None:
264
+ updates["wifi_ssid"] = c.wifi_ssid
265
+ updates["wifi_ip"] = c.wifi_ip
266
+ updates["wifi_signal_dbm"] = c.wifi_signal_dbm
267
+ updates["wifi_freq_mhz"] = c.wifi_freq_mhz
268
+ updates["wifi_last_seen"] = c.wifi_last_seen
269
+ if ctrl.ap_wifi is None and c.ap_wifi is not None:
270
+ updates["ap_wifi"] = c.ap_wifi
271
+ if ctrl.p2p_wifi is None and c.p2p_wifi is not None:
272
+ updates["p2p_wifi"] = c.p2p_wifi
273
+ if (
274
+ ctrl.remote_sensor_mode == RemoteSensorControlMode.UNSPECIFIED
275
+ and c.remote_sensor_mode != RemoteSensorControlMode.UNSPECIFIED
276
+ ):
277
+ updates["remote_sensor_mode"] = c.remote_sensor_mode
278
+ # Hardware fields are never in stream diffs — always preserve
279
+ # from snapshot
280
+ if ctrl.serial_number is None and c.serial_number is not None:
281
+ updates["serial_number"] = c.serial_number
282
+ updates["model_sku"] = c.model_sku
283
+ updates["firmware_version"] = c.firmware_version
284
+ if updates:
285
+ ctrl = replace(ctrl, **updates)
286
+ self.controllers[i] = ctrl
287
+ return ctrl
288
+ self.controllers.append(ctrl)
289
+ return ctrl
290
+
291
+ def apply_qsm(self, qsm: QuiltSmartModule) -> QuiltSmartModule:
292
+ """Patch a stream-updated QuiltSmartModule into the snapshot.
293
+
294
+ Stream diffs are sparse — a controls diff omits state (sensors) and wifi
295
+ state sub-messages. Preserve existing non-None values.
296
+ """
297
+ from dataclasses import replace
298
+
299
+ for i, q in enumerate(self.quilt_smart_modules):
300
+ if q.id == qsm.id:
301
+ updates: dict[str, Any] = {}
302
+ if qsm.sensors is None and q.sensors is not None:
303
+ updates["sensors"] = q.sensors
304
+ if qsm.hosted_wifi is None and q.hosted_wifi is not None:
305
+ updates["hosted_wifi"] = q.hosted_wifi
306
+ if qsm.ap_wifi is None and q.ap_wifi is not None:
307
+ updates["ap_wifi"] = q.ap_wifi
308
+ if qsm.p2p_wifi is None and q.p2p_wifi is not None:
309
+ updates["p2p_wifi"] = q.p2p_wifi
310
+ if updates:
311
+ qsm = replace(qsm, **updates)
312
+ self.quilt_smart_modules[i] = qsm
313
+ return qsm
314
+ self.quilt_smart_modules.append(qsm)
315
+ return qsm
316
+
317
+ def apply_remote_sensor(self, rs: RemoteSensor) -> RemoteSensor:
318
+ """Patch a stream-updated RemoteSensor into the snapshot.
319
+
320
+ Stream diffs are sparse — a state-only diff (temperature/humidity update)
321
+ omits controls, leaving control_mode=UNSPECIFIED. A controls-only diff
322
+ omits state, zeroing all sensor readings. Preserve existing non-None
323
+ values.
324
+ """
325
+ from dataclasses import replace
326
+
327
+ for i, r in enumerate(self.remote_sensors):
328
+ if r.id == rs.id:
329
+ updates: dict[str, Any] = {}
330
+ # controls absent: control_mode defaults to UNSPECIFIED.
331
+ if (
332
+ rs.control_mode == RemoteSensorControlMode.UNSPECIFIED
333
+ and r.control_mode != RemoteSensorControlMode.UNSPECIFIED
334
+ ):
335
+ updates["control_mode"] = r.control_mode
336
+ # state absent: all sensor readings become None
337
+ if rs.ambient_temperature_c is None and r.ambient_temperature_c is not None:
338
+ updates["ambient_temperature_c"] = r.ambient_temperature_c
339
+ if rs.humidity_percent is None and r.humidity_percent is not None:
340
+ updates["humidity_percent"] = r.humidity_percent
341
+ if rs.battery_level_percent is None and r.battery_level_percent is not None:
342
+ updates["battery_level_percent"] = r.battery_level_percent
343
+ if rs.signal_level_dbm is None and r.signal_level_dbm is not None:
344
+ updates["signal_level_dbm"] = r.signal_level_dbm
345
+ if updates:
346
+ rs = replace(rs, **updates)
347
+ self.remote_sensors[i] = rs
348
+ return rs
349
+ self.remote_sensors.append(rs)
350
+ return rs
351
+
352
+ def apply_controller_remote_sensor(
353
+ self, crs: ControllerRemoteSensor
354
+ ) -> ControllerRemoteSensor:
355
+ """Patch a stream-updated ControllerRemoteSensor into the snapshot."""
356
+ from dataclasses import replace
357
+
358
+ for i, r in enumerate(self.controller_remote_sensors):
359
+ if r.id == crs.id:
360
+ updates: dict[str, Any] = {}
361
+ if (
362
+ crs.control_mode == RemoteSensorControlMode.UNSPECIFIED
363
+ and r.control_mode != RemoteSensorControlMode.UNSPECIFIED
364
+ ):
365
+ updates["control_mode"] = r.control_mode
366
+ if crs.ambient_temperature_c is None and r.ambient_temperature_c is not None:
367
+ updates["ambient_temperature_c"] = r.ambient_temperature_c
368
+ if crs.humidity_percent is None and r.humidity_percent is not None:
369
+ updates["humidity_percent"] = r.humidity_percent
370
+ if crs.battery_level_percent is None and r.battery_level_percent is not None:
371
+ updates["battery_level_percent"] = r.battery_level_percent
372
+ if crs.signal_level_dbm is None and r.signal_level_dbm is not None:
373
+ updates["signal_level_dbm"] = r.signal_level_dbm
374
+ if updates:
375
+ crs = replace(crs, **updates)
376
+ self.controller_remote_sensors[i] = crs
377
+ return crs
378
+ self.controller_remote_sensors.append(crs)
379
+ return crs
380
+
381
+ def qsm_for_idu(self, idu: IndoorUnit) -> QuiltSmartModule | None:
382
+ """Return the QSM embedded in the given IDU, or None."""
383
+ if not idu.qsm_id:
384
+ return None
385
+ return next((q for q in self.quilt_smart_modules if q.id == idu.qsm_id), None)
386
+
387
+ def stream_topics(self) -> list[str]:
388
+ """Return the NotifierService topic strings for this snapshot.
389
+
390
+ Pass the result directly to ``client.stream(topics)``. Covers all
391
+ rooms, indoor units, outdoor units, and controllers.
392
+ """
393
+ topics: list[str] = []
394
+ for space in self.rooms:
395
+ topics.append(f"hds/space/{space.id}")
396
+ for idu in self.indoor_units:
397
+ topics.append(f"hds/indoor_unit/{idu.id}")
398
+ for odu in self.outdoor_units:
399
+ topics.append(f"hds/outdoor_unit/{odu.id}")
400
+ for ctrl in self.controllers:
401
+ topics.append(f"hds/controller/{ctrl.id}")
402
+ for qsm in self.quilt_smart_modules:
403
+ topics.append(f"hds/quilt_smart_module/{qsm.id}")
404
+ for rs in self.remote_sensors:
405
+ topics.append(f"hds/remote_sensor/{rs.id}")
406
+ for crs in self.controller_remote_sensors:
407
+ topics.append(f"hds/controller_remote_sensor/{crs.id}")
408
+ for sui in self.software_update_infos:
409
+ topics.append(f"hds/software_update_info/{sui.id}")
410
+ return topics
411
+
412
+ @classmethod
413
+ def from_proto(cls, proto: object) -> SystemSnapshot:
414
+ """Construct from a protobuf HomeDatastoreSystem message."""
415
+ p = cast("Any", proto)
416
+ odu_hw_map = {h.header.object_id: h for h in p.outdoor_unit_hardware}
417
+ ctrl_hw_map = {h.header.object_id: h for h in p.controller_hardware}
418
+
419
+ locations = [Location.from_proto(loc) for loc in p.locations]
420
+ tz = locations[0].timezone if locations else None
421
+
422
+ comfort_settings = [ComfortSetting.from_proto(cs) for cs in p.comfort_settings]
423
+ cs_type_by_id: dict[str, ComfortSettingType] = {cs.id: cs.type for cs in comfort_settings}
424
+
425
+ spaces: list[Space] = []
426
+ for s in p.spaces:
427
+ space = Space.from_proto(s)
428
+ space.active_comfort_setting_type = cs_type_by_id.get(
429
+ space.controls.comfort_setting_id
430
+ )
431
+ spaces.append(space)
432
+
433
+ return cls(
434
+ spaces=spaces,
435
+ indoor_units=[IndoorUnit.from_proto(u) for u in p.indoor_units],
436
+ outdoor_units=[OutdoorUnit.from_proto(u, odu_hw_map) for u in p.outdoor_units],
437
+ controllers=[Controller.from_proto(c, ctrl_hw_map) for c in p.controllers],
438
+ quilt_smart_modules=[QuiltSmartModule.from_proto(q) for q in p.quilt_smart_modules],
439
+ comfort_settings=comfort_settings,
440
+ schedule_weeks=[ScheduleWeek.from_proto(w) for w in p.schedule_weeks],
441
+ schedule_days=[ScheduleDay.from_proto(d) for d in p.schedule_days],
442
+ remote_sensors=[RemoteSensor.from_proto(rs) for rs in p.remote_sensors],
443
+ controller_remote_sensors=[
444
+ ControllerRemoteSensor.from_proto(crs) for crs in p.controller_remote_sensors
445
+ ],
446
+ software_update_infos=[
447
+ SoftwareUpdateInfo.from_proto(s) for s in p.software_update_infos
448
+ ],
449
+ locations=locations,
450
+ timezone=tz,
451
+ )
quilt_hp/py.typed ADDED
@@ -0,0 +1 @@
1
+
@@ -0,0 +1 @@
1
+ """Service layer — thin async wrappers around gRPC stubs."""