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.
Files changed (40) hide show
  1. moat_db_rain-0.0.1/LICENSE.txt +14 -0
  2. moat_db_rain-0.0.1/PKG-INFO +43 -0
  3. moat_db_rain-0.0.1/README.md +13 -0
  4. moat_db_rain-0.0.1/debian/.gitignore +8 -0
  5. moat_db_rain-0.0.1/debian/changelog +5 -0
  6. moat_db_rain-0.0.1/debian/control +20 -0
  7. moat_db_rain-0.0.1/debian/py3dist-overrides +4 -0
  8. moat_db_rain-0.0.1/debian/rules +11 -0
  9. moat_db_rain-0.0.1/debian/source/format +1 -0
  10. moat_db_rain-0.0.1/moat-db-rain@.service +17 -0
  11. moat_db_rain-0.0.1/pyproject.toml +45 -0
  12. moat_db_rain-0.0.1/setup.cfg +4 -0
  13. moat_db_rain-0.0.1/src/moat/db/rain/__init__.py +9 -0
  14. moat_db_rain-0.0.1/src/moat/db/rain/_main.py +69 -0
  15. moat_db_rain-0.0.1/src/moat/db/rain/cmds/__init__.py +3 -0
  16. moat_db_rain-0.0.1/src/moat/db/rain/cmds/_util.py +238 -0
  17. moat_db_rain-0.0.1/src/moat/db/rain/cmds/add.py +25 -0
  18. moat_db_rain-0.0.1/src/moat/db/rain/cmds/controller.py +86 -0
  19. moat_db_rain-0.0.1/src/moat/db/rain/cmds/day.py +169 -0
  20. moat_db_rain-0.0.1/src/moat/db/rain/cmds/dayrange.py +109 -0
  21. moat_db_rain-0.0.1/src/moat/db/rain/cmds/delete.py +17 -0
  22. moat_db_rain-0.0.1/src/moat/db/rain/cmds/env.py +91 -0
  23. moat_db_rain-0.0.1/src/moat/db/rain/cmds/feed.py +104 -0
  24. moat_db_rain-0.0.1/src/moat/db/rain/cmds/gen.py +56 -0
  25. moat_db_rain-0.0.1/src/moat/db/rain/cmds/group.py +324 -0
  26. moat_db_rain-0.0.1/src/moat/db/rain/cmds/history.py +212 -0
  27. moat_db_rain-0.0.1/src/moat/db/rain/cmds/recalc.py +50 -0
  28. moat_db_rain-0.0.1/src/moat/db/rain/cmds/sensor.py +112 -0
  29. moat_db_rain-0.0.1/src/moat/db/rain/cmds/set.py +22 -0
  30. moat_db_rain-0.0.1/src/moat/db/rain/cmds/valve.py +426 -0
  31. moat_db_rain-0.0.1/src/moat/db/rain/engine.py +998 -0
  32. moat_db_rain-0.0.1/src/moat/db/rain/model.py +1043 -0
  33. moat_db_rain-0.0.1/src/moat/db/rain/model_.py +12 -0
  34. moat_db_rain-0.0.1/src/moat/db/rain/monitor.py +1 -0
  35. moat_db_rain-0.0.1/src/moat/db/rain/range.py +288 -0
  36. moat_db_rain-0.0.1/src/moat_db_rain.egg-info/PKG-INFO +43 -0
  37. moat_db_rain-0.0.1/src/moat_db_rain.egg-info/SOURCES.txt +38 -0
  38. moat_db_rain-0.0.1/src/moat_db_rain.egg-info/dependency_links.txt +1 -0
  39. moat_db_rain-0.0.1/src/moat_db_rain.egg-info/requires.txt +13 -0
  40. 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,8 @@
1
+ /files
2
+ /*.log
3
+ /*.debhelper
4
+ /*.debhelper-build-stamp
5
+ /*.substvars
6
+ /debhelper-build-stamp
7
+ /moat-db-rain
8
+ /moat-db-rain@.service
@@ -0,0 +1,5 @@
1
+ moat-db-rain (0.0.1-1) unstable; urgency=medium
2
+
3
+ * Initial creation.
4
+
5
+ -- Matthias Urlichs <matthias@urlichs.de> Fri, 27 Jun 2026 00:00:00 +0200
@@ -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,4 @@
1
+ moat_lib_run python3-moat-lib-run
2
+ moat_lib_config python3-moat-lib-config
3
+ moat_db python3-moat-db
4
+ moat_link python3-moat-link
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/make -f
2
+
3
+ export PYBUILD_NAME=moat-db-rain
4
+ %:
5
+ dh $@ --with python3 --buildsystem=pybuild
6
+
7
+ debian/moat-db-rain@.service: moat-db-rain@.service
8
+ cp $< $@
9
+
10
+ override_dh_auto_install: debian/moat-db-rain@.service
11
+ dh_auto_install
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,9 @@
1
+ """MoaT irrigation database module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from moat.lib.config import CfgStore
6
+
7
+ __all__ = []
8
+
9
+ CfgStore.with_(__name__)
@@ -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,3 @@
1
+ """
2
+ Sub-command processing here.
3
+ """
@@ -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)