Added Glitch Room Source code to Gitty
This commit is contained in:
commit
1ac9eb5644
505 files changed
+28504
No files matched your search
@@ -0,0 +1 @@
|
||||
__pycache__/
|
||||
@@ -0,0 +1,81 @@
|
||||
# Real GPIO on the Raspberry Pi
|
||||
|
||||
The former HardwareManager only logged `[MOCK GPIO]`; it could not light an LED. The standalone launcher also did not forward game events to hardware. Both paths are now connected. GPIO numbers in HardwareAction resources are **BCM numbers**, not physical header positions.
|
||||
|
||||
## 1. Wire the LED (Pi powered off)
|
||||
|
||||
For a typical small red LED:
|
||||
|
||||
```text
|
||||
physical pin 11 (BCM GPIO17) -> 330–470 ohm resistor -> LED anode (long leg)
|
||||
LED cathode (short leg / flat side) -> physical pin 6 (GND)
|
||||
```
|
||||
|
||||
Keep the LED's legs in different breadboard rows. The resistor must be in series, not bypassed by another wire. Physical pin **17 is 3.3V power**, not GPIO17. Do not use the 5V rail for this test. GPIO is 3.3V logic; coin dispensers, relay coils and other powered loads require an appropriate driver, not a direct GPIO connection.
|
||||
|
||||
Pinout and LED reference: [GPIO Zero recipes](https://gpiozero.readthedocs.io/en/stable/recipes.html#pin-numbering).
|
||||
|
||||
## 2. Install dependencies on the Pi
|
||||
|
||||
Use a current Raspberry Pi OS installation and its packaged GPIO Zero/lgpio support (Pi 4 or Pi 5):
|
||||
|
||||
```sh
|
||||
sudo apt update
|
||||
sudo apt install python3-gpiozero python3-lgpio
|
||||
```
|
||||
|
||||
The account running the helper needs access to `/dev/gpiochip*`. Raspberry Pi OS normally provides this via the `gpio` group. If you get a permission error, inspect `ls -l /dev/gpiochip*` and `groups`; if needed, run `sudo usermod -aG gpio "$USER"`, then log out and back in. The game itself does not need to run as root.
|
||||
|
||||
## 3. Test the wiring independently
|
||||
|
||||
Before running the bridge, run this on the Pi. The LED should remain lit for two seconds, then turn off:
|
||||
|
||||
```sh
|
||||
GPIOZERO_PIN_FACTORY=lgpio python3 -c 'from gpiozero import LED; from time import sleep; led=LED(17); led.on(); sleep(2); led.off(); led.close()'
|
||||
```
|
||||
|
||||
If this fails, fix the reported GPIO permissions/package error or check the resistor, LED orientation, breadboard connections and pin numbering before testing the game. A successful command without light does not prove the wiring is correct.
|
||||
|
||||
## 4. Start the GPIO helper
|
||||
|
||||
Copy **pi_gpio_bridge.py** from this folder alongside the exported game on the Pi. It is a separate Python program, not an embedded Godot resource. Keep it running in a terminal:
|
||||
|
||||
```sh
|
||||
GPIOZERO_PIN_FACTORY=lgpio python3 pi_gpio_bridge.py --controller cabinet_01_gpio --pins 17
|
||||
```
|
||||
|
||||
It listens only on `127.0.0.1:8765` and owns the allowed pins. Do not run another LED script at the same time. It accepts only the configured controller ID and listed BCM pins. Add `,20` to the pin list later if you wire the configured Start Light as well. Do not enable other pins without checking their wiring.
|
||||
|
||||
## 5. Run the exported game with real outputs
|
||||
|
||||
In another terminal, replacing `ArcadeEscape.arm64` with your actual executable filename:
|
||||
|
||||
```sh
|
||||
./ArcadeEscape.arm64 -- --gpio=real --gpio-test
|
||||
```
|
||||
|
||||
`--gpio-test` sends a two-second pulse on cabinet 01's coin-release output (BCM17) at startup. No game win is required. The launcher shows GPIO status and the helper terminal logs each accepted command. Once that works, omit the test flag:
|
||||
|
||||
```sh
|
||||
./ArcadeEscape.arm64 -- --gpio=real
|
||||
```
|
||||
|
||||
Choose Pac-Man and win its configured objective. The launcher's **Hardware Scenario** defaults to `proof_of_concept.tres`: `game_won` -> cabinet 01 `coin_release` -> BCM17, active-high, **600 milliseconds**. This is only a short flash. Other launcher games only send hardware events if they have a matching assignment in the selected Hardware Scenario. Add their event mappings in your resources as needed.
|
||||
|
||||
Without `--gpio=real`, runtime outputs remain simulated, including on Windows. Real mode does not silently fall back to simulation if the helper fails. Connection failures, wrong-controller requests and rejected pins appear in the launcher status/log. The editor's room simulation should be run without real mode. This bridge controls the Pi running the game, not GPIO on a different Pi across the network.
|
||||
|
||||
## Timing and stopping
|
||||
|
||||
Managed room rewards include a unique `request_id`. The updated helper records these in `~/.local/state/arcade_gpio/requests.sqlite3` (override with `--ledger`). Repeated IDs return the earlier result without driving the pin again. An interrupted request with no recorded outcome is marked uncertain and will not be replayed. Keep this ledger across restarts; standalone requests without an ID retain the original retrigger behavior.
|
||||
|
||||
The helper controls pulse timing; repeated pulses restart that pin's duration. Digital outputs accept `context.active=false` to switch off. Active-low polarity is supported by GPIO Zero. Conflicting polarity on the same pin is rejected until the helper restarts.
|
||||
|
||||
The game sends a heartbeat every second. If it exits or disconnects, outputs become inactive within roughly three seconds; short pulses still finish at their own deadline. Ctrl+C/SIGTERM shuts down the helper and deactivates its outputs. This is a software watchdog, not a hardware safety circuit; power loss or forcibly killing the helper cannot guarantee pin state. Test with the LED before attaching real actuators.
|
||||
|
||||
Keep the helper running while restarting games with Escape. For unattended boot, launch the helper under your Pi's service manager using the same account/environment and start the game with `--gpio=real`.
|
||||
|
||||
## Validation performed off-Pi
|
||||
|
||||
Bridge tests cover pulse timing/retriggering, active-low configuration, digital off, watchdog expiry, rejected pins/controllers, and shutdown. The Godot integration test sends a real HTTP request from a Pac-Man win to a test bridge with fake GPIO devices. Physical voltage and your wiring must still be verified on the Pi.
|
||||
|
||||
References: [GPIO Zero installation](https://gpiozero.readthedocs.io/en/stable/installing.html), [output device API](https://gpiozero.readthedocs.io/en/stable/api_output.html).
|
||||
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Local Godot -> gpiozero bridge. BCM pins; run on the Pi, not the PC."""
|
||||
import argparse
|
||||
import json
|
||||
import signal
|
||||
import threading
|
||||
import time
|
||||
import hashlib
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
|
||||
class Outputs:
|
||||
def __init__(self, factory, controller, allowed, clock=time.monotonic, ledger=":memory:"):
|
||||
self.factory, self.controller, self.allowed = factory, controller, set(allowed)
|
||||
self.clock = clock
|
||||
self.last_seen = clock()
|
||||
self.pins = {}
|
||||
self.lock = threading.RLock()
|
||||
self.ledger = sqlite3.connect(ledger, check_same_thread=False)
|
||||
self.ledger.execute("CREATE TABLE IF NOT EXISTS requests (id TEXT PRIMARY KEY, digest TEXT NOT NULL, result TEXT)")
|
||||
|
||||
def heartbeat(self):
|
||||
with self.lock:
|
||||
self.last_seen = self.clock()
|
||||
|
||||
def command(self, data):
|
||||
if not isinstance(data, dict): raise ValueError("Expected a JSON object")
|
||||
request_id = data.get("request_id")
|
||||
if request_id is None: return self._apply_command(data)
|
||||
if not isinstance(request_id, str) or not 1 <= len(request_id) <= 100:
|
||||
raise ValueError("Invalid request_id")
|
||||
digest = hashlib.sha256(json.dumps(data, sort_keys=True).encode()).hexdigest()
|
||||
with self.lock:
|
||||
row = self.ledger.execute("SELECT digest, result FROM requests WHERE id=?", (request_id,)).fetchone()
|
||||
if row:
|
||||
if row[0] != digest: raise ValueError("request_id reused with a different output")
|
||||
return json.loads(row[1]) if row[1] else {"ok": False, "error": "Prior output outcome uncertain; refusing to pulse again"}
|
||||
# Commit before touching the pin. A crash can miss a reward, but cannot replay it.
|
||||
with self.ledger:
|
||||
self.ledger.execute("INSERT INTO requests VALUES (?, ?, NULL)", (request_id, digest))
|
||||
try:
|
||||
result = self._apply_command(data)
|
||||
except Exception as exc:
|
||||
result = {"ok": False, "error": str(exc)}
|
||||
with self.ledger:
|
||||
self.ledger.execute("UPDATE requests SET result=? WHERE id=?", (json.dumps(result), request_id))
|
||||
return result
|
||||
|
||||
def _apply_command(self, data):
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Expected a JSON object")
|
||||
if data.get("controller") != self.controller:
|
||||
raise ValueError("Wrong controller_id for this Pi")
|
||||
pin = data.get("pin")
|
||||
if type(pin) is not int or pin not in self.allowed:
|
||||
raise ValueError("BCM pin is not enabled in the bridge --pins list")
|
||||
polarity = data.get("active_high")
|
||||
if type(polarity) is not bool:
|
||||
raise ValueError("active_high must be boolean")
|
||||
mode = data.get("mode")
|
||||
if mode not in ("pulse", "set"):
|
||||
raise ValueError("mode must be pulse or set")
|
||||
duration = data.get("duration_ms", 0)
|
||||
if mode == "pulse" and (type(duration) is not int or not 1 <= duration <= 10000):
|
||||
raise ValueError("Pulse duration must be 1..10000 ms")
|
||||
active = data.get("active", True)
|
||||
if type(active) is not bool:
|
||||
raise ValueError("active must be boolean")
|
||||
with self.lock:
|
||||
if pin not in self.pins:
|
||||
device = self.factory(pin, active_high=polarity, initial_value=False)
|
||||
self.pins[pin] = {"device": device, "polarity": polarity, "deadline": None}
|
||||
entry = self.pins[pin]
|
||||
if entry["polarity"] != polarity:
|
||||
raise ValueError("Pin polarity differs from its earlier configuration; restart bridge to change")
|
||||
self.last_seen = self.clock()
|
||||
if mode == "pulse":
|
||||
entry["device"].on()
|
||||
# Latest pulse restarts the duration; an older timer cannot switch it off.
|
||||
entry["deadline"] = self.clock() + duration / 1000
|
||||
else:
|
||||
entry["device"].on() if active else entry["device"].off()
|
||||
entry["deadline"] = None
|
||||
return {"ok": True, "pin": pin, "active": mode == "pulse" or active}
|
||||
|
||||
def tick(self):
|
||||
with self.lock:
|
||||
now = self.clock()
|
||||
for entry in self.pins.values():
|
||||
if now - self.last_seen > 3 or (entry["deadline"] is not None and now >= entry["deadline"]):
|
||||
entry["device"].off()
|
||||
entry["deadline"] = None
|
||||
|
||||
def close(self):
|
||||
with self.lock:
|
||||
for entry in self.pins.values():
|
||||
try:
|
||||
entry["device"].off()
|
||||
finally:
|
||||
entry["device"].close()
|
||||
self.ledger.close()
|
||||
|
||||
|
||||
def handler_for(outputs):
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
try:
|
||||
if self.headers.get("Origin"):
|
||||
raise ValueError("Browser requests are not supported")
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
if not 0 <= length <= 4096:
|
||||
raise ValueError("Request is too large")
|
||||
data = json.loads(self.rfile.read(length) or b"{}")
|
||||
if self.path == "/heartbeat":
|
||||
outputs.heartbeat()
|
||||
result = {"ok": True}
|
||||
elif self.path == "/output":
|
||||
result = outputs.command(data)
|
||||
print("GPIO:", data, flush=True)
|
||||
else:
|
||||
raise ValueError("Unknown endpoint")
|
||||
code = 200
|
||||
except Exception as exc:
|
||||
result, code = {"ok": False, "error": str(exc)}, 400
|
||||
print("GPIO ERROR:", exc, flush=True)
|
||||
body = json.dumps(result).encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *_args):
|
||||
pass
|
||||
return Handler
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--controller", default="cabinet_01_gpio")
|
||||
parser.add_argument("--pins", default="17", help="BCM pin allowlist, comma separated")
|
||||
parser.add_argument("--ledger", default=str(Path.home() / ".local/state/arcade_gpio/requests.sqlite3"))
|
||||
args = parser.parse_args()
|
||||
pins = [int(n) for n in args.pins.split(",")]
|
||||
if not pins or any(pin < 0 or pin > 27 for pin in pins):
|
||||
parser.error("Use BCM header GPIO numbers 0..27")
|
||||
from gpiozero import OutputDevice
|
||||
Path(args.ledger).parent.mkdir(parents=True, exist_ok=True)
|
||||
outputs = Outputs(OutputDevice, args.controller, pins, ledger=args.ledger)
|
||||
server = HTTPServer(("127.0.0.1", 8765), handler_for(outputs))
|
||||
server.timeout = .05
|
||||
stop = threading.Event()
|
||||
signal.signal(signal.SIGTERM, lambda *_: stop.set())
|
||||
signal.signal(signal.SIGINT, lambda *_: stop.set())
|
||||
|
||||
def watchdog():
|
||||
while not stop.wait(.02):
|
||||
outputs.tick()
|
||||
|
||||
worker = threading.Thread(target=watchdog, daemon=True)
|
||||
worker.start()
|
||||
print(f"GPIO bridge ready: controller={args.controller}, BCM pins={pins}, localhost:8765", flush=True)
|
||||
try:
|
||||
while not stop.is_set():
|
||||
server.handle_request()
|
||||
finally:
|
||||
stop.set()
|
||||
worker.join()
|
||||
outputs.close()
|
||||
server.server_close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user