import argparse import asyncio import io import multicast import sys import unittest from unittest.mock import patch, AsyncMock, Mock, call from lora import timeout, _debprint DEFAULT_MADDR = ('239.192.76.111', 21089) async def to_loragw(rp, wtr, ignpkts): '''Take a multicast.ReceiverProtocol, and pass the packets to the lora gateway on the StreamWriter. Any packets in the set ignpkts will NOT be sent out to the gateway. This is to prevent looping packets back out. ''' while True: pkt, addr = await rp.recv() #_debprint('pkt to send:', repr(pkt), repr(ignpkts)) if pkt in ignpkts: ignpkts.remove(pkt) continue wtr.write(b'pkt:%s\n' % pkt.hex().encode('ascii')) await wtr.drain() async def from_loragw(rdr, tp, txpkts): '''Take a StreamReader, and pass the received packets from the lora gatway to the multicast.TransmitterProtocol. Each packet that will be transmitted will be added the the txpkts set that is passed in. This is to allow a receiver to ignore the loop back. ''' while True: rcv = await rdr.readuntil() #_debprint('from gw:', repr(rcv), repr(txpkts)) rcv = rcv.strip() if rcv.startswith(b'data:'): # we've received a packet data = bytes.fromhex(rcv[5:].decode('ascii')) txpkts.add(data) await tp.send(data) async def open_dev(fname, *args, **kwargs): '''coroutine that returns (reader, writer), in the same way as [open_connection](https://docs.python.org/3/library/asyncio-stream.html#asyncio.open_connection) does. The args are passed to open.''' import functools, os, socket f = open(fname, *args, **kwargs) f.type = socket.SOCK_STREAM f.setblocking = functools.partial(os.set_blocking, f.fileno()) f.getsockname = lambda: f.name f.getpeername = lambda: f.name f.family = socket.AF_UNIX f.recv = f.read f.send = f.write return await asyncio.open_connection(sock=f) async def main(): parser = argparse.ArgumentParser() parser.add_argument('-a', metavar='maddr', type=str, help='multicastip:port to use to send/receive pkts') parser.add_argument('serdev', type=str, help='device for gateway comms') args = parser.parse_args() # open up the gateway device reader, writer = await open_dev(args.serdev, 'w+b', buffering=0) # open up the listener mr = await multicast.create_multicast_receiver(DEFAULT_MADDR) mt = await multicast.create_multicast_transmitter(DEFAULT_MADDR) try: pkts = set() tlgtask = asyncio.create_task(to_loragw(mr, writer, pkts)) flgtask = asyncio.create_task(from_loragw(reader, mt, pkts)) await asyncio.gather(tlgtask, flgtask) finally: mt.close() mr.close() writer.close() if __name__ == '__main__': asyncio.run(main()) class TestLoraServ(unittest.IsolatedAsyncioTestCase): @timeout(2) async def test_from_loragw(self): readermock = AsyncMock() pkts = [ b'astringofdata', b'anotherpkt', b'makeupdata', b'asigo', ] pktset = set() readermock.readuntil.side_effect = [ b'bogus data\r\n', ] + [ b'data: %s\r\n' % x.hex().encode('ascii') for x in pkts ] + [ b'moreignored\r\n', b'rssi: 123\r\n', b'txdone\r\n', asyncio.IncompleteReadError(partial=b'aa', expected=b'\n'), ] writermock = AsyncMock() with self.assertRaises(asyncio.IncompleteReadError): await from_loragw(readermock, writermock, pktset) writermock.send.assert_has_calls([ call(x) for x in pkts ]) self.assertEqual(pktset, set(pkts)) @timeout(2) async def test_to_loragw(self): readermock = AsyncMock() writermock = AsyncMock() pkts = [ (x, None) for x in (b'astringofdata', b'anotherpkt', b'makeupdata', b'asigo', ) ] + [ asyncio.CancelledError(), ] readermock.recv.side_effect = pkts writermock.write = Mock() txpkts = { pkts[-2][0] } with self.assertRaises(asyncio.CancelledError): await to_loragw(readermock, writermock, txpkts) # make sure that the ignored packet was dropped self.assertFalse(txpkts) # and that it wasn't transmitted self.assertNotIn(call(b'pkt:%s\n' % pkts[-2][0].hex().encode('ascii')), writermock.write.mock_calls) writermock.write.assert_has_calls([ call(b'pkt:%s\n' % x.hex().encode('ascii')) for x, addr in pkts[:-2] ]) writermock.drain.assert_has_calls([ call() for x in pkts[:-2] ]) @timeout(2) async def test_argerrors(self): with io.StringIO() as fp: with self.assertRaises(SystemExit) as cm, \ patch.dict(sys.__dict__, dict(argv=[ 'name', ], stderr=fp)): await main() errout = fp.getvalue() self.assertEqual(errout, 'usage: name [-h] [-a maddr] serdev\nname: error: the following arguments are required: serdev\n') self.assertEqual(cm.exception.code, 2) @timeout(2) @patch(__name__ + '.from_loragw') @patch(__name__ + '.to_loragw') @patch('multicast.create_multicast_receiver') @patch('multicast.create_multicast_transmitter') @patch(__name__ + '.open_dev') async def test_main(self, od, cmt, cmr, tlg, flg): # setup various mocks cmtret = Mock() cmrret = Mock() cmt.return_value = cmtret cmr.return_value = cmrret readermock = Mock() writermock = Mock() od.return_value = (readermock, writermock) # make sure that when called w/ an arg serdev = 'abc123' with patch.dict(sys.__dict__, dict(argv=[ 'name', serdev ])): await main() # that open_dev is called with it od.assert_called_with(serdev, 'w+b', buffering=0) # and that the multicast functions were called cmt.assert_called_with(DEFAULT_MADDR) cmr.assert_called_with(DEFAULT_MADDR) # that there was a setobj created setobj = tlg.mock_calls[0][1][-1] self.assertIsInstance(setobj, set) # and the same object was passed self.assertIs(setobj, flg.mock_calls[0][1][-1]) # and both tasks were passed the correct objects tlg.assert_called_with(cmrret, writermock, setobj) flg.assert_called_with(readermock, cmtret, setobj) # and that they were closed in the end cmtret.close.assert_called() cmrret.close.assert_called() # and that the writer was closed as well writermock.close.assert_called()