pywmswebcontrolpro 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.
@@ -0,0 +1,55 @@
1
+ from typing import Any
2
+
3
+ from .room import Room
4
+
5
+
6
+ class Scene:
7
+ def __init__(self, control, id: int, names: list):
8
+ self._control = control
9
+ self._id = id
10
+ self._names = names
11
+
12
+ def __str__(self) -> str:
13
+ return self.name
14
+
15
+ def __repr__(self) -> str:
16
+ return f"<Scene {self.id}: {self}>"
17
+
18
+ def __eq__(self, other) -> bool:
19
+ return self.id == other.id
20
+
21
+ def __hash__(self) -> int:
22
+ return hash(self.id)
23
+
24
+ # --- Properties ---
25
+
26
+ @property
27
+ def host(self) -> str:
28
+ return self._control.host
29
+
30
+ @property
31
+ def id(self) -> int:
32
+ return self._id
33
+
34
+ @property
35
+ def name(self) -> str:
36
+ return self._names[0]
37
+
38
+ @property
39
+ def room(self) -> Room | None:
40
+ for room in self._control.rooms.values():
41
+ if self._id in room._scene_ids:
42
+ return room
43
+ return None
44
+
45
+ # --- Public methods ---
46
+
47
+ async def __call__(self, **kwargs) -> Any:
48
+ return await self._control._sceneActions(self._id, **kwargs)
49
+
50
+ def diag(self) -> dict:
51
+ return {
52
+ "id": self.id,
53
+ "name": self.name,
54
+ "room": {self.room.id: self.room.name} if self.room else None,
55
+ }
@@ -0,0 +1,104 @@
1
+ """Persistent settings and last known state of one destination."""
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ from dataclasses import dataclass, fields
7
+ from enum import StrEnum
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import aiofiles
12
+ from aiofiles.os import makedirs, path as aio_path, replace
13
+
14
+ _LOGGER = logging.getLogger(__name__)
15
+
16
+ STORE_VERSION = 1
17
+
18
+
19
+ class TravelDirection(StrEnum):
20
+ """Direction of a full drive of a cover."""
21
+
22
+ OPEN = "open"
23
+ CLOSE = "close"
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ActionSettings:
28
+ """Settings of a drive action that the user can change.
29
+
30
+ A travel time of 0 means unknown, the target of a drive is then taken over
31
+ at once.
32
+ """
33
+
34
+ travel_time_open: float = 0.0
35
+ travel_time_close: float = 0.0
36
+
37
+ def travel_time(self, direction: TravelDirection) -> float:
38
+ """Return the travel time of a full drive in seconds."""
39
+ if direction is TravelDirection.OPEN:
40
+ return self.travel_time_open
41
+ return self.travel_time_close
42
+
43
+ @classmethod
44
+ def from_dict(cls, data: Any) -> ActionSettings:
45
+ """Build settings from stored data, invalid values become 0."""
46
+ if not isinstance(data, dict):
47
+ return cls()
48
+ return cls(**{f.name: _valid_seconds(data.get(f.name)) for f in fields(cls)})
49
+
50
+
51
+ def _valid_seconds(value: Any) -> float:
52
+ if isinstance(value, bool) or not isinstance(value, int | float):
53
+ return 0.0
54
+ return float(value) if value >= 0 else 0.0
55
+
56
+
57
+ class JsonStore:
58
+ """JSON file with the persistent data of one action.
59
+
60
+ Reads and writes are serialized and a write replaces the file atomically.
61
+ Without a path nothing is stored.
62
+ """
63
+
64
+ def __init__(self, path: Path | None) -> None:
65
+ """Create a store for the given file."""
66
+ self._path = path
67
+ self._lock = asyncio.Lock()
68
+
69
+ @property
70
+ def path(self) -> Path | None:
71
+ """Return the file of this store."""
72
+ return self._path
73
+
74
+ async def load(self) -> dict[str, Any]:
75
+ """Return the stored data, empty if there is none or it is unreadable."""
76
+ if self._path is None:
77
+ return {}
78
+ async with self._lock:
79
+ return await self._read(self._path)
80
+
81
+ @staticmethod
82
+ async def _read(path: Path) -> dict[str, Any]:
83
+ if not await aio_path.exists(path):
84
+ return {}
85
+ async with aiofiles.open(path) as file:
86
+ content = await file.read()
87
+ try:
88
+ data = json.loads(content)
89
+ except json.JSONDecodeError:
90
+ _LOGGER.warning("Ignoring unreadable file %s", path)
91
+ return {}
92
+ return data if isinstance(data, dict) else {}
93
+
94
+ async def save(self, data: dict[str, Any]) -> None:
95
+ """Replace the file with the given data."""
96
+ if self._path is None:
97
+ return
98
+ content = json.dumps(data)
99
+ temp = self._path.with_name(self._path.name + ".tmp")
100
+ async with self._lock:
101
+ await makedirs(self._path.parent, exist_ok=True)
102
+ async with aiofiles.open(temp, mode="w") as file:
103
+ await file.write(content)
104
+ await replace(temp, self._path)
@@ -0,0 +1,241 @@
1
+ """Movement model of a WMS cover, free of Home Assistant imports.
2
+
3
+ The model keeps the displayed position and tilt of one cover. Commands set
4
+ the state, a running drive is interpolated linearly, and a measured value
5
+ from a status poll replaces the calculated one while the cover stands still.
6
+
7
+ Scale: position and tilt use the Home Assistant scale, 0 is closed and 100
8
+ is open. All times are seconds, "now" is a monotonic clock value.
9
+ """
10
+
11
+ from dataclasses import dataclass
12
+ from enum import StrEnum
13
+
14
+
15
+ class PositionSource(StrEnum):
16
+ """Origin of the displayed position."""
17
+
18
+ MEASURED = "measured"
19
+ CALCULATED = "calculated"
20
+ RESTORED = "restored"
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class TravelSettings:
25
+ """Delays and tolerances of the model."""
26
+
27
+ settle_after_travel: float = 2.0
28
+ settle_after_stop: float = 2.0
29
+ settle_tilt: float = 5.0
30
+ settle_unknown_travel: float = 120.0
31
+ position_tolerance: int = 3
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Measurement:
36
+ """Result of handing a measured value to the model."""
37
+
38
+ applied: bool
39
+ deviation: bool
40
+
41
+
42
+ def clamp_percent(value: float) -> float:
43
+ """Limit a value to the range 0..100."""
44
+ return max(0.0, min(100.0, float(value)))
45
+
46
+
47
+ def wms_percentage_to_position(percentage: object) -> int | None:
48
+ """Convert a WMS percentage (0 open, 100 closed) to a position.
49
+
50
+ Returns None if the value is missing or outside 0..100.
51
+ """
52
+ if isinstance(percentage, bool) or not isinstance(percentage, (int, float)):
53
+ return None
54
+ if percentage < 0 or percentage > 100:
55
+ return None
56
+ return int(100 - percentage + 0.5)
57
+
58
+
59
+ class CoverModel:
60
+ """Displayed state of one cover."""
61
+
62
+ def __init__(self, settings: TravelSettings | None = None) -> None:
63
+ """Initialize an unknown cover state."""
64
+ self.settings = settings or TravelSettings()
65
+ self.position: float | None = None
66
+ self.tilt: int | None = None
67
+ self.source: PositionSource | None = None
68
+ self._start: float | None = None
69
+ self._target: float | None = None
70
+ self._started_at: float | None = None
71
+ self._duration: float | None = None
72
+ self._pending_tilt: int | None = None
73
+
74
+ # --- State ---
75
+
76
+ @property
77
+ def is_travelling(self) -> bool:
78
+ """Return True while a drive is interpolated."""
79
+ return self._target is not None
80
+
81
+ @property
82
+ def is_opening(self) -> bool:
83
+ """Return True while an interpolated drive moves towards open."""
84
+ return self.is_travelling and self._target > self._start
85
+
86
+ @property
87
+ def is_closing(self) -> bool:
88
+ """Return True while an interpolated drive moves towards closed."""
89
+ return self.is_travelling and self._target < self._start
90
+
91
+ @property
92
+ def position_int(self) -> int | None:
93
+ """Return the displayed position as integer."""
94
+ if self.position is None:
95
+ return None
96
+ return int(self.position + 0.5)
97
+
98
+ def _clear_travel(self) -> None:
99
+ self._start = None
100
+ self._target = None
101
+ self._started_at = None
102
+ self._duration = None
103
+ self._pending_tilt = None
104
+
105
+ # --- Time ---
106
+
107
+ def advance(self, now: float) -> bool:
108
+ """Move the displayed position to the given time.
109
+
110
+ Returns True if a drive reached its target with this call.
111
+ """
112
+ if not self.is_travelling:
113
+ return False
114
+ elapsed = max(0.0, now - self._started_at)
115
+ if elapsed >= self._duration:
116
+ self.position = self._target
117
+ if self._pending_tilt is not None:
118
+ self.tilt = self._pending_tilt
119
+ self._clear_travel()
120
+ return True
121
+ self.position = self._start + (self._target - self._start) * (
122
+ elapsed / self._duration
123
+ )
124
+ return False
125
+
126
+ # --- Commands ---
127
+
128
+ def start_move(
129
+ self,
130
+ now: float,
131
+ target: float,
132
+ time_open: float,
133
+ time_close: float,
134
+ tilt: int | None = None,
135
+ ) -> float:
136
+ """Handle an accepted drive command.
137
+
138
+ Returns the delay in seconds until the settle poll.
139
+ """
140
+ self.advance(now)
141
+ target = clamp_percent(target)
142
+ start = self.position
143
+ self._clear_travel()
144
+ self.source = PositionSource.CALCULATED
145
+
146
+ if start is None:
147
+ self.position = target
148
+ if tilt is not None:
149
+ self.tilt = tilt
150
+ return self.settings.settle_unknown_travel
151
+
152
+ distance = abs(target - start)
153
+ if distance < 0.5:
154
+ self.position = target
155
+ if tilt is not None:
156
+ self.tilt = tilt
157
+ return self.settings.settle_tilt
158
+
159
+ full_time = time_open if target > start else time_close
160
+ if full_time <= 0:
161
+ self.position = target
162
+ if tilt is not None:
163
+ self.tilt = tilt
164
+ return self.settings.settle_unknown_travel
165
+
166
+ self._start = start
167
+ self._target = target
168
+ self._started_at = now
169
+ self._duration = full_time * distance / 100
170
+ self._pending_tilt = tilt
171
+ return self._duration + self.settings.settle_after_travel
172
+
173
+ def set_tilt(self, now: float, tilt: int) -> float:
174
+ """Handle an accepted tilt-only command, it ends a running drive."""
175
+ self.advance(now)
176
+ self._clear_travel()
177
+ self.tilt = int(clamp_percent(tilt) + 0.5)
178
+ self.source = PositionSource.CALCULATED
179
+ return self.settings.settle_tilt
180
+
181
+ def stop(self, now: float) -> float:
182
+ """Handle an accepted stop command, the displayed value freezes."""
183
+ self.advance(now)
184
+ self._clear_travel()
185
+ if self.position is not None:
186
+ self.source = PositionSource.CALCULATED
187
+ return self.settings.settle_after_stop
188
+
189
+ # --- Measurement and restore ---
190
+
191
+ def apply_measurement(
192
+ self, now: float, position: int | None, tilt: int | None
193
+ ) -> Measurement:
194
+ """Take over a measured value unless a drive is running."""
195
+ self.advance(now)
196
+ if self.is_travelling or position is None:
197
+ return Measurement(applied=False, deviation=False)
198
+ old = self.position
199
+ self.position = clamp_percent(position)
200
+ if tilt is not None:
201
+ self.tilt = int(clamp_percent(tilt) + 0.5)
202
+ self.source = PositionSource.MEASURED
203
+ deviation = (
204
+ old is not None
205
+ and abs(self.position - old) > self.settings.position_tolerance
206
+ )
207
+ return Measurement(applied=True, deviation=deviation)
208
+
209
+ def restore(
210
+ self,
211
+ position: float | None,
212
+ tilt: int | None,
213
+ target_position: float | None,
214
+ target_tilt: int | None,
215
+ ) -> None:
216
+ """Take over the state stored before a restart.
217
+
218
+ A value that is already known (measured at setup) wins.
219
+ """
220
+ if self.position is not None:
221
+ return
222
+ if target_position is not None:
223
+ self.position = clamp_percent(target_position)
224
+ self.tilt = target_tilt if target_tilt is not None else tilt
225
+ elif position is not None:
226
+ self.position = clamp_percent(position)
227
+ self.tilt = tilt
228
+ else:
229
+ return
230
+ self.source = PositionSource.RESTORED
231
+
232
+ def snapshot(self) -> dict[str, int | None]:
233
+ """Return the state to store for a restart."""
234
+ return {
235
+ "position": self.position_int,
236
+ "tilt": self.tilt,
237
+ "target_position": (
238
+ int(self._target + 0.5) if self.is_travelling else None
239
+ ),
240
+ "target_tilt": self._pending_tilt if self.is_travelling else None,
241
+ }
@@ -0,0 +1,218 @@
1
+ import asyncio
2
+ import pprint
3
+ import time
4
+ from collections.abc import Mapping
5
+ from datetime import timedelta
6
+ from pathlib import Path
7
+ from types import MappingProxyType
8
+ from typing import Any
9
+
10
+ from aiohttp import ClientSession
11
+
12
+ from .const import (
13
+ WMS_WebControl_pro_API_actionDescription,
14
+ WMS_WebControl_pro_API_actionType,
15
+ WMS_WebControl_pro_API_command_action,
16
+ WMS_WebControl_pro_API_command_getConfiguration,
17
+ WMS_WebControl_pro_API_command_getStatus,
18
+ WMS_WebControl_pro_API_command_ping,
19
+ WMS_WebControl_pro_API_command_sceneActions,
20
+ WMS_WebControl_pro_API_protocolVersion,
21
+ WMS_WebControl_pro_API_responseType,
22
+ WMS_WebControl_pro_API_sceneActionType,
23
+ WMS_WebControl_pro_API_source,
24
+ )
25
+ from .destination import Destination
26
+ from .room import Room
27
+ from .scene import Scene
28
+
29
+ DEFAULT_STALE_WARNING_AFTER = timedelta(hours=24)
30
+ DEFAULT_RETRY_DELAY = 1.0
31
+ DEFAULT_REQUEST_SPACING = 0.5
32
+
33
+
34
+ class WebControlPro:
35
+ def __init__(
36
+ self,
37
+ host: str,
38
+ session: ClientSession,
39
+ persist: str | None = None,
40
+ *,
41
+ stale_warning_after: timedelta = DEFAULT_STALE_WARNING_AFTER,
42
+ retry_delay: float = DEFAULT_RETRY_DELAY,
43
+ request_spacing: float = DEFAULT_REQUEST_SPACING,
44
+ ) -> None:
45
+ """Create a client; stale_warning_after sets how long a destination may
46
+ stay silent before a warning is logged, retry_delay the pause in
47
+ seconds between retries after a radio busy answer and request_spacing
48
+ the minimum pause in seconds between two status requests.
49
+ """
50
+ self.stale_warning_after = stale_warning_after
51
+ self.retry_delay = retry_delay
52
+ self._common_lock = asyncio.Lock()
53
+ self._status_lock = asyncio.Lock()
54
+ self.request_spacing = request_spacing
55
+ self._status_last = time.monotonic() - self.request_spacing
56
+ self._host = host
57
+ self._control = f"http://{host}/commonCommand"
58
+ self._session = session
59
+ self._persist = Path(persist) if persist else None
60
+ self._config = {}
61
+ self._dests = {}
62
+ self._rooms = {}
63
+ self._scenes = {}
64
+
65
+ # --- Private methods ---
66
+
67
+ async def _commonCommand(self, command: str, **kwargs) -> Any:
68
+ data = {
69
+ "protocolVersion": WMS_WebControl_pro_API_protocolVersion,
70
+ "command": command,
71
+ "source": WMS_WebControl_pro_API_source,
72
+ }
73
+ data.update(kwargs)
74
+ async with (
75
+ self._common_lock,
76
+ self._session.post(url=self._control, json=data, timeout=5) as response,
77
+ ):
78
+ return await response.json()
79
+
80
+ async def _ping(self) -> Any:
81
+ return await self._commonCommand(WMS_WebControl_pro_API_command_ping)
82
+
83
+ async def _getConfiguration(self) -> Any:
84
+ return await self._commonCommand(
85
+ WMS_WebControl_pro_API_command_getConfiguration
86
+ )
87
+
88
+ async def _getStatus(self, destinationId: int) -> Any:
89
+ async with self._status_lock:
90
+ elapsed_time = time.monotonic() - self._status_last
91
+ if elapsed_time < self.request_spacing:
92
+ await asyncio.sleep(self.request_spacing - elapsed_time)
93
+ try:
94
+ return await self._commonCommand(
95
+ WMS_WebControl_pro_API_command_getStatus,
96
+ destinations=[destinationId],
97
+ )
98
+ finally:
99
+ self._status_last = time.monotonic()
100
+
101
+ async def _action(
102
+ self, actions: list, responseType=WMS_WebControl_pro_API_responseType.Instant
103
+ ) -> Any:
104
+ return await self._commonCommand(
105
+ WMS_WebControl_pro_API_command_action,
106
+ responseType=responseType,
107
+ actions=actions,
108
+ )
109
+
110
+ async def _sceneActions(
111
+ self,
112
+ sceneId: int,
113
+ sceneActionType=WMS_WebControl_pro_API_sceneActionType.Execute,
114
+ responseType=WMS_WebControl_pro_API_responseType.Instant,
115
+ ) -> Any:
116
+ return await self._commonCommand(
117
+ WMS_WebControl_pro_API_command_sceneActions,
118
+ responseType=responseType,
119
+ sceneId=sceneId,
120
+ sceneActionType=sceneActionType,
121
+ )
122
+
123
+ # --- Public methods ---
124
+
125
+ async def ping(self) -> bool:
126
+ ping = await self._ping()
127
+ return ping["status"] == 0
128
+
129
+ async def refresh(self) -> None:
130
+ config = await self._getConfiguration()
131
+ self._config = config
132
+ self._dests = {
133
+ dest["id"]: Destination(self, **dest) for dest in config["destinations"]
134
+ }
135
+ self._rooms = {room["id"]: Room(self, **room) for room in config["rooms"]}
136
+ self._scenes = {scene["id"]: Scene(self, **scene) for scene in config["scenes"]}
137
+
138
+ def dest(self, name: str) -> Destination:
139
+ for dest in self._dests.values():
140
+ if dest.name == name:
141
+ return dest
142
+ raise KeyError(f"Destination with name '{name}' not found")
143
+
144
+ def diag(self) -> dict:
145
+ return {
146
+ "host": self._host,
147
+ "config": self._config,
148
+ "dests": {k: v.diag() for k, v in self._dests.items()},
149
+ "rooms": {k: v.diag() for k, v in self._rooms.items()},
150
+ "scenes": {k: v.diag() for k, v in self._scenes.items()},
151
+ }
152
+
153
+ # --- Properties ---
154
+
155
+ @property
156
+ def host(self) -> str:
157
+ return self._host
158
+
159
+ @property
160
+ def config(self) -> Mapping:
161
+ return MappingProxyType(self._config)
162
+
163
+ @property
164
+ def dests(self) -> Mapping:
165
+ return MappingProxyType(self._dests)
166
+
167
+ @property
168
+ def rooms(self) -> Mapping:
169
+ return MappingProxyType(self._rooms)
170
+
171
+ @property
172
+ def scenes(self) -> Mapping:
173
+ return MappingProxyType(self._scenes)
174
+
175
+
176
+ async def async_main_test():
177
+ async with ClientSession() as session:
178
+ control = WebControlPro("webcontrol", session)
179
+ pprint.pprint(await control.ping())
180
+ await control.refresh()
181
+
182
+ for dest in control.dests.values():
183
+ print((dest.room, dest, dest.animationType))
184
+ await dest.refresh()
185
+ for action in dest.actions.values():
186
+ print((action, action._attrs, action._params))
187
+
188
+ for scene in control.scenes.values():
189
+ print(scene)
190
+ if scene.name == "Licht an":
191
+ response = await scene()
192
+ print(response)
193
+
194
+ action = control.dest("Licht").action(
195
+ WMS_WebControl_pro_API_actionDescription.LightDimming,
196
+ WMS_WebControl_pro_API_actionType.Percentage,
197
+ )
198
+ print(action)
199
+ response = await action(
200
+ percentage=0, responseType=WMS_WebControl_pro_API_responseType.Detailed
201
+ )
202
+ print(response)
203
+
204
+ action_list = action.prep(percentage=100)
205
+ action_list += action.prep(percentage=0)
206
+ print(action_list)
207
+ response = await action_list(
208
+ responseType=WMS_WebControl_pro_API_responseType.Detailed
209
+ )
210
+ print(response)
211
+
212
+ pprint.pprint(control.diag())
213
+
214
+
215
+ if __name__ == "__main__":
216
+ loop = asyncio.new_event_loop()
217
+ asyncio.set_event_loop(loop)
218
+ loop.run_until_complete(async_main_test())
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.5
2
+ Name: pywmswebcontrolpro
3
+ Version: 0.1.0
4
+ Summary: Python library for the Warema WMS WebControl pro API
5
+ Author-email: Marc Hoersken <info@marc-hoersken.de>
6
+ License-Expression: Apache-2.0
7
+ License-File: LICENSE
8
+ Keywords: warema,webcontrol,wms
9
+ Requires-Python: >=3.14
10
+ Requires-Dist: aiofiles>=24.1.0
11
+ Requires-Dist: aiohttp>=3.14.1
12
+ Description-Content-Type: text/markdown
13
+
14
+ # pywmswebcontrolpro
15
+
16
+ Python library for the Warema WMS WebControl pro API. It is the companion library of the
17
+ Home Assistant integration `ha_wms_webcontrol_pro`.
18
+
19
+ Reference: https://media.warema.com/dokumente/anleitungen-handbuecher/966664/warema_2064534_alhb_de_v0.pdf
20
+
21
+ ## Origin and license
22
+
23
+ This project is a fork of [`pywmspro`](https://github.com/mback2k/pywmspro) by
24
+ Marc Hoersken, licensed under the Apache License 2.0. The full history of the original
25
+ is kept in this repository. All files changed since the fork are changed by this
26
+ project's contributors; see the git history for details. The license text is in
27
+ `LICENSE` and applies to this repository unchanged.
28
+
29
+ ## Example usage
30
+
31
+ See the main section in `pywmswebcontrolpro/webcontrol.py`.
32
+
33
+ ## Status refresh
34
+
35
+ `Destination.refresh(retries=2)` requests the status of a destination and returns a
36
+ `RefreshOutcome` with the result (`ok`, `busy`, `empty` or `error`) and the values
37
+ of the actions. Only a "radio busy" answer is retried; with `retries=0` exactly one
38
+ request is sent. On any failure the previous values stay unchanged. The outcome is
39
+ truthy only for `ok`.
40
+
41
+ `WebControlPro(request_spacing=...)` sets the minimum pause between two status requests
42
+ to the hub (default 0.5 seconds).
43
+
44
+ Failed refreshes are counted per destination (`Destination.stats`, also part of
45
+ `diag()`) instead of being logged. A warning is logged once when a destination has
46
+ failed continuously for `stale_warning_after` (default 24 hours) and once when it
47
+ delivers data again.
48
+
49
+ ## Settings, movement model and stored state
50
+
51
+ Each action keeps its data in `<persist>/<destination id>/<action id>.json`, the file
52
+ that already holds the `overwrites` (for example the rotation limits). A drive action
53
+ (for example `AwningDrive`, `ValanceDrive` or `RollerShutterBlindDrive`) stores in it:
54
+
55
+ - `settings`: the travel times for a full drive, open and close, in seconds
56
+ (`Action.set_travel_time`, 0 means unknown).
57
+ - `state`: the last known position and tilt and the target of a running drive.
58
+
59
+ The file is written atomically and serialized against concurrent access, unknown keys
60
+ and the `overwrites` of older versions are kept. Without a persist path nothing is
61
+ stored.
62
+
63
+ `Action.model` is the movement model of the cover driven by this action: `start_move`,
64
+ `stop`, `set_tilt` and `apply_measurement` calculate the displayed position, which is
65
+ replaced by a measured value when the cover stands still. Because the model belongs to
66
+ the drive action, an awning and its valance, which share a destination, move
67
+ independently. After a restart the position is restored from the file and marked as
68
+ `restored` until a measurement arrives. Call `Action.sync()` after commands and
69
+ measurements to store changes; `Destination.refresh` does it as well.
70
+
71
+ ## Development
72
+
73
+ ```bash
74
+ python -m pip install -r requirements_lint.txt -r requirements_test.txt
75
+ ruff check . && ruff format --check .
76
+ python -m pytest
77
+ ```
78
+
79
+ The version is derived from git tags (`hatch-vcs`).