# client_w.py Demo of a resilient asynchronous full-duplex ESP8266 client

# Released under the MIT licence.
# Copyright (C) Peter Hinch 2018

import usocket as socket
import uasyncio as asyncio
import ujson
import network
import utime
from machine import Pin
import primitives as asyn  # Stripped-down asyn.py
# Get local config. ID is string of form '1\n'
from client_id import MY_ID, PORT, SERVER


class Client():
    def __init__(self, timeout, loop):
        self.timeout = timeout
        self.led = Pin(2, Pin.OUT, value = 1)
        self._sta_if = network.WLAN(network.STA_IF)
        self._sta_if.active(True)
        self.server = socket.getaddrinfo(SERVER, PORT)[0][-1]  # server read
        self.evfail = asyn.Event(100)
        self.lock = asyn.Lock(100)  # 100ms pause
        self.connects = 0  # Connect count
        self.sock = None
        loop.create_task(self._run(loop))

    # Make an attempt to connect to WiFi. May not succeed.
    async def _connect(self, s):
        print('Connecting to WiFi')
        s.active(True)
        s.connect()  # ESP8266 remembers connection.
        # Break out on fail or success.
        while s.status() == network.STAT_CONNECTING:
            await asyncio.sleep(1)
        t = utime.ticks_ms()
        print('Checking WiFi stability for {}ms'.format(2 * self.timeout))
        # Timeout ensures stable WiFi and forces minimum outage duration
        while s.isconnected() and utime.ticks_diff(utime.ticks_ms(), t) < 2 * self.timeout:
            await asyncio.sleep(1)

    async def _run(self, loop):
        s = self._sta_if
        while True:
            while not s.isconnected():  # Try until stable for 2*server timeout
                await self._connect(s)
            print('WiFi OK')
            self.sock = socket.socket()
            try:
                self.sock.connect(self.server)
                self.sock.setblocking(False)
                await self.send(self.sock, MY_ID)  # Can throw OSError
            except OSError:
                pass
            else:
                self.evfail.clear()
                loop.create_task(asyn.Cancellable(self.reader)())
                loop.create_task(asyn.Cancellable(self.writer)())
                loop.create_task(asyn.Cancellable(self._keepalive)())
                await self.evfail  # Pause until something goes wrong
                await asyn.Cancellable.cancel_all()
                self.close()  # Close sockets
                print('Fail detected. Coros stopped, disconnecting.')
            s.disconnect()
            await asyncio.sleep(1)
            while s.isconnected():
                await asyncio.sleep(1)

    @asyn.cancellable
    async def reader(self):
        c = self.connects  # Count and transmit successful connects
        try:
            while True:
                r = await self.readline()  # OSError on fail
                if c == self.connects:  # If read succeeded
                    self.connects += 1  # update connect count
                d = ujson.loads(r)
                print('Got data', d)
        except OSError:
            self.evfail.set()

    @asyn.cancellable
    async def writer(self):
        data = [0, 0]
        try:
            while True:
                data[0] = self.connects  # Send connection count
                async with self.lock:
                    await self.send(self.sock, '{}\n'.format(ujson.dumps(data)))
                print('Sent data', data)
                data[1] += 1  # Packet counter
                await asyncio.sleep(5)
        except OSError:
            self.evfail.set()

    @asyn.cancellable
    async def _keepalive(self):
        tim = self.timeout * 2 // 3  # Ensure  >= 1 keepalives in server t/o
        try:
            while True:
                await asyncio.sleep_ms(tim)
                async with self.lock:
                    await self.send(self.sock, '\n')
        except OSError:
            self.evfail.set()

    # Read a line from nonblocking socket: reads can return partial data which
    # are joined into a line. Blank lines are keepalive packets which reset
    # the timeout: readline() pauses until a complete line has been received.
    async def readline(self):
        line = b''
        start = utime.ticks_ms()
        while True:
            if line.endswith(b'\n'):
                if len(line) > 1:
                    return line
                line = b''
                start = utime.ticks_ms()  # Blank line is keepalive
                self.led(not self.led())
            await asyncio.sleep_ms(100)  # nonzero wait seems empirically necessary
            d = self.sock.readline()
            if d == b'':
                raise OSError
            if d is not None:
                line = b''.join((line, d))
            if utime.ticks_diff(utime.ticks_ms(), start) > self.timeout:
                raise OSError
 
    async def send(self, s, d):  # Write a line to either socket.
        start = utime.ticks_ms()
        while len(d):
            ns = s.send(d)  # OSError if client fails
            d = d[ns:]  # Possible partial write
            await asyncio.sleep_ms(100)
            if utime.ticks_diff(utime.ticks_ms(), start) > self.timeout:
                raise OSError

    def close(self):
        print('Closing sockets.')
        if isinstance(self.sock, socket.socket):
            self.sock.close()


loop = asyncio.get_event_loop()
client = Client(1500, loop)  # Server timeout set by server side app: 1.5s
try:
    loop.run_forever()
finally:
    client.close()  # Close sockets in case of ctrl-C or bug
