moat-db-rain 0.0.1__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.
- moat_db_rain-0.0.1/LICENSE.txt +14 -0
- moat_db_rain-0.0.1/PKG-INFO +43 -0
- moat_db_rain-0.0.1/README.md +13 -0
- moat_db_rain-0.0.1/debian/.gitignore +8 -0
- moat_db_rain-0.0.1/debian/changelog +5 -0
- moat_db_rain-0.0.1/debian/control +20 -0
- moat_db_rain-0.0.1/debian/py3dist-overrides +4 -0
- moat_db_rain-0.0.1/debian/rules +11 -0
- moat_db_rain-0.0.1/debian/source/format +1 -0
- moat_db_rain-0.0.1/moat-db-rain@.service +17 -0
- moat_db_rain-0.0.1/pyproject.toml +45 -0
- moat_db_rain-0.0.1/setup.cfg +4 -0
- moat_db_rain-0.0.1/src/moat/db/rain/__init__.py +9 -0
- moat_db_rain-0.0.1/src/moat/db/rain/_main.py +69 -0
- moat_db_rain-0.0.1/src/moat/db/rain/cmds/__init__.py +3 -0
- moat_db_rain-0.0.1/src/moat/db/rain/cmds/_util.py +238 -0
- moat_db_rain-0.0.1/src/moat/db/rain/cmds/add.py +25 -0
- moat_db_rain-0.0.1/src/moat/db/rain/cmds/controller.py +86 -0
- moat_db_rain-0.0.1/src/moat/db/rain/cmds/day.py +169 -0
- moat_db_rain-0.0.1/src/moat/db/rain/cmds/dayrange.py +109 -0
- moat_db_rain-0.0.1/src/moat/db/rain/cmds/delete.py +17 -0
- moat_db_rain-0.0.1/src/moat/db/rain/cmds/env.py +91 -0
- moat_db_rain-0.0.1/src/moat/db/rain/cmds/feed.py +104 -0
- moat_db_rain-0.0.1/src/moat/db/rain/cmds/gen.py +56 -0
- moat_db_rain-0.0.1/src/moat/db/rain/cmds/group.py +324 -0
- moat_db_rain-0.0.1/src/moat/db/rain/cmds/history.py +212 -0
- moat_db_rain-0.0.1/src/moat/db/rain/cmds/recalc.py +50 -0
- moat_db_rain-0.0.1/src/moat/db/rain/cmds/sensor.py +112 -0
- moat_db_rain-0.0.1/src/moat/db/rain/cmds/set.py +22 -0
- moat_db_rain-0.0.1/src/moat/db/rain/cmds/valve.py +426 -0
- moat_db_rain-0.0.1/src/moat/db/rain/engine.py +998 -0
- moat_db_rain-0.0.1/src/moat/db/rain/model.py +1043 -0
- moat_db_rain-0.0.1/src/moat/db/rain/model_.py +12 -0
- moat_db_rain-0.0.1/src/moat/db/rain/monitor.py +1 -0
- moat_db_rain-0.0.1/src/moat/db/rain/range.py +288 -0
- moat_db_rain-0.0.1/src/moat_db_rain.egg-info/PKG-INFO +43 -0
- moat_db_rain-0.0.1/src/moat_db_rain.egg-info/SOURCES.txt +38 -0
- moat_db_rain-0.0.1/src/moat_db_rain.egg-info/dependency_links.txt +1 -0
- moat_db_rain-0.0.1/src/moat_db_rain.egg-info/requires.txt +13 -0
- moat_db_rain-0.0.1/src/moat_db_rain.egg-info/top_level.txt +1 -0
|
@@ -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,43 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: moat-db-rain
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: MoaT irrigation scheduling and monitoring
|
|
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: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Framework :: AsyncIO
|
|
12
|
+
Classifier: Framework :: Trio
|
|
13
|
+
Classifier: Framework :: AnyIO
|
|
14
|
+
Classifier: Development Status :: 3 - Alpha
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE.txt
|
|
18
|
+
Requires-Dist: moat-util~=0.63.0
|
|
19
|
+
Requires-Dist: moat-db~=0.2.7
|
|
20
|
+
Requires-Dist: moat-lib-run~=0.2.0
|
|
21
|
+
Requires-Dist: moat-link~=0.9.1
|
|
22
|
+
Requires-Dist: asyncclick>=8.3
|
|
23
|
+
Requires-Dist: anyio>=4.10
|
|
24
|
+
Provides-Extra: systemd
|
|
25
|
+
Requires-Dist: systemd.daemon; extra == "systemd"
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: ruff; extra == "dev"
|
|
28
|
+
Requires-Dist: ty; extra == "dev"
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# MoaT-DB-Rain
|
|
32
|
+
|
|
33
|
+
% start synopsis
|
|
34
|
+
% start main
|
|
35
|
+
|
|
36
|
+
This module provides irrigation scheduling and monitoring for MoaT,
|
|
37
|
+
migrated from the legacy Django `rainman` app to a `moat.db.rain`
|
|
38
|
+
SQLAlchemy + Alembic submodule. It supersedes the old `rainman` Django
|
|
39
|
+
app with command-line CRUD, a scheduler engine, and a per-site
|
|
40
|
+
`moat db rain <SITE> monitor` daemon.
|
|
41
|
+
|
|
42
|
+
% end synopsis
|
|
43
|
+
% end main
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# MoaT-DB-Rain
|
|
2
|
+
|
|
3
|
+
% start synopsis
|
|
4
|
+
% start main
|
|
5
|
+
|
|
6
|
+
This module provides irrigation scheduling and monitoring for MoaT,
|
|
7
|
+
migrated from the legacy Django `rainman` app to a `moat.db.rain`
|
|
8
|
+
SQLAlchemy + Alembic submodule. It supersedes the old `rainman` Django
|
|
9
|
+
app with command-line CRUD, a scheduler engine, and a per-site
|
|
10
|
+
`moat db rain <SITE> monitor` daemon.
|
|
11
|
+
|
|
12
|
+
% end synopsis
|
|
13
|
+
% end main
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Source: moat-db-rain
|
|
2
|
+
Maintainer: Matthias Urlichs <matthias@urlichs.de>
|
|
3
|
+
Section: python
|
|
4
|
+
Priority: optional
|
|
5
|
+
Build-Depends: dh-python, python3-all, debhelper (>= 13), debhelper-compat (= 13),
|
|
6
|
+
python3-setuptools,
|
|
7
|
+
python3-wheel,
|
|
8
|
+
Standards-Version: 3.9.6
|
|
9
|
+
Homepage: https://M-o-a-T.org
|
|
10
|
+
|
|
11
|
+
Package: moat-db-rain
|
|
12
|
+
Architecture: all
|
|
13
|
+
Depends: ${misc:Depends}, ${python3:Depends},
|
|
14
|
+
moat-db (>= 0.2.7),
|
|
15
|
+
moat-link,
|
|
16
|
+
python3-anyio (>= 4.10),
|
|
17
|
+
Description: MoaT irrigation scheduling and monitoring
|
|
18
|
+
Irrigation scheduling and monitoring for MoaT, migrated from the
|
|
19
|
+
legacy Django rainman app to a moat.db.rain SQLAlchemy submodule.
|
|
20
|
+
Ships a per-site systemd monitor daemon.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.0 (quilt)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=MoaT irrigation monitor for %i
|
|
3
|
+
After=moat-link.service
|
|
4
|
+
ConditionFileNotEmpty=/etc/moat/moat.yaml
|
|
5
|
+
|
|
6
|
+
[Install]
|
|
7
|
+
WantedBy=multi-user.target
|
|
8
|
+
|
|
9
|
+
[Service]
|
|
10
|
+
Type=notify
|
|
11
|
+
ExecStart=/usr/bin/moat db rain %i monitor
|
|
12
|
+
EnvironmentFile=/usr/lib/moat/link/env
|
|
13
|
+
EnvironmentFile=-/etc/moat/rain.env
|
|
14
|
+
TimeoutSec=300
|
|
15
|
+
WatchdogSec=10
|
|
16
|
+
Restart=always
|
|
17
|
+
RestartSec=30
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
build-backend = "setuptools.build_meta"
|
|
3
|
+
requires = ["setuptools", "wheel"]
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "moat-db-rain"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "MoaT irrigation scheduling and monitoring"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
keywords = ["MoaT"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Framework :: AsyncIO",
|
|
16
|
+
"Framework :: Trio",
|
|
17
|
+
"Framework :: AnyIO",
|
|
18
|
+
"Development Status :: 3 - Alpha",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"moat-util ~= 0.63.0",
|
|
22
|
+
"moat-db ~= 0.2.7",
|
|
23
|
+
"moat-lib-run ~= 0.2.0",
|
|
24
|
+
"moat-link ~= 0.9.1",
|
|
25
|
+
"asyncclick >= 8.3",
|
|
26
|
+
"anyio >= 4.10",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[[project.authors]]
|
|
30
|
+
name = "Matthias Urlichs"
|
|
31
|
+
email = "matthias@urlichs.de"
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
homepage = "https://m-o-a-t.org"
|
|
35
|
+
repository = "https://github.com/M-o-a-T/moat"
|
|
36
|
+
|
|
37
|
+
[project.optional-dependencies]
|
|
38
|
+
systemd = ["systemd.daemon"]
|
|
39
|
+
dev = ["ruff", "ty"]
|
|
40
|
+
|
|
41
|
+
[tool.setuptools.packages.find]
|
|
42
|
+
where = ["src"]
|
|
43
|
+
|
|
44
|
+
[tool.setuptools.package-data]
|
|
45
|
+
"*" = ["*.yaml"]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Command-line interface for moat.db.rain."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import asyncclick as click
|
|
8
|
+
from sqlalchemy import select
|
|
9
|
+
|
|
10
|
+
from moat.util import yprint
|
|
11
|
+
from moat.db import database
|
|
12
|
+
from moat.lib.run import load_subgroup
|
|
13
|
+
|
|
14
|
+
from .cmds._util import site_dump
|
|
15
|
+
from .model import Site
|
|
16
|
+
|
|
17
|
+
#: Subcommands whose entities are **global** (not per-site) and thus
|
|
18
|
+
#: accept the dummy site ``-`` in place of a real site name.
|
|
19
|
+
#: Currently the day-definition and day-range registries.
|
|
20
|
+
_GLOBAL_SUBCOMMANDS: frozenset[str] = frozenset({"day", "dayrange"})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@load_subgroup(
|
|
24
|
+
sub_pre="moat.db.rain.cmds",
|
|
25
|
+
sub_post="cli",
|
|
26
|
+
ext_pre="moat.db.rain",
|
|
27
|
+
ext_post="_main.cli",
|
|
28
|
+
invoke_without_command=True,
|
|
29
|
+
)
|
|
30
|
+
@click.argument("site", type=str, nargs=1)
|
|
31
|
+
@click.pass_context
|
|
32
|
+
async def cli(ctx, site):
|
|
33
|
+
"""Irrigation management.
|
|
34
|
+
|
|
35
|
+
Manage irrigation sites, controllers, valves, feeds, sensors,
|
|
36
|
+
schedules, and the monitoring daemon. The site is named before the
|
|
37
|
+
verb — ``moat db rain <SITE> <verb>`` — mirroring ``moat link wago``.
|
|
38
|
+
|
|
39
|
+
\b
|
|
40
|
+
Use ``moat db rain -`` to list all sites.
|
|
41
|
+
Use ``moat db rain <SITE>`` (no subcommand) to show one site.
|
|
42
|
+
Use ``moat db rain - <verb>`` for global subcommands (day, dayrange)
|
|
43
|
+
that don't need a site.
|
|
44
|
+
"""
|
|
45
|
+
obj = ctx.obj
|
|
46
|
+
sess = ctx.with_resource(database(obj.cfg.db))
|
|
47
|
+
ctx.with_resource(sess.begin())
|
|
48
|
+
obj.session = sess
|
|
49
|
+
obj.site_name = site
|
|
50
|
+
|
|
51
|
+
if site == "-":
|
|
52
|
+
if ctx.invoked_subcommand is None:
|
|
53
|
+
seen = False
|
|
54
|
+
with sess.execute(select(Site).order_by(Site.name)) as sites:
|
|
55
|
+
for (s,) in sites:
|
|
56
|
+
seen = True
|
|
57
|
+
print(s.name, file=obj.stdout)
|
|
58
|
+
if not seen:
|
|
59
|
+
print("No sites defined yet. Use '--help'?", file=sys.stderr)
|
|
60
|
+
return
|
|
61
|
+
if ctx.invoked_subcommand not in _GLOBAL_SUBCOMMANDS:
|
|
62
|
+
raise click.BadParameter(
|
|
63
|
+
f"The site '-' is a dummy for global commands only; "
|
|
64
|
+
f"'{ctx.invoked_subcommand}' needs a real site.",
|
|
65
|
+
)
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
if ctx.invoked_subcommand is None:
|
|
69
|
+
yprint(site_dump(sess.one(Site, name=site)), stream=obj.stdout)
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Shared helpers for the ``moat db rain`` subcommands.
|
|
2
|
+
|
|
3
|
+
This module's name starts with ``_`` so the subcommand loader (which
|
|
4
|
+
skips underscore-prefixed modules) never exposes it as a command.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
|
|
13
|
+
import asyncclick as click
|
|
14
|
+
from sqlalchemy import select
|
|
15
|
+
|
|
16
|
+
from moat.util import NotGiven
|
|
17
|
+
from moat.db.rain.model import Controller, Site, Valve
|
|
18
|
+
from moat.lib.run import option_ng
|
|
19
|
+
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from collections.abc import Iterator
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
#: Seconds per day — scales the site evaporation ``rate`` between its
|
|
28
|
+
#: stored unit (mm/second) and the CLI unit (mm/day).
|
|
29
|
+
SECS_PER_DAY: int = 24 * 3600
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def site_of(obj):
|
|
33
|
+
"""Resolve the currently-selected site (``obj.site_name``) to a row.
|
|
34
|
+
|
|
35
|
+
Raises:
|
|
36
|
+
click.UsageError: if the named site does not exist.
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
return obj.session.one(Site, name=obj.site_name)
|
|
40
|
+
except KeyError:
|
|
41
|
+
raise click.UsageError(f"Site {obj.site_name!r} doesn't exist.") from None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def scale_site_rate(kw: dict[str, Any]) -> None:
|
|
45
|
+
"""Convert a CLI ``rate`` (mm/day) in ``kw`` to the stored mm/second.
|
|
46
|
+
|
|
47
|
+
Mutates ``kw`` in place: if a ``rate`` was supplied (i.e. not
|
|
48
|
+
:data:`~moat.util.NotGiven`), it is divided by :data:`SECS_PER_DAY`.
|
|
49
|
+
The stored :attr:`Site.rate` column is in mm/second; the CLI accepts
|
|
50
|
+
mm/day, so the command layer scales before :meth:`~Base.apply`.
|
|
51
|
+
"""
|
|
52
|
+
if is_given(kw.get("rate", NotGiven)):
|
|
53
|
+
kw["rate"] = kw["rate"] / SECS_PER_DAY
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def site_dump(site: Site) -> dict[str, Any]:
|
|
57
|
+
"""Dump a site for the CLI, presenting ``rate`` in mm/day.
|
|
58
|
+
|
|
59
|
+
The stored :attr:`Site.rate` is mm/second; the CLI shows mm/day, so
|
|
60
|
+
the dumped value is multiplied by :data:`SECS_PER_DAY`.
|
|
61
|
+
"""
|
|
62
|
+
res = site.dump()
|
|
63
|
+
if "rate" in res:
|
|
64
|
+
res["rate"] = res["rate"] * SECS_PER_DAY
|
|
65
|
+
return res
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def require_name(obj, what: str) -> str:
|
|
69
|
+
"""Return ``obj.name`` or raise a usage error.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
obj: The click object carrying the selected entity name.
|
|
73
|
+
what: Human-readable entity name for the error message.
|
|
74
|
+
"""
|
|
75
|
+
name = obj.name
|
|
76
|
+
if name is None:
|
|
77
|
+
raise click.UsageError(f"The {what} needs a name. Use '--name'.")
|
|
78
|
+
return name
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_one(obj, model, what: str, **kw):
|
|
82
|
+
"""Fetch a single ``model`` row matching ``kw`` or raise.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
obj: The click object carrying the session.
|
|
86
|
+
model: The mapped class to query.
|
|
87
|
+
what: Human-readable entity name for the error message.
|
|
88
|
+
**kw: Filter keyword arguments forwarded to :meth:`Mgr.one`.
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
click.UsageError: if no row matches.
|
|
92
|
+
"""
|
|
93
|
+
try:
|
|
94
|
+
return obj.session.one(model, **kw)
|
|
95
|
+
except KeyError:
|
|
96
|
+
raise click.UsageError(f"This {what} doesn't exist.") from None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def absent(obj, model, what: str, **kw) -> None:
|
|
100
|
+
"""Raise if a ``model`` row matching ``kw`` already exists.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
obj: The click object carrying the session.
|
|
104
|
+
model: The mapped class to query.
|
|
105
|
+
what: Human-readable entity name for the error message.
|
|
106
|
+
**kw: Filter keyword arguments forwarded to :meth:`Mgr.one`.
|
|
107
|
+
|
|
108
|
+
Raises:
|
|
109
|
+
click.UsageError: if a matching row exists.
|
|
110
|
+
"""
|
|
111
|
+
try:
|
|
112
|
+
obj.session.one(model, **kw)
|
|
113
|
+
except KeyError:
|
|
114
|
+
return
|
|
115
|
+
raise click.UsageError(f"This {what} already exists.") from None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def list_in_site(obj, model):
|
|
119
|
+
"""Iterate the rows of ``model`` belonging to the selected site."""
|
|
120
|
+
site = site_of(obj)
|
|
121
|
+
with obj.session.execute(select(model).where(model.site == site).order_by(model.name)) as rs:
|
|
122
|
+
for (row,) in rs:
|
|
123
|
+
yield row
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def list_global(obj, model):
|
|
127
|
+
"""Iterate every row of ``model`` (for globally-scoped entities).
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
obj: The click object carrying the session.
|
|
131
|
+
model: The mapped class to query; must have a ``name`` column.
|
|
132
|
+
"""
|
|
133
|
+
with obj.session.execute(select(model).order_by(model.name)) as rs:
|
|
134
|
+
for (row,) in rs:
|
|
135
|
+
yield row
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def valve_spec(obj, spec: str):
|
|
139
|
+
"""Resolve a ``controller:name`` valve spec to a :class:`Valve` row.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
obj: The click object carrying the session and site name.
|
|
143
|
+
spec: A ``controller:name`` string identifying the valve.
|
|
144
|
+
|
|
145
|
+
Raises:
|
|
146
|
+
click.UsageError: if the spec is malformed or the valve is absent.
|
|
147
|
+
"""
|
|
148
|
+
try:
|
|
149
|
+
ctrl_name, vname = spec.split(":", 1)
|
|
150
|
+
except ValueError:
|
|
151
|
+
raise click.UsageError(f"Bad valve spec {spec!r}; use 'controller:name'.") from None
|
|
152
|
+
ctrl = get_one(obj, Controller, "controller", site=site_of(obj), name=ctrl_name)
|
|
153
|
+
return get_one(obj, Valve, "valve", controller=ctrl, name=vname)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def is_given(v) -> bool:
|
|
157
|
+
"""True unless ``v`` is the ``NotGiven`` sentinel."""
|
|
158
|
+
return v is not NotGiven
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def parse_dt(s: str) -> datetime:
|
|
162
|
+
"""Parse an ISO-8601 timestamp, forcing timezone awareness.
|
|
163
|
+
|
|
164
|
+
Naive timestamps are interpreted in the local timezone, matching the
|
|
165
|
+
repo-wide convention of tz-aware datetimes everywhere.
|
|
166
|
+
|
|
167
|
+
Raises:
|
|
168
|
+
click.UsageError: if ``s`` is not a parseable timestamp.
|
|
169
|
+
"""
|
|
170
|
+
try:
|
|
171
|
+
dt = datetime.fromisoformat(s)
|
|
172
|
+
except ValueError:
|
|
173
|
+
raise click.UsageError(f"Bad timestamp {s!r}.") from None
|
|
174
|
+
if dt.tzinfo is None:
|
|
175
|
+
dt = dt.astimezone()
|
|
176
|
+
return dt
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@contextmanager
|
|
180
|
+
def lookup_errors() -> Iterator[None]:
|
|
181
|
+
"""Translate :class:`Mgr.one` lookup failures into a :class:`UsageError`.
|
|
182
|
+
|
|
183
|
+
``apply()`` methods resolve parents and link targets by name via
|
|
184
|
+
``sess.one(Model, ...)``; a miss raises a bare ``KeyError``. This wraps
|
|
185
|
+
such calls so the CLI surfaces a readable error instead of a traceback.
|
|
186
|
+
"""
|
|
187
|
+
try:
|
|
188
|
+
yield
|
|
189
|
+
except KeyError as e:
|
|
190
|
+
model, kw = e.args[0], e.args[1]
|
|
191
|
+
name = kw.get("name")
|
|
192
|
+
if name is not None:
|
|
193
|
+
raise click.UsageError(f"{model.lower()} {name!r} doesn't exist.") from None
|
|
194
|
+
raise click.UsageError(f"This {model.lower()} doesn't exist.") from None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def bool_pair(set_flag: bool, clr_flag: bool, key: str) -> dict:
|
|
198
|
+
"""Translate two mutually-exclusive set/clear flags into an apply arg.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
set_flag: True if the "set" flag was passed.
|
|
202
|
+
clr_flag: True if the "clear" flag was passed.
|
|
203
|
+
key: The column name to set.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
``{key: True}``, ``{key: False}``, or ``{}`` if neither flag was given.
|
|
207
|
+
|
|
208
|
+
Raises:
|
|
209
|
+
click.UsageError: if both flags were passed at once.
|
|
210
|
+
"""
|
|
211
|
+
if set_flag and clr_flag:
|
|
212
|
+
raise click.UsageError(f"--{key} and --no-{key} are mutually exclusive.")
|
|
213
|
+
if set_flag:
|
|
214
|
+
return {key: True}
|
|
215
|
+
if clr_flag:
|
|
216
|
+
return {key: False}
|
|
217
|
+
return {}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def site_opts(c):
|
|
221
|
+
"""Decorator: the scalar options of a :class:`Site`."""
|
|
222
|
+
c = option_ng("--name", "-n", type=str, help="Rename this site")(c)
|
|
223
|
+
c = option_ng("--comment", "-c", type=str, help="Free-form description")(c)
|
|
224
|
+
c = option_ng("--rate", "-r", "rate", type=float, help="Evaporation rate (mm/day)")(c)
|
|
225
|
+
c = option_ng("--rain-delay", "-d", "rain_delay", type=int, help="Pause after rain (seconds)")(
|
|
226
|
+
c
|
|
227
|
+
)
|
|
228
|
+
return c
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def emit_log(msg: str) -> None:
|
|
232
|
+
"""Print a scheduler event line to stderr (the ``--verbose`` sink).
|
|
233
|
+
|
|
234
|
+
The engine's ``log`` callback is a plain ``Callable[[str], None]``;
|
|
235
|
+
the CLI wires ``--verbose`` to this printer so ``gen`` / ``recalc``
|
|
236
|
+
narrate their decisions without polluting the YAML result on stdout.
|
|
237
|
+
"""
|
|
238
|
+
print(msg, file=sys.stderr)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Create an irrigation site: ``moat db rain <SITE> add``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncclick as click
|
|
6
|
+
|
|
7
|
+
from moat.util import yprint
|
|
8
|
+
from moat.db.rain.model import Site
|
|
9
|
+
|
|
10
|
+
from ._util import absent, scale_site_rate, site_dump, site_opts
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.command()
|
|
14
|
+
@site_opts
|
|
15
|
+
@click.pass_obj
|
|
16
|
+
async def cli(obj, **kw):
|
|
17
|
+
"""Create an irrigation site."""
|
|
18
|
+
name = obj.site_name
|
|
19
|
+
absent(obj, Site, "site", name=name)
|
|
20
|
+
site = Site(name=name)
|
|
21
|
+
obj.session.add(site)
|
|
22
|
+
scale_site_rate(kw)
|
|
23
|
+
site.apply(**kw)
|
|
24
|
+
obj.session.flush()
|
|
25
|
+
yprint(site_dump(site), stream=obj.stdout)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Command-line interface for irrigation controllers.
|
|
2
|
+
|
|
3
|
+
``moat db rain <SITE> controller {show,add,set,delete}``
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
import asyncclick as click
|
|
11
|
+
|
|
12
|
+
from moat.util import yprint
|
|
13
|
+
from moat.db.rain.model import Controller
|
|
14
|
+
from moat.lib.run import option_ng
|
|
15
|
+
|
|
16
|
+
from ._util import absent, get_one, is_given, list_in_site, require_name, site_of
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@click.group(name="controller", short_help="Manage irrigation controllers")
|
|
20
|
+
@click.option("--name", "-n", type=str, default=None, help="Controller name")
|
|
21
|
+
@click.pass_obj
|
|
22
|
+
async def cli(obj, name):
|
|
23
|
+
"""Manage irrigation controllers."""
|
|
24
|
+
obj.name = name
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def opts(c):
|
|
28
|
+
"""Scalar options of a :class:`Controller`."""
|
|
29
|
+
c = option_ng("--name", "-n", type=str, help="Rename this controller")(c)
|
|
30
|
+
c = option_ng("--comment", "-c", type=str, help="Free-form description")(c)
|
|
31
|
+
c = option_ng("--location", "-l", type=str, help="Where the controller lives")(c)
|
|
32
|
+
c = option_ng("--max-on", "-m", "max_on", type=int, help="Max simultaneously-open valves")(c)
|
|
33
|
+
return c
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@cli.command(name="show")
|
|
37
|
+
@click.pass_obj
|
|
38
|
+
async def show_(obj):
|
|
39
|
+
"""Show one controller, or list all controllers in this site."""
|
|
40
|
+
if obj.name is None:
|
|
41
|
+
seen = False
|
|
42
|
+
for c in list_in_site(obj, Controller):
|
|
43
|
+
seen = True
|
|
44
|
+
print(c.name, file=obj.stdout)
|
|
45
|
+
if not seen:
|
|
46
|
+
print("No controllers defined yet. Use '--help'?", file=sys.stderr)
|
|
47
|
+
return
|
|
48
|
+
c = get_one(obj, Controller, "controller", site=site_of(obj), name=obj.name)
|
|
49
|
+
yprint(c.dump(), stream=obj.stdout)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@cli.command()
|
|
53
|
+
@opts
|
|
54
|
+
@click.pass_obj
|
|
55
|
+
async def add(obj, **kw):
|
|
56
|
+
"""Add a controller to this site."""
|
|
57
|
+
name = require_name(obj, "controller")
|
|
58
|
+
if not is_given(kw.get("location", ...)):
|
|
59
|
+
raise click.UsageError("A controller needs --location.")
|
|
60
|
+
absent(obj, Controller, "controller", site=site_of(obj), name=name)
|
|
61
|
+
c = Controller(name=name)
|
|
62
|
+
obj.session.add(c)
|
|
63
|
+
c.apply(site=obj.site_name, **kw)
|
|
64
|
+
obj.session.flush()
|
|
65
|
+
yprint(c.dump(), stream=obj.stdout)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@cli.command(name="set")
|
|
69
|
+
@opts
|
|
70
|
+
@click.pass_obj
|
|
71
|
+
async def set_(obj, **kw):
|
|
72
|
+
"""Modify a controller."""
|
|
73
|
+
name = require_name(obj, "controller")
|
|
74
|
+
c = get_one(obj, Controller, "controller", site=site_of(obj), name=name)
|
|
75
|
+
c.apply(site=obj.site_name, **kw)
|
|
76
|
+
obj.session.flush()
|
|
77
|
+
yprint(c.dump(), stream=obj.stdout)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@cli.command(name="delete")
|
|
81
|
+
@click.pass_obj
|
|
82
|
+
async def delete_(obj):
|
|
83
|
+
"""Remove a controller (and its valves)."""
|
|
84
|
+
name = require_name(obj, "controller")
|
|
85
|
+
c = get_one(obj, Controller, "controller", site=site_of(obj), name=name)
|
|
86
|
+
obj.session.delete(c)
|