Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
135 changes: 135 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# dramma

Donation kiosk + arcade machine for Hacker Embassy.
Accepts cash (bills + coins), processes donations, and now lets people insert coins to play retro games on RetroArch.

---

## Running

```bash
nix-shell
cargo run
```

The app starts fullscreen. Tap the logo **5 times** to open the diagnostics panel.

---

## Configuration

Create `.config/dramma.toml` next to the binary (or in the working directory you run from):

```toml
token = "your-bearer-token" # For Bot donates

# Optional overrides (these are the defaults):
home_assistant_url = "https://ha.hackem.cc/web-dramma/0?BrowserID=dramma"
cashcode_serial_port = "/dev/serial/by-id/usb-Prolific_Technology_Inc._USB-Serial_Controller_D-if00-port0"
cctalk_serial_port = "/dev/ttyUSB0"
stats_db_path = "data/Stats.db"
```

---

## 🕹️ Setting Up Games (Arcade Mode)

Pressing **PLAY** on the main screen takes the user to the coin-insertion screen where they can select a game and insert money:

> **100 AMD = 5 minutes of playtime**
> **50 AMD = 2 minutes 30 seconds**

When they hit **Launch**, dramma starts RetroArch fullscreen + kiosk and auto-closes it when the time runs out. The speaker will announce "2 minutes left" and "1 minute left".

> ROMs are not included for obvious copyright reasons. You know where to find them.

### Configure games in dramma.toml

Add a `[[games]]` block for each game. `name` is what shows up in the UI, `core` is the path to the `.so`, `rom` is the path to your ROM file.

```toml
retroarch_command = "retroarch"

[[games]]
name = "🧱 Tetris"
core = "/etc/retroarch/cores/nestopia_libretro.so"
rom = "/home/dramma/roms/tetris.nes"

[[games]]
name = "🟡 Pac-Man"
core = "/etc/retroarch/cores/fbneo_libretro.so"
rom = "/home/dramma/roms/pacman.zip"

[[games]]
name = "🦔 Sonic the Hedgehog"
core = "/etc/retroarch/cores/picodrive_libretro.so"
rom = "/home/dramma/roms/sonic.md"

[[games]]
name = "🔫 DOOM"
core = "/etc/retroarch/cores/dosbox_pure_libretro.so"
rom = "/home/dramma/roms/doom.wad"

[[games]]
name = "👊 Street Fighter II"
core = "/etc/retroarch/cores/fbneo_libretro.so"
rom = "/home/dramma/roms/sf2.zip"
```

If `[[games]]` is **not configured**, the UI shows a built-in placeholder list (same names, no actual cores/ROMs). RetroArch will still launch but will open its own menu — not useful in production.

### Test it manually first

Before trusting the machine to do it, test the exact command dramma will run:

```bash
retroarch --fullscreen --kiosk -L /path/to/core_libretro.so /path/to/rom
```

If the game boots correctly, the config is right. If RetroArch opens its menu instead of loading the game, the core or ROM path is wrong.


---

## Money → Time conversion

| Inserted | Play time |
|---|---|
| 50 ֏ | 2 min 30 sec |
| 100 ֏ | 5 min |
| 200 ֏ | 10 min |
| 500 ֏ | 25 min |

---

## Architecture

```
main.rs
├── bill_acceptor — CashCode bill acceptor driver (serial)
├── coin_acceptor — ccTalk coin acceptor driver (serial)
├── donation_handler — Donation flow + inactivity timeout
├── game_handler — Arcade mode: RetroArch lifecycle + session timer
├── home_assistant_handler — Chromium kiosk for HASS page
└── diagnostics_handler — Debug log viewer

src/
├── cashcode.rs — CashCode serial protocol
├── cctalk.rs — ccTalk serial protocol
├── config.rs — dramma.toml loader
├── retroarch.rs — RetroArch process manager
├── sound.rs — Audio (yippee + time warnings)
└── ...

ui/
├── pages/
│ ├── main.slint — Main screen (Donate / Play / HASS)
│ ├── insert_coins.slint — Game selector + coin insertion
│ ├── insert_money.slint — Donation coin insertion
│ ├── donate.slint — Donation form
│ └── ...
└── assets/
├── yippee.wav
├── two_minutes_left.wav
└── one_minute_left.wav
```
24 changes: 15 additions & 9 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,36 +15,42 @@ pub enum ConfigError {
ParseError(#[from] toml::de::Error),
}

/// A single playable game entry, configured via `dramma.toml`.
#[derive(Debug, Clone, Deserialize)]
pub struct GameEntry {
pub name: String,
pub core: String,
pub rom: String,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct Config {
pub token: Option<String>,
pub home_assistant_url: String,
pub hass_api_port: u16,
pub cashcode_serial_port: String,
/// Serial port for the ccTalk coin acceptor. Set to `"auto"` (the
/// default) to probe all available USB serial ports at startup and on
/// every reconnect, which handles `/dev/ttyUSBx` number changes.
pub cctalk_serial_port: String,
Comment on lines 30 to 33

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hass_api_port is added to Config, but there are currently no usages in the codebase, so setting it in dramma.toml has no effect. Either wire this into the Home Assistant control path (e.g., the listener) or remove it until it’s actually used to avoid misleading configuration.

Copilot uses AI. Check for mistakes.
/// Override the value for specific coin positions (channels).
/// Use this when the device has misconfigured coin IDs.
/// Format in dramma.toml:
/// cctalk_coin_overrides = [[1, 50], [3, 500]]
/// means position 1 → 50 AMD, position 3 → 500 AMD.
pub cctalk_coin_overrides: Vec<[i32; 2]>,
pub stats_db_path: String,
pub retroarch_command: String,
pub games: Vec<GameEntry>,
}

impl Default for Config {
fn default() -> Self {
Self {
token: None,
home_assistant_url: "https://ha.hackem.cc/web-dramma/0?BrowserID=dramma".to_string(),
hass_api_port: 8321,
cashcode_serial_port:
"/dev/serial/by-id/usb-Prolific_Technology_Inc._USB-Serial_Controller_D-if00-port0"
.to_string(),
cctalk_serial_port: "auto".to_string(),
cctalk_serial_port: "/dev/ttyUSB0".to_string(),

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cctalk_serial_port default was changed to a fixed /dev/ttyUSB0. The ccTalk driver still supports an "auto" mode (see src/cctalk.rs where AUTO_PORT triggers device scanning) to handle /dev/ttyUSBx renumbering on reconnect. Consider keeping "auto" as the default again (or at least documenting the "auto" option in README/config comments), otherwise deployments are more likely to break after USB re-enumeration.

Suggested change
cctalk_serial_port: "/dev/ttyUSB0".to_string(),
cctalk_serial_port: "auto".to_string(),

Copilot uses AI. Check for mistakes.
cctalk_coin_overrides: Vec::new(),
stats_db_path: "data/Stats.db".to_string(),
retroarch_command: "retroarch".to_string(),
games: Vec::new(),
}
}
}
Expand Down
50 changes: 48 additions & 2 deletions src/home_assistant.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
use log::{error, info};
use std::io::{Read, Write};
use std::net::TcpListener;
use std::process::{Child, Command};
use std::sync::mpsc::Sender;
use std::sync::{Arc, Mutex};

/// Manages a Chromium subprocess for displaying Home Assistant
Expand Down Expand Up @@ -30,7 +33,7 @@ impl ChromiumManager {
// Try chromium first, then chromium-browser as fallback (different Debian versions)
let command_result = Command::new("chromium")
.arg("--app=".to_string() + url)
.arg("--window-size=1280,940")
.arg("--start-fullscreen")
.arg("--window-position=0,0")
.arg("--disable-infobars")
.arg("--noerrdialogs")
Expand All @@ -50,7 +53,7 @@ impl ChromiumManager {
// Fallback to chromium-browser
Command::new("chromium-browser")
.arg("--app=".to_string() + url)
.arg("--window-size=1280,940")
.arg("--start-fullscreen")
.arg("--window-position=0,0")
.arg("--disable-infobars")
.arg("--noerrdialogs")
Expand Down Expand Up @@ -108,3 +111,46 @@ impl Drop for ChromiumManager {
self.close();
}
}

/// Starts a simple HTTP listener for remote control from Home Assistant.
/// When a `POST /close-hass` request is received, sends a signal through `tx`.
#[allow(dead_code)]
pub fn start_close_listener(port: u16, tx: Sender<()>) {
let addr = format!("0.0.0.0:{}", port);
let listener = match TcpListener::bind(&addr) {
Ok(l) => l,
Err(e) => {
error!("Failed to bind HASS close listener on {}: {}", addr, e);
return;
}
};
info!("🏠 Home Assistant close listener on port {}", port);

for stream in listener.incoming() {
let Ok(mut stream) = stream else {
continue;
};
let mut buf = [0u8; 512];
let Ok(n) = stream.read(&mut buf) else {
continue;
};
let request = String::from_utf8_lossy(&buf[..n]);
let first_line = request.lines().next().unwrap_or("");

if first_line.starts_with("POST /close-hass") {
info!("🏠 Received remote close-hass request");
let _ = tx.send(());
let _ = stream.write_all(
b"HTTP/1.1 200 OK\r\nAccess-Control-Allow-Origin: *\r\nContent-Length: 2\r\n\r\nOK",
);
Comment on lines +118 to +145

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

start_close_listener binds to 0.0.0.0 and accepts unauthenticated POST /close-hass requests while also sending Access-Control-Allow-Origin: *. This exposes a network-accessible control endpoint that can be triggered by any host on the network (and potentially by web content via CORS), effectively allowing remote DoS of the kiosk. Please bind to localhost by default and/or require authentication (shared secret/bearer token), and consider using a minimal HTTP parser that reads the full request before acting.

Copilot uses AI. Check for mistakes.
} else if first_line.starts_with("OPTIONS") {
// CORS preflight
let _ = stream.write_all(
b"HTTP/1.1 204 No Content\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: POST, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type\r\n\r\n",
);
} else {
let _ =
stream.write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 9\r\n\r\nNot Found");
}
}
}
Loading
Loading