cgse-common 0.16.13__py3-none-any.whl → 0.17.0__py3-none-any.whl

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.
cgse_common/cgse.py CHANGED
@@ -72,7 +72,7 @@ class SortedCommandGroup(TyperGroup):
72
72
  commands = super().list_commands(ctx)
73
73
 
74
74
  # Define priority commands in specific order
75
- priority_commands = ["init", "version", "show", "top", "core", "reg", "not", "log", "cm", "sm", "pm"]
75
+ priority_commands = ["init", "version", "show", "top", "core", "reg", "log", "nh", "cm", "sm", "pm"]
76
76
 
77
77
  # Custom sort:
78
78
  # First the priority commands in the given order (their index)
@@ -175,6 +175,10 @@ def main(ctx: typer.Context, verbose: bool = False):
175
175
  - inspect, configure, monitor the core services and device control servers.
176
176
  """
177
177
 
178
+ from egse.env import setup_env
179
+
180
+ setup_env()
181
+
178
182
  # This is more of a show-case for using application wide optional arguments and how to pass
179
183
  # them into (sub-)commands.
180
184
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cgse-common
3
- Version: 0.16.13
3
+ Version: 0.17.0
4
4
  Summary: Software framework to support hardware testing
5
5
  Author: IvS KU Leuven
6
6
  Maintainer-email: Rik Huygen <rik.huygen@kuleuven.be>, Sara Regibo <sara.regibo@kuleuven.be>
@@ -1,18 +1,18 @@
1
1
  cgse_common/__init__.py,sha256=wTYOpVomEeDFFuqt4Ss9ROSAIa48UUnYCSafdEOx-CU,129
2
- cgse_common/cgse.py,sha256=4ZCm5UfaXld0RGI-Mjnm6dSAqfWCPX1qz1pew46J-1s,6590
2
+ cgse_common/cgse.py,sha256=S8MxwJcSzqYNR0wirEscQw5CTg89AKORa8-NHRdrxJM,6642
3
3
  cgse_common/settings.yaml,sha256=PS8HOoxbhwVoQ7zzDtZyhx25RZB6SGq3uXNuJTgtRIw,443
4
4
  egse/bits.py,sha256=cg6diLN2IwNtnrlZyLgXPfDf9ttcHzS2A7Tjwlwc-lQ,12498
5
5
  egse/calibration.py,sha256=a5JDaXTC6fMwQ1M-qrwNO31Ass-yYSXxDQUK_PPsZg4,8818
6
6
  egse/config.py,sha256=qNW3uvwuEV9VSjiaoQfM_svBYfz4Kwxd1-jv1eTGqyw,9530
7
7
  egse/counter.py,sha256=7UwBeTAu213xdNdGAOYpUWNQ4jD4yVM1bOG10Ax4UFs,5097
8
- egse/decorators.py,sha256=B-zRa1WdLO71zqS5M27JBglcThYPho7seYfa4HOGj5c,27171
9
- egse/device.py,sha256=nn2HkN1KIHAmo37WZcqig-p2mQz1LgqpIfj1wPrUTLc,13240
8
+ egse/decorators.py,sha256=RT_-TmcHLN3oqha-1xn-YbqT1tEtKA84zr0Qu1KFyKo,27176
9
+ egse/device.py,sha256=Ga5MNE96ERuPLDzmb0HF7UfjrTqfp0f_r3NDVZY9l28,13595
10
10
  egse/dicts.py,sha256=dUAq7PTPvs73OrZb2Fh3loxvYv4ifUiK6bBcgrFU77Y,3972
11
- egse/env.py,sha256=0z57ESbD9qtJSQiK_7CcpEZm5sxwrKkOMQaJGANmTho,29090
11
+ egse/env.py,sha256=iJV4gKCd1wFWDMsJbdjtgZJSE4Gr8hBSD4YLTN8xKfM,31630
12
12
  egse/exceptions.py,sha256=QB3MZRJizecWOj1cPbvG0UcIqFn7NRJ6rw1xtdNSFxw,1225
13
- egse/heartbeat.py,sha256=xt5mePu9Zr9fLAhN1MLq1Z7aCOKtNIhRVCAmWhtNwP8,3039
13
+ egse/heartbeat.py,sha256=2SeZzX3tWFog1rgYThX-iaZPwHYq8TVma2ll7r624Eg,3039
14
14
  egse/hk.py,sha256=AumSpB8SYXes75CB2iiKXfLkMK5IkVDHITFKrf8IT6g,32010
15
- egse/log.py,sha256=EJSlv_hueSnN1acy12Js_ywnARG_b49j97keiQnF3MY,5021
15
+ egse/log.py,sha256=Q_TviwuyMam6CwRklaJk1lZM5WIZK_L9GS7wWDy_naM,5251
16
16
  egse/metrics.py,sha256=2hHtJXG0Rn782l2bfmLNBbw6ucC5nf7jPnNzqbhP_Zs,7012
17
17
  egse/observer.py,sha256=xQ7F7NVHqdRZ6IIsBM5M0kMuullMghoR98dwAsjgh0s,1287
18
18
  egse/obsid.py,sha256=y87AYX5mtNEBqEtpEFEec2MhEmo1Hej3Wwi5od84wR8,5848
@@ -25,21 +25,21 @@ egse/ratelimit.py,sha256=JdJxD6UIi9LYngKEsG9zh8bTE9r_56D4EZCnp_fkrI0,9161
25
25
  egse/reload.py,sha256=PzOE0m1tmcNcQPVFH8orMe_cMoQIIiH9Gw2anpQTC40,4717
26
26
  egse/resource.py,sha256=kzNI6kJOE6Jd5QKJs2MkVAycUpwpOTLi1qydh3NSRng,15345
27
27
  egse/response.py,sha256=F04uqOYv1ClpHgDLYZlKTuOCSldHs5TezI_4x6zf2Fw,2717
28
- egse/scpi.py,sha256=WJ73EaLgRUV6ah1V41l0L7AXI-Dc6Jct7hPHlbbCIcg,15461
29
- egse/settings.py,sha256=YrRsMUn_IpOVnhTqUGREQUjMw8-AQ6aUBulQiij9MwY,15486
28
+ egse/scpi.py,sha256=geIHxoolItjPO1LUFVNzL1c9QxpmxcyVwEV9BIi2ftI,15722
29
+ egse/settings.py,sha256=s89xqvS31MOBq7p1dTOj41ODnZnBNB35nHqfknJpqcc,15466
30
30
  egse/settings.yaml,sha256=mz9O2QqmiptezsMvxJRLhnC1ROwIHENX0nbnhMaXUpE,190
31
- egse/setup.py,sha256=iWFe3zx4bIkzaUabpbxedteG8qEc3JYHgpu2HjU7s4E,33973
31
+ egse/setup.py,sha256=ezPYA3n1P3navdPR3qDxh0qJvZCzGl2bIREEP9n2w3Y,34116
32
32
  egse/signal.py,sha256=f5pyOiNW9iTSIxV_ce5stIfG0ub9MRbaekE85kQOVzs,7992
33
- egse/socketdevice.py,sha256=R8XwYHTH3lFhFngfsGbi_L7bTnTLHxMTEKIF7gmm5rc,7465
33
+ egse/socketdevice.py,sha256=rsLJRm4AQhfO3VcM16Q9qwtPbKI-iynVlZqKZ3tc3Fk,14636
34
34
  egse/state.py,sha256=HdU2MFOlYRbawYRZmizV6Y8MgnZrUF0bx4fXaYU-M_s,3023
35
35
  egse/system.py,sha256=3aycoK15UvNdk2yxQ8hx4N-s2Ag4BqTTPQ8eFuJa3Ko,76111
36
36
  egse/task.py,sha256=ODSLE05f31CgWsSVcVFFq1WYUZrJMb1LioPTx6VM824,2804
37
37
  egse/version.py,sha256=e9GvelUZ9mfCDlRju4MWEJeMHJW9kUzK6SKzJpyj91s,6156
38
38
  egse/zmq_ser.py,sha256=YJFupsxuvhI8TJMeS2Hem9oMMcVmSBx0rZv93gvN-hA,3263
39
39
  egse/plugins/metrics/duckdb.py,sha256=E2eeNo3I7ajRuByodaYiPNvC0Zwyc7hsIlhr1W_eXdo,16148
40
- egse/plugins/metrics/influxdb.py,sha256=ecxjA_csYwf8RW3sXjiQxZHREfyrfStH1HA_rAs1AA8,6690
40
+ egse/plugins/metrics/influxdb.py,sha256=WnAqTWRkAyMSd7W2ASwUAIEwFborrv55iX-umceevFA,8162
41
41
  egse/plugins/metrics/timescaledb.py,sha256=Ug0NWDV1Ky2VeFY6tDZL9xg6AFgnAEh2F_llVPnlRBA,21191
42
- cgse_common-0.16.13.dist-info/METADATA,sha256=FEUo16l14WCirVck9cdGRQb3EqHlPJYDkoMjInflkNA,3069
43
- cgse_common-0.16.13.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
44
- cgse_common-0.16.13.dist-info/entry_points.txt,sha256=erQovXd1bGzsngB0_sfY7IYRNwHIhwq3K8fmQvGS12o,198
45
- cgse_common-0.16.13.dist-info/RECORD,,
42
+ cgse_common-0.17.0.dist-info/METADATA,sha256=YIF0aWp-_8lG-Js0EVRZHUqVVVqG5uqspV2Nfsgwsew,3068
43
+ cgse_common-0.17.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
44
+ cgse_common-0.17.0.dist-info/entry_points.txt,sha256=xJsPRIDjtADVgd_oEDHVW10wS5LG30Ox_3brVKeyCGw,168
45
+ cgse_common-0.17.0.dist-info/RECORD,,
@@ -1,6 +1,5 @@
1
1
  [console_scripts]
2
2
  cgse = cgse_common.cgse:app
3
- monitor = egse.monitoring:app
4
3
 
5
4
  [cgse.settings]
6
5
  cgse-common = cgse_common:settings.yaml
egse/decorators.py CHANGED
@@ -15,7 +15,6 @@ from typing import Optional
15
15
 
16
16
  import rich
17
17
 
18
- from egse.settings import Settings
19
18
  from egse.system import get_caller_info
20
19
  from egse.log import logger
21
20
 
@@ -390,6 +389,8 @@ def profile(func):
390
389
  if not hasattr(profile, "counter"):
391
390
  profile.counter = 0
392
391
 
392
+ from egse.settings import Settings
393
+
393
394
  @functools.wraps(func)
394
395
  def wrapper_profile(*args, **kwargs):
395
396
  if Settings.profiling():
egse/device.py CHANGED
@@ -199,7 +199,7 @@ class DeviceConnectionInterface(DeviceConnectionObservable):
199
199
 
200
200
 
201
201
  class DeviceInterface(DeviceConnectionInterface):
202
- """Generic interface for all device classes."""
202
+ """A generic interface for all device classes."""
203
203
 
204
204
  @dynamic_interface
205
205
  def is_simulator(self) -> bool:
@@ -237,6 +237,7 @@ class DeviceTransport:
237
237
  raise NotImplementedError
238
238
 
239
239
  def read_string(self, encoding="utf-8") -> str:
240
+ """Reads a bytes object from the instrument and returns it converted into a stripped UTF-8 string."""
240
241
  return self.read().decode(encoding).strip()
241
242
 
242
243
  def trans(self, command: str) -> bytes:
@@ -297,6 +298,11 @@ class AsyncDeviceTransport:
297
298
 
298
299
  raise NotImplementedError
299
300
 
301
+ async def read_string(self, encoding="utf-8") -> str:
302
+ """Reads a bytes object from the instrument and returns it converted into a stripped UTF-8 string."""
303
+ b = await self.read()
304
+ return b.decode(encoding).strip()
305
+
300
306
  async def trans(self, command: str) -> bytes:
301
307
  """
302
308
  Send a single command to the device controller and block until a response from the
@@ -389,7 +395,7 @@ class AsyncDeviceConnectionInterface(DeviceConnectionObservable):
389
395
 
390
396
 
391
397
  class AsyncDeviceInterface(AsyncDeviceConnectionInterface):
392
- """Generic interface for all device classes."""
398
+ """A generic interface for all device classes."""
393
399
 
394
400
  def is_simulator(self) -> bool:
395
401
  """Checks whether the device is a simulator rather than a real hardware controller.
egse/env.py CHANGED
@@ -43,6 +43,10 @@ from __future__ import annotations
43
43
 
44
44
  __all__ = [
45
45
  "bool_env",
46
+ "int_env",
47
+ "str_env",
48
+ "load_dotenv",
49
+ "setup_env",
46
50
  "env_var",
47
51
  "get_conf_data_location",
48
52
  "get_conf_data_location_env_name",
@@ -68,14 +72,16 @@ import os
68
72
  import warnings
69
73
  from pathlib import Path
70
74
 
75
+ from dotenv import load_dotenv as _load_dotenv
76
+ from dotenv import find_dotenv
77
+ from rich.console import Console
78
+
79
+ from egse.decorators import static_vars
71
80
  from egse.log import logger
72
81
  from egse.system import all_logging_disabled
73
82
  from egse.system import get_caller_info
74
83
  from egse.system import ignore_m_warning
75
-
76
- from rich.console import Console
77
-
78
- console = Console(width=100)
84
+ from egse.system import type_name
79
85
 
80
86
  # Every project shall have a PROJECT and a SITE_ID environment variable set. This variable will be used to
81
87
  # create the other environment variables that are specific to the project.
@@ -99,7 +105,57 @@ KNOWN_PROJECT_ENVIRONMENT_VARIABLES = [
99
105
  ]
100
106
 
101
107
 
102
- def initialize():
108
+ def int_env(var_name: str, default: int, *, minimum: int | None = 1) -> int:
109
+ """Return an integer environment override, falling back to `default` on errors."""
110
+ raw = os.getenv(var_name)
111
+ if raw is None:
112
+ return default
113
+ try:
114
+ value = int(raw)
115
+ except ValueError:
116
+ logger.warning(f"Ignoring invalid integer for {var_name}: {raw!r}, returning {default=}")
117
+ return default
118
+ if minimum is not None and value < minimum:
119
+ logger.warning(f"Ignoring {var_name} because value {value} < {minimum}, returning {default=}")
120
+ return default
121
+ return value
122
+
123
+
124
+ def bool_env(var_name: str, default: bool = False) -> bool:
125
+ """Return True if the environment variable is set to 1, true, yes, or on. All case-insensitive."""
126
+
127
+ if value := os.getenv(var_name):
128
+ return value.strip().lower() in {"1", "true", "yes", "on"}
129
+
130
+ return default
131
+
132
+
133
+ def str_env(var_name: str, default: str | None = None) -> str:
134
+ """Return the value of the environment variable or default if variable is not defined."""
135
+ return os.getenv(var_name, default)
136
+
137
+
138
+ VERBOSE_DEBUG = bool_env("VERBOSE_DEBUG")
139
+
140
+
141
+ def load_dotenv():
142
+ """Overwrites the load_dotenv function for CGSE specifics.
143
+
144
+ - set `CGSE_DOTENV_DISABLED=true` to disable loading the `.env` file
145
+ - the `.env` file is searched for relative to the current working directory,
146
+ unlike the default, which searches from the script location.
147
+
148
+ NOTE:
149
+ - don't use the `load_dotenv` function from the `dotenv` package
150
+ - don't use `load_dotenv` in any modules, only in entrypoints and apps.
151
+ """
152
+ if not bool_env("CGSE_DOTENV_DISABLED") and (dotenv_location := find_dotenv(usecwd=True)):
153
+ logger.debug(f"Loading environment variables from {dotenv_location}.")
154
+ _load_dotenv(dotenv_path=dotenv_location)
155
+
156
+
157
+ @static_vars(is_initialized=False)
158
+ def setup_env():
103
159
  """
104
160
  Initialize the environment variables that are required for the CGSE to function properly.
105
161
  This function will print a warning if any of the mandatory environment variables is not set.
@@ -110,6 +166,14 @@ def initialize():
110
166
 
111
167
  global _env
112
168
 
169
+ if setup_env.is_initialized:
170
+ return
171
+
172
+ if VERBOSE_DEBUG:
173
+ logger.debug(f"Initialising the environment...")
174
+
175
+ load_dotenv()
176
+
113
177
  for name in MANDATORY_ENVIRONMENT_VARIABLES:
114
178
  try:
115
179
  _env.set(name, os.environ[name])
@@ -123,8 +187,10 @@ def initialize():
123
187
  project = _env.get("PROJECT")
124
188
 
125
189
  for gen_var_name in KNOWN_PROJECT_ENVIRONMENT_VARIABLES:
126
- env_var = f"{project}_{gen_var_name}"
127
- _env.set(gen_var_name, os.environ.get(env_var, NoValue()))
190
+ _env_var = f"{project}_{gen_var_name}"
191
+ _env.set(gen_var_name, os.environ.get(_env_var, NoValue()))
192
+
193
+ setup_env.is_initialized = True
128
194
 
129
195
 
130
196
  class _Env:
@@ -136,8 +202,12 @@ class _Env:
136
202
  def set(self, key, value):
137
203
  if value is None:
138
204
  if key in self._env:
205
+ if VERBOSE_DEBUG:
206
+ logger.debug(f"Unsetting environment variable {key}")
139
207
  del self._env[key]
140
208
  else:
209
+ if VERBOSE_DEBUG:
210
+ logger.debug(f"Setting environment variable {key}={value}")
141
211
  self._env[key] = value
142
212
 
143
213
  def get(self, key) -> str:
@@ -166,11 +236,11 @@ class NoValue:
166
236
  return False
167
237
 
168
238
  def __repr__(self):
169
- return f"{self.__class__.__name__}"
239
+ return f"{type_name(self)}"
170
240
 
171
241
 
172
242
  # The module needs to be initialized before it can be used.
173
- initialize()
243
+ setup_env()
174
244
 
175
245
 
176
246
  def _check_no_value(var_name, value):
@@ -244,7 +314,7 @@ def set_data_storage_location(location: str | Path | None):
244
314
  _env.set("DATA_STORAGE_LOCATION", None)
245
315
  return
246
316
 
247
- if not Path(location).exists():
317
+ if not Path(location).expanduser().exists():
248
318
  warnings.warn(f"The location you provided for the environment variable {env_name} doesn't exist: {location}.")
249
319
 
250
320
  os.environ[env_name] = str(location)
@@ -312,7 +382,7 @@ def set_conf_data_location(location: str | Path | None):
312
382
  _env.set("CONF_DATA_LOCATION", None)
313
383
  return
314
384
 
315
- if not Path(location).exists():
385
+ if not Path(location).expanduser().exists():
316
386
  warnings.warn(f"The location you provided for the environment variable {env_name} doesn't exist: {location}.")
317
387
 
318
388
  os.environ[env_name] = location
@@ -378,7 +448,7 @@ def set_log_file_location(location: str | Path | None):
378
448
  _env.set("LOG_FILE_LOCATION", None)
379
449
  return
380
450
 
381
- if not Path(location).exists():
451
+ if not Path(location).expanduser().exists():
382
452
  warnings.warn(f"The location you provided for the environment variable {env_name} doesn't exist: {location}.")
383
453
 
384
454
  os.environ[env_name] = location
@@ -423,7 +493,7 @@ def get_log_file_location(site_id: str = None, check_exists: bool = False) -> st
423
493
  log_data_root = f"{data_root}/log"
424
494
 
425
495
  if check_exists:
426
- if not Path(log_data_root).is_dir():
496
+ if not Path(log_data_root).expanduser().is_dir():
427
497
  raise ValueError(f"The location that was constructed doesn't exist: {log_data_root}")
428
498
 
429
499
  return log_data_root
@@ -453,8 +523,11 @@ def set_local_settings(path: str | Path | None):
453
523
  _env.set("LOCAL_SETTINGS", None)
454
524
  return
455
525
 
456
- if not Path(path).exists():
457
- warnings.warn(f"The location you provided for the environment variable {env_name} doesn't exist: {path}.")
526
+ if not Path(path).expanduser().is_file():
527
+ warnings.warn(
528
+ f"The location you provided for the environment variable {env_name} doesn't exist or is not a "
529
+ f"regular file: {path}."
530
+ )
458
531
 
459
532
  os.environ[env_name] = path
460
533
  _env.set("LOCAL_SETTINGS", path)
@@ -481,9 +554,9 @@ def get_local_settings_path() -> str | None:
481
554
  )
482
555
  return None
483
556
 
484
- if not Path(local_settings).exists():
557
+ if not Path(local_settings).expanduser().is_file():
485
558
  warnings.warn(
486
- f"The local settings path '{local_settings}' doesn't exist. As a result, "
559
+ f"The local settings path '{local_settings}' doesn't exist or is not a regular file. As a result, "
487
560
  f"the local settings for your project will not be loaded."
488
561
  )
489
562
 
@@ -519,7 +592,7 @@ def get_conf_repo_location() -> str | None:
519
592
  )
520
593
  return None
521
594
 
522
- if not Path(location).exists():
595
+ if not Path(location).expanduser().exists():
523
596
  warnings.warn(f"The location of the configuration data repository doesn't exist: {location}.")
524
597
  return None
525
598
 
@@ -545,7 +618,7 @@ def set_conf_repo_location(location: str | Path | None):
545
618
  _env.set("CONF_REPO_LOCATION", None)
546
619
  return
547
620
 
548
- if not Path(location).exists():
621
+ if not Path(location).expanduser().exists():
549
622
  warnings.warn(f"The location you provided for the environment variable {env_name} doesn't exist: {location}.")
550
623
 
551
624
  os.environ[env_name] = location
@@ -574,20 +647,11 @@ def print_env():
574
647
  console.print(f" {get_local_settings_env_name():{col_width}s}: {get_local_settings_path()}")
575
648
 
576
649
 
577
- def bool_env(var_name: str) -> bool:
578
- """Return True if the environment variable is set to 1, true, yes, or on. All case-insensitive."""
579
-
580
- if value := os.getenv(var_name):
581
- return value.strip().lower() in {"1", "true", "yes", "on"}
582
-
583
- return False
584
-
585
-
586
650
  @contextlib.contextmanager
587
651
  def env_var(**kwargs: str | int | float | bool | None):
588
652
  """
589
- Context manager to run some code that need alternate settings for environment variables.
590
- This will automatically initialize the CGSE environment upon entry and re-initialize upon exit.
653
+ Context manager to run some code that need alternate settings for environment variables. This will automatically
654
+ initialize the CGSE environment upon entry and re-initialize when the context manager exits.
591
655
 
592
656
  Note:
593
657
  This context manager is different from the one in `egse.system` because of the CGSE environment changes.
@@ -614,7 +678,7 @@ def env_var(**kwargs: str | int | float | bool | None):
614
678
  else:
615
679
  os.environ[k] = v
616
680
 
617
- initialize()
681
+ setup_env()
618
682
 
619
683
  yield
620
684
 
@@ -625,7 +689,7 @@ def env_var(**kwargs: str | int | float | bool | None):
625
689
  else:
626
690
  os.environ[k] = v
627
691
 
628
- initialize()
692
+ setup_env()
629
693
 
630
694
 
631
695
  def main(args: list | None = None): # pragma: no cover
@@ -650,32 +714,34 @@ def main(args: list | None = None): # pragma: no cover
650
714
  "--mkdir",
651
715
  default=False,
652
716
  action="store_true",
653
- help="Create directory that doesn't exist.",
717
+ help="Create any directory that doesn't exist.",
654
718
  )
655
719
 
656
720
  args = parser.parse_args(args or [])
657
721
 
658
- def check_env_dir(env_var: str):
659
- value = _env.get(env_var)
722
+ setup_env()
723
+
724
+ def check_env_dir(_env_var: str):
725
+ value = _env.get(_env_var)
660
726
 
661
727
  if value == NoValue():
662
728
  value = "[bold red]not set"
663
- elif not value.startswith("/"):
729
+ elif not Path(value).expanduser().is_absolute():
664
730
  value = f"[default]{value} [bold orange3](this is a relative path!)"
665
- elif not os.path.exists(value):
731
+ elif not Path(value).expanduser().exists():
666
732
  value = f"[default]{value} [bold red](location doesn't exist!)"
667
- elif not os.path.isdir(value):
733
+ elif not Path(value).expanduser().is_dir():
668
734
  value = f"[default]{value} [bold red](location is not a directory!)"
669
735
  else:
670
736
  value = f"[default]{value}"
671
737
  return value
672
738
 
673
- def check_env_file(env_var: str):
674
- value = _env.get(env_var)
739
+ def check_env_file(_env_var: str):
740
+ value = _env.get(_env_var)
675
741
 
676
742
  if not value:
677
743
  value = "[bold red]not set"
678
- elif not os.path.exists(value):
744
+ elif not Path(value).expanduser().exists():
679
745
  value = f"[default]{value} [bold red](location doesn't exist!)"
680
746
  else:
681
747
  value = f"[default]{value}"
@@ -701,7 +767,7 @@ def main(args: list | None = None): # pragma: no cover
701
767
  try:
702
768
  rich.print(f" {get_data_storage_location() = }", flush=True, end="")
703
769
  location = get_data_storage_location()
704
- if not Path(location).exists():
770
+ if not Path(location).expanduser().exists():
705
771
  if args.mkdir:
706
772
  rich.print(f" [green]⟶ Creating data storage location: {location} (+ daily + obs)[/]")
707
773
  Path(location).mkdir(parents=True)
@@ -717,7 +783,7 @@ def main(args: list | None = None): # pragma: no cover
717
783
  try:
718
784
  rich.print(f" {get_conf_data_location() = }", flush=True, end="")
719
785
  location = get_conf_data_location()
720
- if not Path(location).exists():
786
+ if not Path(location).expanduser().exists():
721
787
  if args.mkdir:
722
788
  rich.print(f" [green]⟶ Creating configuration data location: {location}[/]")
723
789
  Path(location).mkdir(parents=True)
@@ -731,7 +797,7 @@ def main(args: list | None = None): # pragma: no cover
731
797
  try:
732
798
  rich.print(f" {get_conf_repo_location() = }", flush=True, end="")
733
799
  location = get_conf_repo_location()
734
- if location is None or not Path(location).exists():
800
+ if location is None or not Path(location).expanduser().exists():
735
801
  rich.print(" [red]⟶ ERROR: The configuration repository location doesn't exist![/]")
736
802
  else:
737
803
  rich.print()
@@ -741,7 +807,7 @@ def main(args: list | None = None): # pragma: no cover
741
807
  try:
742
808
  rich.print(f" {get_log_file_location() = }", flush=True, end="")
743
809
  location = get_log_file_location()
744
- if not Path(location).exists():
810
+ if not Path(location).expanduser().exists():
745
811
  if args.mkdir:
746
812
  rich.print(f" [green]⟶ Creating log files location: {location}[/]")
747
813
  Path(location).mkdir(parents=True)
@@ -755,7 +821,7 @@ def main(args: list | None = None): # pragma: no cover
755
821
  try:
756
822
  rich.print(f" {get_local_settings_path() = }", flush=True, end="")
757
823
  location = get_local_settings_path()
758
- if location is None or not Path(location).exists():
824
+ if location is None or not Path(location).expanduser().exists():
759
825
  rich.print(" [red]⟶ ERROR: The local settings file is not defined or doesn't exist![/]")
760
826
  else:
761
827
  rich.print()
@@ -782,8 +848,8 @@ def main(args: list | None = None): # pragma: no cover
782
848
  of the name. By default, this directory is located in the overall data storage folder.
783
849
 
784
850
  [bold]PROJECT_CONF_REPO_LOCATION[/bold]:
785
- This variable is the root of the working copy of the 'plato-cgse-conf' project.
786
- The value is usually set to `~/git/plato-cgse-conf`.
851
+ This variable is the root of the working copy of the repo for configuration files.
852
+ The value is usually set to `~/git/{project}-conf`.
787
853
 
788
854
  [bold]PROJECT_DATA_STORAGE_LOCATION[/bold]:
789
855
  This directory contains all the data files from the control servers and other
@@ -805,7 +871,7 @@ def main(args: list | None = None): # pragma: no cover
805
871
  [bold]PROJECT_LOCAL_SETTINGS[/bold]:
806
872
  This file is used for local site-specific settings. When the environment
807
873
  variable is not set, no local settings will be loaded. By default, this variable
808
- is assumed to be '/cgse/local_settings.yaml'.
874
+ assumes to be '/cgse/local_settings.yaml'.
809
875
  """
810
876
 
811
877
  if args.doc:
egse/heartbeat.py CHANGED
@@ -80,7 +80,7 @@ if __name__ == "__main__":
80
80
  broadcaster.start()
81
81
 
82
82
  # You will see that the timestamp of this custom message is drifting because
83
- # we do not take the execution time of the `set_message()` etc. intyo accout.
83
+ # we do not take the execution time of the `set_message()` etc. into account.
84
84
  # In the actual HeartbeatBroadcaster above, we do take that into account.
85
85
 
86
86
  try:
egse/log.py CHANGED
@@ -135,20 +135,27 @@ for handler in root_logger.handlers:
135
135
  logger = egse_logger
136
136
 
137
137
  if __name__ == "__main__":
138
+ from egse.env import str_env
139
+
138
140
  root_logger = logging.getLogger()
139
141
 
140
- rich.print(
142
+ print(
141
143
  textwrap.dedent(
142
144
  """
145
+ Environment variables:
146
+ - LOG_LEVEL=debug|info|warning|critical
147
+ - LOG_FORMAT=full|clean
148
+
143
149
  Example logging statements
144
150
  - logging level set to INFO
151
+ - logging format set to full
145
152
  - fields are separated by a colon ':'
146
153
  - fields: date & time: process name : level : logger name : lineno : filename : message
147
154
  """
148
155
  )
149
156
  )
150
157
 
151
- if os.getenv("LOG_FORMAT_FULL") == "true":
158
+ if str_env("LOG_FORMAT", "clean").strip().lower() == "full":
152
159
  rich.print(
153
160
  f"[b]{'Date & Time':^23s} : {'Process Name':20s} : {'Level':8s} : {'Logger Name':^25s} : {' Line '} : "
154
161
  f"{'Filename':20s} : {'Message'}[/]"
@@ -37,11 +37,14 @@ import pandas
37
37
  import pyarrow
38
38
  from influxdb_client_3 import InfluxDBClient3
39
39
  from influxdb_client_3 import Point
40
- from influxdb_client_3 import SYNCHRONOUS
40
+ from influxdb_client_3 import WriteType
41
41
  from influxdb_client_3 import write_client_options
42
42
  from influxdb_client_3.exceptions import InfluxDB3ClientError
43
43
  from influxdb_client_3.write_client.domain.write_precision import WritePrecision
44
44
 
45
+ from egse.env import bool_env
46
+ from egse.env import int_env
47
+ from egse.env import str_env
45
48
  from egse.metrics import TimeSeriesRepository
46
49
  from egse.system import type_name
47
50
 
@@ -63,9 +66,38 @@ class InfluxDBRepository(TimeSeriesRepository):
63
66
  def __exit__(self, exc_type, exc_val, exc_tb):
64
67
  self.close()
65
68
 
69
+ def _load_client_options(self):
70
+ self._batch_size = int_env("CGSE_INFLUX_BATCH_SIZE", 1_000)
71
+ self._flush_interval = int_env("CGSE_INFLUX_FLUSH_MS", 1_000)
72
+ self._retry_interval = int_env("CGSE_INFLUX_RETRY_MS", 3_000)
73
+ self._max_retry_delay = int_env("CGSE_INFLUX_RETRY_MAX_DELAY_MS", 3_000)
74
+ self._max_retry_time = int_env("CGSE_INFLUX_RETRY_MAX_TIME_MS", 6_000, minimum=0)
75
+ self._max_retries = int_env("CGSE_INFLUX_MAX_RETRIES", 5, minimum=0)
76
+ self._no_sync = bool_env("CGSE_INFLUX_NO_SYNC", default=True)
77
+ self._write_type_env = str_env("CGSE_INFLUX_WRITE_TYPE")
78
+ self._write_type = WriteType.asynchronous
79
+ if self._write_type_env:
80
+ match self._write_type_env.strip().lower():
81
+ case "batch":
82
+ self._write_type = WriteType.batching
83
+ case "async":
84
+ self._write_type = WriteType.asynchronous
85
+ case "sync":
86
+ self._write_type = WriteType.synchronous
87
+
66
88
  def connect(self):
89
+ self._load_client_options()
90
+
67
91
  wco = write_client_options(
68
- write_options=SYNCHRONOUS, write_precision=self.metrics_time_precision, flush_interval=200
92
+ write_type=self._write_type,
93
+ batch_size=self._batch_size,
94
+ flush_interval=self._flush_interval,
95
+ retry_interval=self._retry_interval,
96
+ max_retries=self._max_retries,
97
+ max_retry_delay=self._max_retry_delay,
98
+ max_retry_time=self._max_retry_time,
99
+ no_sync=self._no_sync,
100
+ write_precision=self.metrics_time_precision,
69
101
  )
70
102
  self.client = InfluxDBClient3(host=self.host, database=self.database, token=self.token, write_options=wco)
71
103
 
egse/scpi.py CHANGED
@@ -77,7 +77,7 @@ class AsyncSCPIInterface(AsyncDeviceInterface, AsyncDeviceTransport):
77
77
  def is_simulator(self) -> bool:
78
78
  return False
79
79
 
80
- async def initialize(self, commands: list[tuple[str, bool]] = None, reset_device: bool = False):
80
+ async def initialize(self, commands: list[tuple[str, bool]] = None, reset_device: bool = False) -> list[str | None]:
81
81
  """Initialize the device with optional reset and command sequence.
82
82
 
83
83
  Performs device initialization by optionally resetting the device and then
@@ -92,21 +92,25 @@ class AsyncSCPIInterface(AsyncDeviceInterface, AsyncDeviceTransport):
92
92
  the command sequence. Defaults to False.
93
93
 
94
94
  Returns:
95
- None
95
+ Response for each of the commands, or None when no response was expected.
96
96
 
97
97
  Raises:
98
98
  Any exceptions raised by the underlying write() or trans() methods,
99
99
  typically communication errors or device timeouts.
100
100
 
101
101
  Example:
102
- >>> await device.initialize([
103
- ... ("*IDN?", True), # Query device ID, expect response
104
- ... ("SYST:ERR?", True), # Check for errors, expect response
105
- ... ("OUTP ON", False) # Enable output, no response expected
106
- ... ], reset_device=True)
102
+ await device.initialize(
103
+ [
104
+ ("*IDN?", True), # Query device ID, expect response
105
+ ("SYST:ERR?", True), # Check for errors, expect response
106
+ ("OUTP ON", False), # Enable output, no response expected
107
+ ],
108
+ reset_device=True
109
+ )
107
110
  """
108
111
 
109
112
  commands = commands or []
113
+ responses = []
110
114
 
111
115
  if reset_device:
112
116
  logger.info(f"Resetting the {self.device_name}...")
@@ -116,10 +120,14 @@ class AsyncSCPIInterface(AsyncDeviceInterface, AsyncDeviceTransport):
116
120
  if expects_response:
117
121
  logger.debug(f"Sending {cmd}...")
118
122
  response = (await self.trans(cmd)).decode().strip()
123
+ responses.append(response)
119
124
  logger.debug(f"{response = }")
120
125
  else:
121
126
  logger.debug(f"Sending {cmd}...")
122
127
  await self.write(cmd)
128
+ responses.append(None)
129
+
130
+ return responses
123
131
 
124
132
  async def connect(self) -> None:
125
133
  """Connect to the device asynchronously.
egse/settings.py CHANGED
@@ -66,9 +66,6 @@ The above code will read the YAML file from the given location and not from the
66
66
 
67
67
  """
68
68
 
69
- from __future__ import annotations
70
-
71
- import logging
72
69
  import re
73
70
  from pathlib import Path
74
71
  from typing import Any
@@ -197,7 +194,7 @@ def load_local_settings(force: bool = False) -> attrdict:
197
194
  local_settings_path = get_local_settings_path()
198
195
 
199
196
  if local_settings_path:
200
- path = Path(local_settings_path)
197
+ path = Path(local_settings_path).expanduser()
201
198
  local_settings = load_settings_file(path.parent, path.name, force)
202
199
 
203
200
  return local_settings
@@ -386,7 +383,9 @@ def main(args: list | None = None): # pragma: no cover
386
383
  #
387
384
  # Use the '--help' option to see what your choices are.
388
385
 
389
- logging.basicConfig(level=20)
386
+ from egse.env import setup_env
387
+
388
+ setup_env()
390
389
 
391
390
  import argparse
392
391
 
egse/setup.py CHANGED
@@ -644,7 +644,7 @@ def _check_conditions_for_get_path_of_setup_file(site_id: str) -> Path:
644
644
 
645
645
  print_env()
646
646
 
647
- repo_location = Path(repo_location)
647
+ repo_location = Path(repo_location).expanduser()
648
648
  setup_location = repo_location / "data" / site_id / "conf"
649
649
 
650
650
  if not repo_location.is_dir():
@@ -689,7 +689,7 @@ def get_path_of_setup_file(setup_id: int, site_id: str) -> Path:
689
689
  """
690
690
 
691
691
  if not has_conf_repo_location():
692
- setup_location = Path(get_conf_data_location(site_id))
692
+ setup_location = Path(get_conf_data_location(site_id)).expanduser()
693
693
  else:
694
694
  setup_location = _check_conditions_for_get_path_of_setup_file(site_id)
695
695
 
@@ -823,10 +823,11 @@ def submit_setup(setup: Setup, description: str, **kwargs) -> str | None:
823
823
 
824
824
  def main(args: list = None): # pragma: no cover
825
825
  import argparse
826
-
827
826
  from rich import print
828
-
829
827
  from egse.config import find_files
828
+ from egse.env import setup_env
829
+
830
+ setup_env()
830
831
 
831
832
  site_id = get_site_id()
832
833
  location = get_conf_data_location()
@@ -882,7 +883,7 @@ def main(args: list = None): # pragma: no cover
882
883
 
883
884
 
884
885
  class SetupManager:
885
- """Unified manager that routes Setup access to appropriate providers.
886
+ """A unified manager that routes Setup access to appropriate providers.
886
887
 
887
888
  Providers are loaded from the `cgse.extension.setup_providers` entrypoints.
888
889
  Providers serve different purposes, the default provider accesses Setups from
@@ -958,6 +959,11 @@ _setup_manager = SetupManager()
958
959
 
959
960
 
960
961
  if __name__ == "__main__":
962
+ from egse.env import setup_env
963
+
964
+ setup_env()
965
+
966
+ # import sys
961
967
  # main(sys.argv[1:])
962
- #
968
+
963
969
  rich.print(load_setup(site_id=get_site_id()))
egse/socketdevice.py CHANGED
@@ -2,29 +2,50 @@
2
2
  This module defines base classes and generic functions to work with sockets.
3
3
  """
4
4
 
5
+ import asyncio
6
+ import select
5
7
  import socket
8
+ import time
9
+ from typing import Optional
6
10
 
7
11
  from egse.device import DeviceConnectionError
8
12
  from egse.device import DeviceConnectionInterface
9
13
  from egse.device import DeviceTimeoutError
10
14
  from egse.device import DeviceTransport
11
15
  from egse.log import logger
16
+ from egse.system import type_name
12
17
 
13
18
 
14
19
  class SocketDevice(DeviceConnectionInterface, DeviceTransport):
15
20
  """Base class that implements the socket interface."""
16
21
 
17
- def __init__(self, hostname: str, port: int):
22
+ # We set a default connect timeout of 3.0 sec before connecting and reset
23
+ # to None (=blocking) after connecting. The reason for this is that when no
24
+ # device is available, e.g. during testing, the timeout will take about
25
+ # two minutes which is way too long. It needs to be evaluated if this
26
+ # approach is acceptable and not causing problems during production.
27
+
28
+ def __init__(
29
+ self,
30
+ hostname: str,
31
+ port: int,
32
+ connect_timeout: float = 3.0,
33
+ read_timeout: float | None = 1.0,
34
+ separator: str = b"\x03",
35
+ ):
18
36
  super().__init__()
19
37
  self.is_connection_open = False
20
38
  self.hostname = hostname
21
39
  self.port = port
40
+ self.connect_timeout = connect_timeout
41
+ self.read_timeout = read_timeout
42
+ self.separator = separator
22
43
  self.socket = None
23
44
 
24
45
  @property
25
46
  def device_name(self):
26
47
  """The name of the device that this interface connects to."""
27
- raise NotImplementedError
48
+ return f"SocketDevice({self.hostname}:{self.port})"
28
49
 
29
50
  def connect(self):
30
51
  """
@@ -53,18 +74,12 @@ class SocketDevice(DeviceConnectionInterface, DeviceTransport):
53
74
 
54
75
  try:
55
76
  self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
56
- except socket.error as e_socket:
57
- raise ConnectionError(f"{self.device_name}: Failed to create socket.") from e_socket
58
-
59
- # We set a timeout of 3 sec before connecting and reset to None
60
- # (=blocking) after the connect. The reason for this is because when no
61
- # device is available, e.g during testing, the timeout will take about
62
- # two minutes which is way too long. It needs to be evaluated if this
63
- # approach is acceptable and not causing problems during production.
77
+ except socket.error as exc:
78
+ raise ConnectionError(f"{self.device_name}: Failed to create socket.") from exc
64
79
 
65
80
  try:
66
81
  logger.debug(f'Connecting a socket to host "{self.hostname}" using port {self.port}')
67
- self.socket.settimeout(3)
82
+ self.socket.settimeout(self.connect_timeout)
68
83
  self.socket.connect((self.hostname, self.port))
69
84
  self.socket.settimeout(None)
70
85
  except ConnectionRefusedError as exc:
@@ -120,32 +135,64 @@ class SocketDevice(DeviceConnectionInterface, DeviceTransport):
120
135
 
121
136
  def read(self) -> bytes:
122
137
  """
123
-
124
- Returns:
125
- A bytes object containing the received telemetry.
138
+ Read until ETX (b'\x03') or until `self.read_timeout` elapses.
139
+ Uses `select` to avoid blocking indefinitely when no data is available.
140
+ If `self.read_timeout` was set to None in the constructor, this will block anyway.
126
141
  """
127
- idx, n_total = 0, 0
142
+ if not self.socket:
143
+ raise DeviceConnectionError(self.device_name, "Not connected")
144
+
128
145
  buf_size = 1024 * 4
129
- response = bytes()
146
+ response = bytearray()
147
+
148
+ # If read_timeout is None we preserve blocking behaviour; otherwise enforce overall timeout.
149
+ if self.read_timeout is None:
150
+ end_time = None
151
+ else:
152
+ end_time = time.monotonic() + self.read_timeout
130
153
 
131
154
  try:
132
- for idx in range(100):
133
- # time.sleep(0.1) # Give the device time to fill the buffer
134
- data = self.socket.recv(buf_size)
135
- n = len(data)
136
- n_total += n
137
- response += data
138
- # if n < buf_size:
139
- # break # there is not more data in the buffer
140
- if b"\x03" in response:
155
+ while True:
156
+ # compute remaining timeout for select, this is needed because we read in different parts
157
+ # until ETX is received, and we want to receive the complete messages including ETX within
158
+ # the read timeout.
159
+ if end_time is None:
160
+ timeout = None
161
+ else:
162
+ remaining = end_time - time.monotonic()
163
+ if remaining <= 0.0:
164
+ raise DeviceTimeoutError(self.device_name, "Socket read timed out")
165
+ timeout = remaining
166
+
167
+ ready, _, _ = select.select([self.socket], [], [], timeout)
168
+
169
+ if not ready:
170
+ # no socket ready within timeout
171
+ raise DeviceTimeoutError(self.device_name, "Socket read timed out")
172
+
173
+ try:
174
+ data = self.socket.recv(buf_size)
175
+ except OSError as exc:
176
+ raise DeviceConnectionError(self.device_name, f"Caught {type_name(exc)}: {exc}") from exc
177
+
178
+ if not data:
179
+ # remote closed connection (EOF)
180
+ raise DeviceConnectionError(self.device_name, "Connection closed by peer")
181
+
182
+ response.extend(data)
183
+
184
+ if self.separator in response:
141
185
  break
142
- except socket.timeout as e_timeout:
143
- logger.warning(f"Socket timeout error from {e_timeout}")
144
- raise DeviceTimeoutError(self.device_name, "Socket timeout error") from e_timeout
145
186
 
146
- # logger.debug(f"Total number of bytes received is {n_total}, idx={idx}")
187
+ except DeviceTimeoutError:
188
+ raise
189
+ except DeviceConnectionError:
190
+ raise
191
+ except Exception as exc:
192
+ # unexpected errors - translate to DeviceConnectionError
193
+ raise DeviceConnectionError(self.device_name, "Socket read error") from exc
147
194
 
148
- return response
195
+ return bytes(response)
149
196
 
150
197
  def write(self, command: str):
151
198
  """
@@ -163,11 +210,11 @@ class SocketDevice(DeviceConnectionInterface, DeviceTransport):
163
210
 
164
211
  try:
165
212
  self.socket.sendall(command.encode())
166
- except socket.timeout as e_timeout:
167
- raise DeviceTimeoutError(self.device_name, "Socket timeout error") from e_timeout
168
- except socket.error as e_socket:
213
+ except socket.timeout as exc:
214
+ raise DeviceTimeoutError(self.device_name, "Socket timeout error") from exc
215
+ except socket.error as exc:
169
216
  # Interpret any socket-related error as an I/O error
170
- raise DeviceConnectionError(self.device_name, "Socket communication error.") from e_socket
217
+ raise DeviceConnectionError(self.device_name, "Socket communication error.") from exc
171
218
 
172
219
  def trans(self, command: str) -> bytes:
173
220
  """
@@ -198,8 +245,133 @@ class SocketDevice(DeviceConnectionInterface, DeviceTransport):
198
245
 
199
246
  return return_string
200
247
 
201
- except socket.timeout as e_timeout:
202
- raise DeviceTimeoutError(self.device_name, "Socket timeout error") from e_timeout
203
- except socket.error as e_socket:
248
+ except socket.timeout as exc:
249
+ raise DeviceTimeoutError(self.device_name, "Socket timeout error") from exc
250
+ except socket.error as exc:
204
251
  # Interpret any socket-related error as an I/O error
205
- raise DeviceConnectionError(self.device_name, "Socket communication error.") from e_socket
252
+ raise DeviceConnectionError(self.device_name, "Socket communication error.") from exc
253
+
254
+
255
+ class AsyncSocketDevice(DeviceConnectionInterface, DeviceTransport):
256
+ """
257
+ Async socket-backed device using asyncio streams.
258
+
259
+ - async connect() / disconnect()
260
+ - async read() reads until ETX (b'\\x03') or timeout
261
+ - async write() and async trans()
262
+ """
263
+
264
+ def __init__(
265
+ self,
266
+ hostname: str,
267
+ port: int,
268
+ connect_timeout: float = 3.0,
269
+ read_timeout: float | None = 1.0,
270
+ separator: str = b"\x03",
271
+ ):
272
+ super().__init__()
273
+ self.hostname = hostname
274
+ self.port = port
275
+ self.connect_timeout = connect_timeout
276
+ self.read_timeout = read_timeout
277
+ self.separator = separator
278
+ self.reader: Optional[asyncio.StreamReader] = None
279
+ self.writer: Optional[asyncio.StreamWriter] = None
280
+ self.is_connection_open = False
281
+
282
+ @property
283
+ def device_name(self) -> str:
284
+ # Override this property for a decent name
285
+ return f"AsyncSocketDevice({self.hostname}:{self.port})"
286
+
287
+ async def connect(self) -> None:
288
+ if self.is_connection_open:
289
+ logger.debug(f"{self.device_name}: already connected")
290
+ return
291
+
292
+ if not self.hostname:
293
+ raise ValueError(f"{self.device_name}: hostname is not initialized.")
294
+ if not self.port:
295
+ raise ValueError(f"{self.device_name}: port is not initialized.")
296
+
297
+ try:
298
+ logger.debug(f"{self.device_name}: connect() called; is_connection_open={self.is_connection_open}")
299
+ coro = asyncio.open_connection(self.hostname, self.port)
300
+ self.reader, self.writer = await asyncio.wait_for(coro, timeout=self.connect_timeout)
301
+ self.is_connection_open = True
302
+ logger.debug(f"{self.device_name}: connected -> peer={self.writer.get_extra_info('peername')}")
303
+
304
+ except asyncio.TimeoutError as exc:
305
+ await self._cleanup()
306
+ logger.warning(f"{self.device_name}: connect timed out")
307
+ raise DeviceTimeoutError(self.device_name, f"Connection to {self.hostname}:{self.port} timed out.") from exc
308
+ except Exception as exc:
309
+ await self._cleanup()
310
+ logger.warning(f"{self.device_name}: connect failed: {type_name(exc)} – {exc}")
311
+ raise DeviceConnectionError(self.device_name, f"Failed to connect to {self.hostname}:{self.port}") from exc
312
+
313
+ async def disconnect(self) -> None:
314
+ logger.debug(f"{self.device_name}: disconnect() called; writer_exists={bool(self.writer)}")
315
+ peer = None
316
+ try:
317
+ if self.writer and not self.writer.is_closing():
318
+ peer = self.writer.get_extra_info("peername")
319
+ self.writer.close()
320
+ # wait for close, but don't hang forever
321
+ try:
322
+ await asyncio.wait_for(self.writer.wait_closed(), timeout=1.0)
323
+ except asyncio.TimeoutError:
324
+ logger.debug(f"{self.device_name}: wait_closed() timed out for peer={peer}")
325
+
326
+ finally:
327
+ await self._cleanup()
328
+ logger.debug(f"{self.device_name}: disconnected ({peer=})")
329
+
330
+ def is_connected(self) -> bool:
331
+ return bool(self.is_connection_open and self.writer and not self.writer.is_closing())
332
+
333
+ async def _cleanup(self) -> None:
334
+ self.reader = None
335
+ self.writer = None
336
+ self.is_connection_open = False
337
+
338
+ async def read(self) -> bytes:
339
+ if not self.reader:
340
+ raise DeviceConnectionError(self.device_name, "Not connected")
341
+ try:
342
+ # readuntil includes the separator; we keep it for parity with existing code
343
+ data = await asyncio.wait_for(self.reader.readuntil(separator=self.separator), timeout=self.read_timeout)
344
+ return data
345
+ except asyncio.IncompleteReadError as exc:
346
+ # EOF before separator
347
+ await self._cleanup()
348
+ raise DeviceConnectionError(self.device_name, "Connection closed while reading") from exc
349
+ except asyncio.TimeoutError as exc:
350
+ raise DeviceTimeoutError(self.device_name, "Socket read timed out") from exc
351
+ except Exception as exc:
352
+ await self._cleanup()
353
+ raise DeviceConnectionError(self.device_name, "Socket read error") from exc
354
+
355
+ async def write(self, command: str) -> None:
356
+ if not self.writer:
357
+ raise DeviceConnectionError(self.device_name, "Not connected")
358
+ try:
359
+ self.writer.write(command.encode())
360
+ await asyncio.wait_for(self.writer.drain(), timeout=self.read_timeout)
361
+ except asyncio.TimeoutError as exc:
362
+ raise DeviceTimeoutError(self.device_name, "Socket write timed out") from exc
363
+ except Exception as exc:
364
+ await self._cleanup()
365
+ raise DeviceConnectionError(self.device_name, "Socket write error") from exc
366
+
367
+ async def trans(self, command: str) -> bytes:
368
+ if not self.writer or not self.reader:
369
+ raise DeviceConnectionError(self.device_name, "Not connected")
370
+ try:
371
+ await self.write(command)
372
+ return await self.read()
373
+ except (DeviceTimeoutError, DeviceConnectionError):
374
+ raise
375
+ except Exception as exc:
376
+ await self._cleanup()
377
+ raise DeviceConnectionError(self.device_name, "Socket trans error") from exc