Initial prototype
This commit is contained in:
commit
8de3aa6cce
|
@ -0,0 +1,3 @@
|
|||
/target
|
||||
.direnv
|
||||
config.dhall
|
File diff suppressed because it is too large
Load Diff
|
@ -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 = "*"
|
|
@ -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" })
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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" ];
|
||||
})];
|
||||
};
|
||||
});
|
||||
}
|
|
@ -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
|
||||
```
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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(())
|
||||
}
|
Loading…
Reference in New Issue