moat-kv-cal 0.1.2__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,3 @@
1
+ from __future__ import annotations
2
+
3
+ __path__ = __import__("pkgutil").extend_path(__path__, __name__)
moat/kv/cal/_cfg.yaml ADDED
@@ -0,0 +1,2 @@
1
+ prefix: !P calendar
2
+ poll: 1200
moat/kv/cal/_main.py ADDED
@@ -0,0 +1,266 @@
1
+ # command line interface
2
+ from __future__ import annotations
3
+
4
+ import asyncclick as click
5
+ from functools import partial
6
+ from moat.util import P, Path
7
+ from moat.kv.data import data_get
8
+ from .model import CalRoot
9
+ from .util import find_next_alarm
10
+ from datetime import datetime, timezone, timedelta
11
+ import pytz
12
+
13
+ import anyio
14
+ import aiocaldav as caldav
15
+
16
+ import logging
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ utc = timezone.utc
21
+ now = partial(datetime.now, utc)
22
+
23
+
24
+ @click.group(short_help="Manage calendar polling.")
25
+ @click.pass_obj
26
+ async def cli(obj):
27
+ """
28
+ List known calendars and poll them.
29
+ """
30
+ obj.data = await CalRoot.as_handler(obj.client)
31
+
32
+
33
+ @cli.command("run")
34
+ @click.pass_obj
35
+ async def run_(obj):
36
+ """Process calendar alarms"""
37
+ from moat.kv.client import client_scope
38
+
39
+ kv = await client_scope(**obj.cfg.kv)
40
+ cal_cfg = (await kv.get(P("calendar.test"))).value
41
+ try:
42
+ tz = pytz.timezone(cal_cfg["zone"])
43
+ except KeyError:
44
+ tz = utc
45
+ else:
46
+ now = partial(datetime.now, tz)
47
+
48
+ try:
49
+ t_scan = datetime.fromtimestamp(cal_cfg["scan"], utc)
50
+ except KeyError:
51
+ t_scan = now()
52
+ interval = timedelta(0, cal_cfg.get("interval", 1800))
53
+
54
+ try:
55
+ t_al = await kv.get(cal_cfg["dst"])
56
+ except KeyError:
57
+ t_al = now()
58
+ else:
59
+ t_al = datetime.fromtimestamp(t_al.value["time"], tz)
60
+
61
+ async with caldav.DAVClient(
62
+ url=cal_cfg["url"],
63
+ username=cal_cfg["user"],
64
+ password=cal_cfg["pass"],
65
+ ) as client:
66
+ principal = await client.principal()
67
+ calendar = await principal.calendar(name="privat neu")
68
+ while True:
69
+ t_now = now()
70
+ if t_now < t_scan:
71
+ await anyio.sleep((t_scan - t_now).total_seconds())
72
+ cal_cfg = (await kv.get(P("calendar.test"))).value
73
+ cal_cfg["scan"] = t_scan.timestamp()
74
+ await kv.set(P("calendar.test"), value=cal_cfg)
75
+ t_now = t_scan
76
+
77
+ logger.info("Scan %s", t_scan)
78
+ ev, v, ev_t = await find_next_alarm(calendar, zone=tz, now=t_scan)
79
+ t_scan += interval
80
+ t_scan = max(t_now, t_scan).astimezone(tz)
81
+
82
+ if ev is None:
83
+ logger.warning("NO EVT")
84
+ continue
85
+ if evt <= t_now:
86
+ if t_al != ev_t:
87
+ # set alarm message
88
+ logger.warning("ALARM %s %s", v.summary.value, ev_t)
89
+ await kv.set(
90
+ cal_cfg["dst"],
91
+ value=dict(time=int(ev_t.timestamp()), info=v.summary.value),
92
+ )
93
+ t_al = ev_t
94
+ t_scan = t_now + timedelta(0, cal_cfg.get("interval", 1800) / 3)
95
+ elif ev_t < t_scan:
96
+ t_scan = ev_t
97
+ logger.warning("ScanEarly %s", t_scan)
98
+ else:
99
+ logger.warning("ScanLate %s", t_scan)
100
+
101
+
102
+ @cli.command("list")
103
+ @click.pass_obj
104
+ async def list_(obj):
105
+ """Emit the current state as a YAML file."""
106
+ prefix = obj.cfg.kv.cal.prefix
107
+ path = Path()
108
+
109
+ def pm(p):
110
+ if len(p) == 0:
111
+ return p
112
+ elif not isinstance(p[0], int):
113
+ return None
114
+ elif len(p) == 1:
115
+ return Path("%02x" % p[0])
116
+ else:
117
+ return Path("%02x.%12x" % (p[0], p[1])) + p[2:]
118
+
119
+ if obj.meta:
120
+
121
+ def pm(p):
122
+ return Path(str(prefix + path)) + p
123
+
124
+ await data_get(obj, prefix + path, as_dict="_", path_mangle=pm)
125
+
126
+
127
+ # @cli.command("attr", help="Mirror a device attribute to/from MoaT-KV")
128
+ # @click.option("-d", "--device", help="Device to access.")
129
+ # @click.option("-f", "--family", help="Device family to modify.")
130
+ # @click.option("-i", "--interval", type=float, help="read value every N seconds")
131
+ # @click.option("-w", "--write", is_flag=True, help="Write to the device")
132
+ # @click.option("-a", "--attr", "attr_", help="The node's attribute to use", default=":")
133
+ # @click.argument("attr", nargs=1)
134
+ # @click.argument("path", nargs=1)
135
+ # @click.pass_obj
136
+ # async def attr__(obj, device, family, write, attr, interval, path, attr_):
137
+ # """Show/add/modify an entry to repeatedly read an 1wire device's attribute.
138
+ #
139
+ # You can only set an interval, not a path, on family codes.
140
+ # A path of '-' deletes the entry.
141
+ # If you set neither interval nor path, reports the current
142
+ # values.
143
+ # """
144
+ # path = P(path)
145
+ # prefix = obj.cfg.kv.cal.prefix
146
+ # if (device is not None) + (family is not None) != 1:
147
+ # raise click.UsageError("Either family or device code must be given")
148
+ # if interval and write:
149
+ # raise click.UsageError("Writing isn't polled")
150
+ #
151
+ # remove = False
152
+ # if len(path) == 1 and path[0] == "-":
153
+ # path = ()
154
+ # remove = True
155
+ #
156
+ # if family:
157
+ # if path:
158
+ # raise click.UsageError("You cannot set a per-family path")
159
+ # fd = (int(family, 16),)
160
+ # else:
161
+ # f, d = device.split(".", 2)[0:2]
162
+ # fd = (int(f, 16), int(d, 16))
163
+ #
164
+ # attr = P(attr)
165
+ # attr_ = P(attr_)
166
+ # if remove:
167
+ # res = await obj.client.delete(prefix + fd + attr)
168
+ # else:
169
+ # val = dict()
170
+ # if path:
171
+ # val["src" if write else "dest"] = path
172
+ # if interval:
173
+ # val["interval"] = interval
174
+ # if len(attr_):
175
+ # val["src_attr" if write else "dest_attr"] = attr_
176
+ #
177
+ # res = await obj.client.set(prefix + fd + attr, value=val)
178
+ #
179
+ # if res is not None and obj.meta:
180
+ # yprint(res, stream=obj.stdout)
181
+ #
182
+ #
183
+ # @cli.command("set")
184
+ # @click.option("-d", "--device", help="Device to modify.")
185
+ # @click.option("-f", "--family", help="Device family to modify.")
186
+ # @attr_args
187
+ # @click.argument("subpath", nargs=1, type=P, default=P(":"))
188
+ # @click.pass_obj
189
+ # async def set_(obj, device, family, subpath, **kw):
190
+ # """Set or delete some random attribute.
191
+ #
192
+ # For deletion, use '-e ATTR -'.
193
+ # """
194
+ # if (device is not None) + (family is not None) != 1:
195
+ # raise click.UsageError("Either family or device code must be given")
196
+ #
197
+ # if family:
198
+ # fd = (int(family, 16),)
199
+ # if len(subpath):
200
+ # raise click.UsageError("You can't use a subpath here.")
201
+ # else:
202
+ # f, d = device.split(".", 2)[0:2]
203
+ # fd = (int(f, 16), int(d, 16))
204
+ #
205
+ # res = await node_attr(obj, obj.cfg.kv.cal.prefix + fd + subpath, **kw)
206
+ # if res and obj.meta:
207
+ # yprint(res, stream=obj.stdout)
208
+ #
209
+ #
210
+ # @cli.command("server")
211
+ # @click.option("-h", "--host", help="Host name of this server.")
212
+ # @click.option("-p", "--port", help="Port of this server.")
213
+ # @click.option("-d", "--delete", is_flag=True, help="Delete this server.")
214
+ # @click.argument("name", nargs=-1)
215
+ # @click.pass_obj
216
+ # async def server_(obj, name, host, port, delete):
217
+ # """
218
+ # Configure a server.
219
+ #
220
+ # No arguments: list them.
221
+ # """
222
+ # prefix = obj.cfg.kv.cal.prefix
223
+ # if not name:
224
+ # if host or port or delete:
225
+ # raise click.UsageError("Use a server name to set parameters")
226
+ # async for r in obj.client.get_tree(
227
+ # prefix | "server", min_depth=1, max_depth=1
228
+ # ):
229
+ # print(r.path[-1], file=obj.stdout)
230
+ # return
231
+ # elif len(name) > 1:
232
+ # raise click.UsageError("Only one server allowed")
233
+ # name = name[0]
234
+ # if host or port:
235
+ # if delete:
236
+ # raise click.UsageError("You can't delete and set at the same time.")
237
+ # value = attrdict()
238
+ # if host:
239
+ # value.host = host
240
+ # if port:
241
+ # if port == "-":
242
+ # value.port = NotGiven
243
+ # else:
244
+ # value.port = int(port)
245
+ # elif delete:
246
+ # res = await obj.client.delete_tree(prefix | "server" | name, nchain=obj.meta)
247
+ # if obj.meta:
248
+ # yprint(res, stream=obj.stdout)
249
+ # return
250
+ # else:
251
+ # value = None
252
+ # res = await node_attr(
253
+ # obj, prefix | "server" | name, ((P("server"), value),),(),())
254
+ # if res and obj.meta:
255
+ # yprint(res, stream=obj.stdout)
256
+ #
257
+ #
258
+ # @cli.command()
259
+ # @click.pass_obj
260
+ # @click.argument("server", nargs=-1)
261
+ # async def monitor(obj, server):
262
+ # """Stand-alone task to monitor one or more OWFS servers."""
263
+ # from .task import task
264
+ #
265
+ # async with as_service(obj) as srv:
266
+ # await task(obj.client, obj.cfg, server, srv)
moat/kv/cal/model.py ADDED
@@ -0,0 +1,94 @@
1
+ """
2
+ Moat-KV client data model for calendars
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+
8
+ from moat.kv.obj import ClientEntry, ClientRoot, AttrClientEntry
9
+ from moat.kv.errors import ErrorRoot
10
+
11
+ import logging
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class CalAlarm(AttrClientEntry):
17
+ """
18
+ An alarm handler. ``True`` is sent to @cmd. If @state doesn't follow
19
+ within @timeout (default ten) seconds, raise an alarm and skip the
20
+ delay. Otherwise go to the next entry.
21
+
22
+ @src if set overrides the acknowledge source of the calendar.
23
+ """
24
+
25
+ ATTRS = ("cmd", "state", "delay", "src", "timeout")
26
+
27
+ cls = ClientEntry
28
+
29
+
30
+ class CalEntry(AttrClientEntry):
31
+ """
32
+ A calendar entry to be monitored specifically (usually recurring).
33
+
34
+ The entry's name is the UUID in the parent calendar.
35
+
36
+ Summary+start+duration are updated from the calendar when it changes.
37
+ @alarms, if set, modify when the entry's alarm sequence should trigger.
38
+ @src if set overrides the acknowledge source of the calendar.
39
+ """
40
+
41
+ ATTRS = ("summary", "start", "duration", "alarms", "src")
42
+
43
+ @classmethod
44
+ def child_type(cls, name):
45
+ if isinstance(name, int):
46
+ return CalAlarm
47
+ return ClientEntry
48
+
49
+
50
+ class CalBase(AttrClientEntry):
51
+ """
52
+ A monitored calendar. Every @freq seconds the CalDAV server at @url
53
+ is queried for events during the next @days. They are published
54
+ to @dst/UUID as records with summary/start/duration/alarmtime(s)/UID.
55
+
56
+ @dst gets the same data, but for the next-most alarm time.
57
+ @src is the signal that the alarm has been acknowledged.
58
+ """
59
+
60
+ ATTRS = ("url", "username", "password", "freq", "days", "dst", "src")
61
+
62
+ @classmethod
63
+ def child_type(cls, name):
64
+ if isinstance(name, int):
65
+ return CalAlarm
66
+ return CalEntry
67
+
68
+
69
+ class CalRoot(ClientRoot):
70
+ cls = {}
71
+ reg = {}
72
+ CFG = "cal"
73
+ err = None
74
+
75
+ async def run_starting(self):
76
+ if self.err is None:
77
+ self.err = await ErrorRoot.as_handler(self.client)
78
+ await super().run_starting()
79
+
80
+ @property
81
+ def server(self):
82
+ return self["server"]
83
+
84
+ @classmethod
85
+ def register(cls, typ):
86
+ def acc(kls):
87
+ cls.reg[typ] = kls
88
+ return kls
89
+
90
+ return acc
91
+
92
+ @classmethod
93
+ def child_type(kls, name):
94
+ return CalBase
moat/kv/cal/util.py ADDED
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timedelta, timezone, date, time
4
+ from dateutil.rrule import rrulestr
5
+ from vobject.icalendar import VAlarm, VEvent
6
+
7
+ import logging
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ async def find_next_alarm(calendar, future=10, now=None, zone=timezone.utc) -> Tuple(
13
+ VAlarm,
14
+ datetime,
15
+ ):
16
+ """
17
+ fetch the next alarm in the current calendar
18
+
19
+ returns an (event, alarm_time) tuple
20
+ """
21
+ ## It should theoretically be possible to find both the events and
22
+ ## tasks in one calendar query, but not all server implementations
23
+ ## supports it, hence either event, todo or journal should be set
24
+ ## to True when searching. Here is a date search for events, with
25
+ ## expand:
26
+ events_fetched = await calendar.search(
27
+ start=datetime.now(),
28
+ end=datetime.now() + timedelta(days=future),
29
+ event=True,
30
+ expand=False,
31
+ )
32
+
33
+ if now is None:
34
+ now = datetime.now(timezone.utc)
35
+ ev = None
36
+ ev_v = None
37
+ ev_t = None
38
+
39
+ for e in events_fetched:
40
+ vx = None
41
+ vx_t = None
42
+ rids = set()
43
+ # create a list of superseded events
44
+ for v in e.vobject_instance.components():
45
+ if v.behavior is not VEvent:
46
+ continue
47
+ try:
48
+ rid = v.recurrence_id
49
+ except AttributeError:
50
+ continue
51
+ else:
52
+ rids.add(rid.value)
53
+
54
+ # find earliest event, skipping superseded ones
55
+ for v in e.vobject_instance.components():
56
+ if v.behavior is not VEvent:
57
+ continue
58
+ try:
59
+ rid = v.recurrence_id
60
+ except AttributeError:
61
+ rid = None
62
+
63
+ t_start = next_start(v, now)
64
+ if t_start is None:
65
+ raise ValueError("Start time: ??")
66
+ # t_start = next_start(v, now)
67
+ if not t_start.tzinfo:
68
+ t_start = t_start.astimezone(zone)
69
+ if rid is None and t_start in rids:
70
+ continue
71
+
72
+ if vx is None or t_start < vx_t:
73
+ vx, vx_t = v, t_start
74
+
75
+ for al in vx.components():
76
+ if al.behavior is not VAlarm:
77
+ continue
78
+ if not al.useBegin:
79
+ continue
80
+ if isinstance(t_start, date) and not isinstance(t_start, datetime):
81
+ t_start = datetime.combine(t_start, time(0), tzinfo=zone)
82
+ # XXX TODO count back from the current timezone's midnight
83
+
84
+ t_al = t_start + al.trigger.value
85
+ if t_al < now:
86
+ continue
87
+ if ev is None or ev_t > t_al:
88
+ ev, ev_v, ev_t = e, vx, t_al
89
+ if ev:
90
+ logger.warning("Next alarm: %s at %s", ev.vobject_instance.vevent.summary.value, ev_t)
91
+ return ev, ev_v, ev_t
92
+
93
+
94
+ def next_start(v, now):
95
+ st = v.dtstart.value
96
+ try:
97
+ rule = rrulestr(v.rrule.value, dtstart=st)
98
+ except AttributeError:
99
+ pass
100
+ else:
101
+ excl = set()
102
+ for edt in v.contents.get("exdate", ()):
103
+ for ed in edt.value:
104
+ excl.add(ed)
105
+
106
+ st = rule.after(now, inc=True)
107
+ while st in excl:
108
+ st = rule.after(edt, inc=False)
109
+
110
+ for edt in v.contents.get("rdate", ()):
111
+ for ed in edt.value:
112
+ if now <= ed.value < st:
113
+ st = ed.value
114
+
115
+ return st
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.4
2
+ Name: moat-kv-cal
3
+ Version: 0.1.2
4
+ Summary: Calendar management for MoaT-KV
5
+ Author-email: Matthias Urlichs <matthias@urlichs.de>
6
+ Project-URL: homepage, https://m-o-a-t.org
7
+ Project-URL: repository, https://github.com/M-o-a-T/moat
8
+ Keywords: MoaT
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Information Technology
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Database
13
+ Classifier: Topic :: Home Automation
14
+ Requires-Python: >=3.8
15
+ Description-Content-Type: text/x-rst
16
+ License-File: LICENSE.txt
17
+ Requires-Dist: moat-kv~=0.70.23
18
+ Requires-Dist: moat-util~=0.56.4
19
+ Requires-Dist: aiocaldav
20
+ Dynamic: license-file
21
+
22
+ ===========
23
+ MoaT-KV-Cal
24
+ ===========
25
+
26
+ MoaT-KV-Cal is a calendar extension to MoaT-KV.
27
+
28
+ It can monitor a calendar (or specific entries thereof), publish pending
29
+ entries, set alerts, control that they are acknowledged, and escalate
30
+ if/when they are not.
31
+
@@ -0,0 +1,10 @@
1
+ moat/kv/cal/__init__.py,sha256=ZBoFcXbv35djD599Vo_PKTqDjvQXYYXTcPUs5RcQZrs,101
2
+ moat/kv/cal/_cfg.yaml,sha256=UrczyUBlF7NIPt26pUu0CFdM67xC5TcPmSoVrg79tyQ,31
3
+ moat/kv/cal/_main.py,sha256=VN_Dn3Oz25HZIWYBZQvXb_P3Yh2rtHSADUBCLoudL0k,8356
4
+ moat/kv/cal/model.py,sha256=-n0yMEYImoHVDuNWibFJ4-LIpYsx3SmCo6hmAc8rsZU,2355
5
+ moat/kv/cal/util.py,sha256=zPuj6zlAug2Q6GS8K0_Zd1k6A_Ci3hK4SkRg8yQ4QFg,3445
6
+ moat_kv_cal-0.1.2.dist-info/licenses/LICENSE.txt,sha256=L5vKJLVOg5t0CEEPpW9-O_0vzbP0PEjEF06tLvnIDuk,541
7
+ moat_kv_cal-0.1.2.dist-info/METADATA,sha256=6w2eC98kQbfUEut6cBSWtxy0I2zTLckpX6RZc1N767Q,929
8
+ moat_kv_cal-0.1.2.dist-info/WHEEL,sha256=xcaH6rP_nCxh1LBIPM7Q0uOnzSGjsIye-Q44j_zbzw8,104
9
+ moat_kv_cal-0.1.2.dist-info/top_level.txt,sha256=pcs9fl5w5AB5GVi4SvBqIVmFrkRwQkVw_dEvW0Q0cSA,5
10
+ moat_kv_cal-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (77.0.3.post20250321)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,14 @@
1
+ The code in this repository, and all MoaT submodules it refers to,
2
+ is part of the MoaT project.
3
+
4
+ Unless a submodule's LICENSE.txt states otherwise, all included files are
5
+ licensed under the LGPL V3, as published by the FSF at
6
+ https://www.gnu.org/licenses/lgpl-3.0.html .
7
+
8
+ In addition to the LGPL's terms, the author(s) respectfully ask all users of
9
+ this code to contribute any bug fixes or enhancements. Also, please link back to
10
+ https://M-o-a-T.org.
11
+
12
+ Thank you.
13
+
14
+ Copyright © 2021 ff.: the MoaT contributor(s), as per the git changelog(s).
@@ -0,0 +1 @@
1
+ moat