mirror of
https://github.com/AbaTekNTNU/followspot-psn.git
synced 2025-12-06 13:54:58 +00:00
Compare commits
4 Commits
02526f340a
...
7435e91b07
| Author | SHA1 | Date | |
|---|---|---|---|
|
7435e91b07
|
|||
|
e0b685bfda
|
|||
|
7498d9066e
|
|||
| b974ac8936 |
26
README.md
Normal file
26
README.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# followspot-psn
|
||||||
|
|
||||||
|
Solution for controlling followspots, and sending the position data to ma3 over
|
||||||
|
[posistagenet (PSN)](https://posistage.net/). It uses
|
||||||
|
[psn-py](https://github.com/vyv/psn-py) to do PSN stuff.
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
|
||||||
|
**TLDR**: `docker compose up --build` on a Linux machine.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Why Linux? This is because PSN uses multicast to transmit positional data to
|
||||||
|
> ma3. On linux this is done using host networking driver. However the host
|
||||||
|
> networking driver on macOS or windows will only allow broadcasting on the
|
||||||
|
> docker linux VM. It could probably be done using some fancy routing/bridge
|
||||||
|
> setup with the docker VM.
|
||||||
|
|
||||||
|
This opens a webserver on port 8000 where you can control the PSN trackers. PSN
|
||||||
|
data is multicasted on `236.10.10.10:56565`. Tracker positions can also be
|
||||||
|
updated using OSC, by default OSC listens on port 9000. The OSC endpoint expects
|
||||||
|
data on `/Tracker/{trackerid}` with three floats specifying x, y and z value.
|
||||||
|
|
||||||
|
Currently all configuration is done in the source files, most notably in
|
||||||
|
[psn_server.py](backend/psn_server.py).
|
||||||
|
|
||||||
|
The rest of the functionality is documented in the code.
|
||||||
@@ -1,22 +1,26 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import socket
|
import socket
|
||||||
import time
|
import time
|
||||||
import weakref
|
import weakref
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
import psn
|
import psn
|
||||||
from pythonosc.osc_server import AsyncIOOSCUDPServer
|
from aiohttp import WSCloseCode, web
|
||||||
from pythonosc.dispatcher import Dispatcher
|
from pythonosc.dispatcher import Dispatcher
|
||||||
from aiohttp import web, WSCloseCode
|
from pythonosc.osc_server import AsyncIOOSCUDPServer
|
||||||
|
|
||||||
PSN_DEFAULT_UDP_PORT = 56565
|
PSN_DEFAULT_UDP_PORT = os.getenv("PSN_DEFAULT_UDP_PORT", 56565)
|
||||||
PSN_DEFAULT_UDP_MCAST_ADDRESS = "236.10.10.10"
|
PSN_DEFAULT_UDP_MCAST_ADDRESS = os.getenv(
|
||||||
WEB_SERVER_PORT = 8000
|
"PSN_DEFAULT_UDP_MCAST_ADDRESS", "236.10.10.10"
|
||||||
|
)
|
||||||
|
WEB_SERVER_PORT = int(os.getenv("WEB_SERVER_PORT", 8000))
|
||||||
IP = "0.0.0.0"
|
IP = "0.0.0.0"
|
||||||
OSC_SERVER_PORT = 6969
|
OSC_SERVER_PORT = int(os.getenv("OSC_SERVER_PORT", 9000))
|
||||||
NUM_TRACKERS = 3
|
NUM_TRACKERS = int(os.getenv("NUM_TRACKERS", 3))
|
||||||
|
|
||||||
|
|
||||||
class SceneDimensions:
|
class SceneDimensions:
|
||||||
x_min: float
|
x_min: float
|
||||||
@@ -30,7 +34,7 @@ class SceneDimensions:
|
|||||||
|
|
||||||
dimension_map = {
|
dimension_map = {
|
||||||
"scene_only": (-13 / 2, 13 / 2, 0, 6.3, 0, 4),
|
"scene_only": (-13 / 2, 13 / 2, 0, 6.3, 0, 4),
|
||||||
"full_arena": (-13 / 2, 13 / 2, -9.7, 6.3 , 0, 4)
|
"full_arena": (-13 / 2, 13 / 2, -9.7, 6.3, 0, 4),
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -53,10 +57,12 @@ class SceneDimensions:
|
|||||||
self.dimension_name = "full_arena"
|
self.dimension_name = "full_arena"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
START_POSITION_INTERNAL = (0.5, 0.5, 2)
|
START_POSITION_INTERNAL = (0.5, 0.5, 2)
|
||||||
|
|
||||||
def map_range(value: float, in_min: float, in_max: float, out_min: float, out_max: float) -> float:
|
|
||||||
|
def map_range(
|
||||||
|
value: float, in_min: float, in_max: float, out_min: float, out_max: float
|
||||||
|
) -> float:
|
||||||
return (value - in_min) * (out_max - out_min) / (in_max - in_min) + out_min
|
return (value - in_min) * (out_max - out_min) / (in_max - in_min) + out_min
|
||||||
|
|
||||||
|
|
||||||
@@ -69,7 +75,9 @@ class TrackerData:
|
|||||||
z: float
|
z: float
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def internal_to_scene_coords_3d(x: float, y: float, z: float, dim: SceneDimensions) -> tuple[float, float, float]:
|
def internal_to_scene_coords_3d(
|
||||||
|
x: float, y: float, z: float, dim: SceneDimensions
|
||||||
|
) -> tuple[float, float, float]:
|
||||||
"""
|
"""
|
||||||
Convert internal coordinates to scene coordinates
|
Convert internal coordinates to scene coordinates
|
||||||
|
|
||||||
@@ -83,7 +91,9 @@ class TrackerData:
|
|||||||
return x_val, y_val, z_val
|
return x_val, y_val, z_val
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def scene_to_internal_coords_3d(x: float, y: float, z: float, dim: SceneDimensions) -> tuple[float, float, float]:
|
def scene_to_internal_coords_3d(
|
||||||
|
x: float, y: float, z: float, dim: SceneDimensions
|
||||||
|
) -> tuple[float, float, float]:
|
||||||
x_val = map_range(x, dim.x_min, dim.x_max, 0, 1)
|
x_val = map_range(x, dim.x_min, dim.x_max, 0, 1)
|
||||||
y_val = map_range(y, dim.y_max, dim.y_min, 0, 1) # Invert y axis
|
y_val = map_range(y, dim.y_max, dim.y_min, 0, 1) # Invert y axis
|
||||||
z_val = z
|
z_val = z
|
||||||
@@ -117,20 +127,25 @@ def get_elapsed_time_ms():
|
|||||||
return get_time_ms() - START_TIME
|
return get_time_ms() - START_TIME
|
||||||
|
|
||||||
|
|
||||||
async def update_all_other_clients(app: web.Application, ws: web.WebSocketResponse = None):
|
async def update_all_other_clients(
|
||||||
|
app: web.Application, ws: web.WebSocketResponse = None
|
||||||
|
):
|
||||||
for ws_send in app["ws_clients"]:
|
for ws_send in app["ws_clients"]:
|
||||||
if ws_send == ws:
|
if ws_send == ws:
|
||||||
continue
|
continue
|
||||||
await ws_send.send_str(trackers_to_json(app))
|
await ws_send.send_str(trackers_to_json(app))
|
||||||
|
|
||||||
|
|
||||||
async def update_all_clients(app: web.Application):
|
async def update_all_clients(app: web.Application):
|
||||||
for ws in app["ws_clients"]:
|
for ws in app["ws_clients"]:
|
||||||
await ws.send_str(trackers_to_json(app))
|
await ws.send_str(trackers_to_json(app))
|
||||||
|
|
||||||
|
|
||||||
async def update_all_clients_bg(app: web.Application):
|
async def update_all_clients_bg(app: web.Application):
|
||||||
for ws in app["ws_clients"]:
|
for ws in app["ws_clients"]:
|
||||||
await ws.send_str(json.dumps({"refresh": True}))
|
await ws.send_str(json.dumps({"refresh": True}))
|
||||||
|
|
||||||
|
|
||||||
async def handle_websocket(request):
|
async def handle_websocket(request):
|
||||||
ws = web.WebSocketResponse()
|
ws = web.WebSocketResponse()
|
||||||
logging.debug("Websocket connection starting")
|
logging.debug("Websocket connection starting")
|
||||||
@@ -161,6 +176,7 @@ async def handle_websocket(request):
|
|||||||
|
|
||||||
return ws
|
return ws
|
||||||
|
|
||||||
|
|
||||||
async def on_shutdown(app):
|
async def on_shutdown(app):
|
||||||
for ws in set(app["ws_clients"]):
|
for ws in set(app["ws_clients"]):
|
||||||
await ws.close(code=WSCloseCode.GOING_AWAY, message="Server shutdown")
|
await ws.close(code=WSCloseCode.GOING_AWAY, message="Server shutdown")
|
||||||
@@ -169,6 +185,7 @@ async def on_shutdown(app):
|
|||||||
async def handle_root(request):
|
async def handle_root(request):
|
||||||
return web.FileResponse("./static/index.html")
|
return web.FileResponse("./static/index.html")
|
||||||
|
|
||||||
|
|
||||||
async def handle_background_image(request):
|
async def handle_background_image(request):
|
||||||
if request.app["scene_dimensions"].dimension_name == "full_arena":
|
if request.app["scene_dimensions"].dimension_name == "full_arena":
|
||||||
return web.FileResponse("./static/scene_and_crowd.png")
|
return web.FileResponse("./static/scene_and_crowd.png")
|
||||||
@@ -176,6 +193,7 @@ async def handle_background_image(request):
|
|||||||
return web.FileResponse("./static/scene_only.png")
|
return web.FileResponse("./static/scene_only.png")
|
||||||
return web.Response(text="Incorrect server scende dimension state", status=500)
|
return web.Response(text="Incorrect server scende dimension state", status=500)
|
||||||
|
|
||||||
|
|
||||||
async def handle_set_mode(request):
|
async def handle_set_mode(request):
|
||||||
try:
|
try:
|
||||||
data = request.data = await request.json()
|
data = request.data = await request.json()
|
||||||
@@ -193,6 +211,7 @@ async def handle_set_mode(request):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return web.Response(text=f"Error: {e}", status=400)
|
return web.Response(text=f"Error: {e}", status=400)
|
||||||
|
|
||||||
|
|
||||||
async def handlet_get_mode(request):
|
async def handlet_get_mode(request):
|
||||||
return web.json_response({"mode": request.app["scene_dimensions"].dimension_name})
|
return web.json_response({"mode": request.app["scene_dimensions"].dimension_name})
|
||||||
|
|
||||||
@@ -232,7 +251,9 @@ def osc_tracker_updater(address, fixed_args, *args) -> None:
|
|||||||
async def receive_osc_data(app):
|
async def receive_osc_data(app):
|
||||||
dispatcher = Dispatcher()
|
dispatcher = Dispatcher()
|
||||||
dispatcher.map("/Tracker*", osc_tracker_updater, app)
|
dispatcher.map("/Tracker*", osc_tracker_updater, app)
|
||||||
server = AsyncIOOSCUDPServer((IP, OSC_SERVER_PORT), dispatcher, asyncio.get_event_loop())
|
server = AsyncIOOSCUDPServer(
|
||||||
|
(IP, OSC_SERVER_PORT), dispatcher, asyncio.get_event_loop()
|
||||||
|
)
|
||||||
transport, protocol = await server.create_serve_endpoint()
|
transport, protocol = await server.create_serve_endpoint()
|
||||||
app["osc_transport"] = transport
|
app["osc_transport"] = transport
|
||||||
yield
|
yield
|
||||||
|
|||||||
@@ -2,3 +2,9 @@ services:
|
|||||||
followspot-psn:
|
followspot-psn:
|
||||||
build: .
|
build: .
|
||||||
network_mode: host
|
network_mode: host
|
||||||
|
environment:
|
||||||
|
PSN_DEFAULT_UDP_PORT: 56565
|
||||||
|
PSN_DEFAULT_UDP_MCAST_ADDRESS: "236.10.10.10"
|
||||||
|
WEB_SERVER_PORT: 8000
|
||||||
|
OSC_SERVER_PORT: 9000
|
||||||
|
NUM_TRACKERS: 3
|
||||||
|
|||||||
@@ -63,6 +63,8 @@
|
|||||||
width = image?.getBoundingClientRect().width ?? 0;
|
width = image?.getBoundingClientRect().width ?? 0;
|
||||||
height = image?.getBoundingClientRect().height ?? 0;
|
height = image?.getBoundingClientRect().height ?? 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onresize={resize} />
|
<svelte:window onresize={resize} />
|
||||||
|
|||||||
@@ -1,10 +1,38 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
type Props = {
|
type Props = {
|
||||||
open: boolean;
|
action: () => void;
|
||||||
onClose: () => void;
|
};
|
||||||
|
|
||||||
|
let { action }: Props = $props();
|
||||||
|
|
||||||
|
let dialog: HTMLDialogElement;
|
||||||
|
const openModal = () => {
|
||||||
|
dialog.showModal();
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<dialog>
|
<button class="mb-24 rounded-md bg-red-400 p-2" onclick={openModal}>
|
||||||
<div></div>
|
Change mode
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<dialog
|
||||||
|
open={false}
|
||||||
|
bind:this={dialog}
|
||||||
|
class="absolute left-0 top-0 z-50 m-0 h-full max-h-full w-full max-w-full p-0 open:bg-black/80"
|
||||||
|
>
|
||||||
|
<div class="flex h-full w-full flex-col items-center justify-center">
|
||||||
|
<h1 class="text-red-600 text-8xl">THIS GONNA FUCK THINGS UP</h1>
|
||||||
|
<div class="flex items-center justify-center gap-6">
|
||||||
|
<button class="rounded-md bg-gray-400 p-4" onclick={() => dialog.close()}
|
||||||
|
>Close</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="rounded-md bg-red-400 p-4"
|
||||||
|
onclick={() => {
|
||||||
|
action();
|
||||||
|
dialog.close();
|
||||||
|
}}>Change mode</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { TrackerData } from "$lib/utils/types";
|
import type { TrackerData } from "$lib/utils/types";
|
||||||
import { onMount } from "svelte";
|
|
||||||
import Modal from "./Modal.svelte";
|
import Modal from "./Modal.svelte";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -56,10 +55,8 @@
|
|||||||
let z_viz = $derived(trackers[selected].z.toFixed(2));
|
let z_viz = $derived(trackers[selected].z.toFixed(2));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal />
|
|
||||||
<div class="slider-container ml-auto">
|
<div class="slider-container ml-auto">
|
||||||
<button class="bg-red-400 rounded-md p-2 mb-24"
|
<Modal action={buttonAction} />
|
||||||
onclick={buttonAction}>Change mode</button>
|
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
id="z"
|
id="z"
|
||||||
|
|||||||
Reference in New Issue
Block a user