PyVLCB 0.1.1__tar.gz → 0.1.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyVLCB
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: A library for CBUS / VLCB communication
5
5
  Author-email: "Stewart Watkiss (penguintutor)" <5587863+penguintutor@users.noreply.github.com>
6
6
  License: MIT License
@@ -35,30 +35,41 @@ License-File: LICENSE
35
35
  Requires-Dist: pyserial>=3.4
36
36
  Dynamic: license-file
37
37
 
38
- # PyVLCB Python Software Library for CBUS / VLCB
38
+ # PyVLCB Python Software Library for VLCB
39
39
 
40
- This is a small software library for CBUS and VLCB using Python.
40
+ This is a Python library for VLCB (Versatile Layout Control Bus). It implements the core
41
+ communication protocols defined by the CBUS® protocol and includes extensions for the newer
42
+ VLCB specifications.
41
43
 
42
- It's designed for use with the MERG CANUSB4 CBUS adapter.
44
+ It is designed for use with the MERG CANUSB4 CBUS adapter and other compatible hardware.
43
45
 
46
+ ---
44
47
 
45
48
  ## Install
46
49
 
47
50
 
48
51
  For most systems including a Raspberry Pi then Python is managed using virtual environment.
49
- To setup a virtual environment enter the following commands
50
52
 
51
- mkdir ~/venv
52
- python3 -m venv ~/venv/pyvlcb --system-site-packages
53
+ **Setup a virtual environment:**
53
54
 
54
- You will then need to run the following command to activate the virtual environment
55
+ ```bash
56
+ mkdir ~/venv
57
+ python3 -m venv ~/venv/pyvlcb --system-site-packages
58
+ ```
55
59
 
56
- source ~/venv/pyvlcb/bin/activate
60
+ **Activate the virtual environment:**
61
+
62
+ ```bash
63
+ source ~/venv/pyvlcb/bin/activate
64
+ ```
57
65
 
58
- To instally the latest release use
66
+ **Install the latest release:**
59
67
 
60
- pip install pyvlcb
68
+ ```bash
69
+ pip install pyvlcb
70
+ ```
61
71
 
72
+ ---
62
73
 
63
74
  ## Development
64
75
 
@@ -69,12 +80,14 @@ The latest source code is available from GitHub. [PyVLCB on GitHub](https://gith
69
80
 
70
81
  To install the library, as a submodule in your own git project repository, then enter your project directory and run:
71
82
 
72
- git submodule add https://github.com/penguintutor/pyvlcb.git lib/pyvlcb
73
- pip install lib/pyvlcb
83
+ ```bash
84
+ git submodule add https://github.com/penguintutor/pyvlcb.git lib/pyvlcb
85
+ pip install lib/pyvlcb
86
+ ```
74
87
 
75
- If you subsequently want to get the latest version of the library run a git pull then use the pip install command again.
88
+ *Note: To use as a submodule, you must create your own git project first and install within the project folder. To update, run `git pull` and repeat the pip install command.*
76
89
 
77
- _Important_ To use as a submodule you must create your own git project first and install within the project folder.
90
+ ---
78
91
 
79
92
  ## Library Reference
80
93
 
@@ -84,12 +97,21 @@ See the link below for the library reference documentation
84
97
 
85
98
  ## Demo examples
86
99
 
87
- Example code is stored within the demo folder available from GitHub. Copy these into your project folder to test the library and connectivity.
88
- The demos are created for a Raspberry Pi or other Linux computer. The USB port is hard-coded as /dev/ttyACM0
89
- For other computers / USB ports then edit the python file directory and update the port statement.
100
+ Example code is stored within the demo folder available on GitHub. Copy these into your project folder to test the library and connectivity.
101
+
102
+ * The demos are created for a Raspberry Pi or other Linux computers.
103
+ * The USB port is hard-coded as /dev/ttyACM0.
104
+ * For other computers or USB ports, edit the Python file and update the port statement.Example code is stored within the demo folder available from GitHub. Copy these into your project folder to test the library and connectivity.
90
105
 
91
106
  ## More Details
92
107
 
93
- For more details see: [PenguinTutor PyVLCB library page](https://www.penguintutor.com/projects/pyvlcb)
108
+ * Project page: [PenguinTutor PyVLCB library page](https://www.penguintutor.com/projects/pyvlcb)
109
+ * Application example: [PenguinTutor Pi SignalBox project page](https://www.penguintutor.com/projects/pisignalbox)
110
+
111
+ ## Legal and Trademarks
112
+
113
+ * **CBUS®** is a registered trademark of Dr. Michael Bolton.
114
+ * This library is an independent implementation based on publicly available protocol documentation and is not officially affiliated with or endorsed by the trademark holder.
115
+ * All other trademarks are the property of their respective owners.
116
+
94
117
 
95
- For an example of a program using this library see the [PenguinTutor Pi SignalBox project page](https://www.penguintutor.com/projects/pisignalbox)
pyvlcb-0.1.3/README.md ADDED
@@ -0,0 +1,80 @@
1
+ # PyVLCB Python Software Library for VLCB
2
+
3
+ This is a Python library for VLCB (Versatile Layout Control Bus). It implements the core
4
+ communication protocols defined by the CBUS® protocol and includes extensions for the newer
5
+ VLCB specifications.
6
+
7
+ It is designed for use with the MERG CANUSB4 CBUS adapter and other compatible hardware.
8
+
9
+ ---
10
+
11
+ ## Install
12
+
13
+
14
+ For most systems including a Raspberry Pi then Python is managed using virtual environment.
15
+
16
+ **Setup a virtual environment:**
17
+
18
+ ```bash
19
+ mkdir ~/venv
20
+ python3 -m venv ~/venv/pyvlcb --system-site-packages
21
+ ```
22
+
23
+ **Activate the virtual environment:**
24
+
25
+ ```bash
26
+ source ~/venv/pyvlcb/bin/activate
27
+ ```
28
+
29
+ **Install the latest release:**
30
+
31
+ ```bash
32
+ pip install pyvlcb
33
+ ```
34
+
35
+ ---
36
+
37
+ ## Development
38
+
39
+ The latest source code is available from GitHub. [PyVLCB on GitHub](https://github.com/penguintutor/pyvlcb).
40
+
41
+
42
+ ### Installing as a git submodule
43
+
44
+ To install the library, as a submodule in your own git project repository, then enter your project directory and run:
45
+
46
+ ```bash
47
+ git submodule add https://github.com/penguintutor/pyvlcb.git lib/pyvlcb
48
+ pip install lib/pyvlcb
49
+ ```
50
+
51
+ *Note: To use as a submodule, you must create your own git project first and install within the project folder. To update, run `git pull` and repeat the pip install command.*
52
+
53
+ ---
54
+
55
+ ## Library Reference
56
+
57
+ See the link below for the library reference documentation
58
+ [PyVLCB Library Reference Documentation](https://penguintutor.github.io/pyvlcb/reference/)
59
+
60
+
61
+ ## Demo examples
62
+
63
+ Example code is stored within the demo folder available on GitHub. Copy these into your project folder to test the library and connectivity.
64
+
65
+ * The demos are created for a Raspberry Pi or other Linux computers.
66
+ * The USB port is hard-coded as /dev/ttyACM0.
67
+ * For other computers or USB ports, edit the Python file and update the port statement.Example code is stored within the demo folder available from GitHub. Copy these into your project folder to test the library and connectivity.
68
+
69
+ ## More Details
70
+
71
+ * Project page: [PenguinTutor PyVLCB library page](https://www.penguintutor.com/projects/pyvlcb)
72
+ * Application example: [PenguinTutor Pi SignalBox project page](https://www.penguintutor.com/projects/pisignalbox)
73
+
74
+ ## Legal and Trademarks
75
+
76
+ * **CBUS®** is a registered trademark of Dr. Michael Bolton.
77
+ * This library is an independent implementation based on publicly available protocol documentation and is not officially affiliated with or endorsed by the trademark holder.
78
+ * All other trademarks are the property of their respective owners.
79
+
80
+
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "PyVLCB"
7
- version = "0.1.1"
7
+ version = "0.1.3"
8
8
  description = "A library for CBUS / VLCB communication"
9
9
  readme = {file="README.md", content-type = "text/markdown"}
10
10
  requires-python = ">=3.6"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyVLCB
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: A library for CBUS / VLCB communication
5
5
  Author-email: "Stewart Watkiss (penguintutor)" <5587863+penguintutor@users.noreply.github.com>
6
6
  License: MIT License
@@ -35,30 +35,41 @@ License-File: LICENSE
35
35
  Requires-Dist: pyserial>=3.4
36
36
  Dynamic: license-file
37
37
 
38
- # PyVLCB Python Software Library for CBUS / VLCB
38
+ # PyVLCB Python Software Library for VLCB
39
39
 
40
- This is a small software library for CBUS and VLCB using Python.
40
+ This is a Python library for VLCB (Versatile Layout Control Bus). It implements the core
41
+ communication protocols defined by the CBUS® protocol and includes extensions for the newer
42
+ VLCB specifications.
41
43
 
42
- It's designed for use with the MERG CANUSB4 CBUS adapter.
44
+ It is designed for use with the MERG CANUSB4 CBUS adapter and other compatible hardware.
43
45
 
46
+ ---
44
47
 
45
48
  ## Install
46
49
 
47
50
 
48
51
  For most systems including a Raspberry Pi then Python is managed using virtual environment.
49
- To setup a virtual environment enter the following commands
50
52
 
51
- mkdir ~/venv
52
- python3 -m venv ~/venv/pyvlcb --system-site-packages
53
+ **Setup a virtual environment:**
53
54
 
54
- You will then need to run the following command to activate the virtual environment
55
+ ```bash
56
+ mkdir ~/venv
57
+ python3 -m venv ~/venv/pyvlcb --system-site-packages
58
+ ```
55
59
 
56
- source ~/venv/pyvlcb/bin/activate
60
+ **Activate the virtual environment:**
61
+
62
+ ```bash
63
+ source ~/venv/pyvlcb/bin/activate
64
+ ```
57
65
 
58
- To instally the latest release use
66
+ **Install the latest release:**
59
67
 
60
- pip install pyvlcb
68
+ ```bash
69
+ pip install pyvlcb
70
+ ```
61
71
 
72
+ ---
62
73
 
63
74
  ## Development
64
75
 
@@ -69,12 +80,14 @@ The latest source code is available from GitHub. [PyVLCB on GitHub](https://gith
69
80
 
70
81
  To install the library, as a submodule in your own git project repository, then enter your project directory and run:
71
82
 
72
- git submodule add https://github.com/penguintutor/pyvlcb.git lib/pyvlcb
73
- pip install lib/pyvlcb
83
+ ```bash
84
+ git submodule add https://github.com/penguintutor/pyvlcb.git lib/pyvlcb
85
+ pip install lib/pyvlcb
86
+ ```
74
87
 
75
- If you subsequently want to get the latest version of the library run a git pull then use the pip install command again.
88
+ *Note: To use as a submodule, you must create your own git project first and install within the project folder. To update, run `git pull` and repeat the pip install command.*
76
89
 
77
- _Important_ To use as a submodule you must create your own git project first and install within the project folder.
90
+ ---
78
91
 
79
92
  ## Library Reference
80
93
 
@@ -84,12 +97,21 @@ See the link below for the library reference documentation
84
97
 
85
98
  ## Demo examples
86
99
 
87
- Example code is stored within the demo folder available from GitHub. Copy these into your project folder to test the library and connectivity.
88
- The demos are created for a Raspberry Pi or other Linux computer. The USB port is hard-coded as /dev/ttyACM0
89
- For other computers / USB ports then edit the python file directory and update the port statement.
100
+ Example code is stored within the demo folder available on GitHub. Copy these into your project folder to test the library and connectivity.
101
+
102
+ * The demos are created for a Raspberry Pi or other Linux computers.
103
+ * The USB port is hard-coded as /dev/ttyACM0.
104
+ * For other computers or USB ports, edit the Python file and update the port statement.Example code is stored within the demo folder available from GitHub. Copy these into your project folder to test the library and connectivity.
90
105
 
91
106
  ## More Details
92
107
 
93
- For more details see: [PenguinTutor PyVLCB library page](https://www.penguintutor.com/projects/pyvlcb)
108
+ * Project page: [PenguinTutor PyVLCB library page](https://www.penguintutor.com/projects/pyvlcb)
109
+ * Application example: [PenguinTutor Pi SignalBox project page](https://www.penguintutor.com/projects/pisignalbox)
110
+
111
+ ## Legal and Trademarks
112
+
113
+ * **CBUS®** is a registered trademark of Dr. Michael Bolton.
114
+ * This library is an independent implementation based on publicly available protocol documentation and is not officially affiliated with or endorsed by the trademark holder.
115
+ * All other trademarks are the property of their respective owners.
116
+
94
117
 
95
- For an example of a program using this library see the [PenguinTutor Pi SignalBox project page](https://www.penguintutor.com/projects/pisignalbox)
@@ -9,6 +9,7 @@ src/PyVLCB.egg-info/top_level.txt
9
9
  src/pyvlcb/__init__.py
10
10
  src/pyvlcb/canusb.py
11
11
  src/pyvlcb/exceptions.py
12
+ src/pyvlcb/utils.py
12
13
  src/pyvlcb/vlcbformat.py
13
14
  src/pyvlcb/tests/run_tests.py
14
15
  src/pyvlcb/tests/test_canusb4.py
@@ -3,6 +3,7 @@
3
3
 
4
4
  from .vlcbformat import VLCBFormat, VLCBOpcode
5
5
  from .canusb import CanUSB4
6
+ from .utils import num_to_1hexstr, num_to_2hexstr, num_to_4hexstr, f_to_bytes, dict_to_string
6
7
  from .exceptions import (
7
8
  MyLibraryError,
8
9
  DeviceConnectionError,
@@ -122,162 +123,17 @@ class VLCB:
122
123
  opcode = vlcb_entry.data[0:2]
123
124
  opcode_string = f'{opcode} - {VLCBOpcode.opcode_mnemonic(opcode)}'
124
125
  #data_string = f"{VLCBOpcode.parse_data(vlcb_entry.data)}"
125
- data_string = VLCB._dict_to_string(VLCBOpcode.parse_data(vlcb_entry.data))
126
+ data_string = dict_to_string(VLCBOpcode.parse_data(vlcb_entry.data))
126
127
  return [date_string, direction, message, str(vlcb_entry.can_id), opcode_string, data_string]
127
128
  # Todo - error handling
128
129
 
129
- # dict to string without {} or ""
130
- @staticmethod
131
- def _dict_to_string (dictionary: dict) -> str:
132
- """ Convert a dict to a string without {} or quotes """
133
- data_string = ""
134
- for key, value in dictionary.items():
135
- if data_string != "":
136
- data_string += " , "
137
- data_string += f"{key} = {value}"
138
- return data_string
139
-
140
- @staticmethod
141
- def f_to_bytes (f_num: int, function_status: List[int]) -> Tuple[bytes, bytes]:
142
- """ Convert a Fnumber (Loco function ID) to bytes
143
-
144
- Can be used to create correct format for loco_set_dfun
145
- Limited to range 0 to 28 - which is max for DFUN
146
- Higher would need to use DFNON/DFOFF
147
-
148
- Args:
149
- f_num: Function number
150
- function_status: List with value of all functions (must be updated before calling)
151
-
152
- Returns:
153
- Tuple of two bytes
154
-
155
- Raises: ValueError
156
-
157
- """
158
- # Must be an F number between 0 and 28
159
- if f_num < 0 or f_num > 28:
160
- raise ValueError (f"Fnumber needs to be between 0 and 28. Number provided {f_num}")
161
-
162
- # Extend function_status to 29 entries
163
- function_list = function_status + [0] * (29 - len(function_status))
164
-
165
- if f_num <= 4:
166
- byte1 = 1
167
- byte2 = (0b10000 * function_list[0] + # fn0 is higher nibble (bit 5)
168
- 0b0001 * function_list[1] +
169
- 0b0010 * function_list[2] +
170
- 0b0100 * function_list[3] +
171
- 0b1000 * function_list[4]
172
- )
173
- elif f_num <= 8:
174
- byte1 = 2
175
- byte2 = (0b0001 * function_list[5] +
176
- 0b0010 * function_list[6] +
177
- 0b0100 * function_list[7] +
178
- 0b1000 * function_list[8]
179
- )
180
- elif f_num <= 12:
181
- byte1 = 3
182
- byte2 = (0b0001 * function_list[9] +
183
- 0b0010 * function_list[10] +
184
- 0b0100 * function_list[11] +
185
- 0b1000 * function_list[12]
186
- )
187
- elif f_num <= 20:
188
- byte1 = 4
189
- byte2 = (0b0001 * function_list[13] +
190
- 0b0010 * function_list[14] +
191
- 0b0100 * function_list[15] +
192
- 0b1000 * function_list[16] +
193
- 0b10000 * function_list[17] +
194
- 0b100000 * function_list[18] +
195
- 0b1000000 * function_list[19] +
196
- 0b10000000 * function_list[20]
197
- )
198
- elif f_num <= 28:
199
- byte1 = 5
200
- byte2 = (0b0001 * function_list[21] +
201
- 0b0010 * function_list[22] +
202
- 0b0100 * function_list[23] +
203
- 0b1000 * function_list[24] +
204
- 0b10000 * function_list[25] +
205
- 0b100000 * function_list[26] +
206
- 0b1000000 * function_list[27] +
207
- 0b10000000 * function_list[28]
208
- )
209
- return (byte1, byte2)
210
130
 
131
+ # Static Methods moved to utils
132
+ #num_to_1hexstr = staticmethod(num_to_1hexstr)
133
+ #num_to_2hexstr = staticmethod(num_to_2hexstr)
134
+ #num_to_4hexstr = staticmethod(num_to_4hexstr)
211
135
 
212
- @staticmethod
213
- # Where 1 x bytes (2 chars)
214
- def num_to_1hexstr (num: int) -> str:
215
- """Convert number to a byte
216
-
217
- Args:
218
- num (int): Number to convert
219
-
220
- Returns:
221
- String: A hex representation of the number (2 chars)
222
- """
223
- return f"{hex(num).upper()[2:]:0>2}"
224
-
225
- @staticmethod
226
- # Where 2 x bytes (4 chars)
227
- def num_to_2hexstr (num: int) -> str:
228
- """Convert number to 2 bytes
229
-
230
- Args:
231
- num (int): Number to convert
232
-
233
- Returns:
234
- String: A hex representation of the number (4 chars)
235
- """
236
- return f"{hex(num).upper()[2:]:0>4}"
237
-
238
- @staticmethod
239
- # Where 4 x bytes (8 chars)
240
- def num_to_4hexstr (num: int) -> str:
241
- """Convert number to 4 bytes
242
-
243
- Args:
244
- num (int): Number to convert
245
-
246
- Returns:
247
- String: A hex representation of the number (8 chars)
248
- """
249
- return f"{hex(num).upper()[2:]:0>8}"
250
-
251
- @staticmethod
252
- # Where 2 bytes convert to addr id
253
- def bytes_to_addr (byte1: bytes, byte2: bytes) -> int:
254
- """Convert 2 byte values into an address id sring
255
-
256
- Args:
257
- byte1 (bytes): Most significant byte
258
- byte2 (bytes): Least significant byte
259
-
260
- Returns:
261
- Int: The address id value
262
- """
263
- msb = int(byte1)
264
- lsb = int(byte2)
265
- return ((msb << 8) + lsb)
266
-
267
- @staticmethod
268
- def bytes_to_hexstr (byte1: bytes, byte2: bytes) -> str:
269
- """Convert 2 bytes to a hex string
270
-
271
- Args:
272
- byte1 (bytes): Most significant byte
273
- byte2 (bytes): Least significant byte
274
136
 
275
- Returns:
276
- String: A hex representation of the number
277
- """
278
- return f"{hex(byte1).upper()[2:]:0>2}{hex(byte2).upper()[2:]:0>2}"
279
-
280
-
281
137
  # Create header using low priority and can_id (or self.can_id)
282
138
  # If opcode provided, but no priority then appropriate min code looked up
283
139
  # MajPri would be based on packet aging - needs to be managed outside of this
@@ -312,6 +168,7 @@ class VLCB:
312
168
  return header_string
313
169
  #return header_string.encode('utf-8')
314
170
 
171
+
315
172
  # Discover nodes
316
173
  def discover (self) -> str:
317
174
  """Create a discovery string
@@ -336,7 +193,7 @@ class VLCB:
336
193
  Returns:
337
194
  String: A string for the request
338
195
  """
339
- return f"{self.make_header(opcode='58')}58{VLCB.num_to_2hexstr(node_id)};"
196
+ return f"{self.make_header(opcode='58')}58{num_to_2hexstr(node_id)};"
340
197
 
341
198
  # Discover number of events available
342
199
  def discover_nevn (self, node_id: int) -> str:
@@ -350,7 +207,7 @@ class VLCB:
350
207
  Returns:
351
208
  String: A string for the request
352
209
  """
353
- return f"{self.make_header(opcode='56')}56{VLCB.num_to_2hexstr(node_id)};"
210
+ return f"{self.make_header(opcode='56')}56{num_to_2hexstr(node_id)};"
354
211
 
355
212
  # Discover stored events NERD
356
213
  def discover_nerd (self, node_id: int) -> str:
@@ -364,7 +221,7 @@ class VLCB:
364
221
  Returns:
365
222
  String: A string for the request
366
223
  """
367
- return f"{self.make_header(opcode='57')}57{VLCB.num_to_2hexstr(node_id)};"
224
+ return f"{self.make_header(opcode='57')}57{num_to_2hexstr(node_id)};"
368
225
 
369
226
  # Emergency stop all locos
370
227
  # RESTP
@@ -419,10 +276,10 @@ class VLCB:
419
276
  # Turn on
420
277
  if state == True or state == "on":
421
278
  # ASON
422
- return f"{self.make_header(opcode='98')}98{VLCB.num_to_2hexstr(node_id)}{VLCB.num_to_2hexstr(ev_id)};"
279
+ return f"{self.make_header(opcode='98')}98{num_to_2hexstr(node_id)}{num_to_2hexstr(ev_id)};"
423
280
  # Turn off = ASOFF
424
281
  else:
425
- return f"{self.make_header(opcode='99')}99{VLCB.num_to_2hexstr(node_id)}{VLCB.num_to_2hexstr(ev_id)};"
282
+ return f"{self.make_header(opcode='99')}99{num_to_2hexstr(node_id)}{num_to_2hexstr(ev_id)};"
426
283
 
427
284
  def accessory_long_command (self, node_id: int, ev_id: int, state: Union[str, bool]) -> str:
428
285
  """Create an accessory long command
@@ -440,10 +297,10 @@ class VLCB:
440
297
  # Turn on
441
298
  if state == True or state == "on":
442
299
  # ASON
443
- return f"{self.make_header(opcode='90')}90{VLCB.num_to_4hexstr(ev_id)};"
300
+ return f"{self.make_header(opcode='90')}90{num_to_4hexstr(ev_id)};"
444
301
  # Turn off = ASOFF
445
302
  else:
446
- return f"{self.make_header(opcode='91')}91{VLCB.num_to_4hexstr(ev_id)};"
303
+ return f"{self.make_header(opcode='91')}91{num_to_4hexstr(ev_id)};"
447
304
 
448
305
  # RLOC (Allocate loco) :SB040N40D446;
449
306
  # Short address upper address all zeros, only 6 bits of the lower byte are used (1 to 127) 0 is decoderless
@@ -468,7 +325,7 @@ class VLCB:
468
325
  raise ValueError ("Invalid short code. Loco ID {loco_id} is larger than 127")
469
326
  if long == True:
470
327
  loco_id = loco_id | 0xC000
471
- return f"{self.make_header(opcode='40')}40{VLCB.num_to_2hexstr(loco_id)};"
328
+ return f"{self.make_header(opcode='40')}40{num_to_2hexstr(loco_id)};"
472
329
 
473
330
  def release_loco (self, session_id: int) -> str:
474
331
  """Create a release loco request
@@ -481,7 +338,7 @@ class VLCB:
481
338
  Returns:
482
339
  String: A string for the request
483
340
  """
484
- return f"{self.make_header(opcode='21')}21{VLCB.num_to_1hexstr(session_id)};"
341
+ return f"{self.make_header(opcode='21')}21{num_to_1hexstr(session_id)};"
485
342
 
486
343
  def steal_loco (self, loco_id: int, long: Optional[bool] = True) -> str:
487
344
  """Create an steal loco request
@@ -504,7 +361,7 @@ class VLCB:
504
361
  raise InvalidLocoError(f"Invalid short code {loco_id}")
505
362
  if long == True:
506
363
  loco_id = loco_id | 0xC000
507
- return f"{self.make_header(opcode='61')}61{VLCB.num_to_2hexstr(loco_id)}01;"
364
+ return f"{self.make_header(opcode='61')}61{num_to_2hexstr(loco_id)}01;"
508
365
 
509
366
  def share_loco (self, loco_id: int, long: Optional[bool] = True) -> str:
510
367
  """Create an share loco request
@@ -527,7 +384,7 @@ class VLCB:
527
384
  raise InvalidLocoError(f"Invalid short code {loco_id}")
528
385
  if long == True:
529
386
  loco_id = loco_id | 0xC000
530
- return f"{self.make_header(opcode='61')}61{VLCB.num_to_2hexstr(loco_id)}02;"
387
+ return f"{self.make_header(opcode='61')}61{num_to_2hexstr(loco_id)}02;"
531
388
 
532
389
  def keep_alive (self, session_id: int) -> str:
533
390
  """Create an keep alive request
@@ -541,14 +398,44 @@ class VLCB:
541
398
  Returns:
542
399
  String: A string for the request
543
400
  """
544
- return f"{self.make_header(opcode='23')}23{VLCB.num_to_1hexstr(session_id)};"
401
+ return f"{self.make_header(opcode='23')}23{num_to_1hexstr(session_id)};"
402
+
403
+ def loco_speed_dir (self, session_id: int, speed: int, direction: int) -> str:
404
+ """Set loco speed and direction based on separate arguments
405
+
406
+ Same as loco_speeddir but this takes 2 arguments, whereas loco_speeddir needs a combined value
407
+ Maximum call this once every 32 milliseconds
408
+
409
+ Uses DSPD (47)
410
+
411
+ Args:
412
+ session_id: Session ID
413
+ speed: 0 to 127 (1 is increased to 2 to avoid emergency stop)
414
+ direction: 1 = forward, 0 = reverse
415
+
416
+ Returns:
417
+ String: A string for the request
418
+
419
+ Raises:
420
+ ValueError is speed is out of range, or invalid direction
421
+ """
422
+ if speed < 0 or speed > 127:
423
+ raise ValueError ("Invalid speed specified. Must be in range 0 to 127")
424
+ if direction <0 or direction > 1:
425
+ raise ValueError ("Direction is not valid. Use 1 for forward, 0 for reverse")
426
+ # special case - ignore emergency stop
427
+ if speed == 1:
428
+ speed = 2
429
+ speeddir = (direction * 0x80) + speed
430
+ return self.loco_speeddir (session_id, speeddir)
545
431
 
546
432
  # Set loco speed and direction (always done together)
547
- # Maximum once every 32 miliseconds (GUI configured based on non triggered so shouldn't be an issue)
548
- # Could add time detection if required
549
- # This uses the combined speed and direction value
550
433
  def loco_speeddir (self, session_id: int, speeddir: int) -> str:
551
- """Create a combined set speed and direction event
434
+ """Set loco speed and direction
435
+
436
+ Maximum call this once every 32 milliseconds
437
+ Needs combined speed and direction value.
438
+ If speed is set to 1 then that is considered an emergency stop
552
439
 
553
440
  Uses DSPD (47)
554
441
 
@@ -559,7 +446,7 @@ class VLCB:
559
446
  Returns:
560
447
  String: A string for the request
561
448
  """
562
- return f"{self.make_header(opcode='47')}47{VLCB.num_to_1hexstr(session_id)}{VLCB.num_to_1hexstr(speeddir)};"
449
+ return f"{self.make_header(opcode='47')}47{num_to_1hexstr(session_id)}{num_to_1hexstr(speeddir)};"
563
450
 
564
451
  # Set function using DFUN - needs to be provided with the two bytes
565
452
  # First byte is group (1 = F1 to F4, 2 = F5 to F8, 3 = F9 to F12)
@@ -579,6 +466,29 @@ class VLCB:
579
466
  Returns:
580
467
  String: A string for the request
581
468
  """
582
- return f"{self.make_header(opcode='60')}60{VLCB.num_to_1hexstr(session_id)}{VLCB.num_to_1hexstr(byte1)}{VLCB.num_to_1hexstr(byte2)};"
583
-
469
+ return f"{self.make_header(opcode='60')}60{num_to_1hexstr(session_id)}{num_to_1hexstr(byte1)}{num_to_1hexstr(byte2)};"
584
470
 
471
+ def loco_set_function (self, session_id: int, function_num, function_list) -> str:
472
+ """Create a set function request using the function list
473
+ Sends the entire group of functions where the function_num resides
474
+ This is an alternative to loco_set_dfun as this calculates the bytes
475
+ this method can only be used for functions 0 to 27
476
+
477
+ Uses DFUN (60)
478
+
479
+ Args:
480
+ session_id: Session ID
481
+ byte1: Function number
482
+ byte2: List of current function statuses
483
+
484
+ Returns:
485
+ String: A string for the request
486
+
487
+ Raises:
488
+ ValueError: Typically raised from f_to_bytes
489
+ """
490
+ byte1_2 = f_to_bytes(function_num, function_list)
491
+ return f"{self.make_header(opcode='60')}60{num_to_1hexstr(session_id)}{byte1_2[0]}{byte1_2[1]};"
492
+
493
+
494
+
@@ -22,7 +22,8 @@ class CanUSB4 ():
22
22
  def __init__ (self,
23
23
  port: str,
24
24
  baud: Optional[int] = 115200,
25
- timeout: Optional[float] = 0.01) -> None:
25
+ timeout: Optional[float] = 0.01,
26
+ exclusive: Optional[bool] = True) -> None:
26
27
  """Inits CanUSB4 with a USB port
27
28
 
28
29
  Args:
@@ -41,6 +42,10 @@ class CanUSB4 ():
41
42
  self.timeout = timeout
42
43
  self.max_retry = 30 # How many times to attempt on get_data must be at least as long as frame
43
44
  # Timeout for a request could be max_rety x timeout
45
+ self.exclusive = exclusive # Exclusive determines if check for exclusive use of the USB port
46
+ # If this is set to false then if another application is already using the port then the application
47
+ # will run, but appear to hang if there is no response because another thread has already
48
+ # taken the incoming data
44
49
 
45
50
  if not port:
46
51
  raise InvalidConfigurationError("Port name cannot be empty")
@@ -53,7 +58,11 @@ class CanUSB4 ():
53
58
 
54
59
 
55
60
  # Optional arguments override existing
56
- def connect(self, port=None, baud=None, timeout=None):
61
+ def connect(self,
62
+ port: Optional[str] = None,
63
+ baud: Optional[int] = None,
64
+ timeout: Optional[float] = None,
65
+ exclusive: Optional[bool] = None) -> None:
57
66
  """Inits CanUSB4 with a USB port
58
67
 
59
68
  Args:
@@ -74,8 +83,15 @@ class CanUSB4 ():
74
83
  self.baud = baud
75
84
  if timeout != None:
76
85
  self.timeout = timeout
86
+ if exclusive != None:
87
+ self.exclusive = exclusive
77
88
  try:
78
- self.ser = serial.Serial(self.port, self.baud, timeout=self.timeout)
89
+ self.ser = serial.Serial(
90
+ self.port,
91
+ self.baud,
92
+ timeout=self.timeout,
93
+ exclusive = exclusive
94
+ )
79
95
  except serial.SerialException as e:
80
96
  raise DeviceConnectionError(f"Could not open port {self.port}") from e
81
97
  if self.ser:
@@ -55,5 +55,14 @@ class InvalidLocoError(MyLibraryError):
55
55
  Raised for an invalid loco definition
56
56
  Includes if the ID is out of range
57
57
  or short code specified but code needs long code
58
+ or if the packet does not contain a loco_id (eg. certain Err codes)
59
+ """
60
+ pass
61
+
62
+ class InvalidFunctionError(MyLibraryError):
63
+ """
64
+ Raised for an invalid function requests
65
+ For example asking for function information
66
+ from a response which doesn't include the function information.
58
67
  """
59
68
  pass
@@ -34,7 +34,7 @@ class TestCanUSB4(unittest.TestCase):
34
34
  def test_init_valid(self):
35
35
  """Test that initialization opens the serial port with correct settings."""
36
36
  # Check if serial.Serial was called with correct args
37
- self.mock_serial_class.assert_called_with(self.port, 115200, timeout=0.01)
37
+ self.mock_serial_class.assert_called_with(self.port, 115200, timeout=0.01, exclusive=None)
38
38
 
39
39
  def test_init_empty_port(self):
40
40
  """Test that empty port raises InvalidConfigurationError."""
@@ -1,6 +1,8 @@
1
1
  import unittest
2
2
  from typing import Optional
3
3
  from pyvlcb import VLCB
4
+ # Import utils
5
+ from pyvlcb import num_to_1hexstr, num_to_2hexstr, num_to_4hexstr, f_to_bytes
4
6
  from pyvlcb.vlcbformat import VLCBFormat
5
7
 
6
8
  class TestVLCB(unittest.TestCase):
@@ -13,14 +15,14 @@ class TestVLCB(unittest.TestCase):
13
15
  def test_num_to_hex_conversions(self):
14
16
  """Test that numbers are correctly padded and converted to hex strings."""
15
17
  # Test 1 byte (2 chars)
16
- self.assertEqual(VLCB.num_to_1hexstr(10), "0A")
17
- self.assertEqual(VLCB.num_to_1hexstr(255), "FF")
18
+ self.assertEqual(num_to_1hexstr(10), "0A")
19
+ self.assertEqual(num_to_1hexstr(255), "FF")
18
20
 
19
21
  # Test 2 bytes (4 chars)
20
- self.assertEqual(VLCB.num_to_2hexstr(10), "000A")
22
+ self.assertEqual(num_to_2hexstr(10), "000A")
21
23
 
22
24
  # Test 4 bytes (8 chars)
23
- self.assertEqual(VLCB.num_to_4hexstr(10), "0000000A")
25
+ self.assertEqual(num_to_4hexstr(10), "0000000A")
24
26
 
25
27
  ## Tests for Packet Parsing
26
28
  def test_parse_input_valid_1(self):
@@ -86,10 +88,10 @@ class TestVLCB(unittest.TestCase):
86
88
  #f_to_bytes (f_num: int, function_status: List[int]) -> Tuple[bytes, bytes]
87
89
  def test_f_to_bytes(self):
88
90
  functions = [1, 1, 0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 1]
89
- self.assertEqual(self.vlcb.f_to_bytes (1, functions), (1, 25))
90
- self.assertEqual(self.vlcb.f_to_bytes (2, functions), (1, 25))
91
- self.assertEqual(self.vlcb.f_to_bytes (7, functions), (2, 6))
92
- self.assertEqual(self.vlcb.f_to_bytes (24, functions), (5, 0))
91
+ self.assertEqual(f_to_bytes (1, functions), ("01", "19"))
92
+ self.assertEqual(f_to_bytes (2, functions), ("01", "19"))
93
+ self.assertEqual(f_to_bytes (7, functions), ("02", "06"))
94
+ self.assertEqual(f_to_bytes (24, functions), ("05", "00"))
93
95
 
94
96
 
95
97
  if __name__ == "__main__":
@@ -0,0 +1,180 @@
1
+ """ Utils for use by other parts of the library """
2
+
3
+ from typing import Optional, Union, Tuple, List
4
+
5
+ # Where 1 x bytes (2 chars)
6
+ def num_to_1hexstr (num: int) -> str:
7
+ """Convert number to a byte
8
+
9
+ Args:
10
+ num (int): Number to convert
11
+
12
+ Returns:
13
+ String: A hex representation of the number (2 chars)
14
+ """
15
+ return f"{hex(num).upper()[2:]:0>2}"
16
+
17
+ # Where 2 x bytes (4 chars)
18
+ def num_to_2hexstr (num: int) -> str:
19
+ """Convert number to 2 bytes
20
+
21
+ Args:
22
+ num (int): Number to convert
23
+
24
+ Returns:
25
+ String: A hex representation of the number (4 chars)
26
+ """
27
+ return f"{hex(num).upper()[2:]:0>4}"
28
+
29
+ # Where 4 x bytes (8 chars)
30
+ def num_to_4hexstr (num: int) -> str:
31
+ """Convert number to 4 bytes
32
+
33
+ Args:
34
+ num (int): Number to convert
35
+
36
+ Returns:
37
+ String: A hex representation of the number (8 chars)
38
+ """
39
+ return f"{hex(num).upper()[2:]:0>8}"
40
+
41
+
42
+ # dict to string without {} or ""
43
+ def dict_to_string (dictionary: dict) -> str:
44
+ """ Convert a dict to a string without {} or quotes """
45
+ data_string = ""
46
+ for key, value in dictionary.items():
47
+ if data_string != "":
48
+ data_string += " , "
49
+ data_string += f"{key} = {value}"
50
+ return data_string
51
+
52
+ def f_to_bytes (f_num: int, function_status: List[int]) -> Tuple[str, str]:
53
+ """ Convert a Fnumber (Loco function ID) to bytes as string representation
54
+
55
+ Can be used to create correct format for loco_set_dfun
56
+ Limited to range 0 to 28 - which is max for DFUN
57
+ Higher would need to use DFNON/DFOFF
58
+
59
+ Args:
60
+ f_num: Function number
61
+ function_status: List with value of all functions (must be updated before calling)
62
+
63
+ Returns:
64
+ Tuple of two x 2-characer strings representing the bytes
65
+
66
+ Raises: ValueError
67
+
68
+ """
69
+ # Must be an F number between 0 and 28
70
+ if f_num < 0 or f_num > 28:
71
+ raise ValueError (f"Fnumber needs to be between 0 and 28. Number provided {f_num}")
72
+
73
+ # Extend function_status to 29 entries
74
+ function_list = function_status + [0] * (29 - len(function_status))
75
+
76
+ if f_num <= 4:
77
+ byte1 = 1
78
+ byte2 = (0b10000 * function_list[0] + # fn0 is higher nibble (bit 5)
79
+ 0b0001 * function_list[1] +
80
+ 0b0010 * function_list[2] +
81
+ 0b0100 * function_list[3] +
82
+ 0b1000 * function_list[4]
83
+ )
84
+ elif f_num <= 8:
85
+ byte1 = 2
86
+ byte2 = (0b0001 * function_list[5] +
87
+ 0b0010 * function_list[6] +
88
+ 0b0100 * function_list[7] +
89
+ 0b1000 * function_list[8]
90
+ )
91
+ elif f_num <= 12:
92
+ byte1 = 3
93
+ byte2 = (0b0001 * function_list[9] +
94
+ 0b0010 * function_list[10] +
95
+ 0b0100 * function_list[11] +
96
+ 0b1000 * function_list[12]
97
+ )
98
+ elif f_num <= 20:
99
+ byte1 = 4
100
+ byte2 = (0b0001 * function_list[13] +
101
+ 0b0010 * function_list[14] +
102
+ 0b0100 * function_list[15] +
103
+ 0b1000 * function_list[16] +
104
+ 0b10000 * function_list[17] +
105
+ 0b100000 * function_list[18] +
106
+ 0b1000000 * function_list[19] +
107
+ 0b10000000 * function_list[20]
108
+ )
109
+ elif f_num <= 28:
110
+ byte1 = 5
111
+ byte2 = (0b0001 * function_list[21] +
112
+ 0b0010 * function_list[22] +
113
+ 0b0100 * function_list[23] +
114
+ 0b1000 * function_list[24] +
115
+ 0b10000 * function_list[25] +
116
+ 0b100000 * function_list[26] +
117
+ 0b1000000 * function_list[27] +
118
+ 0b10000000 * function_list[28]
119
+ )
120
+ # convert to strings before returning
121
+ # and with 0xFF guarentees it doesn't overflow (although not really neccessary for this method)
122
+ str1 = f"{ (byte1 & 0xFF) :02x}"
123
+ str2 = f"{ (byte2 & 0xFF) :02x}"
124
+ return (str1, str2)
125
+
126
+
127
+ # Where 2 bytes convert to addr id
128
+ def bytes_to_addr (byte1: bytes, byte2: bytes) -> int:
129
+ """Convert 2 byte values into an address id sring
130
+
131
+ Args:
132
+ byte1 (bytes): Most significant byte
133
+ byte2 (bytes): Least significant byte
134
+
135
+ Returns:
136
+ Int: The address id value
137
+ """
138
+ msb = int(byte1)
139
+ lsb = int(byte2)
140
+ return ((msb << 8) + lsb)
141
+
142
+
143
+ def bytes_to_hexstr (byte1: bytes, byte2: bytes) -> str:
144
+ """Convert 2 bytes to a hex string
145
+
146
+ Args:
147
+ byte1 (bytes): Most significant byte
148
+ byte2 (bytes): Least significant byte
149
+
150
+ Returns:
151
+ String: A hex representation of the number
152
+ """
153
+ return f"{hex(byte1).upper()[2:]:0>2}{hex(byte2).upper()[2:]:0>2}"
154
+
155
+
156
+ def bytes_to_functions (fn1: bytes, fn2: bytes, fn3: bytes) -> List[int]:
157
+ """ Sets the value of the functions from a PLOC message
158
+ Only returns values for F0 to F12 (others not included in PLOC)
159
+ Provides as a list for inclusion in menus etc.
160
+
161
+ Args:
162
+ fn1: Function byte F0 to F4 - bit 5 = dir lighting (F0), bit 6 = direction, bit 7 = res, bit 8 = 0
163
+ fn2: Function byte F5 to F8 - bit 5 upwards reserved
164
+ fn3: Function byte F9 to F12
165
+
166
+ Returns:
167
+ List of function values as 0 or 1 for each function as off and on
168
+
169
+ """
170
+ data_in = [fn1, fn2, fn3]
171
+ mask = [0b0001, 0b0010, 0b0100, 0b1000]
172
+ function_status = [0] * 29
173
+ # Handle 0 separately as it's in the upper nibble
174
+ function_status[0] = data_in[0] & 0b10000
175
+ # Create a list of 12 entries
176
+ for i in range (0, 3):
177
+ for j in range (0, 4):
178
+ function_status[(i*4)+j+1] = 1 if (data_in[i] & mask[j]) > 0 else 0
179
+
180
+ return function_status
@@ -1,5 +1,6 @@
1
1
  import logging
2
2
  import warnings
3
+ from .utils import bytes_to_addr, bytes_to_functions
3
4
  from typing import List, Optional, Union, Dict, Any
4
5
 
5
6
  # Set up a null handler so nothing prints by default unless the user enables it
@@ -49,7 +50,18 @@ class VLCBFormat :
49
50
  return VLCBOpcode.opcodes[str_value]['opc']
50
51
  else:
51
52
  raise ValueError(f"Opcode {str_value} is not defined.")
52
-
53
+
54
+ def get_data (self) -> OpcodeData:
55
+ """Returns the opcode associated with the data string as a dict
56
+
57
+ Returns:
58
+ OpcodeData: Dict from the VLCBOpcode
59
+
60
+ Raises:
61
+ ValueError: If opcode not found
62
+ """
63
+ return VLCBOpcode.parse_data(self.data)
64
+
53
65
  def format_data (self) -> OpcodeData:
54
66
  """Returns the opcode associated with the data string
55
67
 
@@ -60,7 +72,66 @@ class VLCBFormat :
60
72
  ValueError: If opcode not found
61
73
  """
62
74
  return VLCBOpcode.parse_data(self.data)
75
+
76
+ def get_loco_id (self) -> int:
77
+ """Converts AddrHigh and AddrLow into a loco_id
78
+
79
+ Only valid with certain VLCBFormat packets associated with Locos.
80
+ If packet is does not contain the AddrHigh & AddrLow values (which
81
+ may be formatted differently) then raises a InvalidLocoError
63
82
 
83
+ Returns:
84
+ loco_id: Loco number
85
+
86
+ Raises:
87
+ InvalidLocoError: If AddrHigh / AddrLow are not in the packet
88
+ """
89
+ loco_id = None
90
+ if self.opcode() == "PLOC":
91
+ # Get data
92
+ data_dict = VLCBOpcode.parse_data(self.data)
93
+ loco_id = data_dict['AddrHigh_AddrLow'] & 0x3FFF
94
+ elif self.opcode() == "ERR":
95
+ # also check it's one of the Error codes associated with allocate loco etc.
96
+ # 1 = loco stack full 2 = loco taken, 7 = invalid request
97
+ # The following are not supported as data bytes contain session / consist ID and not loco_id
98
+ # 3 = no session, 4 consist empty, 5 loco not found, 6 can bus error
99
+ data_dict = VLCBOpcode.parse_data(self.data)
100
+ if data_dict["ErrCode"] in [1, 2, 7]:
101
+ loco_id = bytes_to_addr(data_dict['Byte1'],data_dict['Byte2']) & 0x3FFF
102
+ else:
103
+ raise InvalidLocoError (f"Error code {data_dict['ErrCode']} does not contain a loco_id")
104
+
105
+ # If loco_id not updated then raise error
106
+ if loco_id != None:
107
+ return loco_id
108
+ else:
109
+ raise InvalidLocoError(f"Opcode {self.opcode()} does not contain a loco_id")
110
+
111
+ def get_function_list (self) -> List[int]:
112
+ """Where packet contains Fn1, Fn2, Fn3 (eg. PLOC)
113
+ returns
114
+
115
+ Only valid with certain VLCBFormat packets associated with Locos.
116
+ If packet is does not contain the 3 byte values (which
117
+ may be formatted differently) then raises a InvalidFunctionError
118
+
119
+ Returns:
120
+ List of function values as 0 or 1 for each function as off and on
121
+
122
+ Raises:
123
+ InvalidFunctionError: If Function Bytes are not in the packet
124
+ """
125
+ if self.opcode() == "PLOC":
126
+ # Get data
127
+ data_dict = VLCBOpcode.parse_data(self.data)
128
+ return bytes_to_functions (data_dict['Fn1'], data_dict['Fn2'], data_dict['Fn3'])
129
+ else:
130
+ raise InvalidLocoError(f"Opcode {self.opcode()} does not contain a loco_id")
131
+
132
+
133
+
134
+
64
135
  def __str__ (self):
65
136
  return f'{self.priority} : {self.can_id} : {self.opcode()} ({self.data[0:2]}) : {self.data} / {self.format_data()}'
66
137
 
pyvlcb-0.1.1/README.md DELETED
@@ -1,58 +0,0 @@
1
- # PyVLCB Python Software Library for CBUS / VLCB
2
-
3
- This is a small software library for CBUS and VLCB using Python.
4
-
5
- It's designed for use with the MERG CANUSB4 CBUS adapter.
6
-
7
-
8
- ## Install
9
-
10
-
11
- For most systems including a Raspberry Pi then Python is managed using virtual environment.
12
- To setup a virtual environment enter the following commands
13
-
14
- mkdir ~/venv
15
- python3 -m venv ~/venv/pyvlcb --system-site-packages
16
-
17
- You will then need to run the following command to activate the virtual environment
18
-
19
- source ~/venv/pyvlcb/bin/activate
20
-
21
- To instally the latest release use
22
-
23
- pip install pyvlcb
24
-
25
-
26
- ## Development
27
-
28
- The latest source code is available from GitHub. [PyVLCB on GitHub](https://github.com/penguintutor/pyvlcb).
29
-
30
-
31
- ### Installing as a git submodule
32
-
33
- To install the library, as a submodule in your own git project repository, then enter your project directory and run:
34
-
35
- git submodule add https://github.com/penguintutor/pyvlcb.git lib/pyvlcb
36
- pip install lib/pyvlcb
37
-
38
- If you subsequently want to get the latest version of the library run a git pull then use the pip install command again.
39
-
40
- _Important_ To use as a submodule you must create your own git project first and install within the project folder.
41
-
42
- ## Library Reference
43
-
44
- See the link below for the library reference documentation
45
- [PyVLCB Library Reference Documentation](https://penguintutor.github.io/pyvlcb/reference/)
46
-
47
-
48
- ## Demo examples
49
-
50
- Example code is stored within the demo folder available from GitHub. Copy these into your project folder to test the library and connectivity.
51
- The demos are created for a Raspberry Pi or other Linux computer. The USB port is hard-coded as /dev/ttyACM0
52
- For other computers / USB ports then edit the python file directory and update the port statement.
53
-
54
- ## More Details
55
-
56
- For more details see: [PenguinTutor PyVLCB library page](https://www.penguintutor.com/projects/pyvlcb)
57
-
58
- For an example of a program using this library see the [PenguinTutor Pi SignalBox project page](https://www.penguintutor.com/projects/pisignalbox)
File without changes
File without changes