pyfactoryio 0.1.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.
- pyfactoryio/__init__.py +2 -0
- pyfactoryio/gui.py +97 -0
- pyfactoryio/server.py +145 -0
- pyfactoryio-0.1.0.dist-info/METADATA +87 -0
- pyfactoryio-0.1.0.dist-info/RECORD +7 -0
- pyfactoryio-0.1.0.dist-info/WHEEL +5 -0
- pyfactoryio-0.1.0.dist-info/top_level.txt +1 -0
pyfactoryio/__init__.py
ADDED
pyfactoryio/gui.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import tkinter as tk
|
|
2
|
+
from pyModbusTCP.server import ModbusServer
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class FactoryIOGUI:
|
|
6
|
+
def __init__(self, master, server, di_names=None, do_names=None, ri_names=None, ro_names=None):
|
|
7
|
+
self.master = master
|
|
8
|
+
self.master.title("Factory I/O Modbus Server Interface")
|
|
9
|
+
self.master.geometry("800x600")
|
|
10
|
+
self.master.configure(padx=20, pady=20)
|
|
11
|
+
self.server = server
|
|
12
|
+
|
|
13
|
+
di_count = len(di_names) if di_names else 0
|
|
14
|
+
do_count = len(do_names) if do_names else 0
|
|
15
|
+
ri_count = len(ri_names) if ri_names else 0
|
|
16
|
+
ro_count = len(ro_names) if ro_names else 0
|
|
17
|
+
|
|
18
|
+
self.frame_di = tk.LabelFrame(master, text="Digital Inputs (Sensors)", padx=10, pady=10)
|
|
19
|
+
self.frame_do = tk.LabelFrame(master, text="Digital Outputs (Actuators)", padx=10, pady=10)
|
|
20
|
+
self.frame_ri = tk.LabelFrame(master, text="Register Inputs (Sensors)", padx=10, pady=10)
|
|
21
|
+
self.frame_ro = tk.LabelFrame(master, text="Register Outputs (Actuators)", padx=10, pady=10)
|
|
22
|
+
|
|
23
|
+
cols = sum(1 for c in [di_count, do_count, ri_count, ro_count] if c > 0)
|
|
24
|
+
col = 0
|
|
25
|
+
if di_count:
|
|
26
|
+
self.frame_di.grid(row=0, column=col, padx=10, pady=10, sticky="n")
|
|
27
|
+
col += 1
|
|
28
|
+
if do_count:
|
|
29
|
+
self.frame_do.grid(row=0, column=col, padx=10, pady=10, sticky="n")
|
|
30
|
+
col += 1
|
|
31
|
+
if ri_count:
|
|
32
|
+
self.frame_ri.grid(row=0, column=col, padx=10, pady=10, sticky="n")
|
|
33
|
+
col += 1
|
|
34
|
+
if ro_count:
|
|
35
|
+
self.frame_ro.grid(row=0, column=col, padx=10, pady=10, sticky="n")
|
|
36
|
+
|
|
37
|
+
self.di_labels = []
|
|
38
|
+
for i, name in enumerate(di_names or []):
|
|
39
|
+
lbl = tk.Label(self.frame_di, text=name, bg="gray", fg="white", width=20)
|
|
40
|
+
lbl.grid(row=i, column=0, pady=2)
|
|
41
|
+
self.di_labels.append(lbl)
|
|
42
|
+
|
|
43
|
+
self.do_vars = []
|
|
44
|
+
for i, name in enumerate(do_names or []):
|
|
45
|
+
var = tk.IntVar()
|
|
46
|
+
chk = tk.Checkbutton(self.frame_do, text=name, variable=var,
|
|
47
|
+
command=lambda i=i, v=var: self.write_digital_output(i, v))
|
|
48
|
+
chk.grid(row=i, column=0, pady=2, sticky="w")
|
|
49
|
+
self.do_vars.append(var)
|
|
50
|
+
|
|
51
|
+
self.ri_labels = []
|
|
52
|
+
for i, name in enumerate(ri_names or []):
|
|
53
|
+
lbl = tk.Label(self.frame_ri, text=f"{name}: 0", font=("Arial", 10, "bold"), width=20, anchor="w")
|
|
54
|
+
lbl.grid(row=i, column=0, pady=2)
|
|
55
|
+
self.ri_labels.append(lbl)
|
|
56
|
+
|
|
57
|
+
self.ro_entries = []
|
|
58
|
+
for i, name in enumerate(ro_names or []):
|
|
59
|
+
frame = tk.Frame(self.frame_ro)
|
|
60
|
+
frame.grid(row=i, column=0, pady=2)
|
|
61
|
+
tk.Label(frame, text=f"{name}: ").pack(side=tk.LEFT)
|
|
62
|
+
entry = tk.Entry(frame, width=10)
|
|
63
|
+
entry.insert(0, "0")
|
|
64
|
+
entry.pack(side=tk.LEFT)
|
|
65
|
+
entry.bind("<Return>", lambda event, i=i, e=entry: self.write_register_output(i, e))
|
|
66
|
+
self.ro_entries.append(entry)
|
|
67
|
+
|
|
68
|
+
self.update_gui_from_server()
|
|
69
|
+
|
|
70
|
+
def update_gui_from_server(self):
|
|
71
|
+
di_data = self.server.get_digital_inputs()
|
|
72
|
+
for i, lbl in enumerate(self.di_labels):
|
|
73
|
+
if i < len(di_data):
|
|
74
|
+
color = "green" if di_data[i] else "gray"
|
|
75
|
+
lbl.config(bg=color)
|
|
76
|
+
|
|
77
|
+
ri_data = self.server.get_register_inputs()
|
|
78
|
+
for i, lbl in enumerate(self.ri_labels):
|
|
79
|
+
if i < len(ri_data):
|
|
80
|
+
lbl.config(text=f"{self.ri_labels[i].cget('text').split(':')[0]}: {ri_data[i]}")
|
|
81
|
+
|
|
82
|
+
self.master.after(50, self.update_gui_from_server)
|
|
83
|
+
|
|
84
|
+
def write_digital_output(self, index, var):
|
|
85
|
+
val = bool(var.get())
|
|
86
|
+
self.server.set_digital_output(index, val)
|
|
87
|
+
|
|
88
|
+
def write_register_output(self, index, entry):
|
|
89
|
+
try:
|
|
90
|
+
val = int(entry.get())
|
|
91
|
+
self.server.set_register_output(index, val)
|
|
92
|
+
except ValueError:
|
|
93
|
+
print("Please enter a valid integer.")
|
|
94
|
+
|
|
95
|
+
def on_closing(self):
|
|
96
|
+
self.server.stop()
|
|
97
|
+
self.master.destroy()
|
pyfactoryio/server.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from pyModbusTCP.server import ModbusServer
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class FactoryIOServer:
|
|
5
|
+
def __init__(self, host="127.0.0.1", port=5020, di_names=None, do_names=None, ri_names=None, ro_names=None):
|
|
6
|
+
self.host = host
|
|
7
|
+
self.port = port
|
|
8
|
+
self.server = ModbusServer(host=host, port=port, no_block=True)
|
|
9
|
+
self.di_offset = 0
|
|
10
|
+
self.do_offset = 16
|
|
11
|
+
self.ri_offset = 0
|
|
12
|
+
self.ro_offset = 8
|
|
13
|
+
|
|
14
|
+
self.di_names = di_names or []
|
|
15
|
+
self.do_names = do_names or []
|
|
16
|
+
self.ri_names = ri_names or []
|
|
17
|
+
self.ro_names = ro_names or []
|
|
18
|
+
|
|
19
|
+
self._di_map = {name: i for i, name in enumerate(self.di_names)}
|
|
20
|
+
self._do_map = {name: i for i, name in enumerate(self.do_names)}
|
|
21
|
+
self._ri_map = {name: i for i, name in enumerate(self.ri_names)}
|
|
22
|
+
self._ro_map = {name: i for i, name in enumerate(self.ro_names)}
|
|
23
|
+
|
|
24
|
+
self._callbacks = {}
|
|
25
|
+
self._running = False
|
|
26
|
+
|
|
27
|
+
def on_change(self, name):
|
|
28
|
+
"""Decorator to register a callback when a variable changes state."""
|
|
29
|
+
def decorator(func):
|
|
30
|
+
if name not in self._callbacks:
|
|
31
|
+
self._callbacks[name] = []
|
|
32
|
+
self._callbacks[name].append(func)
|
|
33
|
+
return func
|
|
34
|
+
return decorator
|
|
35
|
+
|
|
36
|
+
def run_loop(self, logic_func=None, hz=10):
|
|
37
|
+
"""Runs a continuous control loop, executing logic_func and monitoring events."""
|
|
38
|
+
import time
|
|
39
|
+
if not self.server.is_run:
|
|
40
|
+
self.start()
|
|
41
|
+
|
|
42
|
+
self._running = True
|
|
43
|
+
delay = 1.0 / hz
|
|
44
|
+
|
|
45
|
+
# Initialize previous states for all mapped variables
|
|
46
|
+
prev_state = {}
|
|
47
|
+
all_names = self.di_names + self.ri_names + self.do_names + self.ro_names
|
|
48
|
+
for name in all_names:
|
|
49
|
+
prev_state[name] = getattr(self, name)
|
|
50
|
+
|
|
51
|
+
print(f"Starting control loop at {hz}Hz. Press Ctrl+C to stop.")
|
|
52
|
+
try:
|
|
53
|
+
while self._running:
|
|
54
|
+
# 1. Detect changes and fire callbacks
|
|
55
|
+
for name in all_names:
|
|
56
|
+
current_val = getattr(self, name)
|
|
57
|
+
if current_val != prev_state[name]:
|
|
58
|
+
prev_state[name] = current_val
|
|
59
|
+
for cb in self._callbacks.get(name, []):
|
|
60
|
+
cb(current_val)
|
|
61
|
+
|
|
62
|
+
# 2. Run user logic
|
|
63
|
+
if logic_func:
|
|
64
|
+
logic_func(self)
|
|
65
|
+
|
|
66
|
+
time.sleep(delay)
|
|
67
|
+
except KeyboardInterrupt:
|
|
68
|
+
print("Loop interrupted by user.")
|
|
69
|
+
finally:
|
|
70
|
+
self._running = False
|
|
71
|
+
self.stop()
|
|
72
|
+
|
|
73
|
+
def __getattr__(self, name):
|
|
74
|
+
if name in self.__dict__.get('_di_map', {}):
|
|
75
|
+
index = self._di_map[name]
|
|
76
|
+
data = self.server.data_bank.get_coils(self.di_offset + index, 1)
|
|
77
|
+
return bool(data[0]) if data else False
|
|
78
|
+
|
|
79
|
+
if name in self.__dict__.get('_ri_map', {}):
|
|
80
|
+
index = self._ri_map[name]
|
|
81
|
+
data = self.server.data_bank.get_holding_registers(self.ri_offset + index, 1)
|
|
82
|
+
if data:
|
|
83
|
+
val = data[0]
|
|
84
|
+
return val - 65536 if val > 32767 else val
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
if name in self.__dict__.get('_do_map', {}):
|
|
88
|
+
index = self._do_map[name]
|
|
89
|
+
data = self.server.data_bank.get_discrete_inputs(self.do_offset + index, 1)
|
|
90
|
+
return bool(data[0]) if data else False
|
|
91
|
+
|
|
92
|
+
if name in self.__dict__.get('_ro_map', {}):
|
|
93
|
+
index = self._ro_map[name]
|
|
94
|
+
data = self.server.data_bank.get_input_registers(self.ro_offset + index, 1)
|
|
95
|
+
if data:
|
|
96
|
+
val = data[0]
|
|
97
|
+
return val - 65536 if val > 32767 else val
|
|
98
|
+
return 0
|
|
99
|
+
|
|
100
|
+
raise AttributeError(f"'FactoryIOServer' object has no attribute '{name}'")
|
|
101
|
+
|
|
102
|
+
def __setattr__(self, name, value):
|
|
103
|
+
if hasattr(self, '_do_map') and name in self._do_map:
|
|
104
|
+
index = self._do_map[name]
|
|
105
|
+
self.set_digital_output(index, value)
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
if hasattr(self, '_ro_map') and name in self._ro_map:
|
|
109
|
+
index = self._ro_map[name]
|
|
110
|
+
self.set_register_output(index, value)
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
if hasattr(self, '_di_map') and name in self._di_map:
|
|
114
|
+
raise AttributeError(f"Cannot set digital input '{name}', it is read-only (written by Factory I/O).")
|
|
115
|
+
|
|
116
|
+
if hasattr(self, '_ri_map') and name in self._ri_map:
|
|
117
|
+
raise AttributeError(f"Cannot set register input '{name}', it is read-only (written by Factory I/O).")
|
|
118
|
+
|
|
119
|
+
super().__setattr__(name, value)
|
|
120
|
+
|
|
121
|
+
def start(self):
|
|
122
|
+
self.server.start()
|
|
123
|
+
print(f"Modbus Server Started on {self.host}:{self.port}")
|
|
124
|
+
|
|
125
|
+
def stop(self):
|
|
126
|
+
self.server.stop()
|
|
127
|
+
|
|
128
|
+
def get_digital_inputs(self):
|
|
129
|
+
data = self.server.data_bank.get_coils(self.di_offset, 16)
|
|
130
|
+
return list(data) if data else [False] * 16
|
|
131
|
+
|
|
132
|
+
def get_register_inputs(self):
|
|
133
|
+
data = self.server.data_bank.get_holding_registers(self.ri_offset, 8)
|
|
134
|
+
if data:
|
|
135
|
+
return [val - 65536 if val > 32767 else val for val in data]
|
|
136
|
+
return [0] * 8
|
|
137
|
+
|
|
138
|
+
def set_digital_output(self, index, value):
|
|
139
|
+
self.server.data_bank.set_discrete_inputs(self.do_offset + index, [bool(value)])
|
|
140
|
+
|
|
141
|
+
def set_register_output(self, index, value):
|
|
142
|
+
if not (-32768 <= value <= 32767):
|
|
143
|
+
raise ValueError("Value must be between -32768 and 32767")
|
|
144
|
+
unsigned_val = value & 0xFFFF
|
|
145
|
+
self.server.data_bank.set_input_registers(self.ro_offset + index, [unsigned_val])
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyfactoryio
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Factory IO Modbus Interface
|
|
5
|
+
Author: Developer
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Requires-Python: >=3.8
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: pyModbusTCP
|
|
12
|
+
|
|
13
|
+
# PyFactoryIO
|
|
14
|
+
|
|
15
|
+
PyFactoryIO is a Python library that provides a high-level, modular Modbus interface for interacting with [Factory I/O](https://factoryio.com/). It allows you to easily connect your Python control logic to Factory I/O simulations, abstracting away the low-level Modbus TCP communication and mapping digital/register inputs and outputs by name.
|
|
16
|
+
|
|
17
|
+
## Features
|
|
18
|
+
|
|
19
|
+
- **Easy Configuration:** Map Factory I/O sensors and actuators using descriptive names instead of Modbus addresses.
|
|
20
|
+
- **Event-Driven & Loop-Based Execution:** Provides decorators to run callbacks on state changes, or a continuous control loop for cyclic execution.
|
|
21
|
+
- **Auto-Mapping:** Automatically handles Modbus coils, discrete inputs, holding registers, and input registers.
|
|
22
|
+
- **Lightweight:** Built on top of `pyModbusTCP`.
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
You can install `pyfactoryio` directly via pip:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install pyfactoryio
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Quick Start
|
|
33
|
+
|
|
34
|
+
### 1. Configure Factory I/O
|
|
35
|
+
|
|
36
|
+
Ensure Factory I/O is configured to use the **Modbus TCP Server** driver. By default, PyFactoryIO listens on `127.0.0.1` port `5020`.
|
|
37
|
+
|
|
38
|
+
### 2. Basic Example
|
|
39
|
+
|
|
40
|
+
Here is a simple example demonstrating how to read a sensor and trigger an actuator.
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from pyfactoryio import FactoryIOServer
|
|
44
|
+
|
|
45
|
+
# Define your input and output names in the order they appear in Factory I/O
|
|
46
|
+
DI_NAMES = ["Sensor_1", "Start_Button"]
|
|
47
|
+
DO_NAMES = ["Conveyor", "Warning_Light"]
|
|
48
|
+
|
|
49
|
+
# Initialize the server
|
|
50
|
+
server = FactoryIOServer(
|
|
51
|
+
di_names=DI_NAMES,
|
|
52
|
+
do_names=DO_NAMES
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Example 1: Event-driven callback
|
|
56
|
+
@server.on_change("Start_Button")
|
|
57
|
+
def on_start_pressed(value):
|
|
58
|
+
if value:
|
|
59
|
+
print("Start button pressed!")
|
|
60
|
+
server.Conveyor = True
|
|
61
|
+
else:
|
|
62
|
+
print("Start button released!")
|
|
63
|
+
server.Conveyor = False
|
|
64
|
+
|
|
65
|
+
# Example 2: Continuous control loop
|
|
66
|
+
def my_logic(srv):
|
|
67
|
+
# This function is called continuously by run_loop
|
|
68
|
+
if srv.Sensor_1:
|
|
69
|
+
srv.Warning_Light = True
|
|
70
|
+
else:
|
|
71
|
+
srv.Warning_Light = False
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__":
|
|
74
|
+
# Start the event loop (e.g., at 10Hz)
|
|
75
|
+
server.run_loop(logic_func=my_logic, hz=10)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Supported Variable Types
|
|
79
|
+
|
|
80
|
+
- **di_names:** Digital Inputs (Sensors, Buttons) - Read from Factory I/O via Coils.
|
|
81
|
+
- **do_names:** Digital Outputs (Conveyors, Lights) - Written to Factory I/O via Discrete Inputs.
|
|
82
|
+
- **ri_names:** Register Inputs (Analog Sensors) - Read from Factory I/O via Holding Registers.
|
|
83
|
+
- **ro_names:** Register Outputs (Analog Actuators) - Written to Factory I/O via Input Registers.
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
This project is licensed under the MIT License.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
pyfactoryio/__init__.py,sha256=7bSXSo4WPN_Vc7cTZZZvTAGck6eyvmz7QgzCgg73u0w,88
|
|
2
|
+
pyfactoryio/gui.py,sha256=t6yNThBlyqkff5764EiZED_bSBSIJNunIh-NmneCHec,4000
|
|
3
|
+
pyfactoryio/server.py,sha256=CoyZi4WNkR2xMCJ19-5WW_BzY7Y9eB-jSZjpAgnqR-w,5678
|
|
4
|
+
pyfactoryio-0.1.0.dist-info/METADATA,sha256=SHgtpHJ-HKH7ohX1MdkBb033oYbJMaOwfgjCzARocp4,2915
|
|
5
|
+
pyfactoryio-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
6
|
+
pyfactoryio-0.1.0.dist-info/top_level.txt,sha256=wHw13rUek7LEac6eHGzkNIwjclrUIpKhT7NuFSSNE6w,12
|
|
7
|
+
pyfactoryio-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pyfactoryio
|