536 lines · 15.7 KB · python
Raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536
"""
CruiseBuddy Server v1
=====================
Real-time group drive tracking with WebSockets.
Requirements:
pip install aiohttp
Run:
python server.py
Then open http://localhost:8765 in your browser.
To make it accessible on your network, it binds to 0.0.0.0 by default.
"""
import asyncio
import json
import string
import random
import time
import os
from collections import OrderedDict
from aiohttp import web
# ═══════════════════════════════════════════
# CONFIG
# ═══════════════════════════════════════════
HOST = "0.0.0.0"
PORT = 8765
STATIC_DIR = os.path.dirname(os.path.abspath(__file__))
# ═══════════════════════════════════════════
# STATE
# ═══════════════════════════════════════════
rooms = {} # code -> Room dict
ws_to_room = {} # websocket -> room code
ws_to_name = {} # websocket -> display name
def generate_code():
"""Generate a 5-character room code (no ambiguous chars)."""
chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
while True:
code = "".join(random.choices(chars, k=5))
if code not in rooms:
return code
def get_room(ws):
"""Get the room a websocket belongs to."""
code = ws_to_room.get(ws)
if code and code in rooms:
return rooms[code]
return None
def get_participant(room, ws):
"""Get participant dict from room by websocket."""
for p in room["participants"]:
if p["ws"] is ws:
return p
return None
def participant_list(room):
"""Build a serializable participant list (no ws objects)."""
return [
{
"name": p["name"],
"role": p["role"],
}
for p in room["participants"]
]
async def broadcast(room, message, exclude=None):
"""Send message to all participants in a room."""
data = json.dumps(message)
for p in room["participants"]:
if p["ws"] is not exclude:
try:
await p["ws"].send_str(data)
except Exception:
pass
async def send(ws, message):
"""Send message to a single websocket."""
try:
await ws.send_str(json.dumps(message))
except Exception:
pass
async def send_error(ws, msg):
await send(ws, {"type": "error", "msg": msg})
# ═══════════════════════════════════════════
# MESSAGE HANDLERS
# ═══════════════════════════════════════════
async def handle_create_room(ws, data):
name = data.get("name", "").strip()[:20]
max_p = data.get("max_participants", 5)
max_p = max(2, min(10, int(max_p)))
if not name:
return await send_error(ws, "Name is required.")
# Leave any existing room first
await handle_leave(ws, silent=True)
code = generate_code()
room = {
"code": code,
"leader_ws": ws,
"leader_name": name,
"max_participants": max_p,
"status": "lobby", # lobby | active | paused
"participants": [
{
"ws": ws,
"name": name,
"role": "leader",
"lat": None,
"lng": None,
"speed": 0,
"heading": 0,
}
],
"chat": [],
"created_at": time.time(),
}
rooms[code] = room
ws_to_room[ws] = code
ws_to_name[ws] = name
await send(ws, {
"type": "room_created",
"code": code,
"name": name,
"max_participants": max_p,
"participants": participant_list(room),
})
print(f"[ROOM] {name} created room {code} (max {max_p})")
async def handle_join_room(ws, data):
name = data.get("name", "").strip()[:20]
code = data.get("code", "").strip().upper()
if not name:
return await send_error(ws, "Name is required.")
if not code or len(code) != 5:
return await send_error(ws, "Invalid room code.")
room = rooms.get(code)
if not room:
return await send_error(ws, "Room not found.")
if len(room["participants"]) >= room["max_participants"]:
return await send_error(ws, "Room is full.")
# Check for duplicate name
for p in room["participants"]:
if p["name"].lower() == name.lower():
return await send_error(ws, "Name already taken in this room.")
# Leave any existing room first
await handle_leave(ws, silent=True)
participant = {
"ws": ws,
"name": name,
"role": "member",
"lat": None,
"lng": None,
"speed": 0,
"heading": 0,
}
room["participants"].append(participant)
ws_to_room[ws] = code
ws_to_name[ws] = name
# Send room state to joiner
await send(ws, {
"type": "joined",
"code": code,
"role": "member",
"status": room["status"],
"leader_name": room["leader_name"],
"max_participants": room["max_participants"],
"participants": participant_list(room),
"chat": room["chat"][-50:], # last 50 messages
})
# Broadcast updated participant list to everyone else
await broadcast(room, {
"type": "participant_update",
"participants": participant_list(room),
"event": "joined",
"name": name,
}, exclude=ws)
print(f"[JOIN] {name} joined room {code} ({len(room['participants'])}/{room['max_participants']})")
async def handle_start_drive(ws, data):
room = get_room(ws)
if not room:
return await send_error(ws, "Not in a room.")
if room["leader_ws"] is not ws:
return await send_error(ws, "Only the leader can start.")
if room["status"] != "lobby":
return await send_error(ws, "Drive already started.")
room["status"] = "active"
await broadcast(room, {"type": "drive_started"})
print(f"[DRIVE] Room {room['code']} drive started")
async def handle_pause_drive(ws, data):
room = get_room(ws)
if not room:
return await send_error(ws, "Not in a room.")
if room["leader_ws"] is not ws:
return await send_error(ws, "Only the leader can pause.")
if room["status"] != "active":
return await send_error(ws, "Drive is not active.")
room["status"] = "paused"
await broadcast(room, {"type": "drive_paused"})
print(f"[DRIVE] Room {room['code']} drive paused")
async def handle_resume_drive(ws, data):
room = get_room(ws)
if not room:
return await send_error(ws, "Not in a room.")
if room["leader_ws"] is not ws:
return await send_error(ws, "Only the leader can resume.")
if room["status"] != "paused":
return await send_error(ws, "Drive is not paused.")
room["status"] = "active"
await broadcast(room, {"type": "drive_resumed"})
print(f"[DRIVE] Room {room['code']} drive resumed")
async def handle_end_drive(ws, data):
room = get_room(ws)
if not room:
return await send_error(ws, "Not in a room.")
if room["leader_ws"] is not ws:
return await send_error(ws, "Only the leader can end the drive.")
room["status"] = "lobby"
# Reset all positions
for p in room["participants"]:
p["lat"] = None
p["lng"] = None
p["speed"] = 0
p["heading"] = 0
await broadcast(room, {
"type": "drive_ended",
"participants": participant_list(room),
})
print(f"[DRIVE] Room {room['code']} drive ended")
async def handle_kick(ws, data):
room = get_room(ws)
if not room:
return await send_error(ws, "Not in a room.")
if room["leader_ws"] is not ws:
return await send_error(ws, "Only the leader can kick.")
target_name = data.get("target", "").strip()
target_p = None
for p in room["participants"]:
if p["name"] == target_name and p["role"] != "leader":
target_p = p
break
if not target_p:
return await send_error(ws, "Participant not found.")
target_ws = target_p["ws"]
# Remove from room
room["participants"].remove(target_p)
if target_ws in ws_to_room:
del ws_to_room[target_ws]
if target_ws in ws_to_name:
del ws_to_name[target_ws]
# Tell the kicked person
await send(target_ws, {"type": "you_kicked"})
# Tell everyone else
await broadcast(room, {
"type": "participant_update",
"participants": participant_list(room),
"event": "kicked",
"name": target_name,
})
print(f"[KICK] {target_name} kicked from room {room['code']}")
async def handle_position(ws, data):
room = get_room(ws)
if not room:
return
if room["status"] != "active":
return
p = get_participant(room, ws)
if not p:
return
p["lat"] = data.get("lat")
p["lng"] = data.get("lng")
p["speed"] = data.get("speed", 0)
p["heading"] = data.get("heading", 0)
if p["role"] == "leader":
# Leader position → send to all followers
leader_data = {
"type": "leader_position",
"lat": p["lat"],
"lng": p["lng"],
"speed": p["speed"],
"heading": p["heading"],
}
await broadcast(room, leader_data, exclude=ws)
else:
# Follower position → send to leader only
if room["leader_ws"]:
follower_data = {
"type": "follower_position",
"name": p["name"],
"lat": p["lat"],
"lng": p["lng"],
"speed": p["speed"],
"heading": p["heading"],
}
await send(room["leader_ws"], follower_data)
async def handle_chat(ws, data):
room = get_room(ws)
if not room:
return await send_error(ws, "Not in a room.")
text = data.get("text", "").strip()[:500]
if not text:
return
p = get_participant(room, ws)
if not p:
return
msg = {
"name": p["name"],
"text": text,
"time": int(time.time() * 1000),
"role": p["role"],
}
room["chat"].append(msg)
# Keep last 200 messages
if len(room["chat"]) > 200:
room["chat"] = room["chat"][-200:]
await broadcast(room, {
"type": "chat_msg",
**msg,
})
async def handle_leave(ws, data=None, silent=False):
code = ws_to_room.get(ws)
if not code:
return
room = rooms.get(code)
if not room:
ws_to_room.pop(ws, None)
ws_to_name.pop(ws, None)
return
p = get_participant(room, ws)
if not p:
ws_to_room.pop(ws, None)
ws_to_name.pop(ws, None)
return
name = p["name"]
is_leader = p["role"] == "leader"
# Remove participant
room["participants"] = [x for x in room["participants"] if x["ws"] is not ws]
ws_to_room.pop(ws, None)
ws_to_name.pop(ws, None)
if is_leader or len(room["participants"]) == 0:
# Leader left or room empty → close the room
if not silent:
await broadcast(room, {"type": "room_closed", "reason": "Leader left the drive."})
# Clean up all remaining participants
for rp in room["participants"]:
ws_to_room.pop(rp["ws"], None)
ws_to_name.pop(rp["ws"], None)
del rooms[code]
print(f"[ROOM] Room {code} closed (leader left or empty)")
else:
# Regular member left
if not silent:
await broadcast(room, {
"type": "participant_update",
"participants": participant_list(room),
"event": "left",
"name": name,
})
print(f"[LEAVE] {name} left room {code}")
# ═══════════════════════════════════════════
# WEBSOCKET HANDLER
# ═══════════════════════════════════════════
MESSAGE_HANDLERS = {
"create_room": handle_create_room,
"join_room": handle_join_room,
"start_drive": handle_start_drive,
"pause_drive": handle_pause_drive,
"resume_drive": handle_resume_drive,
"end_drive": handle_end_drive,
"kick": handle_kick,
"position": handle_position,
"chat": handle_chat,
"leave": handle_leave,
}
async def websocket_handler(request):
ws = web.WebSocketResponse(heartbeat=30)
await ws.prepare(request)
remote = request.remote or "unknown"
print(f"[WS] Connected: {remote}")
try:
async for msg in ws:
if msg.type == web.WSMsgType.TEXT:
try:
data = json.loads(msg.data)
msg_type = data.get("type", "")
handler = MESSAGE_HANDLERS.get(msg_type)
if handler:
await handler(ws, data)
else:
await send_error(ws, f"Unknown message type: {msg_type}")
except json.JSONDecodeError:
await send_error(ws, "Invalid JSON.")
except Exception as e:
print(f"[ERROR] {e}")
await send_error(ws, "Server error.")
elif msg.type == web.WSMsgType.ERROR:
print(f"[WS] Error: {ws.exception()}")
finally:
# Clean up on disconnect
await handle_leave(ws)
print(f"[WS] Disconnected: {remote}")
return ws
# ═══════════════════════════════════════════
# HTTP HANDLERS (serve frontend)
# ═══════════════════════════════════════════
async def handle_index(request):
index_path = os.path.join(STATIC_DIR, "index.html")
if os.path.exists(index_path):
return web.FileResponse(index_path)
return web.Response(text="index.html not found. Place it next to server.py.", status=404)
async def handle_health(request):
"""Simple health/status endpoint."""
active_rooms = len(rooms)
total_users = sum(len(r["participants"]) for r in rooms.values())
return web.json_response({
"status": "ok",
"rooms": active_rooms,
"users": total_users,
"uptime": time.time(),
})
# ═══════════════════════════════════════════
# APP SETUP
# ═══════════════════════════════════════════
def create_app():
app = web.Application()
app.router.add_get("/", handle_index)
app.router.add_get("/ws", websocket_handler)
app.router.add_get("/health", handle_health)
return app
if __name__ == "__main__":
print(f"""
╔══════════════════════════════════════════╗
║ CruiseBuddy Server v1 ║
╠══════════════════════════════════════════╣
║ HTTP: http://localhost:{PORT} ║
║ WS: ws://localhost:{PORT}/ws ║
║ ║
║ Listening on {HOST}:{PORT} ║
║ Press Ctrl+C to stop ║
╚══════════════════════════════════════════╝
""")
app = create_app()
web.run_app(app, host=HOST, port=PORT, print=None)