Framework-LED-Matrix 0.1.1__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.
- cli.py +669 -0
- framework_led_matrix/__init__.py +0 -0
- framework_led_matrix/apps/__init__.py +0 -0
- framework_led_matrix/apps/background_runner.py +125 -0
- framework_led_matrix/apps/runtime.py +185 -0
- framework_led_matrix/core/__init__.py +0 -0
- framework_led_matrix/core/led_commands.py +406 -0
- framework_led_matrix/core/math_engine.py +294 -0
- framework_led_matrix/simulations/BihamMiddletonLevineTrafficModel.py +238 -0
- framework_led_matrix/simulations/HardyPomeauPazzis.py +241 -0
- framework_led_matrix/simulations/__init__.py +0 -0
- framework_led_matrix/simulations/inner_totalistic.py +47 -0
- framework_led_matrix/simulations/outer_totalistic.py +112 -0
- framework_led_matrix/utils/__init__.py +0 -0
- framework_led_matrix/utils/anagrams.py +39 -0
- framework_led_matrix/utils/text_rendering.py +281 -0
- framework_led_matrix-0.1.1.dist-info/METADATA +159 -0
- framework_led_matrix-0.1.1.dist-info/RECORD +22 -0
- framework_led_matrix-0.1.1.dist-info/WHEEL +5 -0
- framework_led_matrix-0.1.1.dist-info/entry_points.txt +2 -0
- framework_led_matrix-0.1.1.dist-info/licenses/LICENSE +674 -0
- framework_led_matrix-0.1.1.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import re
|
|
3
|
+
import serial
|
|
4
|
+
import serial.tools.list_ports
|
|
5
|
+
from typing import List, Optional, Tuple
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
WIDTH = 9
|
|
9
|
+
HEIGHT = 34
|
|
10
|
+
verbose = True
|
|
11
|
+
linux = bool(sys.platform == "linux")
|
|
12
|
+
|
|
13
|
+
class VerboseLogger:
|
|
14
|
+
def __init__(self, verbose=False):
|
|
15
|
+
self.verbose = verbose
|
|
16
|
+
def __call__(self, *args, **kwargs):
|
|
17
|
+
if self.verbose:
|
|
18
|
+
print(*args, **kwargs)
|
|
19
|
+
|
|
20
|
+
def find_matching_ports_windows(target_description):
|
|
21
|
+
"""Finds COM ports with descriptions containing the target_description."""
|
|
22
|
+
matching_ports = {
|
|
23
|
+
'left': '',
|
|
24
|
+
'right': ''
|
|
25
|
+
}
|
|
26
|
+
for port in serial.tools.list_ports.comports():
|
|
27
|
+
if target_description in port.description:
|
|
28
|
+
# Check the device name, not the description
|
|
29
|
+
if port.device == 'COM3':
|
|
30
|
+
matching_ports['left'] = port.device
|
|
31
|
+
elif port.device == 'COM4':
|
|
32
|
+
matching_ports['right'] = port.device
|
|
33
|
+
return matching_ports
|
|
34
|
+
|
|
35
|
+
log = VerboseLogger()
|
|
36
|
+
log.verbose = verbose
|
|
37
|
+
|
|
38
|
+
modules = {'left': None, 'right': None}
|
|
39
|
+
|
|
40
|
+
# --- HARDWARE CONSTANTS ---
|
|
41
|
+
RIGHT_PCI_PATH_WINDOWS = ''
|
|
42
|
+
LEFT_PCI_PATH_WINDOWS = ''
|
|
43
|
+
RIGHT_PCI_PATH_LINUX = 'pci-0000:c2:00.3-usb-0:3.3:1.0'
|
|
44
|
+
LEFT_PCI_PATH_LINUX = 'pci-0000:c2:00.3-usb-0:4.2:1.0'
|
|
45
|
+
RIGHT_PCI_PATH = RIGHT_PCI_PATH_LINUX if linux else RIGHT_PCI_PATH_WINDOWS
|
|
46
|
+
LEFT_PCI_PATH = LEFT_PCI_PATH_LINUX if linux else LEFT_PCI_PATH_WINDOWS
|
|
47
|
+
|
|
48
|
+
DEFAULT_FONT_PATH = "/usr/share/fonts/TTF/DejaVuSansMono.ttf"
|
|
49
|
+
|
|
50
|
+
# --- COMMAND CONSTANTS ---
|
|
51
|
+
COMMANDS = {
|
|
52
|
+
"brightness": 0x00, "pattern": 0x01, "bootloader": 0x02, "sleep": 0x03,
|
|
53
|
+
"animate": 0x04, "panic": 0x05, "drawbw": 0x06, "stagecol": 0x07,
|
|
54
|
+
"flushcols": 0x08, "startgame": 0x10, "gamecontrol": 0x11,
|
|
55
|
+
"getsleep": 0x03, "getanimate": 0x04, "gamestatus": 0x12, "version": 0x20
|
|
56
|
+
}
|
|
57
|
+
PATTERNS = {
|
|
58
|
+
"percentage": [0x00], "gradient": [0x01], "doublegradient": [0x02],
|
|
59
|
+
"lotush": [0x03], "zigzag": [0x04], "full": [0x05], "panic": [0x06], "lotusv": [0x07]
|
|
60
|
+
}
|
|
61
|
+
GAMES = { "snake": [0], "pong": [1] }
|
|
62
|
+
GAME_CONTROLS = {
|
|
63
|
+
"pong": {
|
|
64
|
+
"far_player": {"left": [2], "right": [3]},
|
|
65
|
+
"close_player": {"left": [5], "right": [6]}, "stop": [4]
|
|
66
|
+
},
|
|
67
|
+
"snake": {
|
|
68
|
+
"up": [0], "down": [1], "left": [2], "right": [3], "stop": [4]
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_module_paths():
|
|
74
|
+
"""
|
|
75
|
+
Finds the stable /dev/ttyACM* paths for the left and right LED matrix modules
|
|
76
|
+
by matching their known physical PCI paths.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
dict: A dictionary mapping 'left' and 'right' to their /dev/ file paths.
|
|
80
|
+
e.g., {'left': '/dev/ttyACM1', 'right': '/dev/ttyACM0'}
|
|
81
|
+
Values will be None if a path is not found.
|
|
82
|
+
"""
|
|
83
|
+
if not linux:
|
|
84
|
+
if modules['left'] is not None and modules['right'] is not None:
|
|
85
|
+
return modules
|
|
86
|
+
else:
|
|
87
|
+
found_ports = find_matching_ports_windows("USB Serial Device")
|
|
88
|
+
modules['left'] = found_ports['left']
|
|
89
|
+
modules['right'] = found_ports['right']
|
|
90
|
+
log(f"get_module_paths: found ports -> {modules}")
|
|
91
|
+
return modules
|
|
92
|
+
|
|
93
|
+
if modules['left'] is not None and modules['right'] is not None:
|
|
94
|
+
log("get_module_paths: using cached module paths", modules)
|
|
95
|
+
return modules
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
result = subprocess.run(
|
|
99
|
+
['ls', '-l', '/dev/serial/by-path/'],
|
|
100
|
+
capture_output=True,
|
|
101
|
+
text=True,
|
|
102
|
+
check=True
|
|
103
|
+
)
|
|
104
|
+
log("get_module_paths: ls output captured")
|
|
105
|
+
lines = result.stdout.strip().split('\n')
|
|
106
|
+
for line in lines:
|
|
107
|
+
# Find the 'ttyACM*' device name in the line
|
|
108
|
+
match = re.search(r'ttyACM\d+', line)
|
|
109
|
+
if not match:
|
|
110
|
+
continue # Skip lines that aren't ttyACM devices
|
|
111
|
+
device_path = f"/dev/{match.group(0)}"
|
|
112
|
+
log(f"get_module_paths: found device {device_path} in line: {line.strip()}")
|
|
113
|
+
if RIGHT_PCI_PATH in line:
|
|
114
|
+
modules['right'] = device_path # type: ignore
|
|
115
|
+
log(f"get_module_paths: mapped RIGHT -> {device_path}")
|
|
116
|
+
elif LEFT_PCI_PATH in line:
|
|
117
|
+
modules['left'] = device_path # type: ignore
|
|
118
|
+
log(f"get_module_paths: mapped LEFT -> {device_path}")
|
|
119
|
+
except FileNotFoundError:
|
|
120
|
+
log("get_module_paths: Error: 'ls' command not found. Please ensure coreutils are installed.")
|
|
121
|
+
except subprocess.CalledProcessError as e:
|
|
122
|
+
log(f"get_module_paths: Error listing serial devices: {e.stderr}")
|
|
123
|
+
except Exception as e:
|
|
124
|
+
log(f"get_module_paths: unexpected error: {e}")
|
|
125
|
+
log(f"get_module_paths: result -> {modules}")
|
|
126
|
+
return modules
|
|
127
|
+
|
|
128
|
+
def create_matrix(matrix_data):
|
|
129
|
+
"""
|
|
130
|
+
Converts a 2D matrix (34 rows, 9 cols) into a 39-byte payload.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
matrix_data (list[list[int]]): 2D array of 34x9. 1 = ON, 0 = OFF.
|
|
134
|
+
"""
|
|
135
|
+
log(f"create_matrix: building payload")
|
|
136
|
+
|
|
137
|
+
# 1. Initialize the 39-byte payload with all zeros (LEDs off)
|
|
138
|
+
vals = [0] * 39
|
|
139
|
+
|
|
140
|
+
# 2. Pack the 2D matrix data into the 39-byte list
|
|
141
|
+
pixels_set = 0
|
|
142
|
+
for row in range(HEIGHT):
|
|
143
|
+
for col in range(WIDTH):
|
|
144
|
+
|
|
145
|
+
# Check the cell value.
|
|
146
|
+
# We assume 1 is ON, 0 is OFF.
|
|
147
|
+
cell = matrix_data[row][col]
|
|
148
|
+
if cell == 1:
|
|
149
|
+
# Convert [row][col] to 1D index
|
|
150
|
+
i = col + row * WIDTH
|
|
151
|
+
|
|
152
|
+
# Find the byte index (i // 8)
|
|
153
|
+
# Find the bit index (i % 8)
|
|
154
|
+
# Set the corresponding bit to 1
|
|
155
|
+
vals[i // 8] |= (1 << (i % 8))
|
|
156
|
+
pixels_set += 1
|
|
157
|
+
log(f"draw_matrix: packed {pixels_set} pixels into payload")
|
|
158
|
+
return vals
|
|
159
|
+
|
|
160
|
+
def draw_matrix_on_board(matrix_data, which='both'):
|
|
161
|
+
send_command(COMMANDS['drawbw'], create_matrix(matrix_data), which=which)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def create_greyscale_payloads(matrix_data: List[List[int]]) -> List[List[int]]:
|
|
165
|
+
"""
|
|
166
|
+
Prepares the 9 separate column-payloads for the 'stagecol' command.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
matrix_data (list[list[int]]): 2D array of 34x9 with brightness 0-255.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
list[list[int]]: A list of 9 payloads. Each payload is
|
|
173
|
+
[col_index] + [34 bytes of brightness].
|
|
174
|
+
"""
|
|
175
|
+
all_payloads = []
|
|
176
|
+
log("create_greyscale_payloads: preparing 9 column payloads")
|
|
177
|
+
|
|
178
|
+
# Iterate by COLUMN (0 to 8)
|
|
179
|
+
for col in range(WIDTH):
|
|
180
|
+
column_brightness_data = []
|
|
181
|
+
for row in range(HEIGHT):
|
|
182
|
+
# Get brightness, ensure it's an int and clamp (0-255)
|
|
183
|
+
brightness = matrix_data[row][col]
|
|
184
|
+
brightness = max(0, min(255, int(brightness)))
|
|
185
|
+
column_brightness_data.append(brightness)
|
|
186
|
+
|
|
187
|
+
# Create the final parameters: [col_index] + [34 bytes]
|
|
188
|
+
parameters = [col] + column_brightness_data
|
|
189
|
+
all_payloads.append(parameters)
|
|
190
|
+
|
|
191
|
+
log(f"create_greyscale_payloads: created {len(all_payloads)} payloads.")
|
|
192
|
+
return all_payloads
|
|
193
|
+
|
|
194
|
+
def draw_greyscale_on_board(matrix_data: List[List[int]], which: str = 'both'):
|
|
195
|
+
"""
|
|
196
|
+
Creates and draws a greyscale matrix on the board.
|
|
197
|
+
This version does NOT track global state.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
matrix_data (list[list[int]]): 2D array of 34x9 (brightness 0-255).
|
|
201
|
+
which (str): 'left', 'right', 'both'.
|
|
202
|
+
"""
|
|
203
|
+
log(f"draw_greyscale_on_board: starting (which={which})")
|
|
204
|
+
|
|
205
|
+
# 1. Create the payloads
|
|
206
|
+
all_column_payloads = create_greyscale_payloads(matrix_data)
|
|
207
|
+
|
|
208
|
+
# 2. Send all the staged column data
|
|
209
|
+
for payload in all_column_payloads:
|
|
210
|
+
send_command(COMMANDS['stagecol'], payload, which)
|
|
211
|
+
|
|
212
|
+
# 3. After all columns are staged, flush them to the display
|
|
213
|
+
send_command(COMMANDS['flushcols'], [], which)
|
|
214
|
+
log("draw_greyscale_on_board: flushed staged columns")
|
|
215
|
+
|
|
216
|
+
def set_led(matrix, row, col, brightness):
|
|
217
|
+
"""
|
|
218
|
+
Helper function to safely set a pixel's brightness in a matrix.
|
|
219
|
+
This function DOES NOT send any commands.
|
|
220
|
+
"""
|
|
221
|
+
if 0 <= row < HEIGHT and 0 <= col < WIDTH:
|
|
222
|
+
matrix[row][col] = int(max(0, min(255, int(brightness))))
|
|
223
|
+
log(f"set_led: set ({row},{col}) -> {matrix[row][col]}")
|
|
224
|
+
else:
|
|
225
|
+
log(f"set_led: Warning: Pixel ({row}, {col}) is out of bounds.")
|
|
226
|
+
print(f"Warning: Pixel ({row}, {col}) is out of bounds.")
|
|
227
|
+
|
|
228
|
+
def send_command(command_id, parameters, which='both', with_response=False):
|
|
229
|
+
log(f"send_command: enter command_id={command_id} which={which} with_response={with_response}")
|
|
230
|
+
if which == 'both' and with_response:
|
|
231
|
+
log("send_command: Error - cannot request response from both modules")
|
|
232
|
+
print("Error: Cannot request response from 'both' modules simultaneously.")
|
|
233
|
+
print("Please call separately for 'left' and 'right' if responses are needed.")
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
modules = get_module_paths()
|
|
237
|
+
paths_to_send = []
|
|
238
|
+
if which == 'left':
|
|
239
|
+
paths_to_send.append(modules.get('left'))
|
|
240
|
+
elif which == 'right':
|
|
241
|
+
paths_to_send.append(modules.get('right'))
|
|
242
|
+
elif which == 'both':
|
|
243
|
+
paths_to_send.append(modules.get('left'))
|
|
244
|
+
paths_to_send.append(modules.get('right'))
|
|
245
|
+
else:
|
|
246
|
+
log(f"send_command: Error - invalid which parameter '{which}'")
|
|
247
|
+
print(f"Error: 'which' can only be 'left', 'right', or 'both', not '{which}'")
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
# log(f"send_command: resolved paths -> {paths_to_send}")
|
|
251
|
+
response_data = None
|
|
252
|
+
for path in paths_to_send:
|
|
253
|
+
if path is None:
|
|
254
|
+
module_name = "unknown"
|
|
255
|
+
if path == modules.get('left'): module_name = 'left'
|
|
256
|
+
if path == modules.get('right'): module_name = 'right'
|
|
257
|
+
log(f"send_command: Error - Path for '{module_name}' module not found. Skipping.")
|
|
258
|
+
print(f"Error: Path for '{module_name}' module not found. Skipping.")
|
|
259
|
+
continue
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
log(f"send_command: opening serial {path} at 115200")
|
|
263
|
+
with serial.Serial(path, 115200, timeout=1.0) as s:
|
|
264
|
+
|
|
265
|
+
payload = [0x32, 0xAC, command_id] + (parameters or [])
|
|
266
|
+
log(f"send_command: writing payload to {path}: {payload[:16]}{'...' if len(payload)>16 else ''}")
|
|
267
|
+
|
|
268
|
+
s.write(bytes(payload))
|
|
269
|
+
s.flush()
|
|
270
|
+
|
|
271
|
+
if with_response:
|
|
272
|
+
response_data = s.read(3) # Read 3 bytes, not 32
|
|
273
|
+
log(f"send_command: received response from {path}: {response_data}")
|
|
274
|
+
else:
|
|
275
|
+
log(f"send_command: write completed to {path} (no response requested)")
|
|
276
|
+
|
|
277
|
+
except serial.SerialException as e:
|
|
278
|
+
log(f"send_command: SerialException for {path}: {e}")
|
|
279
|
+
print(f"Error connecting to {path}: {e}")
|
|
280
|
+
except Exception as e:
|
|
281
|
+
log(f"send_command: unexpected error for {path}: {e}")
|
|
282
|
+
print(f"An unexpected error occurred with {path}: {e}")
|
|
283
|
+
|
|
284
|
+
log("send_command: exiting")
|
|
285
|
+
return response_data
|
|
286
|
+
|
|
287
|
+
def coordinates_to_matrix(coordinates: List[Tuple[int, int]] | List[List[int]]) -> List[List[int]]:
|
|
288
|
+
"""
|
|
289
|
+
Translates a list of (row, col) tuples into a full 34x9 2D matrix.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
coordinates: A list of (row, col) tuples to mark as '1'.
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
A 34x9 2D matrix (List[List[int]]).
|
|
296
|
+
"""
|
|
297
|
+
# 1. Create the initial board (all 0s)
|
|
298
|
+
matrix = [[0 for _ in range(WIDTH)] for _ in range(HEIGHT)]
|
|
299
|
+
|
|
300
|
+
# 2. Seed the board with the coordinates
|
|
301
|
+
pixels_seeded = 0
|
|
302
|
+
for r, c in coordinates:
|
|
303
|
+
if 0 <= r < HEIGHT and 0 <= c < WIDTH:
|
|
304
|
+
matrix[r][c] = 1 # 1 = live
|
|
305
|
+
pixels_seeded += 1
|
|
306
|
+
|
|
307
|
+
return matrix
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def parse_version_string(response_bytes: Optional[bytes]) -> str:
|
|
311
|
+
"""Helper to parse the 3-byte version response."""
|
|
312
|
+
log("parse_version_string: parsing response")
|
|
313
|
+
if not response_bytes or len(response_bytes) < 3:
|
|
314
|
+
log("parse_version_string: invalid or missing response")
|
|
315
|
+
return "v?.?.? (Error: No response)"
|
|
316
|
+
|
|
317
|
+
try:
|
|
318
|
+
major = response_bytes[0]
|
|
319
|
+
lsb = response_bytes[1]
|
|
320
|
+
pre_release_flag = response_bytes[2]
|
|
321
|
+
|
|
322
|
+
# LSB is mmmmPPPP
|
|
323
|
+
minor = (lsb & 0xF0) >> 4 # Get top 4 bits
|
|
324
|
+
patch = lsb & 0x0F # Get bottom 4 bits
|
|
325
|
+
|
|
326
|
+
pre_release_str = "-pre" if pre_release_flag == 1 else ""
|
|
327
|
+
version = f"v{major}.{minor}.{patch}{pre_release_str}"
|
|
328
|
+
log(f"parse_version_string: parsed version {version}")
|
|
329
|
+
return version
|
|
330
|
+
|
|
331
|
+
except Exception as e:
|
|
332
|
+
log(f"parse_version_string: error parsing response: {e}")
|
|
333
|
+
return f"v?.?.? (Error: {e})"
|
|
334
|
+
|
|
335
|
+
def get_firmware_version():
|
|
336
|
+
"""
|
|
337
|
+
Gets, parses, and prints the firmware version for both modules.
|
|
338
|
+
"""
|
|
339
|
+
log("get_firmware_version: querying modules for version")
|
|
340
|
+
print("Querying modules for version...")
|
|
341
|
+
|
|
342
|
+
right_response = send_command(
|
|
343
|
+
COMMANDS["version"],
|
|
344
|
+
parameters=None,
|
|
345
|
+
which='right',
|
|
346
|
+
with_response=True
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
left_response = send_command(
|
|
350
|
+
COMMANDS["version"],
|
|
351
|
+
parameters=None,
|
|
352
|
+
which='left',
|
|
353
|
+
with_response=True
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
right_parsed = parse_version_string(right_response)
|
|
357
|
+
left_parsed = parse_version_string(left_response)
|
|
358
|
+
log(f"get_firmware_version: right={right_parsed} left={left_parsed}")
|
|
359
|
+
print(f"Right LED Module: {right_parsed}")
|
|
360
|
+
print(f"Left LED Module: {left_parsed}")
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def clear_graph():
|
|
364
|
+
"""
|
|
365
|
+
Clears the LED matrix.
|
|
366
|
+
"""
|
|
367
|
+
log("clear_graph: clearing matrix")
|
|
368
|
+
draw_matrix_on_board([[0] * WIDTH for _ in range(HEIGHT)], 'both')
|
|
369
|
+
|
|
370
|
+
def fill_graph():
|
|
371
|
+
"""
|
|
372
|
+
Fills the LED matrix.
|
|
373
|
+
"""
|
|
374
|
+
log("fill_graph: filling matrix")
|
|
375
|
+
draw_matrix_on_board([[1] * WIDTH for _ in range(HEIGHT)], 'both')
|
|
376
|
+
|
|
377
|
+
def start_animation():
|
|
378
|
+
"""Starts the LED animation."""
|
|
379
|
+
log("start_animation: starting animation on both modules")
|
|
380
|
+
send_command(COMMANDS["animate"], [1], 'both')
|
|
381
|
+
|
|
382
|
+
def stop_animation():
|
|
383
|
+
"""Stops the LED animation."""
|
|
384
|
+
log("stop_animation: stopping animation on both modules")
|
|
385
|
+
send_command(COMMANDS["animate"], [0], 'both')
|
|
386
|
+
|
|
387
|
+
def reset_modules():
|
|
388
|
+
"""Resets both LED matrix modules."""
|
|
389
|
+
log("reset_modules: resetting modules (clear + stop animation)")
|
|
390
|
+
clear_graph()
|
|
391
|
+
stop_animation()
|
|
392
|
+
|
|
393
|
+
def output_ports():
|
|
394
|
+
"""
|
|
395
|
+
Outputs the current module paths for debugging.
|
|
396
|
+
"""
|
|
397
|
+
for port in serial.tools.list_ports.comports():
|
|
398
|
+
print(f"Device: {port.device}")
|
|
399
|
+
print(f" Name: {port.name}")
|
|
400
|
+
print(f" Description: {port.description}")
|
|
401
|
+
print(f" HWID: {port.hwid}")
|
|
402
|
+
print(f" VID: {port.vid}")
|
|
403
|
+
print(f" PID: {port.pid}")
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
|