Inspiration
Nowadays, The Weather forecast depends mostly on weather balloons which could not provide the local citizens precise weather data.We want to design a local weather station that can provide the users a precise weather forecast and the users can also share their weather data to the cloud to calibrate the weather forecast. In addition, we also wanted to help the users who forget to take an umbrella with them when there is a sudden rain.
What it does
- The local weather improves forecasting for user's primary interest
- Network of IOT local weather stations can predict important events better
- Umbrella CheckOut system for users to loan items.
This project aims to measure important details of the weather such as temperature, humidity and windspeed locally to provide information that balloon based weather stations would not have, information local to a town or area.
How We built it
- Design the circuit schematic and determine the pin-out according to the datasheets
- Design the power system(voltage buck,voltage boost) for each modules
- Drew the PCB layout using Altium.
- confirmed parts and submitted design for construction.
- Construct the code for the communication between MQTT and the system.
- Analyzed the returned board for what systems actually work and what do not.
- Fixed what we could, got the power up and demonstrated many parts of the systems actually work.
- Wrote the software to bring up this system so that the board works
Challenges I ran into
Software
- How to download bin and txt files from the internet
- How to load the bin files in the SD card to the MCU
- How to do CRC32
- How to generate the ADC converter
- How to communicate the MQTT
- Many of the important systems after return from the manufacturer did not work as expected, so work arounds needed to be created.
Hardware
- Arranging the parts on the board properly in Altium
- Debug the board sent by PCBNG
Accomplishments that I'm proud of
- The system can successfully download the files from the internet and load the files to the MCU from the SD card.
- All the analog sensors are working properly. The users can get the wind speed, luminosity and temperature data.
- All though the RFID borrowing system is not working properly, we still can send mock data to the interface and the interface can show the data on the dashboard.
- Despite shortcomings, we were able to bring this board up so that essential features for the device working are in place.
What I learned
- We learnt to use Atmel studio to build and debug the program
- We learnt to use Altium to draw a PCB board
- We learnt Node Red to build the user interface and MQTT server.
- We learnt the debugging in software and hardware
What's next for WeatherStation
- Get SPI and I2C sensor to work
- The users can upload their weather data to the cloud for data calibration
- ML code on Server for improving forecasting
- More items for users to borrow.
Updates.
The design was good and ambitious but unfortunately many of the systems on the PCB did not work as expected. Some of the errors involved the power, generating 5 V and 3.3 volts, the serial lines for communication and the SPI lines for getting data from the SD card and from the devices. Board bring up involved getting as many of the hardware and software systems working despite errors. What follows is a description of some of the software and hardware systems that we got to work.
The software to run is based on routines in Microchips Advanced Software Framework(ASF) link specifically for the SAMD21.
Command Line Interface (CLI)
There is a command line interface that was used to read and write characters to the serial console. This console used a circular buffer library written by Phillips Johnston (August 6, 2018) that creates a circular buffer and both stores a character to be written and stores characters to be read.
Our console supports higher level functions such as writing an error message to the console or reading an input. It supports natural typing operations such as backspace. There are a series of debug levels of increasing severity. That way the errors that appear in the console can be controlled allowing all or only the most severe messages to be displayed. All the text displayed, and error messages, in the videos below are run using our command line interface.
The read and write uses USART and is triggered through callbacks. Our routine creates a read and a write circular buffer and configures USART to call our functions if a callback event occurs, such as a character is available to read or characters should be written. For example, to read a character, we register our function, usart_read_callback and it interprets a returned character to determine if it is a command, a backspace, or text. The character itself is stored in a global variable and the set of characters is stored in a read circular buffer. A similar process occurs for writing. When an error message needs to be sent, the characters are written to the TX buffer, which then sends them character by character through the USB interface by using USART callbacks.
Below are the main structures and function definitions used to define the command line interface. The structure, eDebugLogLevels is for a global variable that determines how many and what severity the messages that should be displayed. This is for debugging and for printing errors during normal operations.
The CLI is meant to take commands on the command line and print out informative messages. Commands such as "help" gives a list of commands, "ip" gives the ip address of the device, "ver_app" is the firmware version.
The CLI also supports important user accessibility functions such as backspace. The backspace key will print out the current line with the mistake blanked out, and then will print out the line again, this time putting the cursor where the replacement character should go.
/******************************************************************************
* Structures and Enumerations
******************************************************************************/
enum eDebugLogLevels {
LOG_INFO_LVL = 0, //Logs an INFO message
LOG_DEBUG_LVL = 1, //Logs a DEBUG message
LOG_WARNING_LVL = 2, //Logs a WARNING MSG
LOG_ERROR_LVL = 3, //Logs an Error message
LOG_FATAL_LVL = 4, //Logs a FATAL message (a non-recoverable error)
LOG_OFF_LVL = 5, //Enum to indicate levels are off
N_DEBUG_LEVELS = 6 //Max number of log levels
};
enum Actions {
COMMAND, // input terminated by cr or lf or both
// this means user inputted a command to process
PRINT, // print the next line
BACKSPACE, // backspace key set
DEFAULT // no action;
};
enum Commands {
DEVNAME = 0, // developer name
GETDEVICENAME, // get device name
IP, // get ip address
HELP, // help text
MAC, // mac address
SETDEVICENAME, // set device name
VER_APP, /// get the app version
VER_BL, // get the bootloader version
ERROR}; // invalid command
/******************************************************************************
* Global Function Declarations
******************************************************************************/
void InitializeSerialConsole(void);
void SerialConsoleWriteString(const char * string);
int SerialConsoleReadCharacter(uint8_t *rxChar);
void LogMessage(enum eDebugLogLevels level, const char *format, ...);
void ConsolePrintf(const char *format, ...);
void setLogLevel(enum eDebugLogLevels debugLevel);
enum eDebugLogLevels getLogLevel(void);
// returns the string in the read buffer
const char * getReadString(void);
enum Actions getCurrentState(void);
// backspace operation
void BackSpace(void);
// repeat the string in the buffer
void RepeatString(void);
// parses the command String and determines what Function to run
enum Commands ParseCommand(void);
void HelpCommand(void);
void RunCommands(enum Commands com);
void DeinitializeSerialConsole(void);
/******************************************************************************
* Local Functions
******************************************************************************/
The Bootloader
The boot loader is the first program run and it is responsible for reliably and gracefully starting the main application. The bootloader should decide when to load and update the main application. If it decides to load an new application, then it needs to transfer a binary file to non volatile memory, make sure that the write was successful in that all the bits transferred correctly; if it was successful then it should start the main application. If it was not successful, then it should display an informative error message.
One of the first jobs of the bootloader is to decide whether the current main application version loaded on the SD card is valid and should be loaded into nonvolatile memory. If the answer is no, then either the bootloader will not load a new program. In that case it will either gracefully terminate and display a message if no main application exists, or If a program is already loaded it will transfer control to that application. To determine what to do, the bootloader expects two files: a binary file with the image of the application, and a text file that describes the binary file. That file contains a checksum, and the binary file's version and subversion number. If the version number is more advanced than the current version loaded in non-volatile memory (NVM), then the new application should be loaded. An initial version of our system loads a new version if a button is pressed. The main application is responsible for loading a new version of the application (more below).
Once the bootloader decides to load a new version the steps are as follows: the binary is copied into nonvolatile memory, and the check sum is calculated. If it fails, an error message is displayed. If it passes, updated version numbers are written to memory and the new application is started.
Part of the bootloader has two structures to keep track of the main application and hardware state. These are both written in nonvolatile memory. A structure FW_header keeps track of the version number, its size, and its checksum, and the structure FW_status determines if there is a new image, and whether the NVM has been partitioned at all, ie that there is a main application.
Some of the important functions described below are: jumpToApplication which rebases the stack pointer and the vector table, and converts the address where the main application is written to the main application. Finally the main application is called in a one way jump to main. Some other functions are needed such as initializing the SD card and the serial console. There are also routines to read the NVM and check its checksum and write a row of data from the binary file to the NVM. Here are some videos that show the bootloader working. The first is a demo and the second two are code walk throughs. link link link link
#define DATA_ADDRESS ((uint32_t) 0xA000) // start of Data address
#define APP_START_ADDRESS ((uint32_t)0xB000) //Must be address of start of main application
/// Main application reset vector address
#define APP_START_RESET_VEC_ADDRESS (APP_START_ADDRESS+(uint32_t)0x04)
#define MAX_ADDRESS ((uint32_t) 0xFFFF)
#define GOLDEN_START_ADDRESS ((uint32_t)0xD800) //Must be address of
#define ROW_LENGTH ((uint32_t) NVMCTRL_ROW_PAGES * NVMCTRL_PAGE_SIZE) // length of row in NVM
/// Main application reset vector address
typedef struct FW_status{
uint8_t signature[3]; /// Used to determine that partition was initialized
uint8_t executingImage; /// Image 1 or 2 in the flash memory
uint8_t downloadedImage; /// Image 1 or 2 in the flash memory
bool writeNewImage; /// Is a new Image ready to be written?
}FW_status;
typedef struct FW_header{
uint16_t firmwareMajorVersion;
uint16_t firmwareMinorVersion;
uint16_t firmwareSize;
uint16_t hardwareVersion;
uint32_t checksum;
}FW_header;
//#define stringSize 200
//! Structure for UART module connected to EDBG (used for unit test output)
struct usart_module cdc_uart_module;
static void jumpToApplication(uint32_t startAddress);
bool testReadWriteBinary(void);// test read a binary
bool testReadWriteText(void); // test read write text
bool readNVMBuffer(void); // test read NVM memory
bool loadTest(FATFS * fsp);
/***********************************************************************************
* @brief writeRows writes row(s) of memory to the NVM. It first erases the rows
* @params rowNumber is the row number. This is multiplied by ROW_LENGTH.
* buffer pointer to buffer to copy to destination
* length number of bytes to write
* BufferSize max amount to copy at one time should be
* NVMCTRL_ROW_PAGES* NVMCTRL_PAGE_SIZE * N (N> 0 a number)
* @return status code errors on write
* ***********************************************************************************/
enum status_code writeRows(const uint32_t rowNumber, uint8_t* buffer, uint16_t length, uint16_t BufferSize);
/***********************************************************************************
* @brief readRows reads one rows of memory to the NVM.
* @params rowNumber is the row number. This is multiplied by ROW_LENGTH.
* buffer pointer to buffer to copy data into
* length number of bytes to write
* BufferSize max amount to copy at one time should be
* NVMCTRL_ROW_PAGES* NVMCTRL_PAGE_SIZE
* @return status code errors on write
* ***********************************************************************************/
enum status_code readRows(const uint32_t rowNumber, uint8_t* buffer, uint16_t length, uint16_t BufferSize);
char binName[] ="0:WeatherCode.bin";
char textName[] ="0:WeatherCode.txt";
char goldenBin[] ="0:WeatherGolden.bin";
char goldenText[] ="0:WeatherGolden.txt";
#define textLine ((uint16_t) 200)
/**************************************************************************************
* @readTextVersion reads the text version ad updates the header.
* assumes disk is mounted. Returns the Header from the bin file on disk and a boolean
* indicating the reset was pressed.
* @params txt the text filename
* Header the header to update
* reset returned true/false if the text file indicates forced
* reset
* **********************************************************************************/
FRESULT readTextVersion(char * txt, FW_header * Header, bool* reset);
/*************************************************************************************
* initialize SD CardReader
**************************************************************************************/
FRESULT initSDCard(FATFS *);
/********************************************************************************
* failedToLoad
*/
bool FailedToLoad(void);
/***************************************************************************************
* @func LoadToNVM loads a binary File in to Non Volatile Memory.
* @params rowN Number Row number to start writing in MVN
* @params binName. binary filename
* @params in FW_header with Version number read in, this gets updated with
* file header information
* @return enum status_code indicates if the transfer was successful
****************************************************************************************/
enum status_code loadToNVM(uint32_t RowN, char * binName, FW_header * in);
/**************************************************************************************
* @func readNvM(uint32_t RowN, const FW_header * head)
* @params RowN row in memory to read from
* @params FW_header * Pointer to header
* @brief reads an image and checks the CRC32 of the image if it matches that
* supplied in head
* ***************************************************************************************/
enum status_code readNVM(uint32_t RowN, const FW_header * head);
/*****************************************************************************************
* @func LoadImage loads an image into the MVN and updates the header
* @params RowN The row to load the image into
* binary The binary file name that has the image
* text The text file that has the version number description
* reset if true it will always load image and will overWrite
* inHead
* @out enum status_code if STATUS_OK success
*
* ***************************************************************************************/
enum status_code loadImage(uint32_t RowN, char * binary, char * text, FW_header * inHead, bool reset);
void configure_nvm(void);
Light and Temperature Sensors
The light sensor is intended to measure the luminance on different days, and is meant to distinguish between bright sunny days, cloudy days, sunset and sunrise, and night time, a luminance range spanning several orders of magnitude. The luminance level was set by two switches that set the gain over a range to cover indoor light levels and outdoor light levels. The temperature sensor also covers a range for outdoor temperatures. Both these sensors work on the production board. The software to make this work is described below.
Here is a video demonstrating this working. link
The production board sensors are used and send their signals to a SAMW25 commercial board. The software to make this work is on that board. In short, two ADC channels are configured to acquire the sensor data, using the code below. This configuration uses the ADC callbacks in ASF. The first step is to include the ADC routines and to configure them to enable callbacks as follows.
void configure_adc(void)
{
struct adc_config config_adc;
adc_get_config_defaults(&config_adc);
config_adc.clock_prescaler = ADC_CLOCK_PRESCALER_DIV128;
config_adc.positive_input =ADC_CHANNEL_0 ;
config_adc.reference = ADC_REFCTRL_REFSEL_INTVCC1;
adc_init(&adc_instance, ADC, &config_adc);
adc_enable(&adc_instance);
adc_register_callback(&adc_instance, adcRead, ADC_CALLBACK_READ_BUFFER);
adc_enable_callback(&adc_instance,ADC_CALLBACK_READ_BUFFER);
struct system_pinmux_config config;
system_pinmux_get_config_defaults(&config);
/* Analog functions are all on MUX setting B */
config.input_pull = SYSTEM_PINMUX_PIN_PULL_NONE;
config.mux_position = 1;
system_pinmux_pin_set_config(ADC_CHANNEL_0, &config);
system_pinmux_pin_set_config(ADC_CHANNEL_1, &config);
adc_current_channel = ADC_CHANNEL_0;
adc_set_positive_input(&adc_instance, adc_current_channel);
}
The callback is set up to read accurately, so the clock speed is set to slow. Temperature and light level don't change quickly so slow and accurate is preferred. A buffer adc_buffer is allocated to store the adc values read. Below in adc_read_buffer_job, the callback is triggered after ADC_AVERAGE_NUMBER of reads take place. This reduces the number of callbacks needed dramatically.
volatile enum adc_positive_input adc_current_channel;
uint16_t adc_buffer[ADC_AVERAGE_NUMBER];
adc_read_buffer_job(&adc_instance, adc_buffer, ADC_AVERAGE_NUMBER);
The actual callback is below. When the buffer is full, the callback is triggered and the buffer values are averaged.
The callback is meant to finish early and not have much code in it. The callback switches between the two channels and sets lightReady or tempReady.
void adcRead(struct adc_module *const module)
{
uint32_t avg = 0;
for(uint8_t i=0;i< ADC_AVERAGE_NUMBER;i++)
{
avg += adc_buffer[i];
}
avg >>= ADC_AVERAGE_DIVISOR;
if( adc_current_channel == ADC_CHANNEL_0)
{
light = avg;
adc_current_channel = ADC_CHANNEL_1;
adc_set_positive_input(&adc_instance, adc_current_channel);
lightReady = true;
}
else
{
temperature = avg;
adc_current_channel = ADC_CHANNEL_0;
adc_set_positive_input(&adc_instance, adc_current_channel);
tempReady = true;
}
}
Now in the main loop these global boolean flags are read in the code below. TemperatureFormat() and LuminosityFormat() format the integer light and temperature data into a string (c string) with the JSON like formatting that JavaScript understands to send a record of the data to MQTT. mqtt_publish sends the data to MQTT, the string and the TOPIC. _LogMessage sends a message to the terminal for debugging. The channel is then switched and the ADC triggered again.
if (tempReady) {
TemperatureFormat();
mqtt_publish(&mqtt_inst, TEMPERATURE_TOPIC, (char *) mqtt_msg, strlen(mqtt_msg), 2, 0);
tempReady = false;
LogMessage(LOG_DEBUG_LVL, "temp : %d ", temperature);
//delay_ms(0);
adc_read_buffer_job(&adc_instance, adc_buffer, ADC_AVERAGE_NUMBER);
}
if (lightReady) {
LuminosityFormat();
mqtt_publish(&mqtt_inst, LUM_TOPIC, (char *) mqtt_msg, strlen(mqtt_msg), 2, 0);
lightReady = false;
LogMessage(LOG_DEBUG_LVL, "light: %d \r\n", light);
//delay_ms(0);
adc_read_buffer_job(&adc_instance, adc_buffer, ADC_AVERAGE_NUMBER);
}
Over the Air Updates on the Weatherboard.
The Weatherboard is meant to be deployed in many locations that may be difficult to get to. Therefore it was a design goal to be able to update the firmware and rerun. The firmware was intended to be updated over the air. When a new version of the software was available, the real-time updates would pause while a new version of the firmware was downloaded. Afterwards, the local weatherboard would resume uploading data. Installation would occur at a separate time, perhaps at night when the weather information need not be updated. Ideally, our code would be able to download as many files as needed, but then resume MQTT. Ideally we could switch between the two protocols, http and mqtt, at anytime. In order to update the firmware, we need to download two files: the fiirst the binary file that holds the firmware, and next a text file that has firmware version numbers, and a checksum.
The base code we recieved part of the http protocol was set, but downloading was not working as expected. It downloaded a small text file then started MQTT. As written, the protocol could not be modified to download multiple files. When we tried to download more than one file, the download failed. Also, as written it never could switch back from mqtt to http.
The solution involved establishing several more states of the system.
typedef enum {
NOT_READY = 0, /*!< Not ready. */
STORAGE_READY = 0x01, /*!< Storage is ready. */
WIFI_CONNECTED = 0x02, /*!< Wi-Fi is connected. */
GET_REQUESTED = 0x04, /*!< GET request is sent. */
DOWNLOADING = 0x08, /*!< Running to download. */
COMPLETED = 0x10, /*!< Download completed. */
CANCELED = 0x20, /*!< Download canceled. */
DOWNLOAD_REQUEST = 0x40, /* StartDownloading files */
DOWNLOAD_2ND_FILE = 0x80, /* download the Second file only */
DOWNLOAD_COMPLETED = 0x100 /* second File Downloaded Correctly*/
} download_state;
When http starts, it downloads the first file, then if all is well enters state COMPLETED, meaning the first file was successfully downloaded. Next, the second file started, DOWNLOAD_REQUEST, and when successful, we enter stated DOWNLOAD_COMPLETED. At that point MQTT would start.
One key idea to get the download to work was to realize that two different sockets need to remain open, both the MQTT socket and the HTTP socket. The MQTT socket is assigned to the first slot. In ASF there are only a limited number of open sockets at a time (perhaps 7), but that is sufficient for our purposes. In contrast to the base code, we started MQTT first. This socket remains open and assigned throughout.
/* Initialize socket module. */
socketInit();
/* Register socket callback function. Now has both MQTT and HTPP callbacks
* handlers */
registerSocketCallback(socket_cb, resolve_cb);
registerSocketCallback(socket_cb, resolve_cb);
/* Connect to router. */
LogMessage(LOG_INFO_LVL, "main: connecting to WiFi AP %s...\r\n", (char *)MAIN_WLAN_SSID);
m2m_wifi_connect((char *)MAIN_WLAN_SSID, sizeof(MAIN_WLAN_SSID), MAIN_WLAN_AUTH, (char *)MAIN_WLAN_PSK, M2M_WIFI_CH_ALL);
initializeWifi();
//add_state(DOWNLOAD_REQUEST);
//downloadFiles();
startMQTTInit();
In contrast, the HTTP socket will be opened and closed repeatedly, once for each file downloaded. These socket numbers get assigned sequentially chosen to be any but the MQTT socket. Now the socket and resolve call backs need to handle both HTTP and MQTT sockets. This is done as follows:
/**
* \brief Callback to get the Socket event.
*
* \param[in] Socket descriptor.
* \param[in] msg_type type of Socket notification. Possible types are:
* - [SOCKET_MSG_CONNECT](@ref SOCKET_MSG_CONNECT)
* - [SOCKET_MSG_BIND](@ref SOCKET_MSG_BIND)
* - [SOCKET_MSG_LISTEN](@ref SOCKET_MSG_LISTEN)
* - [SOCKET_MSG_ACCEPT](@ref SOCKET_MSG_ACCEPT)
* - [SOCKET_MSG_RECV](@ref SOCKET_MSG_RECV)
* - [SOCKET_MSG_SEND](@ref SOCKET_MSG_SEND)
* - [SOCKET_MSG_SENDTO](@ref SOCKET_MSG_SENDTO)
* - [SOCKET_MSG_RECVFROM](@ref SOCKET_MSG_RECVFROM)
* \param[in] msg_data A structure contains notification informations.
* This callback does both HTPP and MQTT!
*/
static void socket_cb(SOCKET sock, uint8_t u8Msg, void *pvMsg)
{
if (sock == mqtt_inst.network.socket)
{
mqtt_socket_event_handler(sock, u8Msg, pvMsg);
}
else //if (sock == http_client_module_inst.sock)
{
http_client_socket_event_handler(sock, u8Msg, pvMsg);
}
}
/**
* \brief Callback for the gethostbyname function (DNS Resolution callback).
* \param[in] pu8DomainName Domain name of the host.
* \param[in] u32ServerIP Server IPv4 address encoded in NW byte order format. If it is Zero, then the DNS resolution failed.
*/
/**
* \brief Callback of gethostbyname function.
*
* \param[in] doamin_name Domain name.
* \param[in] server_ip IP of server.
*/
static void resolve_cb(uint8_t *pu8DomainName, uint32_t u32ServerIP)
{
LogMessage(LOG_DEBUG_LVL, "resolve_cb: %s IP address is %d.%d.%d.%d\r\n\r\n", pu8DomainName,
(int)IPV4_BYTE(u32ServerIP, 0), (int)IPV4_BYTE(u32ServerIP, 1),
(int)IPV4_BYTE(u32ServerIP, 2), (int)IPV4_BYTE(u32ServerIP, 3));
if (strncmp((const char *) pu8DomainName,WeatherCodeBin_URL + 7, 18) == 0) {
http_client_socket_resolve_handler(pu8DomainName, u32ServerIP);
}
else {
mqtt_socket_resolve_handler( pu8DomainName, u32ServerIP);
}
}
To chose correctly, we used the DomainName and fields of the SOCKET structure. Having this choice allows sockets of both MQTT and HTTP to pass and never requires us to close the MQTT socket.
To get the files to download, our server used HTTPS as a protocol, so the port needed to be set to 443. After downloading and in order to download the next file, we found it was crucial to close the connection.
clear_state(GET_REQUESTED);
clear_state(DOWNLOADING);
http_client_close(&http_client_module_inst);
if (is_state_set(CANCELED))…
By closing explicitly, this prevented HTTP_CLIENT_CALLBACK_DISCONNECTED errors and stopped http from sometimes hanging. When the next file needs to be downloaded, a function call
http_client_send_request(&http_client_module_inst, urlName, HTTP_METHOD_GET, NULL, NULL);
is run (in start_download) and that will create a new socket and initialize the http file transfer.
The MQTT interface on Node Red also indicates the state of the download. Initially, one toggles the switch to request a download. Data stops being sent to MQTT as HTTP downloads two files. During that time the panel switch stays off. Once download is completed, the panel switch resets allowing a later download.
This can be viewed all working in the following [link] https://youtu.be/CiNzmQ2lUyA (The camera flips to right side up after a few seconds, so please wait)
The beginning is a code walk through, then there is a demonstration of the ADC channels working. Later you see the MQTT interface and the command line prompt showing the download of two files, and lastly there
### Board Bring Up
Our actual board had several hardware issues that we worked on. The power did not work due to the wrong component installed and incorrect resistors. By removing that component, changing resistors and connecting the USB power, both 5 V and 3.3 Volts lines worked. The serial port also did not initially work. This was solved by swapping the Tx and Rx lines breaking the fine soldering pattern and reconnecting. This allowed serial communication via a serial port. The Atmel Studio Ice debugger also worked. We were able to update the software on the chip via the software update and that demonstated that the debugger was working. We also demonstrated our code on our board. We were able to step through using the Atmel debugger.
The remaining issue was the SPI serial port. We had at least three of these connections wired to our chip, one for the SD card, another for a rotary encoder and a third for the RFID reader. So far, even though the DC levels are correct and the pull up resistors are in the correct spots, the SD card did not work. Because two of these ports are for external devices, the SPI serial ports are accessible and it still should be possible to connect all these peripherals to the same SPI port.
Built With
- altium
- atmel-studio
- node-red
Log in or sign up for Devpost to join the conversation.