PyVLCB 0.1.2__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.2
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "PyVLCB"
7
- version = "0.1.2"
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.2
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
@@ -3,7 +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
6
+ from .utils import num_to_1hexstr, num_to_2hexstr, num_to_4hexstr, f_to_bytes, dict_to_string
7
7
  from .exceptions import (
8
8
  MyLibraryError,
9
9
  DeviceConnectionError,
@@ -123,7 +123,7 @@ class VLCB:
123
123
  opcode = vlcb_entry.data[0:2]
124
124
  opcode_string = f'{opcode} - {VLCBOpcode.opcode_mnemonic(opcode)}'
125
125
  #data_string = f"{VLCBOpcode.parse_data(vlcb_entry.data)}"
126
- data_string = VLCB._dict_to_string(VLCBOpcode.parse_data(vlcb_entry.data))
126
+ data_string = dict_to_string(VLCBOpcode.parse_data(vlcb_entry.data))
127
127
  return [date_string, direction, message, str(vlcb_entry.can_id), opcode_string, data_string]
128
128
  # Todo - error handling
129
129
 
@@ -400,12 +400,42 @@ class VLCB:
400
400
  """
401
401
  return f"{self.make_header(opcode='23')}23{num_to_1hexstr(session_id)};"
402
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)
431
+
403
432
  # Set loco speed and direction (always done together)
404
- # Maximum once every 32 miliseconds (GUI configured based on non triggered so shouldn't be an issue)
405
- # Could add time detection if required
406
- # This uses the combined speed and direction value
407
433
  def loco_speeddir (self, session_id: int, speeddir: int) -> str:
408
- """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
409
439
 
410
440
  Uses DSPD (47)
411
441
 
@@ -437,5 +467,28 @@ class VLCB:
437
467
  String: A string for the request
438
468
  """
439
469
  return f"{self.make_header(opcode='60')}60{num_to_1hexstr(session_id)}{num_to_1hexstr(byte1)}{num_to_1hexstr(byte2)};"
440
-
441
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:
@@ -57,4 +57,12 @@ class InvalidLocoError(MyLibraryError):
57
57
  or short code specified but code needs long code
58
58
  or if the packet does not contain a loco_id (eg. certain Err codes)
59
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.
67
+ """
60
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."""
@@ -88,10 +88,10 @@ class TestVLCB(unittest.TestCase):
88
88
  #f_to_bytes (f_num: int, function_status: List[int]) -> Tuple[bytes, bytes]
89
89
  def test_f_to_bytes(self):
90
90
  functions = [1, 1, 0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 1]
91
- self.assertEqual(f_to_bytes (1, functions), (1, 25))
92
- self.assertEqual(f_to_bytes (2, functions), (1, 25))
93
- self.assertEqual(f_to_bytes (7, functions), (2, 6))
94
- self.assertEqual(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"))
95
95
 
96
96
 
97
97
  if __name__ == "__main__":
@@ -40,7 +40,7 @@ def num_to_4hexstr (num: int) -> str:
40
40
 
41
41
 
42
42
  # dict to string without {} or ""
43
- def _dict_to_string (dictionary: dict) -> str:
43
+ def dict_to_string (dictionary: dict) -> str:
44
44
  """ Convert a dict to a string without {} or quotes """
45
45
  data_string = ""
46
46
  for key, value in dictionary.items():
@@ -49,8 +49,8 @@ def _dict_to_string (dictionary: dict) -> str:
49
49
  data_string += f"{key} = {value}"
50
50
  return data_string
51
51
 
52
- def f_to_bytes (f_num: int, function_status: List[int]) -> Tuple[bytes, bytes]:
53
- """ Convert a Fnumber (Loco function ID) to bytes
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
54
 
55
55
  Can be used to create correct format for loco_set_dfun
56
56
  Limited to range 0 to 28 - which is max for DFUN
@@ -61,7 +61,7 @@ def f_to_bytes (f_num: int, function_status: List[int]) -> Tuple[bytes, bytes]:
61
61
  function_status: List with value of all functions (must be updated before calling)
62
62
 
63
63
  Returns:
64
- Tuple of two bytes
64
+ Tuple of two x 2-characer strings representing the bytes
65
65
 
66
66
  Raises: ValueError
67
67
 
@@ -117,7 +117,11 @@ def f_to_bytes (f_num: int, function_status: List[int]) -> Tuple[bytes, bytes]:
117
117
  0b1000000 * function_list[27] +
118
118
  0b10000000 * function_list[28]
119
119
  )
120
- return (byte1, byte2)
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)
121
125
 
122
126
 
123
127
  # Where 2 bytes convert to addr id
@@ -148,3 +152,29 @@ def bytes_to_hexstr (byte1: bytes, byte2: bytes) -> str:
148
152
  """
149
153
  return f"{hex(byte1).upper()[2:]:0>2}{hex(byte2).upper()[2:]:0>2}"
150
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,6 +1,6 @@
1
1
  import logging
2
2
  import warnings
3
- from .utils import bytes_to_addr
3
+ from .utils import bytes_to_addr, bytes_to_functions
4
4
  from typing import List, Optional, Union, Dict, Any
5
5
 
6
6
  # Set up a null handler so nothing prints by default unless the user enables it
@@ -108,6 +108,29 @@ class VLCBFormat :
108
108
  else:
109
109
  raise InvalidLocoError(f"Opcode {self.opcode()} does not contain a loco_id")
110
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
+
111
134
 
112
135
  def __str__ (self):
113
136
  return f'{self.priority} : {self.can_id} : {self.opcode()} ({self.data[0:2]}) : {self.data} / {self.format_data()}'
File without changes
File without changes
File without changes