915MHz LoRa Wireless Deer Notification System

Join me on my unique journey from novice hunter to tech-savvy innovator. What started as quiet moments in the deer blind with my dad evolved into a quest for a game-changing hunting tool. Frustrated by missed opportunities in the field, I explored wireless communication technologies and discovered LoRa, a powerful and free-to-use system. With its long-range capabilities, I set out to design an advanced alert system to detect nearby deer movement and relay it seamlessly to my blind. Dive into the blend of tradition and technology that has transformed the way I hunt.

Background

I’ve been deer hunting with my dad since I was about 18 years old. At first, it was only every few years that I would go out with him. However, in recent years, my interest has grown and now I find myself taking the entire opening week of hunting season off from work to go hang out in the woods.

Over the past two years, I’ve seen lots of deer while sitting in the woods and have been able to harvest two deer—one in each year. Last year, I missed the chance to get a shot off at a few deer because I didn’t have enough time to draw my weapon before the deer was swiftly out of sight.

While sitting there waiting for activity to present itself, there is abundant time to think and ponder about a myriad of things. Last season, after missing the chance at an 8-pointer, I found myself thinking about ways in which I could be notified of nearby deer activity. A simple wireless transmitter that could send messages to a receiving unit in my deer blind was the initial thought.

Initial Research

After doing some research on wireless communications, I found that, in order to achieve longer range, a lower carrier frequency is desirable. After looking into the frequencies available to the public, I found the LoRa technology which operates in the 915MHz range for the US. Based on my initial research, I found the specs of LoRa allowed for up to 20km range with line-of-sight. In my deer blind, the longest range I would need was about 300 yards, but with a few trees in between.

Requirements

After finding out that the range I was seeking was definitely possible with free-to-the-public frequencies, I started deciding on design criteria for the receiver unit that would handle the messages coming from the transmitter devices. Here is the list of features I wanted:

  • 4 LED indicators to show where messages were coming from
  • Audible bird chirp to audibly, and discretely, alert me of nearby activity
  • Volume control
  • Audio enable switch
  • USB-C charger
  • Remote LEDs for spatial awareness of where activity is coming from
  • SMA antenna connector
  • On/Off switch

Prototyping – Sparkfun ProRF

I started researching different LoRa boards to get started with testing. At first, I purchased a pair of the Sparkfun Pro RF boards which appeared to be a good choice given its built-in lithium ion charger, sleep modes, on-board uFL connector, and decent number of I/O pins.

After making some small modifications to the example codes provided by Sparkfun, I had a transmitter which could transmit messages with an incrementing counter and a receiver to receive the messages. I quickly volunteered my girlfriend to help me test out the range at a nearby park. With the transmitter stationary, I took the receiver hooked up to my tablet and started walking while carefully monitoring the received messages to make sure none were missed. I made it out to about 300 yards before I got my first missed message. I had been walking with the receiver in front of me, so I stopped and faced the transmitter. The intermittent messages appeared to stop. I then continued to walk further, making sure to keep the antenna up above my head. At about 430 yards, I was well into the woods and with a pretty decent elevation variation between me and the transmitter. The transmitter was lower in elevation, however there was about a 50 foot tall hill in between the transmitter and receiver. It was at this point that I was receiving about 50% of the messages being transmitted, so I concluded the testing there. Definitely much further than I needed for my application.

At that point, I started figuring out what size and how many batteries I would need to ensure the transmitter would last an entire hunting season. The transmitter would need to operate for about about 1 month in freezing temperatures without needing to be recharged. The current draw of the board was about 60mA while idle. There was a substantial increase in current when transmitting messages, about 120mA, but only for about 100ms. This seemed excessive, given the datasheet stated an idle current of a few hundred µA. I tested out the sleep modes offered by the MPU on the board and was able to get it down to about 30mA, but this was still orders of magnitude larger than expected. At this point I was stumped. After reading more about the board, I found a note from the manufacturer that there was a mistake on this revision of the board where VDD was tied to GND. After finding this, I was able to cut this trace on the board and get the sleep mode current down to about 115µA. Success! In idle mode, this could theoretically run on a single 3500mAh lithium ion cell for about 4 years—much longer than I needed. Granted, the average current draw would be higher than the sleep mode current due to the current spikes when transmitting messages, but even at 1mA, this battery could power the board for nearly 5 months.

The next step was to open up a game camera I’ve used before and had good luck with. I figured using an off-the-shelf game camera would serve two main purposes. The first being I knew I could get reliable triggers of deer with minimal false triggering when no deer were present. The second upside was that I could have more pictures of deer to admire later. The camera I used was a Meidase P60 trail camera. Upon opening up the camera, I noticed there were 2 PIR sensors to detect motion.

By checking the output pins from these chips, I thought at first I could simply use these as my trigger source. However, after further testing it was evident that there was some pre-processing of these signals going on before the camera would actually take an image. This made sense because there are sensitivity settings on the camera that can be adjusted. After looking over the other ICs on the board, I found a CMOS flash memory chip.

After reviewing the datasheet and probing the IC for a bit, I found there was a chip select (CS) pin on the IC that was only active when acquiring an image and when changing settings on the camera’s menu. Since I would not be changing settings on the cameras while in my deer blind, I decided this would work well enough for a trigger signal. An added benefit of using the CS pin for a trigger signal is that it was already 3.3V which works well for most microcontrollers. After connecting the CS pin to one of the Pro RF’s I/O pins, I was quickly able to verify that this was able to give me a reliable trigger signal to transmit messages when an image was captured.

I soldered two wires to the GND and CS pins of the CMOS flash memory IC back to an M8 bulkhead connector. I used this connector style because I’ve become quite familiar with them working in the industrial automation space. They are very robust, but more importantly, waterproof.

Prototyping – Adafruit Feather M0

At this point, the proof of concept was completed, so I started to source components. After realizing the new revision Pro RF boards had been on backorder for a while, I decided to find a new board to work with which offered similar specs. I landed on the Adafruit Feather M0 which was about the same price and was smaller in footprint than the Sparkfun Pro RF.

After deciding on a board, I started to laying out the circuit schematic and then built a prototype of the receiver and transmitter. Below is a picture of the prototype layout in a waterproof enclosure.

Receiver Code (v1.0)

The initial code I wrote for the receiver unit utilized timers to determine when to turn on and off the LED indicators. This code worked well for the first iteration.

// Adafruit Feather M0
// Wireless Deer Notification Receiver v1.0

#define DEBUG 0

#include <SPI.h>
#include <RH_RF95.h>
#include <string.h>
#include <NoDelay.h>
#include <Adafruit_PCF8574.h>
#include "I2CScanner.h"

// Feather M0 w/Radio
#define RFM95_CS    8
#define RFM95_INT   3
#define RFM95_RST   4

#if DEBUG
  #define debug_begin(...)  Serial.begin(__VA_ARGS__);
  #define debug(...)        Serial.print(__VA_ARGS__);
  #define debugln(...)      Serial.println(__VA_ARGS__);
#else
  #define debug_begin(...)
  #define debug(...)
  #define debugln(...)
#endif

// Change to 434.0 or other frequency, must match RX's freq!
#define RF95_FREQ 915.0

// Singleton instance of the radio driver
RH_RF95 rf95(RFM95_CS, RFM95_INT);

// Instance of I2C object
Adafruit_PCF8574 pcf;

I2CScanner scanner;
bool connected = false;

int StatusLED = 13; //Status LED on pin 13

int packetCounter = 0; //Counts the number of packets sent
long timeSinceLastPacket = 0; //Tracks the time stamp of last packet received

class Location
{
  public:
    String name;
    byte light_pin;
    byte i2c_add;
    noDelay timer;
    
    Location(String n, byte p, byte a, noDelay t)
    {
      name = n;
      light_pin = p;
      i2c_add = a;
      timer = t;
    };
};

// // LED pin numbers
// const byte pinLight1 = 6;
// const byte pinLight2 = 10;
// const byte pinLight3 = 11;
// const byte pinLight4 = 12;
// LED pin numbers
const byte pinLight1 = A1;
const byte pinLight2 = A2;
const byte pinLight3 = A3;
const byte pinLight4 = A4;

// I2C addresses
const byte i2c_add1 = 0x20;
const byte i2c_add2 = 0x21;
const byte i2c_add3 = 0x22;
const byte i2c_add4 = 0x23;

// Speaker output pin
const byte SPEAKER = A0;

const byte SPEAKER_ENABLE_PIN = 5;       //Trigger pin D5

// Strings to hold locations
static String location1 = "Northeast";
static String location2 = "Northwest";
static String location3 = "Southeast";
static String location4 = "Southwest";

// How long to keep LEDs on for after activity was detected
int ledOnTime = 10000;

// Strings for message and location
static String msg = "";
static String location = "";

char deviceLoc[20];

// Variable to hold location of first closing carret
int firstClosingCarret = 0;

// Setup timer to turn off LED1 after a certain amount of time
void turnOffLed1();
Location loc1 = Location(location1, pinLight1, i2c_add1, noDelay(ledOnTime, turnOffLed1, false));

// Setup timer to turn off LED2 after a certain amount of time
void turnOffLed2();
Location loc2 = Location(location2, pinLight2, i2c_add2, noDelay(ledOnTime, turnOffLed2, false));

// Setup timer to turn off LED3 after a certain amount of time
void turnOffLed3();
Location loc3 = Location(location3, pinLight3, i2c_add3, noDelay(ledOnTime, turnOffLed3, false));

// Setup timer to turn off LED4 after a certain amount of time
void turnOffLed4();
Location loc4 = Location(location4, pinLight4, i2c_add4, noDelay(ledOnTime, turnOffLed4, false));


// Setup
void setup()
{
  debugln("Starting setup.");
  pinMode(StatusLED, OUTPUT);
  pinMode(loc1.light_pin, OUTPUT);
  pinMode(loc2.light_pin, OUTPUT);
  pinMode(loc3.light_pin, OUTPUT);
  pinMode(loc4.light_pin, OUTPUT);

  pinMode(SPEAKER_ENABLE_PIN, INPUT_PULLDOWN);
  pinMode(SPEAKER, OUTPUT);

  debug_begin(9600);
  if (DEBUG == 1)
  {
    // It may be difficult to read serial messages on startup. The following
    // line will wait for serial to be ready before continuing. Comment out if not needed.
    while(!Serial) delay(1);
    delay(100);
  }

  debugln("RFM Server!");

  // Manual reset
  digitalWrite(RFM95_RST, LOW);
  delay(10);
  digitalWrite(RFM95_RST, HIGH);
  delay(10);

  //Initialize the Radio. 
  while (!rf95.init()) {
    debugln("LoRa radio init failed");
    debugln("Uncomment '#define SERIAL_DEBUG' in RH_RF95.cpp for detailed debug info");
    while (1);
  }
  debugln("LoRa radio init OK!");

  // Defaults after init are 434.0MHz, modulation GFSK_Rb250Fd250, +13dbM
  if (!rf95.setFrequency(RF95_FREQ)) {
    debugln("setFrequency failed");
    while (1);
  }

  digitalWrite(StatusLED, LOW); //Turn off status LED

  scanner.Init();
  debugln("Finished with setup.");

 // The default transmitter power is 13dBm, using PA_BOOST.
 // If you are using RFM95/96/97/98 modules which uses the PA_BOOST transmitter pin, then 
 // you can set transmitter powers from 5 to 23 dBm:
 // rf95.setTxPower(14, false);
}

// Loop
void loop()
{

  // Check if set time has past and if so, will run set function
  loc1.timer.update();
  loc2.timer.update();
  loc3.timer.update();
  loc4.timer.update();
  
  if (rf95.available()){
    // Should be a message for us now
    uint8_t buf[RH_RF95_MAX_MESSAGE_LEN];
    uint8_t len = sizeof(buf);

    if (rf95.recv(buf, &len)){
      digitalWrite(StatusLED, HIGH); //Turn on status LED
      timeSinceLastPacket = millis(); //Timestamp this packet

      msg = (char*)buf;
      firstClosingCarret = msg.indexOf('>');
      location = msg.substring(1, firstClosingCarret);

      if (location == location1){
        handleActivity(&loc1);
      }
      else if (location == location2){
        handleActivity(&loc2);
      }
      else if (location == location3){
        handleActivity(&loc3);
      }
      else if (location == location4){
        handleActivity(&loc4);
      }

      debug("Got message: ");
      debug((char*)buf);
      debug(rf95.headerId(), DEC);
      //SerialUSB.print(" RSSI: ");
      //SerialUSB.print(rf95.lastRssi(), DEC);
      debugln();
    }
    else
      debugln("Recieve failed");
  }

  else
  {
    // debugln("Radio not available");
  }
    
  //Turn off status LED if we haven't received a packet after 1s
  if(millis() - timeSinceLastPacket > 1000){
    digitalWrite(StatusLED, LOW); //Turn off status LED
    timeSinceLastPacket = millis(); //Don't write LED but every 1s
  }
}

// Function to turn off LED1
void turnOffLed1()
{
  // Set pin low
  digitalWrite(loc1.light_pin, LOW);
  setI2CPin(loc1.i2c_add, 0, LOW);
  debugln("Turned off LED1");
  // Stop the timer
  loc1.timer.stop();
}

// Function to turn off LED2
void turnOffLed2()
{
  // Set pin low
  digitalWrite(loc2.light_pin, LOW);
  setI2CPin(loc2.i2c_add, 0, LOW);
  debugln("Turned off LED2");
  // Stop the timer
  loc2.timer.stop();
}

// Function to turn off LED3
void turnOffLed3()
{
  // Set pin low
  digitalWrite(loc3.light_pin, LOW);
  setI2CPin(loc3.i2c_add, 0, LOW);
  debugln("Turned off LED3");
  // Stop the timer
  loc3.timer.stop();
}

// Function to turn off LED4
void turnOffLed4()
{
  // Set pin low
  digitalWrite(loc4.light_pin, LOW);
  setI2CPin(loc4.i2c_add, 0, LOW);
  debugln("Turned off LED4");
  // Stop the timer
  loc4.timer.stop();
}

void chirp() {  // Bird chirp
  // Only output sound if speaker enable pin is pulled low
  if (digitalRead(SPEAKER_ENABLE_PIN) == HIGH)
  {
    debugln("Chirped!");
    for(uint8_t i=180; i>160; i--)
      playTone(i,3);
    delay(100);
    for(uint8_t i=180; i>160; i--)
      playTone(i,3);
  }
  else
  {
    debugln("Speaker not enabled. No chirp.")
  }

}

void playTone(uint16_t tone1, uint16_t duration) {
  if(tone1 < 50 || tone1 > 15000) return;  // these do not play on a piezo
  for (long i = 0; i < duration * 1000L; i += tone1 * 2) {
    digitalWrite(SPEAKER, HIGH);
    delayMicroseconds(tone1);
    digitalWrite(SPEAKER, LOW);
    delayMicroseconds(tone1);
  }     
}

void handleActivity(Location *loc)
{
  // Momentarily write pin low to indicate more activity if the light is already on
  debugln("Handling activity.");
  digitalWrite((*loc).light_pin, LOW);
  debugln("Wrote pin.");
  setI2CPin((*loc).i2c_add, 0, LOW);
  debugln("Wrote I2C.");

  // Delay
  delay(100);

  // Write high
  digitalWrite((*loc).light_pin, HIGH);
  setI2CPin((*loc).i2c_add, 0, HIGH); 

  // Chirp sound
  chirp();
  // Set timer
  (*loc).timer.start();
}


  // Parse the values from the string
  sscanf(input, "<%s>: VBATT=%.2fV", &device, &voltage);

void setI2CPin(uint8_t address, uint8_t module_pin, bool val)
{
  // If address is accessible, write to it. If not, output debug message.
  // if (!pcf.begin(address, &Wire)) {
  //   debugln("Couldn't find PCF8574");
  // }
  connected = scanner.Check(address);

  if (connected)
  {
    debugln("I2C device connected.");
    pcf.begin(address, &Wire);
    pcf.pinMode(module_pin, OUTPUT);
    debugln("Sending I2C");
    pcf.digitalWrite(module_pin, !val);
  }
  else
  {
    debugln("I2C device not detected.")
  }
}

Transmitter Code (v1.0)

// Adafruit Feather M0
// Wireless Deer Notification Transmitter v1.0

#include <SPI.h>
#include <RH_RF95.h>
#include "ArduinoLowPower.h"

// Feather M0 w/Radio
#define RFM95_CS    8
#define RFM95_INT   3
#define RFM95_RST   4

// Change to 434.0 or other frequency, must match RX's freq!
#define RF95_FREQ 915.0


const int LED = 13;               //Status LED is on pin 13
const byte TRIGGER_PIN = 5;       //Trigger pin D5

uint8_t sendMessageFlag;            //Holds the state for sending message
const uint32_t oneShotTime = 3000;  //One-Shot time to reject double-triggers

char deviceID1[] = "<Southwest>"; //Device location
char msg1[100] = "";
uint8_t headerID = 0;

// Singleton instance of the radio driver
RH_RF95 rf95(RFM95_CS, RFM95_INT);

void setup() {
  // Setup status LED
  pinMode(LED, OUTPUT);
  // Setup trigger pin
  pinMode(TRIGGER_PIN, INPUT_PULLDOWN);
  // Initialize sendMessageFlag to 1, so we don't miss the first trigger
  sendMessageFlag = 1;

  // Initialize the Radio.
  if (rf95.init() == false)
  {
    // SerialUSB.println("Radio Init Failed - Freezing");
    while (1);
  }
  else
  {
    // An LED inidicator to let us know radio initialization has completed. 
    // SerialUSB.println("Transmitter up!"); 
    digitalWrite(LED, HIGH);
    delay(500);
    digitalWrite(LED, LOW);
    delay(500);
  }

  // Set frequency
  rf95.setFrequency(RF95_FREQ);

   // The default transmitter power is 13dBm, using PA_BOOST.
   // If you are using RFM95/96/97/98 modules which uses the PA_BOOST transmitter pin, then 
   // you can set transmitter powers from 5 to 23 dBm:
   // Transmitter power can range from 14-20dbm.
   rf95.setTxPower(20, false);
  //  rf95.setHeaderFrom(*(uint8_t*)deviceID);

  // Attach interrupt to trigger pin
  LowPower.attachInterruptWakeup(TRIGGER_PIN, onInterrupt, RISING);
  // Delay before going to sleep
  delay(5000);
  // Go to sleep
  GoToSleep();
}

// Main loop
void loop()
{
  // If sendMessageFlag is set
  if(sendMessageFlag == 1)
  {
    // Send message
    sendMessage();
    // Delay
    delay(oneShotTime);
    // Go to sleep
    GoToSleep();
  }
}

// Send message method
void sendMessage()
{
  // SerialUSB.println("Sending message");
  // Set radio in TX mode (not sure if this is 100% necessary)
  rf95.setModeTx();
  // delay(1000);
  //Format message
  // char str[] = "Hi there!";
  msg1[0] = (char)0;
  strcat(msg1, deviceID1);
  // strcat(msg1, str);

  // Set header ID
  rf95.setHeaderId(headerID);
  // Turn on status LED
  digitalWrite(LED, HIGH);
  // Send the message
  rf95.send((uint8_t*)msg1, sizeof(msg1));
  // Wait for message to finish sending
  rf95.waitPacketSent();
  // Turn off status LED
  digitalWrite(LED, LOW);
  // Increment header ID
  headerID++;
}

// Go to sleep method
void GoToSleep()
{
  // Set the flag low
  sendMessageFlag = 0;
  // Put radio to sleep
  rf95.sleep();
  // Put processor to sleep
  LowPower.deepSleep();
}

// Interrupt callback
void onInterrupt()
{
  // If the send message flag hasn't been set, set it
  if(sendMessageFlag == 0)
    sendMessageFlag = 1;
}

Custom PCB Design

After building the receiver prototype, I quickly realized that I did not want to use perf board to make all these units. The amount of time it took me to solder up everything for one board was about 8 hours. At that point, I knew I wanted professionally made PCBs that I could use SMD and thru hole components on. I had never done this before, but have worked on my share of circuit boards, so I already had a good idea of how they are typically laid out. I had seen some ads for JLCPCB on a YouTube channel I watch, so I figured I’d start there. From there, I found a software called EasyEDA which allows for circuit schematic layout and PCB layout. I downloaded the software and started layout out my schematic.

After layout out the circuit schematic, I then started layout out the traces for the PCB. I went through a few iterations of PCB layout. A few had some design flaws, but the third and final one I ended up with can be seen below. To keep cost down, I designed the board such that I could populate it as a receiver board or a transmitter board. Most of the pads for the transmitter board are unused.

Some really nice features to the EasyEDA were that it has built-in libraries of parts and also has parts that have been added by the community. On top of that, it also allows you to see a 3D rendering of the PCB with all the components on it which I found to be really helpful to understand potential spacing constraint issues..

I ended up designing another PCB for the LED indicators too.

Build

After receiving the PCBs about a week later, I was then able to solder up all the components and put it into a waterproof enclosure. Below is a picture of the receiver board with the LED indicator board.

A picture of the receiver unit closed up, showing the LED indicator lights.

On the bottom left is a switch to turn on/off the speaker and on the bottom right is the main power switch. In the middle of the two switches is a USB-C port for charging the battery. Unlike the transmitters which draw very little current due to being in deep sleep most of the time, the receiver must always be listening for messages and thus draws much more current (~120mA) on average. With this battery, that would only last me about 29 hours, so I put the USB-C port in there so I could charge the battery while sitting in my deer blind with a battery bank.

On the top right is an M8 connector which serves as the remote I/O port and utilizes I2C comms. At the top left is an SMA connector for the antenna.

The next step was to design the remote LEDs. I wanted to use the same M8 4-pin bulkhead connectors, so I knew I needed to get the number of pins down to 4. Having 4 remote LED indicators, I decided to use the I2C protocol to remotely control 4 I2C GPIO expanders. Using I2C would also allow me to expand the number of transmitters beyond 4 if I ever wanted to do so in the future.

A picture of a remote I/O LED indicator.

A picture of the inside of a remote I/O unit.

For the transmitter units, I decided to utilize the same PCB as the receiver to reduce cost and simplicity.

On the bottom of the transmitter unit is an M8 connector to receive the trigger signal from the camera.

On the top of the transmitter unit is an SMA connector for the antenna.

Receiver Code (v2.0)

After testing out the v1.0 code at home, and then installing the devices around my blind at camp, I decided it would be nice to have feedback from the transmitters when their battery voltage was below a certain threshold. Only having 4 LED indicators and a speaker to work with and working around the current functionality, I quickly realized that my v1.0 code was going to be very difficult to modify in order to get the feedback I wanted. I did not want to use the speaker for low battery indication because the speaker isn’t always enabled. This was by design so that I could turn off the audio feedback if I wanted to. The only other option was to use the 4 LED indicators which turn on for 10 seconds when activity is sensed for a given location.

I decided that a blink of the LED at the end of the 10 second on-time would be a good way to have decent indication of low battery without disturbing the existing functionality too much. To do this, I chose to use a state machine code design over the timer-based code in v1.0.

// Adafruit Feather M0
// Wireless Deer Notification Receiver v2.0

// Enable or disable debugging mode
#define DEBUG 0

// Include the necessary libraries for functionality
#include <SPI.h>                   // Library for SPI communication with the LoRa module
#include <RH_RF95.h>               // RadioHead library for LoRa communication
#include <string.h>                // Standard library for string operations
#include <NoDelay.h>               // Library for non-blocking delay functionality
#include <Adafruit_PCF8574.h>      // Library for using the I2C IO expander PCF8574
#include "I2CScanner.h"            // Custom utility to scan for connected I2C devices

// Define pin assignments for the Feather M0 LoRa board
#define RFM95_CS    8              // Chip select pin for the LoRa module
#define RFM95_INT   3              // Interrupt pin for the LoRa module
#define RFM95_RST   4              // Reset pin for the LoRa module

// Debugging macros: Define how to handle debugging based on the DEBUG flag
#if DEBUG
  #define debug_begin(...)  Serial.begin(__VA_ARGS__)     // Initialize serial communication for debugging
  #define debug(...)        Serial.print(__VA_ARGS__)     // Print debug messages
  #define debugln(...)      Serial.println(__VA_ARGS__)   // Print debug messages with a newline
#else
  #define debug_begin(...)  // Do nothing if debugging is disabled
  #define debug(...)        // Do nothing if debugging is disabled
  #define debugln(...)      // Do nothing if debugging is disabled
#endif

// Set the operating frequency for the LoRa radio (must match the transmitter's frequency)
#define RF95_FREQ 915.0             // Frequency in MHz

// Create a singleton instance of the RH_RF95 driver
RH_RF95 rf95(RFM95_CS, RFM95_INT);  // Arguments: chip select pin and interrupt pin

// Create an instance of the I2C expander object for additional I/O control
Adafruit_PCF8574 pcf;

// Create an instance of the I2C scanner utility
I2CScanner scanner;

// Define possible states for each location
enum State { OFF, ON, TRIGGER_BLINK, BLINKING_OFF };

// Flag to indicate if the I2C device is connected
bool connected = false;

// Pin definition for the status LED
int StatusLED = 13;                 // Status LED on pin 13

// Variable for keeping time since last packet was received
unsigned long timeSinceLastPacket = 0; // Timestamp of the last packet received

const unsigned long t_On = 10000;          // Time in milliseconds for LED to stay on
const unsigned long blinkDuration = 100;   // Duration for blink on retrigger
const unsigned long preOffBlinkTime = 150; // Time before t_On to start pre-off blink. preOffBlinkTime should be 1.5-2 times larger than blinkDuration
const float lowBatteryThreshold = 3.6;     // Voltage threshold for low battery

// Speaker output pin
const byte SPEAKER = A0;

const byte SPEAKER_ENABLE_PIN = 5;       //Trigger pin D5

// Variable to store where the current message came from
char msg_loc[10];

// Variable to store voltage from transmitter of current message
float msg_voltage;


// Function to set the state of a pin on an I2C device
void setI2CPin(uint8_t address, uint8_t module_pin, bool val){
  // Check if the I2C device at the given address is connected
  connected = scanner.Check(address);

  // If the I2C device is connected, proceed to configure and control the pin
  if (connected){
    // Initialize the PCF8574 I2C expander at the specified address using the Wire library
    pcf.begin(address, &Wire);

    // Set the specified pin on the I2C expander as an output pin
    pcf.pinMode(module_pin, OUTPUT);

    // Write the desired value to the pin
    // Note: The value is inverted (!val) because the expander uses active-low logic
    pcf.digitalWrite(module_pin, !val);
  } 
  else {
    // If the I2C device is not detected, print an error message
    debugln("I2C device not detected.");
  }
}


// Function to generate a "bird chirp" sound effect
void chirp() {
  // Only output sound if the speaker enable pin is pulled high
  if (digitalRead(SPEAKER_ENABLE_PIN) == HIGH) {
    debugln("Chirped!");  // Output a debug message indicating that the chirp sound is being played

    // Loop to generate a series of tones, starting from frequency 180 and decreasing to 160
    for (uint8_t i = 180; i > 160; i--) {
      playTone(i, 3);  // Play a tone at frequency 'i' for 3 milliseconds
    }

    delay(100);  // Short pause between the two chirps

    // Repeat the tone series to complete the "chirp" effect
    for (uint8_t i = 180; i > 160; i--) {
      playTone(i, 3);  // Play a tone at frequency 'i' for 3 milliseconds
    }
  } 
  else {
    debugln("Speaker not enabled. No chirp.");  // Output a debug message if the speaker is not enabled
  }
}


// Function to play a tone on a speaker
// Parameters:
// - tone1: The frequency of the tone in microseconds
// - duration: The duration for which to play the tone, in milliseconds
void playTone(uint16_t tone1, uint16_t duration) {
  // Check if the tone frequency is within a valid range for a piezo speaker
  if (tone1 < 50 || tone1 > 15000) return;  // If the frequency is too low or too high, exit the function

  // Loop to generate the tone for the specified duration
  for (long i = 0; i < duration * 1000L; i += tone1 * 2) {
    digitalWrite(SPEAKER, HIGH);            // Set the speaker pin high to create sound
    delayMicroseconds(tone1);               // Wait for the specified tone frequency in microseconds
    digitalWrite(SPEAKER, LOW);             // Set the speaker pin low to stop the sound
    delayMicroseconds(tone1);               // Wait again for the specified tone frequency
  }
}


// Struct to represent a location with associated LED, I2C, and state information
struct Location {
    byte ledPin;                  // The pin number for the LED associated with this location
    byte i2cAddress;              // The I2C address of the external device (e.g., I/O expander)
    float voltage;                // Voltage level, used to determine low battery status
    const char* locationID;       // A string identifier for the location (e.g., "Location A")
    State state;                  // Current state of the LED (e.g., ON, OFF, BLINKING)
    unsigned long previousMillis; // Stores the time when the LED state was last changed
    unsigned long blinkStartTime; // Time when a temporary blink started
    bool preOffBlinkDone;         // Flag to ensure the pre-off blink occurs only once

    // Method to handle LED state updates based on elapsed time and voltage level
    void updateState() {
        unsigned long currentMillis = millis(); // Get the current time

        // Use a switch statement to handle different LED states
        switch (state) {
            case TRIGGER_BLINK:
                // If the initial blink duration has passed, turn the LED on and switch to ON state
                if (currentMillis - blinkStartTime >= blinkDuration) {
                    state = ON;                                // Change state to ON
                    digitalWrite(ledPin, HIGH);                // Turn the LED on
                    setI2CPin(i2cAddress, 0, true);            // Set the I2C pin to active
                    previousMillis = millis();                 // Record the time the LED was turned on
                    debugln("Transition to ON after initial blink.");
                }
                break;

            case ON:
                // If the voltage is low and pre-off blink hasn't been done, start a pre-off blink
                if (!preOffBlinkDone && voltage < lowBatteryThreshold &&
                    currentMillis - previousMillis >= t_On - preOffBlinkTime) {
                    state = BLINKING_OFF;                      // Change state to BLINKING_OFF
                    blinkStartTime = currentMillis;            // Record the start time of the blink
                    digitalWrite(ledPin, LOW);                 // Blink the LED off
                    preOffBlinkDone = true;                    // Mark that the pre-off blink is done
                    setI2CPin(i2cAddress, 0, false);           // Set the I2C pin to inactive
                    debugln("Entering BLINKING_OFF due to low battery.");
                }
                // If the LED has been on for `t_On` duration, turn it off and transition to OFF state
                else if (currentMillis - previousMillis >= t_On) {
                    state = OFF;                               // Change state to OFF
                    digitalWrite(ledPin, LOW);                 // Turn the LED off
                    setI2CPin(i2cAddress, 0, false);           // Set the I2C pin to inactive
                    debugln("Transition to OFF after t_On expires.");
                }
                break;

            case BLINKING_OFF:
                // If the blink duration has passed, turn the LED back on and switch to ON state
                if (currentMillis - blinkStartTime >= blinkDuration) {
                    state = ON;                                // Change state to ON
                    digitalWrite(ledPin, HIGH);                // Turn the LED back on
                    setI2CPin(i2cAddress, 0, true);            // Set the I2C pin to active
                    debugln("Returning to ON after low battery blink.");
                }
                break;

            case OFF:
                // Keep the LED off; no action is needed while in the OFF state
                break;
        }
    }

    // Method to trigger an initial blink on receiving a new message and reset the timer
    void trigger() {
        state = TRIGGER_BLINK;          // Set state to TRIGGER_BLINK to start a brief blink
        blinkStartTime = millis();      // Record the start time of the blink
        digitalWrite(ledPin, LOW);      // Turn the LED off briefly for the blink
        setI2CPin(i2cAddress, 0, false); // Set the I2C pin to inactive
        preOffBlinkDone = false;        // Reset the pre-off blink flag
        debug("Triggering LED for ");   // Debug message indicating which location is being triggered
        debugln(locationID);

        // Call an external function to play a chirp sound to indicate a new message
        chirp();
    }
};


// Array to hold all transmitter location objects
Location locations[] = {
    {A1, 0x20, 0.0, "Northeast", OFF, 0, 0, true},
    {A2, 0x21, 0.0, "Northwest", OFF, 0, 0, true},
    {A3, 0x22, 0.0, "Southeast", OFF, 0, 0, true},
    {A4, 0x23, 0.0, "Southwest", OFF, 0, 0, true}
};


// Setup
void setup() {
  debugln("Starting setup.");           // Print a debug message indicating the setup process has started
  pinMode(StatusLED, OUTPUT);           // Set the status LED pin as an output

  // Loop through each Location object in the locations array
  for (Location &loc : locations) {
    pinMode(loc.ledPin, OUTPUT);        // Set each location's LED pin as an output
    digitalWrite(loc.ledPin, LOW);      // Ensure all LEDs are turned off
    setI2CPin(loc.i2cAddress, 0, false); // Set the I2C pin to inactive for each location
  }

  pinMode(SPEAKER_ENABLE_PIN, INPUT_PULLDOWN); // Set the speaker enable pin as an input with pull-down resistor
  pinMode(SPEAKER, OUTPUT);                    // Set the speaker pin as an output

  debug_begin(9600);               // Initialize serial communication for debugging at 9600 baud rate
  if (DEBUG == 1) {                // If debugging is enabled
    // Wait for the serial port to be ready before continuing
    // This is useful when monitoring serial messages at startup
    while (!Serial) delay(1);      // Wait until the Serial port is ready
    delay(100);                    // Short delay to stabilize serial communication
  }

  debugln("RFM Server!");          // Print a message indicating the RFM server initialization

  // Perform a manual reset of the LoRa module
  digitalWrite(RFM95_RST, LOW);    // Pull the reset pin low to reset the module
  delay(10);                       // Wait for 10 milliseconds
  digitalWrite(RFM95_RST, HIGH);   // Pull the reset pin high to complete the reset
  delay(10);                       // Wait another 10 milliseconds for stabilization

  // Initialize the LoRa radio module
  while (!rf95.init()) {               // Attempt to initialize the radio
    debugln("LoRa radio init failed"); // Print an error message if initialization fails
    debugln("Uncomment '#define SERIAL_DEBUG' in RH_RF95.cpp for detailed debug info"); // Suggest enabling detailed debug info if necessary
    while (1);                         // Halt the program indefinitely if initialization fails
  }
  debugln("LoRa radio init OK!");      // Print a success message if initialization is successful

  // Set the frequency for the LoRa radio
  if (!rf95.setFrequency(RF95_FREQ)) {  // Attempt to set the radio frequency
    debugln("setFrequency failed");     // Print an error message if frequency setup fails
    while (1);                          // Halt the program if frequency setup fails
  }

  // Turn off the status LED after setup
  digitalWrite(StatusLED, LOW);    
  // Initialize the I2C scanner to detect connected I2C devices
  scanner.Init();                  
  debugln("Finished with setup."); // Print a debug message indicating setup completion
}

// Get locationID and update corresponding Location object in array
void receiveMessage(const char* locationID, float voltage) {
    for (Location &loc : locations) {
        if (strcmp(locationID, loc.locationID) == 0) {
            loc.voltage = voltage;         // Update voltage
            debug(loc.locationID);         // Show location and voltage
            debug(" V=");
            debugln(loc.voltage);
            loc.trigger();                 // Trigger LED, chirp, and I2C
        }
    }
}


// Main loop
void loop(){
  if (rf95.available()){
    // Should be a message for us now
    uint8_t buf[RH_RF95_MAX_MESSAGE_LEN];
    uint8_t len = sizeof(buf);

    // Receive the message
    if (rf95.recv(buf, &len)){
      digitalWrite(StatusLED, HIGH); //Turn on status LED
      timeSinceLastPacket = millis(); // Upate time since last packet was received

      // If items are parsed out correctly, this is likely a message from one of the transmitters
      if (parseData((char*)buf, msg_loc, &msg_voltage)){
        receiveMessage(msg_loc, msg_voltage); // Process received location and voltage
      }
    }

    else{
      debugln("Recieve failed");
    }
  }

  // Update state for each location based on timers
  for (Location &loc : locations) {
      loc.updateState();
  }
    
  //Turn off status LED if we haven't received a packet after 1s
  if(millis() - timeSinceLastPacket > 1000){
    digitalWrite(StatusLED, LOW); //Turn off status LED
    timeSinceLastPacket = millis(); //Don't write LED but every 1s
  }
}


// Function to parse message and get location and voltage from it.
bool parseData(const char *input, char *location, float *voltage) {
    // Temporary buffer for voltage as a string
    char voltageStr[10]; 

    // Locate the start and end of the location name using '<' and '>'
    char *startLoc = strchr(input, '<');
    char *endLoc = strchr(input, '>');
    if (!startLoc || !endLoc || endLoc <= startLoc) return false; // Early return if location markers are invalid

    // Copy and null-terminate the location name
    int locLength = endLoc - startLoc - 1;
    strncpy(location, startLoc + 1, locLength);
    location[locLength] = '\0';

    // Locate the start of the voltage value and the end 'V' character
    char *startVolt = strchr(input, '=');
    char *endVolt = strrchr(input, 'V');
    if (!startVolt || !endVolt || endVolt <= startVolt) return false; // Early return if voltage markers are invalid

    // Copy and null-terminate the voltage value
    int voltLength = endVolt - startVolt - 1;
    strncpy(voltageStr, startVolt + 1, voltLength);
    voltageStr[voltLength] = '\0';

    // Convert the voltage string to a float
    *voltage = atof(voltageStr);
    if (*voltage == 0.0 && strcmp(voltageStr, "0") != 0) return false; // Check if conversion failed

    // If both parsing steps are successful, return true
    return true;
}

Transmitter Code (v2.0)

// Adafruit Feather M0
// Wireless Deer Notification Transmitter v2.0

// Enable or disable debugging mode
#define DEBUG 0

// Debugging macros: Define how to handle debugging based on the DEBUG flag
#if DEBUG
  #define debug_begin(...)  Serial.begin(__VA_ARGS__)     // Initialize serial communication for debugging
  #define debug(...)        Serial.print(__VA_ARGS__)     // Print debug messages
  #define debugln(...)      Serial.println(__VA_ARGS__)   // Print debug messages with a newline
#else
  #define debug_begin(...)  // Do nothing if debugging is disabled
  #define debug(...)        // Do nothing if debugging is disabled
  #define debugln(...)      // Do nothing if debugging is disabled
#endif

#include <SPI.h>
#include <RH_RF95.h>
#include "ArduinoLowPower.h"
#include <stdint.h>
#include <stdio.h>

// Define pin assignments for the Feather M0 LoRa board
#define RFM95_CS    8              // Chip select pin for the LoRa module
#define RFM95_INT   3              // Interrupt pin for the LoRa module
#define RFM95_RST   4              // Reset pin for the LoRa module

// Battery voltage pin
#define VBAT_PIN A7
// Battery voltage variable
float measuredvbat;

// Set the operating frequency for the LoRa radio (must match the receiver's frequency)
#define RF95_FREQ 915.0

const int LED = 13;               // Status LED is on pin 13
const byte TRIGGER_PIN = 5;       // Trigger pin D5

uint8_t sendMessageFlag;            // Holds the state for sending message
const uint32_t oneShotTime = 3000;  // One-Shot time to reject double-triggers

char deviceID[] = "<Southwest>";  // Device location
char msg[100] = "";               // Variable to hold message
uint8_t headerID = 0;             // Message header ID

// Create a singleton instance of the RH_RF95 driver
RH_RF95 rf95(RFM95_CS, RFM95_INT);

// Setup
void setup() {

  debug_begin(9600);               // Initialize serial communication for debugging at 9600 baud rate
  if (DEBUG == 1) {                // If debugging is enabled
    // Wait for the serial port to be ready before continuing
    // This is useful when monitoring serial messages at startup
    while (!Serial) delay(1);      // Wait until the Serial port is ready
    delay(100);                    // Short delay to stabilize serial communication
  }

  debugln("RFM Client!");          // Print a message indicating the RFM client initialization

  // Setup status LED
  pinMode(LED, OUTPUT);
  // Setup trigger pin
  pinMode(TRIGGER_PIN, INPUT_PULLDOWN);
  pinMode(VBAT_PIN, INPUT);
  // Initialize sendMessageFlag to 1, so we don't miss the first trigger
  sendMessageFlag = 1;

  // Perform a manual reset of the LoRa module
  digitalWrite(RFM95_RST, LOW);    // Pull the reset pin low to reset the module
  delay(10);                       // Wait for 10 milliseconds
  digitalWrite(RFM95_RST, HIGH);   // Pull the reset pin high to complete the reset
  delay(10);                       // Wait another 10 milliseconds for stabilization

  // Initialize the Radio.
  if (rf95.init() == false){
    // SerialUSB.println("Radio Init Failed - Freezing");
    while (1);
  }

  else{
    // An LED inidicator to let us know radio initialization has completed. 
    // SerialUSB.println("Transmitter up!"); 
    digitalWrite(LED, HIGH);
    delay(500);
    digitalWrite(LED, LOW);
    delay(500);
  }

  // Set frequency
  rf95.setFrequency(RF95_FREQ);

   // The default transmitter power is 13dBm, using PA_BOOST.
   // If you are using RFM95/96/97/98 modules which uses the PA_BOOST transmitter pin, then 
   // you can set transmitter powers from 5 to 23 dBm:
   // Transmitter power can range from 14-20dbm.
   rf95.setTxPower(20, false);
  //  rf95.setHeaderFrom(*(uint8_t*)deviceID);

  // Attach interrupt to trigger pin
  LowPower.attachInterruptWakeup(TRIGGER_PIN, onInterrupt, RISING);

  if (DEBUG == 0){
    // Delay before going to sleep
    delay(5000);
    // Go to sleep
    GoToSleep();
  }
}

// Main loop
void loop(){
  // If sendMessageFlag is set
  if(sendMessageFlag == 1){
    // Send message
    sendMessage();
    // Delay
    delay(oneShotTime);
    // Go not debugging, go to sleep
    if (DEBUG == 0){
      GoToSleep();
    }
  }
}

// Send message method
void sendMessage()
{
  // SerialUSB.println("Sending message");
  // Set radio in TX mode (not sure if this is 100% necessary)
  rf95.setModeTx();
  // delay(1000);
  //Format message
  float batteryVoltage = getBatteryVoltage();
  
  // Get the message with the device ID and battery voltage
  char* message = createMessage(msg, deviceID, batteryVoltage);

  // Set header ID
  rf95.setHeaderId(headerID);
  // Turn on status LED
  digitalWrite(LED, HIGH);
  // Send the message

  debugln(message);
  uint8_t buff[strlen(message)+1];

  // Copy the data into destination
  memcpy(buff, (uint8_t*)message, strlen(message));
  buff[strlen(message)] = 0;

  rf95.send(buff, strlen(message)+1);
  // Wait for message to finish sending
  rf95.waitPacketSent();
  // Turn off status LED
  digitalWrite(LED, LOW);
  // Increment header ID
  headerID++;
}

// Enter deep sleep
void GoToSleep(){
  // Set the sendMessageFlag low
  sendMessageFlag = 0;
  // Put radio to sleep
  rf95.sleep();
  // Put processor to sleep
  LowPower.deepSleep();
}

// Calculate battery voltage
float getBatteryVoltage(){
  float battery_voltage = analogRead(VBAT_PIN); // Read voltage on battery voltage pin
  battery_voltage *= 2;    // We divided by 2, so multiply back
  battery_voltage *= 3.3;  // Multiply by 3.3V, our reference voltage
  battery_voltage /= 1024; // Convert to voltage
  return battery_voltage;
}

// Function to create a message with the device ID and battery voltage
// The buffer is passed as an argument, and the function returns the pointer to the buffer
char* createMessage(char* buffer, const char* deviceID, float voltage) {
  // Format the string into the provided buffer
  snprintf(buffer, sizeof(msg), "%s: VBATT=%.2fV", deviceID, voltage);
  
  // Return the buffer (which is a char array)
  return buffer;
}

// Interrupt callback for when a trigger occurs
void onInterrupt(){
  // If the send message flag hasn't been set, set it
  if(sendMessageFlag == 0)
    sendMessageFlag = 1;
}