LoRaWAN Light Control with a Stream Deck
There is something satisfying about a physical button that does a real thing. Not a touchscreen, not a voice command, not an app. A button you press with your finger that makes a light happen somewhere else, over a radio network, through infrastructure you built yourself.
This article covers the software stack for making that work from the Raspberry Pi and Stream Deck end. The LoRaWAN node that receives the command and actually switches the light is hardware that does not exist yet, so that half is documented conceptually rather than practically. Everything on the Pi side, though, can be installed, configured, and tested today.
The full picture
The chain from button press to light looks like this:
Stream Deck button press
↓
Python script on Raspberry Pi
↓
MQTT publish to Mosquitto broker
↓
ChirpStack picks up the command topic
↓
ChirpStack enqueues a downlink
↓
LoRaWAN gateway transmits the downlink
↓
LoRaWAN node receives the command
↓
Node switches relay / smart switch
↓
Light on or off
Every step in that chain except the last two can be implemented and tested now. ChirpStack’s device queue UI shows whether downlinks are being enqueued correctly. When the hardware eventually arrives, it slots into an already-working system rather than being debugged alongside it.
The LoRaWAN node (future hardware)
For completeness: the node at the other end needs to be a LoRaWAN-capable device that can receive Class C downlinks (always listening, not just after an uplink) and switch an output. A relay module is the simplest option. Candidates include:
- Dragino LT-22222-L - a two-channel digital I/O node with relay outputs, specifically designed for this use case. It registers in ChirpStack, receives downlink commands on a configured fPort, and toggles its relay accordingly.
- RAK Wireless RAK7201 - a four-button LoRaWAN remote, though this is an input device rather than an output.
- Any custom node built on a LoRaWAN module (RAK4630, Heltec WiFi LoRa 32) with a relay board attached.
The payload format depends on the device. For the Dragino LT-22222-L, a two-byte command on fPort 1 controls the relay: 0x01 turns relay 1 on, 0x02 turns it off. Other devices will have their own protocol, usually documented in the device’s manual or the ChirpStack device profile repository.
The article will be updated with actual hardware configuration once a node is purchased and working.
Prerequisites
The following need to be in place on the existing infrastructure:
ChirpStack running on February, with an application already created and an API key generated. The API key is in ChirpStack’s UI under API Keys. Note the application ID as well — it is visible in the URL when browsing to the application.
Mosquitto running and accessible from the Pi. The broker address, port, and any TLS/authentication settings need to be known. The Mosquitto article in this series covers setup.
A device registered in ChirpStack, even if it is not physically present yet. ChirpStack needs to know the DevEUI and network/application session keys before it will accept downlink commands for a device. Create a placeholder device entry for now and update the keys when the hardware arrives.
Setting up the Raspberry Pi
Dependencies
sudo apt update
sudo apt install -y python3-pip libudev-dev libusb-1.0-0-dev \
libjpeg-dev zlib1g-dev python3-venv
Create a virtual environment for the project:
mkdir -p ~/streamdeck-lorawan
cd ~/streamdeck-lorawan
python3 -m venv venv
source venv/bin/activate
Install the Python libraries:
pip install streamdeck pillow paho-mqtt
Stream Deck udev rule
Without this, the Stream Deck is only accessible as root:
sudo tee /etc/udev/rules.d/10-streamdeck.rules << 'EOF'
SUBSYSTEMS=="usb", ATTRS{idVendor}=="0fd9", GROUP="users", MODE="0660"
EOF
sudo udevadm control --reload-rules
Unplug and replug the Stream Deck after applying the rule. Confirm your user is in the users group:
groups $USER
If not, add yourself and log out and back in:
sudo usermod -aG users $USER
The configuration file
Store device and broker configuration separately from the script, so the script does not need editing when things change:
tee ~/streamdeck-lorawan/config.py << 'EOF'
# Mosquitto broker
MQTT_HOST = "mosquitto.internal.yourdomain.net"
MQTT_PORT = 8883 # TLS port; use 1883 for unencrypted
MQTT_TLS = True
MQTT_CA_CERT = "/etc/ssl/certs/ca-certificates.crt"
MQTT_USERNAME = "streamdeck"
MQTT_PASSWORD = "your-mqtt-password-here"
# ChirpStack application
CHIRPSTACK_APPLICATION_ID = "your-application-uuid-here"
# Devices: key is a label, value is the DevEUI
DEVICES = {
"living_room": "a1b2c3d4e5f60708",
"desk_lamp": "b2c3d4e5f6070809",
}
# fPort for downlink commands (device-specific)
DOWNLINK_FPORT = 1
# Payload bytes for on/off commands (device-specific)
# These are for a Dragino LT-22222-L; adjust for your device
PAYLOAD_ON = bytes([0x01])
PAYLOAD_OFF = bytes([0x02])
EOF
The control script
#!/usr/bin/env python3
"""
streamdeck-lorawan.py
Stream Deck light controller via LoRaWAN downlink.
Button layout (Stream Deck MK.2, 3 rows x 5 columns):
[0: Living Rm ON ] [1: Living Rm OFF] [2: -------- ] ...
[5: Desk Lamp ON ] [6: Desk Lamp OFF] [7: -------- ] ...
...
A button press publishes a ChirpStack downlink command to MQTT.
ChirpStack picks it up and forwards it as a LoRaWAN downlink.
"""
import base64
import json
import signal
import sys
import threading
import time
import paho.mqtt.client as mqtt
from PIL import Image, ImageDraw, ImageFont
from StreamDeck.DeviceManager import DeviceManager
import config
# ---------------------------------------------------------------------------
# MQTT
# ---------------------------------------------------------------------------
mqtt_client = mqtt.Client(client_id="streamdeck-pi", clean_session=True)
def mqtt_connect():
if config.MQTT_TLS:
mqtt_client.tls_set(ca_certs=config.MQTT_CA_CERT)
if config.MQTT_USERNAME:
mqtt_client.username_pw_set(config.MQTT_USERNAME, config.MQTT_PASSWORD)
mqtt_client.connect(config.MQTT_HOST, config.MQTT_PORT, keepalive=60)
mqtt_client.loop_start()
print(f"MQTT connected to {config.MQTT_HOST}:{config.MQTT_PORT}")
def send_downlink(dev_eui: str, payload: bytes, confirmed: bool = False):
"""Publish a ChirpStack downlink command to MQTT."""
topic = (
f"application/{config.CHIRPSTACK_APPLICATION_ID}"
f"/device/{dev_eui}/command/down"
)
message = {
"devEui": dev_eui,
"confirmed": confirmed,
"fPort": config.DOWNLINK_FPORT,
"data": base64.b64encode(payload).decode(),
}
result = mqtt_client.publish(topic, json.dumps(message), qos=1)
result.wait_for_publish()
print(f"Downlink queued: {dev_eui} → {payload.hex()} (fPort {config.DOWNLINK_FPORT})")
# ---------------------------------------------------------------------------
# Button layout
# ---------------------------------------------------------------------------
# Define what each button key index does.
# Format: (device_label, action) or None for unused buttons.
BUTTON_MAP = {
0: ("living_room", "on"),
1: ("living_room", "off"),
5: ("desk_lamp", "on"),
6: ("desk_lamp", "off"),
}
# ---------------------------------------------------------------------------
# Stream Deck button images
# ---------------------------------------------------------------------------
def make_button_image(deck, label: str, action: str) -> Image.Image:
"""Render a simple text image for a button."""
image = Image.new("RGB", deck.key_image_format()["size"], color=(30, 30, 30))
draw = ImageDraw.Draw(image)
colour = (80, 200, 80) if action == "on" else (200, 80, 80)
text = f"{label.replace('_', ' ').title()}\n{action.upper()}"
# Use default PIL font; swap for a TTF if you want something nicer
draw.text((10, 20), text, fill=colour)
return image
def make_empty_image(deck) -> Image.Image:
return Image.new("RGB", deck.key_image_format()["size"], color=(15, 15, 15))
def pil_to_native(deck, image: Image.Image):
"""Convert a PIL image to the format the Stream Deck expects."""
fmt = deck.key_image_format()
image = image.convert("RGB").resize(fmt["size"])
return image.tobytes()
# ---------------------------------------------------------------------------
# Stream Deck setup
# ---------------------------------------------------------------------------
def setup_deck(deck):
deck.open()
deck.reset()
deck.set_brightness(60)
for key in range(deck.key_count()):
if key in BUTTON_MAP:
device_label, action = BUTTON_MAP[key]
img = make_button_image(deck, device_label, action)
else:
img = make_empty_image(deck)
deck.set_key_image(key, pil_to_native(deck, img))
deck.set_key_callback(key_change_callback)
print(f"Stream Deck ready: {deck.deck_type()}, {deck.key_count()} keys")
def key_change_callback(deck, key, state):
"""Called on every button press and release."""
if not state:
# state=False is the release event; only act on press
return
if key not in BUTTON_MAP:
return
device_label, action = BUTTON_MAP[key]
dev_eui = config.DEVICES.get(device_label)
if dev_eui is None:
print(f"Warning: no DevEUI configured for '{device_label}'")
return
payload = config.PAYLOAD_ON if action == "on" else config.PAYLOAD_OFF
send_downlink(dev_eui, payload)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
mqtt_connect()
manager = DeviceManager()
decks = manager.enumerate()
if not decks:
print("No Stream Deck found. Is it plugged in and is the udev rule applied?")
sys.exit(1)
deck = decks[0]
setup_deck(deck)
# Keep running until Ctrl-C or SIGTERM
stop_event = threading.Event()
signal.signal(signal.SIGINT, lambda *_: stop_event.set())
signal.signal(signal.SIGTERM, lambda *_: stop_event.set())
print("Running. Press Ctrl-C to exit.")
stop_event.wait()
deck.reset()
deck.close()
mqtt_client.loop_stop()
mqtt_client.disconnect()
print("Exited cleanly.")
if __name__ == "__main__":
main()
Save this as ~/streamdeck-lorawan/streamdeck-lorawan.py.
Testing without hardware
Before any LoRaWAN hardware exists, the script can be verified in two stages.
Stage one: verify MQTT output. Subscribe to the downlink topic in a terminal:
mosquitto_sub \
-h mosquitto.internal.yourdomain.net \
-p 8883 \
--cafile /etc/ssl/certs/ca-certificates.crt \
-u streamdeck \
-P your-mqtt-password-here \
-t "application/+/device/+/command/down" \
-v
Then run the script and press a button. The MQTT subscriber should print the JSON payload:
{
"devEui": "a1b2c3d4e5f60708",
"confirmed": false,
"fPort": 1,
"data": "AQ=="
}
AQ== is 0x01 in base64, which is the “on” payload for a Dragino. Ag== is 0x02 (off). If these appear correctly, the Pi side is working.
Stage two: verify ChirpStack enqueues the downlink. In ChirpStack’s UI, navigate to the device and check the Downlink Queue tab after pressing a button. The item should appear briefly before being cleared (ChirpStack clears it once the downlink is transmitted or times out).
If the device does not yet have real session keys (because the hardware does not exist), ChirpStack will still accept the enqueue — it just will not be able to transmit it until a real join occurs.
Running as a service
Once the script is working, run it as a systemd service so it starts at boot:
sudo tee /etc/systemd/system/streamdeck-lorawan.service << 'EOF'
[Unit]
Description=Stream Deck LoRaWAN Light Controller
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=pi
WorkingDirectory=/home/pi/streamdeck-lorawan
ExecStart=/home/pi/streamdeck-lorawan/venv/bin/python streamdeck-lorawan.py
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now streamdeck-lorawan
Replace pi with the actual username on the Pi. Check the service is running:
sudo systemctl status streamdeck-lorawan
journalctl -u streamdeck-lorawan -f
What changes when the hardware arrives
When the LoRaWAN node is purchased, two things need updating:
In ChirpStack: update the device entry with the real DevEUI, JoinEUI, and AppKey from the device’s label or documentation. Select the correct device profile for the hardware (the ChirpStack device profiles repository at github.com/chirpstack/chirpstack-device-profiles has profiles for Dragino and many other common devices).
In config.py: confirm the DOWNLINK_FPORT and payload bytes match the device’s documentation. The Dragino LT-22222-L values in the config above are correct for that specific device but will differ for anything else.
Everything else in the stack stays the same.
Class A devices (the most common LoRaWAN device class) only receive downlinks in the short window after they send an uplink. If the light switch needs to respond immediately to a button press, the node needs to be Class C (always listening). This matters when choosing hardware. The Dragino LT-22222-L is Class A by default; check whether it supports Class C operation or choose a device that does.