The interface
image makes the device an “OPNpool Interface”. Besides monitoring the pool controller and providing access to a web interface, it provides automatic updates, network discovery and supports post mortem debugging.
The entry point of the code is app_main()
in the file main/main.c
.
app_main()
starts by initializing devices and connecting to the WiFi network. The interface appplication is split over different tasks that communicate over message queues offered by the Free Real Time Operating System (Freitas).
-
factory_reset_task
-
pool_task
-
ota_update_task
-
http server
-
mqtt_task
-
hass_task
factory_reset_task
A factory reset erases the Wifi credentials and returns to the factory
app. This allows the device to be provisioned from scratch. To perform a Factory Reset, pull GPIO#0
low for five seconds, while the interface
app is running (not during boot up). The development board, has a boot
button) that services the same function. I
I (753) factory_reset_task: Waiting for 3 sec BOOT/RESET press .. W (26408) factory_reset_task: Factory reset ..
The code base uses the git submodule ESP32_factory-reset-task.
pool_task
The pool_task
forms the heart of OPNpool. It handles data received from the pool controller, and can send requests to the pool controller.
Its responsibilities are:
- Initially, it queues a few messages that request thermostat and scheduling information from the pool controller.
-
If it receives a message in the
to_pool_q
, it creates a corresponding network message. It then callsdatalink_tx_pkt_queue
to add the preamble and tail, and queues it for transmission through the rs485 driver. - Reads bytes from the rs485 driver to create a data packet. It passes this up the stack create a network message. Based on that it updates the pool state information and publishes it using the MQTT task.
The protocol stack is shown in the illustration below.
The following sections provide more information about the protocol stack.
RS-485 driver
Starting from the physical RS-485 bus, the signal passes through the MAX3485 transceiver that connects to the RX, TX and RTS pins of the ESP32 UART. This UART presents the signal as bytes to the driver.
The driver:
- Initializes the UART.
- Provides functions to read and write bytes.
-
Bytes to be transmitted are queued as as
rs485_q_msg
inrs485->tx_q
. When a transmit opportunity arrises, thepool_task
dequeues fromrs485->tx_q
, relies on the driver to transmits the bytes.
Transmitting on the RS-485 is a bit tricky because it operates in half-duplex and has multiple nodes on the same single twisted pair. In practice, the pool controller appears to only accept messages after it broadcasts its periodic status message, and before it communicates with the chlorinator. Therefore, outgoing bytes are queued, until the moment arrives where they can be transmitted
It communicates with the datalink layer using the rs485_handle_t
device handle.
typedef struct rs485_instance_t { rs485_available_fnc_t available; // bytes available in rx buffer rs485_read_bytes_fnc_t read_bytes; // read bytes from rx buffer rs485_write_bytes_fnc_t write_bytes; // write bytes to tx buffer rs485_flush_fnc_t flush; // wait until all bytes are transmitted rx485_tx_mode_fnc_t tx_mode; // controls RTS pin (for half-duplex) rs485_queue_fnc_t queue; // queue to handle->tx_q rs485_dequeue_fnc_t dequeue; // dequeue from handle->tx_q QueueHandle_t tx_q; // transmit queue } rs485_instance_t; typedef struct rs485_instance_t * rs485_handle_t;
Datalink layer
Provides framing and byte error detection. The interface with the network layers is through datalink_pkt_t
.
It supports both the A5 and IC protocol.
It communicates with the network layer using the datalink_pkt_t
structure.
typedef struct datalink_pkt_t { datalink_prot_t prot; // datalink_prot as detected by `_read_head` uint8_t prot_typ; // from datalink_hdr_a5->typ uint8_t src; // from datalink_hdr_a5->src uint8_t dst; // from datalink_hdr_a5->dst datalink_data_t * data; size_t data_len; skb_handle_t skb; } datalink_pkt_t;
Its functions are:
-
datalink_rx_pkt()
, receives bytes from the RS-485 bus and makes adatalink_pkt
from it. It is implemented as a state machine, using socket buffers to store the bytes received. -
datalink_tx_pkt()
, receives adatalink_pkt
and wraps it with a header and tailer. It then queues it for transmission on the RS-485 bus. Once there is a transmit opportunity, thepool_task
send it using the rs485 driver.
Network layer
This layer translates between datalink_pkt
and the protocol-agnostic network_msg
.
It communicates with the `poolstate` layer using the network_msg_t
structure.
typedef struct network_msg_t { network_msg_typ_t typ; union { network_msg_pump_reg_set_t * pump_reg_set; network_msg_pump_reg_resp_t * pump_reg_set_resp; network_msg_pump_ctrl_t * pump_ctrl; network_msg_pump_mode_t * pump_mode; network_msg_pump_run_t * pump_run; network_msg_pump_status_resp_t * pump_status_resp; network_msg_ctrl_set_ack_t * ctrl_set_ack; network_msg_ctrl_circuit_set_t * ctrl_circuit_set; network_msg_ctrl_sched_resp_t * ctrl_sched_resp; network_msg_ctrl_state_t * ctrl_state; network_msg_ctrl_time_t * ctrl_time; network_msg_ctrl_heat_t * ctrl_heat; network_msg_ctrl_heat_set_t * ctrl_heat_set; network_msg_ctrl_layout_t * ctrl_layout; network_msg_ctrl_layout_set_t * ctrl_layout_set; network_msg_chlor_ping_req_t * chlor_ping_req; network_msg_chlor_ping_resp_t * chlor_ping; network_msg_chlor_name_t * chlor_name; network_msg_chlor_level_set_t * chlor_level_set; network_msg_chlor_level_resp_t * chlor_level_resp; uint8_t * bytes; } u; } network_msg_t;
Its public functions are:
-
network_rx_msg()
, translates adatalink_pkt
writes it asnetwork_msg
. -
network_create_msg()
, creates adatalink_pkt
from anetwork_msg
.
Poolstate
The sysState_t
structure stores the state of the pool system. It is updated based on status messages from the controller, and by querying the controller. Once the controller replied to these queries, pool()
starts honoring commands from the Serial port connected to the computer.
ota_update_task
This is the same git submodule that we used for Factory application earlier.
Each time the device powers up, it checks to see if an new interface
app is available. If so, it will download the image and switches to the new version. [doc] To trigger an OTA update, power cycle the device, or press the EN
button.
When the new interface
app has critical errors, a rollback mechanism keep the device working by automatically rolling back to the previous working application.
After the app is downloaded using OTA, it is marked as pending_verify
. Once the application determines that it is working well, it marks it as valid
. On the other hand, when the application marks it as invalid
or the device resets, the bootloader will boot the previous working application. [doc]
http server
Albeit not technically a task, we’ll discuss it here.
The HTTP server provides an UI to see the pool state and control a few settings.
The main_task
uses the git module wifi_connect to establish a connection to the WiFi access point. Once established, it calls httpd_register_handlers
to register the URI handlers for incoming GET requests.
These handlers are:
-
httpd_root
, replies toGET /
-
httpd_ico
“, replies toGET /favicon.ico
-
httpd_json
, replies toGET /json
httpd_root()
To save memory space on the ESP32, the device returns a minimum HTML page. The body part is replaced by the replace_body.js
script.
httpd_ico()
The browser places the favicon next to the page’s title on the tab. This function simply sends an HTTP response containing the favicon.
httpd_json()
The Arduino replies to any command with the pool state information formatted as JSON. See illustration further down.
If query variables were passed, they will be send to the ipc->to_pool_q
. The pool_task
will create a corresponding message and send it to the pool controller.
The following query variables are supported:
homeassistant/switch/opnpool/pool_circuit/set homeassistant/switch/opnpool/spa_circuit/set homeassistant/switch/opnpool/aux1_circuit/set homeassistant/switch/opnpool/aux2_circuit/set homeassistant/switch/opnpool/aux3_circuit/set homeassistant/switch/opnpool/ft1_circuit/set homeassistant/switch/opnpool/ft2_circuit/set homeassistant/switch/opnpool/ft3_circuit/set homeassistant/switch/opnpool/ft4_circuit/set homeassistant/climate/opnpool/pool_heater/set_mode homeassistant/climate/opnpool/pool_heater/set_tmp homeassistant/climate/opnpool/spa_heater/set_mode homeassistant/climate/opnpool/spa_heater/set_tmp
For the switches, it supports the values ON
and OFF
. For a thermostat (climate) mode, it supports the values off
, heat
, solar_pref
and solar
. For the thermostat temperature it accept a value in Fahrenheit.
Example
For details on web integration, refer to the Web UI section.
mqtt_task
IBM’s MQ telemetry transport (MQTT) is a lightweight, publish-subscribe network protocol that transports messages between devices. The keyword is telemetry, or remote monitoring.
MQTT is rapidly becoming the prevailed protocols to connect large numbers of devices. The mqtt_task
implements an MQTT client.
Responsibilities of the task:
- Connect to MQTT broker.
- Initially, check if there is a coredump in flash memory, then publish it to MQTT.
- Subscribe to control topics, for diagnostic commands.
- Subscribing or publishing on MQTT topics on behalf of other processes.
Connect to MQTT broker
The code calls esp_mqtt_client_init
and waits until the MQTT connection with the broker is established. It learns about a successful connection when it the callback function receives a MQTT_EVENT_CONNECTED
.
If the connection were to be broken, the ESP IDF SDK will automatically reconnect to the broker.
Coredump to MQTT
As the devices that are deployed in the field, they have no serial monitor connected. Instead, we configure the ESP-IDF so the coredump is written to flash memory. Next time the ESP32 boots up, and it finds a coredump in flash, it uses the git submodule ESP32_coredump-to-server
to publish the coredump to MQTT under the topic opnpool/data/coredump/ID
.
Subscribe to control topics
Initially, the device asks MQTT task to subscribe to the topics listed below. Here ID
is an unique device identifier.
opnpool/ctrl opnpool/ctrl/ID
When another devices publishes a message on these topics, the MQTT task will handle the message. It supports the following values:
who
, request device info such as device id, firmware version, WiFi details, MQTT details and heaprestart
, restarts the devicehtstart
, starts heap trace (when enabled)htstop
, stops heap trace (when enabled)
When the MQTT task receives a message on these topics, it will take action and publish a reply to pool/data/ID/
as shown below.
Subscribe/publish on behalf of others
The hass_task
queues a message to ipc->to_mqtt_q
in order to subscribe or publish on a topic. The mqtt_task
forwards this request to the ESP-IDF SDK.
When subscribing, the topic is also added to the _subscribers
list, in case the connection with the broker needs to get re-established.
When another MQTT client publishes on a topic that another process subscribed to, the message is forwarded to the ipc->to_pool_q
.
hass_task
Home Assistant is an open source home automation that puts local control and privacy first.
The hass_task’s duties are
- Listen for commands over MQTT.
- Support MQTT device discovery.
Listen for commands over MQTT
Initially, the device subscribes to the MQTT topics that lets other MQTT clients change pool controller settings such as thermostat set point or heating mode.
It subscribes to the topics listed below.
homeassistant/switch/opnpool/pool_circuit/set homeassistant/switch/opnpool/spa_circuit/set homeassistant/switch/opnpool/aux1_circuit/set homeassistant/switch/opnpool/aux2_circuit/set homeassistant/switch/opnpool/aux3_circuit/set homeassistant/switch/opnpool/ft1_circuit/set homeassistant/switch/opnpool/ft2_circuit/set homeassistant/switch/opnpool/ft3_circuit/set homeassistant/switch/opnpool/ft4_circuit/set homeassistant/climate/opnpool/pool_heater/set_mode homeassistant/climate/opnpool/pool_heater/set_tmp homeassistant/climate/opnpool/spa_heater/set_mode homeassistant/climate/opnpool/spa_heater/set_tmp
When another devices publishes a message on these topics, the MQTT task will forward forward it as a network message to the Pool task. The latter will call hass_tx_pair
to handle the message.
For the switches, it supports the values ON
and OFF
. For a thermostat (climate) mode, it supports the values off
, heat
, solar_pref
and solar
. For the thermostat temperature it accept a value in Fahrenheit.
MQTT device discovery
We auto populate the Home Assistant entities using MQTT Device Discovery. This enables us to use MQTT devices with only minimal configuration effort on the side of Home Assistant.
Every five minute, the device publishes MQTT discovery messages, to announce its presence to Home Assistant.
Some examples of these configuration topics are:
topic = homeassistant/sensor/opnpool/air_temp/config; value = { "~": "homeassistant/sensor/opnpool/air_temp", "name": "Pool air temp", "stat_t": "~/state", "unit_of_meas": "°F" }
topic = homeassistant/climate/opnpool/pool_heater/config; value = { "~": "homeassistant/climate/opnpool/pool_heater", "name": "Pool pool heater", "mode_cmd_t": "~/set_mode", "temp_cmd_t": "~/set_temp", "mode_stat_tpl": "{{ value_json.mode }}", "mode_stat_t": "~/state", "temp_stat_tpl": "{{ value_json.target_temp }}", "temp_stat_t": "~/state", "curr_temp_t": "~/state", "curr_temp_tpl": "{{ value_json.current_temp }}", "min_temp": 15, "max_temp": 110, "temp_step": 1, "temp_unit": "F", "avty_t": "~/available", "pl_avail": "online", "pl_not_avail": "offline", "modes": ["off","heat"] }
For details on Home Assistant integration, refer to the corresponding chapter.
From there the device publishes the data as e.g.
homeassistant/sensor/opnpool/air_temp/state 55 homeassistant/climate/opnpool/pool_heater/state {"mode":"off","target_temp":0,"current_temp":0} homeassistant/climate/opnpool/pool_heater/available online