pyvesync 3.0.0b0__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 (49) hide show
  1. pyvesync/__init__.py +11 -0
  2. pyvesync/base_devices/__init__.py +35 -0
  3. pyvesync/base_devices/bulb_base.py +348 -0
  4. pyvesync/base_devices/fan_base.py +398 -0
  5. pyvesync/base_devices/fryer_base.py +69 -0
  6. pyvesync/base_devices/humidifier_base.py +460 -0
  7. pyvesync/base_devices/outlet_base.py +315 -0
  8. pyvesync/base_devices/purifier_base.py +518 -0
  9. pyvesync/base_devices/switch_base.py +270 -0
  10. pyvesync/base_devices/thermostat_base.py +277 -0
  11. pyvesync/base_devices/vesyncbasedevice.py +580 -0
  12. pyvesync/const.py +749 -0
  13. pyvesync/device_container.py +360 -0
  14. pyvesync/device_map.py +1090 -0
  15. pyvesync/devices/__init__.py +1 -0
  16. pyvesync/devices/vesyncbulb.py +1254 -0
  17. pyvesync/devices/vesyncfan.py +238 -0
  18. pyvesync/devices/vesynchumidifier.py +785 -0
  19. pyvesync/devices/vesynckitchen.py +664 -0
  20. pyvesync/devices/vesyncoutlet.py +962 -0
  21. pyvesync/devices/vesyncpurifier.py +951 -0
  22. pyvesync/devices/vesyncswitch.py +449 -0
  23. pyvesync/devices/vesyncthermostat.py +175 -0
  24. pyvesync/models/__init__.py +11 -0
  25. pyvesync/models/base_models.py +117 -0
  26. pyvesync/models/bulb_models.py +173 -0
  27. pyvesync/models/bypass_models.py +264 -0
  28. pyvesync/models/fan_models.py +46 -0
  29. pyvesync/models/fryer_models.py +28 -0
  30. pyvesync/models/home_models.py +158 -0
  31. pyvesync/models/humidifier_models.py +187 -0
  32. pyvesync/models/outlet_models.py +194 -0
  33. pyvesync/models/purifier_models.py +204 -0
  34. pyvesync/models/switch_models.py +89 -0
  35. pyvesync/models/thermostat_models.py +122 -0
  36. pyvesync/models/vesync_models.py +252 -0
  37. pyvesync/utils/__init__.py +1 -0
  38. pyvesync/utils/colors.py +248 -0
  39. pyvesync/utils/device_mixins.py +366 -0
  40. pyvesync/utils/enum_utils.py +25 -0
  41. pyvesync/utils/errors.py +859 -0
  42. pyvesync/utils/helpers.py +696 -0
  43. pyvesync/utils/logs.py +500 -0
  44. pyvesync/vesync.py +445 -0
  45. pyvesync-3.0.0b0.dist-info/METADATA +527 -0
  46. pyvesync-3.0.0b0.dist-info/RECORD +49 -0
  47. pyvesync-3.0.0b0.dist-info/WHEEL +5 -0
  48. pyvesync-3.0.0b0.dist-info/licenses/LICENSE +21 -0
  49. pyvesync-3.0.0b0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,696 @@
1
+ """Helper functions for VeSync API."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import logging
6
+ import re
7
+ import time
8
+ from collections.abc import Iterator
9
+ from dataclasses import InitVar, dataclass, field
10
+ from typing import TYPE_CHECKING, Any, TypeVar, Union
11
+
12
+
13
+ from typing_extensions import deprecated
14
+ from mashumaro.exceptions import MissingField, InvalidFieldValue, UnserializableField
15
+ from mashumaro.mixins.orjson import DataClassORJSONMixin
16
+ import orjson
17
+
18
+ from pyvesync.const import (
19
+ APP_VERSION,
20
+ BYPASS_HEADER_UA,
21
+ DEFAULT_REGION,
22
+ MOBILE_ID,
23
+ PHONE_BRAND,
24
+ PHONE_OS,
25
+ USER_TYPE,
26
+ ConnectionStatus,
27
+ )
28
+ from pyvesync.utils.errors import ErrorCodes, ErrorTypes, ResponseInfo
29
+ from pyvesync.utils.logs import LibraryLogger
30
+
31
+ if TYPE_CHECKING:
32
+ from pyvesync.base_devices.vesyncbasedevice import VeSyncBaseDevice
33
+ from pyvesync.vesync import VeSync
34
+
35
+
36
+ T = TypeVar('T')
37
+ T_MODEL = TypeVar('T_MODEL', bound=DataClassORJSONMixin)
38
+
39
+ _LOGGER = logging.getLogger(__name__)
40
+
41
+ API_BASE_URL = 'https://smartapi.vesync.com'
42
+ API_RATE_LIMIT = 30
43
+ NUMERIC_OPT = Union[float, str, None]
44
+
45
+ NUMERIC_STRICT = Union[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(cls, hue: NUMERIC_OPT, saturation: NUMERIC_OPT,
72
+ value: NUMERIC_OPT) -> bool:
73
+ """Validate HSV values."""
74
+ return (
75
+ cls.validate_range(hue, 0, 360)
76
+ and cls.validate_zero_to_hundred(saturation)
77
+ and cls.validate_zero_to_hundred(value)
78
+ )
79
+
80
+ @classmethod
81
+ def validate_rgb(cls, red: NUMERIC_OPT, green: NUMERIC_OPT,
82
+ blue: NUMERIC_OPT) -> bool:
83
+ """Validate RGB values."""
84
+ return all(
85
+ cls.validate_range(val, 0, 255) for val in (red, green, blue)
86
+ )
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 - 2700) / 153)
96
+
97
+ @staticmethod
98
+ def color_temp_pct_to_kelvin(pct: int) -> int:
99
+ """Convert percentage to Kelvin."""
100
+ return int(pct * 153 + 2700)
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
+ try:
249
+ if isinstance(error_code, str):
250
+ error_int = int(error_code)
251
+ elif isinstance(error_code, int):
252
+ error_int = error_code
253
+ else:
254
+ raise TypeError
255
+ except TypeError:
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
+ result = {}
313
+ normalized_keys = {normalize_name(key): key for key in keys}
314
+ normalized_aliases = [normalize_name(key) for key in alias_map.values()]
315
+
316
+ for attr_name in dir(target_class):
317
+ normalized_attr = normalize_name(attr_name)
318
+ if normalized_attr in normalized_keys:
319
+ attr_val = get_value(attr_name)
320
+ if attr_val is not None:
321
+ result[normalized_keys[normalized_attr]] = attr_val
322
+ if normalized_attr in normalized_aliases:
323
+ attr_val = get_value(attr_name)
324
+ if attr_val is not None:
325
+ key_index = normalized_aliases.index(normalized_attr)
326
+ key_val = list(alias_map.keys())[key_index]
327
+ if key_val in keys:
328
+ result[key_val] = attr_val
329
+
330
+ return result
331
+
332
+ @staticmethod
333
+ def req_legacy_headers(manager: VeSync) -> dict[str, str]:
334
+ """Build header for legacy api GET requests.
335
+
336
+ Args:
337
+ manager (VeSyncManager): Instance of VeSyncManager.
338
+
339
+ Returns:
340
+ dict: Header dictionary for api requests.
341
+
342
+ Examples:
343
+ >>> req_headers(manager)
344
+ {
345
+ 'accept-language': 'en',
346
+ 'accountId': manager.account_id,
347
+ 'appVersion': APP_VERSION,
348
+ 'content-type': 'application/json',
349
+ 'tk': manager.token,
350
+ 'tz': manager.time_zone,
351
+ }
352
+
353
+ """
354
+ return {
355
+ 'accept-language': 'en',
356
+ 'accountId': manager.account_id, # type: ignore[dict-item]
357
+ 'appVersion': APP_VERSION,
358
+ 'content-type': 'application/json',
359
+ 'tk': manager.token, # type: ignore[dict-item]
360
+ 'tz': manager.time_zone,
361
+ }
362
+
363
+ @staticmethod
364
+ def req_header_bypass() -> dict[str, str]:
365
+ """Build header for api requests on 'bypass' endpoint.
366
+
367
+ Returns:
368
+ dict: Header dictionary for api requests.
369
+
370
+ Examples:
371
+ >>> req_header_bypass()
372
+ {
373
+ 'Content-Type': 'application/json; charset=UTF-8',
374
+ 'User-Agent': BYPASS_HEADER_UA,
375
+ }
376
+ """
377
+ return {
378
+ 'Content-Type': 'application/json; charset=UTF-8',
379
+ 'User-Agent': BYPASS_HEADER_UA,
380
+ }
381
+
382
+ @staticmethod
383
+ def _req_body_base(manager: VeSync) -> dict[str, str]:
384
+ """Return universal keys for body of api requests.
385
+
386
+ Args:
387
+ manager (VeSyncManager): Instance of VeSyncManager.
388
+
389
+ Returns:
390
+ dict: Body dictionary for api requests.
391
+
392
+ Examples:
393
+ >>> req_body_base(manager)
394
+ {
395
+ 'timeZone': manager.time_zone,
396
+ 'acceptLanguage': 'en',
397
+ }
398
+ """
399
+ return {'timeZone': manager.time_zone, 'acceptLanguage': 'en'}
400
+
401
+ @staticmethod
402
+ def _req_body_auth(manager: VeSync) -> REQUEST_T:
403
+ """Keys for authenticating api requests.
404
+
405
+ Args:
406
+ manager (VeSyncManager): Instance of VeSyncManager.
407
+
408
+ Returns:
409
+ dict: Authentication keys for api requests.
410
+
411
+ Examples:
412
+ >>> req_body_auth(manager)
413
+ {
414
+ 'accountID': manager.account_id,
415
+ 'token': manager.token,
416
+ }
417
+ """
418
+ return {'accountID': manager.account_id, 'token': manager.token}
419
+
420
+ @staticmethod
421
+ @deprecated("This is a legacy function and will be removed in a future release.")
422
+ def _req_body_details() -> REQUEST_T:
423
+ """Detail keys for api requests.
424
+
425
+ This method is deprecated, use `get_class_attributes` instead.
426
+
427
+ Returns:
428
+ dict: Detail keys for api requests.
429
+
430
+ Examples:
431
+ >>> req_body_details()
432
+ {
433
+ 'appVersion': APP_VERSION,
434
+ 'phoneBrand': PHONE_BRAND,
435
+ 'phoneOS': PHONE_OS,
436
+ 'traceId': str(int(time.time())),
437
+ }
438
+ """
439
+ return {
440
+ 'appVersion': APP_VERSION,
441
+ 'phoneBrand': PHONE_BRAND,
442
+ 'phoneOS': PHONE_OS,
443
+ 'traceId': str(int(time.time())),
444
+ }
445
+
446
+ @classmethod
447
+ @deprecated("This is a legacy function and will be removed in a future release.")
448
+ def req_body(cls, manager: VeSync, type_: str) -> REQUEST_T: # noqa: C901
449
+ """Builder for body of api requests.
450
+
451
+ This method is deprecated, use `get_class_attributes` instead.
452
+
453
+ Args:
454
+ manager (VeSyncManager): Instance of VeSyncManager.
455
+ type_ (str): Type of request to build body for.
456
+
457
+ Returns:
458
+ dict: Body dictionary for api requests.
459
+
460
+ Note:
461
+ The body dictionary will be built based on the type of request.
462
+ The type of requests include:
463
+ - login
464
+ - devicestatus
465
+ - devicelist
466
+ - devicedetail
467
+ - energy_week
468
+ - energy_month
469
+ - energy_year
470
+ - bypass
471
+ - bypassV2
472
+ - bypass_config
473
+ """
474
+ body: REQUEST_T = cls._req_body_base(manager)
475
+
476
+ if type_ == 'login':
477
+ body |= cls._req_body_details()
478
+ body |= {
479
+ 'email': manager.username,
480
+ 'password': cls.hash_password(manager.password),
481
+ 'devToken': '',
482
+ 'userType': USER_TYPE,
483
+ 'method': 'login'
484
+ }
485
+ return body
486
+
487
+ body |= cls._req_body_auth(manager)
488
+
489
+ if type_ == 'devicestatus':
490
+ return body
491
+
492
+ body |= cls._req_body_details()
493
+
494
+ if type_ == 'devicelist':
495
+ body['method'] = 'devices'
496
+ body['pageNo'] = '1'
497
+ body['pageSize'] = '100'
498
+
499
+ elif type_ == 'devicedetail':
500
+ body['method'] = 'devicedetail'
501
+ body['mobileId'] = MOBILE_ID
502
+
503
+ elif type_ == 'energy_week':
504
+ body['method'] = 'energyweek'
505
+ body['mobileId'] = MOBILE_ID
506
+
507
+ elif type_ == 'energy_month':
508
+ body['method'] = 'energymonth'
509
+ body['mobileId'] = MOBILE_ID
510
+
511
+ elif type_ == 'energy_year':
512
+ body['method'] = 'energyyear'
513
+ body['mobileId'] = MOBILE_ID
514
+
515
+ elif type_ == 'bypass':
516
+ body['method'] = 'bypass'
517
+
518
+ elif type_ == 'bypassV2':
519
+ body['deviceRegion'] = DEFAULT_REGION
520
+ body['method'] = 'bypassV2'
521
+
522
+ elif type_ == 'bypass_config':
523
+ body['method'] = 'firmwareUpdateInfo'
524
+
525
+ return body
526
+
527
+ @staticmethod
528
+ def calculate_hex(hex_string: str) -> float:
529
+ """Credit for conversion to itsnotlupus/vesync_wsproxy.
530
+
531
+ Hex conversion for legacy outlet power and voltage.
532
+ """
533
+ hex_conv = hex_string.split(':')
534
+ return (int(hex_conv[0], 16) + int(hex_conv[1], 16)) / 8192
535
+
536
+ @staticmethod
537
+ def hash_password(string: str) -> str:
538
+ """Encode password."""
539
+ return hashlib.md5(string.encode('utf-8')).hexdigest() # noqa: S324
540
+
541
+ @staticmethod
542
+ def _get_internal_codes(response: dict) -> list[tuple[int, str | None]]:
543
+ """Get all error codes from nested dictionary.
544
+
545
+ Args:
546
+ response (dict): API response.
547
+
548
+ Returns:
549
+ list[int]: List of error codes.
550
+ """
551
+ error_keys = ['error', 'code', 'device_error_code', 'errorCode']
552
+
553
+ def extract_all_error_codes(
554
+ key: str, var: dict
555
+ ) -> Iterator[tuple[int, str | None]]:
556
+ """Find all error code keys in nested dictionary."""
557
+ if hasattr(var, 'items'):
558
+ for k, v in var.items():
559
+ if k == key and int(v) != 0:
560
+ msg = var.get('msg') or var.get('result', {}).get('msg')
561
+ yield v, msg
562
+ if isinstance(v, dict):
563
+ yield from extract_all_error_codes(key, v)
564
+ elif isinstance(v, list):
565
+ for item in v:
566
+ yield from extract_all_error_codes(key, item)
567
+ errors = []
568
+ for error_key in error_keys:
569
+ errors.extend(list(extract_all_error_codes(error_key, response)))
570
+ return errors
571
+
572
+
573
+ @dataclass(repr=False)
574
+ class Timer:
575
+ """Dataclass to hold state of timers.
576
+
577
+ Note:
578
+ This should be used by VeSync device instances to manage internal status,
579
+ does not interact with the VeSync API.
580
+
581
+ Args:
582
+ timer_duration (int): Length of timer in seconds
583
+ action (str): Action to perform when timer is done
584
+ id (int): ID of timer, defaults to 1
585
+ remaining (int): Time remaining on timer in seconds, defaults to None
586
+ _update_time (int): Last updated unix timestamp in seconds, defaults to None
587
+
588
+ Attributes:
589
+ update_time (str): Timestamp of last update
590
+ status (str): Status of timer, one of 'active', 'paused', 'done'
591
+ time_remaining (int): Time remaining on timer in seconds
592
+ running (bool): True if timer is running
593
+ paused (bool): True if timer is paused
594
+ done (bool): True if timer is done
595
+ """
596
+
597
+ timer_duration: int
598
+ action: str
599
+ id: int = 1
600
+ remaining: InitVar[int | None] = None
601
+ _status: str = field(default='active', init=False, repr=False)
602
+ _remain: int = field(default=0, init=False, repr=False)
603
+ _update_time: int = int(time.time())
604
+
605
+ def __post_init__(self, remaining: int | None) -> None:
606
+ """Set remaining time if provided."""
607
+ if remaining is not None:
608
+ self._remain = remaining
609
+ else:
610
+ self._remain = self.timer_duration
611
+
612
+ def __repr__(self) -> str:
613
+ """Return string representation of the Timer object.
614
+
615
+ Returns:
616
+ str: String representation of the Timer object.
617
+ """
618
+ return (f'Timer(id={self.id}, duration={self.timer_duration}, '
619
+ f'status={self.status}, remaining={self.time_remaining})')
620
+
621
+ def update_ts(self) -> None:
622
+ """Update timestamp."""
623
+ self._update_time = int(time.time())
624
+
625
+ @property
626
+ def status(self) -> str:
627
+ """Return status of timer."""
628
+ if self._status in ('paused', 'done'):
629
+ return self._status
630
+ if self.time_remaining <= 0:
631
+ self._status = 'done'
632
+ return 'done'
633
+ return 'active'
634
+
635
+ @property
636
+ def time_remaining(self) -> int:
637
+ """Return remaining seconds."""
638
+ if self._status == "paused":
639
+ return self._remain
640
+ if self._status == "done":
641
+ return 0
642
+
643
+ # 'active' state - compute how much time has ticked away
644
+ elapsed = time.time() - self._update_time
645
+ current_remaining = self._remain - elapsed
646
+
647
+ # If we've run out of time, mark it done
648
+ if current_remaining <= 0:
649
+ return 0
650
+ return int(current_remaining)
651
+
652
+ @property
653
+ def running(self) -> bool:
654
+ """Check if timer is active."""
655
+ return (self.time_remaining > 0 and self.status == 'active')
656
+
657
+ @property
658
+ def paused(self) -> bool:
659
+ """Check if timer is paused."""
660
+ return bool(self.status == 'paused')
661
+
662
+ @property
663
+ def done(self) -> bool:
664
+ """Check if timer is complete."""
665
+ return bool(self.time_remaining <= 0 or self._status == 'done')
666
+
667
+ def end(self) -> None:
668
+ """Change status of timer to done."""
669
+ self._status = 'done'
670
+ self._remain = 0
671
+
672
+ def start(self) -> None:
673
+ """Restart paused timer."""
674
+ if self._status != 'paused':
675
+ return
676
+ self._update_time = int(time.time())
677
+ self._status = 'active'
678
+
679
+ def pause(self) -> None:
680
+ """Pauses the timer if it's active.
681
+
682
+ Performs the following steps:
683
+ - Calculate the up-to-date remaining time,
684
+ - Update internal counters,
685
+ - Set _status to 'paused'.
686
+ """
687
+ if self._status == "active":
688
+ # Update the time_remaining based on elapsed
689
+ current_remaining = self.time_remaining
690
+ if current_remaining <= 0:
691
+ self._status = "done"
692
+ self._remain = 0
693
+ else:
694
+ self._status = "paused"
695
+ self._remain = current_remaining
696
+ self._update_time = int(time.time())