Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions field_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ def input_check():
case "web_key":
key = q_message.get("key", "")
return PlayerOutcome(PlayerState.SUCCESS, f"web_key:{key}")
case "parental_digit":
digit = str(q_message.get("digit", ""))
return PlayerOutcome(PlayerState.SUCCESS, f"parental_digit:{digit}")
case "parental_clear":
return PlayerOutcome(PlayerState.SUCCESS, "parental_clear")


channel_socket = StationManager().server_conf["channel_socket"]
Expand Down
114 changes: 87 additions & 27 deletions fs42/fs42_server/api/player.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,50 @@
import shutil
import re
import platform
import os
from fs42.station_manager import StationManager

router = APIRouter(prefix="/player", tags=["player"])

PARENTAL_STATUS_FILE = "runtime/parental_controls.json"


def _read_parental_status():
try:
if not os.path.exists(PARENTAL_STATUS_FILE):
return {"active": False}

with open(PARENTAL_STATUS_FILE, "r") as fp:
data = json.load(fp)

if not isinstance(data, dict):
return {"active": False}

data.setdefault("active", False)
return data
except Exception:
return {"active": False}




@router.get("/info")
async def get_info():
"""Get system information including CPU temp, memory usage, and CPU usage"""
info = {}

# Get CPU temperature
temp_info = await _get_cpu_temperature()
info.update(temp_info)

# Get memory information
memory_info = await _get_memory_info()
info.update(memory_info)

# Get CPU usage
cpu_info = await _get_cpu_info()
info.update(cpu_info)

# Get system information
try:
info["system"] = {
Expand All @@ -36,13 +57,13 @@ async def get_info():
}
except Exception:
info["system"] = {"error": "unavailable"}

return info


async def _get_cpu_temperature():
"""Get CPU temperature using various methods depending on the system"""

# Method 1: Raspberry Pi vcgencmd (original method)
if shutil.which("vcgencmd"):
try:
Expand All @@ -57,7 +78,7 @@ async def _get_cpu_temperature():
}
except Exception:
pass

# Method 2: Linux thermal zones (most modern Linux systems)
try:
with open("/sys/class/thermal/thermal_zone0/temp", "r") as f:
Expand All @@ -71,7 +92,7 @@ async def _get_cpu_temperature():
}
except Exception:
pass

# Method 3: Using lm-sensors (if available)
if shutil.which("sensors"):
try:
Expand All @@ -90,7 +111,7 @@ async def _get_cpu_temperature():
}
except Exception:
pass

return {"temperature": "unavailable"}


Expand All @@ -99,25 +120,25 @@ async def _get_memory_info():
try:
with open("/proc/meminfo", "r") as f:
meminfo = f.read()

# Parse meminfo
mem_total = 0
mem_available = 0
mem_free = 0

for line in meminfo.split('\n'):
if 'MemTotal:' in line:
mem_total = int(line.split()[1]) * 1024 # Convert from KB to bytes
elif 'MemAvailable:' in line:
mem_available = int(line.split()[1]) * 1024
elif 'MemFree:' in line:
mem_free = int(line.split()[1]) * 1024

# Use MemAvailable if available, otherwise fall back to MemFree
available = mem_available if mem_available > 0 else mem_free
used = mem_total - available
used_percent = round((used / mem_total) * 100, 1) if mem_total > 0 else 0

return {
"memory": {
"total_gb": round(mem_total / (1024**3), 1),
Expand All @@ -141,18 +162,18 @@ async def _get_cpu_info():
cpu_count = cpuinfo.count("processor")
except Exception:
cpu_count = 1

# Get load average (simpler than trying to calculate CPU percentage)
try:
with open("/proc/loadavg", "r") as f:
loadavg = f.read().strip().split()
load_1min = float(loadavg[0])
load_5min = float(loadavg[1])
load_15min = float(loadavg[2])

# Convert load to rough percentage (load/cores * 100)
load_percent = round((load_1min / cpu_count) * 100, 1) if cpu_count > 0 else 0

return {
"cpu": {
"cores": cpu_count,
Expand Down Expand Up @@ -225,15 +246,54 @@ async def player_stop(request: Request):
command_queue.put({"command": "exit"})
return {"status": "stopped"}

@router.get("/parental/status")
async def parental_status():
"""Return whether the player is currently waiting for a parental controls PIN."""
return _read_parental_status()


@router.post("/parental/digit/{digit}")
async def parental_digit(request: Request, digit: str):
"""Send one PIN digit to the active parental controls prompt."""
status = _read_parental_status()
if not status.get("active"):
return {"active": False, "handled": False}

if not digit.isdigit() or len(digit) != 1:
raise HTTPException(status_code=400, detail="Digit must be a single number.")

command_queue = request.app.state.player_command_queue
if not command_queue:
raise HTTPException(status_code=503, detail="Player command queue is not connected.")

command_queue.put({"command": "parental_digit", "digit": digit})
return {"active": True, "handled": True}


@router.post("/parental/clear")
async def parental_clear(request: Request):
"""Clear PIN entry on the active parental controls prompt."""
status = _read_parental_status()
if not status.get("active"):
return {"active": False, "handled": False}

command_queue = request.app.state.player_command_queue
if not command_queue:
raise HTTPException(status_code=503, detail="Player command queue is not connected.")

command_queue.put({"command": "parental_clear"})
return {"active": True, "handled": True}


@router.post("/ticker")
async def show_ticker(request: Request):
data = await request.json()
command_queue = request.app.state.player_command_queue
command_queue.put({
"command": "ticker",
"message": data.get("message", ""),
"header": data.get("header", "FS42"),
"style": data.get("style", "fieldstation"),
"command": "ticker",
"message": data.get("message", ""),
"header": data.get("header", "FS42"),
"style": data.get("style", "fieldstation"),
"iterations": data.get("iterations", 2)
})
return {"status": "success"}
Expand Down Expand Up @@ -261,32 +321,32 @@ async def volume_mute():

async def _control_volume(action: str):
"""Control volume using the most appropriate Linux audio system"""

# Check what audio systems are available
amixer_available = shutil.which("amixer") is not None
pactl_available = shutil.which("pactl") is not None
wpctl_available = shutil.which("wpctl") is not None

# Try different audio systems in order of preference
# For WSL, prefer PulseAudio over ALSA since ALSA usually fails
if pactl_available:
try:
return await _volume_pulseaudio(action)
except Exception as e:
print(f"pactl failed: {e}")

if amixer_available:
try:
return await _volume_amixer(action)
except Exception as e:
print(f"amixer failed (expected in WSL): {e}")

if wpctl_available:
try:
return await _volume_wireplumber(action)
except Exception as e:
print(f"wpctl failed: {e}")

raise HTTPException(status_code=500, detail=f"No supported audio system found or all failed. Available: amixer={amixer_available}, pactl={pactl_available}, wpctl={wpctl_available}")


Expand Down Expand Up @@ -478,7 +538,7 @@ async def _get_pulseaudio_volume() -> str:
["pactl", "get-sink-volume", "@DEFAULT_SINK@"],
capture_output=True, text=True, check=True
)

# PulseAudio output looks like: Volume: front-left: 65536 / 100% / 0.00 dB, front-right: 65536 / 100% / 0.00 dB
match = re.search(r'(\d+)%', result.stdout)
if match:
Expand Down Expand Up @@ -543,4 +603,4 @@ async def _get_wireplumber_volume() -> str:
return f"{volume_percent}%"
return "unknown"
except:
return "unknown"
return "unknown"
30 changes: 30 additions & 0 deletions fs42/pi/remote_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,39 @@ def send_channel_change():
clear_press_socket()


def send_parental_digit_if_active(number):
"""Send numeric input to the parental controls prompt when it is active."""
try:
response = requests.get(f"{FS42_BASE_URL}/player/parental/status", timeout=2)
if not response.ok:
return False

status = response.json()
if not status.get("active"):
return False

response = requests.post(
f"{FS42_BASE_URL}/player/parental/digit/{number}",
timeout=2
)

if response.ok:
print(f"Parental controls PIN digit entered: {number}")
else:
print(f"Parental controls digit failed: {response.status_code}")

return True
except Exception as e:
print(f"Parental controls digit error: {e}")
return False


def number_pressed(number):
"""Handle number key presses from remote"""
global channel_input, channel_input_timer

if send_parental_digit_if_active(number):
return

with input_lock:
# Cancel any existing timer
Expand Down
4 changes: 4 additions & 0 deletions fs42/station_config_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,10 @@
}
}
}
},
"parental_controls": {
"type": "boolean",
"description": "Require the global parental controls PIN before playing this station"
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion fs42/station_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,8 @@ def load_main_config(self):
"schedule_agent",
"video_seek_timeout",
"overlay_conf",
"start_channel"
"start_channel",
"parental_controls_pin"
]

for key in to_check:
Expand Down
Loading
Loading