gridworks-admin 1.0.0.dev5__tar.gz → 1.0.0.dev7__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.
- {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/PKG-INFO +2 -2
- {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/pyproject.toml +2 -2
- {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/cli.py +2 -4
- {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/config.py +10 -5
- {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/watch/clients/admin_client.py +10 -1
- gridworks_admin-1.0.0.dev7/src/gwadmin/watch/clients/dac_client.py +319 -0
- {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/watch/relay_app.py +42 -5
- {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/watch/relay_app.tcss +40 -10
- gridworks_admin-1.0.0.dev7/src/gwadmin/watch/widgets/dac_widget_info.py +86 -0
- gridworks_admin-1.0.0.dev7/src/gwadmin/watch/widgets/dacs.py +166 -0
- {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/watch/widgets/keepalive.py +6 -4
- {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/watch/widgets/relay_toggle_button.py +6 -4
- {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/watch/widgets/relays.py +26 -13
- {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/watch/widgets/time_input.py +5 -3
- {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/watch/widgets/timer.py +9 -5
- gridworks_admin-1.0.0.dev5/src/gwadmin/settings.py +0 -31
- {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/README.md +0 -0
- {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/__init__.py +0 -0
- {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/watch/__init__.py +0 -0
- {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/watch/clients/__init__.py +0 -0
- {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/watch/clients/constrained_mqtt_client.py +0 -0
- {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/watch/clients/relay_client.py +0 -0
- {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/watch/widgets/__init__.py +0 -0
- {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/watch/widgets/mqtt.py +0 -0
- {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/watch/widgets/relay_state_text.py +0 -0
- {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/watch/widgets/relay_widget_info.py +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: gridworks-admin
|
|
3
|
-
Version: 1.0.0.
|
|
3
|
+
Version: 1.0.0.dev7
|
|
4
4
|
Summary: CLI tool for monitoring gridworks-scada devices.
|
|
5
5
|
Author: Andrew Schweitzer
|
|
6
6
|
Author-email: Andrew Schweitzer <schweitz72@gmail.com>
|
|
7
7
|
Requires-Dist: gridworks-proactor>=4.1.9
|
|
8
|
-
Requires-Dist: gridworks-scada-protocol
|
|
8
|
+
Requires-Dist: gridworks-scada-protocol>=1.0.0.dev5
|
|
9
9
|
Requires-Dist: numpy>=2.3.3
|
|
10
10
|
Requires-Dist: paho-mqtt>=2.1.0
|
|
11
11
|
Requires-Dist: pydantic>=2.11.9
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "gridworks-admin"
|
|
3
|
-
version = "1.0.0.
|
|
3
|
+
version = "1.0.0.dev7"
|
|
4
4
|
description = "CLI tool for monitoring gridworks-scada devices."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -9,7 +9,7 @@ authors = [
|
|
|
9
9
|
requires-python = ">=3.11"
|
|
10
10
|
dependencies = [
|
|
11
11
|
"gridworks-proactor>=4.1.9",
|
|
12
|
-
"gridworks-scada-protocol",
|
|
12
|
+
"gridworks-scada-protocol>=1.0.0.dev5",
|
|
13
13
|
"numpy>=2.3.3",
|
|
14
14
|
"paho-mqtt>=2.1.0",
|
|
15
15
|
"pydantic>=2.11.9",
|
|
@@ -14,7 +14,7 @@ from pydantic import SecretStr
|
|
|
14
14
|
from gwadmin.config import AdminConfig
|
|
15
15
|
from gwadmin.config import AdminPaths
|
|
16
16
|
from gwadmin.config import CurrentAdminConfig
|
|
17
|
-
from gwadmin.config import
|
|
17
|
+
from gwadmin.config import AdminMQTTClient
|
|
18
18
|
from gwadmin.watch.relay_app import RelaysApp, __version__
|
|
19
19
|
from gwsproto.data_classes.house_0_names import H0N
|
|
20
20
|
|
|
@@ -39,8 +39,6 @@ app = typer.Typer(
|
|
|
39
39
|
help=f"GridWorks Scada Admin Client, version {__version__}",
|
|
40
40
|
)
|
|
41
41
|
|
|
42
|
-
DEFAULT_TARGET: str = "d1.isone.me.versant.keene.orange.scada"
|
|
43
|
-
|
|
44
42
|
def get_config_name(env_file: str = "", config_name: Optional[str] = None) -> str:
|
|
45
43
|
if config_name is None:
|
|
46
44
|
if CONFIG_ENV_VAR in os.environ and os.environ[CONFIG_ENV_VAR]:
|
|
@@ -396,7 +394,7 @@ def add_scada(
|
|
|
396
394
|
if current_config.add_scada(
|
|
397
395
|
name,
|
|
398
396
|
long_name=long_name,
|
|
399
|
-
mqtt_client_config=
|
|
397
|
+
mqtt_client_config=AdminMQTTClient(
|
|
400
398
|
host=host,
|
|
401
399
|
port=port,
|
|
402
400
|
username=username,
|
|
@@ -10,15 +10,20 @@ from pydantic import field_serializer
|
|
|
10
10
|
from pydantic_settings import BaseSettings
|
|
11
11
|
from pydantic_settings import SettingsConfigDict
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
MAX_ADMIN_TIMEOUT = 60 * 60 * 24
|
|
14
|
+
DEFAULT_ADMIN_TIMEOUT = 5 * 60
|
|
15
|
+
|
|
16
|
+
class AdminMQTTClient(MQTTClient):
|
|
17
|
+
|
|
14
18
|
@field_serializer("password", when_used="json")
|
|
15
19
|
def dump_secret(self, v):
|
|
16
20
|
return v.get_secret_value()
|
|
17
21
|
|
|
18
22
|
|
|
19
23
|
class ScadaConfig(BaseSettings):
|
|
20
|
-
|
|
24
|
+
enabled: bool = True
|
|
21
25
|
long_name: str = ""
|
|
26
|
+
mqtt: AdminMQTTClient = AdminMQTTClient()
|
|
22
27
|
|
|
23
28
|
class AdminConfig(BaseModel):
|
|
24
29
|
scadas: dict[str, ScadaConfig] = {}
|
|
@@ -28,7 +33,7 @@ class AdminConfig(BaseModel):
|
|
|
28
33
|
paho_verbosity: Optional[int] = None
|
|
29
34
|
show_clock: bool = False
|
|
30
35
|
show_footer: bool = False
|
|
31
|
-
default_timeout_seconds: int =
|
|
36
|
+
default_timeout_seconds: int = DEFAULT_ADMIN_TIMEOUT
|
|
32
37
|
|
|
33
38
|
class AdminPaths(Paths):
|
|
34
39
|
|
|
@@ -68,7 +73,7 @@ class CurrentAdminConfig(BaseModel):
|
|
|
68
73
|
with self.paths.admin_config_path.open(mode="w") as file:
|
|
69
74
|
file.write(self.config.model_dump_json(indent=2))
|
|
70
75
|
|
|
71
|
-
def add_scada(self, short_name: str, long_name: str, mqtt_client_config:
|
|
76
|
+
def add_scada(self, short_name: str, long_name: str, mqtt_client_config: AdminMQTTClient) -> Optional[ScadaConfig]:
|
|
72
77
|
if short_name not in self.config.scadas:
|
|
73
78
|
self.config.scadas[short_name] = ScadaConfig(
|
|
74
79
|
long_name=long_name,
|
|
@@ -81,4 +86,4 @@ class CurrentAdminConfig(BaseModel):
|
|
|
81
86
|
def last_scada(self) -> str:
|
|
82
87
|
if self.paths.last_scada_path.exists():
|
|
83
88
|
return self.paths.last_scada_path.read_text()
|
|
84
|
-
return ""
|
|
89
|
+
return ""
|
{gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/watch/clients/admin_client.py
RENAMED
|
@@ -4,6 +4,7 @@ import threading
|
|
|
4
4
|
from dataclasses import dataclass
|
|
5
5
|
from logging import Logger
|
|
6
6
|
from typing import Any
|
|
7
|
+
from typing import Callable
|
|
7
8
|
from typing import Optional
|
|
8
9
|
from typing import Sequence
|
|
9
10
|
from typing import Type
|
|
@@ -34,6 +35,9 @@ def type_name(model_type: Type[BaseModel]) -> str:
|
|
|
34
35
|
return str(field.default)
|
|
35
36
|
return ""
|
|
36
37
|
|
|
38
|
+
|
|
39
|
+
ScadaSelectionResetCallback = Callable[[], None]
|
|
40
|
+
|
|
37
41
|
@dataclass
|
|
38
42
|
class AdminClientCallbacks:
|
|
39
43
|
"""Hooks for user of AdminClient. Must be threadsafe."""
|
|
@@ -47,7 +51,8 @@ class AdminClientCallbacks:
|
|
|
47
51
|
"""Hook for user. Called when any mqtt message is received. Called from Paho
|
|
48
52
|
thread. Must be threadsafe."""
|
|
49
53
|
|
|
50
|
-
|
|
54
|
+
scada_selection_reset: Optional[ScadaSelectionResetCallback] = None
|
|
55
|
+
"""Hook for user. Called when scada selection reset."""
|
|
51
56
|
|
|
52
57
|
class AdminSubClient:
|
|
53
58
|
|
|
@@ -183,6 +188,10 @@ class AdminClient:
|
|
|
183
188
|
def switch_scada(self) -> None:
|
|
184
189
|
self._logger.info(f"Switching to scada {self.curr_scada}")
|
|
185
190
|
self.stop()
|
|
191
|
+
for subclient in self._subclients:
|
|
192
|
+
subclient.scada_selection_reset()
|
|
193
|
+
if self._callbacks.scada_selection_reset:
|
|
194
|
+
self._callbacks.scada_selection_reset()
|
|
186
195
|
self._paho_wrapper = ConstrainedMQTTClient(
|
|
187
196
|
settings=self.curr_scada_config.mqtt,
|
|
188
197
|
subscriptions=[
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
import datetime
|
|
3
|
+
import logging
|
|
4
|
+
import threading
|
|
5
|
+
import uuid
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from logging import Logger
|
|
8
|
+
from typing import Callable
|
|
9
|
+
from typing import Optional
|
|
10
|
+
from typing import Self
|
|
11
|
+
from typing import Sequence
|
|
12
|
+
|
|
13
|
+
from gwproto import Message as GWMessage
|
|
14
|
+
from gwproto import MQTTTopic
|
|
15
|
+
from gwproto.enums import ActorClass
|
|
16
|
+
from gwproto.named_types import AnalogDispatch
|
|
17
|
+
|
|
18
|
+
from gwproto.named_types import SingleReading
|
|
19
|
+
from pydantic import BaseModel
|
|
20
|
+
from pydantic import model_validator
|
|
21
|
+
|
|
22
|
+
from gwadmin.watch.clients.admin_client import type_name
|
|
23
|
+
from gwadmin.watch.clients.admin_client import AdminClient
|
|
24
|
+
from gwadmin.watch.clients.admin_client import AdminSubClient
|
|
25
|
+
from gwadmin.watch.clients.constrained_mqtt_client import MessageReceivedCallback
|
|
26
|
+
from gwadmin.watch.clients.constrained_mqtt_client import StateChangeCallback
|
|
27
|
+
from gwsproto.data_classes.house_0_names import H0N
|
|
28
|
+
from gwsproto.named_types import AdminAnalogDispatch
|
|
29
|
+
from gwsproto.named_types import (AdminKeepAlive, AdminReleaseControl,
|
|
30
|
+
LayoutLite, SnapshotSpaceheat)
|
|
31
|
+
|
|
32
|
+
module_logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
class DACConfig(BaseModel):
|
|
35
|
+
about_node_name: str = ""
|
|
36
|
+
channel_name: str = ""
|
|
37
|
+
|
|
38
|
+
class DACState(BaseModel):
|
|
39
|
+
value: int
|
|
40
|
+
time: datetime.datetime
|
|
41
|
+
|
|
42
|
+
class DACInfo(BaseModel):
|
|
43
|
+
config: DACConfig
|
|
44
|
+
observed: Optional[DACState] = None
|
|
45
|
+
|
|
46
|
+
class ObservedDACStateChange(BaseModel):
|
|
47
|
+
old_state: Optional[DACState] = None
|
|
48
|
+
new_state: Optional[DACState] = None
|
|
49
|
+
|
|
50
|
+
@model_validator(mode="after")
|
|
51
|
+
def _model_validator(self) -> Self:
|
|
52
|
+
if self.old_state == self.new_state:
|
|
53
|
+
raise ValueError(
|
|
54
|
+
f"ERROR ObservedDACStateChange has no change: {self.old_state}"
|
|
55
|
+
)
|
|
56
|
+
return self
|
|
57
|
+
|
|
58
|
+
DACStateChangeCallback = Callable[[dict[str, ObservedDACStateChange]], None]
|
|
59
|
+
LayoutCallback = Callable[[LayoutLite], None]
|
|
60
|
+
SnapshotCallback = Callable[[SnapshotSpaceheat], None]
|
|
61
|
+
|
|
62
|
+
class DACConfigChange(BaseModel):
|
|
63
|
+
old_config: Optional[DACConfig] = None
|
|
64
|
+
new_config: Optional[DACConfig] = None
|
|
65
|
+
|
|
66
|
+
@model_validator(mode="after")
|
|
67
|
+
def _model_validator(self) -> Self:
|
|
68
|
+
if self.old_config == self.new_config:
|
|
69
|
+
raise ValueError(
|
|
70
|
+
f"ERROR DACConfigChange has no change: {self.old_config}"
|
|
71
|
+
)
|
|
72
|
+
return self
|
|
73
|
+
|
|
74
|
+
DACConfigChangeCallback = Callable[[dict[str, DACConfigChange]], None]
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class DACClientCallbacks:
|
|
78
|
+
"""Hooks for user of DACWatchClient. Must be threadsafe."""
|
|
79
|
+
|
|
80
|
+
mqtt_state_change_callback: Optional[StateChangeCallback] = None
|
|
81
|
+
"""Hook for user. Called when mqtt client 'state'
|
|
82
|
+
variable changes. Generally, but not exclusively, called from Paho thread.
|
|
83
|
+
Must be threadsafe."""
|
|
84
|
+
|
|
85
|
+
mqtt_message_received_callback: Optional[MessageReceivedCallback] = None
|
|
86
|
+
"""Hook for user. Called when an mqtt message is received if that message is
|
|
87
|
+
not DAC-related or 'pass_all_messages' is True. Called from Paho thread.
|
|
88
|
+
Must be threadsafe."""
|
|
89
|
+
|
|
90
|
+
dac_state_change_callback: Optional[DACStateChangeCallback] = None
|
|
91
|
+
"""Hook for user. Called when a DAC state change is observed.
|
|
92
|
+
Called from Paho thread. Must be threadsafe."""
|
|
93
|
+
|
|
94
|
+
dac_config_change_callback: Optional[DACConfigChangeCallback] = None
|
|
95
|
+
"""Hook for user. Called when a DAC config change is observed.
|
|
96
|
+
Called from Paho thread. Must be threadsafe."""
|
|
97
|
+
|
|
98
|
+
layout_callback: Optional[LayoutCallback] = None
|
|
99
|
+
"""Hook for user. Called when a layout received. Called from Paho thread.
|
|
100
|
+
Must be threadsafe."""
|
|
101
|
+
|
|
102
|
+
snapshot_callback: Optional[SnapshotCallback] = None
|
|
103
|
+
"""Hook for user. Called when a snapshot received. Called from Paho thread.
|
|
104
|
+
Must be threadsafe."""
|
|
105
|
+
|
|
106
|
+
class DACWatchClient(AdminSubClient):
|
|
107
|
+
_lock: threading.RLock
|
|
108
|
+
_dacs: dict[str, DACInfo]
|
|
109
|
+
_channel2node: dict[str, str]
|
|
110
|
+
_pass_all_message: bool = False
|
|
111
|
+
_admin_client: AdminClient
|
|
112
|
+
_callbacks: DACClientCallbacks
|
|
113
|
+
_layout: Optional[LayoutLite] = None
|
|
114
|
+
_snap: Optional[SnapshotSpaceheat] = None
|
|
115
|
+
_logger: Logger | logging.LoggerAdapter[Logger] = module_logger
|
|
116
|
+
|
|
117
|
+
def __init__(
|
|
118
|
+
self,
|
|
119
|
+
callbacks: Optional[DACClientCallbacks] = None,
|
|
120
|
+
*,
|
|
121
|
+
pass_all_messages: bool = False,
|
|
122
|
+
logger: Logger | logging.LoggerAdapter[Logger] = module_logger,
|
|
123
|
+
) -> None:
|
|
124
|
+
self._lock = threading.RLock()
|
|
125
|
+
self._callbacks = callbacks or DACClientCallbacks()
|
|
126
|
+
self._logger = logger
|
|
127
|
+
self._dacs = {}
|
|
128
|
+
self._channel2node = {}
|
|
129
|
+
self._pass_all_message = pass_all_messages
|
|
130
|
+
|
|
131
|
+
def set_admin_client(self, client: AdminClient) -> None:
|
|
132
|
+
self._admin_client = client
|
|
133
|
+
|
|
134
|
+
def set_callbacks(self, callbacks: DACClientCallbacks) -> None:
|
|
135
|
+
if self._admin_client.started():
|
|
136
|
+
raise ValueError(
|
|
137
|
+
"ERROR. AdminClient callbacks must be set before starting "
|
|
138
|
+
"the client."
|
|
139
|
+
)
|
|
140
|
+
self._callbacks = callbacks
|
|
141
|
+
|
|
142
|
+
@classmethod
|
|
143
|
+
def _get_dac_configs(cls, layout: LayoutLite) -> dict[str, DACConfig]:
|
|
144
|
+
dac_node_names = {node.Name for node in layout.ShNodes if node.ActorClass == ActorClass.ZeroTenOutputer}
|
|
145
|
+
dac_channels = {channel.AboutNodeName: channel for channel in layout.DataChannels if channel.AboutNodeName in dac_node_names}
|
|
146
|
+
return {
|
|
147
|
+
node_name : DACConfig(
|
|
148
|
+
about_node_name=node_name,
|
|
149
|
+
channel_name=dac_channels[node_name].Name,
|
|
150
|
+
) for node_name in dac_node_names
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
def _update_layout(self, new_layout: LayoutLite) -> dict[str, DACConfigChange]:
|
|
154
|
+
with self._lock:
|
|
155
|
+
self._layout = new_layout.model_copy()
|
|
156
|
+
new_dac_configs = self._get_dac_configs(self._layout)
|
|
157
|
+
old_dac_names = set(self._dacs.keys())
|
|
158
|
+
new_dac_names = set(new_dac_configs.keys())
|
|
159
|
+
changed_configs = {}
|
|
160
|
+
for added_dac_name in (new_dac_names - old_dac_names):
|
|
161
|
+
self._dacs[added_dac_name] = DACInfo(
|
|
162
|
+
config=new_dac_configs[added_dac_name],
|
|
163
|
+
)
|
|
164
|
+
changed_configs[added_dac_name] = DACConfigChange(
|
|
165
|
+
old_config=None,
|
|
166
|
+
new_config=new_dac_configs[added_dac_name],
|
|
167
|
+
)
|
|
168
|
+
for removed_dac_name in (old_dac_names - new_dac_names):
|
|
169
|
+
changed_configs[removed_dac_name] = DACConfigChange(
|
|
170
|
+
old_config=self._dacs.pop(removed_dac_name).config,
|
|
171
|
+
new_config=None,
|
|
172
|
+
)
|
|
173
|
+
for dac_name in new_dac_names.intersection(old_dac_names):
|
|
174
|
+
new_config = new_dac_configs[dac_name]
|
|
175
|
+
if new_config != self._dacs[dac_name].config:
|
|
176
|
+
changed_configs[dac_name] = DACConfigChange(
|
|
177
|
+
old_config=self._dacs[dac_name].config,
|
|
178
|
+
new_config=new_config,
|
|
179
|
+
)
|
|
180
|
+
self._dacs[dac_name].config = new_config
|
|
181
|
+
if changed_configs:
|
|
182
|
+
self._channel2node = {
|
|
183
|
+
DAC.config.channel_name: DAC.config.about_node_name
|
|
184
|
+
for DAC in self._dacs.values()
|
|
185
|
+
}
|
|
186
|
+
return changed_configs
|
|
187
|
+
|
|
188
|
+
def process_layout_lite(self, layout: LayoutLite) -> None:
|
|
189
|
+
config_changes = self._update_layout(layout)
|
|
190
|
+
if config_changes and self._callbacks.dac_config_change_callback is not None:
|
|
191
|
+
self._callbacks.dac_config_change_callback(config_changes)
|
|
192
|
+
if self._callbacks.layout_callback is not None:
|
|
193
|
+
self._callbacks.layout_callback(layout)
|
|
194
|
+
if self._snap is not None:
|
|
195
|
+
self._process_snapshot(self._snap)
|
|
196
|
+
|
|
197
|
+
def _update_dac_states(self, new_states: dict[str, DACState]) -> dict[str, ObservedDACStateChange]:
|
|
198
|
+
changes: dict[str, ObservedDACStateChange] = {}
|
|
199
|
+
with self._lock:
|
|
200
|
+
for dac_name, new_state in new_states.items():
|
|
201
|
+
data_info = self._dacs.get(dac_name)
|
|
202
|
+
if data_info is not None:
|
|
203
|
+
old_state = copy.deepcopy(data_info.observed)
|
|
204
|
+
if old_state != new_state:
|
|
205
|
+
if old_state is None or new_state.time > old_state.time:
|
|
206
|
+
data_info.observed = new_state
|
|
207
|
+
changes[dac_name] = ObservedDACStateChange(
|
|
208
|
+
old_state=old_state,
|
|
209
|
+
new_state=new_state,
|
|
210
|
+
)
|
|
211
|
+
return changes
|
|
212
|
+
|
|
213
|
+
def _handle_new_dac_states(self, new_states: dict[str, DACState]) -> None:
|
|
214
|
+
state_changes = self._update_dac_states(new_states)
|
|
215
|
+
if state_changes and self._callbacks.dac_state_change_callback is not None:
|
|
216
|
+
self._callbacks.dac_state_change_callback(state_changes)
|
|
217
|
+
|
|
218
|
+
def _dac_info_from_channel(self, channel_name: str) -> Optional[DACInfo]:
|
|
219
|
+
return self._dacs.get(
|
|
220
|
+
self._channel2node.get(channel_name, ""), None
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
def _extract_dac_states(self, readings: Sequence[SingleReading]) -> dict[str, DACState]:
|
|
224
|
+
states = {}
|
|
225
|
+
for reading in readings:
|
|
226
|
+
if data_info := self._dac_info_from_channel(reading.ChannelName):
|
|
227
|
+
states[data_info.config.about_node_name] = DACState(
|
|
228
|
+
value=reading.Value,
|
|
229
|
+
time=reading.ScadaReadTimeUnixMs,
|
|
230
|
+
)
|
|
231
|
+
return states
|
|
232
|
+
|
|
233
|
+
def _process_single_reading(self, payload: bytes) -> None:
|
|
234
|
+
if self._layout is not None:
|
|
235
|
+
self._handle_new_dac_states(
|
|
236
|
+
self._extract_dac_states(
|
|
237
|
+
[GWMessage[SingleReading].model_validate_json(payload).Payload]
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
def process_snapshot(self, snapshot: SnapshotSpaceheat) -> None:
|
|
242
|
+
# self._logger.debug("++DACWatchClient.process_snapshot")
|
|
243
|
+
path_dbg = 0
|
|
244
|
+
self._process_snapshot(snapshot)
|
|
245
|
+
if self._callbacks.snapshot_callback is not None:
|
|
246
|
+
path_dbg |= 0x0000001
|
|
247
|
+
self._callbacks.snapshot_callback(snapshot)
|
|
248
|
+
# self._logger.debug("--DACWatchClient.process_snapshot path:0x%08X", path_dbg)
|
|
249
|
+
|
|
250
|
+
def _process_snapshot(self, snapshot: SnapshotSpaceheat) -> None:
|
|
251
|
+
if self._layout is not None:
|
|
252
|
+
self._handle_new_dac_states(
|
|
253
|
+
self._extract_dac_states(snapshot.LatestReadingList)
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
def process_mqtt_state_changed(self, old_state: str, new_state: str) -> None:
|
|
257
|
+
if self._callbacks.mqtt_state_change_callback is not None:
|
|
258
|
+
self._callbacks.mqtt_state_change_callback(old_state, new_state)
|
|
259
|
+
|
|
260
|
+
def process_mqtt_message(self, topic: str, payload: bytes) -> None:
|
|
261
|
+
decoded_topic = MQTTTopic.decode(topic)
|
|
262
|
+
if decoded_topic.message_type == type_name(SingleReading):
|
|
263
|
+
self._process_single_reading(payload)
|
|
264
|
+
if self._callbacks.mqtt_message_received_callback is not None:
|
|
265
|
+
self._callbacks.mqtt_message_received_callback(topic, payload)
|
|
266
|
+
|
|
267
|
+
def set_dac(self, dac_row_name: str, new_state: int, timeout_seconds: Optional[int] = None):
|
|
268
|
+
self._send_set_command(dac_row_name, new_state, datetime.datetime.now(), timeout_seconds)
|
|
269
|
+
|
|
270
|
+
def _send_set_command(
|
|
271
|
+
self,
|
|
272
|
+
dac_row_name: str,
|
|
273
|
+
value: int,
|
|
274
|
+
set_time: datetime.datetime,
|
|
275
|
+
timeout_seconds: Optional[int] = None
|
|
276
|
+
) -> None:
|
|
277
|
+
dac_node_name = dac_row_name.lower() + "-010v"
|
|
278
|
+
self._admin_client.publish(
|
|
279
|
+
AdminAnalogDispatch(
|
|
280
|
+
Dispatch=AnalogDispatch(
|
|
281
|
+
FromGNodeAlias=None,
|
|
282
|
+
FromHandle=H0N.admin,
|
|
283
|
+
ToHandle=f"{H0N.admin}.{dac_node_name}",
|
|
284
|
+
AboutName=dac_node_name,
|
|
285
|
+
Value=value,
|
|
286
|
+
TriggerId=str(uuid.uuid4()),
|
|
287
|
+
UnixTimeMs=int(set_time.timestamp() * 1000),
|
|
288
|
+
),
|
|
289
|
+
TimeoutSeconds=timeout_seconds,
|
|
290
|
+
)
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
def send_keepalive(self, timeout_seconds: Optional[int] = None) -> None:
|
|
294
|
+
self._admin_client.publish(
|
|
295
|
+
AdminKeepAlive(AdminTimeoutSeconds=timeout_seconds)
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
def send_release_control(self) -> None:
|
|
299
|
+
self._admin_client.publish(
|
|
300
|
+
AdminReleaseControl()
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
def scada_selection_reset(self) -> None:
|
|
304
|
+
self._layout = None
|
|
305
|
+
self._snap = None
|
|
306
|
+
with self._lock:
|
|
307
|
+
removed_dacs = self._dacs
|
|
308
|
+
self._dacs = {}
|
|
309
|
+
self._channel2node = {}
|
|
310
|
+
if removed_dacs and self._callbacks.dac_config_change_callback is not None:
|
|
311
|
+
self._callbacks.dac_config_change_callback(
|
|
312
|
+
{
|
|
313
|
+
dac_name: DACConfigChange(
|
|
314
|
+
old_config=dac.config,
|
|
315
|
+
new_config=None,
|
|
316
|
+
)
|
|
317
|
+
for dac_name, dac in removed_dacs.items()
|
|
318
|
+
}
|
|
319
|
+
)
|
|
@@ -2,21 +2,28 @@ import importlib.metadata
|
|
|
2
2
|
import logging
|
|
3
3
|
|
|
4
4
|
import dotenv
|
|
5
|
+
from textual import on
|
|
5
6
|
from textual.app import App, ComposeResult
|
|
6
7
|
from textual.binding import Binding
|
|
7
8
|
from textual.logging import TextualHandler
|
|
9
|
+
from textual.widgets import Button
|
|
10
|
+
from textual.widgets import DataTable
|
|
8
11
|
from textual.widgets import Header, Footer
|
|
12
|
+
from textual.widgets import Input
|
|
9
13
|
from textual.widgets import Select
|
|
10
14
|
|
|
11
15
|
from gwadmin.config import CurrentAdminConfig
|
|
12
|
-
from gwadmin.
|
|
16
|
+
from gwadmin.config import MAX_ADMIN_TIMEOUT
|
|
13
17
|
from gwadmin.watch.clients.admin_client import AdminClient
|
|
18
|
+
from gwadmin.watch.clients.dac_client import DACWatchClient
|
|
14
19
|
from gwadmin.watch.clients.relay_client import RelayEnergized
|
|
15
20
|
from gwadmin.watch.clients.relay_client import RelayWatchClient
|
|
21
|
+
from gwadmin.watch.widgets.dacs import Dacs
|
|
16
22
|
from gwadmin.watch.widgets.keepalive import KeepAliveButton
|
|
17
23
|
from gwadmin.watch.widgets.keepalive import ReleaseControlButton
|
|
18
24
|
from gwadmin.watch.widgets.relays import Relays
|
|
19
25
|
from gwadmin.watch.widgets.relay_toggle_button import RelayToggleButton
|
|
26
|
+
from gwadmin.watch.widgets.time_input import TimeInput
|
|
20
27
|
from gwadmin.watch.widgets.timer import TimerDigits
|
|
21
28
|
|
|
22
29
|
__version__: str = importlib.metadata.version('gridworks-admin')
|
|
@@ -29,6 +36,7 @@ class RelaysApp(App):
|
|
|
29
36
|
TITLE: str = f"Scada Relay Monitor v{__version__}"
|
|
30
37
|
_admin_client: AdminClient
|
|
31
38
|
_relay_client: RelayWatchClient
|
|
39
|
+
_dac_client: DACWatchClient
|
|
32
40
|
_theme_names: list[str]
|
|
33
41
|
settings: CurrentAdminConfig
|
|
34
42
|
|
|
@@ -56,9 +64,10 @@ class RelaysApp(App):
|
|
|
56
64
|
else:
|
|
57
65
|
paho_logger = None
|
|
58
66
|
self._relay_client = RelayWatchClient(logger=logger)
|
|
67
|
+
self._dac_client = DACWatchClient(logger=logger)
|
|
59
68
|
self._admin_client = AdminClient(
|
|
60
69
|
settings,
|
|
61
|
-
subclients=[self._relay_client],
|
|
70
|
+
subclients=[self._relay_client, self._dac_client],
|
|
62
71
|
logger=logger,
|
|
63
72
|
paho_logger=paho_logger,
|
|
64
73
|
)
|
|
@@ -74,13 +83,21 @@ class RelaysApp(App):
|
|
|
74
83
|
def compose(self) -> ComposeResult:
|
|
75
84
|
yield Header(show_clock=self.settings.config.show_clock)
|
|
76
85
|
relays = Relays(
|
|
77
|
-
|
|
78
|
-
|
|
86
|
+
scadas=[
|
|
87
|
+
scada_name
|
|
88
|
+
for scada_name, scada_config in self.settings.config.scadas.items()
|
|
89
|
+
if scada_config.enabled
|
|
90
|
+
],
|
|
91
|
+
initial_scada=self.settings.curr_scada,
|
|
92
|
+
default_timeout_seconds=self.settings.config.default_timeout_seconds,
|
|
79
93
|
logger=logger,
|
|
80
94
|
id="relays"
|
|
81
95
|
)
|
|
82
|
-
self._relay_client.set_callbacks(relays.relay_client_callbacks())
|
|
83
96
|
yield relays
|
|
97
|
+
self._relay_client.set_callbacks(relays.relay_client_callbacks())
|
|
98
|
+
dacs = Dacs(logger=logger, id="dacs")
|
|
99
|
+
self._dac_client.set_callbacks(dacs.dac_client_callbacks())
|
|
100
|
+
yield dacs
|
|
84
101
|
# Footer disabled by default as defense against memory leaks
|
|
85
102
|
if self.settings.config.show_footer:
|
|
86
103
|
yield Footer()
|
|
@@ -95,6 +112,26 @@ class RelaysApp(App):
|
|
|
95
112
|
message.timeout_seconds
|
|
96
113
|
)
|
|
97
114
|
|
|
115
|
+
@on(Button.Pressed, "#send_dac_button")
|
|
116
|
+
def send_dac_button(self) -> None:
|
|
117
|
+
new_state = self.query_one("#dac_value_input", Input).value
|
|
118
|
+
if new_state is not None:
|
|
119
|
+
new_state = int(new_state)
|
|
120
|
+
dac_table = self.query_one("#dacs_table", DataTable)
|
|
121
|
+
row = dac_table.get_row_at(dac_table.cursor_row)
|
|
122
|
+
time_input_value = self.app.query_one(TimeInput).value
|
|
123
|
+
try:
|
|
124
|
+
time_in_minutes = float(time_input_value) if time_input_value else int(self.settings.config.default_timeout_seconds/60)
|
|
125
|
+
timeout_seconds = int(time_in_minutes * 60)
|
|
126
|
+
except: # noqa
|
|
127
|
+
timeout_seconds = self.settings.config.default_timeout_seconds
|
|
128
|
+
self._dac_client.set_dac(
|
|
129
|
+
dac_row_name=row[0],
|
|
130
|
+
new_state=new_state,
|
|
131
|
+
timeout_seconds=timeout_seconds,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
98
135
|
def action_toggle_dark(self) -> None:
|
|
99
136
|
self.theme = (
|
|
100
137
|
"textual-dark" if "light" in self.theme else "textual-light"
|
|
@@ -14,23 +14,53 @@ Screen {
|
|
|
14
14
|
width: auto;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
#
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
#dacs_table {
|
|
18
|
+
width: 60;
|
|
19
|
+
border: solid $border;
|
|
20
|
+
margin: 0 1;
|
|
21
|
+
}
|
|
21
22
|
|
|
22
|
-
#
|
|
23
|
-
|
|
23
|
+
#dac_control_container {
|
|
24
|
+
width: 60;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#dac_input_vertical {
|
|
28
|
+
width: 20;
|
|
29
|
+
margin: 0 3;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
#dacs_input_label {
|
|
33
|
+
margin: 0 2;
|
|
34
|
+
width: 20;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
#dac_value_input {
|
|
38
|
+
width: 10;
|
|
39
|
+
align: center middle;
|
|
40
|
+
margin: 1 3;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
#send_dac_button {
|
|
44
|
+
width: 30;
|
|
45
|
+
align: center middle;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
#dacs {
|
|
49
|
+
height: 4fr;
|
|
24
50
|
border: solid $border;
|
|
25
51
|
}
|
|
26
52
|
|
|
27
|
-
|
|
28
|
-
height:
|
|
53
|
+
Relays {
|
|
54
|
+
height: 20fr;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
#mqtt_state {
|
|
58
|
+
height: 1fr;
|
|
29
59
|
border: solid $border;
|
|
30
60
|
}
|
|
31
61
|
|
|
32
62
|
#relays_table {
|
|
33
|
-
height:
|
|
63
|
+
height: 3fr;
|
|
34
64
|
border: solid $border;
|
|
35
65
|
}
|
|
36
66
|
|
|
@@ -38,7 +68,7 @@ Screen {
|
|
|
38
68
|
layout: horizontal;
|
|
39
69
|
border: solid $border;
|
|
40
70
|
align: center middle;
|
|
41
|
-
height:
|
|
71
|
+
height: 1fr;
|
|
42
72
|
padding: 1 1;
|
|
43
73
|
}
|
|
44
74
|
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import re
|
|
3
|
+
from functools import cached_property
|
|
4
|
+
from typing import ClassVar
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
from textual.logging import TextualHandler
|
|
9
|
+
|
|
10
|
+
from gwadmin.watch.clients.dac_client import DACConfig
|
|
11
|
+
from gwadmin.watch.clients.dac_client import DACState
|
|
12
|
+
|
|
13
|
+
module_logger = logging.getLogger(__name__)
|
|
14
|
+
module_logger.addHandler(TextualHandler())
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DACTableName(BaseModel):
|
|
18
|
+
channel_name: str = ""
|
|
19
|
+
row_name: str = ""
|
|
20
|
+
|
|
21
|
+
dac_table_name_rgx: ClassVar[re.Pattern] = re.compile(
|
|
22
|
+
r"(?P<channel_part>.*)-010v"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def from_channel_name(cls, channel_name: str) -> "DACTableName":
|
|
27
|
+
dac_match = cls.dac_table_name_rgx.match(channel_name)
|
|
28
|
+
if dac_match is None:
|
|
29
|
+
channel_part = channel_name
|
|
30
|
+
else:
|
|
31
|
+
channel_part = dac_match.group("channel_part")
|
|
32
|
+
return DACTableName(
|
|
33
|
+
channel_name=channel_name,
|
|
34
|
+
row_name=" ".join(
|
|
35
|
+
[
|
|
36
|
+
word.capitalize()
|
|
37
|
+
for word in channel_part.replace("-", " ").split()
|
|
38
|
+
]
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
@cached_property
|
|
43
|
+
def border_title(self) -> str:
|
|
44
|
+
return self.row_name
|
|
45
|
+
|
|
46
|
+
class DACWidgetConfig(DACConfig):
|
|
47
|
+
|
|
48
|
+
@cached_property
|
|
49
|
+
def table_name(self) -> DACTableName:
|
|
50
|
+
return DACTableName.from_channel_name(self.channel_name)
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def from_config(
|
|
54
|
+
cls,
|
|
55
|
+
config: DACConfig,
|
|
56
|
+
) -> "DACWidgetConfig":
|
|
57
|
+
return DACWidgetConfig(
|
|
58
|
+
**config.model_dump()
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def get_state_str(cls, value: Optional[int]) -> str:
|
|
63
|
+
if value is None:
|
|
64
|
+
return "?"
|
|
65
|
+
return f"{value:3d}"
|
|
66
|
+
|
|
67
|
+
def get_current_state_str(self, value: Optional[int]) -> str:
|
|
68
|
+
return self.get_state_str(value)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class DACWidgetInfo(BaseModel):
|
|
72
|
+
config: DACWidgetConfig = DACWidgetConfig()
|
|
73
|
+
observed: Optional[DACState] = None
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def get_observed_state(cls, observed) -> Optional[int]:
|
|
77
|
+
if observed is not None:
|
|
78
|
+
return observed.value
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
def get_state(self) -> Optional[int]:
|
|
82
|
+
return self.get_observed_state(self.observed)
|
|
83
|
+
|
|
84
|
+
def get_state_str(self) -> str:
|
|
85
|
+
return self.config.get_state_str(self.get_state())
|
|
86
|
+
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from logging import Logger
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.containers import Horizontal
|
|
7
|
+
from textual.containers import Vertical
|
|
8
|
+
from textual.logging import TextualHandler
|
|
9
|
+
from textual.messages import Message
|
|
10
|
+
from textual.widget import Widget
|
|
11
|
+
from textual.widgets import Button
|
|
12
|
+
from textual.widgets import DataTable
|
|
13
|
+
from textual.widgets import Input
|
|
14
|
+
from textual.widgets import Static
|
|
15
|
+
from textual.widgets._data_table import CellType # noqa
|
|
16
|
+
|
|
17
|
+
from gwadmin.watch.clients.dac_client import DACClientCallbacks
|
|
18
|
+
from gwadmin.watch.clients.dac_client import DACConfigChange
|
|
19
|
+
from gwadmin.watch.clients.dac_client import ObservedDACStateChange
|
|
20
|
+
from gwadmin.watch.widgets.dac_widget_info import DACWidgetConfig
|
|
21
|
+
from gwadmin.watch.widgets.dac_widget_info import DACWidgetInfo
|
|
22
|
+
from gwsproto.named_types import LayoutLite
|
|
23
|
+
from gwsproto.named_types import SnapshotSpaceheat
|
|
24
|
+
|
|
25
|
+
module_logger = logging.getLogger(__name__)
|
|
26
|
+
module_logger.addHandler(TextualHandler())
|
|
27
|
+
|
|
28
|
+
class Dacs(Widget):
|
|
29
|
+
BINDINGS = [
|
|
30
|
+
("d", "set_dac", "Set selected DAC"),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
logger: Logger
|
|
34
|
+
_dacs: dict[str, DACWidgetInfo]
|
|
35
|
+
|
|
36
|
+
class DacStateChange(Message):
|
|
37
|
+
def __init__(self, changes: dict[str, ObservedDACStateChange]) -> None:
|
|
38
|
+
self.changes = changes
|
|
39
|
+
super().__init__()
|
|
40
|
+
|
|
41
|
+
class ConfigChange(Message):
|
|
42
|
+
def __init__(self, changes: dict[str, DACConfigChange]) -> None:
|
|
43
|
+
self.changes = changes
|
|
44
|
+
super().__init__()
|
|
45
|
+
|
|
46
|
+
class Snapshot(Message):
|
|
47
|
+
def __init__(self, snapshot: SnapshotSpaceheat) -> None:
|
|
48
|
+
self.snapshot = snapshot
|
|
49
|
+
super().__init__()
|
|
50
|
+
|
|
51
|
+
class Layout(Message):
|
|
52
|
+
def __init__(self, layout: LayoutLite) -> None:
|
|
53
|
+
self.layout = layout
|
|
54
|
+
super().__init__()
|
|
55
|
+
|
|
56
|
+
def __init__(self, logger: Optional[Logger] = None, **kwargs) -> None:
|
|
57
|
+
self.logger = logger or module_logger
|
|
58
|
+
self._dacs = {}
|
|
59
|
+
super().__init__(**kwargs)
|
|
60
|
+
|
|
61
|
+
def compose(self) -> ComposeResult:
|
|
62
|
+
with Horizontal(id="dacs_horizontal"):
|
|
63
|
+
yield DataTable(
|
|
64
|
+
id="dacs_table",
|
|
65
|
+
zebra_stripes=True,
|
|
66
|
+
cursor_type="row",
|
|
67
|
+
)
|
|
68
|
+
with Horizontal(id="dac_control_container"):
|
|
69
|
+
with Vertical(id="dac_input_vertical"):
|
|
70
|
+
yield Static("Value for DAC: ", id="dacs_input_label")
|
|
71
|
+
yield Input(
|
|
72
|
+
type="integer",
|
|
73
|
+
id="dac_value_input",
|
|
74
|
+
)
|
|
75
|
+
yield Button(
|
|
76
|
+
id="send_dac_button",
|
|
77
|
+
label="Send Value to DAC",
|
|
78
|
+
variant="primary",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def on_mount(self) -> None:
|
|
82
|
+
data_table = self.query_one("#dacs_table", DataTable)
|
|
83
|
+
for column_name, width in [
|
|
84
|
+
("Name", 25),
|
|
85
|
+
("Current value", 25),
|
|
86
|
+
]:
|
|
87
|
+
data_table.add_column(column_name, key=column_name, width=width)
|
|
88
|
+
|
|
89
|
+
def _get_dac_row_data(self, dac_name: str) -> dict[str, CellType]:
|
|
90
|
+
if dac_name in self._dacs:
|
|
91
|
+
dac = self._dacs[dac_name]
|
|
92
|
+
return {
|
|
93
|
+
"Name": dac.config.table_name.row_name,
|
|
94
|
+
"Current value": dac.config.get_current_state_str(dac.get_state()),
|
|
95
|
+
}
|
|
96
|
+
return {}
|
|
97
|
+
|
|
98
|
+
def on_dacs_dac_state_change(self, message: DacStateChange) -> None:
|
|
99
|
+
for dac_name, change in message.changes.items():
|
|
100
|
+
dac_info = self._dacs.get(dac_name, None)
|
|
101
|
+
if dac_info is not None:
|
|
102
|
+
new_state = DACWidgetInfo.get_observed_state(change.new_state)
|
|
103
|
+
if new_state != dac_info.get_state():
|
|
104
|
+
dac_info.observed = change.new_state
|
|
105
|
+
self._update_dac_row(dac_name)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _get_dac_row(self, dac_name: str) -> list[str | CellType]:
|
|
109
|
+
return list(self._get_dac_row_data(dac_name).values())
|
|
110
|
+
|
|
111
|
+
def _update_dac_row(self, dac_name: str) -> None:
|
|
112
|
+
table = self.query_one("#dacs_table", DataTable)
|
|
113
|
+
data = self._get_dac_row_data(dac_name)
|
|
114
|
+
for column_name, value in data.items():
|
|
115
|
+
table.update_cell(
|
|
116
|
+
dac_name,
|
|
117
|
+
column_name,
|
|
118
|
+
value,
|
|
119
|
+
update_width=column_name=="State",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def on_dacs_config_change(self, message: ConfigChange) -> None:
|
|
123
|
+
message.prevent_default()
|
|
124
|
+
table = self.query_one("#dacs_table", DataTable)
|
|
125
|
+
for dac_name, change in message.changes.items():
|
|
126
|
+
dac_info = self._dacs.get(dac_name, None)
|
|
127
|
+
if dac_info is not None:
|
|
128
|
+
if change.new_config is None:
|
|
129
|
+
self._dacs.pop(dac_name)
|
|
130
|
+
table.remove_row(dac_name)
|
|
131
|
+
else:
|
|
132
|
+
new_config = DACWidgetConfig.from_config(change.new_config)
|
|
133
|
+
if new_config != dac_info.config:
|
|
134
|
+
dac_info.config = new_config
|
|
135
|
+
self._update_dac_row(dac_name)
|
|
136
|
+
else:
|
|
137
|
+
if change.new_config is not None:
|
|
138
|
+
self._dacs[dac_name] = DACWidgetInfo(
|
|
139
|
+
config=DACWidgetConfig.from_config(change.new_config)
|
|
140
|
+
)
|
|
141
|
+
table.add_row(
|
|
142
|
+
*self._get_dac_row(dac_name),
|
|
143
|
+
key=dac_name
|
|
144
|
+
)
|
|
145
|
+
table.sort("Name")
|
|
146
|
+
|
|
147
|
+
def on_data_table_row_highlighted(self, message: DataTable.RowHighlighted) -> None:
|
|
148
|
+
self._update_dac_row(message.row_key.value)
|
|
149
|
+
|
|
150
|
+
def dac_client_callbacks(self) -> DACClientCallbacks:
|
|
151
|
+
return DACClientCallbacks(
|
|
152
|
+
mqtt_state_change_callback=None,
|
|
153
|
+
dac_state_change_callback=self.dac_state_change_callback,
|
|
154
|
+
dac_config_change_callback=self.dac_config_change_callback,
|
|
155
|
+
# disable these as defense against memroy leaks
|
|
156
|
+
mqtt_message_received_callback=None,
|
|
157
|
+
layout_callback=None,
|
|
158
|
+
snapshot_callback=None,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def dac_state_change_callback(self, changes: dict[str, ObservedDACStateChange]) -> None:
|
|
162
|
+
self.post_message(Dacs.DacStateChange(changes))
|
|
163
|
+
|
|
164
|
+
def dac_config_change_callback(self, changes: dict[str, DACConfigChange]) -> None:
|
|
165
|
+
self.post_message(Dacs.ConfigChange(changes))
|
|
166
|
+
|
{gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/watch/widgets/keepalive.py
RENAMED
|
@@ -3,10 +3,10 @@ from textual.logging import TextualHandler
|
|
|
3
3
|
from textual.message import Message
|
|
4
4
|
from textual.widgets import Button
|
|
5
5
|
|
|
6
|
-
from gwadmin.
|
|
6
|
+
from gwadmin.config import DEFAULT_ADMIN_TIMEOUT
|
|
7
|
+
from gwadmin.config import MAX_ADMIN_TIMEOUT
|
|
7
8
|
from gwadmin.watch.widgets.timer import TimerDigits
|
|
8
9
|
from gwadmin.watch.widgets.time_input import TimeInput
|
|
9
|
-
from gwadmin.settings import AdminClientSettings
|
|
10
10
|
|
|
11
11
|
module_logger = logging.getLogger(__name__)
|
|
12
12
|
module_logger.addHandler(TextualHandler())
|
|
@@ -14,6 +14,7 @@ module_logger.addHandler(TextualHandler())
|
|
|
14
14
|
class KeepAliveButton(Button):
|
|
15
15
|
def __init__(
|
|
16
16
|
self,
|
|
17
|
+
default_timeout_seconds: int = DEFAULT_ADMIN_TIMEOUT,
|
|
17
18
|
logger: logging.Logger = module_logger,
|
|
18
19
|
**kwargs
|
|
19
20
|
) -> None:
|
|
@@ -24,7 +25,7 @@ class KeepAliveButton(Button):
|
|
|
24
25
|
**kwargs
|
|
25
26
|
)
|
|
26
27
|
self.logger = logger
|
|
27
|
-
self.default_timeout_seconds =
|
|
28
|
+
self.default_timeout_seconds = default_timeout_seconds
|
|
28
29
|
self.timeout_seconds = self.default_timeout_seconds
|
|
29
30
|
|
|
30
31
|
class Pressed(Message):
|
|
@@ -51,6 +52,7 @@ class KeepAliveButton(Button):
|
|
|
51
52
|
class ReleaseControlButton(Button):
|
|
52
53
|
def __init__(
|
|
53
54
|
self,
|
|
55
|
+
default_timeout_seconds: int = DEFAULT_ADMIN_TIMEOUT,
|
|
54
56
|
logger: logging.Logger = module_logger,
|
|
55
57
|
**kwargs
|
|
56
58
|
) -> None:
|
|
@@ -61,7 +63,7 @@ class ReleaseControlButton(Button):
|
|
|
61
63
|
**kwargs
|
|
62
64
|
)
|
|
63
65
|
self.logger = logger
|
|
64
|
-
self.default_timeout_seconds =
|
|
66
|
+
self.default_timeout_seconds = default_timeout_seconds
|
|
65
67
|
self.timeout_seconds = self.default_timeout_seconds
|
|
66
68
|
|
|
67
69
|
class Pressed(Message):
|
|
@@ -8,11 +8,11 @@ from textual.reactive import Reactive
|
|
|
8
8
|
from textual.reactive import reactive
|
|
9
9
|
from textual.widgets import Button
|
|
10
10
|
|
|
11
|
+
from gwadmin.config import DEFAULT_ADMIN_TIMEOUT
|
|
11
12
|
from gwadmin.watch.widgets.relay_widget_info import RelayWidgetConfig
|
|
12
13
|
from gwadmin.watch.widgets.timer import TimerDigits
|
|
13
14
|
from gwadmin.watch.widgets.time_input import TimeInput
|
|
14
15
|
from gwadmin.watch.widgets.keepalive import KeepAliveButton
|
|
15
|
-
from gwadmin.settings import AdminClientSettings
|
|
16
16
|
|
|
17
17
|
module_logger = logging.getLogger(__name__)
|
|
18
18
|
module_logger.addHandler(TextualHandler())
|
|
@@ -25,11 +25,13 @@ class RelayToggleButton(Button, can_focus=True):
|
|
|
25
25
|
|
|
26
26
|
energized: Reactive[Optional[bool]] = reactive(None)
|
|
27
27
|
config: Reactive[RelayWidgetConfig] = reactive(RelayWidgetConfig)
|
|
28
|
+
timeout_seconds: int
|
|
28
29
|
|
|
29
30
|
def __init__(
|
|
30
31
|
self,
|
|
31
32
|
energized: Optional[bool] = None,
|
|
32
33
|
config: Optional[RelayWidgetConfig] = None,
|
|
34
|
+
default_timeout_seconds: int = DEFAULT_ADMIN_TIMEOUT,
|
|
33
35
|
logger: logging.Logger = module_logger,
|
|
34
36
|
**kwargs
|
|
35
37
|
) -> None:
|
|
@@ -38,7 +40,7 @@ class RelayToggleButton(Button, can_focus=True):
|
|
|
38
40
|
variant=self.variant_from_state(energized),
|
|
39
41
|
**kwargs
|
|
40
42
|
)
|
|
41
|
-
self.default_timeout_seconds =
|
|
43
|
+
self.default_timeout_seconds = default_timeout_seconds
|
|
42
44
|
self.set_reactive(RelayToggleButton.energized, energized)
|
|
43
45
|
self.set_reactive(RelayToggleButton.config, config or RelayWidgetConfig())
|
|
44
46
|
self.update_title()
|
|
@@ -62,7 +64,7 @@ class RelayToggleButton(Button, can_focus=True):
|
|
|
62
64
|
try:
|
|
63
65
|
time_in_minutes = float(input_value) if input_value else int(self.default_timeout_seconds/60)
|
|
64
66
|
self.timeout_seconds = int(time_in_minutes * 60)
|
|
65
|
-
except:
|
|
67
|
+
except: # noqa
|
|
66
68
|
self.timeout_seconds = self.default_timeout_seconds
|
|
67
69
|
if self.energized is not None:
|
|
68
70
|
self.post_message(
|
|
@@ -95,7 +97,7 @@ class RelayToggleButton(Button, can_focus=True):
|
|
|
95
97
|
try:
|
|
96
98
|
time_in_minutes = float(input_value) if input_value else int(self.default_timeout_seconds/60)
|
|
97
99
|
self.timeout_seconds = int(time_in_minutes * 60)
|
|
98
|
-
except:
|
|
100
|
+
except: # noqa
|
|
99
101
|
self.timeout_seconds = self.default_timeout_seconds
|
|
100
102
|
if self.energized is not None:
|
|
101
103
|
self.post_message(
|
{gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/watch/widgets/relays.py
RENAMED
|
@@ -17,6 +17,7 @@ from textual.widgets import Select
|
|
|
17
17
|
from textual.widgets import Static
|
|
18
18
|
from textual.widgets._data_table import CellType # noqa
|
|
19
19
|
|
|
20
|
+
from gwadmin.config import DEFAULT_ADMIN_TIMEOUT
|
|
20
21
|
from gwadmin.watch.clients.constrained_mqtt_client import ConstrainedMQTTClient
|
|
21
22
|
from gwadmin.watch.clients.relay_client import ObservedRelayStateChange
|
|
22
23
|
from gwadmin.watch.clients.relay_client import RelayClientCallbacks
|
|
@@ -47,6 +48,9 @@ class Relays(Widget):
|
|
|
47
48
|
curr_config: Reactive[RelayWidgetConfig] = reactive(RelayWidgetConfig)
|
|
48
49
|
logger: Logger
|
|
49
50
|
_relays: dict[str, RelayWidgetInfo]
|
|
51
|
+
_scadas: list[str]
|
|
52
|
+
_initial_scada: str
|
|
53
|
+
_default_timeout_seconds: int = DEFAULT_ADMIN_TIMEOUT
|
|
50
54
|
|
|
51
55
|
class RelayStateChange(Message):
|
|
52
56
|
def __init__(self, changes: dict[str, ObservedRelayStateChange]) -> None:
|
|
@@ -68,9 +72,17 @@ class Relays(Widget):
|
|
|
68
72
|
self.layout = layout
|
|
69
73
|
super().__init__()
|
|
70
74
|
|
|
71
|
-
def __init__(
|
|
72
|
-
self
|
|
73
|
-
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
scadas: list[str],
|
|
78
|
+
initial_scada: str,
|
|
79
|
+
default_timeout_seconds: int = DEFAULT_ADMIN_TIMEOUT,
|
|
80
|
+
logger: Optional[Logger] = None,
|
|
81
|
+
**kwargs
|
|
82
|
+
) -> None:
|
|
83
|
+
self._scadas = scadas
|
|
84
|
+
self._initial_scada = initial_scada
|
|
85
|
+
self._default_timeout_seconds = default_timeout_seconds
|
|
74
86
|
self.logger = logger or module_logger
|
|
75
87
|
self._relays = {}
|
|
76
88
|
super().__init__(**kwargs)
|
|
@@ -81,18 +93,18 @@ class Relays(Widget):
|
|
|
81
93
|
yield Horizontal(
|
|
82
94
|
Static("Select Scada: ", classes="label"),
|
|
83
95
|
Select(
|
|
84
|
-
((scada, scada) for scada in self.
|
|
96
|
+
((scada, scada) for scada in self._scadas),
|
|
85
97
|
prompt="Scada",
|
|
86
|
-
value=self.
|
|
98
|
+
value=self._initial_scada,
|
|
87
99
|
id="select_scada",
|
|
88
100
|
),
|
|
89
101
|
classes="container",
|
|
90
102
|
)
|
|
91
103
|
with HorizontalGroup():
|
|
92
|
-
yield KeepAliveButton()
|
|
93
|
-
yield ReleaseControlButton()
|
|
94
|
-
yield TimeInput()
|
|
95
|
-
yield TimerDigits()
|
|
104
|
+
yield KeepAliveButton(default_timeout_seconds=self._default_timeout_seconds)
|
|
105
|
+
yield ReleaseControlButton(default_timeout_seconds=self._default_timeout_seconds)
|
|
106
|
+
yield TimeInput(default_timeout_seconds=self._default_timeout_seconds)
|
|
107
|
+
yield TimerDigits(default_timeout_seconds=self._default_timeout_seconds)
|
|
96
108
|
yield DataTable(
|
|
97
109
|
id="relays_table",
|
|
98
110
|
zebra_stripes=True,
|
|
@@ -108,10 +120,10 @@ class Relays(Widget):
|
|
|
108
120
|
energized=Relays.curr_energized,
|
|
109
121
|
config=Relays.curr_config,
|
|
110
122
|
)
|
|
111
|
-
yield DataTable(
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
)
|
|
123
|
+
# yield DataTable(
|
|
124
|
+
# id="message_table",
|
|
125
|
+
# classes="undisplayed",
|
|
126
|
+
# )
|
|
115
127
|
|
|
116
128
|
def on_mount(self) -> None:
|
|
117
129
|
data_table = self.query_one("#relays_table", DataTable)
|
|
@@ -196,6 +208,7 @@ class Relays(Widget):
|
|
|
196
208
|
"Name",
|
|
197
209
|
key=lambda row: (row[0], row[1]) if row[0] is not None else (sys.maxsize, row[1]),
|
|
198
210
|
)
|
|
211
|
+
self.logger.info("--on_relays_config_change")
|
|
199
212
|
|
|
200
213
|
def _update_buttons(self, relay_name: str) -> None:
|
|
201
214
|
relay_info = self._relays[relay_name]
|
{gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/watch/widgets/time_input.py
RENAMED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
from textual.widgets import Input
|
|
2
2
|
from textual.validation import Number
|
|
3
|
-
|
|
3
|
+
|
|
4
|
+
from gwadmin.config import DEFAULT_ADMIN_TIMEOUT
|
|
5
|
+
|
|
4
6
|
|
|
5
7
|
class TimeInput(Input):
|
|
6
|
-
def __init__(self, **kwargs):
|
|
7
|
-
default_value = int(
|
|
8
|
+
def __init__(self, default_timeout_seconds: int = DEFAULT_ADMIN_TIMEOUT, **kwargs):
|
|
9
|
+
default_value = int(default_timeout_seconds/60)
|
|
8
10
|
super().__init__(
|
|
9
11
|
placeholder=f"Timeout minutes (default {default_value})",
|
|
10
12
|
id="time_input",
|
{gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/watch/widgets/timer.py
RENAMED
|
@@ -1,29 +1,33 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
from time import monotonic
|
|
3
3
|
from textual.logging import TextualHandler
|
|
4
|
+
from textual.timer import Timer
|
|
4
5
|
from textual.widgets import Digits
|
|
5
6
|
from textual.reactive import reactive
|
|
6
7
|
|
|
7
|
-
from gwadmin.
|
|
8
|
+
from gwadmin.config import DEFAULT_ADMIN_TIMEOUT
|
|
9
|
+
from gwadmin.config import MAX_ADMIN_TIMEOUT
|
|
8
10
|
from gwadmin.watch.widgets.time_input import TimeInput
|
|
9
|
-
from gwadmin.settings import AdminClientSettings
|
|
10
11
|
|
|
11
12
|
module_logger = logging.getLogger(__name__)
|
|
12
13
|
module_logger.addHandler(TextualHandler())
|
|
13
14
|
|
|
14
15
|
class TimerDigits(Digits):
|
|
16
|
+
update_timer: Timer
|
|
17
|
+
start_time = reactive(monotonic)
|
|
18
|
+
time_remaining = reactive(DEFAULT_ADMIN_TIMEOUT)
|
|
19
|
+
|
|
15
20
|
def __init__(
|
|
16
21
|
self,
|
|
22
|
+
default_timeout_seconds: int = DEFAULT_ADMIN_TIMEOUT,
|
|
17
23
|
logger: logging.Logger = module_logger,
|
|
18
24
|
**kwargs
|
|
19
25
|
) -> None:
|
|
20
26
|
super().__init__(**kwargs)
|
|
21
27
|
self.logger = logger
|
|
22
|
-
self.default_timeout_seconds =
|
|
28
|
+
self.default_timeout_seconds = default_timeout_seconds
|
|
23
29
|
self.countdown_seconds = self.default_timeout_seconds
|
|
24
30
|
|
|
25
|
-
start_time = reactive(monotonic)
|
|
26
|
-
time_remaining = reactive(AdminClientSettings().default_timeout_seconds)
|
|
27
31
|
|
|
28
32
|
def on_mount(self) -> None:
|
|
29
33
|
self.update_timer = self.set_interval(1 / 60, self.update_time, pause=True)
|
|
@@ -1,31 +0,0 @@
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/watch/clients/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/watch/clients/relay_client.py
RENAMED
|
File without changes
|
{gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/watch/widgets/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|