Initial prototype

This commit is contained in:
Sam W 2022-07-05 12:00:07 +01:00
commit 8de3aa6cce
11 changed files with 2229 additions and 0 deletions

1
.envrc Normal file
View File

@ -0,0 +1 @@
use flake

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/target
.direnv
config.dhall

1695
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

13
Cargo.toml Normal file
View File

@ -0,0 +1,13 @@
[package]
name = "sampad"
version = "0.1.0"
edition = "2021"
[dependencies]
hidapi = "1"
rumqttc = "0.12"
bitvec = "1"
serde = "1"
serde_dhall = "0.11"
log = "*"
env_logger = "*"

16
config.example.dhall Normal file
View File

@ -0,0 +1,16 @@
{ mqtt_servers.default = { host = "1.2.3.4", user = Some "username", pass = Some
"password" }
, layers.default
=
{ default_mapping = Mapping.Print "no mapping!"
, mappings =
let sometopic = "homeassistant/switch/something/set"
in { key_0 =
Mapping.Trigger (Action.MQTTPub { topic = sometopic, payload = "ON" })
, key_1 =
Mapping.Trigger
(Action.MQTTPub { topic = sometopic, payload = "OFF" })
}
}
}

176
flake.lock Normal file
View File

@ -0,0 +1,176 @@
{
"nodes": {
"devshell": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1650900878,
"narHash": "sha256-qhNncMBSa9STnhiLfELEQpYC1L4GrYHNIzyCZ/pilsI=",
"owner": "numtide",
"repo": "devshell",
"rev": "d97df53b5ddaa1cfbea7cddbd207eb2634304733",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "devshell",
"type": "github"
}
},
"flake-utils": {
"locked": {
"lastModified": 1642700792,
"narHash": "sha256-XqHrk7hFb+zBvRg6Ghl+AZDq03ov6OshJLiSWOoX5es=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "846b2ae0fc4cc943637d3d1def4454213e203cba",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_2": {
"locked": {
"lastModified": 1637014545,
"narHash": "sha256-26IZAc5yzlD9FlDT54io1oqG/bBoyka+FJk5guaX4x4=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "bba5dcc8e0b20ab664967ad83d24d64cb64ec4f4",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"naersk": {
"inputs": {
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1651574473,
"narHash": "sha256-wQhFORvRjo8LB2hTmETmv6cbyKGDPbfWqvZ/0chnDE4=",
"owner": "nix-community",
"repo": "naersk",
"rev": "f21309b38e1da0d61b881b6b6d41b81c1aed4e1d",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "naersk",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1643381941,
"narHash": "sha256-pHTwvnN4tTsEKkWlXQ8JMY423epos8wUOhthpwJjtpc=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "5efc8ca954272c4376ac929f4c5ffefcc20551d5",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1652059086,
"narHash": "sha256-CjHSbr6LSFkN4YBdTB6+8ZQmSqhsbiXqAeQ9hQJ/gBI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "934e076a441e318897aa17540f6cf7caadc69028",
"type": "github"
},
"original": {
"id": "nixpkgs",
"type": "indirect"
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 1652059086,
"narHash": "sha256-CjHSbr6LSFkN4YBdTB6+8ZQmSqhsbiXqAeQ9hQJ/gBI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "934e076a441e318897aa17540f6cf7caadc69028",
"type": "github"
},
"original": {
"id": "nixpkgs",
"type": "indirect"
}
},
"nixpkgs_4": {
"locked": {
"lastModified": 1637453606,
"narHash": "sha256-Gy6cwUswft9xqsjWxFYEnx/63/qzaFUwatcbV5GF/GQ=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "8afc4e543663ca0a6a4f496262cd05233737e732",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"devshell": "devshell",
"naersk": "naersk",
"nixpkgs": "nixpkgs_3",
"rust-overlay": "rust-overlay",
"utils": "utils"
}
},
"rust-overlay": {
"inputs": {
"flake-utils": "flake-utils_2",
"nixpkgs": "nixpkgs_4"
},
"locked": {
"lastModified": 1652064227,
"narHash": "sha256-ZpIfELJNVzcxa6+YUr1KfbpTkHh02FASdlHaCC5/Q6w=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "31db726b18ad2620fead6e7cd31b6e57d1cd061b",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"utils": {
"locked": {
"lastModified": 1649676176,
"narHash": "sha256-OWKJratjt2RW151VUlJPRALb7OU2S5s+f0vLj4o1bHM=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "a4b154ebbdc88c8498a5c7b01589addc9e9cb678",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

34
flake.nix Normal file
View File

@ -0,0 +1,34 @@
{
description = "Sampad server";
inputs = {
utils.url = "github:numtide/flake-utils";
devshell.url = "github:numtide/devshell";
naersk.url = "github:nix-community/naersk";
rust-overlay.url = "github:oxalica/rust-overlay";
};
outputs = { self, nixpkgs, utils, naersk, devshell, rust-overlay }:
utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs { inherit system; };
naersk-lib = pkgs.callPackage naersk { };
in
{
defaultPackage = naersk-lib.buildPackage ./.;
defaultApp = utils.lib.mkApp {
drv = self.defaultPackage."${system}";
};
devShell =
let pkgs = import nixpkgs {
inherit system;
overlays = [ devshell.overlay (import rust-overlay)];
};
in
pkgs.devshell.mkShell {
packages = with pkgs; [ dhall (rust-bin.stable.latest.default.override {
extensions = [ "rls" ];
})];
};
});
}

11
readme.md Normal file
View File

@ -0,0 +1,11 @@
# Samw's Another Macro PAD
<img src="https://img.shields.io/badge/PRJ-13-blue" />
<img src="https://img.shields.io/badge/Status-Prototype-orange" />
A flexible macropad server. Receives macropad state reports over USB, maps them to MQTT
commands.
```mermaid
graph LR;
pad<-- USB HID --->sampad
sampad-- MQTT ---MQTT broker
```

57
src/config.rs Normal file
View File

@ -0,0 +1,57 @@
use serde::Deserialize;
use serde_dhall::StaticType;
use std::collections::HashMap;
use std::error::Error;
use std::path::Path;
#[derive(Deserialize, StaticType, Debug)]
pub enum Action {
MQTTPub { topic: String, payload: String }, // Publish something (server, topic, payload)
}
#[derive(Deserialize, StaticType, Debug)]
pub enum Mapping {
NOP, // Do nothing.
Passthrough, // Passthrough to the layer below
ActivateLayer(String), // Activate a layer
Print(String), // Print a string to console
Trigger(Action), // Trigger an action
}
#[derive(Deserialize, Debug)]
pub struct Layer {
pub default_mapping: Mapping, // What do we do if there's no mapping for a button?
pub mappings: HashMap<String, Mapping>, // Button id -> mapping
}
#[derive(Deserialize, Debug)]
pub struct MQTTServer {
pub host: String,
pub port: Option<u16>,
pub user: Option<String>,
pub pass: Option<String>,
pub client_id: Option<String>,
pub topic_prefix: Option<String>,
}
#[derive(Deserialize, Debug)]
pub struct Config {
pub mqtt_servers: HashMap<String, MQTTServer>,
pub layers: HashMap<String, Layer>,
}
impl Config {
pub fn from_file(p: &Path) -> Result<Self, Box<dyn Error>> {
match serde_dhall::from_file(p)
.with_builtin_type("Mapping".to_string(), Mapping::static_type())
.with_builtin_type("Action".to_string(), Action::static_type())
.parse::<Self>()
{
Ok(c) => Ok(c),
Err(e) => {
println!("{}", e);
Err(Box::new(e))
}
}
}
}

93
src/device.rs Normal file
View File

@ -0,0 +1,93 @@
use bitvec::prelude::*;
use hidapi::{DeviceInfo, HidApi, HidDevice};
use std::collections::VecDeque;
use std::error::Error;
const HID_USAGE_PAGE: u16 = 0xFF;
const HID_USAGE: u16 = 0x1;
pub fn get_devices<'a>(hid: &'a HidApi, print: bool) -> Vec<&'a DeviceInfo> {
if print {
println!("All HIDs attached:");
println!("Vendor ID, Product ID, Manufacturer, Produce, Usage Page, Usage");
}
let mut devices = vec![];
for device in hid.device_list() {
if (device.usage_page(), device.usage()) == (HID_USAGE_PAGE, HID_USAGE) {
devices.push(device);
}
if print {
println!(
"{}, {}, {}, {}, {}, {}",
device.vendor_id(),
device.product_id(),
device.manufacturer_string().unwrap_or("Unknown"),
device.product_string().unwrap_or("Unknown"),
device.usage_page(),
device.usage(),
);
}
}
devices
}
#[derive(Debug)]
pub enum ButtonEvent {
KeyDown(u8),
KeyUp(u8),
}
type State = u16;
pub struct ButtonPad<'a> {
dev: &'a HidDevice,
state: State,
queued: VecDeque<ButtonEvent>,
}
impl ButtonPad<'_> {
pub fn new(dev: &HidDevice) -> ButtonPad {
ButtonPad {
dev,
state: 0,
queued: VecDeque::new(),
}
}
}
impl Iterator for ButtonPad<'_> {
type Item = Result<ButtonEvent, Box<dyn Error>>;
fn next(&mut self) -> Option<Self::Item> {
loop {
// If we have queued events pop them first
if let Some(ev) = self.queued.pop_front() {
return Some(Ok(ev));
}
// Read from the device, blocking until the next report
let mut buf: [u8; 2] = [0; 2];
self.dev.read(&mut buf).expect("error reading");
log::info!("{:?}", buf);
let new: u16 = buf[0] as u16 | (buf[1] as u16) << 8;
// Work out what, if anything, changed between the last report and this one, and
// queue all the
self.queued.extend(
self.state
.view_bits::<Lsb0>()
.iter()
.zip(new.view_bits::<Lsb0>().iter())
.enumerate()
.filter_map(
|(i, (state_last, state_new))| match (*state_last, *state_new) {
(false, true) => Some(ButtonEvent::KeyDown(i.try_into().unwrap())),
(true, false) => Some(ButtonEvent::KeyUp(i.try_into().unwrap())),
_ => None,
},
),
);
self.state = new;
}
}
}

130
src/main.rs Normal file
View File

@ -0,0 +1,130 @@
use env_logger::Env;
use hidapi::HidApi;
use log::info;
use rumqttc::{Client, MqttOptions, QoS};
use std::collections::HashMap;
use std::error::Error;
use std::path::Path;
use std::thread;
mod config;
mod device;
struct State<'a> {
mqtt_servers: HashMap<String, Client>,
conf: &'a config::Config,
key_state: [bool; 16],
}
impl<'a> State<'a> {
fn init(c: &'a config::Config) -> Self {
let mut s = State {
mqtt_servers: HashMap::new(),
conf: &c,
key_state: [false; 16],
};
for (id, srv) in c.mqtt_servers.iter() {
let mut opts = MqttOptions::new(
srv.client_id
.as_ref()
.unwrap_or(&"samspad-client".to_owned()),
&srv.host,
srv.port.unwrap_or(1883),
);
// Only set credentials if we have both user and pass specified
if let (Some(u), Some(p)) = (&srv.user, &srv.pass) {
opts.set_credentials(u, p);
}
let (cli, mut conn) = Client::new(opts, 10);
thread::spawn(move || {
for notification in conn.iter() {
match notification {
Ok(n) => println!("Notification = {:?}", n),
Err(e) => {
println!("MQTT error: {}", e);
}
}
}
});
s.mqtt_servers.insert(id.to_string(), cli);
}
s
}
fn handle_button(&mut self, ev: &device::ButtonEvent) {
let layer = &self.conf.layers["default"];
match ev {
device::ButtonEvent::KeyDown(key_id) => {
self.key_state[*key_id as usize] = true;
let mapping = if let Some(mapping) = layer.mappings.get(&format!("key_{}", key_id))
{
mapping
} else {
&layer.default_mapping
};
self.execute_mapping(mapping);
}
device::ButtonEvent::KeyUp(key_id) => {
self.key_state[*key_id as usize] = false;
}
}
}
fn execute_mapping(&mut self, m: &config::Mapping) {
match m {
config::Mapping::Print(s) => println!("{}", s),
config::Mapping::Trigger(config::Action::MQTTPub { topic, payload }) => {
let mut topic = topic.to_owned();
let srv = &self.conf.mqtt_servers["default"];
if let Some(prefix) = &srv.topic_prefix {
topic.insert_str(0, &prefix);
}
let cli = &mut self.mqtt_servers.get_mut("default").unwrap();
cli.publish(topic, QoS::AtLeastOnce, false, payload.as_bytes())
.unwrap();
}
_ => {}
}
}
fn get_led_data(&self) -> [u8; 48] {
let data = self
.key_state
.iter()
.flat_map(|s| if *s { [255, 255, 255] } else { [0, 0, 0] })
.collect::<Vec<u8>>();
data[0..48].try_into().unwrap()
}
}
fn main() -> Result<(), Box<dyn Error>> {
env_logger::Builder::from_env(Env::default().default_filter_or("info")).init();
let conf = config::Config::from_file(Path::new("./config.dhall"))?;
let mut state = State::init(&conf);
let hid = HidApi::new().unwrap();
// TODO: handle multiple devices
let device = device::get_devices(&hid, true)
.get(0)
.expect("No device found")
.open_device(&hid)
.expect("Couldn't open device");
let pad = device::ButtonPad::new(&device);
for ev in pad {
if let Ok(ev) = ev {
info!("{:?}", ev);
state.handle_button(&ev);
let mut data = state.get_led_data().to_vec();
data.insert(0, 0x0);
data.insert(0, 0x0);
log::info!("{:?}", data);
device.write(data.as_slice())?;
}
}
Ok(())
}