import threading
import asyncio
import aiofiles
import logging
import functools
import sys
import re
import os
import traceback
import serial.tools.list_ports
import subprocess
import socket
import time
from notify import Notify

# my version
import libs.serial_asyncio.serial_asyncio

async_main_loop = None


class SerialConnection(asyncio.Protocol):
    def __init__(self, cb, f, is_net=False):
        super().__init__()
        self.log = logging.getLogger()  # getChild('SerialConnection')
        self.log.debug('SerialConnection: creating SerialConnection')
        self.cb = cb
        self.f = f
        self.cnt = 0
        self.is_net = is_net
        self._paused = False
        self._drain_waiter = None
        self._connection_lost = False
        self.transport = None

    def connection_made(self, transport):
        self.transport = transport
        self.log.debug('SerialConnection: port opened: {}'.format(transport))
        if self.is_net:
            # we don't want to buffer the entire file on the host
            transport.get_extra_info('socket').setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 2048)
            self.log.info("SerialConnection: Setting net tx buf to 2048")
            # for net we want to limit how much we queue up otherwise the whole file gets queued
            # this also gives us more progress more often
            transport.set_write_buffer_limits(high=1024, low=256)
            self.log.info('SerialConnection: Buffer limits: {} - {}'.format(transport._high_water, transport._low_water))
        else:
            transport.set_write_buffer_limits(high=1024, low=64)
            self.log.info('SerialConnection: Buffer limits: {} - {}'.format(transport._high_water, transport._low_water))
            # transport.serial.rts = False  # You can manipulate Serial object via transport
            transport.serial.reset_input_buffer()
            transport.serial.reset_output_buffer()
            # transport.serial.set_low_latency_mode(True)
            # print(transport.serial)

    def flush_queue(self):
        if not self.is_net and self.transport:
            self.transport.flush()

    def send_message(self, data, hipri=False):
        """ Feed a message to the sender coroutine. """
        self.log.debug('SerialConnection: send_message: {}'.format(data.strip()))
        self.transport.write(data.encode('latin1'))
        # print(self.transport.get_write_buffer_size())

    def data_received(self, data):
        # print('data received', repr(data))
        str = ''
        try:
            str = data.decode(encoding='latin1', errors='ignore')
        except Exception as err:
            self.log.error("SerialConnection: Got decode error on data {}: {}".format(repr(data), err))
            # send it upstream anyway
            self.cb.incoming_data(repr(data), True)
            return

        self.cb.incoming_data(str)

    def connection_lost(self, exc):
        self.log.debug('SerialConnection: port closed')
        self._connection_lost = True

        # Wake up the writer if currently paused.
        if self._paused:
            waiter = self._drain_waiter
            if waiter:
                self._drain_waiter = None
                if not waiter.done():
                    if exc is None:
                        waiter.set_result(None)
                    else:
                        waiter.set_exception(exc)

        # if not self.is_net:
        #     self.transport.serial.reset_output_buffer()

        self.transport.close()
        self.f.set_result('Disconnected')

    async def _drain_helper(self):
        if self._connection_lost:
            raise ConnectionResetError('Connection lost')
        if not self._paused:
            return
        waiter = self._drain_waiter
        assert waiter is None or waiter.cancelled()
        waiter = asyncio.Future()
        self._drain_waiter = waiter
        await waiter

    def pause_writing(self):
        self.log.debug('SerialConnection: pause writing: {}'.format(self.transport.get_write_buffer_size()))
        # if not self.is_net:
        #     return
        # we only do this pause stream stuff for net
        assert not self._paused
        self._paused = True

    def resume_writing(self):
        self.log.debug('SerialConnection: resume writing: {}'.format(self.transport.get_write_buffer_size()))
        # if not self.is_net:
        #     return
        # we only do this pause stream stuff for net
        assert self._paused
        self._paused = False
        waiter = self._drain_waiter
        if waiter is not None:
            self._drain_waiter = None
            if not waiter.done():
                waiter.set_result(None)


class Comms():
    def __init__(self, app, reportrate=1):
        self.app = app
        self.proto = None
        self.timer = None
        self._fragment = None
        self.abort_stream = False
        self.pause_stream = False  # asyncio.Event()
        self.okcnt = None
        self.ping_pong = True  # ping pong protocol for streaming
        self.file_streamer = None
        self.report_rate = reportrate
        self._reroute_incoming_data_to = None
        self._restart_timer = False
        self.is_streaming = False
        self.do_query = False
        self.last_tool = None
        self.is_suspend = False
        self.m0 = None
        self.net_connection = False
        self.log = logging.getLogger()  # .getChild('Comms')
        # logging.getLogger().setLevel(logging.DEBUG)

    def connect(self, port):
        ''' called from UI to connect to given port, runs the asyncio mainloop in a separate thread '''
        self.port = port
        self.log.info('Comms: creating comms thread')
        self.comms_thread = threading.Thread(target=self.run_async_loop)
        self.comms_thread.start()
        return self.comms_thread

    def disconnect(self):
        ''' called by ui thread to disconnect '''
        if self.proto:
            async_main_loop.call_soon_threadsafe(self.proto.transport.close)

    def write(self, data):
        ''' Write to serial port, called from UI thread '''
        if self.proto and async_main_loop:
            async_main_loop.call_soon_threadsafe(self._write, data)
            # asyncio.run_coroutine_threadsafe(self.proto.send_message, async_main_loop)
        else:
            self.log.warning('Comms: Cannot write to closed connection: ' + data)
            # self.app.main_window.async_display("<<< {}".format(data))

    def _write(self, data):
        # calls the send_message in Serial Connection proto
        # print('Comms: _write {}'.format(data))
        if self.proto:
            self.proto.send_message(data)

    def _get_reports(self):
        if self._restart_timer:
            return

        queries = self.app.main_window.get_queries()
        if queries:
            self._write(queries)

        self._write('?')

    def stop(self):
        ''' called by ui thread when it is exiting '''
        if self.proto:
            # abort any streaming immediately
            self._stream_pause(False, True)
            if self.file_streamer:
                self.file_streamer.cancel()

            # we need to close the transport, this will cause mainloop to stop and thread to exit as well
            async_main_loop.call_soon_threadsafe(self.proto.transport.close)
            self.comms_thread.join()

        # else:
        #     if async_main_loop and async_main_loop.is_running():
        #         async_main_loop.call_soon_threadsafe(async_main_loop.stop)

    def get_ports(self):
        return [port for port in serial.tools.list_ports.comports() if port[2] != 'n/a']

    def run_async_loop(self):
        ''' called by connect in a new thread to setup and start the asyncio loop '''
        global async_main_loop

        if async_main_loop:
            self.log.error("Comms: Already running cannot connect again")
            self.app.main_window.async_display('>>> Already running cannot connect again')
            return

        newloop = asyncio.new_event_loop()
        asyncio.set_event_loop(newloop)
        loop = asyncio.get_event_loop()
        async_main_loop = loop
        f = asyncio.Future()

        # if tcp connection port will be net://ipaddress[:port]
        # otherwise it will be serial:///dev/ttyACM0 or serial://COM2:
        if self.port.startswith('net://'):
            sc_factory = functools.partial(SerialConnection, cb=self, f=f, is_net=True)  # uses partial so we can pass a parameter
            self.net_connection = True
            ip = self.port[6:]
            ip = ip.split(':')
            if len(ip) == 1:
                self.port = 23
            else:
                self.port = ip[1]

            self.ipaddress = ip[0]
            self.log.info('Comms: Connecting to Network at {} port {}'.format(self.ipaddress, self.port))
            serial_conn = loop.create_connection(sc_factory, self.ipaddress, self.port)

        elif self.port.startswith('serial://'):
            sc_factory = functools.partial(SerialConnection, cb=self, f=f)  # uses partial so we can pass a parameter
            self.net_connection = False
            self.port = self.port[9:]
            serial_conn = libs.serial_asyncio.serial_asyncio.create_serial_connection(loop, sc_factory, self.port, baudrate=115200)

        else:
            loop.close()
            self.log.error('Comms: Not a valid connection port: {}'.format(self.port))
            self.app.main_window.async_display('>>> Connect failed: unknown connection type, use "serial://" or "net://"'.format(self.port))
            self.app.main_window.disconnected()
            loop.close()
            async_main_loop = None
            return

        try:
            transport, self.proto = loop.run_until_complete(serial_conn)  # sets up connection returning transport and protocol handler
            self.log.debug('Comms: serial connection task completed')

            # this is when we are really setup and ready to go, notify upstream
            self.app.main_window.connected()

            # issue a M115 command to get things started
            self._write('\n')
            self._write('M115\n')

            if self.report_rate > 0:
                # start a timer to get the reports
                self.timer = loop.call_later(self.report_rate, self._get_reports)

            # wait until we are disconnected
            self.log.debug('Comms: waiting until disconnection')
            loop.run_until_complete(f)

            # clean up and notify upstream we have been disconnected
            self.proto = None  # no proto now
            self._stream_pause(False, True)  # abort the stream if one is running
            if self.timer:  # stop the timer if we have one
                self.timer.cancel()
                self.timer = None

            self.app.main_window.disconnected()  # tell upstream we disconnected

            # we wait until all tasks are complete
            pending = asyncio.Task.all_tasks()
            self.log.debug('Comms: waiting for all tasks to complete: {}'.format(pending))
            loop.run_until_complete(asyncio.gather(*pending))
            # loop.run_forever()

        except asyncio.CancelledError:
            pass

        except Exception as err:
            # self.log.error('Comms: {}'.format(traceback.format_exc()))
            self.log.error("Comms: Got serial error opening port: {0}".format(err))
            self.app.main_window.async_display(">>> Connect failed: {0}".format(err))
            self.app.main_window.disconnected()

        finally:
            loop.close()
            async_main_loop = None
            self.log.info('Comms: comms thread Exiting...')

    def _parse_m115(self, s):
        # split fields
        ll = s.split(',')

        # parse into a dict of name: value
        d = {y[0].strip(): y[1].strip() for y in [x.split(':', 1) for x in ll]}
        if 'X-CNC' not in d:
            d['X-CNC'] = '0'
        if 'FIRMWARE_NAME' not in d:
            d['FIRMWARE_NAME'] = 'UNKNOWN'
        if 'FIRMWARE_VERSION' not in d:
            d['FIRMWARE_VERSION'] = 'UNKNOWN'

        self.log.info("Comms: Firmware: {}, Version: {}, CNC: {}".format(d['FIRMWARE_NAME'], d['FIRMWARE_VERSION'], 'Yes' if d['X-CNC'] == '1' else 'No'))
        self.app.main_window.async_display(s)

    def list_sdcard(self, done_cb):
        ''' Issue a ls /sd and send results back to done_cb '''
        self.log.debug('Comms: list_sdcard')
        if self.proto and async_main_loop:
            async_main_loop.call_soon_threadsafe(self._list_sdcard, done_cb)
        else:
            self.log.warning('Comms: Cannot list sd on a closed connection')
            return False

        return True

    def _list_sdcard(self, done_cb):
        asyncio.ensure_future(self._parse_sdcard_list(done_cb))

    async def _parse_sdcard_list(self, done_cb):
        self.log.debug('Comms: _parse_sdcard_list')

        # setup callback to receive and parse listing data
        files = []
        f = asyncio.Future()
        self.redirect_incoming(lambda x: self._rcv_sdcard_line(x, files, f))

        # issue command
        self._write('M20\n')

        # wait for it to complete and get all the lines
        # add a long timeout in case it fails and we don't want to wait for ever
        try:
            await asyncio.wait_for(f, 10)

        except asyncio.TimeoutError:
            self.log.warning("Comms: Timeout waiting for sd card list")
            files = []

        self.redirect_incoming(None)

        # call upstream callback with results
        done_cb(files)

    def _rcv_sdcard_line(self, ll, files, f):
        # accumulate the file list, called with each line received
        if ll.startswith('Begin file list') or ll == 'ok':
            # ignore these lines
            return

        if ll.startswith('End file list'):
            # signal we are done (TODO should we wait for the ok?)
            f.set_result(None)

        else:
            # accumulate the incoming lines
            files.append(ll)

    def redirect_incoming(self, l):
        async_main_loop.call_soon_threadsafe(self._redirect_incoming, l)

    def _redirect_incoming(self, l):
        if l:
            if self.timer:
                # temporarily turn off status timer so we don't get unexpected lines
                self.timer.cancel()
                self.timer = None
                self._restart_timer = True
            else:
                self._restart_timer = False

            self._reroute_incoming_data_to = l

        else:
            # turn off rerouting
            self._reroute_incoming_data_to = None

            if self._restart_timer:
                self.timer = async_main_loop.call_later(0.1, self._get_reports)
                self._restart_timer = False

    # Handle incoming data, see if it is a report and parse it otherwise just display it on the console log
    # Note the data could be a line fragment and we need to only process complete lines terminated with \n
    def incoming_data(self, data, error=False):
        ''' called by Serial connection when incoming data is received '''
        if error:
            self.app.main_window.async_display("WARNING: got bad incoming data: {}".format(data))

        ll = data.splitlines(1)

        self.log.debug('Comms: incoming_data: {}'.format(ll))

        # process incoming data
        for s in ll:
            if self._fragment:
                # handle line fragment
                s = ''.join((self._fragment, s))
                self._fragment = None

            if not s.endswith('\n'):
                # this is the last line and is a fragment
                self._fragment = s
                break

            s = s.rstrip()  # strip off \n

            if len(s) == 0:
                continue

            # send the line to the requested destination for processing
            if self._reroute_incoming_data_to is not None:
                self._reroute_incoming_data_to(s)
                continue

            # process a complete line
            if s.startswith('ok'):
                if self.okcnt is not None:
                    if self.ping_pong:
                        self.okcnt.set()
                    else:
                        self.okcnt += 1

                # if there is anything after the ok display it
                if len(s) > 2:
                    self.app.main_window.async_display('ok {}'.format(s[3:]))

            elif s.startswith('<'):
                try:
                    self.handle_status(s)
                except Exception:
                    self.log.error("Comms: error parsing status")

            elif s.startswith('[PRB:'):
                # Handle PRB reply
                self.handle_probe(s)

            elif s.startswith('[GC:'):
                self.handle_state(s)

            elif s.startswith("!!") or s.startswith("error:Alarm lock"):
                self.handle_alarm(s)
                # we should now be paused
                if self.okcnt is not None and self.ping_pong:
                    # we need to unblock waiting for ok if we get this
                    self.okcnt.set()

            elif s.startswith("ALARM") or s.startswith("ERROR") or s.startswith("HALTED"):
                self.handle_alarm(s)

            elif s.startswith('//'):
                # ignore comments but display them
                # handle // action:pause etc
                pos = s.find('action:')
                if pos >= 0:
                    act = s[pos + 7:].strip()  # extract action command
                    if act in 'pause':
                        self.app.main_window.async_display('>>> Smoothie requested Pause')
                        self.is_suspend = True  # this currently only happens if we suspend (M600)
                        self._stream_pause(True, False)
                    elif act in 'resume':
                        self.app.main_window.async_display('>>> Smoothie requested Resume')
                        self._stream_pause(False, False)
                    elif act in 'disconnect':
                        self.app.main_window.async_display('>>> Smoothie requested Disconnect')
                        self.disconnect()
                    else:
                        self.log.warning('Comms: unknown action command: {}'.format(act))

                else:
                    self.app.main_window.async_display('{}'.format(s))

            elif "FIRMWARE_NAME:" in s:
                # process the response to M115
                self._parse_m115(s)

            elif s.startswith("switch "):
                # switch fan is 0
                n, x, v = s[7:].split(' ')
                self.app.main_window.ids.macros.switch_response(n, v)

            elif s.startswith("done"):
                # ignore these sent after a command on V2
                pass

            else:
                self.app.main_window.async_display('{}'.format(s))

    def handle_state(self, s):
        # [GC:G0 G55 G17 G21 G90 G94 M0 M5 M9 T1 F4000.0000 S0.8000]
        s = s[4:-1]  # strip off [GC: .. ]

        # split fields
        ll = s.split(' ')
        self.log.debug("Comms: Got state: {}".format(ll))
        # we want the current WCS and the current Tool
        if len(ll) < 11:
            self.log.warning('Comms: Bad state report: {}'.format(s))
            return

        self.app.main_window.update_state(ll)

    def handle_status(self, s):
        # <Idle|MPos:68.9980,-49.9240,40.0000,12.3456|WPos:68.9980,-49.9240,40.0000|F:12345.12|S:1.2>
        # if temp readings are enabled then also returns T:25.0,0.0|B:25.2,0.0
        s = s[1:-1]  # strip off < .. >

        # split fields
        ll = s.split('|')
        self.log.debug("Comms: Got status: {}".format(ll))
        if len(ll) < 3:
            self.log.warning('Comms: old status report - set new_status_format')
            self.app.main_window.update_status("ERROR", "set new_status_format true")
            return

        # strip off status
        status = ll[0]

        # strip of rest into a dict of name: [values,...,]
        d = {a: [float(y) for y in b.split(',')] for a, b in [x.split(':') for x in ll[1:]]}

        self.log.debug('Comms: got status:{} - rest: {}'.format(status, d))

        self.app.main_window.update_status(status, d)

        # schedule next report
        self.timer = async_main_loop.call_later(self.report_rate, self._get_reports)

    def handle_probe(self, s):
        # [PRB:1.000,80.137,10.000:0]
        ll = s[5:-1].split(':')
        c = ll[0].split(',')
        st = ll[1]
        self.app.main_window.async_display("Probe: {} - X: {}, Y: {}, Z: {}".format(st, c[0], c[1], c[2]))
        self.app.last_probe = {'X': float(c[0]), 'Y': float(c[1]), 'Z': float(c[2]), 'status': st == '1'}

    def handle_alarm(self, s):
        ''' handle case where smoothie sends us !! or an error of some sort '''
        self.log.warning('Comms: alarm message: {}'.format(s))
        # pause any streaming immediately, (let operator decide to abort or not)
        self._stream_pause(True, False)

        # NOTE old way was to abort, but we could resume if we can fix the error
        # self._stream_pause(False, True)
        # if self.proto:
        #    self.proto.flush_queue()

        # call upstream after we have allowed stream to stop
        async_main_loop.call_soon(self.app.main_window.alarm_state, s)

    def stream_gcode(self, fn, progress=None):
        ''' called from external thread to start streaming a file '''
        self.progress = progress
        if self.proto and async_main_loop:
            async_main_loop.call_soon_threadsafe(self._stream_file, fn)
            return True
        else:
            self.log.warning('Comms: Cannot print to a closed connection')
            return False

    def _stream_file(self, fn):
        self.file_streamer = asyncio.ensure_future(self.stream_file(fn))

    def stream_pause(self, pause, do_abort=False):
        ''' called from external thread to pause or kill in process streaming '''
        async_main_loop.call_soon_threadsafe(self._stream_pause, pause, do_abort)

    def _stream_pause(self, pause, do_abort):
        if self.file_streamer:
            if do_abort:
                self.pause_stream = False
                self.abort_stream = True  # aborts stream
                if self.ping_pong and self.okcnt is not None:
                    self.okcnt.set()  # release it in case it is waiting for ok so it can abort
                self.log.info('Comms: Aborting Stream')

            elif pause:
                self.pause_stream = True  # .clear() # pauses stream
                # tell UI we paused (and if it was due to a suspend)
                self.app.main_window.action_paused(True, self.is_suspend)
                self.is_suspend = False  # always clear this
                self.log.info('Comms: Pausing Stream')

            else:
                self.pause_stream = False  # .set() # releases pause on stream
                self.app.main_window.action_paused(False)
                self.log.info('Comms: Resuming Stream')

    async def stream_file(self, fn):
        self.log.info('Comms: Streaming file {} to port'.format(fn))
        self.is_streaming = True
        self.abort_stream = False
        self.pause_stream = False  # .set() # start out not paused
        self.last_tool = None

        # optional do not use ping pong
        if self.app.fast_stream:
            self.ping_pong = False
            self.log.info("Comms: using fast stream")
        else:
            self.ping_pong = True

        if self.ping_pong:
            self.okcnt = asyncio.Event()
        else:
            self.okcnt = 0

        f = None
        success = False
        linecnt = 0
        tool_change_state = 0

        try:
            f = await aiofiles.open(fn, mode='r')
            while True:

                if tool_change_state == 0:
                    # await self.pause_stream.wait() # wait for pause to be released
                    # needed to do it this way as the Event did not seem to work it would pause but not unpause
                    # TODO maybe use Future here to wait for unpause
                    # create future when pause then await it here then delete it
                    if self.pause_stream:
                        if self.ping_pong:
                            # we need to ignore any ok from command while we are paused
                            self.okcnt = None

                        # wait until pause is released
                        while self.pause_stream:
                            await asyncio.sleep(1)
                            if self.progress:
                                self.progress(linecnt)
                            if self.abort_stream:
                                break

                        # recreate okcnt
                        if self.ping_pong:
                            self.okcnt = asyncio.Event()

                    # read next line
                    line = await f.readline()

                    if not line:
                        # EOF
                        break

                    if self.abort_stream:
                        break

                    line = line.strip()
                    if len(line) == 0 or line.startswith(';'):
                        continue

                    if line.startswith('(MSG'):
                        self.app.main_window.async_display(line)
                        continue

                    if line.startswith('(NOTIFY'):
                        notify = Notify()
                        notify.send(line)
                        continue

                    if line.startswith('('):
                        continue

                    if line.startswith('T'):
                        self.last_tool = line

                    if self.app.manual_tool_change:
                        # handle tool change M6 or M06
                        if line == "M6" or line == "M06" or "M6 " in line or "M06 " in line or line.endswith("M6"):
                            tool_change_state = 1

                    if self.app.wait_on_m0:
                        # handle M0 if required
                        if line == "M0" or line == "M00":
                            # we basically wait for the continue dialog to be dismissed
                            self.app.main_window.m0_dlg()
                            self.m0 = asyncio.Event()
                            await self.m0.wait()
                            self.m0 = None
                            continue

                if self.abort_stream:
                    break

                # handle manual tool change
                if self.app.manual_tool_change and tool_change_state > 0:
                    if tool_change_state == 1:
                        # we insert an M400 so we can wait for last command to actually execute and complete
                        line = "M400"
                        tool_change_state = 2

                    elif tool_change_state == 2:
                        # we got the M400 so queue is empty so we send a suspend and tell upstream
                        line = "M600"
                        # we need to pause the stream here immediately, but the real _stream_pause will be called by suspend
                        self.pause_stream = True  # we don't normally set this directly
                        self.app.main_window.tool_change_prompt("{} - {}".format(line, self.last_tool))
                        tool_change_state = 0

                # s = time.time()
                # print("{} - {}".format(s, line))
                # send the line
                if self.ping_pong and self.okcnt is not None:
                    # clear the event, which will be set by an incoming ok
                    self.okcnt.clear()

                # sending stripped line so add \n
                self._write("{}\n".format(line))

                # wait for ok from that command (I'd prefer to interleave with the file read but it is too complex)
                if self.ping_pong and self.okcnt is not None:
                    try:
                        await self.okcnt.wait()
                        # e = time.time()
                        # print("{} ({}ms) ok".format(e, (e - s) * 1000))
                    except Exception:
                        self.log.debug('Comms: okcnt wait cancelled')
                        break

                # when streaming we need to yield until the flow control is dealt with
                if self.proto._connection_lost:
                    # Yield to the event loop so connection_lost() may be
                    # called.  Without this, _drain_helper() would return
                    # immediately, and code that calls
                    #     write(...); await drain()
                    # in a loop would never call connection_lost(), so it
                    # would not see an error when the socket is closed.
                    await asyncio.sleep(0)

                if self.abort_stream:
                    break

                # if the buffers are full then wait until we can send some more
                await self.proto._drain_helper()
                if self.abort_stream:
                    break

                if self.ping_pong:
                    # we only count lines that start with GMXY
                    if line[0] in "GMXY":
                        linecnt += 1
                else:
                    linecnt += 1

                if self.progress and linecnt % 10 == 0:  # update every 10 lines
                    if self.ping_pong:
                        # number of lines sent
                        self.progress(linecnt)
                    else:
                        # number of lines ok'd
                        self.progress(self.okcnt)

            success = not self.abort_stream

        except Exception as err:
            self.log.error("Comms: Stream file exception: {}".format(err))
            # print('Exception: {}'.format(traceback.format_exc()))

        finally:
            if f:
                await f.close()

            if self.abort_stream:
                if self.proto:
                    self.proto.flush_queue()

                self._write('\x18')

            if success and not self.ping_pong:
                self.log.debug('Comms: Waiting for okcnt to catch up: {} vs {}'.format(self.okcnt, linecnt))
                # we have to wait for all lines to be ack'd
                tmo = 0
                while self.okcnt < linecnt:
                    if self.progress:
                        self.progress(self.okcnt)
                    if self.abort_stream:
                        success = False
                        break

                    await asyncio.sleep(1)
                    tmo += 1
                    if tmo >= 30:  # waited 30 seconds we need to give up
                        self.log.warning("Comms: timed out waitng for backed up oks")
                        break
                # update final progress display
                if self.progress:
                    self.progress(self.okcnt)

            self.file_streamer = None
            self.progress = None
            self.okcnt = None
            self.is_streaming = False
            self.do_query = False

            # notify upstream that we are done
            self.app.main_window.stream_finished(success)

            self.log.info('Comms: Streaming complete: {}'.format(success))

        return success

    def upload_gcode(self, fn, progress=None, done=None):
        ''' called from external thread to start uploading a file '''
        self.progress = progress
        if self.proto and async_main_loop:
            async_main_loop.call_soon_threadsafe(self._upload_gcode, fn, done)
            return True
        else:
            self.log.warning('Comms: Cannot upload to a closed connection')
            return False

    def _upload_gcode(self, fn, donecb):
        self.file_streamer = asyncio.ensure_future(self._stream_upload_gcode(fn, donecb))

    def _rcv_upload_gcode_line(self, ll, ev):
        if ll == 'ok':
            ev.set()
            self.okcnt += 1

        elif ll.startswith('open failed,') or ll.startswith('Error:') or ll.startswith('ALARM:') or ll.startswith('!!') or ll.startswith('error:'):
            self.upload_error = True
            ev.set()

        elif ll.startswith('Writing to file:') or ll.startswith('Done saving file.'):
            # ignore these lines
            return

        else:
            self.log.warning('Comms: unknown response: {}'.format(ll))

    async def _stream_upload_gcode(self, fn, donecb):
        self.log.info('Comms: Upload gcode file {}'.format(fn))

        self.upload_error = False
        self.abort_stream = False
        f = None
        success = False
        linecnt = 0
        okev = asyncio.Event()

        # use the simple ping pong one line per ok or fast stream
        self._redirect_incoming(lambda x: self._rcv_upload_gcode_line(x, okev))

        try:
            self.okcnt = 0
            okev.clear()
            self._write("M28 {}\n".format(os.path.basename(fn).lower()))
            await okev.wait()

            if self.upload_error:
                self.log.error('Comms: M28 failed for file /sd/{}'.format(os.path.basename(fn)))
                self.app.main_window.async_display("error: M28 failed to open file")
                return

            self.okcnt = 0
            if self.app.fast_stream:
                self.ping_pong = False
                self.log.info("Comms: using fast stream")
            else:
                self.ping_pong = True

            f = await aiofiles.open(fn, mode='r')

            while True:
                # read next line
                line = await f.readline()

                if not line:
                    # EOF
                    break

                ln = line.strip()
                if len(ln) == 0 or ln.startswith(';') or ln.startswith('('):
                    continue

                # clear the event, which will be set by an incoming ok
                if self.ping_pong:
                    okev.clear()
                self._write("{}\n".format(ln))

                if self.ping_pong:
                    # wait for ok from that line
                    await okev.wait()

                if self.upload_error:
                    self.log.error('Comms: Upload failed for file /sd/{}'.format(os.path.basename(fn)))
                    self.app.main_window.async_display("error: upload failed during transfer")
                    return

                # when streaming we need to yield until the flow control is dealt with
                if self.proto._connection_lost:
                    await asyncio.sleep(0)

                if self.abort_stream:
                    break

                # if the buffers are full then wait until we can send some more
                await self.proto._drain_helper()

                if self.abort_stream:
                    break

                if self.ping_pong:
                    if ln[0] in "GMXY":
                        # we only count lines that start with GMXY
                        linecnt += 1
                else:
                    # we count all lines sent
                    linecnt += 1

                if self.progress and linecnt % 100 == 0:  # update every 100 lines
                    if self.ping_pong:
                        # number of lines sent
                        self.progress(linecnt)
                    else:
                        # number of lines ok'd
                        self.progress(self.okcnt)

            success = not self.abort_stream

        except Exception as err:
            self.log.error("Comms: Upload GCode file exception: {}".format(err))

        finally:
            if not self.ping_pong:
                # wait for oks to catch up
                self.log.debug('Comms: Waiting for okcnt to catch up: {} vs {}'.format(self.okcnt, linecnt))
                # we have to wait for all lines to be ack'd
                tmo = 0
                while self.okcnt < linecnt:
                    if self.progress:
                        self.progress(self.okcnt)

                    await asyncio.sleep(1)
                    tmo += 1
                    if tmo >= 30:  # waited 30 seconds we need to give up
                        self.log.warning("Comms: timed out waiting for backed up oks")
                        break

                # update final progress display
                if self.progress:
                    self.progress(self.okcnt)

            okev.clear()
            self._write("M29\n")
            await okev.wait()

            self._redirect_incoming(None)
            if f:
                await f.close()
            self.progress = None
            self.file_streamer = None
            self.okcnt = None
            donecb(success)
            self.log.info('Comms: Upload GCode complete: {}'.format(success))

        return success

    def release_m0(self):
        if self.m0:
            self.m0.set()

    @staticmethod
    def file_len(fname, all=False):
        # TODO if windows use a slow python method
        if not all:
            # use external process to quickly find total number of G/M lines in file
            # NOTE some laser raster formats have lines that start with X and no G/M
            # and some CAM programs just output X or Y lines
            p = subprocess.Popen(['grep', '-c', "^[GMXY]", fname], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
            result, err = p.communicate()
            if p.returncode != 0:
                raise IOError(err)
            return int(result.strip().split()[0])
        else:
            # count all lines
            p = subprocess.Popen(['wc', fname], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
            result, err = p.communicate()
            if p.returncode != 0:
                raise IOError(err)
            return int(result.strip().split()[0])


if __name__ == "__main__":

    import datetime
    from time import sleep

    ''' a standalone streamer to test it with '''

    class CommsApp(object):
        """ Standalone app callbacks """
        def __init__(self):
            super(CommsApp, self).__init__()
            self.root = self
            self.log = logging.getLogger()
            self.start_event = threading.Event()
            self.end_event = threading.Event()
            self.is_connected = False
            self.ok = False
            self.main_window = self
            self.timer = None
            self.is_cnc = True
            self.fast_stream = False

        def connected(self):
            self.log.debug("CommsApp: Connected...")
            self.is_connected = True
            self.start_event.set()

        def disconnected(self):
            self.log.debug("CommsApp: Disconnected...")
            self.is_connected = False
            self.start_event.set()

        def async_display(self, data):
            print(data)

        def stream_finished(self, ok):
            self.log.debug('CommsApp: stream finished: {}'.format(ok))
            self.ok = ok
            self.end_event.set()

        def alarm_state(self, s):
            self.ok = False
            # in this case we do want to disconnect
            comms.proto.transport.close()

        def update_status(self, stat, d):
            pass

        def manual_tool_change(self, l):
            print("tool change: {}\n".format(l))

        def action_paused(self, flag, suspend=False):
            print("paused: {}, suspended: {}", flag, suspend)

        def get_queries(self):
            return ""

        def wait_on_m0(self, l):
            print("wait on m0: {}\n".format(l))

    start = None
    nlines = None
    app = CommsApp()

    def display_progress(n):
        global start, nlines

        if nlines:
            now = datetime.datetime.now()
            d = (now - start).seconds
            if n > 10 and d > 10:
                # we have to wait a bit to get reasonable estimates
                lps = n / d
                eta = (nlines - n) / lps
            else:
                eta = 0
            et = datetime.timedelta(seconds=int(eta))
            print("progress: {}/{} {:.1%} ETA {}".format(n, nlines, n / nlines, et))

    def upload_done(x):
        app.ok = x
        app.end_event.set()

    def main():
        global start, nlines
        if len(sys.argv) < 3:
            print("Usage: {} port file [-u] [-f] [-d]".format(sys.argv[0]))
            exit(0)

        upload = False
        loglevel = logging.INFO
        comms = Comms(app, 0)
        while len(sys.argv) > 3:
            a = sys.argv.pop()
            if a == '-u':
                upload = True
                print('Upload only')
            elif a == '-f':
                app.fast_stream = True
                print('Fast Stream')
            elif a == '-d':
                loglevel = logging.DEBUG
            else:
                print("Unknown option: {}".format(a))

        logging.basicConfig(format='%(levelname)s:%(message)s', level=loglevel)

        try:
            nlines = Comms.file_len(sys.argv[2], app.fast_stream)  # get number of lines so we can do progress and ETA
            print('number of lines: {}'.format(nlines))
        except Exception:
            print('Exception: {}'.format(traceback.format_exc()))
            nlines = None

        start = None

        try:
            t = comms.connect(sys.argv[1])
            if app.start_event.wait(5):  # wait for connected as it is in a separate thread
                if app.is_connected:
                    # wait for startup to clear up any incoming oks
                    sleep(2)  # Time in seconds.
                    start = datetime.datetime.now()
                    print("Print started at: {}".format(start.strftime('%x %X')))

                    if upload:
                        comms.upload_gcode(sys.argv[2], progress=lambda x: display_progress(x), done=upload_done)
                    else:
                        comms.stream_gcode(sys.argv[2], progress=lambda x: display_progress(x))

                    app.end_event.wait()  # wait for streaming to complete

                    now = datetime.datetime.now()
                    print("File sent: {}".format('Ok' if app.ok else 'Failed'))
                    print("Print ended at : {}".format(now.strftime('%x %X')))
                    if start:
                        et = datetime.timedelta(seconds=int((now - start).seconds))
                        print("Elapsed time: {}".format(et))

                else:
                    print("Error: Failed to connect")

            else:
                print("Error: Connection timed out")

        except KeyboardInterrupt:
            print("Interrupted- aborting")
            if upload:
                comms.do_abort = True
            else:
                comms.stream_pause(False, True)

            app.end_event.wait()  # wait for streaming to complete

        finally:
            # now stop the comms if it is connected or running
            comms.okcnt = None
            comms.stop()
            t.join()

    main()