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.
- multitrack/Elements.py +835 -0
- multitrack/MultitrackAPI.py +169 -0
- multitrack/SerialBridge.py +61 -0
- multitrack/SerialDriver.py +65 -0
- multitrack/StateStore.py +35 -0
- multitrack/UDPListener.py +64 -0
- multitrack/__init__.py +10 -0
- multitrack/models.py +184 -0
- py_multitrack-0.0.1.dist-info/METADATA +13 -0
- py_multitrack-0.0.1.dist-info/RECORD +13 -0
- py_multitrack-0.0.1.dist-info/WHEEL +5 -0
- py_multitrack-0.0.1.dist-info/licenses/LICENSE +674 -0
- py_multitrack-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -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
|
multitrack/StateStore.py
ADDED
|
@@ -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,,
|