560 lines
16 KiB
C++
560 lines
16 KiB
C++
/**
|
|
* Author : Anatole SCHRAMM-HENRY
|
|
* Created the : 11/07/2021
|
|
* Last updated : 04/11/2025
|
|
* This is a quick and dirty gateway firmware to receive and save data from sensors to a database
|
|
*/
|
|
|
|
#include <SPI.h>
|
|
#include <ESP8266WiFi.h>
|
|
#include <PCF8574.h>
|
|
#include <HttpClient.h>
|
|
#include "RF24.h"
|
|
#include <LoRa.h>
|
|
#include "definition.h"
|
|
#include "credentials.h"
|
|
|
|
/* NRF pinout definition and settings */
|
|
#define NRF_1_CE (15) //chip enable
|
|
#define NRF_1_CS (5) //chip select
|
|
#define NRF_1_IRQ (PCF8574::P1)
|
|
|
|
#define NRF_2_CE (16) //chip enable
|
|
#define NRF_2_CS (4) //chip select
|
|
#define NRF_2_IRQ (PCF8574::P0)
|
|
|
|
#define NRF_CHANNEL (108) //0-125
|
|
#define NRF_PA_LEVEL (RF24_PA_MAX)
|
|
#define NRF_DATA_RATE (RF24_250KBPS) //250 Kb/s, 1 Mb/s & 2 Mb/s
|
|
|
|
#define RECV_CHECK 5000 //We test every 5s if we received something that did not trigger an IRQ (missed)
|
|
#define WIFI_CHECK_TIMEOUT 20000 //We check every 20 seconds the WiFi state
|
|
#define PREVENTIVE_RESET_DELAY 18000000 //Every 5 hours the MCU is reseted, this SHOULD NOT be necessary
|
|
|
|
/* RGB LED pinout */
|
|
#define LED_RED_PIN (PCF8574::P2)
|
|
#define LED_GREEN_PIN (PCF8574::P3)
|
|
#define LED_BLUE_PIN (PCF8574::P4)
|
|
|
|
/* LoRa module pinout definition (using the IO expander) */
|
|
#define LORA_CS_PIN (PCF8574::P5)
|
|
#define LORA_RST_PIN (PCF8574::P6)
|
|
#define LORA_DI0_PIN (PCF8574::P7)
|
|
|
|
#define LORA_BAND (868E6)
|
|
#define LORA_SPREADING_FACTOR (10) //6-12
|
|
|
|
/* PCF8574 IO expander pinout */
|
|
#define PCF_INT_PIN (D9)
|
|
|
|
uint8_t payload[32] = {0};
|
|
DataPacket packetHeader;
|
|
WeatherStationDataPacket wsdp;
|
|
MailboxDataPacket mdp;
|
|
|
|
RF24 NRF_1(NRF_1_CE, NRF_1_CS);
|
|
RF24 NRF_2(NRF_2_CE, NRF_2_CS);
|
|
PCF8574 PCF(0x20);
|
|
|
|
//Wifi event handlers
|
|
WiFiEventHandler gotIp, lostConnection;
|
|
HttpClient DBHost(DB_SERVER, PATH);
|
|
|
|
Dictionary<DictionaryHelper::StringEntity> weatherStationPostData, mailboxPostData;
|
|
|
|
const uint8_t ADDR[] = "WEST1", ADDR2[] = "ABCDE";
|
|
uint32_t timeStamp(0), irqSaver(0), resetTimeStamp(0);
|
|
uint32_t freeMem(0);
|
|
uint16_t biggestContigMemBlock(0);
|
|
uint8_t frag(0);
|
|
HttpClient::HttpQueryStatus insertIntoDBError(HttpClient::HttpQueryStatus::SUCCESS);
|
|
volatile boolean IRQFlag(false);
|
|
volatile boolean IRQIsLoRa(false);
|
|
boolean waitingForResetSignal(false), resetNow(false);
|
|
|
|
void PCFPinMode(uint8_t pin, uint8_t mode)
|
|
{
|
|
PCF.pinMode(static_cast<PCF8574::Pin>(pin), static_cast<boolean>(mode));
|
|
}
|
|
|
|
void PCFDigitalWrite(uint8_t pin, uint8_t val)
|
|
{
|
|
PCF.digitalWrite(static_cast<PCF8574::Pin>(pin), static_cast<boolean>(val));
|
|
}
|
|
|
|
void onLoRaReceive(int payload_size)
|
|
{
|
|
IRQIsLoRa = true;
|
|
PCF.digitalWrite(LED_RED_PIN, LOW);
|
|
|
|
int read_size = LoRa.read(payload, sizeof(payload));
|
|
|
|
Serial.printf("Read LoRa payload size of : %d\n", read_size);
|
|
if (read_size != payload_size)
|
|
Serial.printf("/!\\Read size(%d) != payload size(%d)\n", read_size, payload_size);
|
|
Serial.printf("Packet RSSI : %d dBm\n", LoRa.packetRssi());
|
|
|
|
PCF.digitalWrite(LED_RED_PIN, HIGH);
|
|
}
|
|
|
|
IRAM_ATTR void PCFIRQHandler(void *p)
|
|
{
|
|
Serial.println("PCF IRQs detected !");
|
|
bool pinStates[8];
|
|
PCF.digitalReadAll(pinStates);
|
|
*(boolean *)p = true;
|
|
|
|
/* If the IRQ pin is the DIO1, manually call the LoRa IRQ handler */
|
|
if(pinStates[7] == HIGH)
|
|
{
|
|
LoRa.handleDio0Rise();
|
|
}
|
|
}
|
|
|
|
void setup()
|
|
{
|
|
//We do not need to read on the serial bus
|
|
Serial.begin(115200, SERIAL_8N1, SERIAL_TX_ONLY);
|
|
delay(1000);
|
|
Serial.println("\nSetup begin");
|
|
//We set the WiFi part up :
|
|
gotIp = WiFi.onStationModeGotIP(&(gotIpFunc));
|
|
lostConnection = WiFi.onStationModeDisconnected(&(lostConnectionFunc));
|
|
//To prevent wear and tear on the flash mem
|
|
WiFi.persistent(false);
|
|
WiFi.disconnect(true);
|
|
WiFi.softAPdisconnect(true);
|
|
Serial.println("Connecting to the access point");
|
|
WiFi.begin(SSID, PWD);
|
|
|
|
//We initialize the dictionary
|
|
//These keys are used to post data for the weather station :
|
|
weatherStationPostData.add("accessCode", DictionaryHelper::StringEntity(ACCESS_CODE));
|
|
weatherStationPostData.add("deviceType", DictionaryHelper::StringEntity(WEATHER_STATION_DEV_TYPE));
|
|
weatherStationPostData.add("packetUID", DictionaryHelper::StringEntity(NULL));
|
|
weatherStationPostData.add("battery", DictionaryHelper::StringEntity(NULL));
|
|
weatherStationPostData.add("light", DictionaryHelper::StringEntity(NULL));
|
|
weatherStationPostData.add("bmp_tmp", DictionaryHelper::StringEntity(NULL));
|
|
weatherStationPostData.add("pressure", DictionaryHelper::StringEntity(NULL));
|
|
weatherStationPostData.add("humidity", DictionaryHelper::StringEntity(NULL));
|
|
weatherStationPostData.add("compensated_humidity", DictionaryHelper::StringEntity(NULL));
|
|
weatherStationPostData.add("htu_tmp", DictionaryHelper::StringEntity(NULL));
|
|
|
|
//These keys are used to post data for the mailbox :
|
|
mailboxPostData.add("accessCode", DictionaryHelper::StringEntity(ACCESS_CODE));
|
|
mailboxPostData.add("deviceType", DictionaryHelper::StringEntity(MAILBOX_DEV_TYPE));
|
|
mailboxPostData.add("packetUID", DictionaryHelper::StringEntity(NULL));
|
|
mailboxPostData.add("battery", DictionaryHelper::StringEntity(NULL));
|
|
mailboxPostData.add("event", DictionaryHelper::StringEntity(NULL));
|
|
|
|
|
|
Serial.printf("\nNRF 1 %s\n",NRF_1.begin() ? "started" : "error");
|
|
if(!NRF_1.isChipConnected())
|
|
Serial.println("NRF 1 is missing");
|
|
else
|
|
Serial.println("NRF 1 is detected");
|
|
|
|
NRF_1.setChannel(NRF_CHANNEL);
|
|
NRF_1.setPALevel(NRF_PA_LEVEL);
|
|
NRF_1.setDataRate(NRF_DATA_RATE);
|
|
NRF_1.setRetries(8,15);
|
|
NRF_1.openReadingPipe(1, ADDR);
|
|
NRF_1.startListening();
|
|
|
|
Serial.printf("NRF 2 %s\n",NRF_2.begin() ? "started" : "error");
|
|
if(!NRF_2.isChipConnected())
|
|
Serial.println("NRF 2 is missing");
|
|
else
|
|
Serial.println("NRF 2 is detected");
|
|
|
|
NRF_2.setChannel(NRF_CHANNEL);
|
|
NRF_2.setPALevel(NRF_PA_LEVEL);
|
|
NRF_2.setDataRate(NRF_DATA_RATE);
|
|
NRF_2.setRetries(8,15);
|
|
NRF_2.openReadingPipe(1, ADDR);
|
|
NRF_2.startListening();
|
|
|
|
//Setting the I2C pins and the PCF8574
|
|
Wire.begin(0,2);
|
|
Serial.printf("PCF %s\n", PCF.begin() ? "found" : "not found");
|
|
PCF.pinMode(LED_RED_PIN, OUTPUT);
|
|
PCF.pinMode(LED_GREEN_PIN, OUTPUT);
|
|
PCF.pinMode(LED_BLUE_PIN, OUTPUT);
|
|
|
|
/* Configuring LoRa module and RF settings */
|
|
LoRa.setPins(LORA_CS_PIN, LORA_RST_PIN, LORA_DI0_PIN);
|
|
/* Setting custom GPIO as the LoRa module signals are driven by the IO Expender */
|
|
LoRa.setCustomGPIOFn(&(PCFPinMode), &(PCFDigitalWrite));
|
|
|
|
Serial.printf("LoRa module %s\n", LoRa.begin(LORA_BAND) ? "found" : "not found");
|
|
LoRa.setSpreadingFactor(LORA_SPREADING_FACTOR);
|
|
LoRa.enableCrc();
|
|
LoRa.onReceive(&(onLoRaReceive));
|
|
// Let's start listening to LoRa packets
|
|
LoRa.receive();
|
|
|
|
//We set the esp8266's RXD0 as a GPIO
|
|
pinMode(PCF_INT_PIN, INPUT_PULLUP);
|
|
attachInterruptArg(PCF_INT_PIN, &(PCFIRQHandler), (void *)&IRQFlag, ONLOW);
|
|
Serial.println("End setup");
|
|
}
|
|
|
|
void loop()
|
|
{
|
|
//We check if we got and IRQ from one, both NRFs or the LoRa module
|
|
if(IRQFlag || (millis() - irqSaver > RECV_CHECK))
|
|
{
|
|
if(!IRQIsLoRa && PCF.digitalRead(LORA_DI0_PIN))
|
|
{
|
|
Serial.println("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!IRQ MISSED FOR LoRa MODULE");
|
|
if(LoRa.parsePacket() > 0)
|
|
{
|
|
LoRa.read(payload, sizeof(payload));
|
|
//Set IRQ to true to handle LoRa received payload
|
|
IRQIsLoRa = true;
|
|
}
|
|
LoRa.receive();
|
|
}
|
|
|
|
if(IRQIsLoRa)
|
|
{
|
|
//Payload already retrieved in LoRa module IRQ or in the previous code part
|
|
memcpy(&packetHeader, payload, sizeof(packetHeader));
|
|
switch(packetHeader.header)
|
|
{
|
|
case WEATHER_STATION:
|
|
{
|
|
memcpy(&wsdp, payload, sizeof(wsdp));
|
|
debugStruct(&wsdp);
|
|
insertIntoDBError = insertIntoDB(&wsdp);
|
|
}
|
|
break;
|
|
case CONNECTED_MAILBOX:
|
|
{
|
|
memcpy(&mdp, payload, sizeof(mdp));
|
|
debugStruct(&mdp);
|
|
insertIntoDBError = insertIntoDB(&mdp);
|
|
}
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
|
|
if(waitingForResetSignal)resetNow = true;
|
|
|
|
IRQIsLoRa = false;
|
|
}
|
|
|
|
if(PCF.digitalRead(LORA_DI0_PIN))
|
|
{
|
|
Serial.println("LoRa module DI0 stuck high !");
|
|
}
|
|
|
|
bool tx_ok, tx_fail, rx_ready;
|
|
//We read the PCFs IO to check which NRF raised the IRQ :
|
|
if(!PCF.digitalRead(NRF_1_IRQ)) //IRQs are active low
|
|
{
|
|
if(!IRQFlag)
|
|
Serial.println("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!IRQ MISSED FOR NRF 1");
|
|
Serial.printf("NRF 1 triggered the IRQs\n Checking why :\n");
|
|
NRF_1.whatHappened(tx_ok, tx_fail, rx_ready);
|
|
if(tx_ok)
|
|
{
|
|
Serial.println("NRF 1 TX_OK");
|
|
}
|
|
if(tx_fail)
|
|
{
|
|
Serial.println("NRF 1 TX_FAIL");
|
|
}
|
|
if(rx_ready)
|
|
{
|
|
Serial.printf("NRF 1 Received %u bytes with sig(%s) : \n",NRF_1.getPayloadSize(), NRF_1.testRPD()?"good":"bad");
|
|
NRF_1.read(payload, sizeof(payload));
|
|
|
|
memcpy(&packetHeader, payload, sizeof(packetHeader));
|
|
|
|
switch(packetHeader.header)
|
|
{
|
|
case WEATHER_STATION:
|
|
{
|
|
memcpy(&wsdp, payload, sizeof(wsdp));
|
|
debugStruct(&wsdp);
|
|
insertIntoDBError = insertIntoDB(&wsdp);
|
|
}
|
|
break;
|
|
case CONNECTED_MAILBOX:
|
|
{
|
|
memcpy(&mdp, payload, sizeof(mdp));
|
|
debugStruct(&mdp);
|
|
insertIntoDBError = insertIntoDB(&mdp);
|
|
}
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
|
|
if(waitingForResetSignal)resetNow = true;
|
|
}
|
|
}
|
|
|
|
if(!PCF.digitalRead(NRF_2_IRQ)) //IRQs are active low
|
|
{
|
|
if(!IRQFlag)
|
|
Serial.println("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!IRQ MISSED FOR NRF 2");
|
|
Serial.printf("NRF 2 triggered the IRQs\n Checking why :\n");
|
|
NRF_2.whatHappened(tx_ok, tx_fail, rx_ready);
|
|
if(tx_ok)
|
|
{
|
|
Serial.println("NRF 2 TX_OK");
|
|
}
|
|
if(tx_fail)
|
|
{
|
|
Serial.println("NRF 2 TX_FAIL");
|
|
}
|
|
if(rx_ready)
|
|
{
|
|
Serial.printf("NRF 2 Received %u bytes with sig(%s) : \n",NRF_2.getPayloadSize(), NRF_2.testRPD()?"good":"bad");
|
|
NRF_2.read(payload, sizeof(payload));
|
|
|
|
memcpy(&packetHeader, payload, sizeof(packetHeader));
|
|
|
|
switch(packetHeader.header)
|
|
{
|
|
case WEATHER_STATION:
|
|
{
|
|
memcpy(&wsdp, payload, sizeof(wsdp));
|
|
debugStruct(&wsdp);
|
|
insertIntoDBError = insertIntoDB(&wsdp);
|
|
}
|
|
break;
|
|
case CONNECTED_MAILBOX:
|
|
{
|
|
memcpy(&mdp, payload, sizeof(mdp));
|
|
debugStruct(&mdp);
|
|
insertIntoDBError = insertIntoDB(&mdp);
|
|
}
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
|
|
if(waitingForResetSignal)resetNow = true;
|
|
}
|
|
}
|
|
|
|
//We check if the RX fifo the NRFs are full
|
|
//If yes, it means we have a big problem since packets can be thrown away without notice
|
|
//Could be due to missed IRQs (very bad)
|
|
if(NRF_1.rxFifoFull())
|
|
{
|
|
Serial.println("NRF 1 FIFO FULL !!!!!!!!!!!!");
|
|
NRF_1.flush_rx();
|
|
}
|
|
if(NRF_2.rxFifoFull())
|
|
{
|
|
Serial.println("NRF 2 FIFO FULL !!!!!!!!!!!!");
|
|
NRF_2.flush_rx();
|
|
}
|
|
IRQFlag = false;
|
|
irqSaver = millis();
|
|
}
|
|
|
|
//Here we check if we are still connected
|
|
if(millis() - timeStamp > WIFI_CHECK_TIMEOUT)
|
|
{
|
|
ESP.getHeapStats(&freeMem, &biggestContigMemBlock, &frag);
|
|
printf("Memory Info :\n - Free Mem > %u\n - Heap frag > %u\n - Max block > %u\nWiFi RSSI : %d\n",
|
|
freeMem, frag,
|
|
biggestContigMemBlock,
|
|
WiFi.RSSI());
|
|
|
|
Serial.println("Checking wifi link : ");
|
|
if(WiFi.isConnected())
|
|
{
|
|
Serial.println("Link is established, nothing to be done.");
|
|
}
|
|
else
|
|
{
|
|
Serial.println("We try to reconnect...");
|
|
WiFi.begin(SSID,PWD);
|
|
}
|
|
timeStamp = millis();
|
|
}
|
|
|
|
//Here we handle the reset
|
|
if(millis() - resetTimeStamp > PREVENTIVE_RESET_DELAY)
|
|
{
|
|
Serial.println("Waiting next payload before resetting MCU :( !");
|
|
waitingForResetSignal = true;
|
|
resetTimeStamp = millis();
|
|
}
|
|
|
|
if((waitingForResetSignal && resetNow) || insertIntoDBError == HttpClient::HttpQueryStatus::ERR_CONN)
|
|
{
|
|
ESP.reset();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Part where we declare our event handlers
|
|
*/
|
|
|
|
void gotIpFunc(const WiFiEventStationModeGotIP &event)
|
|
{
|
|
Serial.printf("Got connected to station : ip address : %u.%u.%u.%u \n", event.ip[0], event.ip[1], event.ip[2], event.ip[3]);
|
|
}
|
|
|
|
void lostConnectionFunc(const WiFiEventStationModeDisconnected &event)
|
|
{
|
|
(void)event;
|
|
Serial.println("Lost connection, will try to reconnect ...");
|
|
}
|
|
|
|
HttpClient::HttpQueryStatus insertIntoDB(WeatherStationDataPacket *p)
|
|
{
|
|
char buffer[100] = "";
|
|
HttpClient::HttpQueryStatus result(HttpClient::HttpQueryStatus::SUCCESS);
|
|
//We get the values and put it in our dictionary
|
|
sprintf(buffer ,"%u", p->id);
|
|
weatherStationPostData("packetUID")->setString(buffer);
|
|
|
|
sprintf(buffer, "%.5f", p->battery);
|
|
weatherStationPostData("battery")->setString(buffer);
|
|
|
|
sprintf(buffer, "%u", p->ldr);
|
|
weatherStationPostData("light")->setString(buffer);
|
|
|
|
sprintf(buffer, "%f", p->bmpTemp);
|
|
weatherStationPostData("bmp_tmp")->setString(buffer);
|
|
|
|
sprintf(buffer, "%f", p->bmpPress);
|
|
weatherStationPostData("pressure")->setString(buffer);
|
|
|
|
sprintf(buffer, "%f", p->humidity);
|
|
weatherStationPostData("humidity")->setString(buffer);
|
|
|
|
sprintf(buffer, "%f", p->compensatedHumidity);
|
|
weatherStationPostData("compensated_humidity")->setString(buffer);
|
|
|
|
sprintf(buffer, "%f", p->htuTemp);
|
|
weatherStationPostData("htu_tmp")->setString(buffer);
|
|
|
|
if((result = DBHost.sendHttpQuery(HttpClient::HttpRequestMethod::POST, NULL, &weatherStationPostData)) == HttpClient::HttpQueryStatus::SUCCESS)
|
|
{
|
|
Serial.println("Data posted successfully");
|
|
HttpClient::HTTP_CODE response = DBHost.isReplyAvailable(2000);
|
|
Serial.printf("Got response code : %u\n", response);
|
|
}
|
|
else
|
|
{
|
|
Serial.printf("Failed to post data : error(%d)\n", result);
|
|
}
|
|
DBHost.stop();
|
|
|
|
return result;
|
|
}
|
|
|
|
void debugStruct(WeatherStationDataPacket *p)
|
|
{
|
|
Serial.println("##############WEATHER STATION DATA##############");
|
|
Serial.print("ID : ");
|
|
Serial.println(p->id);
|
|
|
|
Serial.print("HEADER : ");
|
|
Serial.println(p->header);
|
|
|
|
Serial.printf("BATT : %.5f V\n", p->battery);
|
|
|
|
Serial.print("LDR : ");
|
|
Serial.println(p->ldr);
|
|
|
|
Serial.print("BMP TEMP : ");
|
|
Serial.print(p->bmpTemp);
|
|
Serial.println(" *C");
|
|
|
|
Serial.print("BMP PRESS : ");
|
|
Serial.print(p->bmpPress);
|
|
Serial.println(" Pa");
|
|
|
|
Serial.print("HUM : ");
|
|
Serial.print(p->humidity);
|
|
Serial.println(" %");
|
|
|
|
Serial.print("COM HUM : ");
|
|
Serial.print(p->compensatedHumidity);
|
|
Serial.println(" %");
|
|
|
|
Serial.print("HTU TEMP : ");
|
|
Serial.print(p->htuTemp);
|
|
Serial.println(" *C");
|
|
}
|
|
|
|
HttpClient::HttpQueryStatus insertIntoDB(MailboxDataPacket *p)
|
|
{
|
|
char buffer[100] = "";
|
|
HttpClient::HttpQueryStatus result(HttpClient::HttpQueryStatus::SUCCESS);
|
|
//We get the values and put it in our dictionary
|
|
sprintf(buffer ,"%u", p->id);
|
|
mailboxPostData("packetUID")->setString(buffer);
|
|
|
|
sprintf(buffer, "%.5f", p->battery);
|
|
mailboxPostData("battery")->setString(buffer);
|
|
|
|
switch(p->mailbox_event)
|
|
{
|
|
case MAILBOX_LETTER:
|
|
mailboxPostData("event")->setString("LETTER");
|
|
break;
|
|
case MAILBOX_PACKAGE:
|
|
mailboxPostData("event")->setString("PACKAGE");
|
|
break;
|
|
case MAILBOX_COLLECTED:
|
|
mailboxPostData("event")->setString("COLLECTED");
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
|
|
if((result = DBHost.sendHttpQuery(HttpClient::HttpRequestMethod::POST, NULL, &mailboxPostData)) == HttpClient::HttpQueryStatus::SUCCESS)
|
|
{
|
|
Serial.println("Data posted successfully");
|
|
HttpClient::HTTP_CODE response = DBHost.isReplyAvailable(5000); //We increase the timeout because sending a mail on the server side takes some time.
|
|
Serial.printf("Got response code : %u\n", response);
|
|
}
|
|
else
|
|
{
|
|
Serial.printf("Failed to post data : error(%d)\n", result);
|
|
}
|
|
DBHost.stop();
|
|
|
|
return result;
|
|
}
|
|
|
|
void debugStruct(MailboxDataPacket *p)
|
|
{
|
|
Serial.println("##############MAILBOX DATA##############");
|
|
Serial.print("ID : ");
|
|
Serial.println(p->id);
|
|
|
|
Serial.print("HEADER : ");
|
|
Serial.println(p->header);
|
|
|
|
Serial.printf("BATT : %.5f V\n", p->battery);
|
|
|
|
Serial.print("EVENT : ");
|
|
switch(p->mailbox_event)
|
|
{
|
|
case MAILBOX_LETTER:
|
|
Serial.println("LETTER");
|
|
break;
|
|
case MAILBOX_PACKAGE:
|
|
Serial.println("PACKAGE");
|
|
break;
|
|
case MAILBOX_COLLECTED:
|
|
Serial.println("COLLECTED");
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|