boneIO 0.2.2__tar.gz → 0.3.0.dev1__tar.gz

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 (65) hide show
  1. {boneio-0.2.2/boneio.egg-info → boneio-0.3.0.dev1}/PKG-INFO +2 -2
  2. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/bonecli.py +19 -12
  3. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/const.py +4 -0
  4. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/helper/__init__.py +19 -1
  5. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/helper/events.py +67 -2
  6. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/helper/exceptions.py +4 -0
  7. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/helper/gpio.py +31 -6
  8. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/helper/loader.py +49 -44
  9. boneio-0.3.0.dev1/boneio/helper/timeperiod.py +185 -0
  10. boneio-0.3.0.dev1/boneio/helper/yaml_util.py +305 -0
  11. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/input/gpio.py +5 -1
  12. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/manager.py +13 -6
  13. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/mqtt_client.py +6 -0
  14. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/oled.py +10 -10
  15. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/relay/basic.py +5 -0
  16. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/relay/mcp.py +19 -0
  17. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/schema/actions.yaml +1 -1
  18. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/schema/actions_sensor.yaml +1 -0
  19. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/schema/actions_switch.yaml +1 -0
  20. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/schema/schema.yaml +49 -14
  21. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/sensor/adc.py +10 -1
  22. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/sensor/gpio.py +4 -1
  23. boneio-0.3.0.dev1/boneio/version.py +2 -0
  24. {boneio-0.2.2 → boneio-0.3.0.dev1/boneio.egg-info}/PKG-INFO +2 -2
  25. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio.egg-info/SOURCES.txt +2 -1
  26. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio.egg-info/requires.txt +1 -1
  27. {boneio-0.2.2 → boneio-0.3.0.dev1}/requirements.txt +0 -1
  28. {boneio-0.2.2 → boneio-0.3.0.dev1}/setup.py +1 -1
  29. boneio-0.2.2/boneio/helper/yaml.py +0 -64
  30. boneio-0.2.2/boneio/version.py +0 -2
  31. {boneio-0.2.2 → boneio-0.3.0.dev1}/LICENSE +0 -0
  32. {boneio-0.2.2 → boneio-0.3.0.dev1}/MANIFEST.in +0 -0
  33. {boneio-0.2.2 → boneio-0.3.0.dev1}/README.md +0 -0
  34. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/__init__.py +0 -0
  35. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/cover.py +0 -0
  36. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/example_config/__init__.py +0 -0
  37. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/example_config/adc.yaml +0 -0
  38. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/example_config/config.yaml +0 -0
  39. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/example_config/cover.yaml +0 -0
  40. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/example_config/input.yaml +0 -0
  41. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/example_config/output24x16A.yaml +0 -0
  42. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/example_config/output32x5A.yaml +0 -0
  43. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/fonts/danube__.ttf +0 -0
  44. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/helper/ha_discovery.py +0 -0
  45. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/helper/logger.py +0 -0
  46. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/helper/mqtt.py +0 -0
  47. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/helper/oled.py +0 -0
  48. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/helper/queue.py +0 -0
  49. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/helper/state_manager.py +0 -0
  50. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/helper/stats.py +0 -0
  51. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/input/__init__.py +0 -0
  52. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/modbus.py +0 -0
  53. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/relay/__init__.py +0 -0
  54. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/relay/gpio.py +0 -0
  55. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/runner.py +0 -0
  56. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/sensor/__init__.py +0 -0
  57. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/sensor/modbus/__init__.py +0 -0
  58. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/sensor/modbus/sdm120.json +0 -0
  59. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/sensor/modbus/sdm630.json +0 -0
  60. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/sensor/modbus/sofar.json +0 -0
  61. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio/sensor/temp/__init__.py +0 -0
  62. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio.egg-info/dependency_links.txt +0 -0
  63. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio.egg-info/entry_points.txt +0 -0
  64. {boneio-0.2.2 → boneio-0.3.0.dev1}/boneio.egg-info/top_level.txt +0 -0
  65. {boneio-0.2.2 → boneio-0.3.0.dev1}/setup.cfg +0 -0
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: boneio
3
- Version: 0.2.2
3
+ Version: 0.3.0.dev1
4
4
  Summary: Python App for BoneIO
5
5
  Home-page: https://github.com/boneIO-eu/app_bbb
6
- Download-URL: https://github.com/boneIO-eu/app_bbb/archive/0.2.2.zip
6
+ Download-URL: https://github.com/boneIO-eu/app_bbb/archive/0.3.0.dev1.zip
7
7
  License: GNU General Public License v3.0
8
8
  Platform: UNKNOWN
9
9
  Classifier: Programming Language :: Python
@@ -8,9 +8,11 @@ from colorlog import ColoredFormatter
8
8
 
9
9
  from boneio.const import ACTION
10
10
  from boneio.helper import load_config_from_file
11
+ from boneio.helper.exceptions import ConfigurationException
11
12
  from boneio.helper.logger import configure_logger
12
13
  from boneio.runner import async_run
13
14
  from boneio.version import __version__
15
+ from yaml import MarkedYAMLError
14
16
 
15
17
  TASK_CANCELATION_TIMEOUT = 1
16
18
 
@@ -67,18 +69,23 @@ def get_arguments() -> argparse.Namespace:
67
69
  def run(config: str, debug: int, mqttusername: str = "", mqttpassword: str = ""):
68
70
  """Run BoneIO."""
69
71
  _LOGGER.info("BoneIO starting.")
70
- _config = load_config_from_file(config_file=config)
71
- if not _config:
72
- return
73
- configure_logger(log_config=_config.get("logger"), debug=debug)
74
- return asyncio.run(
75
- async_run(
76
- config=_config,
77
- config_file=config,
78
- mqttusername=mqttusername,
79
- mqttpassword=mqttpassword,
80
- ),
81
- )
72
+ try:
73
+ _config = load_config_from_file(config_file=config)
74
+ if not _config:
75
+ _LOGGER.error("Config not loaded. Exiting.")
76
+ return 1
77
+ configure_logger(log_config=_config.get("logger"), debug=debug)
78
+ return asyncio.run(
79
+ async_run(
80
+ config=_config,
81
+ config_file=config,
82
+ mqttusername=mqttusername,
83
+ mqttpassword=mqttpassword,
84
+ ),
85
+ )
86
+ except (ConfigurationException, MarkedYAMLError) as err:
87
+ _LOGGER.error("Failed to load config. %s Exiting.", err)
88
+ return 1
82
89
 
83
90
 
84
91
  def main() -> int:
@@ -4,6 +4,10 @@ from datetime import timedelta
4
4
  try:
5
5
  from Adafruit_BBIO.GPIO import HIGH, LOW, BOTH, FALLING
6
6
  except ModuleNotFoundError:
7
+ HIGH = "high"
8
+ LOW = "low"
9
+ BOTH = "both"
10
+ FALLING = "falling"
7
11
  pass
8
12
  from typing_extensions import Literal
9
13
 
@@ -1,4 +1,6 @@
1
1
  """Helper dir for BoneIO."""
2
+ from typing import Callable, TypeVar, Any
3
+
2
4
  from boneio.helper.gpio import (
3
5
  edge_detect,
4
6
  setup_input,
@@ -11,7 +13,7 @@ from boneio.helper.gpio import (
11
13
  from boneio.helper.oled import make_font
12
14
  from boneio.helper.mqtt import BasicMqtt
13
15
  from boneio.helper.stats import HostData, host_stats
14
- from boneio.helper.yaml import (
16
+ from boneio.helper.yaml_util import (
15
17
  CustomValidator,
16
18
  load_yaml_file,
17
19
  schema_file,
@@ -31,6 +33,21 @@ from boneio.helper.ha_discovery import (
31
33
  )
32
34
  from boneio.helper.exceptions import GPIOInputException, I2CError
33
35
  from boneio.helper.queue import UniqueQueue
36
+ from boneio.helper.timeperiod import TimePeriod
37
+
38
+ CALLABLE_T = TypeVar("CALLABLE_T", bound=Callable[..., Any])
39
+ CALLBACK_TYPE = Callable[[], None]
40
+
41
+
42
+ def callback(func: CALLABLE_T) -> CALLABLE_T:
43
+ """Annotation to mark method as safe to call from within the event loop."""
44
+ setattr(func, "_boneio_callback", True)
45
+ return func
46
+
47
+
48
+ def is_callback(func: Callable[..., Any]) -> bool:
49
+ """Check if function is safe to be called in the event loop."""
50
+ return getattr(func, "_boneio_callback", False) is True
34
51
 
35
52
 
36
53
  __all__ = [
@@ -62,4 +79,5 @@ __all__ = [
62
79
  "schema_file",
63
80
  "load_config_from_string",
64
81
  "load_config_from_file",
82
+ "TimePeriod",
65
83
  ]
@@ -2,16 +2,24 @@ import asyncio
2
2
  import datetime as dt
3
3
  import signal
4
4
  import logging
5
+ import time
6
+ from typing import Callable
7
+ from datetime import datetime
8
+ from boneio.helper import callback
5
9
 
6
10
  _LOGGER = logging.getLogger(__name__)
7
11
  UTC = dt.timezone.utc
8
12
  EVENT_TIME_CHANGED = "event_time_changed"
13
+ CALLBACK_TYPE = Callable[[], None]
9
14
 
10
15
 
11
- def utcnow():
16
+ def utcnow() -> dt.datetime:
12
17
  return dt.datetime.now(UTC)
13
18
 
14
19
 
20
+ time_tracker_utcnow = utcnow
21
+
22
+
15
23
  def _async_create_timer(loop: asyncio.AbstractEventLoop, event_callback) -> None:
16
24
  """Create a timer that will start on BoneIO start."""
17
25
  handle = None
@@ -19,7 +27,7 @@ def _async_create_timer(loop: asyncio.AbstractEventLoop, event_callback) -> None
19
27
  def schedule_tick(now: dt.datetime) -> None:
20
28
  """Schedule a timer tick when the next second rolls around."""
21
29
  nonlocal handle
22
- slp_seconds = 1 - (now.microsecond / 10 ** 6)
30
+ slp_seconds = 1 - (now.microsecond / 10**6)
23
31
  handle = loop.call_later(slp_seconds, fire_time_event)
24
32
 
25
33
  def fire_time_event() -> None:
@@ -109,3 +117,60 @@ class EventBus:
109
117
  """Remove regular listener."""
110
118
  if name in self._listeners:
111
119
  del self._listeners[name]
120
+
121
+
122
+ def as_utc(dattim: dt.datetime) -> dt.datetime:
123
+ """Return a datetime as UTC time.
124
+
125
+ Assumes datetime without tzinfo to be in the DEFAULT_TIME_ZONE.
126
+ """
127
+ if dattim.tzinfo == UTC:
128
+ return dattim
129
+ return dattim.astimezone(UTC)
130
+
131
+
132
+ @callback
133
+ def async_track_point_in_time(
134
+ loop: asyncio.AbstractEventLoop,
135
+ action,
136
+ point_in_time: datetime,
137
+ ) -> CALLBACK_TYPE:
138
+ """Add a listener that fires once after a specific point in UTC time."""
139
+ # Ensure point_in_time is UTC
140
+ utc_point_in_time = as_utc(point_in_time)
141
+
142
+ # Since this is called once, we accept a HassJob so we can avoid
143
+ # having to figure out how to call the action every time its called.
144
+ cancel_callback: asyncio.TimerHandle | None = None
145
+
146
+ @callback
147
+ def run_action(job) -> None:
148
+ """Call the action."""
149
+ nonlocal cancel_callback
150
+
151
+ now = time_tracker_utcnow()
152
+
153
+ # Depending on the available clock support (including timer hardware
154
+ # and the OS kernel) it can happen that we fire a little bit too early
155
+ # as measured by utcnow(). That is bad when callbacks have assumptions
156
+ # about the current time. Thus, we rearm the timer for the remaining
157
+ # time.
158
+ delta = (utc_point_in_time - now).total_seconds()
159
+ if delta > 0:
160
+ _LOGGER.debug("Called %f seconds too early, rearming", delta)
161
+
162
+ cancel_callback = loop.call_later(delta, run_action, job)
163
+ return
164
+
165
+ loop.call_soon(job, utc_point_in_time)
166
+
167
+ delta = utc_point_in_time.timestamp() - time.time()
168
+ cancel_callback = loop.call_later(delta, run_action, action)
169
+
170
+ @callback
171
+ def unsub_point_in_time_listener() -> None:
172
+ """Cancel the call_later."""
173
+ assert cancel_callback is not None
174
+ cancel_callback.cancel()
175
+
176
+ return unsub_point_in_time_listener
@@ -11,3 +11,7 @@ class GPIOInputException(BoneIOException):
11
11
 
12
12
  class I2CError(BoneIOException):
13
13
  """I2C Exception."""
14
+
15
+
16
+ class ConfigurationException(BoneIOException):
17
+ """Configuration yaml exception."""
@@ -1,9 +1,24 @@
1
1
  import asyncio
2
2
  import logging
3
- from Adafruit_BBIO import GPIO
3
+
4
+ try:
5
+ from Adafruit_BBIO import GPIO
6
+ except ModuleNotFoundError:
7
+
8
+ class GPIO:
9
+ PUD_OFF = "poff"
10
+ PUD_UP = "poff"
11
+ PUD_DOWN = "poff"
12
+
13
+ def __init__(self):
14
+ pass
15
+
16
+ pass
4
17
 
5
18
  from boneio.helper.exceptions import GPIOInputException
6
19
  from boneio.const import LOW, FALLING, Gpio_Edges, Gpio_States
20
+ from boneio.helper.timeperiod import TimePeriod
21
+
7
22
  from typing import Callable
8
23
  import subprocess
9
24
 
@@ -34,10 +49,19 @@ def setup_output(pin: str) -> None:
34
49
  GPIO.setup(pin, GPIO.OUT, pull_up_down=GPIO.PUD_DOWN)
35
50
 
36
51
 
37
- def setup_input(pin: str, pull_mode: str = "UP") -> None:
52
+ gpio_modes = {
53
+ "gpio": GPIO.PUD_OFF,
54
+ "gpio_pu": GPIO.PUD_UP,
55
+ "gpio_pd": GPIO.PUD_DOWN,
56
+ "gpio_input": GPIO.PUD_OFF,
57
+ }
58
+
59
+
60
+ def setup_input(pin: str, pull_mode: str = "gpio") -> None:
38
61
  """Set up a GPIO as input."""
62
+ gpio_mode = gpio_modes.get(pull_mode, GPIO.PUD_OFF)
39
63
  try:
40
- GPIO.setup(pin, GPIO.IN, GPIO.PUD_DOWN if pull_mode == "DOWN" else GPIO.PUD_UP)
64
+ GPIO.setup(pin, GPIO.IN, gpio_mode)
41
65
  except (ValueError, SystemError) as err:
42
66
  raise GPIOInputException(err)
43
67
 
@@ -72,11 +96,12 @@ class GpioBaseClass:
72
96
  """Setup GPIO Input Button"""
73
97
  self._pin = pin
74
98
  gpio = kwargs.get("rest_pin", {GPIO_MODE: GPIO_STR})
75
- self._bounce_time = gpio.get("bounce_time", 25)
76
- configure_pin(pin=pin, mode=gpio.get(GPIO_MODE, GPIO_STR))
99
+ gpio_mode = gpio.get(GPIO_MODE, GPIO_STR)
100
+ self._bounce_time = gpio.get("bounce_time", TimePeriod(milliseconds=25))
101
+ configure_pin(pin=pin, mode=gpio_mode)
77
102
  self._loop = asyncio.get_running_loop()
78
103
  self._press_callback = press_callback
79
- setup_input(pin=self._pin)
104
+ setup_input(pin=self._pin, pull_mode=gpio_mode)
80
105
 
81
106
  @property
82
107
  def is_pressed(self):
@@ -192,6 +192,17 @@ def create_modbus_sensors(
192
192
  pass
193
193
 
194
194
 
195
+ OutputEntry = namedtuple("OutputEntry", "OutputClass output_kind output_id")
196
+
197
+
198
+ def output_chooser(output_kind: str, mcp_id: str | None):
199
+ """Get named tuple based on input."""
200
+ if output_kind == MCP:
201
+ return OutputEntry(MCPRelay, MCP, mcp_id)
202
+ else:
203
+ return OutputEntry(GpioRelay, GPIO, GPIO)
204
+
205
+
195
206
  def configure_relay(
196
207
  manager: Manager,
197
208
  state_manager: StateManager,
@@ -201,61 +212,55 @@ def configure_relay(
201
212
  config: dict,
202
213
  ) -> Any:
203
214
  """Configure kind of relay. Most common MCP."""
215
+ restore_state = config.pop(RESTORE_STATE, False)
216
+ output_type = config.pop(OUTPUT_TYPE)
204
217
  restored_state = (
205
218
  state_manager.get(attr_type=RELAY, attr=relay_id, default_value=False)
206
- if config[RESTORE_STATE]
219
+ if restore_state
207
220
  else False
208
221
  )
209
- if config[OUTPUT_TYPE] == NONE and state_manager.get(
210
- attr_type=RELAY, attr=relay_id
211
- ):
222
+ if output_type == NONE and state_manager.get(attr_type=RELAY, attr=relay_id):
212
223
  state_manager.del_attribute(attr_type=RELAY, attribute=relay_id)
213
224
  restored_state = False
214
225
 
215
- if config[KIND] == MCP:
216
- mcp_id = config.get(MCP_ID, "")
217
- mcp = manager.mcp.get(mcp_id)
226
+ output = output_chooser(
227
+ output_kind=config.pop(KIND), mcp_id=config.pop(MCP_ID, None)
228
+ )
229
+
230
+ if getattr(output, "output_kind") == MCP:
231
+ mcp = manager.mcp.get(getattr(output, "output_id"))
218
232
  if not mcp:
219
233
  _LOGGER.error("No such MCP configured!")
220
- return
221
- mcp_relay = MCPRelay(
222
- pin=int(config[PIN]),
223
- id=config[ID],
224
- send_message=manager.send_message,
225
- topic_prefix=topic_prefix,
226
- mcp=mcp,
227
- mcp_id=mcp_id,
228
- output_type=config[OUTPUT_TYPE].lower(),
229
- restored_state=restored_state,
230
- callback=lambda: relay_callback(
231
- relay_type=mcp_id,
232
- relay_id=relay_id,
233
- restore_state=False
234
- if config[OUTPUT_TYPE] == NONE
235
- else config[RESTORE_STATE],
236
- ),
237
- )
238
- manager.grouped_outputs[mcp_id][relay_id] = mcp_relay
239
- return mcp_relay
240
- elif config[KIND] == GPIO:
234
+ return None
235
+ kwargs = {
236
+ "pin": int(config.pop(PIN)),
237
+ "mcp": mcp,
238
+ "mcp_id": getattr(output, "output_id"),
239
+ "output_type": output_type,
240
+ }
241
+ elif getattr(output, "output_kind") == GPIO:
241
242
  if GPIO not in manager.grouped_outputs:
242
243
  manager.grouped_outputs[GPIO] = {}
243
- gpio_relay = GpioRelay(
244
- pin=config[PIN],
245
- id=config[ID],
246
- send_message=manager.send_message,
247
- topic_prefix=topic_prefix,
248
- restored_state=restored_state,
249
- callback=lambda: relay_callback(
250
- relay_type=GPIO,
251
- relay_id=relay_id,
252
- restore_state=False
253
- if config[OUTPUT_TYPE] == NONE
254
- else config[RESTORE_STATE],
255
- ),
256
- )
257
- manager.grouped_outputs[GPIO][relay_id] = gpio_relay
258
- return gpio_relay
244
+ kwargs = {
245
+ "pin": config.pop(PIN),
246
+ }
247
+ else:
248
+ return
249
+ relay = getattr(output, "OutputClass")(
250
+ send_message=manager.send_message,
251
+ topic_prefix=topic_prefix,
252
+ id=config.pop(ID),
253
+ restored_state=restored_state,
254
+ **config,
255
+ **kwargs,
256
+ callback=lambda: relay_callback(
257
+ relay_type=getattr(output, "output_id"),
258
+ relay_id=relay_id,
259
+ restore_state=False if output_type == NONE else restore_state,
260
+ ),
261
+ )
262
+ manager.grouped_outputs[getattr(output, "output_id")][relay_id] = relay
263
+ return relay
259
264
 
260
265
 
261
266
  InputEntry = namedtuple(
@@ -0,0 +1,185 @@
1
+ from collections import OrderedDict
2
+
3
+ from datetime import timedelta
4
+ import math
5
+
6
+
7
+ def is_approximately_integer(value):
8
+ if isinstance(value, int):
9
+ return True
10
+ return abs(value - round(value)) < 0.001
11
+
12
+
13
+ class TimePeriod:
14
+ def __init__(
15
+ self,
16
+ microseconds=None,
17
+ milliseconds=None,
18
+ seconds=None,
19
+ minutes=None,
20
+ hours=None,
21
+ days=None,
22
+ ) -> None:
23
+ if days is not None:
24
+ if not is_approximately_integer(days):
25
+ frac_days, days = math.modf(days)
26
+ hours = (hours or 0) + frac_days * 24
27
+ self.days = int(round(days))
28
+ else:
29
+ self.days = None
30
+
31
+ if hours is not None:
32
+ if not is_approximately_integer(hours):
33
+ frac_hours, hours = math.modf(hours)
34
+ minutes = (minutes or 0) + frac_hours * 60
35
+ self.hours = int(round(hours))
36
+ else:
37
+ self.hours = None
38
+
39
+ if minutes is not None:
40
+ if not is_approximately_integer(minutes):
41
+ frac_minutes, minutes = math.modf(minutes)
42
+ seconds = (seconds or 0) + frac_minutes * 60
43
+ self.minutes = int(round(minutes))
44
+ else:
45
+ self.minutes = None
46
+
47
+ if seconds is not None:
48
+ if not is_approximately_integer(seconds):
49
+ frac_seconds, seconds = math.modf(seconds)
50
+ milliseconds = (milliseconds or 0) + frac_seconds * 1000
51
+ self.seconds = int(round(seconds))
52
+ else:
53
+ self.seconds = None
54
+
55
+ if milliseconds is not None:
56
+ if not is_approximately_integer(milliseconds):
57
+ frac_milliseconds, milliseconds = math.modf(milliseconds)
58
+ microseconds = (microseconds or 0) + frac_milliseconds * 1000
59
+ self.milliseconds = int(round(milliseconds))
60
+ else:
61
+ self.milliseconds = None
62
+
63
+ if microseconds is not None:
64
+ if not is_approximately_integer(microseconds):
65
+ raise ValueError("Maximum precision is microseconds")
66
+ self.microseconds = int(round(microseconds))
67
+ else:
68
+ self.microseconds = None
69
+
70
+ self._timedelta = timedelta(
71
+ days=self.days or 0,
72
+ hours=self.hours or 0,
73
+ minutes=self.minutes or 0,
74
+ seconds=self.seconds or 0,
75
+ milliseconds=self.milliseconds or 0,
76
+ microseconds=self.microseconds or 0,
77
+ )
78
+
79
+ @property
80
+ def as_timedelta(self) -> timedelta:
81
+ return self._timedelta
82
+
83
+ def as_dict(self) -> OrderedDict:
84
+ out = OrderedDict()
85
+ if self.microseconds is not None:
86
+ out["microseconds"] = self.microseconds
87
+ if self.milliseconds is not None:
88
+ out["milliseconds"] = self.milliseconds
89
+ if self.seconds is not None:
90
+ out["seconds"] = self.seconds
91
+ if self.minutes is not None:
92
+ out["minutes"] = self.minutes
93
+ if self.hours is not None:
94
+ out["hours"] = self.hours
95
+ if self.days is not None:
96
+ out["days"] = self.days
97
+ return out
98
+
99
+ def __str__(self) -> str:
100
+ if self.microseconds is not None:
101
+ return f"{self.total_microseconds}us"
102
+ if self.milliseconds is not None:
103
+ return f"{self.total_milliseconds}ms"
104
+ if self.seconds is not None:
105
+ return f"{self.total_seconds}s"
106
+ if self.minutes is not None:
107
+ return f"{self.total_minutes}min"
108
+ if self.hours is not None:
109
+ return f"{self.total_hours}h"
110
+ if self.days is not None:
111
+ return f"{self.total_days}d"
112
+ return "0s"
113
+
114
+ def __repr__(self):
115
+ return f"TimePeriod<{self.total_microseconds}>"
116
+
117
+ @property
118
+ def total_microseconds(self):
119
+ return self.total_milliseconds * 1000 + (self.microseconds or 0)
120
+
121
+ @property
122
+ def total_milliseconds(self):
123
+ return self.total_seconds * 1000 + (self.milliseconds or 0)
124
+
125
+ @property
126
+ def total_seconds(self):
127
+ return self.total_minutes * 60 + (self.seconds or 0)
128
+
129
+ @property
130
+ def total_minutes(self):
131
+ return self.total_hours * 60 + (self.minutes or 0)
132
+
133
+ @property
134
+ def total_hours(self):
135
+ return self.total_days * 24 + (self.hours or 0)
136
+
137
+ @property
138
+ def total_days(self):
139
+ return self.days or 0
140
+
141
+ def __eq__(self, other):
142
+ if isinstance(other, TimePeriod):
143
+ return self.total_microseconds == other.total_microseconds
144
+ return NotImplemented
145
+
146
+ def __ne__(self, other):
147
+ if isinstance(other, TimePeriod):
148
+ return self.total_microseconds != other.total_microseconds
149
+ return NotImplemented
150
+
151
+ def __lt__(self, other):
152
+ if isinstance(other, TimePeriod):
153
+ return self.total_microseconds < other.total_microseconds
154
+ return NotImplemented
155
+
156
+ def __gt__(self, other):
157
+ if isinstance(other, TimePeriod):
158
+ return self.total_microseconds > other.total_microseconds
159
+ return NotImplemented
160
+
161
+ def __le__(self, other):
162
+ if isinstance(other, TimePeriod):
163
+ return self.total_microseconds <= other.total_microseconds
164
+ return NotImplemented
165
+
166
+ def __ge__(self, other):
167
+ if isinstance(other, TimePeriod):
168
+ return self.total_microseconds >= other.total_microseconds
169
+ return NotImplemented
170
+
171
+
172
+ class TimePeriodMicroseconds(TimePeriod):
173
+ pass
174
+
175
+
176
+ class TimePeriodMilliseconds(TimePeriod):
177
+ pass
178
+
179
+
180
+ class TimePeriodSeconds(TimePeriod):
181
+ pass
182
+
183
+
184
+ class TimePeriodMinutes(TimePeriod):
185
+ pass