py-multitrack 0.0.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.

Potentially problematic release.


This version of py-multitrack might be problematic. Click here for more details.

@@ -0,0 +1,169 @@
1
+ from .UDPListener import UDPListener
2
+ from .models import PluginInfo, MultitrackConfig, AdoptModel
3
+ from .Elements import RemoteElements, LocalElements
4
+ from .SerialBridge import SerialBridge
5
+ from .SerialDriver import SerialDriver
6
+ from fastapi import FastAPI, APIRouter, Response
7
+ from contextlib import asynccontextmanager
8
+ from websockets.asyncio.client import connect
9
+ from websockets.exceptions import ConnectionClosed
10
+ import uvicorn
11
+ import asyncio
12
+ import logging
13
+
14
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(asctime)s - %(name)s: %(message)s")
15
+ log = logging.getLogger("MultitrackAPI")
16
+
17
+ class MultitrackAPI():
18
+ """
19
+ Manages the communication between your plugin and the **Multitrack Core**\n
20
+ Exposes the *API endpoints*, runs the *UDPBroadcast listener*, connects to the Core's *WebSocket Server*, manages *plugin configuration*...
21
+ """
22
+ def __init__(self, http_port: int, discovery_port: int, discovery_msg: str, driver: SerialDriver, multicast_group = "239.255.42.69", listening_interface = "0.0.0.0", config_file = "/home/mbn/projects/multitrack/src/plugin_demo/config.ini"):
23
+ # Misc. props
24
+ self.http_port = http_port
25
+ self.discovery_port = discovery_port
26
+ self.discovery_msg = discovery_msg
27
+ self.multicast_group = multicast_group
28
+ self.listening_interface = listening_interface
29
+ # Websocket
30
+ self.ws_task: asyncio.Task | None = None
31
+ # Config
32
+ self.config = MultitrackConfig.read(config_file)
33
+ # Initialize MultitrackBridge
34
+ self.bridge = SerialBridge(driver)
35
+ # UIElements
36
+ self.local = LocalElements()
37
+ self.remote = RemoteElements()
38
+
39
+ # Other inits
40
+ self.bridge.driver.set_plugin_id(self.config.api.id)
41
+
42
+ def run(self):
43
+ log.info("Running application.")
44
+ # Gather plugin info
45
+ self.info = PluginInfo(
46
+ id = self.config.api.id,
47
+ port = self.http_port,
48
+ elements = self.remote.list_elements() + self.local.list_elements(),
49
+ bound = self.config.api.bound
50
+ )
51
+
52
+ # Initialize UDPListener
53
+ self.udp_listener = UDPListener(
54
+ self.discovery_port,
55
+ self.discovery_msg,
56
+ self.info,
57
+ self.multicast_group,
58
+ self.listening_interface
59
+ )
60
+
61
+ # Initialize lifespan events (on_startup/on_shutdown)
62
+ @asynccontextmanager
63
+ async def lifespan(app: FastAPI):
64
+ await self._on_startup()
65
+ yield
66
+ await self._on_shutdown()
67
+
68
+ # Initialize FastAPI
69
+ self.app = FastAPI(lifespan=lifespan)
70
+ self._init_routes()
71
+
72
+ # Runs FastAPI (with lifespan handlers)
73
+ try:
74
+ uvicorn.run(self.app, host="0.0.0.0", port=self.http_port)
75
+ except OSError:
76
+ print("error")
77
+
78
+ async def _on_startup(self):
79
+ # Runs the SerialBridge thread
80
+ self.bridge.bind_loop(asyncio.get_running_loop())
81
+ self.bridge.thread.start()
82
+ for action in self.bridge.driver.on_startup():
83
+ await self.bridge.send_command(action)
84
+ # Runs UDP Listener
85
+ await self.udp_listener.start()
86
+ # Runs the WebSocket connection with the Core
87
+ if self.config.api.bound:
88
+ try:
89
+ self.ws_task = asyncio.create_task(self._connect_to_core())
90
+ except ConnectionError as e:
91
+ log.error(e)
92
+
93
+ async def _on_shutdown(self):
94
+ # Stops SerialBridge thread
95
+ self.bridge.stop_sig.set()
96
+ await asyncio.to_thread(self.bridge.thread.join)
97
+ # Stops UDP Listener
98
+ self.udp_listener.stop()
99
+ # Stops the WebSocket connection
100
+ if self.ws_task:
101
+ self.ws_task.cancel()
102
+ log.info("Disconnected from the Core")
103
+
104
+ async def _connect_to_core(self):
105
+ if not self.config.api.token: raise ConnectionError("API Token not loaded")
106
+ async for ws in connect(f"ws://{self.config.api.core_host}/plugins?id={self.config.api.id}", additional_headers={"bearer-token": self.config.api.token}):
107
+ try:
108
+ log.info(f"Connected to Core on {self.config.api.core_host}")
109
+ self.ws = ws
110
+ async for data in self._fetch_cmds_from_bridge():
111
+ await ws.send(data)
112
+ except ConnectionClosed:
113
+ continue
114
+
115
+ async def _fetch_cmds_from_bridge(self):
116
+ try:
117
+ while True:
118
+ yield await self.bridge.events.get()
119
+ except:
120
+ pass
121
+
122
+ def _init_routes(self):
123
+ @self.app.get("/")
124
+ async def home():
125
+ return self.info
126
+
127
+ @self.app.get("/restart")
128
+ async def restart():
129
+ raise KeyboardInterrupt
130
+
131
+ @self.app.post("/adopt")
132
+ async def adopt(payload: AdoptModel):
133
+ if(self.config.api.bound): return Response(status_code=403, content="Plugin already bound.")
134
+
135
+ self.config.api.core_host = payload.host
136
+ self.config.api.token = payload.token
137
+ self.config.api.bound = True
138
+ self.config.write()
139
+
140
+ self.ws_task = asyncio.create_task(self._connect_to_core())
141
+
142
+ return Response(status_code=200, content="Successfully bound plugin.")
143
+
144
+ remote_router = APIRouter(
145
+ prefix="/remote"
146
+ )
147
+
148
+ local_router = APIRouter(
149
+ prefix="/local"
150
+ )
151
+
152
+ for (path, element) in self.remote.elements.items():
153
+ remote_router.add_api_route(
154
+ path=f"/{path}",
155
+ endpoint=element.make_endpoint(self.bridge.send_command),
156
+ methods=[element.metadata.method],
157
+ name=path
158
+ )
159
+
160
+ for (path, element) in self.local.elements.items():
161
+ local_router.add_api_route(
162
+ path=f"/{path}",
163
+ endpoint=element.make_endpoint(self.bridge.send_command),
164
+ methods=[element.metadata.method],
165
+ name=path
166
+ )
167
+
168
+ self.app.include_router(remote_router)
169
+ self.app.include_router(local_router)
@@ -0,0 +1,61 @@
1
+ from .SerialDriver import SerialDriver
2
+ from typing import Callable
3
+ import logging
4
+ import asyncio, threading, queue, time
5
+
6
+ log = logging.getLogger("SerialBridge")
7
+
8
+ class SerialBridge():
9
+ """
10
+ Bridges the MultitrackAPI and the SerialDriver.
11
+ """
12
+ def __init__(self, driver: SerialDriver):
13
+ self.loop: asyncio.AbstractEventLoop
14
+ self.stop_sig = threading.Event()
15
+ self.commands: queue.Queue[tuple[str, Callable[[str], bool] | None, asyncio.Future]] = queue.Queue()
16
+ self.events = asyncio.Queue()
17
+ self.thread = threading.Thread(target=self._run)
18
+ self.driver = driver
19
+
20
+ def _run(self):
21
+ line = b''
22
+ busy: float | None = None
23
+ predicate: Callable[[str], bool] | None = None
24
+ while not self.stop_sig.is_set():
25
+ # Read a byte from the Serial object init. in the driver
26
+ byte_read = self.driver.serial.read(1)
27
+
28
+ # Resets the line when a 'bol' is found
29
+ if byte_read == self.driver.bol:
30
+ line = b''
31
+ else:
32
+ if byte_read == self.driver.eol:
33
+ # Checks for the ACK, sets the busy flag to None if the condition is fullfilled
34
+ if predicate and predicate(line.decode()):
35
+ busy = None
36
+
37
+ self.loop.call_soon_threadsafe(log.info, f"Received {line.decode()}")
38
+ else:
39
+ # Add the byte read to the line
40
+ line += byte_read
41
+
42
+ # Manages the writing of serial commands
43
+ while not self.commands.empty() and not busy:
44
+ cmd, ack, fut = self.commands.get_nowait()
45
+
46
+ # If the commands has to wait for a provided ACK, set the ok flag to False
47
+ if ack:
48
+ predicate = ack
49
+ busy = time.time()
50
+
51
+ # Write the command on the serial port and returns the result via the Future object
52
+ self.driver.serial.write(self.driver.encode_wrap(cmd))
53
+ self.loop.call_soon_threadsafe(fut.set_result, f"Sent {cmd}")
54
+
55
+ async def send_command(self, cmd: str, ack: Callable[[str], bool] | None = None):
56
+ fut = self.loop.create_future()
57
+ self.commands.put((cmd, ack, fut))
58
+ log.info(await fut)
59
+
60
+ def bind_loop(self, loop: asyncio.AbstractEventLoop):
61
+ self.loop = loop
@@ -0,0 +1,65 @@
1
+ from serial import Serial
2
+ from .models import CoreData
3
+ import logging
4
+ from datetime import datetime
5
+
6
+ log = logging.getLogger("SerialDriver")
7
+
8
+ class SerialDriver():
9
+ """
10
+ Abstract class that serves as a template for **Serial communication**.\n
11
+ Here's how you write your driver :
12
+ - Create a new class **inheriting** from this one
13
+ - Overwrite the **parser()** method with your own
14
+ - Provide a **Serial object** and specific **bol/eol** when initializing the class
15
+ - Bind it to the **MultitrackAPI**
16
+ """
17
+ def __init__(self, serial: Serial, bol: bytes, eol: bytes, MSGCH: int = -1, DEFCH: int = -1, blocking_timeout: int = 5, verbose: bool = False):
18
+ # Misc. properties
19
+ self.verbose = verbose
20
+ # Serial obj. and properties
21
+ self.serial = serial
22
+ self.bol = bol
23
+ self.eol = eol
24
+ self.MSGCH = MSGCH
25
+ self.DEFCH = DEFCH
26
+ self.blocking_timeout = blocking_timeout
27
+
28
+ def on_startup(self) -> list[str]:
29
+ """
30
+ Actions executed when connecting to the serial port.
31
+ """
32
+ return []
33
+
34
+ def set_plugin_id(self, id: str):
35
+ self.plugin_id = id
36
+
37
+ def parser(self, cmd: str) -> CoreData:
38
+ """
39
+ Place here every **command** your radio may receive.\n
40
+ Returns sanitized **PluginData** for the *Core*
41
+ """
42
+ return CoreData(
43
+ plugin_id=self.plugin_id,
44
+ entity_id=None,
45
+ timestamp=datetime.now().timestamp(),
46
+ type="err",
47
+ success=False,
48
+ data=None
49
+ )
50
+
51
+ def nmea_to_decimal(self, value: str, hemisphere: str):
52
+ """
53
+ Small utility to convert **NMEA** coordinates to **decimal**.
54
+ """
55
+ dot = value.index(".")
56
+ degrees = int(value[:dot - 2])
57
+ minutes = float(value[dot - 2:])
58
+ dec = degrees + minutes / 60
59
+ return -dec if hemisphere in ("S", "W") else dec
60
+
61
+ def encode_wrap(self, cmd: str):
62
+ """
63
+ Encode the command in utf-8, then wraps it with *bol* and *eol* characters.
64
+ """
65
+ return self.bol + cmd.encode() + self.eol
@@ -0,0 +1,35 @@
1
+ from pydantic import BaseModel
2
+ from typing import Any, Literal
3
+
4
+ class State(BaseModel):
5
+ scope: Literal["local", "remote"]
6
+ label: str
7
+
8
+ class SimpleState(State):
9
+ type: Literal["str", "int", "float", "bool"]
10
+ default: str | int | float | bool | None
11
+
12
+ class ChatboxState(State):
13
+ direction: bool
14
+ msg: str
15
+
16
+ class GPSState(State):
17
+ lat: float
18
+ long: float
19
+
20
+ class StateStore():
21
+ def __init__(self):
22
+ self.states: dict[str, dict[str, Any]] = {}
23
+
24
+ def register(self, state: State):
25
+ self.states[state.label] = state.model_dump()
26
+
27
+ def get_ref(self, label: str):
28
+ return self.states[label]
29
+
30
+ test = []
31
+ props = ChatboxState.model_json_schema()['properties']
32
+ for p in props:
33
+ test.append((p, props[p]["type"]))
34
+
35
+ print(test)
@@ -0,0 +1,64 @@
1
+ from .models import PluginInfo
2
+ import asyncio
3
+ import logging
4
+ import struct
5
+ import socket
6
+
7
+ log = logging.getLogger("UDPListener")
8
+
9
+ class UDPListener():
10
+ def __init__(self, discovery_port: int, discovery_msg: str, plugin_info: PluginInfo, multicast_group: str, listening_interface: str):
11
+ self.discovery_port = discovery_port
12
+ self.discovery_msg = discovery_msg
13
+ self.multicast_group = multicast_group
14
+ self.listening_interface = listening_interface
15
+ self.plugin_info = plugin_info
16
+ self.transport = None
17
+
18
+ async def start(self):
19
+ # Get the Asyncio running loop
20
+ loop = asyncio.get_running_loop()
21
+ # Creates the socket
22
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
23
+ try:
24
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
25
+ if hasattr(socket, "SO_REUSEPORT"):
26
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
27
+ sock.bind(("", self.discovery_port))
28
+ sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, struct.pack("4s4s", socket.inet_aton(self.multicast_group), socket.inet_aton(self.listening_interface)))
29
+ sock.setblocking(False)
30
+ # Opens the socket for transport, and listens asynchronously
31
+ self.transport, _ = await loop.create_datagram_endpoint(
32
+ lambda: _DiscoveryProtocol(self.discovery_msg, self.plugin_info),
33
+ sock=sock
34
+ )
35
+ except OSError as e:
36
+ log.error(f"Error while binding socket: {e}")
37
+ sock.close()
38
+ raise e
39
+
40
+ def stop(self):
41
+ if self.transport:
42
+ self.transport.close()
43
+ self.transport = None
44
+
45
+ class _DiscoveryProtocol(asyncio.DatagramProtocol):
46
+ def __init__(self, discovery_msg: str, plugin_info: PluginInfo):
47
+ self.discovery_msg = discovery_msg
48
+ self.plugin_info = plugin_info
49
+
50
+ def connection_made(self, transport):
51
+ log.info("Listening for Discovery")
52
+ self.transport = transport
53
+
54
+ def connection_lost(self, exc):
55
+ log.info("No longer listening for Discovery")
56
+
57
+ def datagram_received(self, data, addr):
58
+ log.info(f"received {str(data)}")
59
+ if data != self.discovery_msg.encode():
60
+ return
61
+
62
+ reply = self.plugin_info.model_dump_json(indent=4).encode()
63
+ self.transport.sendto(reply, addr)
64
+ log.info(f"Discovery request from {addr}, replied.")
multitrack/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """
2
+ Framework to write MultitrackCore plugins.\n
3
+ Documentation can be found here : **insert_doc_link**.
4
+ """
5
+
6
+ from .MultitrackAPI import MultitrackAPI
7
+ from .SerialBridge import SerialBridge
8
+ from .SerialDriver import SerialDriver
9
+ from .UDPListener import UDPListener
10
+ from .models import EntityInfo, PluginInfo, TextMessage, GPSPosition, StatusUpdate, CoreData
multitrack/models.py ADDED
@@ -0,0 +1,184 @@
1
+ from pydantic import BaseModel
2
+ from typing import Literal, Callable, Any
3
+ from types import CoroutineType
4
+ from configparser import ConfigParser
5
+ from uuid import uuid4
6
+
7
+ # Metadata
8
+
9
+ class GeneralMetadata(BaseModel):
10
+ path: str
11
+ element: Literal["button", "text", "number", "chatbox", "toggle", "dropdown"]
12
+ blocking: bool
13
+ scope: Literal["local", "remote"] = "remote"
14
+ role: Literal["operator", "admin"] = "operator"
15
+ method: Literal["GET", "POST"] = "POST"
16
+ group: str | None = None
17
+ sensible: bool = False
18
+ display_text: str | None = None
19
+ tooltip: str | None = None
20
+
21
+ class ToggleMetadata(GeneralMetadata):
22
+ ref: str
23
+ colors: tuple[str, str] | None = None
24
+
25
+ class ChatboxMetadata(GeneralMetadata):
26
+ ref: str
27
+
28
+ class NumberMetadata(GeneralMetadata):
29
+ ref: str
30
+ vartype: Literal["int", "float"]
31
+ min_max: tuple[int | float, int | float] | None = None
32
+
33
+ class DropdownMetadata(GeneralMetadata):
34
+ ref: str
35
+ values: list[str]
36
+
37
+ class Element(BaseModel):
38
+ handler: Callable[..., str]
39
+ check_ack: Callable[[str], bool] | None = None
40
+ metadata: GeneralMetadata | type[GeneralMetadata]
41
+
42
+ def make_endpoint(self, send_command: Callable[[str, Callable[[str], bool] | None], CoroutineType[Any, Any, None]]):
43
+ if self.metadata.scope == "remote":
44
+ match self.metadata.element:
45
+ case "button":
46
+ async def remote_button(e: EntityInfo):
47
+ await send_command(self.handler(e), self.check_ack)
48
+ return remote_button
49
+ case "chatbox":
50
+ async def remote_chatbox(e: EntityInfo, msg: str):
51
+ await send_command(self.handler(e, msg), self.check_ack)
52
+ return remote_chatbox
53
+ case "dropdown":
54
+ async def remote_dropdown(e: EntityInfo, value: str):
55
+ await send_command(self.handler(e, value), self.check_ack)
56
+ return remote_dropdown
57
+ case "number":
58
+ async def remote_number(e: EntityInfo, value: int | float):
59
+ await send_command(self.handler(e, value), self.check_ack)
60
+ return remote_number
61
+ case "text":
62
+ async def remote_text(e: EntityInfo, msg: str):
63
+ await send_command(self.handler(e, msg), self.check_ack)
64
+ return remote_text
65
+ case "toggle":
66
+ async def remote_toggle(e: EntityInfo, state: bool):
67
+ await send_command(self.handler(e, state), self.check_ack)
68
+ return remote_toggle
69
+ else:
70
+ match self.metadata.element:
71
+ case "button":
72
+ async def local_button():
73
+ await send_command(self.handler(), self.check_ack)
74
+ return local_button
75
+ case "chatbox":
76
+ async def local_chatbox(msg: str):
77
+ await send_command(self.handler(msg), self.check_ack)
78
+ return local_chatbox
79
+ case "dropdown":
80
+ async def local_dropdown(value: str):
81
+ await send_command(self.handler(value), self.check_ack)
82
+ return local_dropdown
83
+ case "number":
84
+ async def local_number(value: int | float):
85
+ await send_command(self.handler(value), self.check_ack)
86
+ return local_number
87
+ case "text":
88
+ async def local_text(msg: str):
89
+ await send_command(self.handler(msg), self.check_ack)
90
+ return local_text
91
+ case "toggle":
92
+ async def local_toggle(state: bool):
93
+ await send_command(self.handler(state), self.check_ack)
94
+ return local_toggle
95
+
96
+ # Infos
97
+
98
+ class EntityInfo(BaseModel):
99
+ serial: int
100
+
101
+ class PluginInfo(BaseModel):
102
+ id: str
103
+ port: int
104
+ elements: list[GeneralMetadata | type[GeneralMetadata]]
105
+ bound: bool
106
+
107
+ # Data
108
+
109
+ class TextMessage(BaseModel):
110
+ msg: str
111
+
112
+ class GPSPosition(BaseModel):
113
+ latitude: float
114
+ longitude: float
115
+
116
+ class StatusUpdate(BaseModel):
117
+ status: str
118
+
119
+ class CoreData(BaseModel):
120
+ plugin_id: str
121
+ entity_id: str | None
122
+ timestamp: float
123
+ type: Literal["text", "gps", "status", "err"]
124
+ success: bool
125
+ data: TextMessage | GPSPosition | StatusUpdate | None
126
+
127
+ class AdoptModel(BaseModel):
128
+ host: str
129
+ token: str
130
+
131
+ class APIConfig(BaseModel):
132
+ id: str
133
+ core_host: str | None
134
+ token: str | None
135
+ bound: bool
136
+
137
+ class MultitrackConfig(BaseModel):
138
+ filepath: str
139
+ api: APIConfig
140
+
141
+ @classmethod
142
+ def read(cls, filepath: str):
143
+ config = ConfigParser()
144
+ config.read(filepath)
145
+ try:
146
+ api = APIConfig(
147
+ id = config["API"]["id"],
148
+ core_host = None if config["API"]["core_host"] == "None" else config["API"]["core_host"],
149
+ token = None if config["API"]["token"] == "None" else config["API"]["token"],
150
+ bound = config["API"]["bound"] == "True"
151
+ )
152
+ print("Config loaded.")
153
+ return cls(
154
+ filepath = filepath,
155
+ api = api
156
+ )
157
+ except:
158
+ print("Couldn't parse config, generating defaults.")
159
+ config = cls.default(filepath)
160
+ config.write()
161
+ return config
162
+
163
+ @classmethod
164
+ def default(cls, filepath: str):
165
+ return cls(
166
+ filepath = filepath,
167
+ api = APIConfig(
168
+ id = str(uuid4()),
169
+ core_host = None,
170
+ token = None,
171
+ bound = False
172
+ )
173
+ )
174
+
175
+ def write(self):
176
+ config = ConfigParser()
177
+ config["API"] = {}
178
+ config["API"]["id"] = self.api.id
179
+ config["API"]["core_host"] = str(self.api.core_host)
180
+ config["API"]["token"] = str(self.api.token)
181
+ config["API"]["bound"] = str(self.api.bound)
182
+
183
+ with open(self.filepath, "w") as f:
184
+ config.write(f)
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: py-multitrack
3
+ Version: 0.0.1
4
+ Summary: Multitrack Framework to bridge serial radios to the Multitrack Core tracking system
5
+ Author: Maxime Brison
6
+ License-Expression: GPL-3.0
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Dynamic: license-file
11
+
12
+ # py_multitrack
13
+ Python framework to bridge serial radios to the Multitrack Core tracking system.
@@ -0,0 +1,13 @@
1
+ multitrack/Elements.py,sha256=7JIbiCteMEeoY4q2J3AIbjSK1fGGZJ5INFAmCj3vkW8,37211
2
+ multitrack/MultitrackAPI.py,sha256=_YwT1bQFFewK2XS2TVZiesGBTE39EiaQwwdA3_6ska4,6142
3
+ multitrack/SerialBridge.py,sha256=EsP29zwXfMU-TJ14Z0RpxGZOEXQDQxkTd5-fBzNFn_k,2397
4
+ multitrack/SerialDriver.py,sha256=hkWXSrJsBrht1xUc4UAjdsq9SU1Idi9bzRjt86Ei7dc,2136
5
+ multitrack/StateStore.py,sha256=z7-T_RTr75luI65SnFmxG0uKMhJ3zeqKuukcUwuz41s,809
6
+ multitrack/UDPListener.py,sha256=xfzUzxWraWx1q_yPxuhIsyLML498lbZLshqMDiOAI90,2477
7
+ multitrack/__init__.py,sha256=eWpgoByXRzVnQnS3rFpng9gMgEF24jch6drmMWGFWYY,357
8
+ multitrack/models.py,sha256=QsgdIBUGjkwYa2Nafz6bL9Npklx8lB2Q_CNTRdWThYw,6226
9
+ py_multitrack-0.0.1.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
10
+ py_multitrack-0.0.1.dist-info/METADATA,sha256=hhFiz2NddzcDjU4JMYrNOpYSMw7C5nsbvl5uIYvnh8g,406
11
+ py_multitrack-0.0.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
12
+ py_multitrack-0.0.1.dist-info/top_level.txt,sha256=JqCSP17N5fwlcmf-XmXMT3zYXUSCykULjKHmsOO7ox0,11
13
+ py_multitrack-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+