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.
Files changed (26) hide show
  1. {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/PKG-INFO +2 -2
  2. {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/pyproject.toml +2 -2
  3. {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/cli.py +2 -4
  4. {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/config.py +10 -5
  5. {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/watch/clients/admin_client.py +10 -1
  6. gridworks_admin-1.0.0.dev7/src/gwadmin/watch/clients/dac_client.py +319 -0
  7. {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/watch/relay_app.py +42 -5
  8. {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/watch/relay_app.tcss +40 -10
  9. gridworks_admin-1.0.0.dev7/src/gwadmin/watch/widgets/dac_widget_info.py +86 -0
  10. gridworks_admin-1.0.0.dev7/src/gwadmin/watch/widgets/dacs.py +166 -0
  11. {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/watch/widgets/keepalive.py +6 -4
  12. {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/watch/widgets/relay_toggle_button.py +6 -4
  13. {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/watch/widgets/relays.py +26 -13
  14. {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/watch/widgets/time_input.py +5 -3
  15. {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/watch/widgets/timer.py +9 -5
  16. gridworks_admin-1.0.0.dev5/src/gwadmin/settings.py +0 -31
  17. {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/README.md +0 -0
  18. {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/__init__.py +0 -0
  19. {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/watch/__init__.py +0 -0
  20. {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/watch/clients/__init__.py +0 -0
  21. {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/watch/clients/constrained_mqtt_client.py +0 -0
  22. {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/watch/clients/relay_client.py +0 -0
  23. {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/watch/widgets/__init__.py +0 -0
  24. {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/watch/widgets/mqtt.py +0 -0
  25. {gridworks_admin-1.0.0.dev5 → gridworks_admin-1.0.0.dev7}/src/gwadmin/watch/widgets/relay_state_text.py +0 -0
  26. {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.dev5
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.dev5"
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 LessSecretMQTTClient
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=LessSecretMQTTClient(
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
- class LessSecretMQTTClient(MQTTClient):
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
- mqtt: LessSecretMQTTClient = LessSecretMQTTClient()
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 = int(5*60)
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: LessSecretMQTTClient) -> Optional[ScadaConfig]:
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 ""
@@ -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.settings import MAX_ADMIN_TIMEOUT
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
- list(self.settings.config.scadas.keys()),
78
- self.settings.curr_scada,
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
- # #select_scada {
18
- # height: 1.25fr;
19
- # border: solid $border;
20
- # }
17
+ #dacs_table {
18
+ width: 60;
19
+ border: solid $border;
20
+ margin: 0 1;
21
+ }
21
22
 
22
- #mqtt_state {
23
- height: 1fr;
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
- #message_table {
28
- height: 3fr;
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: 6fr;
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: 2fr;
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
+
@@ -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.settings import MAX_ADMIN_TIMEOUT
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 = AdminClientSettings().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 = AdminClientSettings().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 = AdminClientSettings().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(
@@ -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__(self, scadas: list[str], initial_scada: str, logger: Optional[Logger] = None, **kwargs) -> None:
72
- self.scadas = scadas
73
- self.initial_scada = initial_scada
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.scadas),
96
+ ((scada, scada) for scada in self._scadas),
85
97
  prompt="Scada",
86
- value=self.initial_scada,
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
- id="message_table",
113
- classes="undisplayed",
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]
@@ -1,10 +1,12 @@
1
1
  from textual.widgets import Input
2
2
  from textual.validation import Number
3
- from gwadmin.settings import AdminClientSettings
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(AdminClientSettings().default_timeout_seconds/60)
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",
@@ -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.settings import MAX_ADMIN_TIMEOUT
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 = MAX_ADMIN_TIMEOUT
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