pyvesync 3.0.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.
Files changed (50) hide show
  1. pyvesync/__init__.py +5 -0
  2. pyvesync/base_devices/__init__.py +35 -0
  3. pyvesync/base_devices/bulb_base.py +330 -0
  4. pyvesync/base_devices/fan_base.py +392 -0
  5. pyvesync/base_devices/fryer_base.py +68 -0
  6. pyvesync/base_devices/humidifier_base.py +462 -0
  7. pyvesync/base_devices/outlet_base.py +327 -0
  8. pyvesync/base_devices/purifier_base.py +525 -0
  9. pyvesync/base_devices/switch_base.py +271 -0
  10. pyvesync/base_devices/thermostat_base.py +283 -0
  11. pyvesync/base_devices/vesyncbasedevice.py +572 -0
  12. pyvesync/const.py +825 -0
  13. pyvesync/device_container.py +373 -0
  14. pyvesync/device_map.py +1172 -0
  15. pyvesync/devices/__init__.py +1 -0
  16. pyvesync/devices/vesyncbulb.py +1126 -0
  17. pyvesync/devices/vesyncfan.py +233 -0
  18. pyvesync/devices/vesynchumidifier.py +808 -0
  19. pyvesync/devices/vesynckitchen.py +651 -0
  20. pyvesync/devices/vesyncoutlet.py +954 -0
  21. pyvesync/devices/vesyncpurifier.py +990 -0
  22. pyvesync/devices/vesyncswitch.py +415 -0
  23. pyvesync/devices/vesyncthermostat.py +171 -0
  24. pyvesync/models/__init__.py +11 -0
  25. pyvesync/models/base_models.py +129 -0
  26. pyvesync/models/bulb_models.py +197 -0
  27. pyvesync/models/bypass_models.py +289 -0
  28. pyvesync/models/fan_models.py +47 -0
  29. pyvesync/models/fryer_models.py +34 -0
  30. pyvesync/models/home_models.py +169 -0
  31. pyvesync/models/humidifier_models.py +202 -0
  32. pyvesync/models/outlet_models.py +220 -0
  33. pyvesync/models/purifier_models.py +261 -0
  34. pyvesync/models/switch_models.py +101 -0
  35. pyvesync/models/thermostat_models.py +134 -0
  36. pyvesync/models/vesync_models.py +439 -0
  37. pyvesync/utils/__init__.py +1 -0
  38. pyvesync/utils/colors.py +250 -0
  39. pyvesync/utils/device_mixins.py +362 -0
  40. pyvesync/utils/enum_utils.py +26 -0
  41. pyvesync/utils/errors.py +867 -0
  42. pyvesync/utils/helpers.py +700 -0
  43. pyvesync/utils/logs.py +525 -0
  44. pyvesync/vesync.py +638 -0
  45. pyvesync/vesynchome.py +152 -0
  46. pyvesync-3.0.0.dist-info/METADATA +567 -0
  47. pyvesync-3.0.0.dist-info/RECORD +50 -0
  48. pyvesync-3.0.0.dist-info/WHEEL +5 -0
  49. pyvesync-3.0.0.dist-info/licenses/LICENSE +21 -0
  50. pyvesync-3.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,700 @@
1
+ """Helper functions for VeSync API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import logging
7
+ import re
8
+ import time
9
+ from collections.abc import Iterator
10
+ from dataclasses import InitVar, dataclass, field
11
+ from typing import TYPE_CHECKING, Any, TypeVar
12
+
13
+ import orjson
14
+ from mashumaro.exceptions import InvalidFieldValue, MissingField, UnserializableField
15
+ from mashumaro.mixins.orjson import DataClassORJSONMixin
16
+ from typing_extensions import deprecated
17
+
18
+ from pyvesync.const import (
19
+ APP_VERSION,
20
+ BYPASS_HEADER_UA,
21
+ DEFAULT_REGION,
22
+ KELVIN_MAX,
23
+ KELVIN_MIN,
24
+ MOBILE_ID,
25
+ PHONE_BRAND,
26
+ PHONE_OS,
27
+ USER_TYPE,
28
+ ConnectionStatus,
29
+ )
30
+ from pyvesync.utils.errors import ErrorCodes, ErrorTypes, ResponseInfo
31
+ from pyvesync.utils.logs import LibraryLogger
32
+
33
+ if TYPE_CHECKING:
34
+ from pyvesync.base_devices.vesyncbasedevice import VeSyncBaseDevice
35
+ from pyvesync.vesync import VeSync
36
+
37
+
38
+ T = TypeVar('T')
39
+ T_MODEL = TypeVar('T_MODEL', bound=DataClassORJSONMixin)
40
+
41
+ _LOGGER = logging.getLogger(__name__)
42
+
43
+ NUMERIC_OPT = float | str | None
44
+
45
+ NUMERIC_STRICT = float | str
46
+
47
+ REQUEST_T = dict[str, Any]
48
+
49
+
50
+ class Validators:
51
+ """Methods to validate input."""
52
+
53
+ @staticmethod
54
+ def validate_range(
55
+ value: NUMERIC_OPT, minimum: NUMERIC_STRICT, maximum: NUMERIC_STRICT
56
+ ) -> bool:
57
+ """Validate number is within range."""
58
+ if value is None:
59
+ return False
60
+ try:
61
+ return float(minimum) <= float(value) <= float(maximum)
62
+ except (ValueError, TypeError):
63
+ return False
64
+
65
+ @classmethod
66
+ def validate_zero_to_hundred(cls, value: NUMERIC_OPT) -> bool:
67
+ """Validate number is a percentage."""
68
+ return Validators.validate_range(value, 0, 100)
69
+
70
+ @classmethod
71
+ def validate_hsv(
72
+ cls, hue: NUMERIC_OPT, saturation: NUMERIC_OPT, value: NUMERIC_OPT
73
+ ) -> bool:
74
+ """Validate HSV values."""
75
+ return (
76
+ cls.validate_range(hue, 0, 360)
77
+ and cls.validate_zero_to_hundred(saturation)
78
+ and cls.validate_zero_to_hundred(value)
79
+ )
80
+
81
+ @classmethod
82
+ def validate_rgb(
83
+ cls, red: NUMERIC_OPT, green: NUMERIC_OPT, blue: NUMERIC_OPT
84
+ ) -> bool:
85
+ """Validate RGB values."""
86
+ return all(cls.validate_range(val, 0, 255) for val in (red, green, blue))
87
+
88
+
89
+ class Converters:
90
+ """Helper functions to convert units."""
91
+
92
+ @staticmethod
93
+ def color_temp_kelvin_to_pct(kelvin: int) -> int:
94
+ """Convert Kelvin to percentage."""
95
+ return int((kelvin - KELVIN_MIN) / (KELVIN_MAX - KELVIN_MIN) * 100)
96
+
97
+ @staticmethod
98
+ def color_temp_pct_to_kelvin(pct: int) -> int:
99
+ """Convert percentage to Kelvin."""
100
+ return int(KELVIN_MIN + ((pct / 100) * (KELVIN_MAX - KELVIN_MIN)))
101
+
102
+ @staticmethod
103
+ def temperature_kelvin_to_celsius(kelvin: int) -> float:
104
+ """Convert Kelvin to Celsius."""
105
+ return kelvin - 273.15
106
+
107
+ @staticmethod
108
+ def temperature_celsius_to_kelvin(celsius: float) -> int:
109
+ """Convert Celsius to Kelvin."""
110
+ return int(celsius + 273.15)
111
+
112
+ @staticmethod
113
+ def temperature_fahrenheit_to_celsius(fahrenheit: float) -> float:
114
+ """Convert Fahrenheit to Celsius."""
115
+ return (fahrenheit - 32) * 5.0 / 9.0
116
+
117
+ @staticmethod
118
+ def temperature_celsius_to_fahrenheit(celsius: float) -> float:
119
+ """Convert Celsius to Fahrenheit."""
120
+ return celsius * 9.0 / 5.0 + 32
121
+
122
+
123
+ class Helpers:
124
+ """VeSync Helper Functions."""
125
+
126
+ @staticmethod
127
+ def model_maker(
128
+ logger: logging.Logger,
129
+ model: type[T_MODEL],
130
+ method_name: str,
131
+ data: dict[str, Any],
132
+ device: VeSyncBaseDevice | None = None,
133
+ ) -> T_MODEL | None:
134
+ """Create a model instance from a dictionary.
135
+
136
+ This method catches common errors that occur when parsing the
137
+ API response and returns None if the data is invalid. Enable debug
138
+ or verbose logging to see more information.
139
+
140
+
141
+ Args:
142
+ logger (logging.Logger): Logger instance.
143
+ model (type[T_MODEL]): Model class to create an instance of.
144
+ method_name (str): Name of the method used in API call.
145
+ device (VeSyncBaseDevice): Instance of VeSyncBaseDevice.
146
+ data (dict[str, Any] | None): Dictionary to create the model from.
147
+
148
+ Returns:
149
+ T_MODEL: Instance of the model class.
150
+ """
151
+ try:
152
+ model_instance = model.from_dict(data)
153
+ except (MissingField, UnserializableField, InvalidFieldValue) as err:
154
+ LibraryLogger.log_mashumaro_response_error(
155
+ logger,
156
+ method_name,
157
+ data,
158
+ err,
159
+ device,
160
+ )
161
+ return None
162
+ return model_instance
163
+
164
+ @staticmethod
165
+ def bump_level(level: T | None, levels: list[T]) -> T:
166
+ """Increment level by one returning to first level if at last.
167
+
168
+ Args:
169
+ level (T | None): Current level.
170
+ levels (list[T]): List of levels.
171
+ """
172
+ if level in levels:
173
+ idx = levels.index(level)
174
+ if idx < len(levels) - 1:
175
+ return levels[idx + 1]
176
+ return levels[0]
177
+
178
+ @staticmethod
179
+ def try_json_loads(data: str | bytes | None) -> dict | None:
180
+ """Try to load JSON data.
181
+
182
+ Gracefully handle errors and return None if loading fails.
183
+
184
+ Args:
185
+ data (str | bytes | None): JSON data to load.
186
+
187
+ Returns:
188
+ dict | None: Parsed JSON data or None if loading fails.
189
+ """
190
+ if data is None:
191
+ return None
192
+ try:
193
+ return orjson.loads(data)
194
+ except (orjson.JSONDecodeError, TypeError):
195
+ return None
196
+
197
+ @classmethod
198
+ def process_dev_response( # noqa: C901,PLR0912
199
+ cls,
200
+ logger: logging.Logger,
201
+ method_name: str,
202
+ device: VeSyncBaseDevice,
203
+ r_dict: dict | None,
204
+ ) -> dict | None:
205
+ """Process JSON response from Bytes.
206
+
207
+ Parses bytes and checks for errors common to all JSON
208
+ responses, included checking the "code" key for non-zero
209
+ values. Outputs error to passed logger with formatted string
210
+ if an error is found. This also saves the response code information
211
+ to the `device.last_response` attribute.
212
+
213
+ Args:
214
+ logger (logging.Logger): Logger instance.
215
+ method_name (str): Method used in API call.
216
+ r_dict (dict | None): JSON response from API.
217
+ device (VeSyncBaseDevice): Instance of VeSyncBaseDevice.
218
+
219
+ Returns:
220
+ dict | None: Parsed JSON response or None if there was an error.
221
+ """
222
+ device.state.update_ts()
223
+ if r_dict is None:
224
+ logger.error('No response from API for %s', method_name)
225
+ device.last_response = ResponseInfo(
226
+ name='INVALID_RESPONSE',
227
+ error_type=ErrorTypes.BAD_RESPONSE,
228
+ message=f'No response from API for {method_name}',
229
+ )
230
+ return None
231
+
232
+ error_code = (
233
+ r_dict.get('error', {}).get('code')
234
+ if 'error' in r_dict
235
+ else r_dict.get('code')
236
+ )
237
+
238
+ new_msg = None
239
+ # Get error codes from nested dictionaries.
240
+ if error_code == 0:
241
+ internal_codes = cls._get_internal_codes(r_dict)
242
+ for code_tuple in internal_codes:
243
+ if code_tuple[0] != 0:
244
+ error_code = code_tuple[0]
245
+ new_msg = code_tuple[1]
246
+ break
247
+
248
+ if isinstance(error_code, int):
249
+ error_int = error_code
250
+ elif isinstance(error_code, str):
251
+ try:
252
+ error_int = int(error_code)
253
+ except ValueError:
254
+ error_int = -999999999
255
+ else:
256
+ error_int = -999999999
257
+ error_info = ErrorCodes.get_error_info(error_int)
258
+ if new_msg is not None:
259
+ if error_info.error_type == ErrorTypes.UNKNOWN_ERROR:
260
+ error_info.message = new_msg
261
+ else:
262
+ error_info.message = f'{error_info.message} - {new_msg}'
263
+ if error_info.device_online is False:
264
+ device.state.connection_status = ConnectionStatus.OFFLINE
265
+ LibraryLogger.log_device_return_code(
266
+ logger,
267
+ method_name,
268
+ device.device_name,
269
+ device.device_type,
270
+ error_int,
271
+ f'{error_info.error_type} - {error_info.name} {error_info.message}',
272
+ )
273
+ device.last_response = error_info
274
+ if error_int != 0:
275
+ return None
276
+ return r_dict
277
+
278
+ @staticmethod
279
+ def get_class_attributes(target_class: object, keys: list[str]) -> dict[str, Any]:
280
+ """Find matching attributes, static methods, and class methods from list of keys.
281
+
282
+ This function is case insensitive and will remove underscores from the keys before
283
+ comparing them to the class attributes. The provided keys will be returned in the
284
+ same format if found
285
+
286
+ Args:
287
+ target_class (object): Class to search for attributes
288
+ keys (list[str]): List of keys to search for
289
+
290
+ Returns:
291
+ dict[str, Any]: Dictionary of keys and their values from the class
292
+ """
293
+ alias_map = {
294
+ 'userCountryCode': 'countrycode',
295
+ 'deviceId': 'cid',
296
+ 'homeTimeZone': 'timezone',
297
+ 'configModel': 'configmodule',
298
+ 'region': 'countrycode',
299
+ }
300
+
301
+ def normalize_name(name: str) -> str:
302
+ """Normalize a string by removing underscores and making it lowercase."""
303
+ return re.sub(r'_', '', name).lower()
304
+
305
+ def get_value(attr_name: str) -> str | float | None:
306
+ """Get value from attribute."""
307
+ attr = getattr(target_class, attr_name)
308
+ try:
309
+ return attr() if callable(attr) else attr # type: ignore[no-any-return]
310
+ except TypeError:
311
+ return None
312
+
313
+ result = {}
314
+ normalized_keys = {normalize_name(key): key for key in keys}
315
+ normalized_aliases = [normalize_name(key) for key in alias_map.values()]
316
+
317
+ for attr_name in dir(target_class):
318
+ normalized_attr = normalize_name(attr_name)
319
+ if normalized_attr in normalized_keys:
320
+ attr_val = get_value(attr_name)
321
+ if attr_val is not None:
322
+ result[normalized_keys[normalized_attr]] = attr_val
323
+ if normalized_attr in normalized_aliases:
324
+ attr_val = get_value(attr_name)
325
+ if attr_val is not None:
326
+ key_index = normalized_aliases.index(normalized_attr)
327
+ key_val = list(alias_map.keys())[key_index]
328
+ if key_val in keys:
329
+ result[key_val] = attr_val
330
+
331
+ return result
332
+
333
+ @staticmethod
334
+ def req_legacy_headers(manager: VeSync) -> dict[str, str]:
335
+ """Build header for legacy api GET requests.
336
+
337
+ Args:
338
+ manager (VeSyncManager): Instance of VeSyncManager.
339
+
340
+ Returns:
341
+ dict: Header dictionary for api requests.
342
+
343
+ Examples:
344
+ >>> req_headers(manager)
345
+ {
346
+ 'accept-language': 'en',
347
+ 'accountId': manager.account_id,
348
+ 'appVersion': APP_VERSION,
349
+ 'content-type': 'application/json',
350
+ 'tk': manager.token,
351
+ 'tz': manager.time_zone,
352
+ }
353
+
354
+ """
355
+ return {
356
+ 'accept-language': 'en',
357
+ 'accountId': manager.account_id, # type: ignore[dict-item]
358
+ 'appVersion': APP_VERSION,
359
+ 'content-type': 'application/json',
360
+ 'tk': manager.token, # type: ignore[dict-item]
361
+ 'tz': manager.time_zone,
362
+ }
363
+
364
+ @staticmethod
365
+ def req_header_bypass() -> dict[str, str]:
366
+ """Build header for api requests on 'bypass' endpoint.
367
+
368
+ Returns:
369
+ dict: Header dictionary for api requests.
370
+
371
+ Examples:
372
+ >>> req_header_bypass()
373
+ {
374
+ 'Content-Type': 'application/json; charset=UTF-8',
375
+ 'User-Agent': BYPASS_HEADER_UA,
376
+ }
377
+ """
378
+ return {
379
+ 'Content-Type': 'application/json; charset=UTF-8',
380
+ 'User-Agent': BYPASS_HEADER_UA,
381
+ }
382
+
383
+ @staticmethod
384
+ def _req_body_base(manager: VeSync) -> dict[str, str]:
385
+ """Return universal keys for body of api requests.
386
+
387
+ Args:
388
+ manager (VeSyncManager): Instance of VeSyncManager.
389
+
390
+ Returns:
391
+ dict: Body dictionary for api requests.
392
+
393
+ Examples:
394
+ >>> req_body_base(manager)
395
+ {
396
+ 'timeZone': manager.time_zone,
397
+ 'acceptLanguage': 'en',
398
+ }
399
+ """
400
+ return {'timeZone': manager.time_zone, 'acceptLanguage': 'en'}
401
+
402
+ @staticmethod
403
+ def _req_body_auth(manager: VeSync) -> REQUEST_T:
404
+ """Keys for authenticating api requests.
405
+
406
+ Args:
407
+ manager (VeSyncManager): Instance of VeSyncManager.
408
+
409
+ Returns:
410
+ dict: Authentication keys for api requests.
411
+
412
+ Examples:
413
+ >>> req_body_auth(manager)
414
+ {
415
+ 'accountID': manager.account_id,
416
+ 'token': manager.token,
417
+ }
418
+ """
419
+ return {'accountID': manager.account_id, 'token': manager.token}
420
+
421
+ @staticmethod
422
+ @deprecated('This is a legacy function and will be removed in a future release.')
423
+ def _req_body_details() -> REQUEST_T:
424
+ """Detail keys for api requests.
425
+
426
+ This method is deprecated, use `get_class_attributes` instead.
427
+
428
+ Returns:
429
+ dict: Detail keys for api requests.
430
+
431
+ Examples:
432
+ >>> req_body_details()
433
+ {
434
+ 'appVersion': APP_VERSION,
435
+ 'phoneBrand': PHONE_BRAND,
436
+ 'phoneOS': PHONE_OS,
437
+ 'traceId': str(int(time.time())),
438
+ }
439
+ """
440
+ return {
441
+ 'appVersion': APP_VERSION,
442
+ 'phoneBrand': PHONE_BRAND,
443
+ 'phoneOS': PHONE_OS,
444
+ 'traceId': str(int(time.time())),
445
+ }
446
+
447
+ @classmethod
448
+ @deprecated('This is a legacy function and will be removed in a future release.')
449
+ def req_body(cls, manager: VeSync, type_: str) -> REQUEST_T: # noqa: C901
450
+ """Builder for body of api requests.
451
+
452
+ This method is deprecated, use `get_class_attributes` instead.
453
+
454
+ Args:
455
+ manager (VeSyncManager): Instance of VeSyncManager.
456
+ type_ (str): Type of request to build body for.
457
+
458
+ Returns:
459
+ dict: Body dictionary for api requests.
460
+
461
+ Note:
462
+ The body dictionary will be built based on the type of request.
463
+ The type of requests include:
464
+ - login
465
+ - devicestatus
466
+ - devicelist
467
+ - devicedetail
468
+ - energy_week
469
+ - energy_month
470
+ - energy_year
471
+ - bypass
472
+ - bypassV2
473
+ - bypass_config
474
+ """
475
+ body: REQUEST_T = cls._req_body_base(manager)
476
+
477
+ if type_ == 'login':
478
+ body |= cls._req_body_details()
479
+ body |= {
480
+ 'email': manager.username,
481
+ 'password': cls.hash_password(manager.password),
482
+ 'devToken': '',
483
+ 'userType': USER_TYPE,
484
+ 'method': 'login',
485
+ }
486
+ return body
487
+
488
+ body |= cls._req_body_auth(manager)
489
+
490
+ if type_ == 'devicestatus':
491
+ return body
492
+
493
+ body |= cls._req_body_details()
494
+
495
+ if type_ == 'devicelist':
496
+ body['method'] = 'devices'
497
+ body['pageNo'] = '1'
498
+ body['pageSize'] = '100'
499
+
500
+ elif type_ == 'devicedetail':
501
+ body['method'] = 'devicedetail'
502
+ body['mobileId'] = MOBILE_ID
503
+
504
+ elif type_ == 'energy_week':
505
+ body['method'] = 'energyweek'
506
+ body['mobileId'] = MOBILE_ID
507
+
508
+ elif type_ == 'energy_month':
509
+ body['method'] = 'energymonth'
510
+ body['mobileId'] = MOBILE_ID
511
+
512
+ elif type_ == 'energy_year':
513
+ body['method'] = 'energyyear'
514
+ body['mobileId'] = MOBILE_ID
515
+
516
+ elif type_ == 'bypass':
517
+ body['method'] = 'bypass'
518
+
519
+ elif type_ == 'bypassV2':
520
+ body['deviceRegion'] = DEFAULT_REGION
521
+ body['method'] = 'bypassV2'
522
+
523
+ elif type_ == 'bypass_config':
524
+ body['method'] = 'firmwareUpdateInfo'
525
+
526
+ return body
527
+
528
+ @staticmethod
529
+ def calculate_hex(hex_string: str) -> float:
530
+ """Credit for conversion to itsnotlupus/vesync_wsproxy.
531
+
532
+ Hex conversion for legacy outlet power and voltage.
533
+ """
534
+ hex_conv = hex_string.split(':')
535
+ return (int(hex_conv[0], 16) + int(hex_conv[1], 16)) / 8192
536
+
537
+ @staticmethod
538
+ def hash_password(string: str) -> str:
539
+ """Encode password."""
540
+ return hashlib.md5(string.encode('utf-8')).hexdigest() # noqa: S324
541
+
542
+ @staticmethod
543
+ def _get_internal_codes(response: dict) -> list[tuple[int, str | None]]:
544
+ """Get all error codes from nested dictionary.
545
+
546
+ Args:
547
+ response (dict): API response.
548
+
549
+ Returns:
550
+ list[int]: List of error codes.
551
+ """
552
+ error_keys = ['error', 'code', 'device_error_code', 'errorCode']
553
+
554
+ def extract_all_error_codes(
555
+ key: str, var: dict
556
+ ) -> Iterator[tuple[int, str | None]]:
557
+ """Find all error code keys in nested dictionary."""
558
+ if hasattr(var, 'items'):
559
+ for k, v in var.items():
560
+ if k == key and int(v) != 0:
561
+ msg = var.get('msg') or var.get('result', {}).get('msg')
562
+ yield v, msg
563
+ if isinstance(v, dict):
564
+ yield from extract_all_error_codes(key, v)
565
+ elif isinstance(v, list):
566
+ for item in v:
567
+ yield from extract_all_error_codes(key, item)
568
+
569
+ errors = []
570
+ for error_key in error_keys:
571
+ errors.extend(list(extract_all_error_codes(error_key, response)))
572
+ return errors
573
+
574
+
575
+ @dataclass(repr=False)
576
+ class Timer:
577
+ """Dataclass to hold state of timers.
578
+
579
+ Note:
580
+ This should be used by VeSync device instances to manage internal status,
581
+ does not interact with the VeSync API.
582
+
583
+ Args:
584
+ timer_duration (int): Length of timer in seconds
585
+ action (str): Action to perform when timer is done
586
+ id (int): ID of timer, defaults to 1
587
+ remaining (int): Time remaining on timer in seconds, defaults to None
588
+ _update_time (int): Last updated unix timestamp in seconds, defaults to None
589
+
590
+ Attributes:
591
+ update_time (str): Timestamp of last update
592
+ status (str): Status of timer, one of 'active', 'paused', 'done'
593
+ time_remaining (int): Time remaining on timer in seconds
594
+ running (bool): True if timer is running
595
+ paused (bool): True if timer is paused
596
+ done (bool): True if timer is done
597
+ """
598
+
599
+ timer_duration: int
600
+ action: str
601
+ id: int = 1
602
+ remaining: InitVar[int | None] = None
603
+ _status: str = field(default='active', init=False, repr=False)
604
+ _remain: int = field(default=0, init=False, repr=False)
605
+ _update_time: int = int(time.time())
606
+
607
+ def __post_init__(self, remaining: int | None) -> None:
608
+ """Set remaining time if provided."""
609
+ if remaining is not None:
610
+ self._remain = remaining
611
+ else:
612
+ self._remain = self.timer_duration
613
+
614
+ def __repr__(self) -> str:
615
+ """Return string representation of the Timer object.
616
+
617
+ Returns:
618
+ str: String representation of the Timer object.
619
+ """
620
+ return (
621
+ f'Timer(id={self.id}, duration={self.timer_duration}, '
622
+ f'status={self.status}, remaining={self.time_remaining})'
623
+ )
624
+
625
+ def update_ts(self) -> None:
626
+ """Update timestamp."""
627
+ self._update_time = int(time.time())
628
+
629
+ @property
630
+ def status(self) -> str:
631
+ """Return status of timer."""
632
+ if self._status in ('paused', 'done'):
633
+ return self._status
634
+ if self.time_remaining <= 0:
635
+ self._status = 'done'
636
+ return 'done'
637
+ return 'active'
638
+
639
+ @property
640
+ def time_remaining(self) -> int:
641
+ """Return remaining seconds."""
642
+ if self._status == 'paused':
643
+ return self._remain
644
+ if self._status == 'done':
645
+ return 0
646
+
647
+ # 'active' state - compute how much time has ticked away
648
+ elapsed = time.time() - self._update_time
649
+ current_remaining = self._remain - elapsed
650
+
651
+ # If we've run out of time, mark it done
652
+ if current_remaining <= 0:
653
+ return 0
654
+ return int(current_remaining)
655
+
656
+ @property
657
+ def running(self) -> bool:
658
+ """Check if timer is active."""
659
+ return self.time_remaining > 0 and self.status == 'active'
660
+
661
+ @property
662
+ def paused(self) -> bool:
663
+ """Check if timer is paused."""
664
+ return bool(self.status == 'paused')
665
+
666
+ @property
667
+ def done(self) -> bool:
668
+ """Check if timer is complete."""
669
+ return bool(self.time_remaining <= 0 or self._status == 'done')
670
+
671
+ def end(self) -> None:
672
+ """Change status of timer to done."""
673
+ self._status = 'done'
674
+ self._remain = 0
675
+
676
+ def start(self) -> None:
677
+ """Restart paused timer."""
678
+ if self._status != 'paused':
679
+ return
680
+ self._update_time = int(time.time())
681
+ self._status = 'active'
682
+
683
+ def pause(self) -> None:
684
+ """Pauses the timer if it's active.
685
+
686
+ Performs the following steps:
687
+ - Calculate the up-to-date remaining time,
688
+ - Update internal counters,
689
+ - Set _status to 'paused'.
690
+ """
691
+ if self._status == 'active':
692
+ # Update the time_remaining based on elapsed
693
+ current_remaining = self.time_remaining
694
+ if current_remaining <= 0:
695
+ self._status = 'done'
696
+ self._remain = 0
697
+ else:
698
+ self._status = 'paused'
699
+ self._remain = current_remaining
700
+ self._update_time = int(time.time())