# MicroPython lora reliable_delivery example - asynchronous sender program
# MIT license; Copyright (c) 2023 Angus Gratton
import machine
from machine import SPI, Pin
import random
import struct
import time
import asyncio

from lora_rd_settings import RECEIVER_ID, ACK_LENGTH, ACK_DELAY_MS, lora_cfg

SLEEP_BETWEEN_MS = 5000  # Main loop should sleep this long between sending data to the receiver

MAX_RETRIES = 4  # Retry each message this often if no ACK is received

# Initial retry is after this long. Increases by 1.25x each subsequent retry.
BASE_RETRY_TIMEOUT_MS = 1000

# Add random jitter to each retry period, up to this long. Useful to prevent two
# devices ending up in sync.
RETRY_JITTER_MS = 1500

# If reported RSSI value is lower than this, increase
# output power 1dBm
RSSI_WEAK_THRESH = -110

# If reported RSSI value is higher than this, decrease
# output power 1dBm
RSSI_STRONG_THRESH = -70

# IMPORTANT: Set this to the maximum output power in dBm that is permitted in
# your regulatory environment.
OUTPUT_MAX_DBM = 15
OUTPUT_MIN_DBM = -20


def get_async_modem():
    # from lora import AsyncSX1276
    # return AsyncSX1276(
    #     spi=SPI(1, baudrate=2000_000, polarity=0, phase=0,
    #             miso=Pin(19), mosi=Pin(27), sck=Pin(5)),
    #     cs=Pin(18),
    #     dio0=Pin(26),
    #     dio1=Pin(35),
    #     reset=Pin(14),
    #     lora_cfg=lora_cfg,
    # )
    raise NotImplementedError("Replace this function with one that returns a lora modem instance")


def main():
    modem = get_async_modem()
    asyncio.run(sender_task(modem))


async def sender_task(modem):
    # Unique ID of this sender, 16-bit number. This method of generating an ID is pretty crummy,
    # if using this in a real application then probably better to store these in the filesystem or
    # something like that
    DEVICE_ID = sum(b for b in machine.unique_id()) & 0xFFFF

    sender = AsyncSender(modem, DEVICE_ID)
    while True:
        sensor_data = await get_sensor_data()
        await sender.send(sensor_data)

        # Sleep until the next time we should read the sensor data and send it to
        # the receiver. awaiting here means other tasks will run.
        modem.sleep()
        await asyncio.sleep_ms(SLEEP_BETWEEN_MS)


async def get_sensor_data():
    # Return a bytes object with the latest sensor data to send to the receiver.
    #
    # As this is just an example, we send a dummy payload which is just a string
    # containing our ticks_ms() timestamp.
    #
    # In a real application the sensor data should usually be binary data and
    # not a string, to save transmission size.
    return f"Hello, ticks_ms={time.ticks_ms()}".encode()


class AsyncSender:
    def __init__(self, modem, device_id):
        self.modem = modem
        self.device_id = device_id
        self.counter = 0
        self.output_power = lora_cfg["output_power"]  # start with common settings power level
        self.rx_ack = None  # reuse the ack message object when we can

        print(f"Sender initialized with ID {device_id:#x}")
        random.seed(device_id)
        self.adjust_output_power(0)  # set the initial value within MIN/MAX

        modem.calibrate()

    async def send(self, sensor_data, adjust_output_power=True):
        # Send a packet of sensor data to the receiver reliably.
        #
        # Returns True if data was successfully sent and ACKed, False otherwise.
        #
        # If adjust_output_power==True then increase or decrease output power
        # according to the RSSI reported in the ACK packet.
        self.counter = (self.counter + 1) & 0xFF

        # Prepare the simple payload with header and checksum
        # See README for a summary of the simple data message format
        payload = bytearray(len(sensor_data) + 5)
        struct.pack_into("<HBB", payload, 0, self.device_id, self.counter, len(sensor_data))
        payload[4:-1] = sensor_data
        payload[-1] = sum(b for b in payload) & 0xFF

        # Calculate the time on air (in milliseconds) for an ACK packet
        ack_packet_ms = self.modem.get_time_on_air_us(ACK_LENGTH) // 1000 + 1
        timeout = BASE_RETRY_TIMEOUT_MS

        print(f"Sending {len(payload)} bytes")

        # Send the payload, until we receive an acknowledgement or run out of retries
        for _ in range(MAX_RETRIES):
            sent_at = await self.modem.send(payload)

            # We expect the receiver of a valid message to start sending the ACK
            # approximately ACK_DELAY_MS after receiving the message (to allow
            # the sender time to reconfigure the modem.)
            #
            # We start receiving as soon as we can, but allow up to
            # ACK_DELAY_MS*2 of total timing leeway - plus the time on air for
            # the packet itself
            maybe_ack = await self.modem.recv(
                ack_packet_ms + ACK_DELAY_MS * 2, rx_packet=self.rx_ack
            )

            # Check if the packet we received is a valid ACK
            rssi = self._ack_is_valid(maybe_ack, payload[-1])

            if rssi is not None:  # ACK is valid
                self.rx_ack == maybe_ack

                delta = time.ticks_diff(maybe_ack.ticks_ms, sent_at)
                print(
                    f"ACKed with RSSI {rssi}, {delta}ms after sent "
                    + f"(skew {delta-ACK_DELAY_MS-ack_packet_ms}ms)"
                )

                if adjust_output_power:
                    if rssi > RSSI_STRONG_THRESH:
                        self.adjust_output_power(-1)
                    elif rssi < RSSI_WEAK_THRESH:
                        self.adjust_output_power(1)

                return True

            # Otherwise, prepare to sleep briefly and then retry
            next_try_at = time.ticks_add(sent_at, timeout)
            sleep_time = time.ticks_diff(next_try_at, time.ticks_ms()) + random.randrange(
                RETRY_JITTER_MS
            )
            if sleep_time > 0:
                self.modem.sleep()
                await asyncio.sleep_ms(sleep_time)

            # add 25% timeout for next iteration
            timeout = (timeout * 5) // 4

        print(f"Failed, no ACK after {MAX_RETRIES} retries.")
        if adjust_output_power:
            self.adjust_output_power(2)
        self.modem.calibrate_image()  # try and improve the RX sensitivity for next time
        return False

    def _ack_is_valid(self, maybe_ack, csum):
        # Private function to verify if the RxPacket held in 'maybe_ack' is a valid ACK for the
        # current device_id and counter value, and provided csum value.
        #
        # If it is, returns the reported RSSI value from the packet.
        # If not, returns None
        if (not maybe_ack) or len(maybe_ack) != ACK_LENGTH:
            return None

        base_id, ack_id, ack_counter, ack_csum, rssi = struct.unpack("<HHBBb", maybe_ack)

        if (
            base_id != RECEIVER_ID
            or ack_id != self.device_id
            or ack_counter != self.counter
            or ack_csum != csum
        ):
            return None

        return rssi

    def adjust_output_power(self, delta_dbm):
        # Adjust the modem output power by +/-delta_dbm, max of OUTPUT_MAX_DBM
        #
        # (note: the radio may also apply its own power limit internally.)
        new = max(min(self.output_power + delta_dbm, OUTPUT_MAX_DBM), OUTPUT_MIN_DBM)
        self.output_power = new
        print(f"New output_power {new}/{OUTPUT_MAX_DBM} (delta {delta_dbm})")
        self.modem.configure({"output_power": self.output_power})


if __name__ == "__main__":
    main()
