Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
605 changes: 605 additions & 0 deletions _posts/2026-03-31-matter-light-bulb.md

Large diffs are not rendered by default.

134 changes: 134 additions & 0 deletions example/matter-light-bulb/light.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/*
* Light control — LEDs 1-3 as a dimmable "light bulb" via PWM.
* LED0 is reserved for Matter status indication.
* Button 1 (sw0) toggles on/off via callback from Board library.
*/

#include "light.h"

#include <zephyr/drivers/pwm.h>
#include <zephyr/shell/shell.h>
#include <zephyr/logging/log.h>

Comment on lines +9 to +12
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

strtoul() is used below but <stdlib.h> isn’t included, which can trigger a build failure under C99/C11 (implicit declaration). Add the proper standard header.

Copilot uses AI. Check for mistakes.
LOG_MODULE_REGISTER(light, LOG_LEVEL_INF);

static const struct pwm_dt_spec pwm_leds[] = {
PWM_DT_SPEC_GET(DT_NODELABEL(pwm_led1)),
PWM_DT_SPEC_GET(DT_NODELABEL(pwm_led2)),
PWM_DT_SPEC_GET(DT_NODELABEL(pwm_led3)),
};

#define NUM_LEDS ARRAY_SIZE(pwm_leds)

Comment on lines +20 to +22
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NUM_LEDS uses ARRAY_SIZE(), but this translation unit doesn’t include the header that defines it (it relies on transitive includes). Add the appropriate Zephyr util header (or define a local ARRAY_SIZE) to make this file self-contained.

Copilot uses AI. Check for mistakes.
static bool light_on;
static uint8_t light_level = 254; /* default full brightness */

static void apply_pwm(void)
{
for (int i = 0; i < NUM_LEDS; i++) {
uint32_t pulse;

if (!light_on || light_level == 0) {
pulse = 0;
} else {
/* Map level 1–254 to pulse width (use uint64 to avoid overflow) */
pulse = (uint32_t)(((uint64_t)pwm_leds[i].period * light_level) / 254);
}
pwm_set_pulse_dt(&pwm_leds[i], pulse);
}
Comment on lines +34 to +38
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pwm_set_pulse_dt() returns an error code, but it’s ignored here. If the driver rejects an out-of-range pulse (e.g., due to an unexpected level), failures will be silent and the light state will desync from Matter. Capture and log/handle the return value.

Copilot uses AI. Check for mistakes.
}

/* --- Public API --- */

void light_set(bool on)
{
light_on = on;
apply_pwm();
LOG_INF("Light %s (level %d)", on ? "ON" : "OFF", light_level);
}

bool light_get(void)
{
return light_on;
}

void light_set_level(uint8_t level)
{
light_level = level;
if (level > 0) {
light_on = true;
}
Comment on lines +58 to +60
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When level is set to 0, apply_pwm() will turn the LEDs off, but light_on remains unchanged. That can make light_get()/status report ON while brightness is 0. Consider explicitly setting light_on = (level > 0) to keep state consistent.

Suggested change
if (level > 0) {
light_on = true;
}
light_on = (level > 0);

Copilot uses AI. Check for mistakes.
apply_pwm();
LOG_INF("Light level %d", level);
}

uint8_t light_get_level(void)
{
return light_level;
}

void light_toggle(void)
{
light_set(!light_on);
}

void light_init(void)
{
for (int i = 0; i < NUM_LEDS; i++) {
if (!pwm_is_ready_dt(&pwm_leds[i])) {
LOG_ERR("PWM LED %d not ready", i);
}
}
LOG_INF("Light initialized (%d PWM LEDs)", NUM_LEDS);
}

/* --- Shell commands --- */

static int cmd_light_on(const struct shell *sh, size_t argc, char **argv)
{
light_set(true);
return 0;
}

static int cmd_light_off(const struct shell *sh, size_t argc, char **argv)
{
light_set(false);
return 0;
}

static int cmd_light_toggle(const struct shell *sh, size_t argc, char **argv)
{
light_toggle();
return 0;
}

static int cmd_light_level(const struct shell *sh, size_t argc, char **argv)
{
if (argc < 2) {
shell_print(sh, "Level: %d", light_get_level());
return 0;
}
uint8_t level = (uint8_t)strtoul(argv[1], NULL, 10);

light_set_level(level);
return 0;
Comment on lines +111 to +114
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The shell input is parsed with strtoul() and then cast to uint8_t, so out-of-range values will wrap (e.g., 256 → 0) and 255 can produce an out-of-range PWM pulse. Validate/clamp the value to 0–254 (per the help text) and return an error on invalid input.

Copilot uses AI. Check for mistakes.
}

static int cmd_light_status(const struct shell *sh, size_t argc, char **argv)
{
shell_print(sh, "Light is %s, level %d",
light_get() ? "ON" : "OFF", light_get_level());
return 0;
}

SHELL_STATIC_SUBCMD_SET_CREATE(light_sub,
SHELL_CMD(on, NULL, "Turn light on", cmd_light_on),
SHELL_CMD(off, NULL, "Turn light off", cmd_light_off),
SHELL_CMD(toggle, NULL, "Toggle light", cmd_light_toggle),
SHELL_CMD_ARG(level, NULL, "Set/get brightness (0-254)",
cmd_light_level, 1, 1),
SHELL_CMD(status, NULL, "Show light state", cmd_light_status),
SHELL_SUBCMD_SET_END
);

SHELL_CMD_REGISTER(light, &light_sub, "Light control", NULL);
18 changes: 18 additions & 0 deletions example/matter-light-bulb/light.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#ifndef LIGHT_H_
#define LIGHT_H_

#include <stdbool.h>
#include <stdint.h>

/* Initialize PWM for LEDs. Call AFTER Nrf::GetBoard().Init(). */
void light_init(void);

void light_set(bool on);
bool light_get(void);
void light_toggle(void);

/* Brightness: 0 = off, 1–254 = dim to full */
void light_set_level(uint8_t level);
uint8_t light_get_level(void);

#endif /* LIGHT_H_ */
59 changes: 59 additions & 0 deletions example/matter-light-bulb/nrf54lm20dk_nrf54lm20a_cpuapp.overlay
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#include <zephyr/dt-bindings/pwm/pwm.h>

/ {
chosen {
nordic,pm-ext-flash = &mx25r64;
};

aliases {
watchdog0 = &wdt31;
};

/* PWM-driven LEDs for dimmable light (LED1-LED3) */
pwmleds {
compatible = "pwm-leds";
pwm_led1: pwm_led_1 {
pwms = <&pwm20 0 PWM_MSEC(20) PWM_POLARITY_NORMAL>;
};
pwm_led2: pwm_led_2 {
pwms = <&pwm20 1 PWM_MSEC(20) PWM_POLARITY_NORMAL>;
};
pwm_led3: pwm_led_3 {
pwms = <&pwm20 2 PWM_MSEC(20) PWM_POLARITY_NORMAL>;
};
};
};

/* Override pinctrl to add PWM channels for LED2 and LED3 */
&pinctrl {
pwm20_default: pwm20_default {
group1 {
psels = <NRF_PSEL(PWM_OUT0, 1, 25)>, /* LED1: P1.25 */
<NRF_PSEL(PWM_OUT1, 1, 27)>, /* LED2: P1.27 */
<NRF_PSEL(PWM_OUT2, 1, 28)>; /* LED3: P1.28 */
};
};
pwm20_sleep: pwm20_sleep {
group1 {
psels = <NRF_PSEL(PWM_OUT0, 1, 25)>,
<NRF_PSEL(PWM_OUT1, 1, 27)>,
<NRF_PSEL(PWM_OUT2, 1, 28)>;
low-power-enable;
};
};
};

&rram_controller {
cpuapp_rram: rram@0 {
reg = <0x0 DT_SIZE_K(2036)>;
ranges = <0x0 0x0 DT_SIZE_K(2036)>;
};
};

&mx25r64 {
status = "okay";
};

&wdt31 {
status = "okay";
};
62 changes: 62 additions & 0 deletions example/matter-light-bulb/zap_compose.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""
Compose a Matter ZAP file from fragments.

Usage:
python3 zap_compose.py base.json [fragment1.json ...] > output.zap

The base fragment provides the global config (fileFormat, featureLevel,
keyValuePairs, package) and the root endpoint. Each additional fragment
adds an application endpoint.

Endpoints are assigned IDs sequentially: 0 for the base's endpoint,
1 for the first fragment, 2 for the second, etc.
"""

import json
import sys


def compose(base_path, fragment_paths):
with open(base_path) as f:
zap = json.load(f)

for frag_path in fragment_paths:
with open(frag_path) as f:
frag = json.load(f)

# Append fragment's endpointTypes
zap["endpointTypes"].extend(frag["endpointTypes"])

# Rebuild endpoints array from endpointTypes
zap["endpoints"] = []
for i, ep_type in enumerate(zap["endpointTypes"]):
zap["endpoints"].append(
{
"endpointTypeName": ep_type["name"],
"endpointTypeIndex": i,
"profileId": 259, # Matter profile
"endpointId": i,
"networkId": 0,
"parentEndpointIdentifier": None,
}
)

return zap


def main():
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} base.json [fragment.json ...]", file=sys.stderr)
sys.exit(1)

base_path = sys.argv[1]
fragment_paths = sys.argv[2:]

zap = compose(base_path, fragment_paths)
json.dump(zap, sys.stdout, indent=2)
print() # trailing newline


if __name__ == "__main__":
main()
68 changes: 68 additions & 0 deletions example/matter-light-bulb/zap_decompose.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""
Decompose a Matter ZAP file into composable fragments.

Usage:
python3 zap_decompose.py input.zap output_dir/

Creates:
output_dir/base.json — root endpoint + global config
output_dir/<endpoint_name>.json — one file per application endpoint
"""

import json
import os
import re
import sys


def slugify(name):
"""Convert endpoint name to a filename-safe slug."""
return re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_")


def decompose(zap_path, output_dir):
with open(zap_path) as f:
zap = json.load(f)

os.makedirs(output_dir, exist_ok=True)

endpoint_types = zap["endpointTypes"]

# Base fragment: global config + first endpointType (root)
base = {
"fileFormat": zap["fileFormat"],
"featureLevel": zap["featureLevel"],
"creator": zap["creator"],
"keyValuePairs": zap["keyValuePairs"],
"package": zap["package"],
"endpointTypes": [endpoint_types[0]],
}

base_path = os.path.join(output_dir, "base.json")
with open(base_path, "w") as f:
json.dump(base, f, indent=2)
f.write("\n")
print(f" {base_path} ({endpoint_types[0]['name']})")

# Application fragments: one per remaining endpointType
for ep_type in endpoint_types[1:]:
frag = {"endpointTypes": [ep_type]}
name = slugify(ep_type["name"])
frag_path = os.path.join(output_dir, f"{name}.json")
with open(frag_path, "w") as f:
json.dump(frag, f, indent=2)
f.write("\n")
print(f" {frag_path} ({ep_type['name']})")


def main():
if len(sys.argv) != 3:
print(f"Usage: {sys.argv[0]} input.zap output_dir/", file=sys.stderr)
sys.exit(1)

decompose(sys.argv[1], sys.argv[2])


if __name__ == "__main__":
main()
Loading
Loading