Compare commits

...

4 Commits

Author SHA1 Message Date
7435e91b07 Make backend configurable 2025-03-07 15:53:11 +01:00
e0b685bfda Format readme 2025-03-07 15:44:15 +01:00
7498d9066e Add modal confirmation to mode change 2025-03-07 15:40:42 +01:00
b974ac8936 readme 2025-03-07 15:29:26 +01:00
6 changed files with 105 additions and 25 deletions

26
README.md Normal file
View 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.

View File

@@ -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

View File

@@ -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

View File

@@ -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} />

View File

@@ -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>

View File

@@ -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"