Introduction
Here we will show the complete process of building a smart touch-controlled LED device - starting from hardware wiring and finishing with a fully functional device logic on the microcontroller.
You will learn how to send real-time telemetry, handle server-side RPC commands, and integrate the device with ThingsBoard dashboards for remote monitoring and control using the ThingsBoard MicroPython Client SDK.
All device behavior is implemented on the ESP32 using MicroPython.
PrerequisitesBefore we begin, let’s review the full list of hardware, software, and MicroPython libraries used in this project, along with the links you will need.
SoftwareFor the software part, we use MicroPython as the device runtime, PyCharm as the development environment together with the MicroPython Tools extension, and ThingsBoard as the device management platform.
To send telemetry and handle communication over MQTT, the project relies on the ThingsBoard MicroPython Client SDK.
HardwareFor the hardware setup, we use a TTP223 capacitive touch key module, male-to-male jumper wires, an ESP32-PICO-KIT-V4 development board (any compatible ESP32 board can be used as an alternative), and a solderless breadboard for quick prototyping and wiring.
MicroPython LibrariesThe standard ESP32 MicroPython firmware already includes the core system modules required for working with hardware, networking, and timing. Therefore, the only external dependency we need to install is the ThingsBoard MicroPython Client SDK, which will be covered in a later step.
For now, let’s briefly look at the purpose of each library used in the project:
- machine - provides access to hardware features such as GPIO pins, PWM, and peripherals.
- time - used for delays, timing operations, and non-blocking time measurements.
- mip - MicroPython package manager used to install external libraries directly on the device.
- network - enables Wi-Fi connectivity on the ESP32.
- Thingsboard Micropython SDK - allows the device to communicate with the ThingsBoard platform over MQTT, send telemetry, and process RPC commands.
For connection simplicity of all required hardware components, we are attaching the connection schema below with its detailed description.
The ESP32 is powered through the USB Type-C cable, which provides both power and a serial connection for programming. The TTP223 capacitive touch sensor is connected using three wires: VCC to the ESP32 3.3 V pin, GND to GND, and OUT to a digital GPIO pin (GPIO18 in this project) to detect touch events. The LED is connected to another GPIO pin (GPIO23) through a 220 Ω resistor, which limits the current and protects both the LED and the ESP32 pin.
When the touch sensor is pressed, the ESP32 reads the signal from GPIO18 and controls the LED brightness on GPIO23 using PWM, allowing smooth fading instead of simple ON/OFF switching.
ThingsBoard PreparationBefore starting, make sure that you have ThingsBoard up and running or use ThingsBoard Cloud. In this step, we will create a device on the ThingsBoard platform and obtain the access token required to connect our physical ESP32 device to the platform.
To complete this step, follow the instructions below:
- On the ThingsBoard platform, open the Devices page. By default, you navigate to the device group “All”. Click on the “+” icon in the top right corner of the table and then select “Add new device”.
- Input device name (it should be called “LED Lamp”) and click the“Add” button.
- Click on the device row in the table to open device details. Click "Copy access token". The Token will be copied to your clipboard.
ESP32 development boards don’t come with MicroPython firmware preinstalled, so it is required to flash the firmware onto the device. You can follow the official MicroPython installation guide for ESP32, which provides step-by-step instructions for different operating systems.
After completing this process, the ESP32 will be ready to run MicroPython code.
Code ExplanationNow it’s time to look at the code that runs our touch-controlled LED lamp using PWM brightness control. We will go through each logical part step by step - from connecting the ESP32 to Wi-Fi, to implementing the fading logic, sending telemetry, and handling server-side RPC commands.
You can find the source code here. Copy-paste it to your preferred IDE or tool (such as Thonny or PyCharm with MicroPython Tools).
By the end of this section, you’ll have a complete understanding of MicroPython code that will let you monitor the lamp in real time, both physically and remotely from a ThingsBoard dashboard, even without touching the physical sensor.
Connecting to WiFiTo install the ThingsBoard MicroPython Client SDK and communicate with ThingsBoard over MQTT, the ESP32 must first be connected to a Wi-Fi network.
In the next step, we’ll configure the Wi-Fi interface and verify that the board is online.
- Import the necessary libraries required for establishing the connection. At this stage, we only need the network library to configure Wi-Fi, while the remaining libraries will be introduced and used later in the project.
from machine import Pin, PWM
import network, time- Define SSID and password values for your Wi-Fi point and initialize the WLAN instance.
WIFI_SSID = "YOUR_NETWORK_SSID"
WIFI_PASSWORD = "YOUR_PASSWORD"
wlan = network.WLAN(network.STA_IF)
wlan.active(True)- Start a connection to Wi-Fi and start up the Serial port for logging. The Serial Monitor will give us some debug information about the connection status.
if not wlan.isconnected():
print('Connecting to network...')
wlan.connect(WIFI_SSID, WIFI_PASSWORD)
while not wlan.isconnected():
pass
print("connected:", wlan.isconnected())
print("ifconfig:", wlan.ifconfig())Installing ThingsBoard MicroPython Client SDKAfter connecting to WIFI, we need to install ThingsBoard MicroPython Client SDK. The code below will install the required library, and on the next code run, it will check if it is already installed, we simply continue without reinstalling.
import mip
try:
from sdk_core.device_mqtt import RPC_RESPONSE_TOPIC
from thingsboard_sdk.tb_device_mqtt import TBDeviceMqttClient
print("thingsboard-micropython-client-sdk package already installed.")
except ImportError:
print("Installing thingsboard-micropython-client-sdk package...")
mip.install('github:thingsboard/thingsboard-micropython-client-sdk')
from thingsboard_sdk.tb_device_mqtt import TBDeviceMqttClient
from sdk_core.device_mqtt import RPC_RESPONSE_TOPIC
HOST = "YOUR_HOST"
PORT = "YOUR_PORT"
ACCESS_TOKEN = "YOUR_ACCESS_TOKEN"- HOST / PORT - define where the ThingsBoard MQTT broker is running.
- ACCESS_TOKEN - is the device credential used to authenticate your ESP32 in ThingsBoard, you will have to use the token you obtained from the ThingsBoard Preparation step.
In that section, we implement the core logic that controls the LED brightness using the touch sensor. You will learn how PWM-based fading works and how the device reacts to touch events in real time. After completing this step, you will have a fully functional LED lamp with smooth brightness control driven by the sensor.
1. Hardware configuration and PWM control constants:
SENSOR_PIN = 18
LED_WHITE_PIN = 23
U16_MAX = 65535- SENSOR_PIN - defines the GPIO used to read the digital output of the TTP223 capacitive touch sensor.
- LED_WHITE_PIN - specifies the GPIO used to control the LED brightness using PWM.
- U16_MAX - represents the maximum 16-bit PWM duty cycle value in MicroPython, where 0 means the LED is fully OFF and 65535 means the LED is fully ON.
2. Timing, telemetry constants:
Please note that these values, except for the state variable, are fully configurable and may be adjusted according to your specific device behavior and performance requirements.
FULL_PERIOD_MS = 10000
FADE_UPDATE_MS = 100
STAT_PERIOD_MS = 10_000
MAIN_LOOP_SLEEP_MS = 10
RELEASE_POLL_MS = 100
RPC_METHOD_SET_BRIGHTNESS = "setBrightnessPct"
state = {
"brightness": 0,
"direction_up": True,
"percentage_light": 0,
"is_touched": False,
"fade_elapsed_ms": 0,
}- FULL_PERIOD_MS - defines how long a complete fade transition (0 - 100% or 100% - 0) takes in milliseconds(it is possible to set your own custom time without any further changes).
- FADE_UPDATE_MS - determines how often telemetry is sent to ThingsBoard during the fade process.
- STAT_PERIOD_MS - sets the interval for periodic status telemetry, even when the sensor is not touched.
- MAIN_LOOP_SLEEP_MS - adds a short delay in the main loop to reduce CPU load and stabilize execution.
- RELEASE_POLL_MS - defines how often the program checks whether the touch sensor has been released after reaching brightness limits.
- RPC_METHOD_SET_BRIGHTNESS - the RPC method name we will use later, you can call it however you want, and fade_elapsed_ms that we will not send asa telemetry key.
- state - stores the current device state, including brightness level, fade direction, calculated percentage, and touch status.
3. Initialization of hardware instances:
sensor = Pin(SENSOR_PIN, Pin.IN)
led_pwm = PWM(Pin(LED_WHITE_PIN), freq=1000)
led_pwm.duty_u16(0)- sensor - configures the selected GPIO as a digital input to read the touch sensor signal.
- led_pwm - initializes a PWM output at 1 kHz, which is fast enough to prevent visible LED flickering.
- led_pwm.duty_u16(0) - ensures the LED starts in the OFF state when the device boots.
4. Helper functions:
Below are defined helper functions for various data manipulation, just moved to functions for code clarity and reusability.
def set_brightness_u16(x):
if x < 0:
x = 0
if x > U16_MAX:
x = U16_MAX
led_pwm.duty_u16(x)The set_brightness_u16() - safely applies PWM values by evaluating them to the valid 16-bit range before updating the LED output.
def calculate_value_from_time(passed_ms):
if passed_ms <= 0:
return 0
if passed_ms >= FULL_PERIOD_MS:
return U16_MAX
return (passed_ms * U16_MAX + (FULL_PERIOD_MS // 2)) // FULL_PERIOD_MSThe calculate_value_from_time() - converts elapsed time into a proportional PWM value, enabling smooth fade-in and fade-out behavior.
def wait_release(sensor):
while sensor.value() == 1:
time.sleep_ms(RELEASE_POLL_MS)The wait_release() - blocks execution until the touch sensor is released, ensuring correct handling of press-and-hold interactions.
def connect_to_broker(client):
try:
client.connect()
print("Connected to MQTT broker")
return True
except OSError as e:
print("[TB] connect OSError:", e)
except Exception as e:
print(f"Failed to connect to MQTT broker: {e}")
return FalseThe connect_to_broker() - a function that takes a client that will be initialized later and attempts to connect it to the broker.
5. Fade function is the core of the lamp logic and is responsible for smoothly changing the LED brightness using PWM:
def fade(client, sensor, state, direction_up):
state["direction_up"] = direction_up
press_start_ms = time.ticks_ms()
base_elapsed_ms = state.get("fade_elapsed_ms", 0)
while sensor.value() == 1:
held_ms = time.ticks_diff(time.ticks_ms(), press_start_ms)
effective_ms = base_elapsed_ms + held_ms
if effective_ms >= FULL_PERIOD_MS:
effective_ms = FULL_PERIOD_MS
progress = calculate_value_from_time(effective_ms)
if direction_up:
state["brightness"] = progress
if effective_ms == FULL_PERIOD_MS:
state["brightness"] = U16_MAX
state["direction_up"] = False
state["fade_elapsed_ms"] = FULL_PERIOD_MS
else:
state["brightness"] = U16_MAX - progress
if effective_ms == FULL_PERIOD_MS:
state["brightness"] = 0
state["direction_up"] = True
state["fade_elapsed_ms"] = FULL_PERIOD_MS
set_brightness_u16(state["brightness"])
send_state_telemetry(client, sensor, state)
time.sleep_ms(FADE_UPDATE_MS)
held_ms = time.ticks_diff(time.ticks_ms(), press_start_ms)
new_elapsed = base_elapsed_ms + held_ms
if new_elapsed > FULL_PERIOD_MS:
new_elapsed = FULL_PERIOD_MS
state["fade_elapsed_ms"] = new_elapsed
if state["fade_elapsed_ms"] >= FULL_PERIOD_MS:
state["fade_elapsed_ms"] = 0
state["direction_up"] = not direction_upThe fade() function smoothly increases or decreases the LED brightness while the touch sensor is pressed. When a press begins, it uses the current brightness to calculate the start time, allowing the fade to continue, instead of restarting from zero. While the sensor remains active, elapsed time is converted into a PWM duty cycle to update the brightness in real time. When the LED reaches minimum or maximum brightness, the fade direction automatically reverses and waits for the user to release the touch sensor.
6. Main loop and Touch Detection:
def main():
prev_touch = 0
set_brightness_u16(0)
try:
while True:
touch = sensor.value()
if touch == 1 and prev_touch == 0:
if state["direction_up"]:
fade(sensor, state, direction_up=True)
else:
fade(sensor, state, direction_up=False)
print("Released; holding brightness:", state["brightness"])
prev_touch = touch
time.sleep_ms(MAIN_LOOP_SLEEP_MS)
finally:
try:
set_brightness_u16(0)
except Exception:
pass
main()The main() function is the entry point of the program and continuously reads the touch sensor state in real time. When a new touch is detected, it calls the fade() function in the appropriate direction so the LED smoothly adjusts brightness from its current level. After release, the LED keeps the last brightness until the next interaction. The finally block ensures safe cleanup by turning the LED off if the program stops or an error occurs.
Data SendingAn LED lamp is useful, but it becomes much more informative when it reports its state to the platform in real time. In this section, we’ll add telemetry reporting so you can send brightness, touch activity, and fade direction to the ThingsBoard platform.
1. Telemetry Helper for the Main Loop:
def send_state_telemetry(client, sensor, state):
telemetry = {
"brightness": state["brightness"],
"is_touched": bool(sensor.value()),
"percentage_light": (state["brightness"] * 100 + (U16_MAX // 2)) // U16_MAX,
"is_growing": state["direction_up"],
}
try:
client.send_telemetry(telemetry)
except Exception as e:
print("[TB] send_telemetry failed:", e)
return telemetrysend_state_telemetry() - before integrating ThingsBoard into the main control loop. It is a small helper that packages the current lamp state into a telemetry payload and sends it to the platform. We’ll reuse this function later inside main().
2. ThingsBoard MicroPython SDK Client Initialization:
def main():
prev_touch = 0
client = TBDeviceMqttClient(HOST, PORT, access_token=ACCESS_TOKEN)
connect_to_broker(client)
send_state_telemetry(client, sensor, state)
set_brightness_u16(0)
last_stat_ms = time.ticks_ms()
try:
while True:
touch = sensor.value()
if touch == 1 and prev_touch == 0:
if state["direction_up"]:
fade(client, sensor, state, direction_up=True)
else:
fade(client, sensor, state, direction_up=False)
print("Released; holding brightness:", state["brightness"])
prev_touch = touch
now_ms = time.ticks_ms()
if time.ticks_diff(now_ms, last_stat_ms) >= STAT_PERIOD_MS:
last_stat_ms = now_ms
telemetry = send_state_telemetry(client, sensor, state)
print("Stat telemetry:", telemetry)
time.sleep_ms(MAIN_LOOP_SLEEP_MS)
finally:
try:
set_brightness_u16(0)
except Exception:
pass
try:
client.disconnect()
except Exception as e:
print("Could not disconnect client:", e)
main()- We create a TBDeviceMqttClient instance using the broker host/port and the device ACCESS_TOKEN (initialization patterns are available in the SDK examples). After connecting with connect_to_broker(), we immediately send the initial device state via send_state_telemetry(). At the end, we also add try/except block that guarantees the MQTT client is disconnected cleanly, even if an error occurs or you stop the script.
- Besides sending telemetry during touch events, the main loop also publishes periodic “status telemetry” every STAT_PERIOD_MS (for example, every 10 seconds). This is useful when the LED stays at a fixed brightness for a long time: even without touching the sensor, the device still reports its current brightness, touch state, and fade direction. The logic is implemented using last_stat_ms + time.ticks_diff() to measure elapsed time. When the interval is reached, we call send_state_telemetry() and print the payload for local debugging.
At this point, the device can already send telemetry to ThingsBoard. The next step is to control the lamp remotely using server-side RPC - for example, set brightness from a dashboard, or instantly turn the LED ON/OFF without touching the sensor.
1. Helper function to parse RPC brightness:
def parse_rpc_brightness_params(params):
if params is None:
raise ValueError("Params is None")
percents = int(params)
if percents < 0:
percents = 0
if percents > 100:
percents = 100
return percentsparse_rpc_brightness_params() - is a simple helper that takes the params value from an incoming RPC request (expected to be a percent) and normalizes it to a safe range 0…100. That way, your device code doesn’t crash on strings or out-of-range values.
2. On server-side RPC callback function:
def on_server_side_rpc_request(request_id, request_body):
print("[RPC] id:", request_id, "body:", request_body)
try:
method = request_body.get("method")
params = request_body.get("params")
except AttributeError:
print("[RPC] bad request format (not a dict)")
return
if method != RPC_METHOD_SET_BRIGHTNESS:
print("Such method is not supported:", method)
return
try:
pct = parse_rpc_brightness_params(params)
brightness = int((pct * U16_MAX) / 100)
state["brightness"] = brightness
set_brightness_u16(brightness)
if brightness <= 0:
state["direction_up"] = True
elif brightness >= U16_MAX:
state["direction_up"] = False
reply = {"brightness": brightness, "percentage_light": pct, "is_growing": state["direction_up"]}
state["pending_rpc_reply"] = (request_id, reply)
except ValueError as e:
print("[RPC] invalid params:", e)
except Exception as e:
print("[RPC] handler error:", e)on_server_side_rpc_request() - is the server-side RPC callback that executes when an RPC command is received from ThingsBoard. It applies the requested brightness immediately and prepares a response that will be sent back to the platform.
3. Helper Functions for Reliable RPC Handling:
def safe_check_msg(client):
try:
client.check_for_msg()
return True
except OSError as e:
print("[TB] check_msg OSError:", e)
if not connect_to_broker(client):
print("[TB] Reconnection failed")
except Exception as e:
print(f"Failed to check messages: {e}")
return False
def send_pending_rpc_reply(client, state):
pending = state.get("pending_rpc_reply")
if pending is not None:
request_id, reply = pending
state["pending_rpc_reply"] = None
try:
client.send_rpc_reply(request_id, reply)
except Exception as e:
print("[RPC] publish failed in main loop:", e)
try:
client.send_telemetry(reply)
except Exception as e:
print("[TB] send_telemetry failed in main loop:", e)- safe_check_msg() - is a helper function that performs a non-blocking MQTT poll. It allows the device to detect incoming RPC requests while continuing to run other logic.
- send_pending_rpc_reply() - is a helper function that sends a previously prepared RPC response stored in the device state and synchronizes the telemetry with the RPC update.
4. Integrating RPC Handling into the Main Loop:
def main():
prev_touch = 0
client = TBDeviceMqttClient(HOST, PORT, access_token=ACCESS_TOKEN)
# --- RPC integration (new part) ---
client.set_server_side_rpc_request_handler(on_server_side_rpc_request)
# ... (existing setup: connect_to_broker, initial telemetry, timers, etc.)
try:
while True:
# ... (existing touch + fade logic)
# ... (existing status telemetry block)
# --- RPC polling + reply (new part) ---
safe_check_msg(client)
send_pending_rpc_reply(client, state)
time.sleep_ms(MAIN_LOOP_SLEEP_MS)
finally:
# ... (existing cleanup: LED off, disconnect)
main()main() - In this version of main(), we register the RPC handler, you may find examples about it here, so the device can receive and execute remote commands from the ThingsBoard. The loop continues to handle touch input, send periodic status telemetry, and process incoming MQTT messages using safe_check_msg() without blocking the device logic. Any pending RPC responses are sent using send_pending_rpc_reply(), and the finally block ensures the LED is turned off and the client disconnects safely on shutdown.
First Device StartupAll preparation steps are now complete, and it is time to start up the device. Connect the hardware as shown in the Wiring diagram section, upload the MicroPython code to your ESP32, and execute it using your preferred IDE or tool (such as Thonny or PyCharm with MicroPython Tools). After the program starts, press and hold the touch sensor for a few seconds, then release it to observe how the brightness changes and how telemetry is sent to ThingsBoard.
If everything has been done correctly, you will see logs in the serial terminal. In the logs, you will see that the device successfully connects to Wi-Fi and initializes the ThingsBoard MicroPython SDK. If the SDK is already installed, the program will reuse it; otherwise, it will install it automatically. When you touch the sensor, the console will display brightness values ranging from 0 to 65535, representing the current PWM level - for example, a value such as 19811 indicates a 30% of brightness level.
In order to see the telemetry coming to the platform, use the following steps:
- In the left navigation bar, click on “Entities” - “Devices”.
- Click on the device to open device details.
- Go to the “Latest telemetry tab”.
On the “Latest telemetry” tab, you will see incoming telemetry that reflects the device state in real time. This includes:
- brightness - current PWM brightness value
- percentage_light - brightness converted to percentage (0–100%)
- is_touched - indicates whether the sensor is currently pressed
- is_growing - indicates whether brightness is increasing or decreasing
Additionally, the device sends status telemetry every 10 seconds, even when there is no user interaction, allowing you to continuously monitor the lamp state from the platform.
Data VisualizationTo make the device data easier to understand and interact with, we will visualize it using widgets on a ThingsBoard dashboard. Dashboards allow you to display telemetry such as brightness, touch state, and percentage level in a clear and human-readable form using charts, gauges, and indicators.
You are able to import a dashboard in JSON format. You can find the corresponding dashboard and download it here. In order to import the dashboard, use the following steps:
- Navigate to the “Dashboards” page on the left sidebar.
- Click on the “+” button in the upper right corner of the page and choose “Import dashboard”.
- The dashboard import window should pop up, and you will be prompted to upload the downloaded dashboard JSON file and click “Import”.
- Then click on the created dashboard, and you will see the following dashboard from the JSON file. You are able to both monitor and control the device via appropriate widgets.
Make sure that the device name has to be called “LED Lamp” in order to bind to an Aliase and widget states synchronization.Summary
In this article, we walked through the entire process - from wiring the hardware and implementing the device logic in MicroPython to integrating the device with ThingsBoard for real-time telemetry and remote control. You are free to adapt and extend the code for your own use cases, customize the dashboards, or reuse the same approach for other MicroPython-based devices. By following these steps, you can quickly build IoT devices that report live data and respond to RPC commands through intuitive dashboards.
If you have questionsor ideas for improvements, place a comment below!








Comments