evmqtt - Linux Input Event to MQTT Gateway
Capture Linux input events (keyboards, IR remotes, gamepads) and publish them to an MQTT broker. Perfect for integrating hardware buttons and remote controls with Home Assistant.
Based on the original gist by James Bulpin.
Which install do you need?
| Your setup | Install |
|---|---|
| Home Assistant OS or Supervised | Add-on |
| Home Assistant Container or Core | HACS integration |
| Any other MQTT consumer, or no Home Assistant at all | Docker, systemd or pip |
Features
- Home Assistant MQTT device discovery: one HA device per input device, with an
evententity for keys and aswitchto enable or disable it - Stable device ids that survive reboots,
eventNrenumbering and (with a serial) port moves - Grabs only devices you list and have enabled; auto-discovered devices are read without taking them from the system
- Opt-in by default: an auto-discovered device (which may be your own keyboard) starts disabled, so it is never published until a person enables it
- Enable state persists across restarts
- Gateway and per-device availability (LWT), hotplug support
- Keeps running while the broker is down and reconnects with backoff
- Home Assistant add-on that uses the Mosquitto add-on's credentials automatically
- Docker, systemd and plain Python deployment
- HACS integration for HA Container and Core: native entities, no broker, optional MQTT mirror
Installation
Option 1: Home Assistant Add-on (Recommended)
The easiest way to use evmqtt with Home Assistant OS or Supervised is as a
Supervisor add-on. Uses the prebuilt image from ghcr.io/odtgit/evmqtt, no
local build.
Add Repository to Supervisor
- Go to Settings → Add-ons → Add-on Store
- Click ⋮ (three dots menu) → Repositories
- Add this repository URL:
https://github.com/odtgit/evmqtt - Click Add → Close
- Find "evmqtt" in the add-on store and click Install
- Configure via the add-on's Configuration tab
- Start the add-on
Local Add-on Installation
Alternatively, clone directly to your local add-ons folder (config.yaml
still points at the prebuilt image, so this does not build locally either):
cd /addons
git clone https://github.com/odtgit/evmqtt
Then restart Home Assistant, go to Settings → Add-ons → evmqtt and configure.
Option 2: HACS Integration
For Home Assistant Container or Core (no Supervisor), install the custom integration through HACS:
- HACS → Custom repositories → add
https://github.com/odtgit/evmqtt, category Integration - Install "evmqtt"
- Restart Home Assistant
- Settings → Devices & Services → Add Integration → search "evmqtt"
See HACS integration.
Option 3: Docker Container
# Create your config from the template
cp config.example.json config.json
# Edit config.json with your settings
# Run with access to all input devices, including hotplugged ones
docker run -d \
--name evmqtt \
--network host \
--device-cgroup-rule='c 13:* rw' \
-v /dev/input:/dev/input:ro \
-v $(pwd)/config.json:/data/config.json:ro \
-v evmqtt-state:/var/lib/evmqtt \
-e STATE_DIRECTORY=/var/lib/evmqtt \
ghcr.io/odtgit/evmqtt:latest
c 13:* rw gives the container every input device, so auto-discovery also
finds the host's own keyboard. It gets entities but starts disabled: it is
neither grabbed nor published until a person enables it (in Home Assistant,
or by listing it). List the device you want in devices or enabled_devices
(see Device selection), or pass only that device instead
of the cgroup rule (--device /dev/input/rc; a device passed this way is not
seen again after it is replugged).
Or use Docker Compose (also expects a config.json created from
config.example.json as above; compose.yaml has a commented build: . if
you want to build locally instead of pulling the image):
docker compose up -d
Option 4: Python Package
pip install 'evmqtt[mqtt]'
evmqtt -c config.json -v
Installing from source or in editable mode is under Development.
Option 5: Systemd Service
evmqtt.service runs as a systemd DynamicUser, which has no home
directory, so install evmqtt somewhere on the system PATH that a
service can see, not with a plain per-user pip/pipx install.
Venv:
sudo python3 -m venv /opt/evmqtt
sudo /opt/evmqtt/bin/pip install 'evmqtt[mqtt]'
sudo ln -s /opt/evmqtt/bin/evmqtt /usr/local/bin/evmqtt
Or pipx (>= 1.4) in global mode, which also lands in /usr/local/bin:
sudo pipx install --global 'evmqtt[mqtt]'
Either way evmqtt ends up on /usr/local/bin, which is on the PATH that
ExecStart=/usr/bin/env evmqtt ... in evmqtt.service resolves against.
Then configure and install the unit (both files are in this repo):
EVMQTT_VERSION=2.1.0 # the release you installed: pip show evmqtt
EVMQTT_RAW=https://raw.githubusercontent.com/odtgit/evmqtt/refs/tags/v$EVMQTT_VERSION
sudo mkdir -p /etc/evmqtt
sudo curl -fsSL -o /etc/evmqtt/config.json "$EVMQTT_RAW/config.example.json"
sudo chmod 644 /etc/evmqtt/config.json
# Edit /etc/evmqtt/config.json with your settings
sudo curl -fsSL -o /etc/systemd/system/evmqtt.service "$EVMQTT_RAW/evmqtt.service"
sudo systemctl daemon-reload
sudo systemctl enable --now evmqtt
evmqtt.service runs in the input group, so /etc/evmqtt/config.json
must stay world-readable (mode 644) for the service to read it.
Configuration
The same keys work in config.json and in the add-on options.
| Key | Default | Description |
|---|---|---|
mqtt_host |
add-on: provided broker | Broker host. Required outside the add-on. |
mqtt_port |
1883, 8883 with TLS |
Broker port |
mqtt_username / mqtt_password |
none | Broker credentials |
mqtt_tls |
false |
Connect with TLS |
mqtt_tls_ca |
system CAs | CA file for TLS (implies TLS) |
name |
evmqtt <hostname> |
Name of the gateway device in HA |
discovery_prefix |
homeassistant |
HA discovery prefix |
base_topic |
evmqtt/<hostname> |
Root of all state, event and command topics. Must not be under discovery_prefix. |
auto_discover |
true |
Select keyboard-like devices automatically. When false, only devices are used. |
devices |
[] |
Extra devices by stable id, path or name. Listed devices are used even if virtual or not keyboard-like, start enabled, and are grabbed while enabled. |
enabled_devices |
[] |
Devices that should start enabled, by id, path or name, for the first time they are seen. Everything else (including every auto-discovered device) starts disabled; see Enable, grab and persistence. |
keystates |
["PRESS"] |
Any of PRESS, REPEAT, RELEASE |
rescan_interval |
5 |
Seconds between hotplug scans, 0 disables |
state_file |
see below | Where the enable state is kept |
cleanup_legacy |
true |
Remove retained 1.x discovery on start |
log_level |
info |
debug, info, warning, error. -v, -d and --log-level override it. |
Deprecated 1.x keys still load with a warning: serverip, port,
username, password, tls, tls_ca map to the mqtt_* keys; topic and
filter_keys_only are described in Upgrading from 1.x.
Configuration is read from, in order: -c FILE, $EVMQTT_CONFIG,
/data/options.json (add-on), ./config.local.json, ./config.json.
{
"mqtt_host": "192.168.1.10",
"mqtt_username": "mqtt_user",
"mqtt_password": "mqtt_password",
"name": "Living room remote",
"keystates": ["PRESS", "RELEASE"],
"enabled_devices": ["gpio-ir-recv-1a2b3c4d"]
}
Home Assistant add-on
Leave MQTT Host empty: the add-on declares services: mqtt:need and
reads host, port, credentials and TLS of the broker Home Assistant provides
(the Mosquitto add-on) from the Supervisor. Any mqtt_* option you set
overrides the provided value.
Device selection
By default evmqtt uses every device that has at least one real keyboard key,
so mice, power buttons and the video bus are left alone. Virtual devices
(bus VIRTUAL or created through uinput, like keyd's
keyd virtual keyboard or ydotool) are always skipped unless listed in
devices or enabled_devices: grabbing keyd's output device takes away all
keyboard input on a desktop. Bluetooth LE keyboards and remotes, which BlueZ
creates through uhid, are not treated as virtual.
In devices and enabled_devices a path may also be a symlink to the event
node, such as a udev rule's /dev/input/rc or /dev/input/by-id/....
Selection only decides which devices get entities; it does not enable them.
An auto-discovered device (not named in devices or enabled_devices)
always starts disabled, see below.
evmqtt --list-devices prints every device with its stable id and whether
it is selected by default:
/dev/input/event3 razer-razer-huntsman-mini-048d6e11 "Razer Razer Huntsman Mini" [keyboard] (default)
/dev/input/event10 keyd-virtual-keyboard-271f969c "keyd virtual keyboard" [keyboard, virtual]
The id is also in the log and in every event payload (deviceId).
Enable, grab and persistence
Privacy rationale: an auto-discovered device may be the keyboard you are typing this config on, so evmqtt never publishes its keys until a person opts it in.
A device listed in devices or enabled_devices starts enabled. Every
other device, including every auto-discovered one, starts disabled: it gets
discovery entities, but nothing is read into events, nothing is published,
and it is not grabbed until it is enabled. Enable it with the switch in
Home Assistant, or by adding it to devices or enabled_devices.
A listed device is grabbed (EVIOCGRAB) while it is enabled: its keys reach
evmqtt only, not the console or desktop. Turning the switch off releases the
grab and stops events; on turns both back on. A device that cannot be
grabbed (for example because another program holds it) is reported
unavailable and retried on the next rescan.
A device found only by auto-discovery is never grabbed, since it may be the
keyboard you use on that machine: once enabled its keys are published and
still reach the system. List a remote to grab it, so that keys like
KEY_POWER or KEY_SLEEP on it do not also act on the host.
The switch state is saved to a state file, keyed by device id:
| Deployment | State file |
|---|---|
| add-on | /data/evmqtt-state.json |
systemd (StateDirectory=evmqtt) |
/var/lib/evmqtt/state.json |
compose (STATE_DIRECTORY) |
/var/lib/evmqtt/state.json in the evmqtt-state volume |
| otherwise | $XDG_STATE_HOME/evmqtt/state.json, or ~/.local/state/evmqtt/state.json |
devices and enabled_devices only decide the starting state the first
time a device is seen; after that the switch decides, and the choice is
kept in the state file across restarts.
MQTT over TLS
Set mqtt_tls to use the system CA certificates, or mqtt_tls_ca to a CA
file. The default port becomes 8883. In a container, mount the CA file:
volumes:
- "/etc/ssl/certs/ca-certificates.crt:/etc/ssl/certs/ca-certificates.crt:ro"
Usage
evmqtt [-h] [-c CONFIG] [--log-level {debug,info,warning,error}] [-v] [-d]
[--list-devices] [--auto-discover]
evmqtt keeps running when the broker is unreachable or refuses the connection, and reconnects with backoff (1 s up to 60 s). It keeps running with no devices and picks them up when they are plugged in. It exits with 1 only for configuration errors (bad option, missing CA file, no broker configured, Supervisor refusing access).
MQTT contract
<base> is base_topic, <id> the stable device id, <node> the gateway
id derived from base_topic (evmqtt/pi gives pi).
| Topic | Retained | Payload |
|---|---|---|
<base>/status |
yes | online / offline (last will) |
<base>/<id>/availability |
yes | online / offline |
<base>/<id>/event |
no | key event JSON |
<base>/<id>/switch/state |
yes | ON / OFF |
<base>/<id>/switch/set |
ON / OFF (command) |
|
<prefix>/device/evmqtt_<node>/config |
yes | gateway discovery |
<prefix>/device/evmqtt_<node>_<id>/config |
yes | device discovery |
evmqtt also listens to <prefix>/status and republishes discovery when
Home Assistant comes online.
Key event, one message per configured key state:
{
"event_type": "press",
"key": "KEY_VOLUMEUP",
"modifiers": ["KEY_LEFTSHIFT"],
"state": "PRESS",
"deviceId": "gpio-ir-recv-1a2b3c4d",
"deviceName": "gpio_ir_recv",
"devicePath": "/dev/input/event3"
}
key is the kernel name of the key, modifiers the modifier keys held on
the same device, sorted. Modifier keys and KEY_NUMLOCK produce no events
of their own.
Device discovery (homeassistant/device/evmqtt_pi_gpio-ir-recv-1a2b3c4d/config):
{
"device": {
"identifiers": ["evmqtt_pi_gpio-ir-recv-1a2b3c4d"],
"name": "gpio_ir_recv",
"manufacturer": "Logitech",
"model": "USB Receiver",
"model_id": "046d:c52b",
"via_device": "evmqtt_pi"
},
"origin": {"name": "evmqtt", "sw_version": "2.1.0", "support_url": "https://github.com/odtgit/evmqtt"},
"availability": [
{"topic": "evmqtt/pi/status", "payload_available": "online", "payload_not_available": "offline"},
{"topic": "evmqtt/pi/gpio-ir-recv-1a2b3c4d/availability", "payload_available": "online", "payload_not_available": "offline"}
],
"availability_mode": "all",
"components": {
"event": {
"platform": "event",
"unique_id": "evmqtt_pi_gpio-ir-recv-1a2b3c4d_event",
"name": "Key",
"icon": "mdi:keyboard",
"device_class": "button",
"state_topic": "evmqtt/pi/gpio-ir-recv-1a2b3c4d/event",
"event_types": ["press"]
},
"switch": {
"platform": "switch",
"unique_id": "evmqtt_pi_gpio-ir-recv-1a2b3c4d_switch",
"name": "Enabled",
"icon": "mdi:keyboard-settings",
"entity_category": "config",
"state_topic": "evmqtt/pi/gpio-ir-recv-1a2b3c4d/switch/state",
"command_topic": "evmqtt/pi/gpio-ir-recv-1a2b3c4d/switch/set",
"payload_on": "ON",
"payload_off": "OFF",
"state_on": "ON",
"state_off": "OFF"
}
}
}
manufacturer and model come from the USB descriptors in sysfs and are
left out when unknown, model_id is vendor:product. The gateway device
has a Status connectivity binary_sensor on <base>/status. Discovery needs
Home Assistant 2024.12 or later.
A device that is unplugged goes unavailable and keeps its entities; it comes back when plugged in again.
Home Assistant
Each input device shows up as a device with event.<device>_key and
switch.<device>_enabled. Automation on a key:
automation:
- alias: "Remote volume up"
triggers:
- trigger: state
entity_id: event.gpio_ir_recv_key
conditions:
- condition: template
value_template: >
{{ trigger.to_state.attributes.event_type == 'press'
and trigger.to_state.attributes.key == 'KEY_VOLUMEUP' }}
actions:
- action: media_player.volume_up
target:
entity_id: media_player.living_room
Node-RED and other MQTT consumers subscribe to <base>/+/event for the JSON
stream.
HACS integration
Native Home Assistant integration for HA Container and Core, where add-ons
are not available. No broker needed. It runs the same core as the daemon
(evmqtt from PyPI, installed by HA from the manifest). Needs HA 2026.3 or
newer.
Install through HACS (Option 2), or copy
custom_components/evmqtt into <config>/custom_components/ and restart.
Device access: HA Container
services:
homeassistant:
image: ghcr.io/home-assistant/home-assistant:stable
network_mode: host
volumes:
- ./config:/config
- /dev/input:/dev/input:ro
device_cgroup_rules:
- "c 13:* rw"
c 13:* rwallows every input device node (major 13), including ones plugged in later; the bind mount shows new nodes without a restart (CI checks this). python-evdev only lists nodes it may open read-write, soc 13:* rfinds no devices.:rokeeps the container from creating or removing nodes, it does not stop writes to them.devices: [/dev/input/event3]works for one fixed device but breaks on hotplug andeventNrenumbering.- The image runs as root. With
user:set, addgroup_add: ["<gid>"]using the host's input group id (getent group input | cut -d: -f3).
Device access: HA Core (venv)
sudo usermod -aG input homeassistant
sudo systemctl restart home-assistant@homeassistant
evdev 1.9 has no wheels on PyPI, so the first install builds it: the host
needs a C compiler, Python headers and kernel headers (Debian:
build-essential python3-dev linux-libc-dev). HA Container uses HA's
prebuilt wheel.
Configuration
Settings → Devices & Services → Add Integration → evmqtt.
The form lists keyboard-like devices (no mice, power buttons or video bus).
Tick the ones to enable. Every listed device gets entities, unticked ones
start disabled. Include virtual devices adds uinput devices (keyd,
kanata, ydotool); Bluetooth LE remotes (BlueZ uhid) are not virtual and are
always listed. If nothing is readable the form says why:
/dev/input not mapped, or no permission.
Configure on the integration:
| Option | Default | |
|---|---|---|
| Enabled devices | from setup | Grabbed devices |
| Key states | press | Which of press/repeat/release fire events |
| Rescan interval | 5 s | Hotplug scan, 0 disables |
| Include virtual devices | off | |
| MQTT mirror | off | Only shown when the MQTT integration is set up |
| MQTT base topic | evmqtt/<hostname> |
Same default as the daemon |
Changing only the enabled devices applies live, anything else reloads the entry.
Grabbing follows the daemon's 2.1.0 rule: only devices you chose are grabbed. Enabling a device here, in the options or with its switch counts as listing it, so it is grabbed while enabled. Nothing is enabled or grabbed automatically. Disabled devices stay open to track modifiers but fire no events.
Entities
One HA device per input device, keyed by the core's stable id; manufacturer
and model from the USB descriptors, model id vendor:product.
event.<device>_key: device classbutton, event types from the key states. Attributeskey,modifiers,state(PRESS/REPEAT/RELEASE),device_id,device_name,device_path. Modifier keys do not fire on their own, they show up inmodifiers.switch.<device>_enabled(config): on grabs the device, so its keys reach only HA. Off releases it. Stored in the entry options, survives restarts.- Unplugged: both entities unavailable, kept, back on replug. Devices that are not plugged in can be deleted from their device page.
- Newly seen devices get entities with the switch off: not grabbed, no events. A new keyboard on the HA host keeps typing locally.
- Privacy: an enabled full keyboard sends every keystroke to HA. Any HA user
or access token can read them live from the event entity (
/api/states, websocket).key,modifiers,stateand thedevice_*attributes are excluded from the recorder, so history keeps only when a press happened and its type. The MQTT mirror publishes them to the broker too. Enable remotes and macro pads, not the keyboard people type passwords on.
automation:
- alias: "Remote: Ctrl+P toggles the lamp"
triggers:
- trigger: state
entity_id: event.ir_remote_key
conditions:
- condition: template
value_template: >
{{ trigger.to_state.attributes.event_type == 'press'
and trigger.to_state.attributes.key == 'KEY_P'
and 'KEY_LEFTCTRL' in trigger.to_state.attributes.modifiers }}
actions:
- action: light.toggle
target:
entity_id: light.lamp
MQTT mirror
Publishes each event to <base>/<device id>/event with the daemon's JSON
payload (QoS 0, not retained), so flows built on the daemon keep working.
No discovery: the entities are native.
Coexistence
- Only one process can grab a device. With the add-on or daemon and this
integration on the same device, the second grab fails with
EBUSY. The integration logs one warning, marks the event entity unavailable and retries on every rescan; the switch stays usable. Switch off reads the device without grabbing. Use one of them per device. - HA's built-in
keyboard_remoteis YAML only, always grabs, and fireskeyboard_remote_command_receivedbus events with numeric key codes. This integration adds a config flow, entities per device, stable ids, modifiers, runtime grab on/off, hotplug of new devices and the MQTT mirror. Do not point both at the same device.
Upgrading to 3.0
Auto-discovered devices now start disabled (opt-in). In 2.0 and 2.1, an
empty enabled_devices meant "enable all", so every auto-discovered
keyboard-like device (which can include the host's own keyboard) was enabled
and publishing keys by default. 3.0 closes that: only devices listed in
devices or enabled_devices start enabled; everything else, including
every auto-discovered device, starts disabled and must be turned on with the
Home Assistant switch (or added to devices/enabled_devices).
On first start with a 2.x state file, evmqtt migrates it: devices that were
"on" only because of the old default are switched to "off" (devices that
match devices/enabled_devices, or that a person already toggled and got
persisted, are unaffected). It logs one WARNING naming every device it
disabled and how to re-enable it, then rewrites the state file with the new
schema version. After that first migration the file is trusted as-is.
If you rely on an auto-discovered device (for example a remote that was
never listed), add it to devices or enabled_devices, or re-enable it in
Home Assistant, after upgrading.
Upgrading from 1.x
2.0 changes topics, entities, payloads and some config keys. Old entities are removed automatically; automations on them have to be rewritten.
Topics
| 1.x | 2.0 |
|---|---|
<topic>/<slug>/state |
<base>/<id>/event |
<topic>/<slug>/config, homeassistant/switch/<uid>/config |
homeassistant/device/evmqtt_<node>_<id>/config |
<topic>/<slug>/switch/state, /switch/set |
<base>/<id>/switch/state, /switch/set |
| none | <base>/status, <base>/<id>/availability |
<slug> was the name slug (plus -2 for duplicates, eventN in manual
mode); <id> is the stable id (name slug plus a hash), so topics no longer
move when eventN changes.
Entities
sensor.<name>_<device>(last key as state) becomesevent.<device>_key. The key is in thekeyattribute, the state is the event time.switch.<device>_enablebecomesswitch.<device>_enabled, in the device's configuration section.- Every input device is its own HA device, linked to a new gateway device.
Payload
- New:
event_type(lowercase key state),modifiers(list),deviceId. keyis the plain key name. 1.x appended held modifiers (KEY_A_KEY_LEFTSHIFT) and joined aliased names (KEY_MIN_INTERESTING|KEY_MUTE); 2.0 sendsKEY_Awith"modifiers": ["KEY_LEFTSHIFT"], andKEY_MUTE.state,devicePathanddeviceNameare unchanged.
Config
serverip,port,username,password,tls,tls_ca: renamed tomqtt_host,mqtt_port,mqtt_username,mqtt_password,mqtt_tls,mqtt_tls_ca. The old names still work and log a warning.topic: deprecated. If it is underdiscovery_prefix(the 1.x defaulthomeassistant/sensor/evmqtt), it is ignored for state topics, which move tobase_topic. If it is elsewhere andbase_topicis not set, it becomesbase_topic. In both cases it tells the cleanup where the 1.x discovery is.filter_keys_only: ignored. The default filter is stricter (keyboard-like, no virtual devices); list anything else indevices.devicesandenabled_devicesaccept ids and names as well as paths, anddevicesno longer requiresauto_discover: false.auto_discovernow defaults totrueinconfig.jsontoo.- Since 2.1.0, only devices listed in
devicesorenabled_devicesare grabbed. 1.x and 2.0.0 grabbed every device they used; list your devices to keep that. - Add-on:
mqtt_hostcan be left empty to use the Mosquitto add-on. - Enable/disable is now kept in a state file instead of the retained switch
topic; the first 2.0 start seeds it from
enabled_devices.
Automations
- Replace
statetriggers onsensor.*with astatetrigger on theevent.*entity and a condition ontrigger.to_state.attributes.key(see the example above). Ato:on the key no longer works: the state of an event entity is a timestamp. - Keys with modifiers: check
attributes.modifiersinstead of matchingKEY_A_KEY_LEFTSHIFT. - MQTT triggers and Node-RED flows: subscribe to
<base>/+/event. - Switches: update entity ids.
Cleanup of old entities
On the first connect evmqtt subscribes for a few seconds to
<prefix>/+/+/config and <topic>/+/config, and clears (empty retained
message) only configs whose unique_id starts with evmqtt_ and whose
state_topic is under the 1.x topic, plus the retained 1.x switch state.
Home Assistant then removes the old sensor and switch entities. Nothing else
is touched: other integrations' configs, unparseable payloads and 2.0 device
configs are left alone. Set cleanup_legacy: false to skip it.
If several 1.x gateways shared one broker and topic, the first upgraded one
removes the 1.x entities of all of them; the others recreate theirs on their
next 1.x start. Upgrade them together, or set cleanup_legacy: false until
the last one is upgraded.
Core Library
evmqtt.core is the evdev-only asyncio layer the daemon runs on, usable
without MQTT (pip install evmqtt):
import asyncio
from evmqtt.core import (
DeviceReader,
GrabMode,
KeyEvent,
KeyState,
is_keyboard_like,
list_devices,
open_device,
)
def on_event(event: KeyEvent) -> None:
if event.state is KeyState.PRESS:
print(event.key, event.modifiers)
async def main() -> None:
info = list_devices(is_keyboard_like)[0]
reader = DeviceReader(
open_device(info.path), on_event, info=info, grab=GrabMode.WHILE_ENABLED
)
await reader.run()
asyncio.run(main())
info.id is stable across reboots and eventN renumbering: name slug plus a
hash of bus, vendor, product, name and either the serial (uniq, plus the
interface number) when the device has a real one, so it survives a port
move, or the port path (phys) when it does not. The MQTT daemon keys its
topics and Home Assistant ids on it.
DeviceWatcher runs the same scan on an interval and reports added/removed
devices, for hotplug without an event loop of your own:
from evmqtt.core import DeviceWatcher
watcher = DeviceWatcher(
on_added=lambda info: print("added", info.id),
on_removed=lambda info: print("removed", info.id),
predicate=is_keyboard_like,
interval=5.0,
)
asyncio.run(watcher.run())
Development
Clone and install in editable mode, with the mqtt and dev extras:
git clone https://github.com/odtgit/evmqtt
cd evmqtt
pip install -e ".[mqtt,dev]"
Running Tests
# Run tests (see tests/README.md for the broker and uinput tiers)
pytest -m "not broker and not uinput"
# Run with coverage
pytest tests/ -v --cov=evmqtt --cov-report=html
HACS integration tests (Python 3.14, own venv, fake devices only):
python3.14 -m venv .venv-ha
.venv-ha/bin/pip install -r tests_ha/requirements.txt -e ".[mqtt]"
cd tests_ha && ../.venv-ha/bin/pytest -q
scripts/ha_integration_validate.py runs the integration in a real HA
container. Locally it only checks install and the config flow; CI adds a
uinput remote (--uinput, root).
Project Structure
evmqtt/
├── src/evmqtt/ # Main package
│ ├── __init__.py
│ ├── core/ # evdev-only asyncio library (no MQTT)
│ ├── __main__.py # CLI entry point
│ ├── config.py # Configuration
│ ├── gateway.py # Daemon: readers, hotplug, persistence, MQTT
│ ├── ha.py # Topics and HA discovery payloads
│ ├── mqtt_client.py # paho wrapper
│ ├── state.py # Enable state file
│ ├── supervisor.py # Add-on broker lookup
│ └── sysinfo.py # sysfs: virtual devices, vendor/model
├── tests/ # Test suite
├── custom_components/evmqtt/ # HACS integration
├── tests_ha/ # HACS integration tests
├── hacs.json # HACS manifest
├── config.yaml # HA add-on manifest
├── repository.yaml # HA add-on repository manifest
├── Dockerfile # Container build
├── pyproject.toml # Python packaging
└── run.sh # Container entrypoint
Type Checking
mypy src/evmqtt/core
Linting
ruff check src/ tests/
ruff format src/ tests/
Requirements
- Python 3.10+
- evdev >= 1.6.0
- paho-mqtt >= 2.0.0 for the daemon (
evmqtt[mqtt]) - Linux with input device access
Troubleshooting
Permission Denied for Input Device
Add your user to the input group:
sudo usermod -a -G input $USER
# Log out and back in
Or run with sudo (not recommended for production).
Device Not Found
- Check the device exists:
ls -la /dev/input/ - Verify permissions:
groupsshould includeinput - For Docker/add-on, ensure the device is passed through
MQTT Connection Failed
evmqtt logs MQTT broker ... unreachable or refused the connection and
keeps retrying.
- Verify
mqtt_hostandmqtt_port - Check username/password (
refused ... Not authorized) - Check the broker:
mosquitto_sub -h <broker> -t 'evmqtt/#' -v
Devices Not Appearing in Home Assistant
- Check MQTT discovery is enabled in Home Assistant and
discovery_prefixmatches it - Check the device is selected:
evmqtt --list-devices, and the log at startup - Look in Settings → Devices & Services → MQTT → Devices
License
MIT License - see LICENSE file for details.
Credits
- Original concept by James Bulpin
- python-evdev for input device access
- paho-mqtt for MQTT client
Release files for evmqtt 3.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| evmqtt-3.0.0.tar.gz | 71.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| evmqtt-3.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 114.0 kB
Release files / evmqtt-3.0.0.tar.gz
| Download URL | evmqtt-3.0.0.tar.gz |
|---|---|
| Size | 71.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
00c522ff786f324d6ba8b6823e19185769a1a528ef3716e3d0fd8b16a6a9f3c2
|
|
BLAKE2b-256 checksum How to use checksums |
943f15ae7e558ee0d8162404fb87b0468a8ed2f5bc1ee0bbaa86d10d1a674370
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 28, 2026.
Transparency logRelease files / evmqtt-3.0.0-py3-none-any.whl
| Download URL | evmqtt-3.0.0-py3-none-any.whl |
|---|---|
| Size | 42.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
7a4cdcee9d8027afb254c7f589218cc2653c84121fbba126543511a4a55625bb
|
|
BLAKE2b-256 checksum How to use checksums |
866ec6a0e7a825bcde2ef53f88fce319cc1f998d855b0bd0e90765473c39ddca
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 28, 2026.
Transparency log