uphy-controller 0.1.0.dev2794__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.
@@ -0,0 +1,5 @@
1
+ Copyright rt-labs AB, Sweden.
2
+ All rights reserved.
3
+
4
+ You may not use this software in a commercial product without
5
+ purchasing a license. Contact sales@rt-labs.com for more information.
@@ -0,0 +1,42 @@
1
+ Metadata-Version: 2.1
2
+ Name: uphy-controller
3
+ Version: 0.1.0.dev2794
4
+ Summary: Add your description here
5
+ License: Copyright rt-labs AB, Sweden.
6
+ All rights reserved.
7
+
8
+ You may not use this software in a commercial product without
9
+ purchasing a license. Contact sales@rt-labs.com for more information.
10
+
11
+ Project-URL: RT-Labs, https://rt-labs.com
12
+ Project-URL: U-Phy, https://rt-labs.com/u-phy
13
+ Project-URL: Documentation, https://docs.rt-labs.com/u-phy
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE.txt
17
+ Requires-Dist: dearpygui>=2.0.0
18
+ Requires-Dist: pymodbus>=3.7.3
19
+ Requires-Dist: rich>=13.9.2
20
+ Requires-Dist: typer>=0.12.5
21
+ Requires-Dist: zeroconf>=0.135.0
22
+
23
+ # U-Phy Controller
24
+
25
+ This contain a python based U-Phy device controller that act as a fieldbus master/controller.
26
+
27
+ Supported fieldbuses:
28
+
29
+ - modbus-tcp
30
+
31
+ ## Install
32
+
33
+ ```sh
34
+ uv pip install uphy-controller
35
+ ```
36
+
37
+ ## Workflow
38
+
39
+ ```sh
40
+ # Start up controller against an existing device
41
+ uphy-controller modbus-tcp --model [PATH_TO_MODEL] --target [UUID]:[HOST]:[PORT]
42
+ ```
@@ -0,0 +1,20 @@
1
+ # U-Phy Controller
2
+
3
+ This contain a python based U-Phy device controller that act as a fieldbus master/controller.
4
+
5
+ Supported fieldbuses:
6
+
7
+ - modbus-tcp
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ uv pip install uphy-controller
13
+ ```
14
+
15
+ ## Workflow
16
+
17
+ ```sh
18
+ # Start up controller against an existing device
19
+ uphy-controller modbus-tcp --model [PATH_TO_MODEL] --target [UUID]:[HOST]:[PORT]
20
+ ```
@@ -0,0 +1,26 @@
1
+ [project]
2
+ name = "uphy-controller"
3
+ version = "0.1.0.dev2794"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ dependencies = [
8
+ "dearpygui>=2.0.0",
9
+ "pymodbus>=3.7.3",
10
+ "rich>=13.9.2",
11
+ "typer>=0.12.5",
12
+ "zeroconf>=0.135.0",
13
+ ]
14
+ license = {file = "LICENSE.txt"}
15
+
16
+
17
+ [project.urls]
18
+ RT-Labs = "https://rt-labs.com"
19
+ U-Phy = "https://rt-labs.com/u-phy"
20
+ Documentation = "https://docs.rt-labs.com/u-phy"
21
+
22
+ [project.scripts]
23
+ uphy-controller = "uphy.controller.__main__:app"
24
+
25
+ [project.entry-points.'uphy.cli']
26
+ controller = 'uphy.controller.__main__:app'
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ # This is a namespace package to hold on to uphy namespace
2
+ __path__ = __import__("pkgutil").extend_path(__path__, __name__)
@@ -0,0 +1,8 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass
5
+ class Target:
6
+ id: str
7
+ host: str
8
+ port: str = 502
@@ -0,0 +1,187 @@
1
+ import logging
2
+ from functools import partial
3
+ from pathlib import Path
4
+ from threading import Event, Thread
5
+ from typing import Annotated
6
+ from rich.table import Table
7
+ from rich import print
8
+
9
+ from typer import Option, Typer, BadParameter
10
+ from upgen.model.uphy import Root as RootModel
11
+
12
+ from . import Target, gui, modbus
13
+
14
+ LOG = logging.getLogger(__name__)
15
+
16
+ app = Typer(
17
+ pretty_exceptions_enable=False,
18
+ no_args_is_help=True,
19
+ name="controller",
20
+ help="Run a demo modbus controller based on a model",
21
+ )
22
+
23
+
24
+ @staticmethod
25
+ def parse_target(value: str):
26
+ values = value.split(":")
27
+ if len(values) != 3:
28
+ raise BadParameter(f"Expected uuid:host:port, got {value}")
29
+
30
+ return Target(*value.split(":"))
31
+
32
+
33
+ @app.command()
34
+ def modbus_tcp(
35
+ model: Annotated[list[Path], Option()],
36
+ target: Annotated[
37
+ list[Target] | None,
38
+ Option(
39
+ parser=parse_target,
40
+ metavar="ID:HOST:PORT",
41
+ help="Targets to connect controller to, where ID is a device uuid or name, HOST is hostname to connect to and PORT is the port the modbus-tcp server is listening on.",
42
+ ),
43
+ ] = None,
44
+ ):
45
+ models = [RootModel.parse_file(file) for file in model]
46
+
47
+ devices_by_id = {
48
+ device.id: (root, device) for root in models for device in root.devices
49
+ }
50
+
51
+ devices_by_name = {
52
+ device.name: (root, device) for root in models for device in root.devices
53
+ }
54
+
55
+ if target is None:
56
+ table = Table(title="Available devices")
57
+ table.add_column("ID")
58
+ table.add_column("Name")
59
+
60
+ for _, device in devices_by_id.values():
61
+ table.add_row(str(device.id), device.name)
62
+
63
+ print(table)
64
+ print()
65
+ print(
66
+ "Please add targets to connect to using the --target ID:HOST:PORT argument."
67
+ )
68
+ print()
69
+ exit(-1)
70
+
71
+ gui.setup()
72
+
73
+ stop = Event()
74
+ try:
75
+ for target_obj in target:
76
+ root, device = devices_by_id.get(target_obj.id, (None, None))
77
+ if not device:
78
+ root, device = devices_by_name.get(target_obj.id, (None, None))
79
+ if not device:
80
+ LOG.warning("Unable to find device %s", target_obj.id)
81
+
82
+ device_gui = gui.add_device(
83
+ root, device, f"{target_obj.host}:{target_obj.port}"
84
+ )
85
+
86
+ thread = Thread(
87
+ target=partial(modbus.target_runner, stop, target_obj, device_gui),
88
+ name=device.name,
89
+ )
90
+ thread.start()
91
+
92
+ gui.run()
93
+ finally:
94
+ stop.set()
95
+
96
+
97
+ @app.command()
98
+ def mdns():
99
+ from zeroconf import (
100
+ Zeroconf,
101
+ ServiceBrowser,
102
+ ServiceStateChange,
103
+ IPVersion,
104
+ ServiceInfo,
105
+ )
106
+ import requests
107
+
108
+ zeroconf = Zeroconf(ip_version=IPVersion.V4Only)
109
+ services = ["_modbus._tcp.local."]
110
+
111
+ device_runners: dict[tuple, Event] = {}
112
+
113
+ def on_device_added(device_key: tuple, info: ServiceInfo):
114
+ addresses = info.parsed_addresses()
115
+ properties = info.decoded_properties
116
+ model_port = properties.get("model-port")
117
+ model_path = properties.get("model-path")
118
+ device_id = properties.get("device-id")
119
+ if not model_port or not model_path or not device_id:
120
+ LOG.error("Missing model info for %s", info.name)
121
+ return
122
+
123
+ device_stop = Event()
124
+ device_runners[device_key] = device_stop
125
+
126
+ def _run():
127
+ for address in info.parsed_addresses(IPVersion.V4Only):
128
+ model_url = f"http://{address}:{model_port}{model_path}"
129
+ target_obj = Target(device_id, address, info.port)
130
+ try:
131
+ model_raw = requests.get(model_url).content
132
+ break
133
+ except Exception as exception:
134
+ LOG.debug("Failed to connect to %s:%r", model_url, exception)
135
+ continue
136
+ else:
137
+ LOG.error("Unable to get model for %s", info.name)
138
+ return
139
+
140
+
141
+ model = RootModel.parse_raw(model_raw)
142
+ for device in model.devices:
143
+ if str(device.id) == device_id:
144
+ break
145
+ else:
146
+ LOG.error("Can't find device %s in model", device_id)
147
+ return
148
+
149
+ LOG.info("Adding device %s", info.name)
150
+ device_gui = gui.add_device(model, device, address)
151
+ modbus.target_runner(device_stop, target_obj, device_gui)
152
+ device_gui.close()
153
+
154
+ thread = Thread(
155
+ target=_run,
156
+ )
157
+ thread.start()
158
+
159
+ def on_service_state_change(
160
+ zeroconf: Zeroconf,
161
+ service_type: str,
162
+ name: str,
163
+ state_change: ServiceStateChange,
164
+ ) -> None:
165
+ print(f"Service {name}: {state_change}")
166
+
167
+ device_key = (service_type, name)
168
+
169
+ if state_change is ServiceStateChange.Added:
170
+ info = zeroconf.get_service_info(service_type, name)
171
+ if info and info.port:
172
+ on_device_added(device_key, info)
173
+
174
+ if state_change is ServiceStateChange.Removed:
175
+ device_stop = device_runners.pop(device_key, None)
176
+ if device_stop:
177
+ device_stop.set()
178
+
179
+ gui.setup()
180
+
181
+ ServiceBrowser(zeroconf, services, handlers=[on_service_state_change])
182
+
183
+ gui.run()
184
+
185
+
186
+ if __name__ == "__main__":
187
+ app()
@@ -0,0 +1,157 @@
1
+ import math
2
+ from dataclasses import dataclass
3
+
4
+ import dearpygui.dearpygui as dpg
5
+ from upgen.model.uphy import DataType, Device, Signal, Slot, Parameter
6
+ from upgen.model.uphy import Root as RootModel
7
+
8
+ datatype_to_min_max = {
9
+ DataType.INT8: (-(2**7), 2**7 - 1),
10
+ DataType.INT16: (-(2**15), 2**15 - 1),
11
+ DataType.INT32: (-(2**31), 2**31 - 1),
12
+ DataType.UINT8: (0, 2**8 - 1),
13
+ DataType.UINT16: (0, 2**16 - 1),
14
+ DataType.UINT32: (0, 2**32 - 1),
15
+ DataType.REAL32: (-math.inf, math.inf),
16
+ }
17
+
18
+
19
+ @dataclass
20
+ class InputGUI:
21
+ signal: Signal
22
+ value_id: list[str | int]
23
+
24
+ def __init__(self, signal: Signal):
25
+ self.signal = signal
26
+ dpg.add_text(signal.name)
27
+ with dpg.group():
28
+ self.value_id = [
29
+ dpg.add_text("N/A") for _ in range(signal.array_length or 1)
30
+ ]
31
+
32
+ def update(self, value: list[int | float] | None):
33
+ if value is None:
34
+ for ix in range(self.signal.array_length or 1):
35
+ dpg.set_value(self.value_id[ix], "N/A")
36
+ else:
37
+ for ix in range(self.signal.array_length or 1):
38
+ dpg.set_value(self.value_id[ix], value[ix])
39
+
40
+
41
+ @dataclass
42
+ class OutputGUI:
43
+ signal: Signal
44
+ value_id: list[str | int]
45
+ value: list[int] | None = None
46
+
47
+ def __init__(self, signal: Signal):
48
+ self.signal = signal
49
+ self.value = [0] * (signal.array_length or 1)
50
+ dpg.add_text(signal.name)
51
+ min, max = datatype_to_min_max[signal.datatype]
52
+ with dpg.group():
53
+ self.value_id = [
54
+ dpg.add_input_int(
55
+ width=100,
56
+ callback=lambda sender, app_data, user_data: self.callback(
57
+ ix, app_data
58
+ ),
59
+ min_value=min,
60
+ max_value=max,
61
+ )
62
+ for ix in range(signal.array_length or 1)
63
+ ]
64
+
65
+ def callback(self, ix: int, value: int):
66
+ self.value[ix] = value
67
+
68
+
69
+ class ParamGUI:
70
+ def __init__(self, signal: Parameter):
71
+ self.signal = signal
72
+
73
+
74
+ @dataclass
75
+ class SlotGUI:
76
+ inputs: dict[str, InputGUI]
77
+ outputs: dict[str, OutputGUI]
78
+ params: dict[str, ParamGUI]
79
+
80
+
81
+ @dataclass
82
+ class DeviceGUI:
83
+ widget: int | str
84
+ slots: dict[str, SlotGUI]
85
+ status_id: str | int
86
+
87
+ def update_status(self, status: str):
88
+ dpg.set_value(self.status_id, status)
89
+
90
+ def close(self):
91
+ dpg.delete_item(self.widget)
92
+
93
+
94
+ def add_device(model: RootModel, device: Device, suffix: str) -> DeviceGUI:
95
+ width = 300
96
+ windows = dpg.get_windows()
97
+ for ix, window in enumerate(windows):
98
+ dpg.set_item_pos(window, [ix * width, 0])
99
+ ix = len(windows)
100
+
101
+ with dpg.window(
102
+ width=width,
103
+ pos=[ix * width, 0],
104
+ autosize=True,
105
+ label=f"{device.name} - {suffix}",
106
+ ) as window:
107
+ with dpg.group(horizontal=True):
108
+ dpg.add_text("Status")
109
+ status_id = dpg.add_text()
110
+
111
+ slot_guis = {}
112
+ for slot in device.slots:
113
+ with dpg.child_window(auto_resize_y=True):
114
+ slot_gui = _add_slot(model, slot)
115
+ slot_guis[slot.name] = slot_gui
116
+
117
+ return DeviceGUI(window, slot_guis, status_id)
118
+
119
+
120
+ def _add_slot(model: RootModel, slot: Slot):
121
+ module = model.get_module(slot.module)
122
+ dpg.add_text(f"{slot.name}")
123
+
124
+ dpg.add_separator()
125
+
126
+ with dpg.group(indent=10), dpg.table(label=slot.name, header_row=False):
127
+ dpg.add_table_column()
128
+ dpg.add_table_column()
129
+
130
+ inputs: dict[str, InputGUI] = {}
131
+ for signal in module.inputs:
132
+ with dpg.table_row():
133
+ inputs[signal.id] = InputGUI(signal)
134
+
135
+ outputs: dict[str, OutputGUI] = {}
136
+ for signal in module.outputs:
137
+ with dpg.table_row():
138
+ outputs[signal.id] = OutputGUI(signal)
139
+
140
+ params: dict[str, ParamGUI] = {}
141
+ for signal in module.parameters:
142
+ with dpg.table_row():
143
+ params[signal.id] = ParamGUI(signal)
144
+
145
+ return SlotGUI(inputs=inputs, outputs=outputs, params=params)
146
+
147
+
148
+ def setup():
149
+ dpg.create_context()
150
+ dpg.create_viewport(title="U-Phy Controller", width=800)
151
+ dpg.setup_dearpygui()
152
+
153
+
154
+ def run():
155
+ dpg.show_viewport()
156
+ dpg.start_dearpygui()
157
+ dpg.destroy_context()
@@ -0,0 +1,103 @@
1
+ import logging
2
+ from threading import Event
3
+ from time import sleep
4
+
5
+ import pymodbus.client
6
+ from pymodbus.client import ModbusBaseClient, ModbusTcpClient
7
+ import pymodbus.exceptions
8
+ from upgen.model.uphy import DataType, Signal
9
+
10
+ from . import Target, gui
11
+
12
+ datatype_to_modbus_type = {
13
+ DataType.INT8: ModbusBaseClient.DATATYPE.INT16,
14
+ DataType.INT16: ModbusBaseClient.DATATYPE.INT16,
15
+ DataType.INT32: ModbusBaseClient.DATATYPE.INT32,
16
+ DataType.UINT8: ModbusBaseClient.DATATYPE.UINT16,
17
+ DataType.UINT16: ModbusBaseClient.DATATYPE.UINT16,
18
+ DataType.UINT32: ModbusBaseClient.DATATYPE.UINT32,
19
+ DataType.REAL32: ModbusBaseClient.DATATYPE.FLOAT32,
20
+ }
21
+
22
+
23
+ def get_type(datatype: DataType) -> tuple[ModbusBaseClient.DATATYPE, int]:
24
+ data_type = datatype_to_modbus_type[datatype]
25
+ data_type_bits = data_type.value[1] * 16
26
+ return data_type, data_type_bits
27
+
28
+
29
+ def signal_to_regs_and_type(signal: Signal):
30
+ data_type, data_bits = get_type(signal.datatype)
31
+ data_regs = bits_to_regs(data_bits) * (signal.array_length or 1)
32
+ return data_regs, data_type
33
+
34
+
35
+ def bits_to_regs(bits: int) -> None:
36
+ return (bits + 15) // 16
37
+
38
+
39
+ def target_runner(stop: Event, target: Target, device_gui: gui.DeviceGUI):
40
+ device_gui.update_status("CONNECTING")
41
+ client = ModbusTcpClient(target.host, port=target.port)
42
+
43
+ while not stop.is_set():
44
+ try:
45
+ client.connect()
46
+
47
+ base = 0
48
+ for slot in device_gui.slots.values():
49
+ for input in slot.inputs.values():
50
+ count, data_type = signal_to_regs_and_type(input.signal)
51
+ index = base
52
+ base += count
53
+ try:
54
+ response = client.read_holding_registers(index, count)
55
+ if response.isError():
56
+ raise pymodbus.ModbusException("Error")
57
+ except pymodbus.exceptions.ConnectionException:
58
+ raise
59
+ except pymodbus.ModbusException as exception:
60
+ logging.error("Error on read %d - %r", index, exception)
61
+ input.update(None)
62
+ continue
63
+
64
+ values = [
65
+ client.convert_from_registers(
66
+ response.registers[ix : ix + data_type.value[1]], data_type
67
+ )
68
+ for ix in range(0, count, data_type.value[1])
69
+ ]
70
+ input.update(values)
71
+
72
+ for output in slot.outputs.values():
73
+ count, data_type = signal_to_regs_and_type(input.signal)
74
+ index = base
75
+ base += count
76
+
77
+ if output.value is None:
78
+ continue
79
+
80
+ payload = []
81
+ for value in output.value:
82
+ payload.extend(client.convert_to_registers(value, data_type))
83
+
84
+ try:
85
+ response = client.write_registers(index, payload)
86
+ if response.isError():
87
+ raise pymodbus.ModbusException("Error")
88
+ except pymodbus.exceptions.ConnectionException:
89
+ raise
90
+ except pymodbus.ModbusException as exception:
91
+ logging.error("Error on read %d - %r", index, exception)
92
+
93
+ for param in slot.params.values():
94
+ count = bits_to_regs(param.signal.bitlen)
95
+ index = base
96
+ base += count
97
+ except pymodbus.ModbusException as exception:
98
+ device_gui.update_status("ERROR")
99
+ logging.error("%r", exception)
100
+ sleep(2)
101
+ else:
102
+ device_gui.update_status("CONNECTED")
103
+ sleep(0.5)
@@ -0,0 +1,42 @@
1
+ Metadata-Version: 2.1
2
+ Name: uphy-controller
3
+ Version: 0.1.0.dev2794
4
+ Summary: Add your description here
5
+ License: Copyright rt-labs AB, Sweden.
6
+ All rights reserved.
7
+
8
+ You may not use this software in a commercial product without
9
+ purchasing a license. Contact sales@rt-labs.com for more information.
10
+
11
+ Project-URL: RT-Labs, https://rt-labs.com
12
+ Project-URL: U-Phy, https://rt-labs.com/u-phy
13
+ Project-URL: Documentation, https://docs.rt-labs.com/u-phy
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE.txt
17
+ Requires-Dist: dearpygui>=2.0.0
18
+ Requires-Dist: pymodbus>=3.7.3
19
+ Requires-Dist: rich>=13.9.2
20
+ Requires-Dist: typer>=0.12.5
21
+ Requires-Dist: zeroconf>=0.135.0
22
+
23
+ # U-Phy Controller
24
+
25
+ This contain a python based U-Phy device controller that act as a fieldbus master/controller.
26
+
27
+ Supported fieldbuses:
28
+
29
+ - modbus-tcp
30
+
31
+ ## Install
32
+
33
+ ```sh
34
+ uv pip install uphy-controller
35
+ ```
36
+
37
+ ## Workflow
38
+
39
+ ```sh
40
+ # Start up controller against an existing device
41
+ uphy-controller modbus-tcp --model [PATH_TO_MODEL] --target [UUID]:[HOST]:[PORT]
42
+ ```
@@ -0,0 +1,14 @@
1
+ LICENSE.txt
2
+ README.md
3
+ pyproject.toml
4
+ src/uphy/__init__.py
5
+ src/uphy/controller/__init__.py
6
+ src/uphy/controller/__main__.py
7
+ src/uphy/controller/gui.py
8
+ src/uphy/controller/modbus.py
9
+ src/uphy_controller.egg-info/PKG-INFO
10
+ src/uphy_controller.egg-info/SOURCES.txt
11
+ src/uphy_controller.egg-info/dependency_links.txt
12
+ src/uphy_controller.egg-info/entry_points.txt
13
+ src/uphy_controller.egg-info/requires.txt
14
+ src/uphy_controller.egg-info/top_level.txt
@@ -0,0 +1,5 @@
1
+ [console_scripts]
2
+ uphy-controller = uphy.controller.__main__:app
3
+
4
+ [uphy.cli]
5
+ controller = uphy.controller.__main__:app
@@ -0,0 +1,5 @@
1
+ dearpygui>=2.0.0
2
+ pymodbus>=3.7.3
3
+ rich>=13.9.2
4
+ typer>=0.12.5
5
+ zeroconf>=0.135.0