Skip to content
Merged
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
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,6 @@ jobs:

- uses: Swatinem/rust-cache@v2

- name: Format
run: cargo fmt --all -- --check

- name: Fetch wp-proto sibling repo
run: |
set -euo pipefail
Expand All @@ -38,6 +35,9 @@ jobs:
git clone --depth 1 https://github.com/dcc-bigfred/wireless-programmer.git "$dest"
fi

- name: Format
run: cargo fmt --all -- --check

- name: Clippy
run: cargo clippy --all-targets -- -D warnings

Expand Down
11 changes: 10 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ description = "Party/event kiosk for BigFred (SSO, accounts, handset pairing, CV
license = "MIT"
publish = false

[workspace]
members = [".", "crates/z21-lan"]

[[bin]]
name = "bigfred-wizard"
path = "src/main.rs"
Expand Down Expand Up @@ -34,6 +37,7 @@ url = "2"
qrcode = { version = "0.14.1", default-features = false, features = ["svg"] }
# Wire types for the wireless-programmer Unix socket (sibling repo).
wp-proto = { path = "../wireless-programmer/crates/wp-proto" }
z21-lan = { path = "crates/z21-lan" }

[profile.release]
lto = true
Expand Down
33 changes: 32 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ else
endif

.PHONY: all build web-build release-musl host test test-release-assertions \
fmt clippy clean dist dev-backend dev-web
fmt clippy clean dist dev-backend dev-web deploy-hub

all: build

Expand Down Expand Up @@ -95,3 +95,34 @@ clean:
$(CARGO) clean
rm -rf "$(WEB_DIR)/node_modules" dist
find "$(WEB_DIR)/dist" -mindepth 1 ! -name .gitkeep -exec rm -rf {} + 2>/dev/null || true

# --- Hub deploy (RO rootfs: binary lives on /data) ------------------------
# Hub runs Dropbear. Older images lack /usr/libexec/sftp-server; -O uses
# legacy scp. Harmless on images that ship openssh sftp-server.
#
# make deploy-hub
# make deploy-hub HUB=192.168.0.10
#
# /etc/init.d/bigfred-wizard prefers /data/opt/bigfred/bin/bigfred-wizard
# over the image copy in /usr/sbin.
HUB ?= 192.168.0.1
HUB_USER ?= root
HUB_SSH ?= $(HUB_USER)@$(HUB)
SCP ?= scp
SCP_OPTS ?= -O
SSH ?= ssh
DIST_ARM64 ?= dist/bigfred-wizard-linux-arm64
HUB_BIN_DIR ?= /data/opt/bigfred/bin

# Upload next to the target and rename: writing in place fails with ETXTBSY
# once the hub is running the /data copy, and rename(2) swaps the inode
# atomically.
deploy-hub: release-musl
@test -f $(DIST_ARM64) || { echo "error: $(DIST_ARM64) missing — run make release-musl" >&2; exit 1; }
$(SSH) $(HUB_SSH) 'mkdir -p $(HUB_BIN_DIR)'
$(SCP) $(SCP_OPTS) $(DIST_ARM64) $(HUB_SSH):$(HUB_BIN_DIR)/.bigfred-wizard.new
$(SSH) $(HUB_SSH) 'set -e; \
cd $(HUB_BIN_DIR); \
chmod 755 .bigfred-wizard.new; \
mv -f .bigfred-wizard.new bigfred-wizard; \
microinit restart bigfred-wizard'
18 changes: 18 additions & 0 deletions crates/z21-lan/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[package]
name = "z21-lan"
version = "0.1.0"
edition = "2021"
description = "Z21 LAN (UDP) framing and locomotive CV programming client"
license = "MIT"
publish = false

[lib]
name = "z21_lan"
path = "src/lib.rs"

[dependencies]
thiserror = "1"
tokio = { version = "1", features = ["net", "time", "sync", "rt", "macros"] }

[dev-dependencies]
tokio = { version = "1", features = ["net", "time", "sync", "rt", "macros", "rt-multi-thread"] }
95 changes: 95 additions & 0 deletions crates/z21-lan/src/client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
//! Connected-UDP Z21 client with a single-flight send-and-await window.

use std::net::SocketAddr;
use std::time::Duration;

use tokio::net::UdpSocket;
use tokio::sync::Mutex;
use tokio::time::{timeout, Instant};

use crate::packets::{cv_read, cv_write, parse_cv_reply, pom_read, pom_write, CvReply};

const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);

#[derive(Debug, thiserror::Error)]
pub enum CvError {
#[error("udp: {0}")]
Io(#[from] std::io::Error),
#[error("timeout waiting for CV reply")]
Timeout,
#[error("decoder NACK")]
Nack,
#[error("decoder short circuit")]
ShortCircuit,
}

/// One connected UDP socket talking to a Z21 / RailBOX LAN command station.
pub struct Z21Client {
sock: UdpSocket,
io: Mutex<()>,
timeout: Duration,
}

impl Z21Client {
pub async fn connect(addr: SocketAddr) -> Result<Self, CvError> {
let sock = UdpSocket::bind("0.0.0.0:0").await?;
sock.connect(addr).await?;
Ok(Self {
sock,
io: Mutex::new(()),
timeout: DEFAULT_TIMEOUT,
})
}

pub fn set_timeout(&mut self, timeout: Duration) {
if !timeout.is_zero() {
self.timeout = timeout;
}
}

pub async fn read_cv(&self, cv: u16) -> Result<u8, CvError> {
let _g = self.io.lock().await;
self.await_result(&cv_read(cv), cv).await
}

pub async fn write_cv(&self, cv: u16, value: u8) -> Result<u8, CvError> {
let _g = self.io.lock().await;
self.await_result(&cv_write(cv, value), cv).await
}

pub async fn read_cv_pom(&self, addr: u16, cv: u16) -> Result<u8, CvError> {
let _g = self.io.lock().await;
self.await_result(&pom_read(addr, cv), cv).await
}

/// POM write has no Z21 reply (spec §6.6).
pub async fn write_cv_pom(&self, addr: u16, cv: u16, value: u8) -> Result<(), CvError> {
let _g = self.io.lock().await;
self.sock.send(&pom_write(addr, cv, value)).await?;
Ok(())
}

async fn await_result(&self, req: &[u8], cv: u16) -> Result<u8, CvError> {
self.sock.send(req).await?;
let deadline = Instant::now() + self.timeout;
let mut buf = [0u8; 1500];
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(CvError::Timeout);
}
let n = match timeout(remaining, self.sock.recv(&mut buf)).await {
Ok(Ok(n)) => n,
Ok(Err(err)) => return Err(err.into()),
Err(_) => return Err(CvError::Timeout),
};
match parse_cv_reply(&buf[..n]) {
Some(CvReply::Result { cv: got, value }) if got == cv => return Ok(value),
Some(CvReply::Result { .. }) => continue,
Some(CvReply::Nack) => return Err(CvError::Nack),
Some(CvReply::NackShortCircuit) => return Err(CvError::ShortCircuit),
None => continue,
}
}
}
}
14 changes: 14 additions & 0 deletions crates/z21-lan/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//! Z21 LAN (UDP) framing and locomotive CV / POM helpers.
//!
//! Packet layouts follow Roco Z21 LAN protocol §6. CV numbers on the wire
//! are zero-based (`0` = CV1). This crate is intentionally small: no radio,
//! no mDNS, no LocoNet dispatch.

mod client;
mod packets;

pub use client::{CvError, Z21Client};
pub use packets::{
cv_read, cv_write, encode_xbus, parse_cv_reply, parse_records, pom_read, pom_write, xor_sum,
CvReply, HEADER_XBUS, Z21_UDP_PORT,
};
Loading
Loading