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
2 changes: 2 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ jobs:
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: '24'
- name: Verify pinned upstream controller opcode source
run: node interfaces/controller-wire/consumer.mjs --pakar --check --upstream
- uses: arduino/setup-arduino-cli@81d310742121c928ea9c8bbd407b4217b432ae02 # v2
- name: Install pinned host-test headers only (no board SDKs)
shell: pwsh
Expand Down
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# PakarController continuation

- Wire opcode ownership: PCController Project/ProtocolContract.json owns stable
namespace allocations. Reuse its pinned interfaces/controller-wire generator;
never maintain numeric opcode tables in firmware, clients or provisioning tools.
Run consumer.mjs --pakar --check before builds. Native A5/1 and Pakar A5/0 are
fixed namespace admission, not versions: never relax marker checks, guess a
dialect, reinterpret an unknown opcode or assume ACK for an unknown request.
Keep published assignments append-only; capability omissions return Unsupported.

- 2026-10-29 keycontroller authorization: David directly approved paired ESP/AVR deployment and testing through the tablet and said, "from now on no asking for the keycontroller." Continue scoped keycontroller updates/tests without repeated approval or PBX calls, preserving its saved configuration and rollback. This does not lift ledcontroller's separate production hold. David also instructed to skip PBX and ask directly in chat whenever a different action actually requires approval.

- LED incident exception: David directly authorized immediate ESP board updates and diagnostics for the first22-cell/480-pixel output regression without another permission/PBX round-trip. This scoped authority covers the LED recovery deployment, reboot and output comparison/feedback; it does not lift the production hold for unrelated features or AVR programming. Preserve the saved scene and verified rollback, and never claim optical success from GPIO capture/counters alone.
Expand Down
10 changes: 5 additions & 5 deletions desktop/src/board_silent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,25 +143,25 @@ impl Transaction {
}
pub fn request(&self) -> (u8, Vec<u8>) {
if self.phase == 1 {
(5, self.next.as_ref().unwrap().bytes.to_vec())
(crate::protocol_opcodes::controller::SET_SETTINGS, self.next.as_ref().unwrap().bytes.to_vec())
} else {
(4, vec![])
(crate::protocol_opcodes::controller::GET_SETTINGS, vec![])
}
}
pub fn accept(&mut self, opcode: u8, payload: &[u8]) -> Result<Option<Settings>, String> {
if opcode == 0x82 {
if opcode == crate::protocol_opcodes::controller::ERROR_RESPONSE {
return Err(format!(
"Board rejected settings: {payload:02x?}; nothing retried"
));
}
if self.phase == 1 {
if opcode != 0x80 || payload != [5, 0] {
if opcode != crate::protocol_opcodes::controller::ACK || payload != [crate::protocol_opcodes::controller::SET_SETTINGS, 0] {
return Err("Missing AVR settings ACK; outcome unknown, no replay".into());
}
self.phase = 2;
return Ok(None);
}
if opcode != 0x91 {
if opcode != crate::protocol_opcodes::controller::SETTINGS_RESPONSE {
return Err("AVR settings response did not match; no state assumed".into());
}
let settings = Settings::parse(&pc_contract::encode_hex(payload))?;
Expand Down
3 changes: 2 additions & 1 deletion desktop/src/cell_batch.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
//! One board-owned atomic selection mutation, never a fan-out/replay queue.
//! Reuses PCController Session's intent admission/ACK/no-late-write rule through
//! the existing HTTP Pin and USB Admission; the board owns transition timing.
use crate::protocol_opcodes::pakar as wire;
use crate::{DeviceStatus, Endpoint, peer_http};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};

pub const OPCODE: u8 = 0x5f;
pub const OPCODE: u8 = wire::CELLS_ACTION;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Request {
pub ids: Vec<u8>,
Expand Down
97 changes: 57 additions & 40 deletions desktop/src/main.rs

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion desktop/src/panel_event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@
//! deadlines and no-replay semantics from hostbridge/buzzer_mirror.go. The
//! donor's motion/relay/menu-page IDs do not describe this board and are not
//! imported as capability authority (Project/FrontPanelModel.h).
use crate::protocol_opcodes::pakar as wire;
use serde::{Deserialize, Serialize};
use std::{
collections::VecDeque,
time::{Duration, Instant},
};

pub const USB_OPCODE: u8 = 0xb8;
pub const USB_OPCODE: u8 = wire::PANEL_EVENT;
const KINDS: [&str; 13] = [
"rest", "cell", "mode", "effect", "pixel", "network", "progress", "fault", "complete",
"output", "menu", "scan", "state",
Expand Down
60 changes: 39 additions & 21 deletions desktop/src/pc_contract.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,30 @@
use reqwest::blocking::Client;
use serde_json::Value;
use std::time::{Duration, Instant};
use crate::protocol_opcodes::controller as wire;

pub const PROFILE: u8 = 0x50;
pub fn expected_response(opcode: u8, payload: &[u8]) -> Option<u8> {
match opcode {
wire::HELLO => Some(wire::HELLO_RESPONSE),
wire::GET_STATUS => Some(wire::STATUS_RESPONSE),
wire::GET_SETTINGS => Some(wire::SETTINGS_RESPONSE),
wire::TEMPERATURE_LIST => Some(wire::TEMPERATURE_LIST_RESPONSE),
wire::PWM_GET => Some(wire::PWM_VALUES_RESPONSE),
wire::STATUS_PROFILE_GET => Some(wire::STATUS_PROFILE_RESPONSE),
wire::RADIO_LEARN_LIST => Some(wire::RADIO_LEARN_LIST_RESPONSE),
wire::I2C_TRANSFER => Some(wire::I2C_TRANSFER_RESPONSE),
wire::FRONT_PANEL_GET => Some(wire::FRONT_PANEL_RESPONSE),
wire::MENU_LIST => Some(wire::MENU_LIST_RESPONSE),
wire::MENU_LAYOUT_GET => Some(wire::MENU_LAYOUT_RESPONSE),
wire::HOST_MENU_STATE_GET => Some(wire::HOST_MENU_STATE_RESPONSE),
wire::AUTOMATION_LIST => Some(wire::AUTOMATION_LIST_RESPONSE),
wire::AUTOMATION_PUT => Some(wire::AUTOMATION_RECORD_RESPONSE),
wire::MACRO_STEP if matches!(payload.first().copied(), Some(2 | 5)) => Some(wire::MACRO_STATUS_RESPONSE),
wire::SET_STREAM_PERIOD | wire::SET_SETTINGS | wire::BUZZER | wire::PWM_SET | wire::PWM_ALL_OFF | wire::STATUS_RGB | wire::ADDRESSABLE_LED | wire::STATUS_EFFECT | wire::STATUS_PROFILE_SET | wire::RADIO_TRANSMIT | wire::RADIO_LEARN_START | wire::RADIO_LEARN_CANCEL | wire::RADIO_LEARN_CLEAR | wire::RADIO_LEARN_REMOVE | wire::MENU_ACTION | wire::RELAY_SET | wire::RELAY_SIDE | wire::RELAY_ALL_OFF | wire::RELAY_TEST | wire::RESET | wire::MENU_SET_PAGE | wire::DISPLAY_TEXT | wire::MACRO_START | wire::MACRO_CANCEL | wire::MACRO_STEP | wire::REMOTE_KEY_GESTURE | wire::RADIO_LEARN_REPLACE | wire::MENU_LAYOUT_SET | wire::HOST_MENU_DIRECTORY | wire::HOST_MENU_CONTENT | wire::PROGRAM_STATE | wire::AUTOMATION_REMOVE | wire::AUTOMATION_CLEAR => Some(wire::ACK),
_ => None,
}
}

pub fn decode_hex(input: &str) -> Result<Vec<u8>, String> {
if input.len() > 96 || input.len() % 2 != 0 || !input.is_ascii() {
Expand Down Expand Up @@ -237,7 +259,8 @@ pub fn request(
if payload.len() > 48 {
return Err("PCController payload exceeds 48 bytes".into());
}
let payload_for_request = payload;
let expected = u64::from(expected_response(opcode, payload)
.ok_or("Unknown controller response contract; nothing sent, no fallback")?);
let value: Value = client
.post(format!("{}/api/avr/contracts", base.trim_end_matches('/')))
.basic_auth(user, Some(password))
Expand Down Expand Up @@ -285,7 +308,7 @@ pub fn request(
.as_str()
.ok_or("Missing response payload")?,
)?;
if response == 0x82 {
if response == u64::from(wire::ERROR_RESPONSE) {
if payload.len() != 2 || payload[0] != opcode || !(1..=6).contains(&payload[1])
{
return Err(
Expand All @@ -299,25 +322,7 @@ pub fn request(
"AVR rejected opcode 0x{opcode:02x}: {payload:02x?}"
));
}
let expected = match opcode {
1 => 0x81,
2 => 0x90,
4 => 0x91,
6 => 0x95,
0x15 => 0x92,
0x18 => 0x9f,
0x24 => 0x94,
0x36 => 0x93,
0x3c => 0x96,
0x3e => 0x97,
0x40 => 0x99,
0x44 => 0x9b,
0x46 => 0xa1,
0x47 => 0xa2,
0x3b if matches!(payload_for_request.first().copied(), Some(2 | 5)) => 0x98,
_ => 0x80,
};
if response != expected || (response == 0x80 && payload != [opcode, 0]) {
if response != expected || (response == u64::from(wire::ACK) && payload != [opcode, 0]) {
return Err(
"Response does not match the request; operation outcome is unknown".into(),
);
Expand All @@ -342,6 +347,19 @@ pub fn request(
mod tests {
use super::*;
#[test]
fn opcode_namespace_has_explicit_response_contract_for_every_registered_request() {
let registry: Value = serde_json::from_str(include_str!("../../docs/pccontroller/ProtocolContract.json")).unwrap();
for opcode in 0..=255_u8 {
let request = registry["opcodes"].as_array().unwrap().iter().any(|row|
row["value"].as_u64() == Some(u64::from(opcode)) && opcode < 128);
assert_eq!(expected_response(opcode, &[]).is_some(), request);
}
assert_eq!(expected_response(wire::MACRO_STEP, &[2]), Some(wire::MACRO_STATUS_RESPONSE));
assert_eq!(expected_response(wire::MACRO_STEP, &[5]), Some(wire::MACRO_STATUS_RESPONSE));
assert_eq!(expected_response(wire::MACRO_STEP, &[0]), Some(wire::ACK));
assert_eq!(expected_response(0xfc, &[]), None);
}
#[test]
fn actual_donor_registry_has_no_pwm_audio_alias() {
assert_eq!(opcode("PWM_SET").unwrap(), 0x11);
assert_eq!(opcode("DISPLAY_TEXT").unwrap(), 0x38);
Expand Down
6 changes: 6 additions & 0 deletions desktop/src/peer_http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,10 @@ mod tests {
let accept = || loop {
match listener.accept() {
Ok((stream, _)) => {
// Winsock may inherit the listener's nonblocking mode.
// Only accept is polled; fixture HTTP/WS I/O must wait
// for bytes within the bounded socket timeouts below.
stream.set_nonblocking(false).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
Expand Down Expand Up @@ -1221,6 +1225,8 @@ mod tests {
}
Err(error) => panic!("{error}"),
};
// Do not inherit the polled listener's mode on Windows.
socket.set_nonblocking(false).unwrap();
socket
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
Expand Down
7 changes: 4 additions & 3 deletions desktop/src/peer_identity.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
//! Descriptive peer identity and minimum supported releases. API paths and
//! optional fields remain one living contract, never version-selected adapters.
use crate::protocol_opcodes::pakar as wire;
use serde::{Deserialize, Serialize};
use std::sync::OnceLock;

pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const PRODUCT: &str = "pakarcontroller-native";
pub const USB_REPORT: u8 = 0x61;
pub const USB_REPORT: u8 = wire::PEER_REPORT;
const POLICY: &str = include_str!("../../interfaces/peer-policy.json");
pub fn session_id() -> &'static str {
static ID: OnceLock<String> = OnceLock::new();
Expand Down Expand Up @@ -326,7 +327,7 @@ impl UsbReportSupport {
) -> bool {
if !enforce
&& request.0 == USB_REPORT
&& response == (0x82, request.1)
&& response == (wire::ERROR_RESPONSE, request.1)
&& payload == [USB_REPORT, 2]
{
self.unavailable = true;
Expand Down Expand Up @@ -357,7 +358,7 @@ pub fn report_chunks(
/// Rejection is not necessarily a release-policy mismatch: report validation,
/// capacity, maintenance and malformed responses must remain distinguishable.
pub fn usb_introduction_error(request: u8, response: u8, payload: &[u8]) -> String {
if response == 0x82 && payload.len() == 2 && payload[0] == request {
if response == wire::ERROR_RESPONSE && payload.len() == 2 && payload[0] == request {
let reason = match payload[1] {
1 => "bad envelope",
2 => "unsupported caller or introduction",
Expand Down
143 changes: 143 additions & 0 deletions desktop/src/protocol_opcodes.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// Generated from PCController Project/ProtocolContract.json; DO NOT EDIT.
#[allow(dead_code)]
pub mod pakar {
pub const MAGIC: u8 = 0xA5;
pub const RESERVED: u8 = 0;
pub const MAXIMUM_PAYLOAD: usize = 48;
pub const HELLO: u8 = 0x01;
pub const GET_STATUS: u8 = 0x02;
pub const SET_STREAM_PERIOD: u8 = 0x03;
pub const BUZZER: u8 = 0x10;
pub const AUDIO_CUE: u8 = 0x11;
pub const AUDIO_STREAM: u8 = 0x12;
pub const DISPLAY_TEXT: u8 = 0x38;
pub const SET_MODE: u8 = 0x4B;
pub const CELL_ACTION: u8 = 0x4C;
pub const GAME_RESET: u8 = 0x4D;
pub const OUTPUT_MASK: u8 = 0x4E;
pub const PING: u8 = 0x4F;
pub const PROGRAMMING_STATE: u8 = 0x50;
pub const SET_BRIGHTNESS: u8 = 0x51;
pub const SET_LED_PATTERN: u8 = 0x52;
pub const NETWORK_CONFIG: u8 = 0x53;
pub const ACCESS_CONFIG: u8 = 0x54;
pub const DEVICE_NAME: u8 = 0x55;
pub const PC_CONTRACT: u8 = 0x56;
pub const PC_CONTRACT_CHUNK: u8 = 0x57;
pub const GET_TELEMETRY: u8 = 0x58;
pub const GET_IDENTITY: u8 = 0x59;
pub const ESP_PANEL: u8 = 0x5A;
pub const AVR_RX_DIAGNOSTIC: u8 = 0x5B;
pub const SET_LED_OUTPUT: u8 = 0x5C;
pub const GET_TABLE_SNAPSHOT: u8 = 0x5D;
pub const TABLE_COMMAND: u8 = 0x5E;
pub const CELLS_ACTION: u8 = 0x5F;
pub const SHELL_REQUEST: u8 = 0x60;
pub const PEER_REPORT: u8 = 0x61;
pub const CELL_ASSIGNMENTS: u8 = 0x62;
pub const CELL_LOCK: u8 = 0x63;
pub const HOST_DEMAND: u8 = 0x64;
pub const PIXEL_DIAGNOSTIC: u8 = 0x65;
pub const KEY_MASK: u8 = 0x66;
pub const KEY_SETTINGS: u8 = 0x67;
pub const GAME_PRESS: u8 = 0x68;
pub const KEY_PRESENTATION: u8 = 0x69;
pub const USB_API: u8 = 0x6A;
pub const ACK: u8 = 0x80;
pub const HELLO_RESPONSE: u8 = 0x81;
pub const ERROR_RESPONSE: u8 = 0x82;
pub const STATUS_RESPONSE: u8 = 0x90;
pub const GAME_STATE: u8 = 0xA3;
pub const INPUT_EVENT: u8 = 0xA4;
pub const CELL_EVENT: u8 = 0xA5;
pub const PONG: u8 = 0xA6;
pub const PC_CONTRACT_RESPONSE: u8 = 0xB0;
pub const TELEMETRY_RESPONSE: u8 = 0xB1;
pub const IDENTITY_RESPONSE: u8 = 0xB2;
pub const ESP_PANEL_RESPONSE: u8 = 0xB3;
pub const AVR_RX_DIAGNOSTIC_RESPONSE: u8 = 0xB4;
pub const RESET_TELEMETRY_RESPONSE: u8 = 0xB5;
pub const TABLE_SNAPSHOT_RESPONSE: u8 = 0xB6;
pub const TABLE_COMMAND_STATE: u8 = 0xB7;
pub const PANEL_EVENT: u8 = 0xB8;
pub const SHELL_RESPONSE: u8 = 0xB9;
pub const HOST_DEMAND_RESPONSE: u8 = 0xBA;
pub const AUDIO_STREAM_RESPONSE: u8 = 0xBC;
pub const USB_API_RESPONSE: u8 = 0xBD;
}

#[allow(dead_code)]
pub mod controller {
pub const MAGIC: u8 = 0xA5;
pub const RESERVED: u8 = 1;
pub const MAXIMUM_PAYLOAD: usize = 48;
pub const HELLO: u8 = 0x01;
pub const GET_STATUS: u8 = 0x02;
pub const SET_STREAM_PERIOD: u8 = 0x03;
pub const GET_SETTINGS: u8 = 0x04;
pub const SET_SETTINGS: u8 = 0x05;
pub const TEMPERATURE_LIST: u8 = 0x06;
pub const BUZZER: u8 = 0x10;
pub const PWM_SET: u8 = 0x11;
pub const PWM_ALL_OFF: u8 = 0x12;
pub const STATUS_RGB: u8 = 0x14;
pub const PWM_GET: u8 = 0x15;
pub const ADDRESSABLE_LED: u8 = 0x16;
pub const STATUS_EFFECT: u8 = 0x17;
pub const STATUS_PROFILE_GET: u8 = 0x18;
pub const STATUS_PROFILE_SET: u8 = 0x19;
pub const RADIO_TRANSMIT: u8 = 0x20;
pub const RADIO_LEARN_START: u8 = 0x21;
pub const RADIO_LEARN_CANCEL: u8 = 0x22;
pub const RADIO_LEARN_CLEAR: u8 = 0x23;
pub const RADIO_LEARN_LIST: u8 = 0x24;
pub const RADIO_LEARN_REMOVE: u8 = 0x25;
pub const MENU_ACTION: u8 = 0x30;
pub const RELAY_SET: u8 = 0x31;
pub const RELAY_SIDE: u8 = 0x32;
pub const RELAY_ALL_OFF: u8 = 0x33;
pub const RELAY_TEST: u8 = 0x34;
pub const RESET: u8 = 0x35;
pub const I2C_TRANSFER: u8 = 0x36;
pub const MENU_SET_PAGE: u8 = 0x37;
pub const DISPLAY_TEXT: u8 = 0x38;
pub const MACRO_START: u8 = 0x39;
pub const MACRO_CANCEL: u8 = 0x3A;
pub const MACRO_STEP: u8 = 0x3B;
pub const FRONT_PANEL_GET: u8 = 0x3C;
pub const REMOTE_KEY_GESTURE: u8 = 0x3D;
pub const MENU_LIST: u8 = 0x3E;
pub const RADIO_LEARN_REPLACE: u8 = 0x3F;
pub const MENU_LAYOUT_GET: u8 = 0x40;
pub const MENU_LAYOUT_SET: u8 = 0x41;
pub const HOST_MENU_DIRECTORY: u8 = 0x42;
pub const HOST_MENU_CONTENT: u8 = 0x43;
pub const HOST_MENU_STATE_GET: u8 = 0x44;
pub const PROGRAM_STATE: u8 = 0x45;
pub const AUTOMATION_LIST: u8 = 0x46;
pub const AUTOMATION_PUT: u8 = 0x47;
pub const AUTOMATION_REMOVE: u8 = 0x48;
pub const AUTOMATION_CLEAR: u8 = 0x49;
pub const ACK: u8 = 0x80;
pub const HELLO_RESPONSE: u8 = 0x81;
pub const ERROR_RESPONSE: u8 = 0x82;
pub const STATUS_RESPONSE: u8 = 0x90;
pub const SETTINGS_RESPONSE: u8 = 0x91;
pub const PWM_VALUES_RESPONSE: u8 = 0x92;
pub const I2C_TRANSFER_RESPONSE: u8 = 0x93;
pub const RADIO_LEARN_LIST_RESPONSE: u8 = 0x94;
pub const TEMPERATURE_LIST_RESPONSE: u8 = 0x95;
pub const FRONT_PANEL_RESPONSE: u8 = 0x96;
pub const MENU_LIST_RESPONSE: u8 = 0x97;
pub const MACRO_STATUS_RESPONSE: u8 = 0x98;
pub const MENU_LAYOUT_RESPONSE: u8 = 0x99;
pub const HOST_MENU_CONTENT_REQUEST: u8 = 0x9A;
pub const HOST_MENU_STATE_RESPONSE: u8 = 0x9B;
pub const SEGMENT_CHANGED: u8 = 0x9C;
pub const BUZZER_CHANGED: u8 = 0x9D;
pub const STATUS_LED_CHANGED: u8 = 0x9E;
pub const STATUS_PROFILE_RESPONSE: u8 = 0x9F;
pub const EVENT: u8 = 0xA0;
pub const AUTOMATION_LIST_RESPONSE: u8 = 0xA1;
pub const AUTOMATION_RECORD_RESPONSE: u8 = 0xA2;
}
7 changes: 4 additions & 3 deletions desktop/src/usb_api.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
//! Bounded API transfer on the existing USB owner; execute once, never replay.
//! Uses the same open/append/execute/read/close contract as mobile UsbTransport.
use crate::protocol_opcodes::pakar as wire;
use crate::{DeviceStatus, usb_table};
use serde_json::Value;
use std::{sync::{mpsc::Sender, Arc, atomic::{AtomicBool, Ordering}}, time::{Duration, Instant}};
pub const OPCODE: u8 = 0x6a;
pub const RESPONSE: u8 = 0xbd;
pub const OPCODE: u8 = wire::USB_API;
pub const RESPONSE: u8 = wire::USB_API_RESPONSE;

pub struct Work {
pub admission: usb_table::Admission,
Expand Down Expand Up @@ -67,7 +68,7 @@ impl Transaction {
}
pub fn accept(&mut self, opcode: u8, bytes: &[u8]) -> Result<Option<Value>, String> {
if self.action == 4 {
return if opcode == 0x80 && bytes == [OPCODE, 0] { Ok(self.value.take()) }
return if opcode == wire::ACK && bytes == [OPCODE, 0] { Ok(self.value.take()) }
else { Err("USB API close not acknowledged; result not replayed".into()) };
}
if opcode != RESPONSE || !(9..=48).contains(&bytes.len()) || bytes[0] != self.action {
Expand Down
Loading
Loading