commit 2212913cd085a644c054bed67e1d531be7e4a24c Author: osiu97 Date: Mon Jan 5 11:17:19 2026 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1cc9853 --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# Build output (in-repo) +build/ + +# Other common build dirs +cmake-build-*/ +out/ +dist/ + +# CMake/Ninja generated files (in case you build in-tree elsewhere) +CMakeCache.txt +CMakeFiles/ +cmake_install.cmake +install_manifest.txt +compile_commands.json +*.ninja +.ninja_deps +.ninja_log + +# Pico SDK / embedded build artifacts +*.elf +*.uf2 +*.bin +*.hex +*.map +*.dis +*.lst +*.sym + +# Object/dependency archives +*.o +*.a +*.d +*.su + +# Logs and crash dumps +*.log +core +core.* + +# IDE/editor +.vscode/ +.idea/ +*.code-workspace +*.swp +*.swo +*~ +.DS_Store +Thumbs.db diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..0627554 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,68 @@ +# Generated Cmake Pico project file + +cmake_minimum_required(VERSION 3.13) + +set(CMAKE_C_STANDARD 11) +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +# Initialise pico_sdk from installed location +# (note this can come from environment, CMake cache etc) + +# == DO NOT EDIT THE FOLLOWING LINES for the Raspberry Pi Pico VS Code Extension to work == +if(WIN32) + set(USERHOME $ENV{USERPROFILE}) +else() + set(USERHOME $ENV{HOME}) +endif() +set(sdkVersion 2.2.0) +set(toolchainVersion 14_2_Rel1) +set(picotoolVersion 2.2.0-a4) +set(picoVscode ${USERHOME}/.pico-sdk/cmake/pico-vscode.cmake) +if (EXISTS ${picoVscode}) + include(${picoVscode}) +endif() +# ==================================================================================== +set(PICO_BOARD pico_w CACHE STRING "Board type") + +# Pull in Raspberry Pi Pico SDK (must be before project) +include(pico_sdk_import.cmake) + +project(pico-usb-host-modbus-hopi C CXX ASM) + +# Initialise the Raspberry Pi Pico SDK +pico_sdk_init() + +# Add executable. Default name is the project name, version 0.1 + +add_executable(pico-usb-host-modbus-hopi + pico-usb-host-modbus-hopi.c + modbus_rtu.c +) + +pico_set_program_name(pico-usb-host-modbus-hopi "pico-usb-host-modbus-hopi") +pico_set_program_version(pico-usb-host-modbus-hopi "0.1") + +# Modify the below lines to enable/disable output over UART/USB +pico_enable_stdio_uart(pico-usb-host-modbus-hopi 1) +pico_enable_stdio_usb(pico-usb-host-modbus-hopi 0) + +# Add the standard library to the build +target_link_libraries(pico-usb-host-modbus-hopi + pico_stdlib + pico_stdio_uart + hardware_uart + tinyusb_host + tinyusb_board + pico_cyw43_arch_lwip_threadsafe_background + pico_lwip_mqtt) + +# Add the standard include files to the build +target_include_directories(pico-usb-host-modbus-hopi PRIVATE + ${CMAKE_CURRENT_LIST_DIR} +) + + + +pico_add_extra_outputs(pico-usb-host-modbus-hopi) + diff --git a/README.md b/README.md new file mode 100644 index 0000000..404a50e --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +## TODO +- NTP +- HTTP with data + timestamp +- Push to MQTT +- Log to file? \ No newline at end of file diff --git a/lwiopts.h b/lwiopts.h new file mode 100644 index 0000000..9dad61b --- /dev/null +++ b/lwiopts.h @@ -0,0 +1,64 @@ +#ifndef LWIPOPTS_H +#define LWIPOPTS_H + +#include + +// Minimal lwIP configuration for Pico W (CYW43) with DHCP. +// This is intentionally small: we only need basic IPv4 + DHCP to get an address. + +// No RTOS +#define NO_SYS 1 + +// Memory options +#define MEM_LIBC_MALLOC 0 +#define MEM_ALIGNMENT 4 +#define MEM_SIZE (16 * 1024) + +// Pbuf options +#define PBUF_POOL_SIZE 24 +#define PBUF_POOL_BUFSIZE 1700 + +// Link layer +#define LWIP_ARP 1 +#define LWIP_ETHERNET 1 + +// IP options +#define LWIP_IPV4 1 +#define LWIP_IPV6 0 +#define IP_REASSEMBLY 0 +#define IP_FRAG 0 + +// DHCP (client) +#define LWIP_DHCP 1 + +// Increase timeout slots (DHCP + DNS + TCP + MQTT can consume several; retries can amplify this). +#define MEMP_NUM_SYS_TIMEOUT 24 + +// Optional helpers (kept off unless needed) +#define LWIP_DNS 1 +#define LWIP_UDP 1 +#define LWIP_TCP 1 + +// MQTT needs an output ring buffer large enough for topic+payload. +// Default (256) is too small for our JSON payload and results in ERR_MEM (-1). +#define MQTT_OUTPUT_RINGBUF_SIZE 1024 + +// Sockets / netconn APIs not used +#define LWIP_SOCKET 0 +#define LWIP_NETCONN 0 + +// Checksums +#define CHECKSUM_GEN_IP 1 +#define CHECKSUM_GEN_UDP 1 +#define CHECKSUM_GEN_TCP 1 +#define CHECKSUM_CHECK_IP 1 +#define CHECKSUM_CHECK_UDP 1 +#define CHECKSUM_CHECK_TCP 1 + +// Stats disabled +#define LWIP_STATS 0 + +// Debug disabled +#define LWIP_DEBUG 0 + +#endif // LWIPOPTS_H diff --git a/lwipopts.h b/lwipopts.h new file mode 100644 index 0000000..1fc65b3 --- /dev/null +++ b/lwipopts.h @@ -0,0 +1,5 @@ +#pragma once + +// lwIP expects this exact filename: lwipopts.h +// Keep the actual configuration in lwiopts.h (existing file in this repo). +#include "lwiopts.h" diff --git a/modbus_rtu.c b/modbus_rtu.c new file mode 100644 index 0000000..69f3b29 --- /dev/null +++ b/modbus_rtu.c @@ -0,0 +1,144 @@ +#include "modbus_rtu.h" + +#include + +// Standard Modbus RTU CRC16 (poly 0xA001) +uint16_t modbus_rtu_crc16(const uint8_t *data, size_t len) { + uint16_t crc = 0xFFFF; + for (size_t i = 0; i < len; i++) { + crc ^= data[i]; + for (int j = 0; j < 8; j++) { + if (crc & 1) { + crc = (uint16_t)((crc >> 1) ^ 0xA001); + } else { + crc >>= 1; + } + } + } + return crc; +} + +uint16_t modbus_rtu_get_u16_be(const uint8_t *p) { + return (uint16_t)(((uint16_t)p[0] << 8) | (uint16_t)p[1]); +} + +void modbus_rtu_master_init(modbus_rtu_master_t *m, modbus_rtu_master_config_t cfg) { + memset(m, 0, sizeof(*m)); + m->cfg = cfg; +} + +size_t modbus_rtu_build_read_holding(uint8_t *out, size_t out_cap, + uint8_t slave_id, uint16_t start_addr, uint16_t quantity, + bool disable_crc) { + // Request: [id][0x03][start hi][start lo][qty hi][qty lo][crc lo][crc hi] + const size_t base_len = 6; + const size_t crc_len = disable_crc ? 0 : 2; + if (out_cap < base_len + crc_len) return 0; + + out[0] = slave_id; + out[1] = 0x03; + out[2] = (uint8_t)(start_addr >> 8); + out[3] = (uint8_t)(start_addr & 0xFF); + out[4] = (uint8_t)(quantity >> 8); + out[5] = (uint8_t)(quantity & 0xFF); + + if (!disable_crc) { + uint16_t crc = modbus_rtu_crc16(out, base_len); + out[6] = (uint8_t)(crc & 0xFF); + out[7] = (uint8_t)(crc >> 8); + } + + return base_len + crc_len; +} + +void modbus_rtu_feed(modbus_rtu_master_t *m, const uint8_t *data, size_t len) { + // Simple append with truncation on overflow. + size_t space = sizeof(m->rx_buf) - m->rx_len; + if (len > space) len = space; + if (len == 0) return; + memcpy(&m->rx_buf[m->rx_len], data, len); + m->rx_len += len; +} + +static void drop_prefix(modbus_rtu_master_t *m, size_t n) { + if (n == 0 || n > m->rx_len) return; + memmove(m->rx_buf, m->rx_buf + n, m->rx_len - n); + m->rx_len -= n; +} + +bool modbus_rtu_try_parse_read_holding_response(modbus_rtu_master_t *m, + uint8_t expected_slave_id, + uint16_t expected_quantity, + size_t *out_data_offset, + uint16_t *out_word_count) { + // Response: [id][0x03][byte_count][data...][crc lo][crc hi] + // Some devices always include CRC but may compute it incorrectly. + // If m->cfg.disable_crc==true, we do NOT validate CRC, but we still accept frames that include CRC bytes. + // We also accept CRC-less frames only when m->cfg.disable_crc==true. + const size_t min_header = 3; + + // Try to resync if buffer has junk: look for [id][0x03]. + while (m->rx_len >= 2) { + if (m->rx_buf[0] == expected_slave_id && m->rx_buf[1] == 0x03) break; + drop_prefix(m, 1); + } + + if (m->rx_len < min_header) return false; + + uint8_t byte_count = m->rx_buf[2]; + if ((byte_count % 2) != 0) { + // invalid, resync + drop_prefix(m, 1); + return false; + } + + const uint16_t word_count = (uint16_t)(byte_count / 2); + if (word_count != expected_quantity) { + // Could be a different request/response; resync minimally. + // (You can relax this if you later add more Modbus queries.) + drop_prefix(m, 1); + return false; + } + + const size_t data_len = (size_t)3 + (size_t)byte_count; + if (m->rx_len < data_len) return false; + + // Prefer CRC-framed response when present. + const bool has_crc = (m->rx_len >= (data_len + 2)); + const size_t total_len = has_crc ? (data_len + 2) : data_len; + + if (has_crc && !m->cfg.disable_crc) { + uint16_t got_crc = (uint16_t)((uint16_t)m->rx_buf[total_len - 2] | ((uint16_t)m->rx_buf[total_len - 1] << 8)); + uint16_t calc_crc = modbus_rtu_crc16(m->rx_buf, total_len - 2); + if (got_crc != calc_crc) { + drop_prefix(m, 1); + return false; + } + } + + if (!has_crc && !m->cfg.disable_crc) { + // In strict mode we require CRC bytes to be present. + return false; + } + + *out_data_offset = 3; + *out_word_count = word_count; + + // Leave data in buffer for caller to consume; caller should drop_prefix(total_len) afterwards. + return true; +} + +float modbus_float_dcba(uint16_t w1, uint16_t w2) { + // Matches your ESPHome lambdas: + // b0=low(w2), b1=high(w2), b2=low(w1), b3=high(w1) + // u = b0<<24 | b1<<16 | b2<<8 | b3 + uint8_t b0 = (uint8_t)(w2 & 0xFF); + uint8_t b1 = (uint8_t)((w2 >> 8) & 0xFF); + uint8_t b2 = (uint8_t)(w1 & 0xFF); + uint8_t b3 = (uint8_t)((w1 >> 8) & 0xFF); + + uint32_t u = ((uint32_t)b0 << 24) | ((uint32_t)b1 << 16) | ((uint32_t)b2 << 8) | (uint32_t)b3; + float f; + memcpy(&f, &u, sizeof(f)); + return f; +} diff --git a/modbus_rtu.h b/modbus_rtu.h new file mode 100644 index 0000000..8c5fd77 --- /dev/null +++ b/modbus_rtu.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include +#include + +// Simple Modbus RTU master focused on function 0x03 (Read Holding Registers). +// Transport is byte-stream oriented (USB-serial in this project). + +typedef struct { + uint8_t slave_id; + bool disable_crc; +} modbus_rtu_master_config_t; + +typedef struct { + modbus_rtu_master_config_t cfg; + + // RX accumulation + uint8_t rx_buf[256]; + size_t rx_len; + + // Pending request tracking + bool awaiting_response; + uint32_t request_started_ms; + uint16_t expected_byte_count; +} modbus_rtu_master_t; + +void modbus_rtu_master_init(modbus_rtu_master_t *m, modbus_rtu_master_config_t cfg); + +// Builds a Read Holding Registers (0x03) request. +// Returns frame length. +size_t modbus_rtu_build_read_holding(uint8_t *out, size_t out_cap, + uint8_t slave_id, uint16_t start_addr, uint16_t quantity, + bool disable_crc); + +// Feed received bytes into the Modbus parser. +void modbus_rtu_feed(modbus_rtu_master_t *m, const uint8_t *data, size_t len); + +// Returns true when a full response for function 0x03 is present in rx_buf. +// If true, `*out_data_offset` points to first data byte, `*out_word_count` is number of 16-bit registers. +bool modbus_rtu_try_parse_read_holding_response(modbus_rtu_master_t *m, + uint8_t expected_slave_id, + uint16_t expected_quantity, + size_t *out_data_offset, + uint16_t *out_word_count); + +uint16_t modbus_rtu_get_u16_be(const uint8_t *p); +uint16_t modbus_rtu_crc16(const uint8_t *data, size_t len); + +float modbus_float_dcba(uint16_t w1, uint16_t w2); diff --git a/mqtt_config.h b/mqtt_config.h new file mode 100644 index 0000000..520f668 --- /dev/null +++ b/mqtt_config.h @@ -0,0 +1,41 @@ +#pragma once + +// MQTT settings (no TLS) +// +// If you use a hostname, DNS must work on your network. +// For simplest setup, use an IPv4 string like "192.168.1.10". + +#ifndef MQTT_BROKER_HOST +#define MQTT_BROKER_HOST "172.29.0.179" +#endif + +#ifndef MQTT_BROKER_PORT +#define MQTT_BROKER_PORT 8883 +#endif + +#ifndef MQTT_CLIENT_ID +#define MQTT_CLIENT_ID "pico-usb-host-modbus" +#endif + +// Publish topic for readings +#ifndef MQTT_TOPIC_READINGS +#define MQTT_TOPIC_READINGS "pico-usb-host-modbus/modbus/readings" +#endif + +// Optional auth (set to NULL if unused) +#ifndef MQTT_USERNAME +#define MQTT_USERNAME "ha" +#endif + +#ifndef MQTT_PASSWORD +#define MQTT_PASSWORD "rNgMmtrYtLjU4rdzbibo" +#endif + +// MQTT publish options +#ifndef MQTT_QOS +#define MQTT_QOS 0 +#endif + +#ifndef MQTT_RETAIN +#define MQTT_RETAIN 0 +#endif diff --git a/pico-usb-host-modbus-hopi.c b/pico-usb-host-modbus-hopi.c new file mode 100644 index 0000000..c34b97e --- /dev/null +++ b/pico-usb-host-modbus-hopi.c @@ -0,0 +1,575 @@ +#include +#include + +#include "pico/error.h" +#include "pico/cyw43_arch.h" +#include "pico/stdio_uart.h" +#include "pico/stdlib.h" +#include "tusb.h" + +#include "lwip/apps/mqtt.h" +#include "lwip/dns.h" +#include "lwip/ip4_addr.h" +#include "lwip/netif.h" +#include "lwip/timeouts.h" + +#include "modbus_rtu.h" + +#include "wifi_config.h" +#include "mqtt_config.h" + +// ---- User settings (mirror your ESPHome config) ---- +#define MODBUS_SLAVE_ID 1 +#define MODBUS_BAUDRATE 9600 +// Device expects a correct CRC in the query, but may respond with an incorrect CRC. +// So: TX includes CRC, RX ignores CRC validation. +#define MODBUS_DISABLE_CRC 1 +#define POLL_INTERVAL_MS 5000 +#define RESPONSE_TIMEOUT_MS 800 + +// We read holding registers 0..19 (20 regs) +#define READ_START_ADDR 0 +#define READ_QUANTITY 20 + +// We'll use UART0 for logs because RP2040 USB is used for host. +static void log_uart_init(void) { + // Note: these are GPIO numbers (GP16/GP17), not physical header pin numbers. + stdio_uart_init_full(uart0, 115200, 16, 17); + sleep_ms(50); +} + +// ---- Debug helpers ---- +#ifndef MODBUS_DEBUG +#define MODBUS_DEBUG 1 +#endif + +static void hexdump_line(const uint8_t *buf, size_t len, size_t max_len) { + const size_t n = (len < max_len) ? len : max_len; + for (size_t i = 0; i < n; i++) { + printf("%02X", buf[i]); + if (i + 1 != n) printf(" "); + } + if (len > n) printf(" ..."); +} + +static uint32_t ms_now(void) { + return to_ms_since_boot(get_absolute_time()); +} + +// ---- MQTT publisher (lwIP) ---- +static mqtt_client_t *g_mqtt = NULL; +static volatile bool g_mqtt_connected = false; +static ip_addr_t g_mqtt_broker_ip; +static volatile bool g_mqtt_dns_done = false; +static volatile bool g_mqtt_dns_ok = false; + +static const char *lwip_err_name(err_t err) { + switch (err) { + case ERR_OK: return "ERR_OK"; + case ERR_MEM: return "ERR_MEM"; + case ERR_BUF: return "ERR_BUF"; + case ERR_TIMEOUT: return "ERR_TIMEOUT"; + case ERR_RTE: return "ERR_RTE"; + case ERR_INPROGRESS: return "ERR_INPROGRESS"; + case ERR_VAL: return "ERR_VAL"; + case ERR_WOULDBLOCK: return "ERR_WOULDBLOCK"; + case ERR_USE: return "ERR_USE"; + case ERR_ALREADY: return "ERR_ALREADY"; + case ERR_ISCONN: return "ERR_ISCONN"; + case ERR_CONN: return "ERR_CONN"; + case ERR_IF: return "ERR_IF"; + case ERR_ABRT: return "ERR_ABRT"; + case ERR_RST: return "ERR_RST"; + case ERR_CLSD: return "ERR_CLSD"; + case ERR_ARG: return "ERR_ARG"; + default: return "ERR_?"; + } +} + +static const char *mqtt_status_name(mqtt_connection_status_t st) { + switch (st) { + case MQTT_CONNECT_ACCEPTED: return "ACCEPTED"; + case MQTT_CONNECT_REFUSED_PROTOCOL_VERSION: return "REFUSED_PROTOCOL"; + case MQTT_CONNECT_REFUSED_IDENTIFIER: return "REFUSED_ID"; + case MQTT_CONNECT_REFUSED_SERVER: return "REFUSED_SERVER"; + case MQTT_CONNECT_REFUSED_USERNAME_PASS: return "REFUSED_USERPASS"; + case MQTT_CONNECT_REFUSED_NOT_AUTHORIZED_: return "REFUSED_NOAUTH"; + case MQTT_CONNECT_DISCONNECTED: return "DISCONNECTED"; + case MQTT_CONNECT_TIMEOUT: return "TIMEOUT"; + default: return "UNKNOWN"; + } +} + +static void mqtt_request_cb(void *arg, err_t err) { + (void)arg; + if (err != ERR_OK) { + printf("MQTT: publish ack err=%d (%s)\n", (int)err, lwip_err_name(err)); + } +} + +static void mqtt_connection_cb(mqtt_client_t *client, void *arg, mqtt_connection_status_t status) { + (void)client; + (void)arg; + g_mqtt_connected = (status == MQTT_CONNECT_ACCEPTED); + printf("MQTT: connection status=%d (%s)\n", (int)status, mqtt_status_name(status)); +} + +static void mqtt_dns_found_cb(const char *name, const ip_addr_t *ipaddr, void *arg) { + (void)name; + (void)arg; + if (ipaddr) { + g_mqtt_broker_ip = *ipaddr; + g_mqtt_dns_ok = true; + } else { + g_mqtt_dns_ok = false; + } + g_mqtt_dns_done = true; +} + +static bool mqtt_resolve_broker(uint32_t timeout_ms) { + const char *host = MQTT_BROKER_HOST; + + if (ipaddr_aton(host, &g_mqtt_broker_ip)) { + return true; + } + + g_mqtt_dns_done = false; + g_mqtt_dns_ok = false; + ip_addr_set_any(IPADDR_TYPE_ANY, &g_mqtt_broker_ip); + + cyw43_arch_lwip_begin(); + ip_addr_t cached; + err_t derr = dns_gethostbyname(host, &cached, mqtt_dns_found_cb, NULL); + if (derr == ERR_OK) { + g_mqtt_broker_ip = cached; + g_mqtt_dns_ok = true; + g_mqtt_dns_done = true; + } else if (derr != ERR_INPROGRESS) { + g_mqtt_dns_ok = false; + g_mqtt_dns_done = true; + } + cyw43_arch_lwip_end(); + + const uint32_t start = ms_now(); + while (!g_mqtt_dns_done && (ms_now() - start) < timeout_ms) { + cyw43_arch_lwip_begin(); + sys_check_timeouts(); + cyw43_arch_lwip_end(); + sleep_ms(10); + } + + return g_mqtt_dns_done && g_mqtt_dns_ok; +} + +static void mqtt_init_and_connect(void) { + if (!g_mqtt) { + g_mqtt = mqtt_client_new(); + if (!g_mqtt) { + printf("MQTT: mqtt_client_new failed\n"); + return; + } + } + + if (!mqtt_resolve_broker(3000)) { + printf("MQTT: DNS failed for '%s'\n", MQTT_BROKER_HOST); + return; + } + + const struct mqtt_connect_client_info_t ci = { + .client_id = MQTT_CLIENT_ID, + .client_user = MQTT_USERNAME, + .client_pass = MQTT_PASSWORD, + .keep_alive = 60, + .will_topic = NULL, + .will_msg = NULL, + .will_msg_len = 0, + .will_qos = 0, + .will_retain = 0, + }; + + cyw43_arch_lwip_begin(); + err_t err = mqtt_client_connect(g_mqtt, &g_mqtt_broker_ip, (u16_t)MQTT_BROKER_PORT, + mqtt_connection_cb, NULL, &ci); + cyw43_arch_lwip_end(); + + if (err != ERR_OK) { + printf("MQTT: connect err=%d\n", (int)err); + } +} + +static void mqtt_publish_readings(float active_power_w, + float rms_current_a, + float voltage_v, + float frequency_hz, + float power_factor, + float annual_kwh, + float active_kwh, + float reactive_kwh, + float load_time_h, + uint16_t hours_day, + uint16_t device_addr) { + if (!g_mqtt_connected) { + // Best-effort reconnect. + mqtt_init_and_connect(); + if (!g_mqtt_connected) return; + } + + static char payload[320]; + const uint32_t uptime_ms = ms_now(); + const int n = snprintf(payload, sizeof(payload), + "{\"uptime_ms\":%lu,\"active_power_w\":%.2f,\"rms_current_a\":%.3f,\"voltage_v\":%.1f," + "\"frequency_hz\":%.2f,\"power_factor\":%.3f,\"annual_kwh\":%.3f,\"active_kwh\":%.3f," + "\"reactive_kwh\":%.3f,\"load_time_h\":%.2f,\"hours_day\":%u,\"device_addr\":%u}", + (unsigned long)uptime_ms, + active_power_w, + rms_current_a, + voltage_v, + frequency_hz, + power_factor, + annual_kwh, + active_kwh, + reactive_kwh, + load_time_h, + (unsigned)hours_day, + (unsigned)device_addr); + if (n <= 0 || (size_t)n >= sizeof(payload)) return; + + cyw43_arch_lwip_begin(); + err_t err = mqtt_publish(g_mqtt, MQTT_TOPIC_READINGS, + payload, (u16_t)n, + (u8_t)MQTT_QOS, (u8_t)MQTT_RETAIN, + mqtt_request_cb, NULL); + cyw43_arch_lwip_end(); + + if (err != ERR_OK) { + printf("MQTT: publish call err=%d (%s) payload_len=%d topic='%s'\n", + (int)err, lwip_err_name(err), n, MQTT_TOPIC_READINGS); + } +} + +static modbus_rtu_master_t g_mb; +static uint8_t g_cdc_idx = 0xFF; + +static uint32_t g_usb_rx_bytes_total = 0; +static uint32_t g_usb_rx_last_print_ms = 0; + +typedef enum { + APP_WAIT_USB = 0, + APP_IDLE, + APP_WAIT_RESPONSE, +} app_state_t; + +static app_state_t g_state = APP_WAIT_USB; +static uint32_t g_next_poll_ms = 0; + +static uint32_t wifi_backoff_ms(uint32_t attempt_1_based) { + // attempt=1 -> base, attempt=2 -> 2x, attempt=3 -> 4x, ... clamped. + uint32_t delay = WIFI_RETRY_BASE_DELAY_MS; + if (attempt_1_based > 1) { + const uint32_t shift = attempt_1_based - 1; + if (shift >= 31) { + delay = WIFI_RETRY_MAX_DELAY_MS; + } else { + uint32_t mult = 1u << shift; + if (mult == 0) mult = 0xFFFFFFFFu; + delay = WIFI_RETRY_BASE_DELAY_MS * mult; + } + } + if (delay > WIFI_RETRY_MAX_DELAY_MS) delay = WIFI_RETRY_MAX_DELAY_MS; + // Add small jitter (0..255ms) to avoid sync collisions. + delay += (time_us_32() & 0xFFu); + return delay; +} + +static bool wifi_wait_for_dhcp_ip(char *ip_buf, size_t ip_buf_len) { + absolute_time_t deadline = make_timeout_time_ms(WIFI_DHCP_TIMEOUT_MS); + while (absolute_time_diff_us(get_absolute_time(), deadline) > 0) { + bool has_ip = false; + if (ip_buf && ip_buf_len) ip_buf[0] = '\0'; + + cyw43_arch_lwip_begin(); + struct netif *netif = &cyw43_state.netif[CYW43_ITF_STA]; + const ip4_addr_t *ip = netif_ip4_addr(netif); + if (netif_is_up(netif) && ip && !ip4_addr_isany(ip)) { + if (ip_buf && ip_buf_len) { + snprintf(ip_buf, ip_buf_len, "%s", ip4addr_ntoa(ip)); + } + has_ip = true; + } + cyw43_arch_lwip_end(); + + if (has_ip) return true; + sleep_ms(200); + } + return false; +} + +static bool wifi_init_and_connect(void) { + int init_err = cyw43_arch_init_with_country(WIFI_COUNTRY); + if (init_err != 0) { + printf("WiFi init failed: %d\n", init_err); + return false; + } + + cyw43_arch_enable_sta_mode(); + + for (uint32_t attempt = 1; attempt <= WIFI_CONNECT_RETRIES; attempt++) { + + printf("WiFi: connecting to SSID '%s' (attempt %lu/%u)...\n", + WIFI_SSID, (unsigned long)attempt, (unsigned)WIFI_CONNECT_RETRIES); + int err = cyw43_arch_wifi_connect_timeout_ms( + WIFI_SSID, + WIFI_PASSWORD, + WIFI_AUTH, + WIFI_CONNECT_TIMEOUT_MS); + if (err != 0) { + if (err == PICO_ERROR_BADAUTH) { + printf("WiFi connect failed (bad auth): %d\n", err); + return false; + } + printf("WiFi connect failed: %d\n", err); + const uint32_t delay = wifi_backoff_ms(attempt); + printf("WiFi: retrying in %lu ms\n", (unsigned long)delay); + sleep_ms(delay); + continue; + } + + char ip_buf[16] = {0}; + if (!wifi_wait_for_dhcp_ip(ip_buf, sizeof(ip_buf))) { + printf("WiFi DHCP timeout (attempt %lu/%u)\n", + (unsigned long)attempt, (unsigned)WIFI_CONNECT_RETRIES); + const uint32_t delay = wifi_backoff_ms(attempt); + printf("WiFi: retrying in %lu ms\n", (unsigned long)delay); + sleep_ms(delay); + continue; + } + + printf("WiFi up. IP: %s\n", ip_buf); + + // Connect MQTT once at boot (best-effort). + mqtt_init_and_connect(); + return true; + } + + printf("WiFi: giving up after %u attempts\n", (unsigned)WIFI_CONNECT_RETRIES); + return false; +} + +static void start_poll(void) { + uint8_t frame[16]; + // Always include CRC on TX (your device requires it). + size_t n = modbus_rtu_build_read_holding(frame, sizeof(frame), MODBUS_SLAVE_ID, READ_START_ADDR, READ_QUANTITY, false); + if (n == 0) return; + +#if MODBUS_DEBUG + printf("Modbus TX (%u bytes): ", (unsigned)n); + hexdump_line(frame, n, 32); + printf("\n"); +#endif + + // Clear any stale RX bytes + g_mb.rx_len = 0; + + tuh_cdc_write(g_cdc_idx, frame, (uint32_t)n); + tuh_cdc_write_flush(g_cdc_idx); + + g_mb.awaiting_response = true; + g_mb.request_started_ms = ms_now(); + g_state = APP_WAIT_RESPONSE; +} + +static void print_decoded(const uint16_t regs[READ_QUANTITY]) { + float active_power_w = modbus_float_dcba(regs[0], regs[1]); + float rms_current_a = modbus_float_dcba(regs[2], regs[3]); + float voltage_v = modbus_float_dcba(regs[4], regs[5]); + float frequency_hz = modbus_float_dcba(regs[6], regs[7]); + float power_factor = modbus_float_dcba(regs[8], regs[9]); + float annual_kwh = modbus_float_dcba(regs[10], regs[11]); + float active_kwh = modbus_float_dcba(regs[12], regs[13]); + float reactive_kwh = modbus_float_dcba(regs[14], regs[15]); + float load_time_h = modbus_float_dcba(regs[16], regs[17]) / 60.0f; + uint16_t hours_day = regs[18]; + uint16_t device_addr = regs[19]; + + printf("Active Power: %.2f W\n", active_power_w); + printf("RMS Current: %.3f A\n", rms_current_a); + printf("Voltage: %.1f V\n", voltage_v); + printf("Frequency: %.2f Hz\n", frequency_hz); + printf("Power Factor: %.3f\n", power_factor); + printf("Annual Power Consumption: %.3f kWh\n", annual_kwh); + printf("Active Consumption: %.3f kWh\n", active_kwh); + printf("Reactive Consumption: %.3f kWh\n", reactive_kwh); + printf("Load Time: %.2f h\n", load_time_h); + printf("Work Hours Per Day: %u h\n", hours_day); + printf("Device Address: %u\n", device_addr); + printf("----\n"); + + // Publish once per refresh. + mqtt_publish_readings(active_power_w, + rms_current_a, + voltage_v, + frequency_hz, + power_factor, + annual_kwh, + active_kwh, + reactive_kwh, + load_time_h, + hours_day, + device_addr); +} + +static void app_task(void) { + const uint32_t now = ms_now(); + + if (g_state == APP_IDLE) { + if ((int32_t)(now - g_next_poll_ms) >= 0) { + start_poll(); + g_next_poll_ms = now + POLL_INTERVAL_MS; + } + return; + } + + if (g_state == APP_WAIT_RESPONSE) { + // Non-blocking receive: accumulate whatever is available. + while (tuh_cdc_read_available(g_cdc_idx)) { + uint8_t tmp[64]; + uint32_t n = tuh_cdc_read(g_cdc_idx, tmp, sizeof(tmp)); + if (n == 0) break; + +#if MODBUS_DEBUG + g_usb_rx_bytes_total += n; + // Print at most every 100ms to avoid flooding UART. + if ((now - g_usb_rx_last_print_ms) > 100) { + printf("USB RX chunk (%lu bytes), total=%lu: ", (unsigned long)n, (unsigned long)g_usb_rx_bytes_total); + hexdump_line(tmp, (size_t)n, 32); + printf("\n"); + printf("Modbus RX buf len=%u: ", (unsigned)g_mb.rx_len); + hexdump_line(g_mb.rx_buf, g_mb.rx_len, 32); + printf("\n"); + g_usb_rx_last_print_ms = now; + } +#endif + + modbus_rtu_feed(&g_mb, tmp, (size_t)n); + } + + size_t data_off = 0; + uint16_t word_count = 0; + + if (modbus_rtu_try_parse_read_holding_response(&g_mb, MODBUS_SLAVE_ID, READ_QUANTITY, &data_off, &word_count)) { +#if MODBUS_DEBUG + printf("Modbus: parsed response (word_count=%u, data_off=%u, rx_len=%u)\n", + (unsigned)word_count, (unsigned)data_off, (unsigned)g_mb.rx_len); +#endif + uint16_t regs[READ_QUANTITY]; + memset(regs, 0, sizeof(regs)); + + // Data is big-endian 16-bit registers. + const uint8_t *p = &g_mb.rx_buf[data_off]; + for (uint16_t i = 0; i < word_count && i < READ_QUANTITY; i++) { + regs[i] = modbus_rtu_get_u16_be(&p[i * 2]); + } + + print_decoded(regs); + + // Drop the parsed frame from RX buffer + const size_t crc_len = MODBUS_DISABLE_CRC ? 0 : 2; + const size_t total_len = 3 + (size_t)(READ_QUANTITY * 2) + crc_len; + // (Reuse internal helper behavior by just resetting for single outstanding request) + g_mb.rx_len = 0; + + g_state = APP_IDLE; + return; + } + + if ((now - g_mb.request_started_ms) > RESPONSE_TIMEOUT_MS) { +#if MODBUS_DEBUG + const uint32_t age = now - g_mb.request_started_ms; + printf("Modbus timeout (no valid response) age=%lu ms, usb_total=%lu, rx_len=%u\n", + (unsigned long)age, (unsigned long)g_usb_rx_bytes_total, (unsigned)g_mb.rx_len); + if (g_mb.rx_len) { + printf("Modbus RX buf: "); + hexdump_line(g_mb.rx_buf, g_mb.rx_len, 64); + printf("\n"); + } + printf("----\n"); +#else + printf("Modbus timeout (no valid response)\n----\n"); +#endif + g_mb.rx_len = 0; + g_state = APP_IDLE; + return; + } + } +} + +// TinyUSB Host CDC callbacks +void tuh_cdc_mount_cb(uint8_t idx) { + g_cdc_idx = idx; + + // Configure UART settings on the adapter (supported by ACM + CH34x; some other chips may vary). + cdc_line_coding_t lc = { + .bit_rate = MODBUS_BAUDRATE, + .stop_bits = CDC_LINE_CODING_STOP_BITS_1, + .parity = CDC_LINE_CODING_PARITY_NONE, + .data_bits = 8, + }; + + tuh_cdc_set_line_coding(idx, &lc, NULL, 0); + tuh_cdc_connect(idx, NULL, 0); // assert DTR/RTS + +#if MODBUS_DEBUG + printf("USB-serial mounted (cdc idx=%u) line=%lu %u%c%u\n", + idx, + (unsigned long)lc.bit_rate, + (unsigned)lc.data_bits, + (lc.parity == CDC_LINE_CODING_PARITY_NONE) ? 'N' : + (lc.parity == CDC_LINE_CODING_PARITY_ODD) ? 'O' : + (lc.parity == CDC_LINE_CODING_PARITY_EVEN) ? 'E' : '?', + (lc.stop_bits == CDC_LINE_CODING_STOP_BITS_1) ? 1 : 2); +#else + printf("USB-serial mounted (cdc idx=%u)\n", idx); +#endif + g_state = APP_IDLE; + g_next_poll_ms = ms_now(); +} + +void tuh_cdc_umount_cb(uint8_t idx) { + if (g_cdc_idx == idx) g_cdc_idx = 0xFF; + printf("USB-serial unmounted (cdc idx=%u)\n", idx); + g_state = APP_WAIT_USB; +} + +void tuh_cdc_rx_cb(uint8_t idx) { + // We also poll in app_task(); keep this empty to avoid duplicating logic. + (void)idx; +} + +int main(void) { + log_uart_init(); + printf("pico_usb_modbus_host starting...\n"); + + // Optional: Wi-Fi STA + DHCP (Pico W) + // If this fails, we keep running the USB host application. + (void)wifi_init_and_connect(); + + modbus_rtu_master_config_t cfg = { + .slave_id = MODBUS_SLAVE_ID, + .disable_crc = (MODBUS_DISABLE_CRC != 0), + }; + modbus_rtu_master_init(&g_mb, cfg); + + // Initialize TinyUSB host stack (RP2040 USB controller as host) + tuh_init(0); + + while (true) { + tuh_task(); + + if (g_state != APP_WAIT_USB && g_cdc_idx != 0xFF && tuh_cdc_mounted(g_cdc_idx)) { + app_task(); + } + + // Keep loop responsive. + sleep_ms(1); + } +} 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/tusb_config.h b/tusb_config.h new file mode 100644 index 0000000..9e0f3ba --- /dev/null +++ b/tusb_config.h @@ -0,0 +1,45 @@ +#pragma once + +// TinyUSB configuration for RP2040 in HOST mode. +// This targets USB-serial adapters, including WCH CH34x (CH340/CH341). + +#include "tusb_option.h" + +#ifdef __cplusplus +extern "C" { +#endif + +//------------- COMMON ------------- +#define CFG_TUSB_MCU OPT_MCU_RP2040 +#define CFG_TUSB_OS OPT_OS_PICO + +// Use RP2040 USB controller as host (FS) +#define CFG_TUSB_RHPORT0_MODE (OPT_MODE_HOST | OPT_MODE_FULL_SPEED) + +//------------- HOST ------------- +#define CFG_TUH_ENABLED 1 + +// Small but sufficient for CDC-serial +#define CFG_TUH_ENUMERATION_BUFSIZE 256 + +// We only need CDC in this project +#define CFG_TUH_HID 0 +#define CFG_TUH_MSC 0 +#define CFG_TUH_VENDOR 0 + +// Enable 1 CDC interface +#define CFG_TUH_CDC 1 + +// CDC buffers (host side) +#define CFG_TUH_CDC_RX_BUFSIZE 512 +#define CFG_TUH_CDC_TX_BUFSIZE 512 + +// USB-UART chip support under TinyUSB host CDC +#define CFG_TUH_CDC_CH34X 1 +#define CFG_TUH_CDC_CP210X 0 +#define CFG_TUH_CDC_FTDI 0 +#define CFG_TUH_CDC_PL2303 0 + +#ifdef __cplusplus +} +#endif diff --git a/wifi_config.h b/wifi_config.h new file mode 100644 index 0000000..87f7378 --- /dev/null +++ b/wifi_config.h @@ -0,0 +1,39 @@ +#pragma once + +// Wi-Fi client configuration (Pico W / CYW43) +// +// NOTE: These are example credentials. +// Change them to match your Wi-Fi network. + +#define WIFI_SSID "729_IoT" +#define WIFI_PASSWORD "DiU0w1V3z4tNscfa" + +// Authentication mode for cyw43_arch_wifi_connect_timeout_ms() +// Common values: +// - CYW43_AUTH_OPEN +// - CYW43_AUTH_WPA2_AES_PSK +#define WIFI_AUTH CYW43_AUTH_WPA2_AES_PSK + +// Optional regulatory domain; set this if your AP uses channels 12/13 (common in EU) +// Examples: CYW43_COUNTRY_POLAND, CYW43_COUNTRY_GERMANY, CYW43_COUNTRY_USA +#ifndef WIFI_COUNTRY +#define WIFI_COUNTRY CYW43_COUNTRY_USA +#endif + +// Timeouts +#define WIFI_CONNECT_TIMEOUT_MS 30000 +#define WIFI_DHCP_TIMEOUT_MS 30000 + +// Retry strategy +#ifndef WIFI_CONNECT_RETRIES +#define WIFI_CONNECT_RETRIES 5 +#endif + +// Base delay after a failed attempt; actual delay is exponential backoff +#ifndef WIFI_RETRY_BASE_DELAY_MS +#define WIFI_RETRY_BASE_DELAY_MS 500 +#endif + +#ifndef WIFI_RETRY_MAX_DELAY_MS +#define WIFI_RETRY_MAX_DELAY_MS 10000 +#endif