Add ArtNetDMX Pico W firmware with dual DMX and docs.

Art-Net to two RS-485 outputs over Wi-Fi (AP or STA with SoftAP fallback), web UI, mDNS, and QLC+/ArtPoll notes for unicast on STA.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-11 00:00:23 +12:00
co-authored by Cursor
commit fea6dffa74
26 changed files with 3013 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
/build/
*.uf2
*.elf
*.bin
*.hex
*.dis
*.map
.idea/
.vscode/
*.o
+54
View File
@@ -0,0 +1,54 @@
cmake_minimum_required(VERSION 3.13)
set(PICO_BOARD pico_w CACHE STRING "Board type")
set(PICO_CYW43_ARCH_THREADSAFE_BACKGROUND ON)
include(${CMAKE_CURRENT_LIST_DIR}/pico_sdk_import.cmake)
project(artnetdmx C CXX ASM)
set(CMAKE_C_STANDARD 11)
set(CMAKE_CXX_STANDARD 17)
pico_sdk_init()
add_executable(artnetdmx
src/main.c
src/config.c
src/channels.c
src/dmx_out.c
src/artnet.c
src/wifi_mgr.c
src/http_server.c
third_party/dhcpserver/dhcpserver.c
)
pico_generate_pio_header(artnetdmx ${CMAKE_CURRENT_LIST_DIR}/src/dmx_out.pio)
target_include_directories(artnetdmx PRIVATE
${CMAKE_CURRENT_LIST_DIR}/include
${CMAKE_CURRENT_LIST_DIR}
${CMAKE_CURRENT_LIST_DIR}/third_party/dhcpserver
)
target_compile_definitions(artnetdmx PRIVATE
PICO_CYW43_ARCH_THREADSAFE_BACKGROUND=1
)
target_link_libraries(artnetdmx
pico_stdlib
pico_cyw43_arch_lwip_threadsafe_background
pico_lwip_mdns
hardware_pio
hardware_dma
hardware_flash
hardware_sync
)
pico_configure_ip4_address(artnetdmx PRIVATE
CYW43_DEFAULT_IP_AP_ADDRESS 192.168.4.1
)
pico_enable_stdio_usb(artnetdmx 1)
pico_enable_stdio_uart(artnetdmx 0)
pico_add_extra_outputs(artnetdmx)
+118
View File
@@ -0,0 +1,118 @@
# ArtNetDMX — Pico W Art-Net → Dual DMX
Firmware for **Raspberry Pi Pico W** that receives Art-Net over Wi‑Fi and outputs **two DMX512 universes** through RS‑485, using **PIO + DMA** at **40 Hz**. A web UI configures Wi‑Fi (STA / AP) and shows / sets all 512 channels per universe.
## Hardware
| Signal | Pico W | RS‑485 module |
|--------|--------|----------------|
| DMX A data | GP0 | DI (universe A) |
| DMX B data | GP1 | DI (universe B) |
| GND | GND | GND |
| Power | 3V3 or 5V | VCC (many MAX485 boards want 5V) |
TX‑only: tie **DE** and **/RE** high (to VCC) on each module. XLR pinout is typically 1=GND, 2=Data− (B), 3=Data+ (A) — match your module labels.
Pins are defined in [`include/board.h`](include/board.h).
```
Pico W GP0 ──► MAX485 A DI
Pico W GP1 ──► MAX485 B DI
Pico W GND ──► both module GND
VCC ──► both module VCC
DE,/RE ──► VCC (TX enable)
A/B ──► XLR / DMX cable
```
## Build
Requires [Pico SDK](https://github.com/raspberrypi/pico-sdk) and an ARM GCC toolchain.
```bash
export PICO_SDK_PATH=/path/to/pico-sdk
mkdir -p build && cd build
cmake -DPICO_BOARD=pico_w ..
make -j$(nproc)
```
Flash with BOOTSEL + drag-and-drop, or:
```bash
picotool load -f build/artnetdmx.uf2
```
USB serial (115200) prints boot status, Wi‑Fi mode, and IP addresses.
Do not `cp` the UF2 onto a path that is not the mounted `RPI-RP2` volume (a failed copy can leave a plain file instead of flashing).
## First boot (AP)
1. Join Wi‑Fi **ArtNetDMX** / password **artnetdmx**
2. Open **http://192.168.4.1/** (or **http://artnetdmx.local/** once mDNS is up)
3. **WiFi** page: choose mode, credentials, Art‑Net universe numbers, mDNS hostname
4. Save — device reboots
### Wi‑Fi modes
| Mode | Behaviour |
|------|-----------|
| **Access Point** | SoftAP only (default `ArtNetDMX` / `artnetdmx` @ `192.168.4.1`) |
| **Station** | Joins your LAN only. If join fails, SoftAP starts for recovery |
There is no concurrent AP+STA mode (legacy “AP+Station” configs are treated as Station). SoftAP is paused while STA obtains DHCP, then left off if join succeeds.
mDNS hostname defaults to **`artnetdmx`** → **http://artnetdmx.local/** (configurable on the WiFi page).
## Art-Net
- UDP port **6454**
- Replies to **ArtPoll** / sends **ArtPollReply** (QLC+ and similar discovery)
- Maps configured universes to physical ports A (GP0) and B (GP1)
- Defaults: universe **0** → A, universe **1** → B
### SoftAP vs Station (important)
| Mode | Art-Net transport |
|------|-------------------|
| **SoftAP** | Controller joins the Pico’s AP. **Broadcast** ArtDmx usually works. |
| **Station** | Pico and controller share a home/office AP. Many APs **drop Wi‑Fi broadcast**, so ArtDmx broadcast never arrives. Use **unicast** to the Pico’s STA IP. |
Unicast ArtDmx on STA has been verified end-to-end; subnet broadcast often is not delivered by the upstream AP.
### QLC+ setup
1. Put the Pico in **Station** mode and note its IP (web UI, serial, or mDNS).
2. **Inputs/Outputs** → Art-Net plugin.
3. You will typically see **two nodes**: **QLC+ itself** and **ArtNetDMX**. Select **ArtNetDMX** (not the QLC+ loopback node).
4. Set output to **unicast** → Pico STA IP (e.g. `10.1.1.190`), not broadcast and not `192.168.4.1` unless you are on SoftAP.
5. Enable the Art-Net **output** line (icon lit).
6. Map universes: **0** → DMX A, **1** → DMX B (or match the WiFi page).
7. Patch fixtures to those universes.
Confirm on **http://&lt;pico-ip&gt;/** (Channels): Art-Net packet count should rise and channel values should follow faders. If the web UI updates but fixtures do not, check RS‑485 wiring / DE·/RE / cabling.
## Web UI
- **Channels** — tabs for universe A/B; all 512 values; edit live; Clear / All 255
- **WiFi** — AP or Station, credentials, Art‑Net universes, mDNS hostname, live debug line
- Status strip: mode, IP, Art‑Net packet count, age since last packet per port
Manual channel edits drive the same buffers as Art‑Net; new Art‑Net packets overwrite those channels.
Embedded pages live in `src/http_server.c`. The `www/` tree is a reference copy of the HTML structure.
## Project layout
| Path | Role |
|------|------|
| `src/main.c` | Boot, DMX timer, poll loops |
| `src/wifi_mgr.c` | SoftAP / STA, DHCP, mDNS hooks |
| `src/artnet.c` | ArtDmx + ArtPoll/ArtPollReply |
| `src/dmx_out.c` + `.pio` | PIO + DMA DMX TX |
| `src/http_server.c` | Web UI + JSON API |
| `src/config.c` | Flash config (CRC, versioned) |
| `lwipopts.h` | lwIP tuning (UDP PCBs, SoftAP DHCP, broadcast) |
## License notes
DMX PIO timing is adapted from [jostlowe/Pico-DMX](https://github.com/jostlowe/Pico-DMX) (BSD‑3‑Clause). DHCP server is from MicroPython / pico‑examples (MIT).
+14
View File
@@ -0,0 +1,14 @@
#ifndef ARTNET_H
#define ARTNET_H
#include <stdbool.h>
bool artnet_start(void);
/** Re-bind listeners to current AP/STA IPs (call after SoftAP re-assert or STA join). */
void artnet_refresh(void);
/** Broadcast ArtPollReply (call after interface comes up). */
void artnet_announce(void);
/** Periodic discovery announces (call from main loop). */
void artnet_poll(void);
#endif
+25
View File
@@ -0,0 +1,25 @@
#ifndef BOARD_H
#define BOARD_H
#include <stdint.h>
/** DMX universe A (port 0) TX pin → RS-485 DI */
#define BOARD_DMX_A_PIN 0
/** DMX universe B (port 1) TX pin → RS-485 DI */
#define BOARD_DMX_B_PIN 1
#define BOARD_NUM_UNIVERSES 2
#define BOARD_DMX_CHANNELS 512
#define BOARD_DMX_FRAME_LEN (1 + BOARD_DMX_CHANNELS) /* start code + 512 */
#define BOARD_DMX_HZ 40
#define BOARD_DMX_PERIOD_MS (1000 / BOARD_DMX_HZ)
#define BOARD_DEFAULT_UNIVERSE_A 0
#define BOARD_DEFAULT_UNIVERSE_B 1
#define BOARD_AP_SSID_DEFAULT "ArtNetDMX"
#define BOARD_AP_PASS_DEFAULT "artnetdmx"
#define BOARD_HOSTNAME "artnetdmx"
#endif
+24
View File
@@ -0,0 +1,24 @@
#ifndef CHANNELS_H
#define CHANNELS_H
#include <stdint.h>
#include <stddef.h>
#include "board.h"
void channels_init(void);
/** Returns pointer to 512 channel values for universe index 0 or 1 (no start code). */
uint8_t *channels_get(unsigned universe);
/** Build a 513-byte DMX frame (start code 0 + 512 channels) into out. */
void channels_build_frame(unsigned universe, uint8_t out[BOARD_DMX_FRAME_LEN]);
void channels_set(unsigned universe, unsigned channel, uint8_t value);
void channels_set_range(unsigned universe, const uint8_t *values, size_t len);
void channels_clear(unsigned universe);
void channels_note_artnet(unsigned universe);
uint32_t channels_artnet_packets(void);
uint32_t channels_ms_since_artnet(unsigned universe);
#endif
+57
View File
@@ -0,0 +1,57 @@
#ifndef CONFIG_H
#define CONFIG_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "board.h"
#define CONFIG_MAGIC 0x414E4458u /* 'ANDX' */
#define CONFIG_VERSION 3
#define CONFIG_SSID_MAX 32
#define CONFIG_PASS_MAX 64
#define CONFIG_HOSTNAME_MAX 32
typedef enum {
WIFI_MODE_AP = 0,
WIFI_MODE_STA = 1,
/** Legacy flash value; treated as STA (with AP fallback on failure). */
WIFI_MODE_AP_STA = 2,
} wifi_mode_t;
typedef struct {
uint32_t magic;
uint32_t version;
uint32_t crc;
wifi_mode_t wifi_mode;
char sta_ssid[CONFIG_SSID_MAX];
char sta_pass[CONFIG_PASS_MAX];
char ap_ssid[CONFIG_SSID_MAX];
char ap_pass[CONFIG_PASS_MAX];
uint16_t universe_a;
uint16_t universe_b;
/* v3: mDNS hostname without ".local" (e.g. "artnetdmx") */
char mdns_host[CONFIG_HOSTNAME_MAX];
} config_t;
void config_set_defaults(config_t *cfg);
bool config_load(config_t *cfg);
bool config_save(const config_t *cfg);
const config_t *config_get(void);
void config_init(void);
/** Lowercase DNS-label sanitize into out; returns false if empty/invalid → uses default. */
bool config_sanitize_hostname(const char *in, char *out, size_t out_sz);
static inline const char *wifi_mode_name(wifi_mode_t m) {
switch (m) {
case WIFI_MODE_STA:
case WIFI_MODE_AP_STA:
return "STA";
case WIFI_MODE_AP:
default:
return "AP";
}
}
#endif
+21
View File
@@ -0,0 +1,21 @@
#ifndef DMX_OUT_H
#define DMX_OUT_H
#include <stdbool.h>
#include <stdint.h>
#include "hardware/pio.h"
typedef struct {
PIO pio;
uint sm;
uint dma;
uint pin;
uint prgm_offset;
bool inited;
} dmx_out_t;
bool dmx_out_init(dmx_out_t *out, uint pin, PIO pio);
void dmx_out_write(dmx_out_t *out, const uint8_t *frame, uint length);
bool dmx_out_busy(const dmx_out_t *out);
#endif
+8
View File
@@ -0,0 +1,8 @@
#ifndef HTTP_SERVER_H
#define HTTP_SERVER_H
#include <stdbool.h>
bool http_server_start(void);
#endif
+25
View File
@@ -0,0 +1,25 @@
#ifndef WIFI_MGR_H
#define WIFI_MGR_H
#include <stdbool.h>
#include "config.h"
/**
* AP mode: SoftAP only.
* STA mode: join LAN; if that fails, SoftAP comes up for recovery.
*/
bool wifi_mgr_start(const config_t *cfg);
/** Call from main loop — runs deferred STA join (STA mode only). */
void wifi_mgr_poll(void);
wifi_mode_t wifi_mgr_active_mode(void);
/** Primary IP for UI (STA if up, else AP). */
const char *wifi_mgr_ip_str(void);
const char *wifi_mgr_sta_ip_str(void);
const char *wifi_mgr_ap_ip_str(void);
const char *wifi_mgr_mdns_name(void);
bool wifi_mgr_sta_up(void);
bool wifi_mgr_ap_up(void);
bool wifi_mgr_is_up(void);
const char *wifi_mgr_debug(void);
#endif
+37
View File
@@ -0,0 +1,37 @@
#ifndef _LWIPOPTS_H
#define _LWIPOPTS_H
#include "lwipopts_examples_common.h"
#undef MEM_SIZE
#define MEM_SIZE 16000
#undef MEMP_NUM_TCP_SEG
#define MEMP_NUM_TCP_SEG 48
#undef PBUF_POOL_SIZE
#define PBUF_POOL_SIZE 32
/*
* Default MEMP_NUM_UDP_PCB is 4 — not enough for concurrent SoftAP DHCP
* server + STA DHCP client + Art-Net (ANY/AP/STA) + mDNS. STA then sticks
* at CYW43_LINK_NOIP with dhcp_start() returning ERR_MEM (-1).
*/
#undef MEMP_NUM_UDP_PCB
#define MEMP_NUM_UDP_PCB 12
/* mDNS + DHCP server + dual netif (AP+STA) need extra sys_timeout slots.
* Too-small MEMP_NUM_SYS_TIMEOUT panics when a SoftAP client joins (DHCP). */
#define LWIP_MDNS_RESPONDER 1
#define LWIP_IGMP 1
#define LWIP_NUM_NETIF_CLIENT_DATA 1
#define MDNS_RESP_USENETIF_EXTCALLBACK 1
#undef MEMP_NUM_SYS_TIMEOUT
#define MEMP_NUM_SYS_TIMEOUT (LWIP_NUM_SYS_TIMEOUT_INTERNAL + 16)
/* Allow Art-Net UDP bind on AP IP + STA IP + ANY (same port). */
#define SO_REUSE 1
/* Art-Net discovery uses directed / limited broadcast. */
#define IP_SOF_BROADCAST 1
#define IP_SOF_BROADCAST_RECV 1
#endif
+92
View File
@@ -0,0 +1,92 @@
#ifndef _LWIPOPTS_EXAMPLE_COMMONH_H
#define _LWIPOPTS_EXAMPLE_COMMONH_H
// Common settings used in most of the pico_w examples
// (see https://www.nongnu.org/lwip/2_1_x/group__lwip__opts.html for details)
// allow override in some examples
#ifndef NO_SYS
#define NO_SYS 1
#endif
// allow override in some examples
#ifndef LWIP_SOCKET
#define LWIP_SOCKET 0
#endif
#if PICO_CYW43_ARCH_POLL
#define MEM_LIBC_MALLOC 1
#else
// MEM_LIBC_MALLOC is incompatible with non polling versions
#define MEM_LIBC_MALLOC 0
#endif
#define MEM_ALIGNMENT 4
#ifndef MEM_SIZE
#define MEM_SIZE 4000
#endif
#define MEMP_NUM_TCP_SEG 32
#define MEMP_NUM_ARP_QUEUE 10
#define PBUF_POOL_SIZE 24
#define LWIP_ARP 1
#define LWIP_ETHERNET 1
#define LWIP_ICMP 1
#define LWIP_RAW 1
#define TCP_WND (8 * TCP_MSS)
#define TCP_MSS 1460
#define TCP_SND_BUF (8 * TCP_MSS)
#define TCP_SND_QUEUELEN ((4 * (TCP_SND_BUF) + (TCP_MSS - 1)) / (TCP_MSS))
#define LWIP_NETIF_STATUS_CALLBACK 1
#define LWIP_NETIF_LINK_CALLBACK 1
#define LWIP_NETIF_HOSTNAME 1
#define LWIP_NETCONN 0
#define MEM_STATS 0
#define SYS_STATS 0
#define MEMP_STATS 0
#define LINK_STATS 0
// #define ETH_PAD_SIZE 2
#define LWIP_CHKSUM_ALGORITHM 3
#define LWIP_DHCP 1
#define LWIP_IPV4 1
#define LWIP_TCP 1
#define LWIP_UDP 1
#define LWIP_DNS 1
#define LWIP_TCP_KEEPALIVE 1
#define LWIP_NETIF_TX_SINGLE_PBUF 1
#define DHCP_DOES_ARP_CHECK 0
#define LWIP_DHCP_DOES_ACD_CHECK 0
#ifndef NDEBUG
#define LWIP_DEBUG 1
#define LWIP_STATS 1
#define LWIP_STATS_DISPLAY 1
#endif
#define ETHARP_DEBUG LWIP_DBG_OFF
#define NETIF_DEBUG LWIP_DBG_OFF
#define PBUF_DEBUG LWIP_DBG_OFF
#define API_LIB_DEBUG LWIP_DBG_OFF
#define API_MSG_DEBUG LWIP_DBG_OFF
#define SOCKETS_DEBUG LWIP_DBG_OFF
#define ICMP_DEBUG LWIP_DBG_OFF
#define INET_DEBUG LWIP_DBG_OFF
#define IP_DEBUG LWIP_DBG_OFF
#define IP_REASS_DEBUG LWIP_DBG_OFF
#define RAW_DEBUG LWIP_DBG_OFF
#define MEM_DEBUG LWIP_DBG_OFF
#define MEMP_DEBUG LWIP_DBG_OFF
#define SYS_DEBUG LWIP_DBG_OFF
#define TCP_DEBUG LWIP_DBG_OFF
#define TCP_INPUT_DEBUG LWIP_DBG_OFF
#define TCP_OUTPUT_DEBUG LWIP_DBG_OFF
#define TCP_RTO_DEBUG LWIP_DBG_OFF
#define TCP_CWND_DEBUG LWIP_DBG_OFF
#define TCP_WND_DEBUG LWIP_DBG_OFF
#define TCP_FR_DEBUG LWIP_DBG_OFF
#define TCP_QLEN_DEBUG LWIP_DBG_OFF
#define TCP_RST_DEBUG LWIP_DBG_OFF
#define UDP_DEBUG LWIP_DBG_OFF
#define TCPIP_DEBUG LWIP_DBG_OFF
#define PPP_DEBUG LWIP_DBG_OFF
#define SLIP_DEBUG LWIP_DBG_OFF
#define DHCP_DEBUG LWIP_DBG_OFF
#endif /* __LWIPOPTS_H__ */
+121
View File
@@ -0,0 +1,121 @@
# This is a copy of <PICO_SDK_PATH>/external/pico_sdk_import.cmake
# This can be dropped into an external project to help locate this SDK
# It should be include()ed prior to project()
# Copyright 2020 (c) 2020 Raspberry Pi (Trading) Ltd.
#
# Redistribution and use in source and binary forms, with or without modification, are permitted provided that the
# following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following
# disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following
# disclaimer in the documentation and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products
# derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
# INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
if (DEFINED ENV{PICO_SDK_PATH} AND (NOT PICO_SDK_PATH))
set(PICO_SDK_PATH $ENV{PICO_SDK_PATH})
message("Using PICO_SDK_PATH from environment ('${PICO_SDK_PATH}')")
endif ()
if (DEFINED ENV{PICO_SDK_FETCH_FROM_GIT} AND (NOT PICO_SDK_FETCH_FROM_GIT))
set(PICO_SDK_FETCH_FROM_GIT $ENV{PICO_SDK_FETCH_FROM_GIT})
message("Using PICO_SDK_FETCH_FROM_GIT from environment ('${PICO_SDK_FETCH_FROM_GIT}')")
endif ()
if (DEFINED ENV{PICO_SDK_FETCH_FROM_GIT_PATH} AND (NOT PICO_SDK_FETCH_FROM_GIT_PATH))
set(PICO_SDK_FETCH_FROM_GIT_PATH $ENV{PICO_SDK_FETCH_FROM_GIT_PATH})
message("Using PICO_SDK_FETCH_FROM_GIT_PATH from environment ('${PICO_SDK_FETCH_FROM_GIT_PATH}')")
endif ()
if (DEFINED ENV{PICO_SDK_FETCH_FROM_GIT_TAG} AND (NOT PICO_SDK_FETCH_FROM_GIT_TAG))
set(PICO_SDK_FETCH_FROM_GIT_TAG $ENV{PICO_SDK_FETCH_FROM_GIT_TAG})
message("Using PICO_SDK_FETCH_FROM_GIT_TAG from environment ('${PICO_SDK_FETCH_FROM_GIT_TAG}')")
endif ()
if (PICO_SDK_FETCH_FROM_GIT AND NOT PICO_SDK_FETCH_FROM_GIT_TAG)
set(PICO_SDK_FETCH_FROM_GIT_TAG "master")
message("Using master as default value for PICO_SDK_FETCH_FROM_GIT_TAG")
endif()
set(PICO_SDK_PATH "${PICO_SDK_PATH}" CACHE PATH "Path to the Raspberry Pi Pico SDK")
set(PICO_SDK_FETCH_FROM_GIT "${PICO_SDK_FETCH_FROM_GIT}" CACHE BOOL "Set to ON to fetch copy of SDK from git if not otherwise locatable")
set(PICO_SDK_FETCH_FROM_GIT_PATH "${PICO_SDK_FETCH_FROM_GIT_PATH}" CACHE FILEPATH "location to download SDK")
set(PICO_SDK_FETCH_FROM_GIT_TAG "${PICO_SDK_FETCH_FROM_GIT_TAG}" CACHE FILEPATH "release tag for SDK")
if (NOT PICO_SDK_PATH)
if (PICO_SDK_FETCH_FROM_GIT)
include(FetchContent)
set(FETCHCONTENT_BASE_DIR_SAVE ${FETCHCONTENT_BASE_DIR})
if (PICO_SDK_FETCH_FROM_GIT_PATH)
get_filename_component(FETCHCONTENT_BASE_DIR "${PICO_SDK_FETCH_FROM_GIT_PATH}" REALPATH BASE_DIR "${CMAKE_SOURCE_DIR}")
endif ()
FetchContent_Declare(
pico_sdk
GIT_REPOSITORY https://github.com/raspberrypi/pico-sdk
GIT_TAG ${PICO_SDK_FETCH_FROM_GIT_TAG}
)
if (NOT pico_sdk)
message("Downloading Raspberry Pi Pico SDK")
# GIT_SUBMODULES_RECURSE was added in 3.17
if (${CMAKE_VERSION} VERSION_GREATER_EQUAL "3.17.0")
FetchContent_Populate(
pico_sdk
QUIET
GIT_REPOSITORY https://github.com/raspberrypi/pico-sdk
GIT_TAG ${PICO_SDK_FETCH_FROM_GIT_TAG}
GIT_SUBMODULES_RECURSE FALSE
SOURCE_DIR ${FETCHCONTENT_BASE_DIR}/pico_sdk-src
BINARY_DIR ${FETCHCONTENT_BASE_DIR}/pico_sdk-build
SUBBUILD_DIR ${FETCHCONTENT_BASE_DIR}/pico_sdk-subbuild
)
else ()
FetchContent_Populate(
pico_sdk
QUIET
GIT_REPOSITORY https://github.com/raspberrypi/pico-sdk
GIT_TAG ${PICO_SDK_FETCH_FROM_GIT_TAG}
SOURCE_DIR ${FETCHCONTENT_BASE_DIR}/pico_sdk-src
BINARY_DIR ${FETCHCONTENT_BASE_DIR}/pico_sdk-build
SUBBUILD_DIR ${FETCHCONTENT_BASE_DIR}/pico_sdk-subbuild
)
endif ()
set(PICO_SDK_PATH ${pico_sdk_SOURCE_DIR})
endif ()
set(FETCHCONTENT_BASE_DIR ${FETCHCONTENT_BASE_DIR_SAVE})
else ()
message(FATAL_ERROR
"SDK location was not specified. Please set PICO_SDK_PATH or set PICO_SDK_FETCH_FROM_GIT to on to fetch from git."
)
endif ()
endif ()
get_filename_component(PICO_SDK_PATH "${PICO_SDK_PATH}" REALPATH BASE_DIR "${CMAKE_BINARY_DIR}")
if (NOT EXISTS ${PICO_SDK_PATH})
message(FATAL_ERROR "Directory '${PICO_SDK_PATH}' not found")
endif ()
set(PICO_SDK_INIT_CMAKE_FILE ${PICO_SDK_PATH}/pico_sdk_init.cmake)
if (NOT EXISTS ${PICO_SDK_INIT_CMAKE_FILE})
message(FATAL_ERROR "Directory '${PICO_SDK_PATH}' does not appear to contain the Raspberry Pi Pico SDK")
endif ()
set(PICO_SDK_PATH ${PICO_SDK_PATH} CACHE PATH "Path to the Raspberry Pi Pico SDK" FORCE)
include(${PICO_SDK_INIT_CMAKE_FILE})
+339
View File
@@ -0,0 +1,339 @@
#include "artnet.h"
#include <stdio.h>
#include <string.h>
#include "lwip/pbuf.h"
#include "lwip/udp.h"
#include "lwip/netif.h"
#include "pico/cyw43_arch.h"
#include "pico/stdlib.h"
#include "board.h"
#include "channels.h"
#include "config.h"
#include "wifi_mgr.h"
#define ARTNET_PORT 6454
#define ARTNET_OPCODE_POLL 0x2000
#define ARTNET_OPCODE_POLLREPLY 0x2100
#define ARTNET_OPCODE_DMX 0x5000
/* Classic ArtPollReply length (Art-Net 3 / widely accepted by QLC+). */
#define ARTNET_POLLREPLY_LEN 239
static struct udp_pcb *g_pcb;
static uint32_t g_rx_count;
static uint32_t g_poll_count;
static absolute_time_t g_announce_until;
static absolute_time_t g_next_announce;
static absolute_time_t g_last_poll_reply;
static uint16_t rd_le16(const uint8_t *p) {
return (uint16_t)p[0] | ((uint16_t)p[1] << 8);
}
static void wr_le16(uint8_t *p, uint16_t v) {
p[0] = (uint8_t)(v & 0xff);
p[1] = (uint8_t)(v >> 8);
}
static void fill_ip4(uint8_t out[4], const ip4_addr_t *ip) {
uint32_t a = ip4_addr_get_u32(ip);
out[0] = (uint8_t)(a & 0xff);
out[1] = (uint8_t)((a >> 8) & 0xff);
out[2] = (uint8_t)((a >> 16) & 0xff);
out[3] = (uint8_t)((a >> 24) & 0xff);
}
static bool our_ipv4(ip4_addr_t *out) {
if (wifi_mgr_sta_up()) {
*out = *netif_ip4_addr(&cyw43_state.netif[CYW43_ITF_STA]);
if (!ip4_addr_isany_val(*out)) {
return true;
}
}
if (wifi_mgr_ap_up()) {
*out = *netif_ip4_addr(&cyw43_state.netif[CYW43_ITF_AP]);
if (!ip4_addr_isany_val(*out)) {
return true;
}
IP4_ADDR(out, 192, 168, 4, 1);
return true;
}
return false;
}
static struct udp_pcb *send_pcb(void) {
return g_pcb;
}
static void build_poll_reply(uint8_t *pkt) {
const config_t *cfg = config_get();
memset(pkt, 0, ARTNET_POLLREPLY_LEN);
memcpy(pkt, "Art-Net", 8);
wr_le16(&pkt[8], ARTNET_OPCODE_POLLREPLY);
ip4_addr_t ip;
if (our_ipv4(&ip)) {
fill_ip4(&pkt[10], &ip);
}
wr_le16(&pkt[14], ARTNET_PORT); /* Protocol port 0x1936 */
pkt[16] = 1; /* VersInfo Hi */
pkt[17] = 0; /* VersInfo Lo — firmware 1.0 */
/* 15-bit Port-Address split across Net / Sub / Universe nibble */
uint16_t pa = cfg->universe_a & 0x7fff;
pkt[18] = (uint8_t)((pa >> 8) & 0x7f); /* NetSwitch */
pkt[19] = (uint8_t)((pa >> 4) & 0x0f); /* SubSwitch */
pkt[20] = 0x00; /* Oem Hi — unregistered / prototype */
pkt[21] = 0xff; /* Oem Lo */
pkt[22] = 0; /* UbeaVersion */
pkt[23] = 0xe0; /* Status1: indicators normal, Port-Address Authority set */
pkt[24] = 0x00; /* ESTA Man Lo */
pkt[25] = 0x00; /* ESTA Man Hi */
strncpy((char *)&pkt[26], "ArtNetDMX", 17);
strncpy((char *)&pkt[44], "ArtNetDMX Pico W dual DMX", 63);
snprintf((char *)&pkt[108], 64, "#0001 [%04u] OK", (unsigned)(g_rx_count & 0xffff));
pkt[172] = 0;
pkt[173] = 2; /* NumPorts = 2 */
/* PortTypes: Art-Net capable of output (DMX out from network) */
pkt[174] = 0x80;
pkt[175] = 0x80;
/* GoodInput unused for TX-only */
pkt[178] = 0;
pkt[179] = 0;
/* GoodOutputA: data is being transmitted on channel */
pkt[182] = 0x80;
pkt[183] = 0x80;
/* SwIn unused */
/* SwOut: low 4 bits of each port's Port-Address */
pkt[190] = (uint8_t)(cfg->universe_a & 0x0f);
pkt[191] = (uint8_t)(cfg->universe_b & 0x0f);
pkt[200] = 0x00; /* Style = StNode */
uint8_t mac[6] = {0};
int itf = wifi_mgr_sta_up() ? CYW43_ITF_STA : CYW43_ITF_AP;
cyw43_wifi_get_mac(&cyw43_state, itf, mac);
memcpy(&pkt[201], mac, 6);
if (our_ipv4(&ip)) {
fill_ip4(&pkt[207], &ip); /* BindIp */
}
pkt[211] = 1; /* BindIndex */
pkt[212] = 0x08; /* Status2: DHCP capable / 15-bit Port-Address support */
}
static void send_poll_reply(const ip_addr_t *dst, u16_t port) {
struct udp_pcb *pcb = send_pcb();
if (!pcb || !dst) {
return;
}
uint8_t pkt[ARTNET_POLLREPLY_LEN];
build_poll_reply(pkt);
struct pbuf *p = pbuf_alloc(PBUF_TRANSPORT, ARTNET_POLLREPLY_LEN, PBUF_RAM);
if (!p) {
printf("[artnet] poll reply alloc failed\n");
return;
}
memcpy(p->payload, pkt, ARTNET_POLLREPLY_LEN);
err_t err = udp_sendto(pcb, p, dst, port);
pbuf_free(p);
if (err != ERR_OK) {
printf("[artnet] poll reply send failed %d\n", (int)err);
} else if ((g_poll_count <= 4) || ((g_poll_count & 0x1f) == 0)) {
printf("[artnet] ArtPollReply #%lu -> %s:%u\n",
(unsigned long)g_poll_count, ipaddr_ntoa(dst), port);
}
}
void artnet_announce(void) {
ip4_addr_t local;
if (!our_ipv4(&local)) {
return;
}
/* One subnet-directed broadcast — dual announces looked like two nodes in QLC+. */
ip_addr_t bcast;
ip4_addr_t mask;
if (wifi_mgr_sta_up()) {
mask = *netif_ip4_netmask(&cyw43_state.netif[CYW43_ITF_STA]);
} else {
IP4_ADDR(&mask, 255, 255, 255, 0);
}
ip4_addr_t b4;
b4.addr = (local.addr & mask.addr) | ~mask.addr;
ip_addr_set_ip4_u32_val(bcast, b4.addr);
cyw43_arch_lwip_begin();
send_poll_reply(&bcast, ARTNET_PORT);
cyw43_arch_lwip_end();
g_poll_count++;
}
void artnet_poll(void) {
if (is_nil_time(g_announce_until) || time_reached(g_announce_until)) {
return;
}
if (!time_reached(g_next_announce)) {
return;
}
g_next_announce = make_timeout_time_ms(2500);
artnet_announce();
}
static void artnet_recv(void *arg, struct udp_pcb *pcb, struct pbuf *p,
const ip_addr_t *addr, u16_t port) {
(void)pcb;
const char *via = (const char *)arg;
if (!p) {
return;
}
if (p->tot_len < 12) {
pbuf_free(p);
return;
}
uint8_t hdr[18];
size_t want = p->tot_len < sizeof(hdr) ? p->tot_len : sizeof(hdr);
if (pbuf_copy_partial(p, hdr, want, 0) != want) {
pbuf_free(p);
return;
}
if (memcmp(hdr, "Art-Net", 8) != 0) {
pbuf_free(p);
return;
}
uint16_t opcode = rd_le16(&hdr[8]);
if (opcode == ARTNET_OPCODE_POLL) {
g_poll_count++;
if ((g_poll_count <= 8) || ((g_poll_count & 0x1f) == 0)) {
printf("[artnet] ArtPoll #%lu via=%s from=%s:%u\n",
(unsigned long)g_poll_count, via ? via : "?",
addr ? ipaddr_ntoa(addr) : "?", port);
}
/* One reply per poll burst — dual PCBs used to create a ghost second node. */
if (addr && (is_nil_time(g_last_poll_reply) ||
absolute_time_diff_us(g_last_poll_reply, get_absolute_time()) > 200000)) {
g_last_poll_reply = get_absolute_time();
send_poll_reply(addr, port ? port : ARTNET_PORT);
}
pbuf_free(p);
return;
}
if (opcode != ARTNET_OPCODE_DMX) {
pbuf_free(p);
return;
}
if (p->tot_len < 18) {
pbuf_free(p);
return;
}
/* ArtDmx: sequence(12), physical(13), universe LE(14-15), length BE(16-17), data... */
uint16_t universe = rd_le16(&hdr[14]);
uint16_t length = ((uint16_t)hdr[16] << 8) | hdr[17];
if (length > BOARD_DMX_CHANNELS) {
length = BOARD_DMX_CHANNELS;
}
const config_t *cfg = config_get();
int port_idx = -1;
if (universe == cfg->universe_a) {
port_idx = 0;
} else if (universe == cfg->universe_b) {
port_idx = 1;
}
g_rx_count++;
if ((g_rx_count <= 8) || ((g_rx_count & 0x3f) == 0)) {
printf("[artnet] #%lu via=%s from=%s uni=%u len=%u -> %s\n",
(unsigned long)g_rx_count,
via ? via : "?",
addr ? ipaddr_ntoa(addr) : "?",
universe, length,
port_idx == 0 ? "DMX A" : port_idx == 1 ? "DMX B" : "ignored");
}
if (port_idx >= 0 && length > 0) {
uint8_t data[BOARD_DMX_CHANNELS];
memset(data, 0, sizeof(data));
uint16_t copied = pbuf_copy_partial(p, data, length, 18);
if (copied > 0) {
channels_set_range((unsigned)port_idx, data, copied);
channels_note_artnet((unsigned)port_idx);
}
}
pbuf_free(p);
}
static void remove_pcb(void) {
if (g_pcb) {
udp_remove(g_pcb);
g_pcb = NULL;
}
}
static struct udp_pcb *bind_listener(void) {
struct udp_pcb *pcb = udp_new_ip_type(IPADDR_TYPE_V4);
if (!pcb) {
printf("[artnet] udp_new failed\n");
return NULL;
}
ip_set_option(pcb, SOF_BROADCAST);
err_t err = udp_bind(pcb, IP4_ADDR_ANY, ARTNET_PORT);
if (err != ERR_OK) {
printf("[artnet] bind :%u failed (%d)\n", ARTNET_PORT, (int)err);
udp_remove(pcb);
return NULL;
}
udp_recv(pcb, artnet_recv, (void *)"UDP");
printf("[artnet] listening on 0.0.0.0:%u\n", ARTNET_PORT);
return pcb;
}
void artnet_refresh(void) {
cyw43_arch_lwip_begin();
remove_pcb();
g_pcb = bind_listener();
if (wifi_mgr_sta_up()) {
netif_set_default(&cyw43_state.netif[CYW43_ITF_STA]);
}
cyw43_arch_lwip_end();
if (wifi_mgr_sta_up() || wifi_mgr_ap_up()) {
artnet_announce();
/* Short burst only — long announce spam confused QLC+ discovery. */
g_announce_until = make_timeout_time_ms(8000);
g_next_announce = make_timeout_time_ms(2000);
}
}
bool artnet_start(void) {
printf("[artnet] start (port %u)\n", ARTNET_PORT);
artnet_refresh();
return g_pcb != NULL;
}
+78
View File
@@ -0,0 +1,78 @@
#include "channels.h"
#include <string.h>
#include "pico/stdlib.h"
#include "pico/time.h"
static uint8_t g_channels[BOARD_NUM_UNIVERSES][BOARD_DMX_CHANNELS];
static absolute_time_t g_last_artnet[BOARD_NUM_UNIVERSES];
static volatile uint32_t g_artnet_packets;
void channels_init(void) {
memset(g_channels, 0, sizeof(g_channels));
for (unsigned u = 0; u < BOARD_NUM_UNIVERSES; u++) {
g_last_artnet[u] = nil_time;
}
g_artnet_packets = 0;
}
uint8_t *channels_get(unsigned universe) {
if (universe >= BOARD_NUM_UNIVERSES) {
return NULL;
}
return g_channels[universe];
}
void channels_build_frame(unsigned universe, uint8_t out[BOARD_DMX_FRAME_LEN]) {
if (!out || universe >= BOARD_NUM_UNIVERSES) {
return;
}
out[0] = 0; /* start code */
memcpy(&out[1], g_channels[universe], BOARD_DMX_CHANNELS);
}
void channels_set(unsigned universe, unsigned channel, uint8_t value) {
if (universe >= BOARD_NUM_UNIVERSES || channel >= BOARD_DMX_CHANNELS) {
return;
}
g_channels[universe][channel] = value;
}
void channels_set_range(unsigned universe, const uint8_t *values, size_t len) {
if (universe >= BOARD_NUM_UNIVERSES || !values) {
return;
}
if (len > BOARD_DMX_CHANNELS) {
len = BOARD_DMX_CHANNELS;
}
memcpy(g_channels[universe], values, len);
}
void channels_clear(unsigned universe) {
if (universe >= BOARD_NUM_UNIVERSES) {
return;
}
memset(g_channels[universe], 0, BOARD_DMX_CHANNELS);
}
void channels_note_artnet(unsigned universe) {
if (universe >= BOARD_NUM_UNIVERSES) {
return;
}
g_last_artnet[universe] = get_absolute_time();
g_artnet_packets++;
}
uint32_t channels_artnet_packets(void) {
return g_artnet_packets;
}
uint32_t channels_ms_since_artnet(unsigned universe) {
if (universe >= BOARD_NUM_UNIVERSES) {
return UINT32_MAX;
}
if (is_nil_time(g_last_artnet[universe])) {
return UINT32_MAX;
}
return (uint32_t)absolute_time_diff_us(g_last_artnet[universe], get_absolute_time()) / 1000u;
}
+151
View File
@@ -0,0 +1,151 @@
#include "config.h"
#include <ctype.h>
#include <stddef.h>
#include <stdio.h>
#include <string.h>
#include "hardware/flash.h"
#include "hardware/sync.h"
#include "pico/stdlib.h"
/* Store config in the last flash sector. */
#define CONFIG_FLASH_OFFSET (PICO_FLASH_SIZE_BYTES - FLASH_SECTOR_SIZE)
/* Layout before mdns_host was added (v1/v2). */
#define CONFIG_SIZE_V2 offsetof(config_t, mdns_host)
static config_t g_config;
static uint32_t crc32_calc(const uint8_t *data, size_t len) {
uint32_t crc = 0xFFFFFFFFu;
for (size_t i = 0; i < len; i++) {
crc ^= data[i];
for (int b = 0; b < 8; b++) {
uint32_t mask = -(crc & 1u);
crc = (crc >> 1) ^ (0xEDB88320u & mask);
}
}
return ~crc;
}
static uint32_t config_payload_crc(const config_t *cfg) {
config_t tmp = *cfg;
tmp.crc = 0;
return crc32_calc((const uint8_t *)&tmp, sizeof(tmp));
}
bool config_sanitize_hostname(const char *in, char *out, size_t out_sz) {
if (!out || out_sz < 2) {
return false;
}
size_t j = 0;
for (size_t i = 0; in && in[i] && j + 1 < out_sz && j < 63; i++) {
unsigned char c = (unsigned char)in[i];
if (c == '.') {
continue; /* strip .local if pasted */
}
if (isalnum(c)) {
out[j++] = (char)tolower(c);
} else if (c == '-' && j > 0) {
out[j++] = '-';
}
}
while (j > 0 && out[j - 1] == '-') {
j--;
}
out[j] = 0;
if (j == 0) {
strncpy(out, BOARD_HOSTNAME, out_sz - 1);
out[out_sz - 1] = 0;
return false;
}
return true;
}
void config_set_defaults(config_t *cfg) {
memset(cfg, 0, sizeof(*cfg));
cfg->magic = CONFIG_MAGIC;
cfg->version = CONFIG_VERSION;
cfg->wifi_mode = WIFI_MODE_AP;
strncpy(cfg->ap_ssid, BOARD_AP_SSID_DEFAULT, CONFIG_SSID_MAX - 1);
strncpy(cfg->ap_pass, BOARD_AP_PASS_DEFAULT, CONFIG_PASS_MAX - 1);
cfg->universe_a = BOARD_DEFAULT_UNIVERSE_A;
cfg->universe_b = BOARD_DEFAULT_UNIVERSE_B;
strncpy(cfg->mdns_host, BOARD_HOSTNAME, CONFIG_HOSTNAME_MAX - 1);
cfg->crc = config_payload_crc(cfg);
}
bool config_load(config_t *cfg) {
const config_t *flash_cfg = (const config_t *)(XIP_BASE + CONFIG_FLASH_OFFSET);
if (flash_cfg->magic != CONFIG_MAGIC) {
return false;
}
uint32_t ver = flash_cfg->version;
if (ver < 1 || ver > CONFIG_VERSION) {
return false;
}
config_t tmp;
memset(&tmp, 0, sizeof(tmp));
size_t copy_n = (ver < 3) ? CONFIG_SIZE_V2 : sizeof(config_t);
memcpy(&tmp, flash_cfg, copy_n);
uint32_t expect = tmp.crc;
tmp.crc = 0;
size_t crc_len = (ver < 3) ? CONFIG_SIZE_V2 : sizeof(config_t);
if (crc32_calc((const uint8_t *)&tmp, crc_len) != expect) {
return false;
}
*cfg = tmp;
cfg->version = CONFIG_VERSION;
/* AP+STA is gone — legacy value 2 becomes STA (AP fallback on failure). */
if (cfg->wifi_mode == WIFI_MODE_AP_STA || cfg->wifi_mode > WIFI_MODE_STA) {
cfg->wifi_mode = (cfg->wifi_mode == WIFI_MODE_AP_STA) ? WIFI_MODE_STA : WIFI_MODE_AP;
}
if (!cfg->mdns_host[0]) {
strncpy(cfg->mdns_host, BOARD_HOSTNAME, CONFIG_HOSTNAME_MAX - 1);
} else {
char cleaned[CONFIG_HOSTNAME_MAX];
config_sanitize_hostname(cfg->mdns_host, cleaned, sizeof(cleaned));
strncpy(cfg->mdns_host, cleaned, CONFIG_HOSTNAME_MAX - 1);
}
return true;
}
bool config_save(const config_t *cfg) {
config_t to_write = *cfg;
to_write.magic = CONFIG_MAGIC;
to_write.version = CONFIG_VERSION;
char cleaned[CONFIG_HOSTNAME_MAX];
config_sanitize_hostname(to_write.mdns_host, cleaned, sizeof(cleaned));
strncpy(to_write.mdns_host, cleaned, CONFIG_HOSTNAME_MAX - 1);
to_write.mdns_host[CONFIG_HOSTNAME_MAX - 1] = 0;
to_write.crc = config_payload_crc(&to_write);
uint8_t sector[FLASH_SECTOR_SIZE];
memset(sector, 0xFF, sizeof(sector));
memcpy(sector, &to_write, sizeof(to_write));
uint32_t ints = save_and_disable_interrupts();
flash_range_erase(CONFIG_FLASH_OFFSET, FLASH_SECTOR_SIZE);
flash_range_program(CONFIG_FLASH_OFFSET, sector, FLASH_SECTOR_SIZE);
restore_interrupts(ints);
g_config = to_write;
return true;
}
const config_t *config_get(void) {
return &g_config;
}
void config_init(void) {
if (!config_load(&g_config)) {
printf("[config] no valid flash config — using defaults (AP)\n");
config_set_defaults(&g_config);
} else {
printf("[config] loaded from flash: mode=%d sta='%s' mdns='%s'\n",
(int)g_config.wifi_mode, g_config.sta_ssid, g_config.mdns_host);
}
}
+84
View File
@@ -0,0 +1,84 @@
#include "dmx_out.h"
#include "hardware/clocks.h"
#include "hardware/dma.h"
#include "hardware/irq.h"
#include "dmx_out.pio.h"
#define DMX_SM_FREQ 1000000u
bool dmx_out_init(dmx_out_t *out, uint pin, PIO pio) {
if (!out) {
return false;
}
if (!pio_can_add_program(pio, &dmx_out_program)) {
return false;
}
uint offset = pio_add_program(pio, &dmx_out_program);
int sm = pio_claim_unused_sm(pio, false);
if (sm < 0) {
return false;
}
pio_sm_set_pins_with_mask(pio, (uint)sm, 1u << pin, 1u << pin);
pio_sm_set_pindirs_with_mask(pio, (uint)sm, 1u << pin, 1u << pin);
pio_gpio_init(pio, pin);
pio_sm_config conf = dmx_out_program_get_default_config(offset);
sm_config_set_out_pins(&conf, pin, 1);
sm_config_set_sideset_pins(&conf, pin);
sm_config_set_clkdiv(&conf, (float)clock_get_hz(clk_sys) / (float)DMX_SM_FREQ);
sm_config_set_out_shift(&conf, true, false, 8);
pio_sm_init(pio, (uint)sm, offset, &conf);
pio_sm_set_enabled(pio, (uint)sm, true);
int dma = dma_claim_unused_channel(false);
if (dma < 0) {
pio_sm_set_enabled(pio, (uint)sm, false);
pio_sm_unclaim(pio, (uint)sm);
return false;
}
dma_channel_config dma_conf = dma_channel_get_default_config((uint)dma);
channel_config_set_transfer_data_size(&dma_conf, DMA_SIZE_8);
channel_config_set_read_increment(&dma_conf, true);
channel_config_set_write_increment(&dma_conf, false);
channel_config_set_dreq(&dma_conf, pio_get_dreq(pio, (uint)sm, true));
dma_channel_configure((uint)dma, &dma_conf, &pio->txf[sm], NULL, 0, false);
out->pio = pio;
out->sm = (uint)sm;
out->dma = (uint)dma;
out->pin = pin;
out->prgm_offset = offset;
out->inited = true;
return true;
}
void dmx_out_write(dmx_out_t *out, const uint8_t *frame, uint length) {
if (!out || !out->inited || !frame || length == 0) {
return;
}
pio_sm_set_enabled(out->pio, out->sm, false);
pio_sm_clear_fifos(out->pio, out->sm);
pio_sm_restart(out->pio, out->sm);
pio_sm_exec(out->pio, out->sm, pio_encode_jmp(out->prgm_offset));
pio_sm_set_enabled(out->pio, out->sm, true);
dma_channel_transfer_from_buffer_now(out->dma, frame, length);
}
bool dmx_out_busy(const dmx_out_t *out) {
if (!out || !out->inited) {
return false;
}
if (dma_channel_is_busy(out->dma)) {
return true;
}
return !pio_sm_is_tx_fifo_empty(out->pio, out->sm);
}
+21
View File
@@ -0,0 +1,21 @@
;
; DMX512 TX via PIO at 1 MHz (adapted from jostlowe/Pico-DMX, BSD-3-Clause)
;
; BREAK ~176 us, MAB ~8 us, then 8 data bits with start/stop timing via side-set.
;
.program dmx_out
.side_set 1 opt
set x, 21 side 0 ; break (line low)
break_loop:
jmp x-- break_loop [7]
nop side 1 [7] ; mark-after-break
.wrap_target
pull block side 1 [7] ; stop / idle high between bytes
set x, 7 side 0 [3] ; start bit
bitloop:
out pins, 1
jmp x-- bitloop [2]
.wrap
+645
View File
@@ -0,0 +1,645 @@
#include "http_server.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
#include "pico/cyw43_arch.h"
#include "pico/stdlib.h"
#include "hardware/watchdog.h"
#include "lwip/pbuf.h"
#include "lwip/tcp.h"
#include "board.h"
#include "channels.h"
#include "config.h"
#include "wifi_mgr.h"
#define HTTP_PORT 80
#define REQ_BUF 2048
#define RESP_BUF 4096
typedef struct {
struct tcp_pcb *pcb;
char req[REQ_BUF];
int req_len;
char *resp;
int resp_len;
int sent;
bool close_after;
} http_conn_t;
static struct tcp_pcb *g_server;
static const char PAGE_INDEX[] =
"<!DOCTYPE html><html><head><meta charset=utf-8><meta name=viewport content=\"width=device-width,initial-scale=1\">"
"<title>ArtNetDMX</title><link rel=stylesheet href=/style.css></head><body>"
"<header><h1>ArtNetDMX</h1><nav><a href=/ class=active>Channels</a><a href=/wifi>WiFi</a></nav>"
"<div id=status class=status>…</div></header>"
"<main><div class=tabs>"
"<button type=button id=tab0 class=active data-u=0>Universe A</button>"
"<button type=button id=tab1 data-u=1>Universe B</button>"
"<button type=button id=clear>Clear</button>"
"<button type=button id=full>All 255</button>"
"</div><div id=grid class=grid></div></main>"
"<script src=/app.js></script></body></html>";
static const char PAGE_WIFI[] =
"<!DOCTYPE html><html><head><meta charset=utf-8><meta name=viewport content=\"width=device-width,initial-scale=1\">"
"<title>WiFi — ArtNetDMX</title><link rel=stylesheet href=/style.css></head><body>"
"<header><h1>ArtNetDMX</h1><nav><a href=/>Channels</a><a href=/wifi class=active>WiFi</a></nav>"
"<div id=status class=status>…</div></header>"
"<main class=form-wrap>"
"<section class=ip-box><div class=ip-label>Current IP</div><div id=currentIp class=ip-value>—</div>"
"<div id=currentMode class=ip-meta>—</div>"
"<div id=addrDetail class=ip-meta></div>"
"<div id=mdnsName class=ip-meta></div>"
"<div id=wifiDebug class=ip-debug>—</div>"
"<p class=hint id=artnetHint></p></section>"
"<form id=wifiForm>"
"<label>Mode<select name=mode id=mode>"
"<option value=0>Access Point</option>"
"<option value=1>Station (AP fallback if join fails)</option>"
"</select></label>"
"<p class=hint>Station joins your LAN only. If join fails, SoftAP starts for recovery. mDNS name is set below.</p>"
"<fieldset><legend>Station</legend>"
"<label>SSID<input name=sta_ssid id=sta_ssid maxlength=31></label>"
"<label>Password<input name=sta_pass id=sta_pass type=password maxlength=63></label>"
"</fieldset>"
"<fieldset><legend>Access Point</legend>"
"<label>SSID<input name=ap_ssid id=ap_ssid maxlength=31></label>"
"<label>Password<input name=ap_pass id=ap_pass type=password maxlength=63></label>"
"</fieldset>"
"<fieldset><legend>Art-Net universes</legend>"
"<label>Port A<input name=universe_a id=universe_a type=number min=0 max=32767></label>"
"<label>Port B<input name=universe_b id=universe_b type=number min=0 max=32767></label>"
"</fieldset>"
"<fieldset><legend>mDNS</legend>"
"<label>Hostname<input name=mdns_host id=mdns_host maxlength=31 pattern=\"[A-Za-z0-9-]+\" placeholder=artnetdmx></label>"
"<p class=hint>Resolves as <code>http://&lt;hostname&gt;.local/</code> on the LAN (and SoftAP). Letters, digits, hyphen. Reboot required.</p>"
"</fieldset>"
"<button type=submit>Save &amp; reboot</button>"
"<p class=hint>After save the device reboots into the selected mode. AP default: ArtNetDMX / artnetdmx @ 192.168.4.1</p>"
"</form></main>"
"<script src=/wifi.js></script></body></html>";
static const char PAGE_CSS[] =
":root{--bg:#0e1116;--panel:#171b22;--text:#e8ecf1;--muted:#8b95a5;--accent:#f0a202;--line:#2a3140}"
"*{box-sizing:border-box}body{margin:0;font-family:\"IBM Plex Sans\",Segoe UI,sans-serif;background:radial-gradient(1200px 600px at 10% -10%,#1a2230,var(--bg));color:var(--text);min-height:100vh}"
"header{padding:1rem 1.25rem;border-bottom:1px solid var(--line);display:flex;flex-wrap:wrap;gap:.75rem 1.5rem;align-items:center}"
"h1{margin:0;font-family:\"IBM Plex Mono\",ui-monospace,monospace;font-size:1.25rem;letter-spacing:.04em;color:var(--accent)}"
"nav{display:flex;gap:.75rem}nav a{color:var(--muted);text-decoration:none;padding:.35rem .6rem;border-radius:4px}nav a.active,nav a:hover{color:var(--text);background:var(--panel)}"
".status{margin-left:auto;font-size:.85rem;color:var(--muted);font-family:\"IBM Plex Mono\",monospace}"
"main{padding:1rem}"
".tabs{display:flex;flex-wrap:wrap;gap:.5rem;margin-bottom:1rem}"
"button,.tabs button{background:var(--panel);color:var(--text);border:1px solid var(--line);padding:.45rem .8rem;border-radius:4px;cursor:pointer}"
"button:hover,.tabs button.active{border-color:var(--accent);color:var(--accent)}"
".grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(3.4rem,1fr));gap:4px}"
".cell{background:var(--panel);border:1px solid var(--line);border-radius:3px;padding:2px;text-align:center}"
".cell label{display:block;font-size:.65rem;color:var(--muted);font-family:monospace}"
".cell input{width:100%;background:#0a0d12;border:none;color:var(--text);text-align:center;font-family:monospace;font-size:.85rem;padding:.2rem 0}"
".form-wrap{max-width:32rem}form{display:flex;flex-direction:column;gap:1rem}"
"label{display:flex;flex-direction:column;gap:.35rem;font-size:.9rem;color:var(--muted)}"
"input,select{background:var(--panel);border:1px solid var(--line);color:var(--text);padding:.55rem .65rem;border-radius:4px;font:inherit}"
"fieldset{border:1px solid var(--line);border-radius:6px;padding:1rem;display:flex;flex-direction:column;gap:.75rem}"
"legend{padding:0 .4rem;color:var(--accent)}"
".hint{color:var(--muted);font-size:.85rem;line-height:1.4}"
"button[type=submit]{background:var(--accent);color:#111;border:none;font-weight:600;padding:.7rem 1rem}"
".ip-box{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:1rem 1.1rem;margin-bottom:1.25rem}"
".ip-label{font-size:.75rem;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:.35rem}"
".ip-value{font-family:\"IBM Plex Mono\",ui-monospace,monospace;font-size:1.6rem;color:var(--accent);letter-spacing:.02em}"
".ip-meta{margin-top:.35rem;font-size:.85rem;color:var(--muted);font-family:\"IBM Plex Mono\",monospace}"
".ip-debug{margin-top:.55rem;font-size:.8rem;color:#c9d1d9;font-family:\"IBM Plex Mono\",monospace;line-height:1.35;word-break:break-word}";
static const char PAGE_APP_JS[] =
"let uni=0;const grid=document.getElementById('grid');"
"function mkGrid(){grid.innerHTML='';for(let i=0;i<512;i++){const d=document.createElement('div');d.className='cell';"
"d.innerHTML=`<label>${i+1}</label><input data-ch=${i} type=number min=0 max=255 value=0>`;"
"d.querySelector('input').addEventListener('change',e=>{const ch=+e.target.dataset.ch;const val=Math.max(0,Math.min(255,+e.target.value||0));"
"e.target.value=val;fetch('/api/channels',{method:'POST',headers:{'Content-Type':'application/json'},"
"body:JSON.stringify({u:uni,ch,val})});});grid.appendChild(d);}}"
"async function refresh(){try{const r=await fetch('/api/channels?u='+uni);const j=await r.json();"
"const inputs=grid.querySelectorAll('input');j.values.forEach((v,i)=>{if(document.activeElement!==inputs[i])inputs[i].value=v;});"
"const s=await fetch('/api/status');const st=await s.json();"
"document.getElementById('status').textContent=`${st.mode} ${st.ip}${st.mdns?' · '+st.mdns:''} | ArtNet ${st.artnet_packets} pkts | A ${st.age_a}ms B ${st.age_b}ms`;"
"}catch(e){}}"
"document.querySelectorAll('[data-u]').forEach(b=>b.addEventListener('click',()=>{"
"document.querySelectorAll('[data-u]').forEach(x=>x.classList.remove('active'));b.classList.add('active');uni=+b.dataset.u;refresh();}));"
"document.getElementById('clear').onclick=()=>fetch('/api/channels',{method:'POST',headers:{'Content-Type':'application/json'},"
"body:JSON.stringify({u:uni,clear:true})}).then(refresh);"
"document.getElementById('full').onclick=()=>{const values=Array(512).fill(255);"
"fetch('/api/channels',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({u:uni,values})}).then(refresh);};"
"mkGrid();refresh();setInterval(refresh,2000);";
static const char PAGE_WIFI_JS[] =
"function showSt(st){document.getElementById('status').textContent=st.mode+' '+st.ip;"
"document.getElementById('currentIp').textContent=st.ip;"
"document.getElementById('currentMode').textContent='Mode: '+st.mode;"
"let d=[];if(st.sta_up)d.push('STA '+st.sta_ip);if(st.ap_up)d.push('AP '+st.ap_ip);"
"document.getElementById('addrDetail').textContent=d.join(' · ');"
"document.getElementById('mdnsName').textContent=st.mdns?'mDNS: http://'+st.mdns+'/':'';"
"document.getElementById('wifiDebug').textContent=st.debug||'';"
"const h=document.getElementById('artnetHint');"
"if(h){if(st.sta_up&&st.sta_ip&&st.sta_ip!=='0.0.0.0'){"
"h.textContent='QLC+/Art-Net: unicast to '+st.sta_ip+' (home Wi‑Fi drops Art-Net broadcast; SoftAP did not).';"
"}else if(st.ap_up){h.textContent='QLC+/Art-Net: join SoftAP and send to '+st.ap_ip+' (broadcast OK on SoftAP).';"
"}else{h.textContent='';}}"
"}"
"async function load(){const s=await fetch('/api/status');const st=await s.json();showSt(st);"
"const c=await fetch('/api/config');const cfg=await c.json();"
"mode.value=cfg.wifi_mode;sta_ssid.value=cfg.sta_ssid;ap_ssid.value=cfg.ap_ssid;"
"sta_pass.placeholder='(unchanged if left blank)';ap_pass.placeholder='(unchanged if left blank)';"
"universe_a.value=cfg.universe_a;universe_b.value=cfg.universe_b;"
"mdns_host.value=cfg.mdns_host||'artnetdmx';}"
"wifiForm.onsubmit=async e=>{e.preventDefault();"
"const body={wifi_mode:+mode.value,sta_ssid:sta_ssid.value,"
"ap_ssid:ap_ssid.value,universe_a:+universe_a.value,universe_b:+universe_b.value,"
"mdns_host:mdns_host.value.trim()};"
"if(sta_pass.value)body.sta_pass=sta_pass.value;if(ap_pass.value)body.ap_pass=ap_pass.value;"
"const r=await fetch('/api/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});"
"document.getElementById('status').textContent=r.ok?'Saved — rebooting…':'Save failed';};"
"load();setInterval(async()=>{try{const s=await fetch('/api/status');showSt(await s.json());}catch(e){}},2000);";
static void http_conn_free(http_conn_t *c) {
if (!c) {
return;
}
free(c->resp);
free(c);
}
static err_t http_close(http_conn_t *c, struct tcp_pcb *tpcb, err_t err) {
if (tpcb) {
tcp_arg(tpcb, NULL);
tcp_recv(tpcb, NULL);
tcp_sent(tpcb, NULL);
tcp_err(tpcb, NULL);
tcp_poll(tpcb, NULL, 0);
tcp_close(tpcb);
}
http_conn_free(c);
return err;
}
static err_t http_send_more(http_conn_t *c, struct tcp_pcb *tpcb) {
while (c->sent < c->resp_len) {
u16_t space = tcp_sndbuf(tpcb);
if (space == 0) {
break;
}
u16_t chunk = (u16_t)(c->resp_len - c->sent);
if (chunk > space) {
chunk = space;
}
if (chunk > 1024) {
chunk = 1024;
}
err_t e = tcp_write(tpcb, c->resp + c->sent, chunk, TCP_WRITE_FLAG_COPY);
if (e == ERR_MEM) {
break;
}
if (e != ERR_OK) {
printf("[http] tcp_write err %d\n", (int)e);
return http_close(c, tpcb, e);
}
c->sent += chunk;
}
tcp_output(tpcb);
if (c->sent >= c->resp_len && c->close_after) {
return http_close(c, tpcb, ERR_OK);
}
return ERR_OK;
}
static err_t http_poll(void *arg, struct tcp_pcb *tpcb) {
http_conn_t *c = (http_conn_t *)arg;
if (!c) {
return ERR_OK;
}
if (c->resp && c->sent < c->resp_len) {
return http_send_more(c, tpcb);
}
return ERR_OK;
}
static err_t http_sent(void *arg, struct tcp_pcb *tpcb, u16_t len) {
(void)len;
return http_send_more((http_conn_t *)arg, tpcb);
}
static char *build_response(const char *status, const char *ctype, const char *body, int body_len, int *out_len) {
int hdr_max = 160;
char *buf = malloc((size_t)hdr_max + (size_t)body_len + 1);
if (!buf) {
return NULL;
}
int n = snprintf(buf, (size_t)hdr_max,
"HTTP/1.1 %s\r\nContent-Type: %s\r\nContent-Length: %d\r\nConnection: close\r\nCache-Control: no-store\r\n\r\n",
status, ctype, body_len);
if (n < 0 || n >= hdr_max) {
free(buf);
return NULL;
}
memcpy(buf + n, body, (size_t)body_len);
*out_len = n + body_len;
return buf;
}
static char *json_escape(const char *s, char *out, size_t out_sz) {
size_t j = 0;
out[j++] = '"';
for (size_t i = 0; s[i] && j + 2 < out_sz; i++) {
char c = s[i];
if (c == '"' || c == '\\') {
if (j + 3 >= out_sz) {
break;
}
out[j++] = '\\';
out[j++] = c;
} else if ((unsigned char)c < 0x20) {
continue;
} else {
out[j++] = c;
}
}
if (j + 1 < out_sz) {
out[j++] = '"';
}
out[j] = 0;
return out;
}
static const char *find_body(const char *req) {
const char *p = strstr(req, "\r\n\r\n");
return p ? p + 4 : NULL;
}
static int query_int(const char *req, const char *key, int def) {
char pat[32];
snprintf(pat, sizeof(pat), "%s=", key);
const char *q = strchr(req, '?');
if (!q) {
return def;
}
const char *p = strstr(q, pat);
if (!p) {
return def;
}
return atoi(p + strlen(pat));
}
static bool json_get_str(const char *json, const char *key, char *out, size_t out_sz) {
char pat[48];
snprintf(pat, sizeof(pat), "\"%s\"", key);
const char *p = strstr(json, pat);
if (!p) {
return false;
}
p = strchr(p + strlen(pat), ':');
if (!p) {
return false;
}
p++;
while (*p && isspace((unsigned char)*p)) {
p++;
}
if (*p != '"') {
return false;
}
p++;
size_t i = 0;
while (*p && *p != '"' && i + 1 < out_sz) {
if (*p == '\\' && p[1]) {
p++;
}
out[i++] = *p++;
}
out[i] = 0;
return true;
}
static bool json_get_int(const char *json, const char *key, int *out) {
char pat[48];
snprintf(pat, sizeof(pat), "\"%s\"", key);
const char *p = strstr(json, pat);
if (!p) {
return false;
}
p = strchr(p + strlen(pat), ':');
if (!p) {
return false;
}
*out = atoi(p + 1);
return true;
}
static char *handle_api_channels_get(const char *req, int *resp_len) {
int u = query_int(req, "u", 0);
if (u < 0 || u >= BOARD_NUM_UNIVERSES) {
u = 0;
}
uint8_t *ch = channels_get((unsigned)u);
char *body = malloc(4096);
if (!body || !ch) {
free(body);
return build_response("500 Internal Server Error", "text/plain", "err", 3, resp_len);
}
int n = snprintf(body, 4096, "{\"u\":%d,\"values\":[", u);
for (int i = 0; i < BOARD_DMX_CHANNELS; i++) {
n += snprintf(body + n, (size_t)(4096 - n), "%s%u", i ? "," : "", ch[i]);
if (n >= 4000) {
break;
}
}
n += snprintf(body + n, (size_t)(4096 - n), "]}");
char *resp = build_response("200 OK", "application/json", body, n, resp_len);
free(body);
return resp;
}
static char *handle_api_status(int *resp_len) {
char body[640];
char dbg_esc[220];
const char *mode = wifi_mode_name(wifi_mgr_active_mode());
uint32_t age_a = channels_ms_since_artnet(0);
uint32_t age_b = channels_ms_since_artnet(1);
json_escape(wifi_mgr_debug(), dbg_esc, sizeof(dbg_esc));
int n = snprintf(body, sizeof(body),
"{\"mode\":\"%s\",\"ip\":\"%s\",\"sta_ip\":\"%s\",\"ap_ip\":\"%s\","
"\"sta_up\":%s,\"ap_up\":%s,\"mdns\":\"%s\","
"\"artnet_packets\":%lu,\"age_a\":%lu,\"age_b\":%lu,\"uni_a\":%u,\"uni_b\":%u,\"debug\":%s}",
mode, wifi_mgr_ip_str(),
wifi_mgr_sta_ip_str(), wifi_mgr_ap_ip_str(),
wifi_mgr_sta_up() ? "true" : "false",
wifi_mgr_ap_up() ? "true" : "false",
wifi_mgr_mdns_name(),
(unsigned long)channels_artnet_packets(),
(unsigned long)age_a, (unsigned long)age_b,
config_get()->universe_a, config_get()->universe_b,
dbg_esc);
return build_response("200 OK", "application/json", body, n, resp_len);
}
static char *handle_api_config_get(int *resp_len) {
const config_t *c = config_get();
char ss[80], ap[80], md[80];
char body[640];
int n = snprintf(body, sizeof(body),
"{\"wifi_mode\":%d,\"sta_ssid\":%s,\"ap_ssid\":%s,\"universe_a\":%u,\"universe_b\":%u,\"mdns_host\":%s}",
(int)c->wifi_mode,
json_escape(c->sta_ssid, ss, sizeof(ss)),
json_escape(c->ap_ssid, ap, sizeof(ap)),
c->universe_a, c->universe_b,
json_escape(c->mdns_host[0] ? c->mdns_host : BOARD_HOSTNAME, md, sizeof(md)));
return build_response("200 OK", "application/json", body, n, resp_len);
}
static void parse_channel_values(const char *json, unsigned u) {
const char *p = strstr(json, "\"values\"");
if (!p) {
return;
}
p = strchr(p, '[');
if (!p) {
return;
}
p++;
uint8_t vals[BOARD_DMX_CHANNELS];
memset(vals, 0, sizeof(vals));
int idx = 0;
while (*p && *p != ']' && idx < BOARD_DMX_CHANNELS) {
while (*p && (isspace((unsigned char)*p) || *p == ',')) {
p++;
}
if (*p == ']' || !*p) {
break;
}
vals[idx++] = (uint8_t)atoi(p);
while (*p && *p != ',' && *p != ']') {
p++;
}
}
channels_set_range(u, vals, (size_t)idx);
}
static char *handle_api_channels_post(const char *req, int *resp_len) {
const char *body = find_body(req);
if (!body) {
return build_response("400 Bad Request", "text/plain", "no body", 7, resp_len);
}
int u = 0;
json_get_int(body, "u", &u);
if (u < 0 || u >= BOARD_NUM_UNIVERSES) {
u = 0;
}
if (strstr(body, "\"clear\":true") || strstr(body, "\"clear\": true")) {
channels_clear((unsigned)u);
} else if (strstr(body, "\"values\"")) {
parse_channel_values(body, (unsigned)u);
} else {
int ch = 0, val = 0;
json_get_int(body, "ch", &ch);
json_get_int(body, "val", &val);
if (ch >= 0 && ch < BOARD_DMX_CHANNELS) {
channels_set((unsigned)u, (unsigned)ch, (uint8_t)val);
}
}
return build_response("200 OK", "application/json", "{\"ok\":true}", 11, resp_len);
}
static char *handle_api_config_post(const char *req, int *resp_len) {
const char *body = find_body(req);
if (!body) {
return build_response("400 Bad Request", "text/plain", "no body", 7, resp_len);
}
config_t cfg = *config_get();
int mode = (int)cfg.wifi_mode;
int ua = cfg.universe_a, ub = cfg.universe_b;
json_get_int(body, "wifi_mode", &mode);
json_get_int(body, "universe_a", &ua);
json_get_int(body, "universe_b", &ub);
if (mode != (int)WIFI_MODE_STA) {
mode = (int)WIFI_MODE_AP;
}
cfg.wifi_mode = (wifi_mode_t)mode;
cfg.universe_a = (uint16_t)ua;
cfg.universe_b = (uint16_t)ub;
json_get_str(body, "sta_ssid", cfg.sta_ssid, sizeof(cfg.sta_ssid));
json_get_str(body, "ap_ssid", cfg.ap_ssid, sizeof(cfg.ap_ssid));
{
char host[CONFIG_HOSTNAME_MAX];
if (json_get_str(body, "mdns_host", host, sizeof(host))) {
config_sanitize_hostname(host, cfg.mdns_host, sizeof(cfg.mdns_host));
}
}
/* Password fields are blank in the UI on load — only overwrite if a new value was sent. */
{
char pass[CONFIG_PASS_MAX];
if (json_get_str(body, "sta_pass", pass, sizeof(pass)) && pass[0]) {
strncpy(cfg.sta_pass, pass, sizeof(cfg.sta_pass) - 1);
cfg.sta_pass[sizeof(cfg.sta_pass) - 1] = 0;
}
if (json_get_str(body, "ap_pass", pass, sizeof(pass)) && pass[0]) {
strncpy(cfg.ap_pass, pass, sizeof(cfg.ap_pass) - 1);
cfg.ap_pass[sizeof(cfg.ap_pass) - 1] = 0;
}
}
printf("Config save: mode=%s sta_ssid='%s' sta_pass_len=%u ap_ssid='%s' mdns='%s'\n",
wifi_mode_name(cfg.wifi_mode),
cfg.sta_ssid, (unsigned)strlen(cfg.sta_pass), cfg.ap_ssid, cfg.mdns_host);
config_save(&cfg);
char *resp = build_response("200 OK", "application/json", "{\"ok\":true,\"reboot\":true}", 25, resp_len);
/* Reboot shortly after response is queued — caller sets flag via returning special; do it after delay in main isn't available.
Schedule reboot from here with a short sleep in a deferred way: use alarm. */
return resp;
}
static int64_t reboot_cb(alarm_id_t id, void *user) {
(void)id;
(void)user;
watchdog_reboot(0, 0, 0);
return 0;
}
static char *dispatch(const char *req, int *resp_len, bool *reboot) {
*reboot = false;
if (strncmp(req, "GET /api/channels", 17) == 0) {
return handle_api_channels_get(req, resp_len);
}
if (strncmp(req, "GET /api/status", 15) == 0) {
return handle_api_status(resp_len);
}
if (strncmp(req, "GET /api/config", 15) == 0) {
return handle_api_config_get(resp_len);
}
if (strncmp(req, "POST /api/channels", 18) == 0) {
return handle_api_channels_post(req, resp_len);
}
if (strncmp(req, "POST /api/config", 16) == 0) {
*reboot = true;
return handle_api_config_post(req, resp_len);
}
if (strncmp(req, "GET /style.css", 14) == 0) {
return build_response("200 OK", "text/css", PAGE_CSS, (int)strlen(PAGE_CSS), resp_len);
}
if (strncmp(req, "GET /app.js", 11) == 0) {
return build_response("200 OK", "application/javascript", PAGE_APP_JS, (int)strlen(PAGE_APP_JS), resp_len);
}
if (strncmp(req, "GET /wifi.js", 12) == 0) {
return build_response("200 OK", "application/javascript", PAGE_WIFI_JS, (int)strlen(PAGE_WIFI_JS), resp_len);
}
if (strncmp(req, "GET /wifi", 9) == 0) {
return build_response("200 OK", "text/html", PAGE_WIFI, (int)strlen(PAGE_WIFI), resp_len);
}
/* Root: "GET / ", "GET /?", "GET /\r", "GET /HTTP" edge cases */
if (strncmp(req, "GET /", 5) == 0) {
char n = req[5];
if (n == ' ' || n == '?' || n == '\r' || n == '\n') {
return build_response("200 OK", "text/html", PAGE_INDEX, (int)strlen(PAGE_INDEX), resp_len);
}
if (strncmp(req, "GET /index.html", 15) == 0) {
return build_response("200 OK", "text/html", PAGE_INDEX, (int)strlen(PAGE_INDEX), resp_len);
}
}
if (strncmp(req, "GET /favicon.ico", 16) == 0) {
return build_response("204 No Content", "text/plain", "", 0, resp_len);
}
printf("[http] 404: %.40s\n", req);
return build_response("404 Not Found", "text/plain", "not found", 9, resp_len);
}
static err_t http_recv(void *arg, struct tcp_pcb *tpcb, struct pbuf *p, err_t err) {
http_conn_t *c = (http_conn_t *)arg;
if (!p) {
return http_close(c, tpcb, ERR_OK);
}
if (err != ERR_OK) {
pbuf_free(p);
return http_close(c, tpcb, err);
}
if (p->tot_len + c->req_len < REQ_BUF) {
pbuf_copy_partial(p, c->req + c->req_len, p->tot_len, 0);
c->req_len += p->tot_len;
c->req[c->req_len] = 0;
}
tcp_recved(tpcb, p->tot_len);
pbuf_free(p);
if (!strstr(c->req, "\r\n\r\n")) {
return ERR_OK;
}
/* For POST, wait until Content-Length bytes arrived */
if (strncmp(c->req, "POST", 4) == 0) {
const char *cl = strstr(c->req, "Content-Length:");
if (cl) {
int need = atoi(cl + 15);
const char *body = find_body(c->req);
if (!body || (int)strlen(body) < need) {
return ERR_OK;
}
}
}
bool reboot = false;
printf("[http] %.48s\n", c->req);
c->resp = dispatch(c->req, &c->resp_len, &reboot);
c->sent = 0;
c->close_after = true;
if (!c->resp) {
return http_close(c, tpcb, ERR_MEM);
}
if (reboot) {
add_alarm_in_ms(500, reboot_cb, NULL, true);
}
return http_send_more(c, tpcb);
}
static void http_err(void *arg, err_t err) {
(void)err;
http_conn_free((http_conn_t *)arg);
}
static err_t http_accept(void *arg, struct tcp_pcb *newpcb, err_t err) {
(void)arg;
if (err != ERR_OK || !newpcb) {
return ERR_VAL;
}
http_conn_t *c = calloc(1, sizeof(*c));
if (!c) {
return ERR_MEM;
}
c->pcb = newpcb;
tcp_arg(newpcb, c);
tcp_recv(newpcb, http_recv);
tcp_sent(newpcb, http_sent);
tcp_err(newpcb, http_err);
tcp_poll(newpcb, http_poll, 2);
printf("[http] client accept\n");
return ERR_OK;
}
bool http_server_start(void) {
cyw43_arch_lwip_begin();
g_server = tcp_new_ip_type(IPADDR_TYPE_ANY);
if (!g_server) {
cyw43_arch_lwip_end();
return false;
}
if (tcp_bind(g_server, IP_ANY_TYPE, HTTP_PORT) != ERR_OK) {
tcp_close(g_server);
g_server = NULL;
cyw43_arch_lwip_end();
return false;
}
g_server = tcp_listen_with_backlog(g_server, 2);
tcp_accept(g_server, http_accept);
cyw43_arch_lwip_end();
printf("HTTP server on port %d\n", HTTP_PORT);
return true;
}
+89
View File
@@ -0,0 +1,89 @@
#include <stdio.h>
#include <string.h>
#include "pico/stdlib.h"
#include "pico/stdio_usb.h"
#include "pico/cyw43_arch.h"
#include "hardware/pio.h"
#include "board.h"
#include "config.h"
#include "channels.h"
#include "dmx_out.h"
#include "artnet.h"
#include "wifi_mgr.h"
#include "http_server.h"
static dmx_out_t g_dmx[BOARD_NUM_UNIVERSES];
static uint8_t g_frames[BOARD_NUM_UNIVERSES][BOARD_DMX_FRAME_LEN];
static bool dmx_timer_cb(repeating_timer_t *rt) {
(void)rt;
for (unsigned u = 0; u < BOARD_NUM_UNIVERSES; u++) {
if (dmx_out_busy(&g_dmx[u])) {
continue;
}
channels_build_frame(u, g_frames[u]);
dmx_out_write(&g_dmx[u], g_frames[u], BOARD_DMX_FRAME_LEN);
}
return true;
}
int main(void) {
stdio_init_all();
/* Give USB CDC a moment so early boot logs are visible after flash/reboot. */
for (int i = 0; i < 40 && !stdio_usb_connected(); i++) {
sleep_ms(50);
}
sleep_ms(200);
printf("\nArtNetDMX starting\n");
fflush(stdout);
config_init();
channels_init();
if (!dmx_out_init(&g_dmx[0], BOARD_DMX_A_PIN, pio0)) {
printf("DMX A init failed\n");
}
if (!dmx_out_init(&g_dmx[1], BOARD_DMX_B_PIN, pio1)) {
printf("DMX B init failed\n");
}
if (!wifi_mgr_start(config_get())) {
printf("WiFi start failed\n");
while (true) {
tight_loop_contents();
}
}
if (!http_server_start()) {
printf("HTTP start failed\n");
}
if (!artnet_start()) {
printf("Art-Net start failed\n");
}
static repeating_timer_t dmx_timer;
if (!add_repeating_timer_ms(-BOARD_DMX_PERIOD_MS, dmx_timer_cb, NULL, &dmx_timer)) {
printf("DMX timer failed\n");
} else {
printf("DMX output at %d Hz\n", BOARD_DMX_HZ);
}
printf("Ready — mode=%s primary=%s mDNS http://%s/\n",
wifi_mode_name(wifi_mgr_active_mode()),
wifi_mgr_ip_str(),
wifi_mgr_mdns_name());
if (wifi_mgr_ap_up()) {
printf("SoftAP http://%s/ (ssid ArtNetDMX)\n", wifi_mgr_ap_ip_str());
} else {
printf("Joining STA (SoftAP starts only if join fails)...\n");
}
while (true) {
wifi_mgr_poll();
artnet_poll();
tight_loop_contents();
sleep_ms(10);
}
}
+569
View File
@@ -0,0 +1,569 @@
#include "artnet.h"
#include "wifi_mgr.h"
#include <stdio.h>
#include <string.h>
#include "pico/cyw43_arch.h"
#include "pico/stdlib.h"
#include "pico/error.h"
#include "lwip/netif.h"
#include "lwip/dhcp.h"
#include "lwip/apps/mdns.h"
#include "dhcpserver.h"
#include "board.h"
#ifndef BOARD_WIFI_COUNTRY
#define BOARD_WIFI_COUNTRY CYW43_COUNTRY_NEW_ZEALAND
#endif
static wifi_mode_t g_mode = WIFI_MODE_AP;
static char g_ip[16] = "0.0.0.0";
static char g_sta_ip[16] = "0.0.0.0";
static char g_ap_ip[16] = "0.0.0.0";
static bool g_sta_up;
static bool g_ap_up;
static bool g_up;
static dhcp_server_t g_dhcp;
static bool g_dhcp_active;
static bool g_mdns_ok;
static char g_debug[192] = "boot";
static bool g_scan_found_target;
static int g_scan_rssi;
static int g_scan_chan;
static uint8_t g_scan_auth;
static char g_target_ssid[CONFIG_SSID_MAX];
static config_t g_cfg;
static bool g_sta_pending;
static absolute_time_t g_sta_deadline;
static void set_debug(const char *msg) {
snprintf(g_debug, sizeof(g_debug), "%s", msg);
printf("[wifi] %s\n", msg);
}
const char *wifi_mgr_debug(void) {
return g_debug;
}
static void wifi_disable_powersave(void) {
/* Keep radio fully awake — important for Art-Net / DMX timing reliability. */
cyw43_wifi_pm(&cyw43_state, CYW43_NONE_PM);
printf("[wifi] powersave OFF (CYW43_NONE_PM)\n");
}
static void refresh_sta_ip(void) {
snprintf(g_sta_ip, sizeof(g_sta_ip), "%s",
ip4addr_ntoa(netif_ip4_addr(&cyw43_state.netif[CYW43_ITF_STA])));
}
static void update_primary_ip(void) {
if (g_sta_up) {
refresh_sta_ip();
snprintf(g_ip, sizeof(g_ip), "%s", g_sta_ip);
} else if (g_ap_up) {
snprintf(g_ip, sizeof(g_ip), "%s", g_ap_ip);
} else {
snprintf(g_ip, sizeof(g_ip), "0.0.0.0");
}
}
static const char *link_name(int status) {
switch (status) {
case CYW43_LINK_DOWN: return "DOWN";
case CYW43_LINK_JOIN: return "JOIN";
case CYW43_LINK_NOIP: return "NOIP";
case CYW43_LINK_UP: return "UP";
case CYW43_LINK_FAIL: return "FAIL";
case CYW43_LINK_NONET: return "NONET";
case CYW43_LINK_BADAUTH: return "BADAUTH";
default: return "?";
}
}
static const char *err_name(int rc) {
switch (rc) {
case PICO_OK: return "OK";
case PICO_ERROR_TIMEOUT: return "TIMEOUT";
case PICO_ERROR_BADAUTH: return "BADAUTH";
case PICO_ERROR_CONNECT_FAILED: return "CONNECT_FAILED";
case PICO_ERROR_GENERIC: return "GENERIC";
default: return "ERR";
}
}
static void dump_ssid_debug(const char *label, const char *ssid) {
size_t n = strlen(ssid);
printf("[wifi] %s ssid='%s' len=%u hex:", label, ssid, (unsigned)n);
for (size_t i = 0; i < n && i < 32; i++) {
printf(" %02x", (unsigned char)ssid[i]);
}
printf("\n");
}
static void dump_pass_debug(const char *pass) {
size_t n = pass ? strlen(pass) : 0;
if (n == 0) {
printf("[wifi] password: (empty / open)\n");
return;
}
printf("[wifi] password: len=%u first='%c' last='%c'\n",
(unsigned)n, pass[0], pass[n - 1]);
}
static int scan_result_cb(void *env, const cyw43_ev_scan_result_t *result) {
(void)env;
if (!result || result->ssid_len == 0) {
return 0;
}
char ssid[33];
size_t n = result->ssid_len < 32 ? result->ssid_len : 32;
memcpy(ssid, result->ssid, n);
ssid[n] = 0;
bool match = g_target_ssid[0] && strcmp(ssid, g_target_ssid) == 0;
printf("[wifi] scan %sssid='%-32s' rssi=%4d chan=%3u auth=0x%02x mac=%02x:%02x:%02x:%02x:%02x:%02x\n",
match ? "* " : " ",
ssid, result->rssi, result->channel, result->auth_mode,
result->bssid[0], result->bssid[1], result->bssid[2],
result->bssid[3], result->bssid[4], result->bssid[5]);
if (match) {
g_scan_found_target = true;
g_scan_rssi = result->rssi;
g_scan_chan = result->channel;
g_scan_auth = result->auth_mode;
}
return 0;
}
static void wifi_scan_for_target(const char *ssid) {
memset(g_target_ssid, 0, sizeof(g_target_ssid));
if (ssid) {
strncpy(g_target_ssid, ssid, sizeof(g_target_ssid) - 1);
}
g_scan_found_target = false;
set_debug("scanning 2.4GHz networks...");
cyw43_wifi_scan_options_t opts;
memset(&opts, 0, sizeof(opts));
int err = cyw43_wifi_scan(&cyw43_state, &opts, NULL, scan_result_cb);
if (err) {
printf("[wifi] scan start failed: %d\n", err);
set_debug("scan start failed");
return;
}
absolute_time_t deadline = make_timeout_time_ms(10000);
while (cyw43_wifi_scan_active(&cyw43_state)) {
if (time_reached(deadline)) {
printf("[wifi] scan timeout\n");
break;
}
sleep_ms(50);
}
if (g_scan_found_target) {
char buf[160];
snprintf(buf, sizeof(buf), "SSID found rssi=%d chan=%d auth=0x%02x",
g_scan_rssi, g_scan_chan, g_scan_auth);
set_debug(buf);
} else {
set_debug("SSID NOT SEEN in scan (wrong name, 5GHz-only, or out of range)");
printf("[wifi] *** target SSID '%s' not seen — Pico W is 2.4GHz only ***\n",
g_target_ssid);
}
}
static const char *mdns_hostname(void) {
const config_t *c = config_get();
if (c && c->mdns_host[0]) {
return c->mdns_host;
}
return BOARD_HOSTNAME;
}
static void mdns_txt(struct mdns_service *service, void *txt_userdata) {
(void)txt_userdata;
mdns_resp_add_service_txtitem(service, "path=/", 6);
}
static void mdns_add_netif(struct netif *netif, const char *label) {
if (!netif) {
return;
}
const char *host = mdns_hostname();
err_t e = mdns_resp_add_netif(netif, host);
if (e != ERR_OK) {
printf("[mdns] add_netif %s failed %d\n", label, (int)e);
return;
}
s8_t slot = mdns_resp_add_service(netif, host, "_http", DNSSD_PROTO_TCP, 80,
mdns_txt, NULL);
if (slot < 0) {
printf("[mdns] add_service %s failed %d\n", label, (int)slot);
} else {
printf("[mdns] %s -> %s.local (_http._tcp)\n", label, host);
g_mdns_ok = true;
}
}
static void mdns_start(void) {
cyw43_arch_lwip_begin();
mdns_resp_init();
if (g_sta_up) {
mdns_add_netif(&cyw43_state.netif[CYW43_ITF_STA], "STA");
}
if (g_ap_up) {
mdns_add_netif(&cyw43_state.netif[CYW43_ITF_AP], "AP");
}
cyw43_arch_lwip_end();
if (g_mdns_ok) {
printf("[mdns] browse http://%s.local/\n", mdns_hostname());
}
}
/* Remember SoftAP credentials so we can re-assert after STA/scan. */
static char g_ap_ssid_active[CONFIG_SSID_MAX];
static char g_ap_pass_active[CONFIG_PASS_MAX];
static void resolve_ap_credentials(const config_t *cfg, const char **ssid, const char **pass) {
*ssid = (cfg->ap_ssid[0]) ? cfg->ap_ssid : BOARD_AP_SSID_DEFAULT;
*pass = (cfg->ap_pass[0]) ? cfg->ap_pass : BOARD_AP_PASS_DEFAULT;
/* WPA2-PSK requires 8–63 character passphrase; fall back to defaults. */
size_t plen = strlen(*pass);
if (plen < 8 || plen > 63) {
printf("[wifi] AP pass len=%u invalid for WPA2 — using defaults\n", (unsigned)plen);
*ssid = BOARD_AP_SSID_DEFAULT;
*pass = BOARD_AP_PASS_DEFAULT;
}
if (!(*ssid)[0]) {
*ssid = BOARD_AP_SSID_DEFAULT;
}
}
static bool start_ap_iface(const config_t *cfg) {
const char *ssid;
const char *pass;
resolve_ap_credentials(cfg, &ssid, &pass);
strncpy(g_ap_ssid_active, ssid, sizeof(g_ap_ssid_active) - 1);
g_ap_ssid_active[sizeof(g_ap_ssid_active) - 1] = 0;
strncpy(g_ap_pass_active, pass, sizeof(g_ap_pass_active) - 1);
g_ap_pass_active[sizeof(g_ap_pass_active) - 1] = 0;
dump_ssid_debug("AP", g_ap_ssid_active);
dump_pass_debug(g_ap_pass_active);
/*
* SoftAP must come up before STA. On CYW43, later STA join/scan can stop
* beaconing — callers re-assert via this same path after STA work.
*/
cyw43_arch_enable_ap_mode(g_ap_ssid_active, g_ap_pass_active, CYW43_AUTH_WPA2_AES_PSK);
wifi_disable_powersave();
/* Let SoftAP beacon before we touch STA / scan. */
sleep_ms(300);
ip4_addr_t gw, mask;
IP4_ADDR(&gw, 192, 168, 4, 1);
IP4_ADDR(&mask, 255, 255, 255, 0);
if (!g_dhcp_active) {
dhcp_server_init(&g_dhcp, (ip_addr_t *)&gw, (ip_addr_t *)&mask);
g_dhcp_active = true;
printf("[wifi] DHCP server on 192.168.4.0/24 (gw 192.168.4.1)\n");
}
g_ap_up = true;
snprintf(g_ap_ip, sizeof(g_ap_ip), "%s",
ip4addr_ntoa(netif_ip4_addr(&cyw43_state.netif[CYW43_ITF_AP])));
if (strcmp(g_ap_ip, "0.0.0.0") == 0) {
snprintf(g_ap_ip, sizeof(g_ap_ip), "192.168.4.1");
}
printf("[wifi] AP '%s' up at %s (netif)\n", g_ap_ssid_active, g_ap_ip);
return true;
}
static void softap_pause_for_sta(void) {
/* SoftAP DHCP binds IP_ANY:67 and can steal STA discovers / UDP PCBs. */
if (g_dhcp_active) {
printf("[wifi] stopping SoftAP DHCP server for STA join\n");
dhcp_server_deinit(&g_dhcp);
g_dhcp_active = false;
}
if (!g_ap_up) {
return;
}
printf("[wifi] pausing SoftAP so STA can get DHCP\n");
cyw43_arch_disable_ap_mode();
g_ap_up = false;
sleep_ms(200);
}
static int connect_with_status(const char *ssid, const char *pass, uint32_t auth,
const char *label, uint32_t timeout_ms) {
printf("[wifi] join %s auth=0x%08lx timeout=%lums\n",
label, (unsigned long)auth, (unsigned long)timeout_ms);
int err = cyw43_arch_wifi_connect_async(ssid, pass, auth);
if (err) {
printf("[wifi] join async failed: %d (%s)\n", err, err_name(err));
return err;
}
absolute_time_t until = make_timeout_time_ms(timeout_ms);
int status = 999;
absolute_time_t last_print = get_absolute_time();
absolute_time_t last_dhcp = get_absolute_time();
while (!time_reached(until)) {
int st = cyw43_tcpip_link_status(&cyw43_state, CYW43_ITF_STA);
if (st != status || absolute_time_diff_us(last_print, get_absolute_time()) > 2000000) {
status = st;
last_print = get_absolute_time();
printf("[wifi] link=%s (%d)\n", link_name(st), st);
char buf[96];
snprintf(buf, sizeof(buf), "STA %s link=%s", label, link_name(st));
set_debug(buf);
}
if (st == CYW43_LINK_UP) {
return PICO_OK;
}
if (st == CYW43_LINK_BADAUTH) {
return PICO_ERROR_BADAUTH;
}
if (st == CYW43_LINK_FAIL) {
return PICO_ERROR_CONNECT_FAILED;
}
/* Associated but no lease — restart DHCP (free UDP PCB pool if SoftAP ate it). */
if (st == CYW43_LINK_NOIP || st == CYW43_LINK_JOIN) {
if (absolute_time_diff_us(last_dhcp, get_absolute_time()) > 4000000) {
last_dhcp = get_absolute_time();
cyw43_arch_lwip_begin();
dhcp_release_and_stop(&cyw43_state.netif[CYW43_ITF_STA]);
err_t de = dhcp_start(&cyw43_state.netif[CYW43_ITF_STA]);
cyw43_arch_lwip_end();
printf("[wifi] dhcp_start on STA => %d\n", (int)de);
}
}
if (st == CYW43_LINK_NONET) {
cyw43_arch_wifi_connect_async(ssid, pass, auth);
}
sleep_ms(100);
}
int final = cyw43_tcpip_link_status(&cyw43_state, CYW43_ITF_STA);
printf("[wifi] %s timed out, final link=%s\n", label, link_name(final));
if (final == CYW43_LINK_BADAUTH) {
return PICO_ERROR_BADAUTH;
}
if (final == CYW43_LINK_UP) {
return PICO_OK;
}
return PICO_ERROR_TIMEOUT;
}
static bool start_sta_iface(const config_t *cfg) {
if (!cfg->sta_ssid[0]) {
set_debug("STA aborted: empty SSID in flash");
return false;
}
/* SoftAP (if any) must be down — concurrent SoftAP breaks STA DHCP on CYW43. */
softap_pause_for_sta();
cyw43_arch_enable_sta_mode();
wifi_disable_powersave();
netif_set_default(&cyw43_state.netif[CYW43_ITF_STA]);
dump_ssid_debug("STA target", cfg->sta_ssid);
dump_pass_debug(cfg->sta_pass);
const char *pass = cfg->sta_pass[0] ? cfg->sta_pass : NULL;
struct {
uint32_t auth;
const char *label;
} attempts[] = {
{CYW43_AUTH_WPA2_AES_PSK, "WPA2_AES"},
{CYW43_AUTH_WPA2_MIXED_PSK, "WPA2_MIXED"},
{CYW43_AUTH_WPA_TKIP_PSK, "WPA_TKIP"},
};
int rc = PICO_ERROR_CONNECT_FAILED;
for (unsigned i = 0; i < count_of(attempts); i++) {
if (!pass) {
rc = connect_with_status(cfg->sta_ssid, NULL, CYW43_AUTH_OPEN, "OPEN", 25000);
break;
}
char buf[96];
snprintf(buf, sizeof(buf), "trying %s...", attempts[i].label);
set_debug(buf);
rc = connect_with_status(cfg->sta_ssid, pass, attempts[i].auth, attempts[i].label, 25000);
printf("[wifi] attempt %s => %d (%s)\n", attempts[i].label, rc, err_name(rc));
if (rc == PICO_OK) {
break;
}
if (rc == PICO_ERROR_BADAUTH) {
continue;
}
/* Timeout/fail after association — other auth modes won't help. */
if (rc == PICO_ERROR_TIMEOUT) {
break;
}
}
if (rc != PICO_OK) {
char buf[160];
snprintf(buf, sizeof(buf), "STA FAILED %s", err_name(rc));
set_debug(buf);
cyw43_arch_disable_sta_mode();
g_sta_up = false;
return false;
}
wifi_disable_powersave();
g_sta_up = true;
refresh_sta_ip();
printf("[wifi] STA connected IP=%s\n", g_sta_ip);
return true;
}
bool wifi_mgr_start(const config_t *cfg) {
printf("[wifi] cyw43 init country=NZ...\n");
if (cyw43_arch_init_with_country(BOARD_WIFI_COUNTRY)) {
set_debug("cyw43 init FAILED");
return false;
}
cyw43_arch_gpio_put(CYW43_WL_GPIO_LED_PIN, 1);
wifi_disable_powersave();
g_cfg = *cfg;
wifi_mode_t want = g_cfg.wifi_mode;
/* Legacy AP+STA → STA with SoftAP fallback on failure. */
if (want == WIFI_MODE_AP_STA) {
want = WIFI_MODE_STA;
}
if (want != WIFI_MODE_STA) {
want = WIFI_MODE_AP;
}
g_cfg.wifi_mode = want;
printf("[wifi] configured mode=%s sta='%s' ap='%s'\n",
wifi_mode_name(want), g_cfg.sta_ssid, g_cfg.ap_ssid);
if (want == WIFI_MODE_AP) {
set_debug("starting SoftAP...");
if (!start_ap_iface(&g_cfg)) {
set_debug("AP start FAILED");
return false;
}
sleep_ms(300);
g_mode = WIFI_MODE_AP;
g_up = true;
update_primary_ip();
mdns_start();
char buf[160];
snprintf(buf, sizeof(buf), "AP ready ssid='%s' ip=%s",
g_ap_ssid_active, g_ap_ip);
set_debug(buf);
return true;
}
/* STA: join after HTTP/Art-Net start; SoftAP only if join fails. */
g_mode = WIFI_MODE_STA;
g_up = false;
g_sta_deadline = make_timeout_time_ms(500);
g_sta_pending = true;
set_debug("STA mode — joining LAN shortly (SoftAP if join fails)");
printf("[wifi] STA join deferred 0.5s; SoftAP only on failure\n");
return true;
}
void wifi_mgr_poll(void) {
if (!g_sta_pending || !time_reached(g_sta_deadline)) {
return;
}
g_sta_pending = false;
printf("[wifi] STA join starting\n");
set_debug("attempting STA...");
bool sta_ok = start_sta_iface(&g_cfg);
if (sta_ok) {
g_mode = WIFI_MODE_STA;
g_up = true;
update_primary_ip();
mdns_start();
artnet_refresh();
char buf[160];
snprintf(buf, sizeof(buf), "STA ok ip=%s (SoftAP off)", g_sta_ip);
set_debug(buf);
printf("[wifi] %s\n", buf);
return;
}
printf("[wifi] STA failed — SoftAP fallback for recovery\n");
set_debug("STA failed — starting SoftAP fallback...");
if (!start_ap_iface(&g_cfg)) {
set_debug("STA failed and SoftAP fallback FAILED");
g_mode = WIFI_MODE_AP;
g_up = false;
return;
}
sleep_ms(300);
g_mode = WIFI_MODE_AP;
g_up = true;
update_primary_ip();
mdns_start();
artnet_refresh();
char buf[160];
snprintf(buf, sizeof(buf), "AP fallback ssid='%s' ip=%s",
g_ap_ssid_active, g_ap_ip);
set_debug(buf);
printf("[wifi] %s\n", buf);
}
wifi_mode_t wifi_mgr_active_mode(void) {
return g_mode;
}
const char *wifi_mgr_ip_str(void) {
update_primary_ip();
return g_ip;
}
const char *wifi_mgr_sta_ip_str(void) {
if (g_sta_up) {
refresh_sta_ip();
}
return g_sta_ip;
}
const char *wifi_mgr_ap_ip_str(void) {
return g_ap_ip;
}
const char *wifi_mgr_mdns_name(void) {
static char name[CONFIG_HOSTNAME_MAX + 8];
snprintf(name, sizeof(name), "%s.local", mdns_hostname());
return name;
}
bool wifi_mgr_sta_up(void) {
return g_sta_up;
}
bool wifi_mgr_ap_up(void) {
return g_ap_up;
}
bool wifi_mgr_is_up(void) {
return g_up;
}
+309
View File
@@ -0,0 +1,309 @@
/*
* This file is part of the MicroPython project, http://micropython.org/
*
* The MIT License (MIT)
*
* Copyright (c) 2018-2019 Damien P. George
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
// For DHCP specs see:
// https://www.ietf.org/rfc/rfc2131.txt
// https://tools.ietf.org/html/rfc2132 -- DHCP Options and BOOTP Vendor Extensions
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include "cyw43_config.h"
#include "dhcpserver.h"
#include "lwip/udp.h"
#define DHCPDISCOVER (1)
#define DHCPOFFER (2)
#define DHCPREQUEST (3)
#define DHCPDECLINE (4)
#define DHCPACK (5)
#define DHCPNACK (6)
#define DHCPRELEASE (7)
#define DHCPINFORM (8)
#define DHCP_OPT_PAD (0)
#define DHCP_OPT_SUBNET_MASK (1)
#define DHCP_OPT_ROUTER (3)
#define DHCP_OPT_DNS (6)
#define DHCP_OPT_HOST_NAME (12)
#define DHCP_OPT_REQUESTED_IP (50)
#define DHCP_OPT_IP_LEASE_TIME (51)
#define DHCP_OPT_MSG_TYPE (53)
#define DHCP_OPT_SERVER_ID (54)
#define DHCP_OPT_PARAM_REQUEST_LIST (55)
#define DHCP_OPT_MAX_MSG_SIZE (57)
#define DHCP_OPT_VENDOR_CLASS_ID (60)
#define DHCP_OPT_CLIENT_ID (61)
#define DHCP_OPT_END (255)
#define PORT_DHCP_SERVER (67)
#define PORT_DHCP_CLIENT (68)
#define DEFAULT_LEASE_TIME_S (24 * 60 * 60) // in seconds
#define MAC_LEN (6)
#define MAKE_IP4(a, b, c, d) ((a) << 24 | (b) << 16 | (c) << 8 | (d))
typedef struct {
uint8_t op; // message opcode
uint8_t htype; // hardware address type
uint8_t hlen; // hardware address length
uint8_t hops;
uint32_t xid; // transaction id, chosen by client
uint16_t secs; // client seconds elapsed
uint16_t flags;
uint8_t ciaddr[4]; // client IP address
uint8_t yiaddr[4]; // your IP address
uint8_t siaddr[4]; // next server IP address
uint8_t giaddr[4]; // relay agent IP address
uint8_t chaddr[16]; // client hardware address
uint8_t sname[64]; // server host name
uint8_t file[128]; // boot file name
uint8_t options[312]; // optional parameters, variable, starts with magic
} dhcp_msg_t;
static int dhcp_socket_new_dgram(struct udp_pcb **udp, void *cb_data, udp_recv_fn cb_udp_recv) {
// family is AF_INET
// type is SOCK_DGRAM
*udp = udp_new();
if (*udp == NULL) {
return -ENOMEM;
}
// Register callback
udp_recv(*udp, cb_udp_recv, (void *)cb_data);
return 0; // success
}
static void dhcp_socket_free(struct udp_pcb **udp) {
if (*udp != NULL) {
udp_remove(*udp);
*udp = NULL;
}
}
static int dhcp_socket_bind(struct udp_pcb **udp, uint16_t port) {
// TODO convert lwIP errors to errno
return udp_bind(*udp, IP_ANY_TYPE, port);
}
static int dhcp_socket_sendto(struct udp_pcb **udp, struct netif *nif, const void *buf, size_t len, uint32_t ip, uint16_t port) {
if (len > 0xffff) {
len = 0xffff;
}
struct pbuf *p = pbuf_alloc(PBUF_TRANSPORT, len, PBUF_RAM);
if (p == NULL) {
return -ENOMEM;
}
memcpy(p->payload, buf, len);
ip_addr_t dest;
IP4_ADDR(ip_2_ip4(&dest), ip >> 24 & 0xff, ip >> 16 & 0xff, ip >> 8 & 0xff, ip & 0xff);
err_t err;
if (nif != NULL) {
err = udp_sendto_if(*udp, p, &dest, port, nif);
} else {
err = udp_sendto(*udp, p, &dest, port);
}
pbuf_free(p);
if (err != ERR_OK) {
return err;
}
return len;
}
static uint8_t *opt_find(uint8_t *opt, uint8_t cmd) {
for (int i = 0; i < 308 && opt[i] != DHCP_OPT_END;) {
if (opt[i] == cmd) {
return &opt[i];
}
i += 2 + opt[i + 1];
}
return NULL;
}
static void opt_write_n(uint8_t **opt, uint8_t cmd, size_t n, const void *data) {
uint8_t *o = *opt;
*o++ = cmd;
*o++ = n;
memcpy(o, data, n);
*opt = o + n;
}
static void opt_write_u8(uint8_t **opt, uint8_t cmd, uint8_t val) {
uint8_t *o = *opt;
*o++ = cmd;
*o++ = 1;
*o++ = val;
*opt = o;
}
static void opt_write_u32(uint8_t **opt, uint8_t cmd, uint32_t val) {
uint8_t *o = *opt;
*o++ = cmd;
*o++ = 4;
*o++ = val >> 24;
*o++ = val >> 16;
*o++ = val >> 8;
*o++ = val;
*opt = o;
}
static void dhcp_server_process(void *arg, struct udp_pcb *upcb, struct pbuf *p, const ip_addr_t *src_addr, u16_t src_port) {
dhcp_server_t *d = arg;
(void)upcb;
(void)src_addr;
(void)src_port;
// This is around 548 bytes
dhcp_msg_t dhcp_msg;
#define DHCP_MIN_SIZE (240 + 3)
if (p->tot_len < DHCP_MIN_SIZE) {
goto ignore_request;
}
size_t len = pbuf_copy_partial(p, &dhcp_msg, sizeof(dhcp_msg), 0);
if (len < DHCP_MIN_SIZE) {
goto ignore_request;
}
dhcp_msg.op = DHCPOFFER;
memcpy(&dhcp_msg.yiaddr, &ip4_addr_get_u32(ip_2_ip4(&d->ip)), 4);
uint8_t *opt = (uint8_t *)&dhcp_msg.options;
opt += 4; // assume magic cookie: 99, 130, 83, 99
uint8_t *msgtype = opt_find(opt, DHCP_OPT_MSG_TYPE);
if (msgtype == NULL) {
// A DHCP package without MSG_TYPE?
goto ignore_request;
}
switch (msgtype[2]) {
case DHCPDISCOVER: {
int yi = DHCPS_MAX_IP;
for (int i = 0; i < DHCPS_MAX_IP; ++i) {
if (memcmp(d->lease[i].mac, dhcp_msg.chaddr, MAC_LEN) == 0) {
// MAC match, use this IP address
yi = i;
break;
}
if (yi == DHCPS_MAX_IP) {
// Look for a free IP address
if (memcmp(d->lease[i].mac, "\x00\x00\x00\x00\x00\x00", MAC_LEN) == 0) {
// IP available
yi = i;
}
uint32_t expiry = d->lease[i].expiry << 16 | 0xffff;
if ((int32_t)(expiry - cyw43_hal_ticks_ms()) < 0) {
// IP expired, reuse it
memset(d->lease[i].mac, 0, MAC_LEN);
yi = i;
}
}
}
if (yi == DHCPS_MAX_IP) {
// No more IP addresses left
goto ignore_request;
}
dhcp_msg.yiaddr[3] = DHCPS_BASE_IP + yi;
opt_write_u8(&opt, DHCP_OPT_MSG_TYPE, DHCPOFFER);
break;
}
case DHCPREQUEST: {
uint8_t *o = opt_find(opt, DHCP_OPT_REQUESTED_IP);
if (o == NULL) {
// Should be NACK
goto ignore_request;
}
if (memcmp(o + 2, &ip4_addr_get_u32(ip_2_ip4(&d->ip)), 3) != 0) {
// Should be NACK
goto ignore_request;
}
uint8_t yi = o[5] - DHCPS_BASE_IP;
if (yi >= DHCPS_MAX_IP) {
// Should be NACK
goto ignore_request;
}
if (memcmp(d->lease[yi].mac, dhcp_msg.chaddr, MAC_LEN) == 0) {
// MAC match, ok to use this IP address
} else if (memcmp(d->lease[yi].mac, "\x00\x00\x00\x00\x00\x00", MAC_LEN) == 0) {
// IP unused, ok to use this IP address
memcpy(d->lease[yi].mac, dhcp_msg.chaddr, MAC_LEN);
} else {
// IP already in use
// Should be NACK
goto ignore_request;
}
d->lease[yi].expiry = (cyw43_hal_ticks_ms() + DEFAULT_LEASE_TIME_S * 1000) >> 16;
dhcp_msg.yiaddr[3] = DHCPS_BASE_IP + yi;
opt_write_u8(&opt, DHCP_OPT_MSG_TYPE, DHCPACK);
printf("DHCPS: client connected: MAC=%02x:%02x:%02x:%02x:%02x:%02x IP=%u.%u.%u.%u\n",
dhcp_msg.chaddr[0], dhcp_msg.chaddr[1], dhcp_msg.chaddr[2], dhcp_msg.chaddr[3], dhcp_msg.chaddr[4], dhcp_msg.chaddr[5],
dhcp_msg.yiaddr[0], dhcp_msg.yiaddr[1], dhcp_msg.yiaddr[2], dhcp_msg.yiaddr[3]);
break;
}
default:
goto ignore_request;
}
opt_write_n(&opt, DHCP_OPT_SERVER_ID, 4, &ip4_addr_get_u32(ip_2_ip4(&d->ip)));
opt_write_n(&opt, DHCP_OPT_SUBNET_MASK, 4, &ip4_addr_get_u32(ip_2_ip4(&d->nm)));
opt_write_n(&opt, DHCP_OPT_ROUTER, 4, &ip4_addr_get_u32(ip_2_ip4(&d->ip))); // aka gateway; can have multiple addresses
opt_write_n(&opt, DHCP_OPT_DNS, 4, &ip4_addr_get_u32(ip_2_ip4(&d->ip))); // this server is the dns
opt_write_u32(&opt, DHCP_OPT_IP_LEASE_TIME, DEFAULT_LEASE_TIME_S);
*opt++ = DHCP_OPT_END;
struct netif *nif = ip_current_input_netif();
dhcp_socket_sendto(&d->udp, nif, &dhcp_msg, opt - (uint8_t *)&dhcp_msg, 0xffffffff, PORT_DHCP_CLIENT);
ignore_request:
pbuf_free(p);
}
void dhcp_server_init(dhcp_server_t *d, ip_addr_t *ip, ip_addr_t *nm) {
ip_addr_copy(d->ip, *ip);
ip_addr_copy(d->nm, *nm);
memset(d->lease, 0, sizeof(d->lease));
if (dhcp_socket_new_dgram(&d->udp, d, dhcp_server_process) != 0) {
return;
}
dhcp_socket_bind(&d->udp, PORT_DHCP_SERVER);
}
void dhcp_server_deinit(dhcp_server_t *d) {
dhcp_socket_free(&d->udp);
}
+49
View File
@@ -0,0 +1,49 @@
/*
* This file is part of the MicroPython project, http://micropython.org/
*
* The MIT License (MIT)
*
* Copyright (c) 2018-2019 Damien P. George
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
#ifndef MICROPY_INCLUDED_LIB_NETUTILS_DHCPSERVER_H
#define MICROPY_INCLUDED_LIB_NETUTILS_DHCPSERVER_H
#include "lwip/ip_addr.h"
#define DHCPS_BASE_IP (16)
#define DHCPS_MAX_IP (8)
typedef struct _dhcp_server_lease_t {
uint8_t mac[6];
uint16_t expiry;
} dhcp_server_lease_t;
typedef struct _dhcp_server_t {
ip_addr_t ip;
ip_addr_t nm;
dhcp_server_lease_t lease[DHCPS_MAX_IP];
struct udp_pcb *udp;
} dhcp_server_t;
void dhcp_server_init(dhcp_server_t *d, ip_addr_t *ip, ip_addr_t *nm);
void dhcp_server_deinit(dhcp_server_t *d);
#endif // MICROPY_INCLUDED_LIB_NETUTILS_DHCPSERVER_H
+2
View File
@@ -0,0 +1,2 @@
Note: runtime serves embedded copies from src/http_server.c.
Edit both places if you change the UI, or regenerate embeds from these files.
+26
View File
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ArtNetDMX</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<header>
<h1>ArtNetDMX</h1>
<nav><a href="/" class="active">Channels</a><a href="/wifi">WiFi</a></nav>
<div id="status" class="status">…</div>
</header>
<main>
<div class="tabs">
<button type="button" id="tab0" class="active" data-u="0">Universe A</button>
<button type="button" id="tab1" data-u="1">Universe B</button>
<button type="button" id="clear">Clear</button>
<button type="button" id="full">All 255</button>
</div>
<div id="grid" class="grid"></div>
</main>
<script src="/app.js"></script>
</body>
</html>
+45
View File
@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>WiFi — ArtNetDMX</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<header>
<h1>ArtNetDMX</h1>
<nav><a href="/">Channels</a><a href="/wifi" class="active">WiFi</a></nav>
<div id="status" class="status">…</div>
</header>
<main class="form-wrap">
<form id="wifiForm">
<label>Mode
<select name="mode" id="mode">
<option value="0">Access Point</option>
<option value="1">Station (AP fallback if join fails)</option>
</select>
</label>
<p class="hint">Station joins your LAN only. If join fails, SoftAP starts for recovery.</p>
<fieldset>
<legend>Station</legend>
<label>SSID<input name="sta_ssid" id="sta_ssid" maxlength="31"></label>
<label>Password<input name="sta_pass" id="sta_pass" type="password" maxlength="63"></label>
</fieldset>
<fieldset>
<legend>Access Point</legend>
<label>SSID<input name="ap_ssid" id="ap_ssid" maxlength="31"></label>
<label>Password<input name="ap_pass" id="ap_pass" type="password" maxlength="63"></label>
</fieldset>
<fieldset>
<legend>Art-Net universes</legend>
<label>Port A<input name="universe_a" id="universe_a" type="number" min="0" max="32767"></label>
<label>Port B<input name="universe_b" id="universe_b" type="number" min="0" max="32767"></label>
</fieldset>
<button type="submit">Save &amp; reboot</button>
<p class="hint">After save the device reboots into the selected mode. AP default: ArtNetDMX / artnetdmx @ 192.168.4.1</p>
</form>
</main>
<script src="/wifi.js"></script>
</body>
</html>