psu-top 0.1.0__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.
psu_top-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Oren Collaco
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
psu_top-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,102 @@
1
+ Metadata-Version: 2.4
2
+ Name: psu-top
3
+ Version: 0.1.0
4
+ Summary: htop-style terminal monitor and controller for SCPI bench power supplies (Kiprim DC310S / OWON SPE3103)
5
+ Author: Oren Collaco
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Nero7991/psu-top
8
+ Project-URL: Issues, https://github.com/Nero7991/psu-top/issues
9
+ Keywords: scpi,power-supply,psu,kiprim,owon,tui,bench,serial,instrument
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Operating System :: POSIX :: Linux
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: System :: Hardware
20
+ Classifier: Topic :: Terminals
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: pyserial>=3.5
25
+ Requires-Dist: textual>=0.50
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest; extra == "dev"
28
+ Requires-Dist: pytest-asyncio; extra == "dev"
29
+ Requires-Dist: pytest-cov; extra == "dev"
30
+ Dynamic: license-file
31
+
32
+ # psu-top
33
+
34
+ htop-style terminal monitor and controller for SCPI bench power supplies.
35
+ Built for and tested on the Kiprim DC310S (a rebranded OWON SPE3103) over
36
+ USB serial.
37
+
38
+ ```
39
+ PSU KIPRIM DC310S /dev/ttyUSB0 [CONNECTED] OUTPUT: ON (CV)
40
+ ┌─ Voltage ──────────────────────────────────────────────────────────┐
41
+ │ 18.063 V (set 18.100) │
42
+ │ ▁▂▃▄▅▆▇█▇▆▅▄▃▂▁▁▂▃▄▅▆▇█▇▆▅▄▃▂▁▁▂▃▄▅▆▇█▇▆▅▄ │
43
+ └──────────────────────────────────────────────────────────────────────┘
44
+ ┌─ Current ──────────────────────────────────────────────────────────┐
45
+ │ 1.077 A (set 2.100) │
46
+ │ ▁▂▃▄▅▆▇█▇▆▅▄▃▂▁▁▂▃▄▅▆▇█▇▆▅▄▃▂▁▁▂▃▄▅▆▇█▇▆▅▄▃▂▁▁▂▃▄▅▆▇█▇ │
47
+ └──────────────────────────────────────────────────────────────────────┘
48
+ Power: 19.45 W
49
+ o Output on/off v Set voltage c Set current r Clear graphs q Quit
50
+ ```
51
+
52
+ The graphs are rolling, oscilloscope-style strip charts: one sample per column,
53
+ newest on the right, scrolling left one column per poll. Heights autoscale to
54
+ the samples currently on screen so low-magnitude signals stay visible.
55
+
56
+ ## Install
57
+
58
+ ```bash
59
+ pip install psu-top
60
+ ```
61
+
62
+ From a checkout, for development:
63
+
64
+ ```bash
65
+ pip install --user -e .
66
+ ```
67
+
68
+ ## Usage
69
+
70
+ ```bash
71
+ psu-top # defaults: /dev/ttyUSB0, 115200, 0.3 s poll
72
+ psu-top --port /dev/ttyUSB1 --baud 115200 --interval 0.5
73
+ ```
74
+
75
+ Keys: `v` set voltage, `c` set current, `o` toggle output, `r` clear graphs
76
+ (empties the trace and rescales fresh), `q` quit.
77
+ Values are clamped to 0-30 V / 0-10 A before sending.
78
+
79
+ ## Protocol notes (Kiprim DC310S, FW V5.2.0)
80
+
81
+ - 115200 8N1 over the built-in CH340 USB serial adapter.
82
+ - Commands MUST be terminated with CR LF (`\r\n`). LF alone gets no
83
+ response from this firmware, although other documentation claims LF
84
+ suffices. Responses end with CR LF. Invalid commands return `ERR`.
85
+ - Commands used: `*IDN?`, `OUTP?`, `OUTP <0/1>`, `VOLT?`, `VOLT x.xxx`,
86
+ `CURR?`, `CURR x.xxx`, `MEAS:VOLT?`, `MEAS:CURR?`.
87
+ - CV/CC display is derived (measured current at/near the limit means CC);
88
+ the device has no documented mode query.
89
+ - Command reference: https://github.com/maximweb/kiprim-dc310s
90
+
91
+ ## Linux note: brltty steals CH340 ports
92
+
93
+ On Ubuntu, `brltty` claims CH340 adapters and detaches `ttyUSB0`. Fix:
94
+
95
+ ```bash
96
+ sudo systemctl mask brltty-udev.service
97
+ sudo systemctl disable --now brltty.service
98
+ ```
99
+
100
+ ## License
101
+
102
+ MIT
@@ -0,0 +1,71 @@
1
+ # psu-top
2
+
3
+ htop-style terminal monitor and controller for SCPI bench power supplies.
4
+ Built for and tested on the Kiprim DC310S (a rebranded OWON SPE3103) over
5
+ USB serial.
6
+
7
+ ```
8
+ PSU KIPRIM DC310S /dev/ttyUSB0 [CONNECTED] OUTPUT: ON (CV)
9
+ ┌─ Voltage ──────────────────────────────────────────────────────────┐
10
+ │ 18.063 V (set 18.100) │
11
+ │ ▁▂▃▄▅▆▇█▇▆▅▄▃▂▁▁▂▃▄▅▆▇█▇▆▅▄▃▂▁▁▂▃▄▅▆▇█▇▆▅▄ │
12
+ └──────────────────────────────────────────────────────────────────────┘
13
+ ┌─ Current ──────────────────────────────────────────────────────────┐
14
+ │ 1.077 A (set 2.100) │
15
+ │ ▁▂▃▄▅▆▇█▇▆▅▄▃▂▁▁▂▃▄▅▆▇█▇▆▅▄▃▂▁▁▂▃▄▅▆▇█▇▆▅▄▃▂▁▁▂▃▄▅▆▇█▇ │
16
+ └──────────────────────────────────────────────────────────────────────┘
17
+ Power: 19.45 W
18
+ o Output on/off v Set voltage c Set current r Clear graphs q Quit
19
+ ```
20
+
21
+ The graphs are rolling, oscilloscope-style strip charts: one sample per column,
22
+ newest on the right, scrolling left one column per poll. Heights autoscale to
23
+ the samples currently on screen so low-magnitude signals stay visible.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pip install psu-top
29
+ ```
30
+
31
+ From a checkout, for development:
32
+
33
+ ```bash
34
+ pip install --user -e .
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ```bash
40
+ psu-top # defaults: /dev/ttyUSB0, 115200, 0.3 s poll
41
+ psu-top --port /dev/ttyUSB1 --baud 115200 --interval 0.5
42
+ ```
43
+
44
+ Keys: `v` set voltage, `c` set current, `o` toggle output, `r` clear graphs
45
+ (empties the trace and rescales fresh), `q` quit.
46
+ Values are clamped to 0-30 V / 0-10 A before sending.
47
+
48
+ ## Protocol notes (Kiprim DC310S, FW V5.2.0)
49
+
50
+ - 115200 8N1 over the built-in CH340 USB serial adapter.
51
+ - Commands MUST be terminated with CR LF (`\r\n`). LF alone gets no
52
+ response from this firmware, although other documentation claims LF
53
+ suffices. Responses end with CR LF. Invalid commands return `ERR`.
54
+ - Commands used: `*IDN?`, `OUTP?`, `OUTP <0/1>`, `VOLT?`, `VOLT x.xxx`,
55
+ `CURR?`, `CURR x.xxx`, `MEAS:VOLT?`, `MEAS:CURR?`.
56
+ - CV/CC display is derived (measured current at/near the limit means CC);
57
+ the device has no documented mode query.
58
+ - Command reference: https://github.com/maximweb/kiprim-dc310s
59
+
60
+ ## Linux note: brltty steals CH340 ports
61
+
62
+ On Ubuntu, `brltty` claims CH340 adapters and detaches `ttyUSB0`. Fix:
63
+
64
+ ```bash
65
+ sudo systemctl mask brltty-udev.service
66
+ sudo systemctl disable --now brltty.service
67
+ ```
68
+
69
+ ## License
70
+
71
+ MIT
@@ -0,0 +1 @@
1
+ """psu-top: terminal monitor and controller for SCPI bench power supplies."""
@@ -0,0 +1,39 @@
1
+ """Command-line entry point for psu-top."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ import serial
9
+
10
+ from .app import PSUTopApp
11
+
12
+
13
+ def parse_args(argv=None) -> argparse.Namespace:
14
+ parser = argparse.ArgumentParser(
15
+ prog="psu-top",
16
+ description="Terminal monitor and controller for SCPI bench power supplies",
17
+ )
18
+ parser.add_argument("--port", default="/dev/ttyUSB0", help="serial port (default: /dev/ttyUSB0)")
19
+ parser.add_argument("--baud", type=int, default=115200, help="baud rate (default: 115200)")
20
+ parser.add_argument("--interval", type=float, default=0.3, help="seconds between polls (default: 0.3)")
21
+ return parser.parse_args(argv)
22
+
23
+
24
+ def main(argv=None) -> int:
25
+ args = parse_args(argv)
26
+ if args.interval <= 0:
27
+ print(f"psu-top: --interval must be positive (got {args.interval})", file=sys.stderr)
28
+ return 1
29
+ try:
30
+ serial.Serial(args.port, args.baud, timeout=1).close()
31
+ except (serial.SerialException, OSError) as exc:
32
+ print(f"psu-top: cannot open {args.port}: {exc}", file=sys.stderr)
33
+ return 1
34
+ PSUTopApp(args.port, args.baud, args.interval).run()
35
+ return 0
36
+
37
+
38
+ if __name__ == "__main__":
39
+ sys.exit(main())
@@ -0,0 +1,344 @@
1
+ """Textual UI for psu-top."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections import deque
7
+ from dataclasses import dataclass
8
+ from typing import ClassVar
9
+
10
+ import serial
11
+ from rich.console import Console, ConsoleOptions, RenderResult
12
+ from rich.segment import Segment
13
+ from rich.style import Style
14
+ from textual.app import App, ComposeResult
15
+ from textual.containers import Vertical
16
+ from textual.widget import Widget
17
+ from textual.widgets import Footer, Input, Static
18
+ from textual.worker import get_current_worker
19
+
20
+ from .scpi import PSUClient, PSUError
21
+
22
+ WINDOW_SECONDS = 300.0 # width of the rolling time window shown in each graph
23
+ CC_MARGIN = 0.05 # amps below the limit at which we call the mode CC
24
+ RECONNECT_DELAY = 2.0 # seconds between reopen attempts after a serial error
25
+
26
+
27
+ @dataclass
28
+ class Sample:
29
+ volts: float
30
+ amps: float
31
+ set_volts: float
32
+ set_amps: float
33
+ output_on: bool
34
+
35
+
36
+ class _Bars:
37
+ """Rich renderable for a rolling column graph.
38
+
39
+ ``columns`` is one value (or None for "no data yet") per character cell, one
40
+ raw sample each. Bar height is the value scaled against ``low``/``high`` --
41
+ the min and max of the samples currently on screen -- so the scale zooms to
42
+ the visible data and low currents stay visible.
43
+ """
44
+
45
+ BARS = "▁▂▃▄▅▆▇█" # eight eighths, index 0 = shortest
46
+
47
+ def __init__(
48
+ self, columns: list[float | None], height: int, low: float, high: float, color
49
+ ) -> None:
50
+ self._columns = columns
51
+ self._height = height
52
+ self._low = low
53
+ self._extent = (high - low) or 0.0
54
+ self._style = Style.from_color(color)
55
+
56
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
57
+ cols = self._columns
58
+ height = self._height
59
+ per_row = len(self.BARS) # eighths per text row
60
+ span = per_row * height - 1 # total addressable eighths
61
+ for row in reversed(range(height)): # top row first
62
+ floor = row * per_row
63
+ for value in cols:
64
+ if value is None:
65
+ yield Segment(" ")
66
+ continue
67
+ # A flat (zero-extent) window sits mid-height rather than on the
68
+ # floor, so a steady reading reads as a level line, not "empty".
69
+ ratio = 0.5 if not self._extent else (value - self._low) / self._extent
70
+ ratio = 0.0 if ratio < 0.0 else 1.0 if ratio > 1.0 else ratio
71
+ index = int(ratio * span)
72
+ if index < floor:
73
+ yield Segment(" ")
74
+ elif index >= floor + per_row:
75
+ yield Segment("█", self._style)
76
+ else:
77
+ yield Segment(self.BARS[index - floor], self._style)
78
+ if row > 0:
79
+ yield Segment.line()
80
+
81
+
82
+ class History(Widget):
83
+ """A rolling, oscilloscope-style bar graph: one sample per column.
84
+
85
+ The newest sample sits at the right edge and the trace scrolls one column
86
+ left per poll. Heights autoscale to the min/max of the samples on screen, so
87
+ low-magnitude signals stay visible. ``clear()`` empties the trace, which also
88
+ forces a fresh autoscale as new samples arrive.
89
+ """
90
+
91
+ COMPONENT_CLASSES: ClassVar[set[str]] = {"history--bar"}
92
+ DEFAULT_CSS = """
93
+ History > .history--bar { color: $primary; }
94
+ """
95
+
96
+ def __init__(self, capacity: int, **kwargs) -> None:
97
+ super().__init__(**kwargs)
98
+ self._capacity = max(1, capacity)
99
+ self._samples: deque[float] = deque(maxlen=self._capacity)
100
+
101
+ def add(self, value: float) -> None:
102
+ self._samples.append(float(value))
103
+ self.refresh()
104
+
105
+ def clear(self) -> None:
106
+ self._samples.clear()
107
+ self.refresh()
108
+
109
+ def _columns(self, width: int) -> list[float | None]:
110
+ """One raw sample per column, newest at the right.
111
+
112
+ The graph shows the most recent ``width`` samples, so each poll advances
113
+ the trace exactly one column (rolling rate == update rate) and a sample's
114
+ height never changes once drawn -- it simply slides left. Older samples
115
+ beyond the terminal width stay in the buffer but off-screen.
116
+ """
117
+ data = list(self._samples)
118
+ count = len(data)
119
+ if count >= width:
120
+ return list(data[-width:]) # newest width samples, right-aligned
121
+ return [None] * (width - count) + list(data) # part-full: pad the left
122
+
123
+ def render(self) -> RenderResult:
124
+ width, height = self.size.width, self.size.height
125
+ if width <= 0 or height <= 0:
126
+ return ""
127
+ columns = self._columns(width)
128
+ visible = [c for c in columns if c is not None]
129
+ low, high = (min(visible), max(visible)) if visible else (0.0, 1.0)
130
+ color = self.get_component_styles("history--bar").color
131
+ return _Bars(columns, height, low, high, color.rich_color)
132
+
133
+
134
+ class MeterPanel(Vertical):
135
+ """One bordered panel: live value + setpoint + rolling history graph."""
136
+
137
+ def __init__(self, title: str, unit: str, capacity: int, **kwargs) -> None:
138
+ super().__init__(**kwargs)
139
+ self.border_title = title
140
+ self._unit = unit
141
+ self._graph = History(capacity)
142
+
143
+ def compose(self) -> ComposeResult:
144
+ yield Static("--", classes="meter-value")
145
+ yield self._graph
146
+
147
+ def update_value(self, measured: float, setpoint: float) -> None:
148
+ self._graph.add(measured)
149
+ self.query_one(".meter-value", Static).update(
150
+ f"{measured:8.3f} {self._unit} (set {setpoint:.3f})"
151
+ )
152
+
153
+ def clear_graph(self) -> None:
154
+ self._graph.clear()
155
+
156
+
157
+ class PSUTopApp(App):
158
+ """htop-style monitor/controller for the Kiprim DC310S."""
159
+
160
+ CSS = """
161
+ #header { height: 1; background: $boost; }
162
+ #power { height: 1; }
163
+ MeterPanel { border: round $primary; height: 9; }
164
+ .meter-value { height: 1; }
165
+ History { height: 4; margin: 1 0 0 0; }
166
+ #entry { display: none; }
167
+ #entry.visible { display: block; }
168
+ """
169
+
170
+ # Without this, Textual auto-focuses the hidden #entry Input on mount and
171
+ # it swallows the key bindings.
172
+ AUTO_FOCUS = None
173
+
174
+ BINDINGS = [
175
+ ("o", "toggle_output", "Output on/off"),
176
+ ("v", "set_voltage", "Set voltage"),
177
+ ("c", "set_current", "Set current"),
178
+ ("r", "clear_graphs", "Clear graphs"),
179
+ ("q", "quit", "Quit"),
180
+ ]
181
+
182
+ def __init__(self, port: str, baud: int, interval: float) -> None:
183
+ super().__init__()
184
+ self._port = port
185
+ self._baud = baud
186
+ self._interval = interval
187
+ # Samples that fill the rolling window at the current poll rate.
188
+ self._capacity = max(1, round(WINDOW_SECONDS / interval))
189
+ self._client: PSUClient | None = None
190
+ self._identity = ""
191
+ self._last: Sample | None = None
192
+ self._entry_target: str | None = None
193
+
194
+ def compose(self) -> ComposeResult:
195
+ yield Static(" connecting...", id="header")
196
+ # Stacked, not side by side, so each graph spans the full terminal width.
197
+ yield MeterPanel("Voltage", "V", self._capacity, id="voltage")
198
+ yield MeterPanel("Current", "A", self._capacity, id="current")
199
+ yield Static(id="power")
200
+ yield Input(id="entry")
201
+ yield Footer()
202
+
203
+ def on_mount(self) -> None:
204
+ self.run_worker(self._poll_loop, thread=True, exclusive=True)
205
+
206
+ # ---- poll worker (runs in its own thread) ----
207
+
208
+ def _poll_loop(self) -> None:
209
+ worker = get_current_worker()
210
+ while not worker.is_cancelled:
211
+ try:
212
+ if self._client is None:
213
+ self._connect()
214
+ client = self._client
215
+ sample = Sample(
216
+ volts=client.measured_voltage(),
217
+ amps=client.measured_current(),
218
+ set_volts=client.get_voltage_setpoint(),
219
+ set_amps=client.get_current_setpoint(),
220
+ output_on=client.get_output(),
221
+ )
222
+ except (PSUError, serial.SerialException, OSError):
223
+ dead, self._client = self._client, None
224
+ if dead is not None:
225
+ try:
226
+ dead.close()
227
+ except OSError:
228
+ pass
229
+ if worker.is_cancelled:
230
+ return
231
+ self.call_from_thread(self._show_disconnected)
232
+ self._sleep(worker, RECONNECT_DELAY)
233
+ continue
234
+ if worker.is_cancelled:
235
+ return
236
+ self.call_from_thread(self._show_sample, sample)
237
+ self._sleep(worker, self._interval)
238
+
239
+ def _connect(self) -> None:
240
+ ser = serial.Serial(self._port, self._baud, timeout=1)
241
+ try:
242
+ client = PSUClient(ser)
243
+ idn = client.identify() # e.g. KIPRIM,DC310S,25012662,FV:V5.2.0
244
+ except Exception:
245
+ ser.close() # don't leak the port if identify fails
246
+ raise
247
+ self._identity = " ".join(idn.split(",")[:2])
248
+ self._client = client
249
+
250
+ @staticmethod
251
+ def _sleep(worker, seconds: float) -> None:
252
+ """Sleep in small steps so quit is not delayed by a long wait."""
253
+ deadline = time.monotonic() + seconds
254
+ while time.monotonic() < deadline and not worker.is_cancelled:
255
+ time.sleep(0.1)
256
+
257
+ # ---- UI updates (run on the app thread via call_from_thread) ----
258
+
259
+ def _show_sample(self, sample: Sample) -> None:
260
+ self._last = sample
261
+ mode = ""
262
+ if sample.output_on:
263
+ mode = " (CC)" if sample.amps >= sample.set_amps - CC_MARGIN else " (CV)"
264
+ state = ("ON" if sample.output_on else "OFF") + mode
265
+ self.query_one("#header", Static).update(
266
+ f" PSU {self._identity} {self._port} [CONNECTED] OUTPUT: {state}"
267
+ )
268
+ self.query_one("#voltage", MeterPanel).update_value(sample.volts, sample.set_volts)
269
+ self.query_one("#current", MeterPanel).update_value(sample.amps, sample.set_amps)
270
+ self.query_one("#power", Static).update(
271
+ f" Power: {sample.volts * sample.amps:6.2f} W"
272
+ )
273
+
274
+ def _show_disconnected(self) -> None:
275
+ self._last = None
276
+ self.query_one("#header", Static).update(
277
+ f" PSU {self._identity or 'unknown'} {self._port} [DISCONNECTED] retrying every {RECONNECT_DELAY:.0f}s"
278
+ )
279
+
280
+ # ---- controls ----
281
+
282
+ def _run_control(self, fn) -> None:
283
+ """Run a control command off the UI thread, surfacing any error.
284
+
285
+ Control writes can race a serial disconnect; on failure we notify
286
+ rather than let the worker die silently (the next poll would also
287
+ report it, but only after a visible lag).
288
+ """
289
+ def task() -> None:
290
+ try:
291
+ fn()
292
+ except (PSUError, serial.SerialException, OSError) as exc:
293
+ self.call_from_thread(self.notify, f"Command failed: {exc}", severity="error")
294
+
295
+ self.run_worker(task, thread=True, exit_on_error=False)
296
+
297
+ def action_toggle_output(self) -> None:
298
+ client, last = self._client, self._last
299
+ if client is None or last is None:
300
+ return
301
+ target = client.output_off if last.output_on else client.output_on
302
+ self._run_control(target)
303
+
304
+ def action_set_voltage(self) -> None:
305
+ self._prompt("voltage", "V")
306
+
307
+ def action_set_current(self) -> None:
308
+ self._prompt("current", "A")
309
+
310
+ def action_clear_graphs(self) -> None:
311
+ for panel in self.query(MeterPanel):
312
+ panel.clear_graph()
313
+
314
+ def _prompt(self, target: str, unit: str) -> None:
315
+ if self._client is None:
316
+ return
317
+ self._entry_target = target
318
+ entry = self.query_one("#entry", Input)
319
+ entry.placeholder = f"new {target} ({unit}) -- Enter to apply, Esc to cancel"
320
+ entry.value = ""
321
+ entry.add_class("visible")
322
+ entry.focus()
323
+
324
+ def on_input_submitted(self, event: Input.Submitted) -> None:
325
+ client, target = self._client, self._entry_target
326
+ self._dismiss_entry()
327
+ if client is None or target is None:
328
+ return
329
+ try:
330
+ value = float(event.value)
331
+ except ValueError:
332
+ return
333
+ setter = client.set_voltage if target == "voltage" else client.set_current
334
+ self._run_control(lambda: setter(value))
335
+
336
+ def on_key(self, event) -> None:
337
+ if event.key == "escape":
338
+ self._dismiss_entry()
339
+
340
+ def _dismiss_entry(self) -> None:
341
+ self._entry_target = None
342
+ entry = self.query_one("#entry", Input)
343
+ entry.remove_class("visible")
344
+ self.set_focus(None)
@@ -0,0 +1,96 @@
1
+ """Serial SCPI client for the Kiprim DC310S (OWON SPE3103).
2
+
3
+ Protocol (verified live on FW V5.2.0): commands must be terminated with
4
+ CR LF -- LF alone gets no response, despite community docs claiming
5
+ otherwise. Responses end with CR LF. Invalid commands return "ERR".
6
+ Community command reference: https://github.com/maximweb/kiprim-dc310s
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import threading
12
+
13
+ TERMINATOR = b"\r\n"
14
+
15
+
16
+ class PSUError(Exception):
17
+ """Raised when the PSU returns ERR, nothing, or garbage."""
18
+
19
+
20
+ class PSUClient:
21
+ """Talks to the PSU over a pyserial-compatible object.
22
+
23
+ All serial I/O is serialized through one lock so writes triggered by
24
+ the UI never interleave with poll-loop reads.
25
+ """
26
+
27
+ VOLTAGE_MAX = 30.0
28
+ CURRENT_MAX = 10.0
29
+
30
+ def __init__(self, ser) -> None:
31
+ self._ser = ser
32
+ self._lock = threading.Lock()
33
+
34
+ def close(self) -> None:
35
+ with self._lock:
36
+ self._ser.close()
37
+
38
+ def identify(self) -> str:
39
+ return self._query("*IDN?")
40
+
41
+ def measured_voltage(self) -> float:
42
+ return self._query_float("MEAS:VOLT?")
43
+
44
+ def measured_current(self) -> float:
45
+ return self._query_float("MEAS:CURR?")
46
+
47
+ def get_voltage_setpoint(self) -> float:
48
+ return self._query_float("VOLT?")
49
+
50
+ def get_current_setpoint(self) -> float:
51
+ return self._query_float("CURR?")
52
+
53
+ def get_output(self) -> bool:
54
+ text = self._query("OUTP?").upper()
55
+ if text not in ("ON", "OFF"):
56
+ raise PSUError(f"unexpected response to 'OUTP?': {text!r}")
57
+ return text == "ON"
58
+
59
+ def set_voltage(self, volts: float) -> None:
60
+ self._write(f"VOLT {_clamp(volts, self.VOLTAGE_MAX):.3f}")
61
+
62
+ def set_current(self, amps: float) -> None:
63
+ self._write(f"CURR {_clamp(amps, self.CURRENT_MAX):.3f}")
64
+
65
+ def output_on(self) -> None:
66
+ self._write("OUTP 1")
67
+
68
+ def output_off(self) -> None:
69
+ self._write("OUTP 0")
70
+
71
+ def _query(self, cmd: str) -> str:
72
+ with self._lock:
73
+ self._ser.reset_input_buffer()
74
+ self._ser.write(cmd.encode("ascii") + TERMINATOR)
75
+ raw = self._ser.read_until(TERMINATOR)
76
+ text = raw.decode("ascii", errors="replace").strip()
77
+ if not text:
78
+ raise PSUError(f"no response to {cmd!r}")
79
+ if text == "ERR":
80
+ raise PSUError(f"device returned ERR for {cmd!r}")
81
+ return text
82
+
83
+ def _query_float(self, cmd: str) -> float:
84
+ text = self._query(cmd)
85
+ try:
86
+ return float(text)
87
+ except ValueError:
88
+ raise PSUError(f"unparseable response to {cmd!r}: {text!r}") from None
89
+
90
+ def _write(self, cmd: str) -> None:
91
+ with self._lock:
92
+ self._ser.write(cmd.encode("ascii") + TERMINATOR)
93
+
94
+
95
+ def _clamp(value: float, maximum: float) -> float:
96
+ return max(0.0, min(float(value), maximum))