From fea6dffa74e76bfd33d881a937744ad3109b14aa Mon Sep 17 00:00:00 2001 From: Jimmy Date: Fri, 11 Sep 2026 00:00:23 +1200 Subject: [PATCH] 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 --- .gitignore | 10 + CMakeLists.txt | 54 +++ README.md | 118 +++++ include/artnet.h | 14 + include/board.h | 25 ++ include/channels.h | 24 ++ include/config.h | 57 +++ include/dmx_out.h | 21 + include/http_server.h | 8 + include/wifi_mgr.h | 25 ++ lwipopts.h | 37 ++ lwipopts_examples_common.h | 92 ++++ pico_sdk_import.cmake | 121 ++++++ src/artnet.c | 339 +++++++++++++++ src/channels.c | 78 ++++ src/config.c | 151 +++++++ src/dmx_out.c | 84 ++++ src/dmx_out.pio | 21 + src/http_server.c | 645 ++++++++++++++++++++++++++++ src/main.c | 89 ++++ src/wifi_mgr.c | 569 ++++++++++++++++++++++++ third_party/dhcpserver/dhcpserver.c | 309 +++++++++++++ third_party/dhcpserver/dhcpserver.h | 49 +++ www/README.md | 2 + www/index.html | 26 ++ www/wifi.html | 45 ++ 26 files changed, 3013 insertions(+) create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 README.md create mode 100644 include/artnet.h create mode 100644 include/board.h create mode 100644 include/channels.h create mode 100644 include/config.h create mode 100644 include/dmx_out.h create mode 100644 include/http_server.h create mode 100644 include/wifi_mgr.h create mode 100644 lwipopts.h create mode 100644 lwipopts_examples_common.h create mode 100644 pico_sdk_import.cmake create mode 100644 src/artnet.c create mode 100644 src/channels.c create mode 100644 src/config.c create mode 100644 src/dmx_out.c create mode 100644 src/dmx_out.pio create mode 100644 src/http_server.c create mode 100644 src/main.c create mode 100644 src/wifi_mgr.c create mode 100644 third_party/dhcpserver/dhcpserver.c create mode 100644 third_party/dhcpserver/dhcpserver.h create mode 100644 www/README.md create mode 100644 www/index.html create mode 100644 www/wifi.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d3e0a9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +/build/ +*.uf2 +*.elf +*.bin +*.hex +*.dis +*.map +.idea/ +.vscode/ +*.o diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..3db8d3e --- /dev/null +++ b/CMakeLists.txt @@ -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) diff --git a/README.md b/README.md new file mode 100644 index 0000000..fd2206d --- /dev/null +++ b/README.md @@ -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://<pico-ip>/** (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). diff --git a/include/artnet.h b/include/artnet.h new file mode 100644 index 0000000..3a8a857 --- /dev/null +++ b/include/artnet.h @@ -0,0 +1,14 @@ +#ifndef ARTNET_H +#define ARTNET_H + +#include + +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 diff --git a/include/board.h b/include/board.h new file mode 100644 index 0000000..ca44a18 --- /dev/null +++ b/include/board.h @@ -0,0 +1,25 @@ +#ifndef BOARD_H +#define BOARD_H + +#include + +/** 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 diff --git a/include/channels.h b/include/channels.h new file mode 100644 index 0000000..9a4df4f --- /dev/null +++ b/include/channels.h @@ -0,0 +1,24 @@ +#ifndef CHANNELS_H +#define CHANNELS_H + +#include +#include +#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 diff --git a/include/config.h b/include/config.h new file mode 100644 index 0000000..a1f85d1 --- /dev/null +++ b/include/config.h @@ -0,0 +1,57 @@ +#ifndef CONFIG_H +#define CONFIG_H + +#include +#include +#include +#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 diff --git a/include/dmx_out.h b/include/dmx_out.h new file mode 100644 index 0000000..28f1330 --- /dev/null +++ b/include/dmx_out.h @@ -0,0 +1,21 @@ +#ifndef DMX_OUT_H +#define DMX_OUT_H + +#include +#include +#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 diff --git a/include/http_server.h b/include/http_server.h new file mode 100644 index 0000000..933b812 --- /dev/null +++ b/include/http_server.h @@ -0,0 +1,8 @@ +#ifndef HTTP_SERVER_H +#define HTTP_SERVER_H + +#include + +bool http_server_start(void); + +#endif diff --git a/include/wifi_mgr.h b/include/wifi_mgr.h new file mode 100644 index 0000000..47aedcc --- /dev/null +++ b/include/wifi_mgr.h @@ -0,0 +1,25 @@ +#ifndef WIFI_MGR_H +#define WIFI_MGR_H + +#include +#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 diff --git a/lwipopts.h b/lwipopts.h new file mode 100644 index 0000000..59ef55f --- /dev/null +++ b/lwipopts.h @@ -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 diff --git a/lwipopts_examples_common.h b/lwipopts_examples_common.h new file mode 100644 index 0000000..3cc083c --- /dev/null +++ b/lwipopts_examples_common.h @@ -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__ */ diff --git a/pico_sdk_import.cmake b/pico_sdk_import.cmake new file mode 100644 index 0000000..d493cc2 --- /dev/null +++ b/pico_sdk_import.cmake @@ -0,0 +1,121 @@ +# This is a copy of /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}) diff --git a/src/artnet.c b/src/artnet.c new file mode 100644 index 0000000..286fd29 --- /dev/null +++ b/src/artnet.c @@ -0,0 +1,339 @@ +#include "artnet.h" + +#include +#include + +#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; +} diff --git a/src/channels.c b/src/channels.c new file mode 100644 index 0000000..cf6c9e7 --- /dev/null +++ b/src/channels.c @@ -0,0 +1,78 @@ +#include "channels.h" + +#include +#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; +} diff --git a/src/config.c b/src/config.c new file mode 100644 index 0000000..2e6d82b --- /dev/null +++ b/src/config.c @@ -0,0 +1,151 @@ +#include "config.h" + +#include +#include +#include +#include +#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); + } +} diff --git a/src/dmx_out.c b/src/dmx_out.c new file mode 100644 index 0000000..94d5419 --- /dev/null +++ b/src/dmx_out.c @@ -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); +} diff --git a/src/dmx_out.pio b/src/dmx_out.pio new file mode 100644 index 0000000..086e365 --- /dev/null +++ b/src/dmx_out.pio @@ -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 diff --git a/src/http_server.c b/src/http_server.c new file mode 100644 index 0000000..808fd7d --- /dev/null +++ b/src/http_server.c @@ -0,0 +1,645 @@ +#include "http_server.h" + +#include +#include +#include +#include + +#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[] = +"" +"ArtNetDMX" +"

ArtNetDMX

" +"
…
" +"
" +"" +"" +"" +"" +"
" +""; + +static const char PAGE_WIFI[] = +"" +"WiFi — ArtNetDMX" +"

ArtNetDMX

" +"
…
" +"
" +"
Current IP
—
" +"
—
" +"
" +"
" +"
—
" +"

" +"
" +"" +"

Station joins your LAN only. If join fails, SoftAP starts for recovery. mDNS name is set below.

" +"
Station" +"" +"" +"
" +"
Access Point" +"" +"" +"
" +"
Art-Net universes" +"" +"" +"
" +"
mDNS" +"" +"

Resolves as http://<hostname>.local/ on the LAN (and SoftAP). Letters, digits, hyphen. Reboot required.

" +"
" +"" +"

After save the device reboots into the selected mode. AP default: ArtNetDMX / artnetdmx @ 192.168.4.1

" +"
" +""; + +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=``;" +"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; +} diff --git a/src/main.c b/src/main.c new file mode 100644 index 0000000..0490943 --- /dev/null +++ b/src/main.c @@ -0,0 +1,89 @@ +#include +#include + +#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); + } +} diff --git a/src/wifi_mgr.c b/src/wifi_mgr.c new file mode 100644 index 0000000..4c7edec --- /dev/null +++ b/src/wifi_mgr.c @@ -0,0 +1,569 @@ +#include "artnet.h" +#include "wifi_mgr.h" + +#include +#include + +#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; +} diff --git a/third_party/dhcpserver/dhcpserver.c b/third_party/dhcpserver/dhcpserver.c new file mode 100644 index 0000000..2061d04 --- /dev/null +++ b/third_party/dhcpserver/dhcpserver.c @@ -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 +#include +#include + +#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); +} diff --git a/third_party/dhcpserver/dhcpserver.h b/third_party/dhcpserver/dhcpserver.h new file mode 100644 index 0000000..2349d2e --- /dev/null +++ b/third_party/dhcpserver/dhcpserver.h @@ -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 diff --git a/www/README.md b/www/README.md new file mode 100644 index 0000000..bcd63aa --- /dev/null +++ b/www/README.md @@ -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. diff --git a/www/index.html b/www/index.html new file mode 100644 index 0000000..a902daf --- /dev/null +++ b/www/index.html @@ -0,0 +1,26 @@ + + + + + +ArtNetDMX + + + +
+

ArtNetDMX

+ +
…
+
+
+
+ + + + +
+
+
+ + + diff --git a/www/wifi.html b/www/wifi.html new file mode 100644 index 0000000..97706df --- /dev/null +++ b/www/wifi.html @@ -0,0 +1,45 @@ + + + + + +WiFi — ArtNetDMX + + + +
+

ArtNetDMX

+ +
…
+
+
+
+ +

Station joins your LAN only. If join fails, SoftAP starts for recovery.

+
+ Station + + +
+
+ Access Point + + +
+
+ Art-Net universes + + +
+ +

After save the device reboots into the selected mode. AP default: ArtNetDMX / artnetdmx @ 192.168.4.1

+
+
+ + +