Compare commits
4
Commits
b0c1c7d966
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83c94f8802 | ||
|
|
7cd6639283 | ||
|
|
3065040189 | ||
|
|
7b2a92012b |
Generated
+728
-400
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -4,10 +4,11 @@ version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
hidapi = "1"
|
||||
hidapi = "2"
|
||||
rumqttc = "0.12"
|
||||
bitvec = "1"
|
||||
serde = "1"
|
||||
serde_dhall = "0.11"
|
||||
tracing = "0.1.35"
|
||||
tracing-subscriber = "0.3.14"
|
||||
chrono = "0.4.19"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
use std::error::Error;
|
||||
use std::process::Command;
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
let hash = Command::new("git")
|
||||
.arg("rev-parse")
|
||||
.arg("--short")
|
||||
.arg("HEAD")
|
||||
.output()?
|
||||
.stdout;
|
||||
println!("cargo:rustc-env=VERSION={}", std::str::from_utf8(&hash)?);
|
||||
Ok(())
|
||||
}
|
||||
+25
-15
@@ -1,16 +1,26 @@
|
||||
{ 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"
|
||||
let T = ./types.dhall
|
||||
|
||||
in { key_0 =
|
||||
Mapping.Trigger (Action.MQTTPub { topic = sometopic, payload = "ON" })
|
||||
, key_1 =
|
||||
Mapping.Trigger
|
||||
(Action.MQTTPub { topic = sometopic, payload = "OFF" })
|
||||
}
|
||||
}
|
||||
}
|
||||
in { mqtt_servers.default
|
||||
=
|
||||
{ host = "1.2.3.4", user = Some "username", pass = Some "password" }
|
||||
, layers.default
|
||||
=
|
||||
{ default_mapping = T.Action.Print "no mapping!"
|
||||
, mappings =
|
||||
let sometopic = "homeassistant/switch/something/set"
|
||||
|
||||
in { key_0 =
|
||||
T.Mapping.Trigger
|
||||
(T.Action.MQTTPub { topic = sometopic, payload = "ON" })
|
||||
, key_1 =
|
||||
T.Mapping.Trigger
|
||||
(T.Action.MQTTPub { topic = sometopic, payload = "OFF" })
|
||||
, key_2 =
|
||||
T.Mapping.TriggerMulti
|
||||
[ T.Action.Print "Doing a thing"
|
||||
, T.Action.MQTTPub
|
||||
{ topic = "thing/foo/bar", payload = "boop" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+50
-34
@@ -6,11 +6,11 @@
|
||||
"nixpkgs": "nixpkgs"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1650900878,
|
||||
"narHash": "sha256-qhNncMBSa9STnhiLfELEQpYC1L4GrYHNIzyCZ/pilsI=",
|
||||
"lastModified": 1678957337,
|
||||
"narHash": "sha256-Gw4nVbuKRdTwPngeOZQOzH/IFowmz4LryMPDiJN/ah4=",
|
||||
"owner": "numtide",
|
||||
"repo": "devshell",
|
||||
"rev": "d97df53b5ddaa1cfbea7cddbd207eb2634304733",
|
||||
"rev": "3e0e60ab37cd0bf7ab59888f5c32499d851edb47",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -36,11 +36,11 @@
|
||||
},
|
||||
"flake-utils_2": {
|
||||
"locked": {
|
||||
"lastModified": 1656065134,
|
||||
"narHash": "sha256-oc6E6ByIw3oJaIyc67maaFcnjYOz1mMcOtHxbEf9NwQ=",
|
||||
"lastModified": 1659877975,
|
||||
"narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "bee6a7250dd1b01844a2de7e02e4df7d8a0a206c",
|
||||
"rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -54,11 +54,11 @@
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1651574473,
|
||||
"narHash": "sha256-wQhFORvRjo8LB2hTmETmv6cbyKGDPbfWqvZ/0chnDE4=",
|
||||
"lastModified": 1679567394,
|
||||
"narHash": "sha256-ZvLuzPeARDLiQUt6zSZFGOs+HZmE+3g4QURc8mkBsfM=",
|
||||
"owner": "nix-community",
|
||||
"repo": "naersk",
|
||||
"rev": "f21309b38e1da0d61b881b6b6d41b81c1aed4e1d",
|
||||
"rev": "88cd22380154a2c36799fe8098888f0f59861a15",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -69,11 +69,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1643381941,
|
||||
"narHash": "sha256-pHTwvnN4tTsEKkWlXQ8JMY423epos8wUOhthpwJjtpc=",
|
||||
"lastModified": 1677383253,
|
||||
"narHash": "sha256-UfpzWfSxkfXHnb4boXZNaKsAcUrZT9Hw+tao1oZxd08=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "5efc8ca954272c4376ac929f4c5ffefcc20551d5",
|
||||
"rev": "9952d6bc395f5841262b006fbace8dd7e143b634",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -85,12 +85,11 @@
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1652059086,
|
||||
"narHash": "sha256-CjHSbr6LSFkN4YBdTB6+8ZQmSqhsbiXqAeQ9hQJ/gBI=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "934e076a441e318897aa17540f6cf7caadc69028",
|
||||
"type": "github"
|
||||
"lastModified": 1678500271,
|
||||
"narHash": "sha256-tRBLElf6f02HJGG0ZR7znMNFv/Uf7b2fFInpTHiHaSE=",
|
||||
"path": "/nix/store/x00nwwv3ima5ri9q6wi7mq9vml4djjgn-source",
|
||||
"rev": "5eb98948b66de29f899c7fe27ae112a47964baf8",
|
||||
"type": "path"
|
||||
},
|
||||
"original": {
|
||||
"id": "nixpkgs",
|
||||
@@ -99,12 +98,11 @@
|
||||
},
|
||||
"nixpkgs_3": {
|
||||
"locked": {
|
||||
"lastModified": 1652059086,
|
||||
"narHash": "sha256-CjHSbr6LSFkN4YBdTB6+8ZQmSqhsbiXqAeQ9hQJ/gBI=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "934e076a441e318897aa17540f6cf7caadc69028",
|
||||
"type": "github"
|
||||
"lastModified": 1678500271,
|
||||
"narHash": "sha256-tRBLElf6f02HJGG0ZR7znMNFv/Uf7b2fFInpTHiHaSE=",
|
||||
"path": "/nix/store/x00nwwv3ima5ri9q6wi7mq9vml4djjgn-source",
|
||||
"rev": "5eb98948b66de29f899c7fe27ae112a47964baf8",
|
||||
"type": "path"
|
||||
},
|
||||
"original": {
|
||||
"id": "nixpkgs",
|
||||
@@ -113,11 +111,11 @@
|
||||
},
|
||||
"nixpkgs_4": {
|
||||
"locked": {
|
||||
"lastModified": 1656401090,
|
||||
"narHash": "sha256-bUS2nfQsvTQW2z8SK7oEFSElbmoBahOPtbXPm0AL3I4=",
|
||||
"lastModified": 1665296151,
|
||||
"narHash": "sha256-uOB0oxqxN9K7XGF1hcnY+PQnlQJ+3bP2vCn/+Ru/bbc=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "16de63fcc54e88b9a106a603038dd5dd2feb21eb",
|
||||
"rev": "14ccaaedd95a488dd7ae142757884d8e125b3363",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -142,11 +140,11 @@
|
||||
"nixpkgs": "nixpkgs_4"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1657248581,
|
||||
"narHash": "sha256-VSAhHCruQCFWzTz7ZSW5QLb2xIKrBGeEyEfKds4MXeY=",
|
||||
"lastModified": 1681179516,
|
||||
"narHash": "sha256-Ij/3YM5Gz5AiDKnP77ODJp2RGRRWmGTWYlfnuUT3z78=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "6bd9919005ab6bf72464c9ebdd80f89b0cf82f97",
|
||||
"rev": "9e7373ba5627ffe952f66a3e82e3a375bdc38565",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -155,13 +153,31 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"utils": {
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1649676176,
|
||||
"narHash": "sha256-OWKJratjt2RW151VUlJPRALb7OU2S5s+f0vLj4o1bHM=",
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1681202837,
|
||||
"narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "a4b154ebbdc88c8498a5c7b01589addc9e9cb678",
|
||||
"rev": "cfacdce06f30d2b68473a46042957675eebb3401",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -27,16 +27,17 @@
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
|
||||
overlays = [devshell.overlay (import rust-overlay)];
|
||||
overlays = [devshell.overlays.default (import rust-overlay)];
|
||||
};
|
||||
in
|
||||
pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
# Dhall (config) stuff
|
||||
dhall
|
||||
dhall-json
|
||||
dhall-lsp-server
|
||||
# Rust!
|
||||
(rust-bin.stable.latest.default.override {
|
||||
(rust-bin.stable."1.68.1".default.override {
|
||||
extensions = ["rls"];
|
||||
})
|
||||
# Build requirements
|
||||
@@ -45,6 +46,7 @@
|
||||
openssl
|
||||
# Utils
|
||||
mosquitto # So we can poke mqtt
|
||||
usbutils
|
||||
];
|
||||
};
|
||||
formatter = pkgs.alejandra;
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 256 256" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g id="Layer1">
|
||||
<rect x="0" y="0" width="256" height="256" style="fill:rgb(57,57,57);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.933333,0,0,0.933333,8.53333,8.53333)">
|
||||
<path d="M185.333,209C185.333,201.825 179.508,196 172.333,196L146.333,196C139.158,196 133.333,201.825 133.333,209L133.333,235C133.333,242.175 139.158,248 146.333,248L172.333,248C179.508,248 185.333,242.175 185.333,235L185.333,209ZM248,209C248,201.825 242.175,196 235,196L209,196C201.825,196 196,201.825 196,209L196,235C196,242.175 201.825,248 209,248L235,248C242.175,248 248,242.175 248,235L248,209ZM122.667,209C122.667,201.825 116.842,196 109.667,196L83.667,196C76.492,196 70.667,201.825 70.667,209L70.667,235C70.667,242.175 76.492,248 83.667,248L109.667,248C116.842,248 122.667,242.175 122.667,235L122.667,209ZM60,209C60,201.825 54.175,196 47,196L21,196C13.825,196 8,201.825 8,209L8,235C8,242.175 13.825,248 21,248L47,248C54.175,248 60,242.175 60,235L60,209ZM122.667,146.333C122.667,139.158 116.842,133.333 109.667,133.333L83.667,133.333C76.492,133.333 70.667,139.158 70.667,146.333L70.667,172.333C70.667,179.508 76.492,185.333 83.667,185.333L109.667,185.333C116.842,185.333 122.667,179.508 122.667,172.333L122.667,146.333ZM60,146.333C60,139.158 54.175,133.333 47,133.333L21,133.333C13.825,133.333 8,139.158 8,146.333L8,172.333C8,179.508 13.825,185.333 21,185.333L47,185.333C54.175,185.333 60,179.508 60,172.333L60,146.333ZM185.333,146.333C185.333,139.158 179.508,133.333 172.333,133.333L146.333,133.333C139.158,133.333 133.333,139.158 133.333,146.333L133.333,172.333C133.333,179.508 139.158,185.333 146.333,185.333L172.333,185.333C179.508,185.333 185.333,179.508 185.333,172.333L185.333,146.333ZM248,146.333C248,139.158 242.175,133.333 235,133.333L209,133.333C201.825,133.333 196,139.158 196,146.333L196,172.333C196,179.508 201.825,185.333 209,185.333L235,185.333C242.175,185.333 248,179.508 248,172.333L248,146.333ZM185.333,83.667C185.333,76.492 179.508,70.667 172.333,70.667L146.333,70.667C139.158,70.667 133.333,76.492 133.333,83.667L133.333,109.667C133.333,116.842 139.158,122.667 146.333,122.667L172.333,122.667C179.508,122.667 185.333,116.842 185.333,109.667L185.333,83.667ZM248,83.667C248,76.492 242.175,70.667 235,70.667L209,70.667C201.825,70.667 196,76.492 196,83.667L196,109.667C196,116.842 201.825,122.667 209,122.667L235,122.667C242.175,122.667 248,116.842 248,109.667L248,83.667ZM122.667,83.667C122.667,76.492 116.842,70.667 109.667,70.667L83.667,70.667C76.492,70.667 70.667,76.492 70.667,83.667L70.667,109.667C70.667,116.842 76.492,122.667 83.667,122.667L109.667,122.667C116.842,122.667 122.667,116.842 122.667,109.667L122.667,83.667ZM60,83.667C60,76.492 54.175,70.667 47,70.667L21,70.667C13.825,70.667 8,76.492 8,83.667L8,109.667C8,116.842 13.825,122.667 21,122.667L47,122.667C54.175,122.667 60,116.842 60,109.667L60,83.667ZM185.333,21C185.333,13.825 179.508,8 172.333,8L146.333,8C139.158,8 133.333,13.825 133.333,21L133.333,47C133.333,54.175 139.158,60 146.333,60L172.333,60C179.508,60 185.333,54.175 185.333,47L185.333,21ZM248,21C248,13.825 242.175,8 235,8L209,8C201.825,8 196,13.825 196,21L196,47C196,54.175 201.825,60 209,60L235,60C242.175,60 248,54.175 248,47L248,21ZM60,21C60,13.825 54.175,8 47,8L21,8C13.825,8 8,13.825 8,21L8,47C8,54.175 13.825,60 21,60L47,60C54.175,60 60,54.175 60,47L60,21ZM122.667,21C122.667,13.825 116.842,8 109.667,8L83.667,8C76.492,8 70.667,13.825 70.667,21L70.667,47C70.667,54.175 76.492,60 83.667,60L109.667,60C116.842,60 122.667,54.175 122.667,47L122.667,21Z" style="fill:url(#_Linear1);"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(224,224,-224,224,16,16)"><stop offset="0" style="stop-color:rgb(255,173,77);stop-opacity:1"/><stop offset="0.58" style="stop-color:rgb(174,222,199);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(137,245,255);stop-opacity:1"/></linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
+33
-12
@@ -1,5 +1,7 @@
|
||||
use crate::StdError;
|
||||
use chrono::Utc;
|
||||
use serde::Deserialize;
|
||||
use serde_dhall::StaticType;
|
||||
use serde_dhall::{SimpleType, StaticType};
|
||||
use std::collections::HashMap;
|
||||
use std::error::Error;
|
||||
use std::fmt;
|
||||
@@ -24,7 +26,11 @@ impl fmt::Display for Action {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, StaticType, Debug)]
|
||||
// Newtype wrapper around a Vec of actions.
|
||||
// serde(transparent) and the imp StaticType below ensures we ignore the outer struct and
|
||||
// transparently deserialize into the inner vec.
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[serde(transparent)]
|
||||
pub struct Actions(Vec<Action>);
|
||||
|
||||
impl Actions {
|
||||
@@ -40,6 +46,12 @@ impl fmt::Display for Actions {
|
||||
}
|
||||
}
|
||||
|
||||
impl StaticType for Actions {
|
||||
fn static_type() -> SimpleType {
|
||||
SimpleType::List(Box::new(Action::static_type()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, StaticType, Debug)]
|
||||
pub enum Mapping {
|
||||
NOP, // Do nothing.
|
||||
@@ -87,16 +99,25 @@ pub struct Config {
|
||||
|
||||
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())
|
||||
serde_dhall::from_file(p)
|
||||
.parse::<Self>()
|
||||
{
|
||||
Ok(c) => Ok(c),
|
||||
Err(e) => {
|
||||
println!("{}", e);
|
||||
Err(Box::new(e))
|
||||
}
|
||||
}
|
||||
.map_err(|e| e.into())
|
||||
}
|
||||
|
||||
pub fn generate_prelude() -> StdError<()> {
|
||||
let mut m = HashMap::new();
|
||||
m.insert("Action".to_string(), Action::static_type());
|
||||
m.insert("Mapping".to_string(), Mapping::static_type());
|
||||
println!(
|
||||
"-- Sampad Dhall types, autogenerated by git#{} on {}",
|
||||
option_env!("VERSION").unwrap_or("unknown"),
|
||||
Utc::now(),
|
||||
);
|
||||
println!(
|
||||
"{{ Action = {}, Mapping = {} }}",
|
||||
Action::static_type(),
|
||||
Mapping::static_type()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
+17
-3
@@ -5,11 +5,14 @@ use config::{Action, Config, Mapping};
|
||||
use hidapi::HidApi;
|
||||
use rumqttc::{Client, MqttOptions, QoS};
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::error::Error;
|
||||
use std::path::Path;
|
||||
use std::thread;
|
||||
use tracing::{event, Level};
|
||||
|
||||
type StdError<T> = Result<T, Box<dyn Error>>;
|
||||
|
||||
struct State<'a> {
|
||||
mqtt_servers: HashMap<String, Client>,
|
||||
conf: &'a Config,
|
||||
@@ -107,7 +110,19 @@ impl<'a> State<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
fn main() -> StdError<()> {
|
||||
let args: Vec<_> = env::args().collect();
|
||||
if let [prog, args @ ..] = &args[..] {
|
||||
if args == ["generate-dhall"] {
|
||||
return Config::generate_prelude();
|
||||
} else if args.len() > 0 {
|
||||
eprintln!("Usage: {} [generate-dhall]", prog);
|
||||
return Err("Invalid usage".into());
|
||||
}
|
||||
} else {
|
||||
unreachable!();
|
||||
}
|
||||
|
||||
tracing_subscriber::fmt::init();
|
||||
event!(Level::INFO, "Starting...");
|
||||
let conf = Config::from_file(Path::new("./config.dhall"))?;
|
||||
@@ -120,8 +135,7 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
let device = device::get_devices(&hid, true)
|
||||
.get(0)
|
||||
.expect("No device found")
|
||||
.open_device(&hid)
|
||||
.expect("Couldn't open device");
|
||||
.open_device(&hid)?;
|
||||
|
||||
let pad = device::ButtonPad::new(&device);
|
||||
for ev in pad {
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
-- Sampad Dhall types, autogenerated by git#3065040 on 2022-07-12 12:53:11.933270598 UTC
|
||||
{ Action =
|
||||
< ActivateLayer : Text
|
||||
| MQTTPub : { payload : Text, topic : Text }
|
||||
| Print : Text
|
||||
>
|
||||
, Mapping =
|
||||
< NOP
|
||||
| Passthrough
|
||||
| Trigger :
|
||||
< ActivateLayer : Text
|
||||
| MQTTPub : { payload : Text, topic : Text }
|
||||
| Print : Text
|
||||
>
|
||||
| TriggerMulti :
|
||||
List
|
||||
< ActivateLayer : Text
|
||||
| MQTTPub : { payload : Text, topic : Text }
|
||||
| Print : Text
|
||||
>
|
||||
>
|
||||
}
|
||||
Reference in New Issue
Block a user