gridworks-admin 0.1.0.dev3__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 (33) hide show
  1. gridworks_admin-0.1.0.dev3/PKG-INFO +19 -0
  2. gridworks_admin-0.1.0.dev3/README.md +0 -0
  3. gridworks_admin-0.1.0.dev3/pyproject.toml +34 -0
  4. gridworks_admin-0.1.0.dev3/src/gwadmin/__init__.py +0 -0
  5. gridworks_admin-0.1.0.dev3/src/gwadmin/cli.py +167 -0
  6. gridworks_admin-0.1.0.dev3/src/gwadmin/settings.py +31 -0
  7. gridworks_admin-0.1.0.dev3/src/gwadmin/tdemo/__init__.py +0 -0
  8. gridworks_admin-0.1.0.dev3/src/gwadmin/tdemo/actions.py +46 -0
  9. gridworks_admin-0.1.0.dev3/src/gwadmin/tdemo/actions05.tcss +20 -0
  10. gridworks_admin-0.1.0.dev3/src/gwadmin/tdemo/cli.py +39 -0
  11. gridworks_admin-0.1.0.dev3/src/gwadmin/tdemo/stopwatch.py +109 -0
  12. gridworks_admin-0.1.0.dev3/src/gwadmin/tdemo/stopwatch.tcss +51 -0
  13. gridworks_admin-0.1.0.dev3/src/gwadmin/tdemo/switch.py +42 -0
  14. gridworks_admin-0.1.0.dev3/src/gwadmin/tdemo/switch.tcss +28 -0
  15. gridworks_admin-0.1.0.dev3/src/gwadmin/watch/__init__.py +0 -0
  16. gridworks_admin-0.1.0.dev3/src/gwadmin/watch/clients/__init__.py +0 -0
  17. gridworks_admin-0.1.0.dev3/src/gwadmin/watch/clients/admin_client.py +249 -0
  18. gridworks_admin-0.1.0.dev3/src/gwadmin/watch/clients/constrained_mqtt_client.py +337 -0
  19. gridworks_admin-0.1.0.dev3/src/gwadmin/watch/clients/relay_client.py +342 -0
  20. gridworks_admin-0.1.0.dev3/src/gwadmin/watch/relay_app.py +135 -0
  21. gridworks_admin-0.1.0.dev3/src/gwadmin/watch/relay_app.tcss +52 -0
  22. gridworks_admin-0.1.0.dev3/src/gwadmin/watch/watchex/__init__.py +0 -0
  23. gridworks_admin-0.1.0.dev3/src/gwadmin/watch/watchex/watchex_app.py +49 -0
  24. gridworks_admin-0.1.0.dev3/src/gwadmin/watch/watchex/watchex_app.tcss +0 -0
  25. gridworks_admin-0.1.0.dev3/src/gwadmin/watch/widgets/__init__.py +0 -0
  26. gridworks_admin-0.1.0.dev3/src/gwadmin/watch/widgets/keepalive.py +76 -0
  27. gridworks_admin-0.1.0.dev3/src/gwadmin/watch/widgets/mqtt.py +47 -0
  28. gridworks_admin-0.1.0.dev3/src/gwadmin/watch/widgets/relay_state_text.py +33 -0
  29. gridworks_admin-0.1.0.dev3/src/gwadmin/watch/widgets/relay_toggle_button.py +110 -0
  30. gridworks_admin-0.1.0.dev3/src/gwadmin/watch/widgets/relay_widget_info.py +124 -0
  31. gridworks_admin-0.1.0.dev3/src/gwadmin/watch/widgets/relays.py +265 -0
  32. gridworks_admin-0.1.0.dev3/src/gwadmin/watch/widgets/time_input.py +13 -0
  33. gridworks_admin-0.1.0.dev3/src/gwadmin/watch/widgets/timer.py +64 -0
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.3
2
+ Name: gridworks-admin
3
+ Version: 0.1.0.dev3
4
+ Summary: Add your description here
5
+ Author: Andrew Schweitzer
6
+ Author-email: Andrew Schweitzer <schweitz72@gmail.com>
7
+ Requires-Dist: gridworks-proactor>=4.1.9
8
+ Requires-Dist: gridworks-scada-protocol
9
+ Requires-Dist: numpy>=2.3.3
10
+ Requires-Dist: paho-mqtt>=2.1.0
11
+ Requires-Dist: pydantic>=2.11.9
12
+ Requires-Dist: python-dotenv>=1.1.1
13
+ Requires-Dist: result>=0.9.0
14
+ Requires-Dist: rich>=14.1.0
15
+ Requires-Dist: textual>=6.1.0
16
+ Requires-Dist: typer>=0.17.4
17
+ Requires-Python: >=3.11
18
+ Description-Content-Type: text/markdown
19
+
File without changes
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "gridworks-admin"
3
+ version = "0.1.0.dev3"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Andrew Schweitzer", email = "schweitz72@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.11"
10
+ dependencies = [
11
+ "gridworks-proactor>=4.1.9",
12
+ "gridworks-scada-protocol",
13
+ "numpy>=2.3.3",
14
+ "paho-mqtt>=2.1.0",
15
+ "pydantic>=2.11.9",
16
+ "python-dotenv>=1.1.1",
17
+ "result>=0.9.0",
18
+ "rich>=14.1.0",
19
+ "textual>=6.1.0",
20
+ "typer>=0.17.4",
21
+ ]
22
+
23
+ [project.scripts]
24
+ gwa = "gwadmin:cli.app"
25
+
26
+ [build-system]
27
+ requires = ["uv_build>=0.8.8,<0.9.0"]
28
+ build-backend = "uv_build"
29
+
30
+ [tool.uv.sources]
31
+ gridworks-scada-protocol = { path = "../gridworks-scada-protocol", editable = true }
32
+
33
+ [tool.uv.build-backend]
34
+ module-name = "gwadmin"
File without changes
@@ -0,0 +1,167 @@
1
+ import logging
2
+ from enum import StrEnum
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+ from typing import Optional
6
+
7
+ import dotenv
8
+ import rich
9
+ import typer
10
+
11
+ from gwadmin.tdemo.cli import app as tdemo_cli
12
+ from gwadmin.settings import AdminClientSettings
13
+ from gwadmin.watch.relay_app import RelaysApp, __version__
14
+ from gwadmin.watch.watchex.watchex_app import WatchExApp
15
+
16
+ app = typer.Typer(
17
+ no_args_is_help=True,
18
+ pretty_exceptions_enable=False,
19
+ rich_markup_mode="rich",
20
+ help="GridWorks Scada Admin Client, version {__version__}",
21
+ )
22
+
23
+ app.add_typer(tdemo_cli, name="demo", help="Textual demo commands.")
24
+
25
+ DEFAULT_TARGET: str = "d1.isone.me.versant.keene.orange.scada"
26
+
27
+ class RelayState(StrEnum):
28
+ open = "0"
29
+ closed = "1"
30
+
31
+ def watch_settings(
32
+ target: str = "",
33
+ env_file: str = ".env",
34
+ verbose: int = 0,
35
+ paho_verbose: int = 0,
36
+ show_clock: bool = False,
37
+ show_footer: bool = False,
38
+ ) -> AdminClientSettings:
39
+ # https://github.com/koxudaxi/pydantic-pycharm-plugin/issues/1013
40
+ # noinspection PyArgumentList
41
+ settings = AdminClientSettings(
42
+ _env_file=dotenv.find_dotenv(env_file),
43
+ show_clock=show_clock,
44
+ show_footer=show_footer,
45
+ ).update_paths_name("admin")
46
+ if target:
47
+ settings.target_gnode = target
48
+ elif not settings.target_gnode:
49
+ settings.target_gnode = DEFAULT_TARGET
50
+ if verbose:
51
+ if verbose == 1:
52
+ verbosity = logging.INFO
53
+ else:
54
+ verbosity = logging.DEBUG
55
+ settings.verbosity = verbosity
56
+ if paho_verbose:
57
+ if paho_verbose == 1:
58
+ paho_verbosity = logging.INFO
59
+ else:
60
+ paho_verbosity = logging.DEBUG
61
+ settings.paho_verbosity = paho_verbosity
62
+ return settings
63
+
64
+ @app.command()
65
+ def watch(
66
+ target: str = "",
67
+ env_file: str = ".env",
68
+ verbose: Annotated[int, typer.Option("--verbose", "-v", count=True)] = 0,
69
+ paho_verbose: Annotated[int, typer.Option("--paho-verbose", count=True)] = 0,
70
+ show_clock: Annotated[
71
+ bool,
72
+ typer.Option(
73
+ "--show-clock",
74
+ ),
75
+ ] = False,
76
+ show_footer: Annotated[
77
+ bool,
78
+ typer.Option(
79
+ "--show-footer",
80
+ ),
81
+ ] = False,
82
+ ) -> None:
83
+ """Watch and set relays."""
84
+ settings = watch_settings(
85
+ target,
86
+ env_file,
87
+ verbose,
88
+ paho_verbose,
89
+ show_clock=show_clock,
90
+ show_footer=show_footer,
91
+ )
92
+ rich.print(settings)
93
+ watch_app = RelaysApp(settings=settings)
94
+ watch_app.run()
95
+
96
+ @app.command()
97
+ def watchex(
98
+ target: str = "",
99
+ env_file: str = ".env",
100
+ verbose: Annotated[
101
+ int,
102
+ typer.Option(
103
+ "--verbose", "-v", count=True
104
+ )
105
+ ] = 0,
106
+ paho_verbose: Annotated[
107
+ int,
108
+ typer.Option(
109
+ "--paho-verbose", count=True
110
+ )
111
+ ] = 0
112
+ ) -> None:
113
+ """Watch and set relays with experimental features"""
114
+ settings = watch_settings(target, env_file, verbose, paho_verbose)
115
+ rich.print(settings)
116
+ watch_app = WatchExApp(settings=settings)
117
+ watch_app.run()
118
+
119
+ @app.command()
120
+ def config(
121
+ target: str = "",
122
+ env_file: str = ".env",
123
+ verbose: Annotated[
124
+ int,
125
+ typer.Option(
126
+ "--verbose", "-v", count=True
127
+ )
128
+ ] = 0,
129
+ paho_verbose: Annotated[
130
+ int,
131
+ typer.Option(
132
+ "--paho-verbose", count=True
133
+ )
134
+ ] = 0
135
+ ) -> None:
136
+ """Show admin settings."""
137
+ settings = watch_settings(target, env_file, verbose, paho_verbose)
138
+ rich.print(
139
+ f"Env file: <{env_file}> exists: {bool(env_file and Path(env_file).exists())}"
140
+ )
141
+ rich.print(settings)
142
+ missing_tls_paths_ = settings.check_tls_paths_present(raise_error=False)
143
+ if missing_tls_paths_:
144
+ rich.print(missing_tls_paths_)
145
+
146
+
147
+ def version_callback(value: bool):
148
+ if value:
149
+ print(f"gws admin {__version__}")
150
+ raise typer.Exit()
151
+
152
+ @app.callback()
153
+ def _main(
154
+ _version: Annotated[
155
+ Optional[bool],
156
+ typer.Option(
157
+ "--version",
158
+ callback=version_callback,
159
+ is_eager=True,
160
+ help="Show version and exit."
161
+ ),
162
+ ] = None,
163
+ ) -> None: ...
164
+
165
+
166
+ if __name__ == "__main__":
167
+ app()
@@ -0,0 +1,31 @@
1
+ import logging
2
+ from typing import Optional
3
+ from typing import Self
4
+
5
+ from gwproactor import AppSettings
6
+ from gwproactor.config import MQTTClient
7
+ from pydantic import model_validator
8
+ from pydantic_settings import SettingsConfigDict
9
+
10
+ from gwsproto.data_classes.house_0_names import H0N
11
+
12
+ MAX_ADMIN_TIMEOUT = 60 * 60 * 24
13
+
14
+ class AdminClientSettings(AppSettings):
15
+ target_gnode: str = ""
16
+ default_timeout_seconds: int = int(5*60)
17
+ link: MQTTClient = MQTTClient()
18
+ verbosity: int = logging.WARN
19
+ paho_verbosity: Optional[int] = None
20
+ show_clock: bool = False
21
+ show_footer: bool = False
22
+ model_config = SettingsConfigDict(
23
+ env_prefix="GWADMIN_",
24
+ env_nested_delimiter="__",
25
+ extra="ignore",
26
+ )
27
+
28
+ @model_validator(mode="after")
29
+ def validate(self) -> Self:
30
+ self.link.update_tls_paths(self.paths.certs_dir, H0N.admin)
31
+ return self
@@ -0,0 +1,46 @@
1
+ from textual.app import App, ComposeResult
2
+ from textual.widgets import Button
3
+ from textual.widgets import Footer
4
+ from textual.widgets import Static
5
+
6
+ TEXT = """
7
+ [b]Set your background[/b]
8
+ [@click=set_background('cyan')]Cyan[/]
9
+ [@click=set_background('magenta')]Magenta[/]
10
+ [@click=set_background('yellow')]Yellow[/]
11
+ """
12
+
13
+
14
+ class ColorSwitcher(Static, can_focus=True):
15
+ BINDINGS = [
16
+ ("o", "set_background('olive')", "olive"),
17
+ ("w", "set_background('ansi_white')", "ansi_white"),
18
+ ("s", "set_background('silver')", "silver"),
19
+ ]
20
+ def action_set_background(self, color: str) -> None:
21
+ self.styles.background = color
22
+
23
+
24
+ class ActionsApp(App):
25
+ CSS_PATH = "actions05.tcss"
26
+ BINDINGS = [
27
+ ("q", "quit"),
28
+ ("ctrl+c", "quit"),
29
+ ("r", "set_background('red')", "Red"),
30
+ ("g", "set_background('green')", "Green"),
31
+ ("b", "set_background('blue')", "Blue"),
32
+ ]
33
+
34
+ def compose(self) -> ComposeResult:
35
+ yield ColorSwitcher(TEXT)
36
+ yield ColorSwitcher(TEXT)
37
+ yield Button("foo")
38
+ yield Footer()
39
+
40
+ def action_set_background(self, color: str) -> None:
41
+ self.screen.styles.background = color
42
+
43
+
44
+ if __name__ == "__main__":
45
+ app = ActionsApp()
46
+ app.run()
@@ -0,0 +1,20 @@
1
+ Screen {
2
+ layout: grid;
3
+ grid-size: 1;
4
+ grid-gutter: 2 4;
5
+ grid-rows: 1fr;
6
+ }
7
+
8
+ ColorSwitcher {
9
+ height: 100%;
10
+ margin: 2 4;
11
+ border: solid blue;
12
+
13
+ &:focus {
14
+ background: $primary;
15
+ color: $text;
16
+ text-style: bold;
17
+ outline-left: thick $accent;
18
+ }
19
+ }
20
+
@@ -0,0 +1,39 @@
1
+ import typer
2
+
3
+ from gwadmin.tdemo.actions import ActionsApp
4
+ from gwadmin.tdemo.stopwatch import StopwatchApp
5
+ from gwadmin.tdemo.switch import SwitchApp
6
+
7
+ app = typer.Typer(
8
+ no_args_is_help=True,
9
+ pretty_exceptions_enable=False,
10
+ rich_markup_mode="rich",
11
+ help="Textual demo apps",
12
+ )
13
+
14
+
15
+ @app.command()
16
+ def stopwatch():
17
+ """Run textual stopwatch demo"""
18
+ stopwatch_app = StopwatchApp()
19
+ stopwatch_app.run()
20
+
21
+ @app.command()
22
+ def switch():
23
+ """Run textual switch demo"""
24
+ switch_app = SwitchApp()
25
+ switch_app.run()
26
+
27
+ @app.command()
28
+ def actions():
29
+ """Run textual actions demo"""
30
+ actions_app = ActionsApp()
31
+ actions_app.run()
32
+
33
+
34
+ @app.callback()
35
+ def _main() -> None: ...
36
+
37
+
38
+ if __name__ == "__main__":
39
+ app()
@@ -0,0 +1,109 @@
1
+ from time import monotonic
2
+
3
+ from textual.app import App, ComposeResult
4
+ from textual.containers import HorizontalGroup, VerticalScroll
5
+ from textual.reactive import reactive
6
+ from textual.widgets import Button, Digits, Footer, Header
7
+
8
+
9
+ class TimeDisplay(Digits):
10
+ """A widget to display elapsed time."""
11
+
12
+ start_time = reactive(monotonic)
13
+ time = reactive(0.0)
14
+ total = reactive(0.0)
15
+
16
+ def on_mount(self) -> None:
17
+ """Event handler called when widget is added to the app."""
18
+ self.update_timer = self.set_interval(1 / 60, self.update_time, pause=True)
19
+
20
+ def update_time(self) -> None:
21
+ """Method to update time to current."""
22
+ self.time = self.total + (monotonic() - self.start_time)
23
+
24
+ def watch_time(self, time: float) -> None:
25
+ """Called when the time attribute changes."""
26
+ minutes, seconds = divmod(time, 60)
27
+ hours, minutes = divmod(minutes, 60)
28
+ self.update(f"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}")
29
+
30
+ def start(self) -> None:
31
+ """Method to start (or resume) time updating."""
32
+ self.start_time = monotonic()
33
+ self.update_timer.resume()
34
+
35
+ def stop(self):
36
+ """Method to stop the time display updating."""
37
+ self.update_timer.pause()
38
+ self.total += monotonic() - self.start_time
39
+ self.time = self.total
40
+
41
+ def reset(self):
42
+ """Method to reset the time display to zero."""
43
+ self.total = 0
44
+ self.time = 0
45
+
46
+
47
+ class Stopwatch(HorizontalGroup):
48
+ """A stopwatch widget."""
49
+
50
+ def on_button_pressed(self, event: Button.Pressed) -> None:
51
+ """Event handler called when a button is pressed."""
52
+ button_id = event.button.id
53
+ time_display = self.query_one(TimeDisplay)
54
+ if button_id == "start":
55
+ time_display.start()
56
+ self.add_class("started")
57
+ elif button_id == "stop":
58
+ time_display.stop()
59
+ self.remove_class("started")
60
+ elif button_id == "reset":
61
+ time_display.reset()
62
+
63
+ def compose(self) -> ComposeResult:
64
+ """Create child widgets of a stopwatch."""
65
+ yield Button("Start", id="start", variant="success")
66
+ yield Button("Stop", id="stop", variant="error")
67
+ yield Button("Reset", id="reset")
68
+ yield TimeDisplay()
69
+
70
+
71
+ class StopwatchApp(App):
72
+ """A Textual app to manage stopwatches."""
73
+
74
+ CSS_PATH = "stopwatch.tcss"
75
+
76
+ BINDINGS = [
77
+ ("d", "toggle_dark", "Toggle dark mode"),
78
+ ("a", "add_stopwatch", "Add"),
79
+ ("r", "remove_stopwatch", "Remove"),
80
+ ]
81
+
82
+ def compose(self) -> ComposeResult:
83
+ """Called to add widgets to the app."""
84
+ yield Header()
85
+ yield Footer()
86
+ yield VerticalScroll(Stopwatch(), Stopwatch(), Stopwatch(), id="timers")
87
+
88
+ def action_add_stopwatch(self) -> None:
89
+ """An action to add a timer."""
90
+ new_stopwatch = Stopwatch()
91
+ self.query_one("#timers").mount(new_stopwatch)
92
+ new_stopwatch.scroll_visible()
93
+
94
+ def action_remove_stopwatch(self) -> None:
95
+ """Called to remove a timer."""
96
+ timers = self.query("Stopwatch")
97
+ if timers:
98
+ timers.last().remove()
99
+
100
+ def action_toggle_dark(self) -> None:
101
+ """An action to toggle dark mode."""
102
+ self.theme = (
103
+ "textual-dark" if self.theme == "textual-light" else "textual-light"
104
+ )
105
+
106
+
107
+ if __name__ == "__main__":
108
+ app = StopwatchApp()
109
+ app.run()
@@ -0,0 +1,51 @@
1
+ Stopwatch {
2
+ background: $boost;
3
+ height: 5;
4
+ margin: 1;
5
+ min-width: 50;
6
+ padding: 1;
7
+ }
8
+
9
+ TimeDisplay {
10
+ text-align: center;
11
+ color: $foreground-muted;
12
+ height: 3;
13
+ }
14
+
15
+ Button {
16
+ width: 16;
17
+ }
18
+
19
+ #start {
20
+ dock: left;
21
+ }
22
+
23
+ #stop {
24
+ dock: left;
25
+ display: none;
26
+ }
27
+
28
+ #reset {
29
+ dock: right;
30
+ }
31
+
32
+ .started {
33
+ background: $success-muted;
34
+ color: $text;
35
+ }
36
+
37
+ .started TimeDisplay {
38
+ color: $foreground;
39
+ }
40
+
41
+ .started #start {
42
+ display: none
43
+ }
44
+
45
+ .started #stop {
46
+ display: block
47
+ }
48
+
49
+ .started #reset {
50
+ visibility: hidden
51
+ }
@@ -0,0 +1,42 @@
1
+ from textual.app import App, ComposeResult
2
+ from textual.containers import Horizontal
3
+ from textual.widgets import Static, Switch
4
+
5
+
6
+ class SwitchApp(App):
7
+ CSS_PATH = "switch.tcss"
8
+
9
+ BINDINGS = [
10
+ ("d", "toggle_dark", "Toggle dark mode"),
11
+ ("q", "quit", "Quit"),
12
+ ]
13
+
14
+ def compose(self) -> ComposeResult:
15
+ yield Static("[b]Example switches\n", classes="label")
16
+ yield Horizontal(
17
+ Static("off: ", classes="label"),
18
+ Switch(animate=False),
19
+ classes="container",
20
+ )
21
+ yield Horizontal(
22
+ Static("on: ", classes="label"),
23
+ Switch(value=True),
24
+ classes="container",
25
+ )
26
+
27
+ focused_switch = Switch()
28
+ focused_switch.focus()
29
+ yield Horizontal(
30
+ Static("focused: ", classes="label"), focused_switch, classes="container"
31
+ )
32
+
33
+ yield Horizontal(
34
+ Static("custom: ", classes="label"),
35
+ Switch(id="custom-design"),
36
+ classes="container",
37
+ )
38
+
39
+
40
+ app = SwitchApp(css_path="switch.tcss")
41
+ if __name__ == "__main__":
42
+ app.run()
@@ -0,0 +1,28 @@
1
+ Screen {
2
+ align: center middle;
3
+ }
4
+
5
+ .container {
6
+ height: auto;
7
+ width: auto;
8
+ }
9
+
10
+ Switch {
11
+ height: auto;
12
+ width: auto;
13
+ }
14
+
15
+ .label {
16
+ height: 3;
17
+ content-align: center middle;
18
+ width: auto;
19
+ }
20
+
21
+ #custom-design {
22
+ background: darkslategrey;
23
+ }
24
+
25
+ #custom-design > .switch--slider {
26
+ color: dodgerblue;
27
+ background: darkslateblue;
28
+ }