From 80159d8789fe2ea0b36d84b83348813f67e18652 Mon Sep 17 00:00:00 2001 From: Edi Feschiyan <553226+refresher@users.noreply.github.com> Date: Thu, 18 Jun 2020 13:12:31 +0300 Subject: Rearranging files for distribution, setup.py modifications --- .gitignore | 1 - pyagl/services/audiomixer.py | 94 ++++++++ pyagl/services/base.py | 256 ++++++++++++++++++++ pyagl/services/bluetooth-map.py | 0 pyagl/services/bluetooth-pbap.py | 0 pyagl/services/bluetooth.py | 94 ++++++++ pyagl/services/geoclue.py | 44 ++++ pyagl/services/gps.py | 56 +++++ pyagl/services/mediaplayer.py | 160 +++++++++++++ pyagl/services/network.py | 0 pyagl/services/nfc.py | 42 ++++ pyagl/services/taskmanager.py | 0 pyagl/services/weather.py | 48 ++++ pyagl/tests/test_bluetooth.py | 105 +++++++++ pyagl/tests/test_gps.py | 73 ++++++ pyagl/tests/test_weather.py | 0 services/audiomixer.py | 94 -------- services/base.py | 258 --------------------- services/bluetooth-map.py | 0 services/bluetooth-pbap.py | 0 services/bluetooth.py | 96 -------- services/geoclue.py | 44 ---- services/gps.py | 56 ----- services/mediaplayer.py | 160 ------------- services/network.py | 0 services/nfc.py | 42 ---- services/taskmanager.py | 0 services/weather.py | 48 ---- setup.py | 26 +++ templates/cookiecutter.json | 8 + templates/service/cookiecutter.json | 6 - templates/service/{{cookiecutter.file_name}}.py | 9 - templates/test/{{cookiecutter.file_name}}.py | 0 .../{{cookiecutter.service_slug}}.py | 17 ++ .../test_{{cookiecutter.service_slug}}.py | 23 ++ 35 files changed, 1046 insertions(+), 814 deletions(-) create mode 100644 pyagl/services/audiomixer.py create mode 100644 pyagl/services/base.py create mode 100644 pyagl/services/bluetooth-map.py create mode 100644 pyagl/services/bluetooth-pbap.py create mode 100644 pyagl/services/bluetooth.py create mode 100644 pyagl/services/geoclue.py create mode 100644 pyagl/services/gps.py create mode 100644 pyagl/services/mediaplayer.py create mode 100644 pyagl/services/network.py create mode 100644 pyagl/services/nfc.py create mode 100644 pyagl/services/taskmanager.py create mode 100644 pyagl/services/weather.py create mode 100644 pyagl/tests/test_bluetooth.py create mode 100644 pyagl/tests/test_gps.py create mode 100644 pyagl/tests/test_weather.py delete mode 100644 services/audiomixer.py delete mode 100644 services/base.py delete mode 100644 services/bluetooth-map.py delete mode 100644 services/bluetooth-pbap.py delete mode 100644 services/bluetooth.py delete mode 100644 services/geoclue.py delete mode 100644 services/gps.py delete mode 100644 services/mediaplayer.py delete mode 100644 services/network.py delete mode 100644 services/nfc.py delete mode 100644 services/taskmanager.py delete mode 100644 services/weather.py create mode 100644 templates/cookiecutter.json delete mode 100644 templates/service/cookiecutter.json delete mode 100644 templates/service/{{cookiecutter.file_name}}.py delete mode 100644 templates/test/{{cookiecutter.file_name}}.py create mode 100644 templates/{{cookiecutter.services_dir}}/{{cookiecutter.service_slug}}.py create mode 100644 templates/{{cookiecutter.tests_dir}}/test_{{cookiecutter.service_slug}}.py diff --git a/.gitignore b/.gitignore index 67ad1bc..a98ea7d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ .idea/* -scratch __pycache__ downloads/ diff --git a/pyagl/services/audiomixer.py b/pyagl/services/audiomixer.py new file mode 100644 index 0000000..3f35bc8 --- /dev/null +++ b/pyagl/services/audiomixer.py @@ -0,0 +1,94 @@ +from pyagl.services.base import AGLBaseService, AFBResponse +import asyncio +import os + +verbs = ['subscribe', 'unsubscribe', 'list_controls', 'volume', 'mute'] +events = ['volume_changed', 'mute_changed', 'controls_changed'] + + +class AudioMixerService(AGLBaseService): + service = 'agl-service-audiomixer' + parser = AGLBaseService.getparser() + parser.add_argument('--list_controls', default=True, help='Request list of controls', action='store_true') + parser.add_argument('--getmute', help='Get mute state', action='store_true') + parser.add_argument('--setmute', help='Set mute state', type=int, choices=[0, 1]) + parser.add_argument('--setvolume', help='Set volume level', type=float) + parser.add_argument('--getvolume', help='Get volume level', action='store_true') + + + def __init__(self, ip, port=None, service='agl-service-audiomixer'): + super().__init__(api='audiomixer', ip=ip, port=port, service=service) + + async def subscribe(self, event='volume_changed'): # audio mixer uses 'event' instead 'value', + return await self.request('subscribe', {'event': event}) + + async def unsubscribe(self, event='volume_changed'): + return await self.request('unsubscribe', {'event': event}) + + async def list_controls(self): + return await self.request('list_controls') + + async def volume(self, control='Master', value=None): + if value is not None: + return await self.request('volume', {'control': control, 'value': value}) + else: + return await self.request('volume', {'control': control}) + + async def mute(self, value=None): + return await self.request('mute', {'control': 'Master', 'value': value}) + + +async def main(): + args = AudioMixerService.parser.parse_args() + ams = await AudioMixerService(ip=args.ipaddr, port=args.port) + + if args.list_controls: + resp = await ams.list_controls() + print(f'Requesting list_controls with id {resp}') + r = AFBResponse(await ams.response()) + print(r) + + if args.setvolume is not None: + resp = await ams.volume(args.setvolume) + print(f'Setting volume to {args.setvolume} with id {resp}') + r = AFBResponse(await ams.response()) + print(r) + + if args.getvolume: + resp = await ams.volume() + print(f'Requesting volume with id {resp}') + r = AFBResponse(await ams.response()) + print(r) + + if args.setmute is not None: + resp = await ams.mute(args.setmute) + print(f'Setting mute to {args.setmute} with id {resp}') + r = AFBResponse(await ams.response()) + print(r) + + if args.getmute: + resp = await ams.mute() + r = AFBResponse(await ams.response()) + print(r) + + if args.subscribe: + for event in args.subscribe: + msgid = await ams.subscribe(event) + print(f'Subscribing to {event} with id {msgid}') + r = AFBResponse(await ams.response()) + print(r) + + if args.unsubscribe: + for event in args.unsubscribe: + msgid = await ams.unsubscribe(event) + print(f'Unsubscribing from {event} with id {msgid}') + r = AFBResponse(await ams.response()) + print(r) + + if args.listener: + async for response in ams.listener(): + print(response) + +if __name__ == '__main__': + loop = asyncio.get_event_loop() + loop.run_until_complete(main()) diff --git a/pyagl/services/base.py b/pyagl/services/base.py new file mode 100644 index 0000000..ce5c8c7 --- /dev/null +++ b/pyagl/services/base.py @@ -0,0 +1,256 @@ +from json import JSONDecodeError +from parse import Result, parse +from websockets import connect +from random import randint +from enum import IntEnum +from typing import Union +import asyncssh +import argparse +import asyncio +import binascii +import logging +import json +import sys +import os +import re + +# logging.getLogger('AGLBaseService') +# logging.basicConfig(level=logging.DEBUG) + +# AFB message type +class AFBT(IntEnum): + REQUEST = 2, + RESPONSE = 3, + ERROR = 4, + EVENT = 5 + +msgq = {} +AFBLEN = 3 + + +def newrand(): + while True: + bs = os.urandom(5) + result = bs[0] * bs[1] * bs[2] * bs[3] + bs[4] + yield result + +def addrequest(msgid, msg): + msgq[msgid] = {'request': msg, 'response': None} + +def addresponse(msgid, msg): + if msgid in msgq.keys(): + msgq[msgid]['response'] = msg + +class AFBResponse: + type: AFBT + msgid: int + data: dict + api: str + status: str + info: str + + def __init__(self, data: list): + if type(data[0]) is not int: + logging.debug(f'Received a response with non-integer message type {binascii.hexlify(data[0])}') + raise ValueError('Received a response with non-integer message type') + if data[0] not in AFBT._value2member_map_: + raise ValueError(f'Received a response with invalid message type {data[0]}') + self.type = AFBT(data[0]) + + if self.type == AFBT.RESPONSE: + if 'request' not in data[2]: + logging.error(f'Received malformed or invalid response without "request" dict - {data}') + if not str.isnumeric(data[1]): + raise ValueError(f'Received a response with non-numeric message id {data[1]}') + else: + self.msgid = int(data[1]) + self.status = data[2]['request']['status'] + if 'info' in data[2]['request']: + self.info = data[2]['request']['info'] + if 'response' in data[2]: + self.data = data[2]['response'] + + elif self.type == AFBT.EVENT: + self.api = data[1] + if 'data' in data[2]: + self.data = data[2]['data'] + + elif self.type == AFBT.ERROR: + logging.debug(f'AFB returned erroneous response {data}') + self.msgid = int(data[1]) + self.status = data[2]['request']['status'] + self.info = data[2]['request']['info'] + + if 'response' in data[2]: + self.data = data[2]['response'] + + def __str__(self): # for debugging purposes + if self.type == AFBT.EVENT: + return f'[{self.type.name}][{self.api}][Data: {self.data if hasattr(self, "data") else None}]' + else: + return f'[{self.type.name}][Status: {self.status}][{self.msgid}]' \ + f'[Info: {self.info if hasattr(self,"info") else None}]' \ + f'[Data: {self.data if hasattr(self, "data") else None}]' + + +class AGLBaseService: + api: str + url: str + ip : str + port = None + token: str + uuid: str + service = None + logger = None + + @staticmethod + def getparser(): + parser = argparse.ArgumentParser(description='Utility to interact with agl-service-* via it\'s websocket') + parser.add_argument('-l', '--loglevel', help='Level of logging verbosity', default='INFO', + choices=list(logging._nameToLevel.keys())) + parser.add_argument('ipaddr', default=os.environ.get('AGL_TGT_IP', 'localhost'), help='AGL host address') + parser.add_argument('--port', default=os.environ.get('AGL_TGT_PORT', None), help=f'AGL service websocket port') + parser.add_argument('--listener', default=False, help='Register a listener for incoming events', action='store_true') + parser.add_argument('--subscribe', type=str, help='Subscribe to event type', action='append', metavar='event') + parser.add_argument('--unsubscribe', type=str, help='Unsubscribe from event type', action='append', metavar='event') + parser.add_argument('--json', type=str, help='Send your own json string') + parser.add_argument('--verb', type=str, help='Send the json above to specific verb') + parser.add_argument('--api', type=str, help='Send the above two to a specific api') + return parser + + def __init__(self, api: str, ip: str, port: str = None, url: str = None, + token: str = 'HELLO', uuid: str = 'magic', service: str = None): + self.api = api + self.url = url + self.ip = ip + self.port = port + self.token = token + self.uuid = uuid + self.service = service + self.logger = logging.getLogger(service) + + def __await__(self): + return self._async_init().__await__() + + async def __aenter__(self): + return self._async_init() + + async def _async_init(self): + # setting ping_interval to None because AFB does not support websocket ping + # if set to !None, the library will close the socket after the default timeout + if self.port is None: + serviceport = await self.portfinder() + if serviceport is not None: + self.port = serviceport + else: + self.logger.error('Unable to find port') + exit(1) + + URL = f'ws://{self.ip}:{self.port}/api?token={self.token}&uuid={self.uuid}' + self._conn = connect(close_timeout=0, uri=URL, subprotocols=['x-afb-ws-json1'], ping_timeout=None, compression=None) + self.websocket = await self._conn.__aenter__() + return self + + async def __aexit__(self, *args, **kwargs): + await self._conn.__aexit__(*args, **kwargs) + + async def close(self): + await self._conn.__aexit__(*sys.exc_info()) + + async def send(self, message): + await self.websocket.send(message) + + async def receive(self): + return await self.websocket.recv() + + async def portfinder(self): + # TODO:handle ssh timeouts, asyncssh does not support it apparently, and connect returns context_manager which + # cannot be used with asyncio.wait_for + + async with asyncssh.connect(self.ip, username='root') as c: + + servicename = await c.run(f"systemctl --all | grep {self.service}-- | awk '{{print $1}}'", check=False) + if self.service not in servicename.stdout: + logging.error(f"Service matching pattern - '{self.service}' - NOT FOUND") + exit(1) + pidres = await c.run(f'systemctl show --property MainPID --value {servicename.stdout.strip()}') + pid = int(pidres.stdout.strip(), 10) + if pid is 0: + logging.warning(f'Service {servicename.stdout.strip()} is stopped') + return None + else: + self.logger.debug(f'Service PID: {str(pid)}') + + sockets = await c.run(f'find /proc/{pid}/fd/ | xargs readlink | grep socket') + inodes = frozenset(re.findall("socket:\\[(.*)\\]", sockets.stdout)) + self.logger.debug(f"Socket inodes: {inodes}") + + procnettcp = await c.run('cat /proc/net/tcp') + fieldsstr = '{sl}: {local_address} {rem_address} {st} {tx_queue}:{rx_queue} {tr}:{tmwhen} {retrnsmt} {uid}'\ + ' {timeout} {inode} {sref_cnt} {memloc} {rto} {pred_sclk} {ackquick} {congest} {slowstart}' + tcpsockets = [' '.join(l.split()) for l in procnettcp.stdout.splitlines()[1:]] + # different lines with less stats appear sometimes, parse will return None, so ignore 'None' lines + parsedtcpsockets = [] + for l in tcpsockets: + res = parse(fieldsstr, l) + if isinstance(res, Result): + parsedtcpsockets.append(res) + + socketinodesbythisprocess = [l for l in parsedtcpsockets if + isinstance(l, Result) and + l.named['inode'] in inodes and + # 0A is listening state for the socket + l.named['st'] == '0A'] + + for s in socketinodesbythisprocess: + _, port = tuple(parse('{}:{}', s['local_address'])) + port = int(port, 16) + if port >= 30000: # the port is above 30000 range, 8080 is some kind of proxy + self.logger.debug(f'Service running at port {port}') + return port + + async def listener(self, stdout: bool = False): + while True: + raw = await self.response() + data = AFBResponse(raw) + if stdout: print(data) + yield data + + async def response(self): + try: + msg = await self.websocket.recv() + try: + data = json.loads(msg) + self.logger.debug('[AGL] -> ' + msg) + if isinstance(data, list): + # check whether the received response is an answer to previous query and queue it for debugging + if len(data) == AFBLEN and data[0] == AFBT.RESPONSE and str.isnumeric(data[1]): + msgid = int(data[1]) + if msgid in msgq: + addresponse(msgid, data) + return data + except JSONDecodeError: + self.logger.warning("Not decoding a non-json message") + + except KeyboardInterrupt: + self.logger.debug("Received keyboard interrupt, exiting") + except asyncio.CancelledError: + self.logger.warning("Websocket listener coroutine stopped") + except Exception as e: + self.logger.error("Unhandled seal: " + str(e)) + + async def afbresponse(self): + return AFBResponse(await self.response()) + + async def request(self, verb: str, values: Union[str, dict] = "", msgid: int = None): + msgid = next(newrand()) if msgid is None else msgid + l = json.dumps([AFBT.REQUEST, str(msgid), f'{self.api}/{verb}', values]) + self.logger.debug(f'[AGL] <- {l}') + await self.send(l) + return msgid + + async def subscribe(self, event): + return await self.request('subscribe', {'value': f'{event}'}) # some services may use 'event' instead 'value' + + async def unsubscribe(self, event): + return await self.request('unsubscribe', {'value': f'{event}'}) diff --git a/pyagl/services/bluetooth-map.py b/pyagl/services/bluetooth-map.py new file mode 100644 index 0000000..e69de29 diff --git a/pyagl/services/bluetooth-pbap.py b/pyagl/services/bluetooth-pbap.py new file mode 100644 index 0000000..e69de29 diff --git a/pyagl/services/bluetooth.py b/pyagl/services/bluetooth.py new file mode 100644 index 0000000..d111d09 --- /dev/null +++ b/pyagl/services/bluetooth.py @@ -0,0 +1,94 @@ +from pyagl.services.base import AGLBaseService, AFBResponse +import asyncio +import os + +Verbs = ['subscribe', 'unsubscribe', 'managed_objects', 'adapter_state', 'default_adapter', 'avrcp_controls', + 'connect', 'disconnect', 'pair', 'cancel_pairing', 'confirm_pairing', 'remove_device'] +AdapterStateParams = ['discovery', 'discoverable', 'powered', ] + +BTEventType = ['adapter_changes', 'device_changes', 'media', 'agent'] + + +class BluetoothService(AGLBaseService): + service = 'agl-service-bluetooth' + parser = AGLBaseService.getparser() + parser.add_argument('--default_adapter', help='Get default bluetooth adapter', action='store_true') + parser.add_argument('--managed_objects', help='Get managed objects', action='store_true') + parser.add_argument('--adapter', help='Select remote adapter', required=False, default='hci0') + parser.add_argument('--adapter_state') + parser.add_argument('--connect', help='Connect to device', metavar='dev_88_0F_10_96_D3_20') + parser.add_argument('--disconnect', help='Disconnect from device', metavar='dev_88_0F_10_96_D3_20') + parser.add_argument('--pair', help='Pair with a device', metavar='dev_88_0F_10_96_D3_20') + parser.add_argument('--cancel_pairing', help='Cancel ongoing pairing') + parser.add_argument('--confirm_pairing', metavar='pincode') + parser.add_argument('--remove_device', metavar='dev_88_0F_10_96_D3_20', help='Remove paired device') + + def __init__(self, ip, port=None, service='agl-service-bluetooth'): + super().__init__(api='Bluetooth-Manager', ip=ip, port=port, service=service) + + async def subscribe(self, event='device_changes'): + await super().subscribe(event=event) + + async def unsubscribe(self, event='device_changes'): + await super().unsubscribe(event=event) + + async def managed_objects(self): + return await self.request('managed_objects') + + async def adapter_state(self, adapter=None, value=None): + p = {} + if adapter: + p = {'adapter': adapter} + if isinstance(value, dict): + p = {**p, **value} + + return await self.request('adapter_state', p) + + async def default_adapter(self): + return await self.request('default_adapter', "") + + async def connect(self, device: str = 'hci0'): + return await self.request('connect', {'device': device}) + + async def disconnect(self, device: str = 'hci0'): + return await self.request('disconnect', {'device': device}) + + async def pair(self, device): + return await self.request('pair', {'device': device}) + + async def cancel_pairing(self): + return await self.request('cancel_pairing') + + async def confirm_pairing(self, pincode): + return await self.request('confirm_pairing', {'pincode': pincode}) + + async def remove_device(self, device): + return await self.request('remove_device', {'device': device}) + + async def avrcp_controls(self): + pass + +async def main(loop): + args = BluetoothService.parser.parse_args() + bts = await BluetoothService(ip=args.ipaddr, port=args.port) + + if args.default_adapter: + msgid = await bts.default_adapter() + print(f'Requesting default adapter with id {msgid}') + r = AFBResponse(await bts.response()) + print(r) + + if args.adapter_state: + pass + + if args.listener: + for response in bts.listener(): + print(response) + + bts.logger.debug(await bts.adapter_state('hci0', {'uuids': ['0000110e-0000-1000-8000-00805f9b34fb']})) + + + +if __name__ == '__main__': + loop = asyncio.get_event_loop() + loop.run_until_complete(main(loop)) diff --git a/pyagl/services/geoclue.py b/pyagl/services/geoclue.py new file mode 100644 index 0000000..c14c6db --- /dev/null +++ b/pyagl/services/geoclue.py @@ -0,0 +1,44 @@ +from pyagl.services.base import AGLBaseService, AFBResponse +import asyncio +import os + + +class GeoClueService(AGLBaseService): + service = 'agl-service-geoclue' + parser = AGLBaseService.getparser() + parser.add_argument('--location', help='Get current location', action='store_true') + + def __init__(self, ip, port=None, api='geoclue'): + super().__init__(ip=ip, port=port, api=api, service='agl-service-geoclue') + + async def location(self): + return await self.request('location') + + async def subscribe(self, event='location'): + return await super().subscribe(event=event) + + async def unsubscribe(self, event='location'): + return await super().unsubscribe(event=event) + + +async def main(loop): + args = GeoClueService.parser.parse_args() + gcs = await GeoClueService(args.ipaddr) + + if args.location: + msgid = await gcs.location() + print(f'Sent location request with messageid {msgid}') + print(AFBResponse(await gcs.response())) + + if args.subscribe: + for event in args.subscribe: + msgid = await gcs.subscribe(event) + print(f"Subscribed for {event} with messageid {msgid}") + print(AFBResponse(await gcs.response())) + if args.listener: + async for response in gcs.listener(): + print(response) + +if __name__ == '__main__': + loop = asyncio.get_event_loop() + loop.run_until_complete(main(loop)) diff --git a/pyagl/services/gps.py b/pyagl/services/gps.py new file mode 100644 index 0000000..69c253e --- /dev/null +++ b/pyagl/services/gps.py @@ -0,0 +1,56 @@ +from pyagl.services.base import AGLBaseService, AFBResponse +import asyncio +import os + + +class GPSService(AGLBaseService): + service = 'agl-service-gps' + parser = AGLBaseService.getparser() + parser.add_argument('--record', help='Begin recording verb ') + parser.add_argument('--location', help='Get current location', action='store_true') + + def __init__(self, ip, port=None): + super().__init__(api='gps', ip=ip, port=port, service='agl-service-gps') + + async def location(self): + return await self.request('location') + + async def record(self, state='on'): + return await self.request('record', {'state': state}) + + async def subscribe(self, event='location'): + return await super().subscribe(event=event) + + async def unsubscribe(self, event='location'): + return await super().subscribe(event=event) + + +async def main(loop): + args = GPSService.parser.parse_args() + gpss = await GPSService(ip=args.ipaddr, port=args.port) + + if args.loglevel: + gpss.logger.setLevel(args.loglevel) + + if args.record: + msgid = await gpss.record(args.record) + print(f'Sent gps record request with value {args.record} with messageid {msgid}') + print(AFBResponse(await gpss.response())) + + if args.location: + msgid = await gpss.location() + print(AFBResponse(await gpss.response())) + + if args.subscribe: + for event in args.subscribe: + msgid = await gpss.subscribe(event) + print(f'Subscribed for event {event} with messageid {msgid}') + print(AFBResponse(await gpss.response())) + + if args.listener: + async for response in gpss.listener(): + print(response) + +if __name__ == '__main__': + loop = asyncio.get_event_loop() + loop.run_until_complete(main(loop)) diff --git a/pyagl/services/mediaplayer.py b/pyagl/services/mediaplayer.py new file mode 100644 index 0000000..4472531 --- /dev/null +++ b/pyagl/services/mediaplayer.py @@ -0,0 +1,160 @@ +from pyagl.services.base import AGLBaseService, AFBResponse +from typing import Union +import logging +import asyncio +import os + +class AFBMediaPlayerResponse(AFBResponse): + status: str + info: str + data = None + + def __init__(self, data: AFBResponse): + if isinstance(data, list): + super().__init__(data) + self.msgid = data.msgid + self.type = data.type + self.data = data.data + + +class MediaPlayerService(AGLBaseService): + service = 'agl-service-mediaplayer' + parser = AGLBaseService.getparser() + parser.add_argument('--playlist', help='Get current playlist', action='store_true') + parser.add_argument('--control', help='Play/Pause/Previous/Next') + parser.add_argument('--seek', help='Seek time through audio track', metavar='msec', type=int) + parser.add_argument('--rewind', help='Rewind time', metavar='msec', type=int) + parser.add_argument('--fastforward', help='Fast forward time', metavar='msec', type=int) + parser.add_argument('--picktrack', help='Play specific track in the playlist', metavar='index', type=int) + parser.add_argument('--volume', help='Volume control - <1-100>', metavar='int') + parser.add_argument('--loop', help='Set loop state - ', metavar='string') + parser.add_argument('--avrcp', help='AVRCP Controls') + def __await__(self): + return super()._async_init().__await__() + + def __init__(self, ip, port=None): + super().__init__(api='mediaplayer', ip=ip, port=port, service='agl-service-mediaplayer') + + async def playlist(self): + return await self.request('playlist') + + async def subscribe(self, event='metadata'): + return await super().subscribe(event=event) + + async def unsubscribe(self, event='metadata'): + return await super().subscribe(event=event) + + async def control(self, name, value=None): + loopstate = ['off', 'playlist', 'track'] + avrcp_controls = ['next', 'previous', 'play', 'pause'] + controls = { + 'play': None, + 'pause': None, + 'previous': None, + 'next': None, + 'seek': 'position', + 'fast-forward': 'position', + 'rewind': 'position', + 'pick-track': 'index', + 'volume': 'volume', + 'loop': 'state', + # 'avrcp_controls': 'value' + } + assert name in controls.keys(), f'Tried to use non-existent {name} as control for {self.api}' + msg = None + if name in ['play', 'pause', 'previous', 'next']: + msg = {'value': name} + elif name in ['seek', 'fast-forward', 'rewind']: + #assert value > 0, "Tried to seek with negative integer" + msg = {'value': name, controls[name]: str(value)} + elif name == 'pick-track': + assert type(value) is int, "Try picking a song with an integer" + assert value > 0, "Tried to pick a song with a negative integer" + msg = {'value': name, controls[name]: str(value)} + elif name == 'volume': + assert type(value) is int, "Try setting the volume with an integer" + assert value > 0, "Tried to set the volume with a negative integer, use values betwen 0-100" + assert value < 100, "Tried to set the volume over 100%, use values betwen 0-100" + msg = {'value': name, name: str(value)} + elif name == 'loop': + assert value in loopstate, f'Tried to set invalid loopstate - {value}, use "off", "playlist" or "track"' + msg = {'value': name, controls[name]: str(value)} + # elif name == 'avrcp_controls': + # msg = {'value': name, } + assert msg is not None, "Congratulations, somehow you made an invalid control request" + + return await self.request('controls', msg) + + +async def main(loop): + args = MediaPlayerService.parser.parse_args() + MPS = await MediaPlayerService(ip=args.ipaddr) + + if args.playlist: + msgid = await MPS.playlist() + r = AFBResponse(await MPS.response()) + for l in r.data['list']: print(l) + + if args.control: + msgid = await MPS.control(args.control) + print(f'Sent {args.control} request with messageid {msgid}') + r = AFBResponse(await MPS.response()) + print(r) + + if args.seek: + msgid = await MPS.control('seek', args.seek) + print(f'Sent seek request to {args.seek} msec with messageid {msgid}') + r = AFBResponse(await MPS.response()) + print(r) + + if args.fastforward: + msgid = await MPS.control('fast-forward', args.fastforward) + print(f'Sent fast-forward request for {args.fastforward} msec with messageid {msgid}') + r = AFBResponse(await MPS.response()) + print(r) + + if args.rewind: + msgid = await MPS.control('rewind', -args.rewind) + print(f'Sent rewind request for {args.rewind} msec with messageid {msgid}') + r = AFBResponse(await MPS.response()) + print(r) + + if args.picktrack: + msgid = await MPS.control('pick-track', args.picktrack) + print(f'Sent pick-track request with index {args.rewind} with messageid {msgid}') + r = AFBResponse(await MPS.response()) + print(r) + + if args.volume: + msgid = await MPS.control('volume', int(args.volume)) + print(f'Sent volume request: {args.rewind} with messageid {msgid}') + r = AFBResponse(await MPS.response()) + print(r) + + if args.loop: + msgid = await MPS.control('loop', args.loop) + print(f'Sent loop-state request: {args.loop} with messageid {msgid}') + r = AFBResponse(await MPS.response()) + print(r) + + # if args.avrcp: + # id = await MPS.control('avrcp_controls', args.avrcp) + # print(f'Sent AVRCP control request: {args.loop} with messageid {id}') + # r = AFBResponse(await MPS.response()) + # print(r) + + if args.subscribe: + for event in args.subscribe: + msgid = await MPS.subscribe(event) + print(f"Subscribed for event {event} with messageid {msgid}") + r = await MPS.response() + print(r) + + if args.listener: + async for response in MPS.listener(): + print(response) + + +if __name__ == '__main__': + loop = asyncio.get_event_loop() + loop.run_until_complete(main(loop)) diff --git a/pyagl/services/network.py b/pyagl/services/network.py new file mode 100644 index 0000000..e69de29 diff --git a/pyagl/services/nfc.py b/pyagl/services/nfc.py new file mode 100644 index 0000000..78f9b6e --- /dev/null +++ b/pyagl/services/nfc.py @@ -0,0 +1,42 @@ +from pyagl.services.base import AGLBaseService, AFBResponse +import asyncio + +class NFCService(AGLBaseService): + service = 'agl-service-nfc' + parser = AGLBaseService.getparser() + + def __init__(self, ip, port=None, api='nfc'): + super().__init__(ip=ip, port=port, api=api, service='agl-service-nfc') + + async def subscribe(self, event='presence'): + return await super().subscribe(event=event) + + async def unsubscribe(self, event='presence'): + return await super().unsubscribe(event=event) + + +async def main(loop): + args = NFCService.parser.parse_args() + nfcs = await NFCService(ip=args.ipaddr, port=args.port) + + if args.subscribe: + for event in args.subscribe: + msgid = await nfcs.subscribe(event) + print(f"Subscribing for event {event} with messageid {msgid}") + r = AFBResponse(await nfcs.response()) + print(r) + + if args.unsubscribe: + for event in args.unsubscribe: + msgid = await nfcs.unsubscribe(event) + print(f"Unsubscribing for event {event} with messageid {msgid}") + r = AFBResponse(await nfcs.response()) + print(r) + + if args.listener: + async for response in nfcs.listener(): + print(response) + +if __name__ == '__main__': + loop = asyncio.get_event_loop() + loop.run_until_complete(main(loop)) diff --git a/pyagl/services/taskmanager.py b/pyagl/services/taskmanager.py new file mode 100644 index 0000000..e69de29 diff --git a/pyagl/services/weather.py b/pyagl/services/weather.py new file mode 100644 index 0000000..389be70 --- /dev/null +++ b/pyagl/services/weather.py @@ -0,0 +1,48 @@ +from pyagl.services.base import AGLBaseService, AFBResponse +import asyncio +import json + + +class WeatherService(AGLBaseService): + service = 'agl-service-weather' + parser = AGLBaseService.getparser() + parser.add_argument('--current', default=True, help='Request current weather state', action='store_true') + parser.add_argument('--apikey', default=False, help='Request weather API Key', action='store_true') + + def __init__(self, ip, port=None): + super().__init__(api='weather', ip=ip, port=port, service='agl-service-weather') + + async def current_weather(self): + return await self.request('current_weather', "") + + async def apikey(self): + return await self.request('api_key', "") + + +async def main(): + args = WeatherService.parser.parse_args() + aws = await WeatherService(ip=args.ipaddr, port=args.port) + if args.current: + msgid = await aws.current_weather() + resp = AFBResponse(await aws.response()) + print(json.dumps(resp.data, indent=2)) + + if args.apikey: + msgid = await aws.apikey() + resp = AFBResponse(await aws.response()) + print(resp.data['api_key']) + + if args.subscribe: + for event in args.subscribe: + msgid = await aws.subscribe(event) + print(f'Subscribed for event {event} with messageid {msgid}') + resp = AFBResponse(await aws.response()) + print(resp) + + if args.listener: + async for response in aws.listener(): + print(response) + +if __name__ == '__main__': + loop = asyncio.get_event_loop() + loop.run_until_complete(main()) diff --git a/pyagl/tests/test_bluetooth.py b/pyagl/tests/test_bluetooth.py new file mode 100644 index 0000000..0670f5e --- /dev/null +++ b/pyagl/tests/test_bluetooth.py @@ -0,0 +1,105 @@ +import asyncio +import os +import pytest + +from pyagl.services.base import AFBResponse, AFBT +from pyagl.services.bluetooth import BluetoothService as BTS +import logging + +logger = logging.getLogger(f'pytest-{BTS.service}') +logger.setLevel(logging.DEBUG) +pytestmark = pytest.mark.asyncio + + +@pytest.fixture(scope='module') +def event_loop(): + loop = asyncio.get_event_loop() + yield loop + loop.close() + + +@pytest.fixture(scope='module') +async def service(): + address = os.environ.get('AGL_TGT_IP', 'localhost') + port = os.environ.get('AGL_TGT_PORT', None) + + bts = await BTS(ip=address, port=port) + yield bts + await bts.websocket.close() + + +@pytest.mark.xfail +@pytest.fixture(scope='module') +def btaddr(): + bthtestaddr = os.environ.get('AGL_TEST_BT_ADDR', None) + if not bthtestaddr: + pytest.xfail('No test bluetooth test address set in environment variables') + + return bthtestaddr + + +@pytest.mark.dependency +async def test_default_adapter(event_loop, service: BTS): + msgid = await service.default_adapter() + resp = AFBResponse(await service.response()) + assert resp.status == 'success', resp + assert 'adapter' in resp.data.keys() + assert resp.data['adapter'] == 'hci0' + + +@pytest.mark.dependency(depends=['test_default_adapter']) +async def test_managed_objects(event_loop, service: BTS): + msgid = await service.managed_objects() + resp = AFBResponse(await service.response()) + assert resp.status == 'success', str(resp) + + +@pytest.mark.dependency(depends=['test_default_adapter']) +async def test_has_single_adapter(event_loop, service: BTS): + msgid = await service.managed_objects() + resp = AFBResponse(await service.response()) + assert len(resp.data['adapters']) == 1, \ + f'Detected {len(resp.data["adapters"])} adapters. Multiple adapters may also break testing' + + +@pytest.mark.dependency(depends=['test_default_adapter']) +async def test_adapter_state(event_loop, service: BTS): + msgid = await service.adapter_state('hci0') + resp = AFBResponse(await service.response()) + assert resp.status == 'success', 'adapter state verb failed' + + +async def test_pairing_verb(event_loop, service: BTS, btaddr): + msgid = await service.pair(btaddr) + resp = await service.afbresponse() + assert msgid == resp.msgid + assert resp.status == 'success', f'pair verb failed - {resp.info}' + + +async def test_connect_verb(event_loop, service: BTS, btaddr): + msgid = await service.connect(btaddr) + resp = await service.afbresponse() + assert msgid == resp.msgid + assert resp.status == 'success', f'connect verb failed - {resp.info}' + + +async def test_disconnect_verb(event_loop, service: BTS, btaddr): + msgid = await service.disconnect(btaddr) + resp = await service.afbresponse() + assert msgid == resp.msgid + assert resp.status == 'success', f'disconnect verb failed - {resp.info}' + + +async def test_remove_pairing_verb(event_loop, service: BTS, btaddr): + msgid = await service.remove_device(btaddr) + resp = await service.afbresponse() + assert msgid == resp.msgid + assert resp.status == 'success' + + +@pytest.mark.xfail(reason='This is expected to fail because there has to be an ongoing pairing attempt') +async def test_confirm_pairing_verb(event_loop, service: BTS, btaddr): + msgid = await service.confirm_pairing(pincode='123456') + resp = await service.afbresponse() + assert msgid == resp.msgid + assert resp.status == 'success', f'confirm_pairing verb failed - {resp.info}' diff --git a/pyagl/tests/test_gps.py b/pyagl/tests/test_gps.py new file mode 100644 index 0000000..7e56d1e --- /dev/null +++ b/pyagl/tests/test_gps.py @@ -0,0 +1,73 @@ +import asyncio +import os +import pytest +import logging +from pyagl.services.base import AFBResponse, AFBT +from pyagl.services.gps import GPSService as GPS +from concurrent.futures import TimeoutError + +pytestmark = pytest.mark.asyncio + +@pytest.fixture(scope='module') +def event_loop(): + loop = asyncio.get_event_loop() + yield loop + loop.close() + +@pytest.fixture(scope='module') +async def service(): + address = os.environ.get('AGL_TGT_IP', 'localhost') + port = os.environ.get('AGL_TGT_PORT', None) + gpss = await GPS(ip=address, port=port) + yield gpss + await gpss.websocket.close() + +# @pytest.fixture(scope='module') +# async def response(event_loop, service): +# async for _response in service.listener(): +# yield _response + +async def test_location_verb(event_loop, service: GPS): + msgid = await service.location() + resp = AFBResponse(await service.response()) + assert resp.msgid == msgid + +@pytest.mark.xfail # expecting this to fail because of "No 3D GNSS fix" and GPS is unavailable +async def test_location_result(event_loop, service: GPS): + msgid = await service.location() + resp = AFBResponse(await service.response()) + assert resp.status == 'success' + +async def test_subscribe_verb(event_loop, service: GPS): + msgid = await service.subscribe("") + resp = AFBResponse(await service.response()) + assert resp.msgid == msgid + assert resp.status == 'success' + +async def test_subscribe_location(event_loop, service: GPS): + msgid = await service.subscribe('location') + resp = AFBResponse(await service.response()) + assert resp.msgid == msgid + assert resp.status == 'success' + +async def test_unsubscribe(event_loop, service: GPS): + msgid = await service.unsubscribe('location') + resp = AFBResponse(await service.response()) + assert resp.msgid == msgid + assert resp.status == 'success' + +@pytest.mark.xfail # expecting this to fail because of "No 3D GNSS fix" and GPS is unavailable +async def test_location_events(event_loop, service: GPS): + msgid = await service.subscribe('location') + resp = AFBResponse(await service.response()) + assert resp.msgid == msgid + assert resp.status == 'success' # successful subscription + try: + resp = await asyncio.wait_for(service.afbresponse(), 10) + resp = AFBResponse(resp) + assert resp.type == AFBT.EVENT, f'Expected EVENT response, got {resp.type.name} instead' + # TODO one more assert for the actual received event, haven't received a location event yet + except TimeoutError: + pytest.xfail("Did not receive location event") + + diff --git a/pyagl/tests/test_weather.py b/pyagl/tests/test_weather.py new file mode 100644 index 0000000..e69de29 diff --git a/services/audiomixer.py b/services/audiomixer.py deleted file mode 100644 index e594471..0000000 --- a/services/audiomixer.py +++ /dev/null @@ -1,94 +0,0 @@ -from aglbaseservice import AGLBaseService, AFBResponse -import asyncio -import os - -verbs = ['subscribe', 'unsubscribe', 'list_controls', 'volume', 'mute'] -events = ['volume_changed', 'mute_changed', 'controls_changed'] - - -class AudioMixerService(AGLBaseService): - service = 'agl-service-audiomixer' - parser = AGLBaseService.getparser() - parser.add_argument('--list_controls', default=True, help='Request list of controls', action='store_true') - parser.add_argument('--getmute', help='Get mute state', action='store_true') - parser.add_argument('--setmute', help='Set mute state', type=int, choices=[0, 1]) - parser.add_argument('--setvolume', help='Set volume level', type=float) - parser.add_argument('--getvolume', help='Get volume level', action='store_true') - - - def __init__(self, ip, port=None, service='agl-service-audiomixer'): - super().__init__(api='audiomixer', ip=ip, port=port, service=service) - - async def subscribe(self, event='volume_changed'): # audio mixer uses 'event' instead 'value', - return await self.request('subscribe', {'event': event}) - - async def unsubscribe(self, event='volume_changed'): - return await self.request('unsubscribe', {'event': event}) - - async def list_controls(self): - return await self.request('list_controls') - - async def volume(self, control='Master', value=None): - if value is not None: - return await self.request('volume', {'control': control, 'value': value}) - else: - return await self.request('volume', {'control': control}) - - async def mute(self, value=None): - return await self.request('mute', {'control': 'Master', 'value': value}) - - -async def main(): - args = AudioMixerService.parser.parse_args() - ams = await AudioMixerService(ip=args.ipaddr, port=args.port) - - if args.list_controls: - resp = await ams.list_controls() - print(f'Requesting list_controls with id {resp}') - r = AFBResponse(await ams.response()) - print(r) - - if args.setvolume is not None: - resp = await ams.volume(args.setvolume) - print(f'Setting volume to {args.setvolume} with id {resp}') - r = AFBResponse(await ams.response()) - print(r) - - if args.getvolume: - resp = await ams.volume() - print(f'Requesting volume with id {resp}') - r = AFBResponse(await ams.response()) - print(r) - - if args.setmute is not None: - resp = await ams.mute(args.setmute) - print(f'Setting mute to {args.setmute} with id {resp}') - r = AFBResponse(await ams.response()) - print(r) - - if args.getmute: - resp = await ams.mute() - r = AFBResponse(await ams.response()) - print(r) - - if args.subscribe: - for event in args.subscribe: - id = await ams.subscribe(event) - print(f'Subscribing to {event} with id {id}') - r = AFBResponse(await ams.response()) - print(r) - - if args.unsubscribe: - for event in args.unsubscribe: - id = await ams.unsubscribe(event) - print(f'Unsubscribing from {event} with id {id}') - r = AFBResponse(await ams.response()) - print(r) - - if args.listener: - async for response in ams.listener(): - print(response) - -if __name__ == '__main__': - loop = asyncio.get_event_loop() - loop.run_until_complete(main()) diff --git a/services/base.py b/services/base.py deleted file mode 100644 index 03d4793..0000000 --- a/services/base.py +++ /dev/null @@ -1,258 +0,0 @@ -from json import JSONDecodeError -from parse import Result, parse -from websockets import connect -from random import randint -from enum import IntEnum -from typing import Union -import asyncssh -import argparse -import asyncio -import binascii -import logging -import json -import sys -import os -import re - -# logging.getLogger('AGLBaseService') -# logging.basicConfig(level=logging.DEBUG) - -# AFB message type -class AFBT(IntEnum): - REQUEST = 2, - RESPONSE = 3, - ERROR = 4, - EVENT = 5 - -msgq = {} -AFBLEN = 3 - - -def newrand(): - while True: - bs = os.urandom(5) - result = bs[0] * bs[1] * bs[2] * bs[3] + bs[4] - yield result - -def addrequest(msgid, msg): - msgq[msgid] = {'request': msg, 'response': None} - -def addresponse(msgid, msg): - if msgid in msgq.keys(): - msgq[msgid]['response'] = msg - -class AFBResponse: - type: AFBT - msgid: int - data: dict - api: str - status: str - info: str - - def __init__(self, data: list): - if type(data[0]) is not int: - logging.debug(f'Received a response with non-integer message type {binascii.hexlify(data[0])}') - raise ValueError('Received a response with non-integer message type') - if data[0] not in AFBT._value2member_map_: - raise ValueError(f'Received a response with invalid message type {data[0]}') - self.type = AFBT(data[0]) - - if self.type == AFBT.RESPONSE: - if 'request' not in data[2]: - logging.error(f'Received malformed or invalid response without "request" dict - {data}') - if not str.isnumeric(data[1]): - raise ValueError(f'Received a response with non-numeric message id {data[1]}') - else: - self.msgid = int(data[1]) - self.status = data[2]['request']['status'] - if 'info' in data[2]['request']: - self.info = data[2]['request']['info'] - if 'response' in data[2]: - self.data = data[2]['response'] - - elif self.type == AFBT.EVENT: - self.api = data[1] - if 'data' in data[2]: - self.data = data[2]['data'] - - elif self.type == AFBT.ERROR: - logging.debug(f'AFB returned erroneous response {data}') - self.msgid = int(data[1]) - self.status = data[2]['request']['status'] - self.info = data[2]['request']['info'] - # raise ValueError(f'AFB returned erroneous response {data}') - # if 'request' not in data[2] or 'response' not in data[2]: - - if 'response' in data[2]: - self.data = data[2]['response'] - - def __str__(self): # for debugging purposes - if self.type == AFBT.EVENT: - return f'[{self.type.name}][{self.api}][Data: {self.data if hasattr(self, "data") else None}]' - else: - return f'[{self.type.name}][Status: {self.status}][{self.msgid}]' \ - f'[Info: {self.info if hasattr(self,"info") else None}]' \ - f'[Data: {self.data if hasattr(self, "data") else None}]' - - -class AGLBaseService: - api: str - url: str - ip : str - port = None - token: str - uuid: str - service = None - logger = None - - @staticmethod - def getparser(): - parser = argparse.ArgumentParser(description='Utility to interact with agl-service-* via it\'s websocket') - parser.add_argument('-l', '--loglevel', help='Level of logging verbosity', default='INFO', - choices=list(logging._nameToLevel.keys())) - parser.add_argument('ipaddr', default=os.environ.get('AGL_TGT_IP', 'localhost'), help='AGL host address') - parser.add_argument('--port', default=os.environ.get('AGL_TGT_PORT', None), help=f'AGL service websocket port') - parser.add_argument('--listener', default=False, help='Register a listener for incoming events', action='store_true') - parser.add_argument('--subscribe', type=str, help='Subscribe to event type', action='append', metavar='event') - parser.add_argument('--unsubscribe', type=str, help='Unsubscribe from event type', action='append', metavar='event') - parser.add_argument('--json', type=str, help='Send your own json string') - parser.add_argument('--verb', type=str, help='Send the json above to specific verb') - parser.add_argument('--api', type=str, help='Send the above two to a specific api') - return parser - - def __init__(self, api: str, ip: str, port: str = None, url: str = None, - token: str = 'HELLO', uuid: str = 'magic', service: str = None): - self.api = api - self.url = url - self.ip = ip - self.port = port - self.token = token - self.uuid = uuid - self.service = service - self.logger = logging.getLogger(service) - - def __await__(self): - return self._async_init().__await__() - - async def __aenter__(self): - return self._async_init() - - async def _async_init(self): - # setting ping_interval to None because AFB does not support websocket ping - # if set to !None, the library will close the socket after the default timeout - if self.port is None: - serviceport = await self.portfinder() - if serviceport is not None: - self.port = serviceport - else: - self.logger.error('Unable to find port') - exit(1) - - URL = f'ws://{self.ip}:{self.port}/api?token={self.token}&uuid={self.uuid}' - self._conn = connect(close_timeout=0, uri=URL, subprotocols=['x-afb-ws-json1'], ping_timeout=None, compression=None) - self.websocket = await self._conn.__aenter__() - return self - - async def __aexit__(self, *args, **kwargs): - await self._conn.__aexit__(*args, **kwargs) - - async def close(self): - await self._conn.__aexit__(*sys.exc_info()) - - async def send(self, message): - await self.websocket.send(message) - - async def receive(self): - return await self.websocket.recv() - - async def portfinder(self): - # TODO:handle ssh timeouts, asyncssh does not support it apparently, and connect returns context_manager which - # cannot be used with asyncio.wait_for - - async with asyncssh.connect(self.ip, username='root') as c: - - servicename = await c.run(f"systemctl --all | grep {self.service}-- | awk '{{print $1}}'", check=False) - if self.service not in servicename.stdout: - logging.error(f"Service matching pattern - '{self.service}' - NOT FOUND") - exit(1) - pidres = await c.run(f'systemctl show --property MainPID --value {servicename.stdout.strip()}') - pid = int(pidres.stdout.strip(), 10) - if pid is 0: - logging.warning(f'Service {servicename.stdout.strip()} is stopped') - return None - else: - self.logger.debug(f'Service PID: {str(pid)}') - - sockets = await c.run(f'find /proc/{pid}/fd/ | xargs readlink | grep socket') - inodes = frozenset(re.findall("socket:\\[(.*)\\]", sockets.stdout)) - self.logger.debug(f"Socket inodes: {inodes}") - - procnettcp = await c.run('cat /proc/net/tcp') - fieldsstr = '{sl}: {local_address} {rem_address} {st} {tx_queue}:{rx_queue} {tr}:{tmwhen} {retrnsmt} {uid}'\ - ' {timeout} {inode} {sref_cnt} {memloc} {rto} {pred_sclk} {ackquick} {congest} {slowstart}' - tcpsockets = [' '.join(l.split()) for l in procnettcp.stdout.splitlines()[1:]] - # different lines with less stats appear sometimes, parse will return None, so ignore 'None' lines - parsedtcpsockets = [] - for l in tcpsockets: - res = parse(fieldsstr, l) - if isinstance(res, Result): - parsedtcpsockets.append(res) - - socketinodesbythisprocess = [l for l in parsedtcpsockets if - isinstance(l, Result) and - l.named['inode'] in inodes and - # 0A is listening state for the socket - l.named['st'] == '0A'] - - for s in socketinodesbythisprocess: - _, port = tuple(parse('{}:{}', s['local_address'])) - port = int(port, 16) - if port >= 30000: # the port is above 30000 range, 8080 is some kind of proxy - self.logger.debug(f'Service running at port {port}') - return port - - async def listener(self, stdout: bool = False): - while True: - raw = await self.response() - data = AFBResponse(raw) - if stdout: print(data) - yield data - - async def response(self): - try: - msg = await self.websocket.recv() - try: - data = json.loads(msg) - self.logger.debug('[AGL] -> ' + msg) - if isinstance(data, list): - # check whether the received response is an answer to previous query and queue it for debugging - if len(data) == AFBLEN and data[0] == AFBT.RESPONSE and str.isnumeric(data[1]): - msgid = int(data[1]) - if msgid in msgq: - addresponse(msgid, data) - return data - except JSONDecodeError: - self.logger.warning("Not decoding a non-json message") - - except KeyboardInterrupt: - self.logger.debug("Received keyboard interrupt, exiting") - except asyncio.CancelledError: - self.logger.warning("Websocket listener coroutine stopped") - except Exception as e: - self.logger.error("Unhandled seal: " + str(e)) - - async def afbresponse(self): - return AFBResponse(await self.response()) - - async def request(self, verb: str, values: Union[str, dict] = "", msgid: int = None): - msgid = next(newrand()) if msgid is None else msgid - l = json.dumps([AFBT.REQUEST, str(msgid), f'{self.api}/{verb}', values]) - self.logger.debug(f'[AGL] <- {l}') - await self.send(l) - return msgid - - async def subscribe(self, event): - return await self.request('subscribe', {'value': f'{event}'}) # some services may use 'event' instead 'value' - - async def unsubscribe(self, event): - return await self.request('unsubscribe', {'value': f'{event}'}) diff --git a/services/bluetooth-map.py b/services/bluetooth-map.py deleted file mode 100644 index e69de29..0000000 diff --git a/services/bluetooth-pbap.py b/services/bluetooth-pbap.py deleted file mode 100644 index e69de29..0000000 diff --git a/services/bluetooth.py b/services/bluetooth.py deleted file mode 100644 index b73f8a8..0000000 --- a/services/bluetooth.py +++ /dev/null @@ -1,96 +0,0 @@ -from aglbaseservice import AGLBaseService, AFBResponse -import asyncio -import os - -Verbs = ['subscribe', 'unsubscribe', 'managed_objects', 'adapter_state', 'default_adapter', 'avrcp_controls', - 'connect', 'disconnect', 'pair', 'cancel_pairing', 'confirm_pairing', 'remove_device'] -AdapterStateParams = ['discovery', 'discoverable', 'powered', ] - -BTEventType = ['adapter_changes', 'device_changes', 'media', 'agent'] - - -class BluetoothService(AGLBaseService): - service = 'agl-service-bluetooth' - parser = AGLBaseService.getparser() - parser.add_argument('--default_adapter', help='Get default bluetooth adapter', action='store_true') - parser.add_argument('--managed_objects', help='Get managed objects', action='store_true') - parser.add_argument('--adapter', help='Select remote adapter', required=False, default='hci0') - parser.add_argument('--adapter_state') - parser.add_argument('--connect', help='Connect to device', metavar='dev_88_0F_10_96_D3_20') - parser.add_argument('--disconnect', help='Disconnect from device', metavar='dev_88_0F_10_96_D3_20') - parser.add_argument('--pair', help='Pair with a device', metavar='dev_88_0F_10_96_D3_20') - parser.add_argument('--cancel_pairing', help='Cancel ongoing pairing') - parser.add_argument('--confirm_pairing', metavar='pincode') - parser.add_argument('--remove_device', metavar='dev_88_0F_10_96_D3_20', help='Remove paired device') - - - - def __init__(self, ip, port=None, service='agl-service-bluetooth'): - super().__init__(api='Bluetooth-Manager', ip=ip, port=port, service=service) - - async def subscribe(self, event='device_changes'): - await super().subscribe(event=event) - - async def unsubscribe(self, event='device_changes'): - await super().unsubscribe(event=event) - - async def managed_objects(self): - return await self.request('managed_objects') - - async def adapter_state(self, adapter=None, value=None): - p = {} - if adapter: - p = {'adapter': adapter} - if isinstance(value, dict): - p = {**p, **value} - - return await self.request('adapter_state', p) - - async def default_adapter(self): - return await self.request('default_adapter', "") - - async def connect(self, device: str = 'hci0'): - return await self.request('connect', {'device': device}) - - async def disconnect(self, device: str = 'hci0'): - return await self.request('disconnect', {'device': device}) - - async def pair(self, device): - return await self.request('pair', {'device': device}) - - async def cancel_pairing(self): - return await self.request('cancel_pairing') - - async def confirm_pairing(self, pincode): - return await self.request('confirm_pairing', {'pincode': pincode}) - - async def remove_device(self, device): - return await self.request('remove_device', {'device': device}) - - async def avrcp_controls(self): - pass - -async def main(loop): - args = BluetoothService.parser.parse_args() - bts = await BluetoothService(ip=args.ipaddr, port=args.port) - - if args.default_adapter: - id = await bts.default_adapter() - print(f'Requesting default adapter with id {id}') - r = AFBResponse(await bts.response()) - print(r) - - if args.adapter_state: - pass - - if args.listener: - for response in bts.listener(): - print(response) - - bts.logger.debug(await bts.adapter_state('hci0', {'uuids': ['0000110e-0000-1000-8000-00805f9b34fb']})) - - - -if __name__ == '__main__': - loop = asyncio.get_event_loop() - loop.run_until_complete(main(loop)) diff --git a/services/geoclue.py b/services/geoclue.py deleted file mode 100644 index df1afd6..0000000 --- a/services/geoclue.py +++ /dev/null @@ -1,44 +0,0 @@ -from aglbaseservice import AGLBaseService, AFBResponse -import asyncio -import os - - -class GeoClueService(AGLBaseService): - service = 'agl-service-geoclue' - parser = AGLBaseService.getparser() - parser.add_argument('--location', help='Get current location', action='store_true') - - def __init__(self, ip, port=None, api='geoclue'): - super().__init__(ip=ip, port=port, api=api, service='agl-service-geoclue') - - async def location(self): - return await self.request('location') - - async def subscribe(self, event='location'): - return await super().subscribe(event=event) - - async def unsubscribe(self, event='location'): - return await super().unsubscribe(event=event) - - -async def main(loop): - args = GeoClueService.parser.parse_args() - gcs = await GeoClueService(args.ipaddr) - - if args.location: - id = await gcs.location() - print(f'Sent location request with messageid {id}') - print(AFBResponse(await gcs.response())) - - if args.subscribe: - for event in args.subscribe: - id = await gcs.subscribe(event) - print(f"Subscribed for {event} with messageid {id}") - print(AFBResponse(await gcs.response())) - if args.listener: - async for response in gcs.listener(): - print(response) - -if __name__ == '__main__': - loop = asyncio.get_event_loop() - loop.run_until_complete(main(loop)) diff --git a/services/gps.py b/services/gps.py deleted file mode 100644 index 1905e0f..0000000 --- a/services/gps.py +++ /dev/null @@ -1,56 +0,0 @@ -from aglbaseservice import AGLBaseService, AFBResponse -import asyncio -import os - - -class GPSService(AGLBaseService): - service = 'agl-service-gps' - parser = AGLBaseService.getparser() - parser.add_argument('--record', help='Begin recording verb ') - parser.add_argument('--location', help='Get current location', action='store_true') - - def __init__(self, ip, port=None): - super().__init__(api='gps', ip=ip, port=port, service='agl-service-gps') - - async def location(self): - return await self.request('location') - - async def record(self, state='on'): - return await self.request('record', {'state': state}) - - async def subscribe(self, event='location'): - return await super().subscribe(event=event) - - async def unsubscribe(self, event='location'): - return await super().subscribe(event=event) - - -async def main(loop): - args = GPSService.parser.parse_args() - gpss = await GPSService(ip=args.ipaddr, port=args.port) - - if args.loglevel: - gpss.logger.setLevel(args.loglevel) - - if args.record: - id = await gpss.record(args.record) - print(f'Sent gps record request with value {args.record} with messageid {id}') - print(AFBResponse(await gpss.response())) - - if args.location: - msgid = await gpss.location() - print(AFBResponse(await gpss.response())) - - if args.subscribe: - for event in args.subscribe: - id = await gpss.subscribe(event) - print(f'Subscribed for event {event} with messageid {id}') - print(AFBResponse(await gpss.response())) - - if args.listener: - async for response in gpss.listener(): - print(response) - -if __name__ == '__main__': - loop = asyncio.get_event_loop() - loop.run_until_complete(main(loop)) diff --git a/services/mediaplayer.py b/services/mediaplayer.py deleted file mode 100644 index 9291981..0000000 --- a/services/mediaplayer.py +++ /dev/null @@ -1,160 +0,0 @@ -from aglbaseservice import AGLBaseService, AFBResponse -from typing import Union -import logging -import asyncio -import os - -class AFBMediaPlayerResponse(AFBResponse): - status: str - info: str - data = None - - def __init__(self, data: AFBResponse): - if isinstance(data, list): - super().__init__(data) - self.msgid = data.msgid - self.type = data.type - self.data = data.data - - -class MediaPlayerService(AGLBaseService): - service = 'agl-service-mediaplayer' - parser = AGLBaseService.getparser() - parser.add_argument('--playlist', help='Get current playlist', action='store_true') - parser.add_argument('--control', help='Play/Pause/Previous/Next') - parser.add_argument('--seek', help='Seek time through audio track', metavar='msec', type=int) - parser.add_argument('--rewind', help='Rewind time', metavar='msec', type=int) - parser.add_argument('--fastforward', help='Fast forward time', metavar='msec', type=int) - parser.add_argument('--picktrack', help='Play specific track in the playlist', metavar='index', type=int) - parser.add_argument('--volume', help='Volume control - <1-100>', metavar='int') - parser.add_argument('--loop', help='Set loop state - ', metavar='string') - parser.add_argument('--avrcp', help='AVRCP Controls') - def __await__(self): - return super()._async_init().__await__() - - def __init__(self, ip, port=None): - super().__init__(api='mediaplayer', ip=ip, port=port, service='agl-service-mediaplayer') - - async def playlist(self): - return await self.request('playlist') - - async def subscribe(self, event='metadata'): - return await super().subscribe(event=event) - - async def unsubscribe(self, event='metadata'): - return await super().subscribe(event=event) - - async def control(self, name, value=None): - loopstate = ['off', 'playlist', 'track'] - avrcp_controls = ['next', 'previous', 'play', 'pause'] - controls = { - 'play': None, - 'pause': None, - 'previous': None, - 'next': None, - 'seek': 'position', - 'fast-forward': 'position', - 'rewind': 'position', - 'pick-track': 'index', - 'volume': 'volume', - 'loop': 'state', - # 'avrcp_controls': 'value' - } - assert name in controls.keys(), f'Tried to use non-existent {name} as control for {self.api}' - msg = None - if name in ['play', 'pause', 'previous', 'next']: - msg = {'value': name} - elif name in ['seek', 'fast-forward', 'rewind']: - #assert value > 0, "Tried to seek with negative integer" - msg = {'value': name, controls[name]: str(value)} - elif name == 'pick-track': - assert type(value) is int, "Try picking a song with an integer" - assert value > 0, "Tried to pick a song with a negative integer" - msg = {'value': name, controls[name]: str(value)} - elif name == 'volume': - assert type(value) is int, "Try setting the volume with an integer" - assert value > 0, "Tried to set the volume with a negative integer, use values betwen 0-100" - assert value < 100, "Tried to set the volume over 100%, use values betwen 0-100" - msg = {'value': name, name: str(value)} - elif name == 'loop': - assert value in loopstate, f'Tried to set invalid loopstate - {value}, use "off", "playlist" or "track"' - msg = {'value': name, controls[name]: str(value)} - # elif name == 'avrcp_controls': - # msg = {'value': name, } - assert msg is not None, "Congratulations, somehow you made an invalid control request" - - return await self.request('controls', msg) - - -async def main(loop): - args = MediaPlayerService.parser.parse_args() - MPS = await MediaPlayerService(ip=args.ipaddr) - - if args.playlist: - id = await MPS.playlist() - r = AFBResponse(await MPS.response()) - for l in r.data['list']: print(l) - - if args.control: - id = await MPS.control(args.control) - print(f'Sent {args.control} request with messageid {id}') - r = AFBResponse(await MPS.response()) - print(r) - - if args.seek: - id = await MPS.control('seek', args.seek) - print(f'Sent seek request to {args.seek} msec with messageid {id}') - r = AFBResponse(await MPS.response()) - print(r) - - if args.fastforward: - id = await MPS.control('fast-forward', args.fastforward) - print(f'Sent fast-forward request for {args.fastforward} msec with messageid {id}') - r = AFBResponse(await MPS.response()) - print(r) - - if args.rewind: - id = await MPS.control('rewind', -args.rewind) - print(f'Sent rewind request for {args.rewind} msec with messageid {id}') - r = AFBResponse(await MPS.response()) - print(r) - - if args.picktrack: - id = await MPS.control('pick-track', args.picktrack) - print(f'Sent pick-track request with index {args.rewind} with messageid {id}') - r = AFBResponse(await MPS.response()) - print(r) - - if args.volume: - id = await MPS.control('volume', int(args.volume)) - print(f'Sent volume request: {args.rewind} with messageid {id}') - r = AFBResponse(await MPS.response()) - print(r) - - if args.loop: - id = await MPS.control('loop', args.loop) - print(f'Sent loop-state request: {args.loop} with messageid {id}') - r = AFBResponse(await MPS.response()) - print(r) - - # if args.avrcp: - # id = await MPS.control('avrcp_controls', args.avrcp) - # print(f'Sent AVRCP control request: {args.loop} with messageid {id}') - # r = AFBResponse(await MPS.response()) - # print(r) - - if args.subscribe: - for event in args.subscribe: - id = await MPS.subscribe(event) - print(f"Subscribed for event {event} with messageid {id}") - r = await MPS.response() - print(r) - - if args.listener: - async for response in MPS.listener(): - print(response) - - -if __name__ == '__main__': - loop = asyncio.get_event_loop() - loop.run_until_complete(main(loop)) diff --git a/services/network.py b/services/network.py deleted file mode 100644 index e69de29..0000000 diff --git a/services/nfc.py b/services/nfc.py deleted file mode 100644 index c966c4a..0000000 --- a/services/nfc.py +++ /dev/null @@ -1,42 +0,0 @@ -from aglbaseservice import AGLBaseService, AFBResponse -import asyncio - -class NFCService(AGLBaseService): - service = 'agl-service-nfc' - parser = AGLBaseService.getparser() - - def __init__(self, ip, port=None, api='nfc'): - super().__init__(ip=ip, port=port, api=api, service='agl-service-nfc') - - async def subscribe(self, event='presence'): - return await super().subscribe(event=event) - - async def unsubscribe(self, event='presence'): - return await super().unsubscribe(event=event) - - -async def main(loop): - args = NFCService.parser.parse_args() - nfcs = await NFCService(ip=args.ipaddr, port=args.port) - - if args.subscribe: - for event in args.subscribe: - id = await nfcs.subscribe(event) - print(f"Subscribing for event {event} with messageid {id}") - r = AFBResponse(await nfcs.response()) - print(r) - - if args.unsubscribe: - for event in args.unsubscribe: - id = await nfcs.unsubscribe(event) - print(f"Unsubscribing for event {event} with messageid {id}") - r = AFBResponse(await nfcs.response()) - print(r) - - if args.listener: - async for response in nfcs.listener(): - print(response) - -if __name__ == '__main__': - loop = asyncio.get_event_loop() - loop.run_until_complete(main(loop)) diff --git a/services/taskmanager.py b/services/taskmanager.py deleted file mode 100644 index e69de29..0000000 diff --git a/services/weather.py b/services/weather.py deleted file mode 100644 index f3c02d0..0000000 --- a/services/weather.py +++ /dev/null @@ -1,48 +0,0 @@ -import asyncio -import json -from aglbaseservice import AGLBaseService, AFBResponse - - -class WeatherService(AGLBaseService): - service = 'agl-service-weather' - parser = AGLBaseService.getparser() - parser.add_argument('--current', default=True, help='Request current weather state', action='store_true') - parser.add_argument('--apikey', default=False, help='Request weather API Key', action='store_true') - - def __init__(self, ip, port=None): - super().__init__(api='weather', ip=ip, port=port, service='agl-service-weather') - - async def current_weather(self): - return await self.request('current_weather', "") - - async def apikey(self): - return await self.request('api_key', "") - - -async def main(): - args = WeatherService.parser.parse_args() - aws = await WeatherService(ip=args.ipaddr, port=args.port) - if args.current: - id = await aws.current_weather() - resp = AFBResponse(await aws.response()) - print(json.dumps(resp.data, indent=2)) - - if args.apikey: - id = await aws.apikey() - resp = AFBResponse(await aws.response()) - print(resp.data['api_key']) - - if args.subscribe: - for event in args.subscribe: - id = await aws.subscribe(event) - print(f'Subscribed for event {event} with messageid {id}') - resp = AFBResponse(await aws.response()) - print(resp) - - if args.listener: - async for response in aws.listener(): - print(response) - -if __name__ == '__main__': - loop = asyncio.get_event_loop() - loop.run_until_complete(main()) diff --git a/setup.py b/setup.py index e69de29..afb45ab 100644 --- a/setup.py +++ b/setup.py @@ -0,0 +1,26 @@ +import setuptools + +with open("README.md", "r") as fh: + long_description = fh.read() + +setuptools.setup( + name="pyagl", + version="0.0.1", + author="Edi Feschiyan", + author_email="edi.feschiyan@konsulko.com", + description="Python bindings and tests for Automotive Grade Linux services", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/refresher/pyagl", + packages=setuptools.find_packages(), + license="Apache 2.0", + install_requires=['websockets', 'parse', 'asyncssh', 'pytest', 'pytest-asyncio'], + classifiers=[ + 'Development Status :: 4 - Beta', + 'Environment :: Console', + "Programming Language :: Python :: 3", + "License :: OSI Approved :: Apache Software License", + "Operating System :: POSIX :: Linux", + ], + python_requires='>=3.6', +) diff --git a/templates/cookiecutter.json b/templates/cookiecutter.json new file mode 100644 index 0000000..c4afdd6 --- /dev/null +++ b/templates/cookiecutter.json @@ -0,0 +1,8 @@ +{ + "services_dir": "services", + "tests_dir": "tests", + "service_slug": "service", + "aglsystemdservice": "agl-service-something", + "classname": "NewService", + "api": "serviceapi" +} diff --git a/templates/service/cookiecutter.json b/templates/service/cookiecutter.json deleted file mode 100644 index f37ea1f..0000000 --- a/templates/service/cookiecutter.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "aglsystemdservice": "agl-service-something", - "classname": "NewService", - "api": "serviceapi", - "verblist": ["subscribe", "unsubscribe"] -} \ No newline at end of file diff --git a/templates/service/{{cookiecutter.file_name}}.py b/templates/service/{{cookiecutter.file_name}}.py deleted file mode 100644 index 6defd28..0000000 --- a/templates/service/{{cookiecutter.file_name}}.py +++ /dev/null @@ -1,9 +0,0 @@ -from aglbaseservice import AGLBaseService, AFBResponse -import asyncio -import os - -class {{cookiecutter.classname}}(AGLBaseService): - - def __init__(self, ip, port=None, service='{{cookiecutter.aglsystemdservice}}'): - super().__init__(api='{{cookiecutter.api}}', ip=ip, port=port, service=service) - diff --git a/templates/test/{{cookiecutter.file_name}}.py b/templates/test/{{cookiecutter.file_name}}.py deleted file mode 100644 index e69de29..0000000 diff --git a/templates/{{cookiecutter.services_dir}}/{{cookiecutter.service_slug}}.py b/templates/{{cookiecutter.services_dir}}/{{cookiecutter.service_slug}}.py new file mode 100644 index 0000000..dab3a68 --- /dev/null +++ b/templates/{{cookiecutter.services_dir}}/{{cookiecutter.service_slug}}.py @@ -0,0 +1,17 @@ +from pyagl.services.base import AGLBaseService, AFBResponse +import asyncio +import os + + +class {{cookiecutter.classname}}(AGLBaseService): + service = '{{cookiecutter.aglsystemdservice}}' + parser = AGLBaseService.getparser() + + def __init__(self, ip, port=None, service='{{cookiecutter.aglsystemdservice}}'): + super().__init__(api='{{cookiecutter.api}}', ip=ip, port=port, service=service) + # more init stuff specific to the new service + +async def main(loop): + args = {{cookiecutter.classname}}.parser.parse_args() + svc = {{cookiecutter.classname}}(args.ipaddr) + diff --git a/templates/{{cookiecutter.tests_dir}}/test_{{cookiecutter.service_slug}}.py b/templates/{{cookiecutter.tests_dir}}/test_{{cookiecutter.service_slug}}.py new file mode 100644 index 0000000..e193205 --- /dev/null +++ b/templates/{{cookiecutter.tests_dir}}/test_{{cookiecutter.service_slug}}.py @@ -0,0 +1,23 @@ +import asyncio +import os +import pytest +import logging +from pyagl.services.base import AFBResponse, AFBT +from concurrent.futures import TimeoutError + +from pyagl.services.{{cookiecutter.service_slug}} import {{cookiecutter.classname}} +pytestmark = pytest.mark.asyncio + +@pytest.fixture(scope='module') +def event_loop(): + loop = asyncio.get_event_loop() + yield loop + loop.close() + +@pytest.fixture(scope='module') +async def service(): + address = os.environ.get('AGL_TGT_IP', 'localhost') + port = os.environ.get('AGL_TGT_PORT', None) + svc = await {{cookiecutter.classname}}(ip=address, port=port) + yield svc + await svc.websocket.close() -- cgit 1.2.3-korg