Release 1.3.0

This commit is contained in:
2021-08-26 12:33:15 +02:00
commit 9bb72f0207
1148 changed files with 92133 additions and 0 deletions

6
packages/homehub_backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
__pycache__/
build/
dist/
homehub_backend.egg-info/
*.pyc

View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -0,0 +1 @@
include LICENSE.txt NOTICE.txt README.md requirements.txt

View File

@@ -0,0 +1,18 @@
PHONY: clean dev lint test build publish
clean:
rm -rf build/ dist/ homehub_backend-egg-info/
dev:
lint:
flake8
test:
pytest -v --disable-warnings
build: clean
python setup.py build sdist bdist_wheel
publish:
twine upload --repository pypi-hosted.nexus.bthlabs.pl --skip-existing dist/*

View File

@@ -0,0 +1,14 @@
BTHLabs HomeHub - Backend Application
Copyright 2021-present BTHLabs <contact@bthlabs.pl> (https://bthlabs.pl/)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -0,0 +1,3 @@
# homehub_backend
BTHLabs HomeHub - Backend Application

View File

@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
__version__ = '1.3.0'

View File

@@ -0,0 +1,82 @@
# -*- coding: utf-8 -*-
import codecs
import datetime
import importlib
import logging
import os
import sys
from aiohttp import web
from aiojobs.aiohttp import setup as setup_aiojobs
from homehub_backend import handlers
from homehub_backend.defs import FRONTEND_DIR
from homehub_backend.lib.state_store import setup as setup_state_store
from homehub_backend.lib.rpc import setup as setup_rpc
from homehub_backend.lib.services import setup as setup_services
from homehub_backend.lib.websocket import setup as setup_websocket
settings = importlib.import_module(
os.environ.get('HOMEHUB_SETTINGS_MODULE', 'settings'),
)
logger = logging.getLogger('homehub')
if getattr(settings, 'TESTING', False):
logger.setLevel(logging.WARNING)
elif settings.DEBUG:
logger.setLevel(logging.DEBUG)
else:
logger.setLevel(logging.INFO)
handler = logging.StreamHandler(sys.stdout)
formatter = logging.Formatter(
'%(asctime)s %(name)s: %(levelname)s: %(message)s',
)
handler.setFormatter(formatter)
logger.addHandler(handler)
app_logger = logging.getLogger('homehub.app')
async def app_on_startup(app):
logger.info('HomeHub 1.0 by BTHLabs')
if app['SETTINGS'].DEBUG:
logger.debug('Running with DEBUG=True')
app['STARTUP_TS'] = datetime.datetime.now()
logger.debug('My PID = {pid}'.format(pid=os.getpid()))
def create_app(loop=None):
app = web.Application(logger=app_logger, loop=loop)
app['SETTINGS'] = settings
app.on_startup.append(app_on_startup)
setup_aiojobs(app)
setup_rpc(app)
setup_state_store(app)
setup_services(app)
setup_websocket(app)
frontend_dir = getattr(settings, 'FRONTEND_DIR', FRONTEND_DIR)
index_html_path = os.path.join(frontend_dir, 'index.html')
if os.path.isfile(index_html_path):
with codecs.open(index_html_path, 'r', 'utf-8') as index_html_f:
app['INDEX_HTML'] = index_html_f.read()
app.add_routes([
web.get('/', handlers.get_index),
web.get('/index.html', handlers.get_index),
])
if os.path.isdir(frontend_dir):
app.add_routes([
web.static('/frontend', frontend_dir),
])
return app
app = create_app()

View File

@@ -0,0 +1,2 @@
from .paths import * # noqa: F401, F403
from .services import * # noqa: F401, F403

View File

@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
import os
CONTEXT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
FRONTEND_DIR = os.path.join(os.getcwd(), 'frontend')

View File

@@ -0,0 +1,2 @@
kServiceUptime = 'kServiceUptime'
kServiceWeather = 'kServiceWeather'

View File

@@ -0,0 +1 @@
from .main import * # noqa: F401, F403

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
from aiohttp import web
__all__ = ['get_index']
async def get_index(request):
index_html = request.config_dict.get('INDEX_HTML', None)
if not index_html:
return web.Response(
content_type='text/plain',
charset='utf-8',
text='404: Not Found',
)
return web.Response(
content_type='text/html',
charset='utf-8',
text=index_html,
)

View File

@@ -0,0 +1,175 @@
# -*- coding: utf-8 -*-
import logging
from types import MethodType
from aiohttp import web
LOGGER = logging.getLogger('homehub.lib.rpc')
class BaseJSONRPCError(Exception):
def __init__(self, data=None):
self.data = data
def to_json(self):
result = {
'code': self.ERROR_CODE,
'message': self.ERROR_MESSAGE,
}
if self.data:
result['data'] = self.data
return result
class JSONRPCParseError(BaseJSONRPCError):
ERROR_CODE = -32700
ERROR_MESSAGE = 'Parse error'
class JSONRPCInvalidRequestError(BaseJSONRPCError):
ERROR_CODE = -32600
ERROR_MESSAGE = 'Invalid Request'
class JSONRPCMethodNotFoundError(BaseJSONRPCError):
ERROR_CODE = -32601
ERROR_MESSAGE = 'Method not found'
class JSONRPCInvalidParamsError(BaseJSONRPCError):
ERROR_CODE = -32602
ERROR_MESSAGE = 'Invalid params'
class JSONRPCInternalErrorError(BaseJSONRPCError):
ERROR_CODE = -32603
ERROR_MESSAGE = 'Internal error'
async def post_rpc(request):
responses = []
results = []
calls = []
try:
try:
data = await request.json()
except Exception as exc:
LOGGER.error('Error deserializing RPC call!', exc_info=exc)
raise JSONRPCParseError()
if isinstance(data, list):
calls = data
else:
calls.append(data)
if len(calls) == 0:
raise JSONRPCInvalidRequestError()
for call in calls:
call_method = None
method_handler = None
result = None
try:
try:
assert isinstance(call, dict), JSONRPCInvalidRequestError
assert call.get('jsonrpc') == '2.0', JSONRPCInvalidRequestError
call_method = call.get('method', None)
assert call_method is not None, JSONRPCInvalidRequestError
method_handler = request.config_dict['RPC_METHOD_REGISTRY'].get(call_method, None)
assert method_handler is not None, JSONRPCMethodNotFoundError
except AssertionError as exc:
klass = exc.args[0]
raise klass()
args = []
kwargs = {}
call_params = call.get('params', None)
if call_params is not None:
if isinstance(call_params, list):
args = call_params
elif isinstance(call_params, dict):
kwargs = call_params
else:
raise JSONRPCInvalidParamsError()
args.insert(0, request)
try:
result = await method_handler(*args, **kwargs)
except TypeError as exc:
LOGGER.error(
f'Error handling RPC method: {call_method}!',
exc_info=exc,
)
raise JSONRPCInvalidParamsError(str(exc))
except Exception as exc:
if isinstance(exc, BaseJSONRPCError):
result = exc
else:
LOGGER.error(
f'Error handling RPC method: {call_method}!',
exc_info=exc,
)
result = JSONRPCInternalErrorError(str(exc))
finally:
results.append((call, result))
except Exception as exc:
if isinstance(exc, BaseJSONRPCError):
results = [(None, exc)]
else:
raise
for result in results:
call, call_result = result
response = {
'jsonrpc': '2.0',
'id': None,
}
if isinstance(call, dict) and call.get('id', None) is not None:
response['id'] = call['id']
if isinstance(call_result, BaseJSONRPCError):
response['error'] = call_result.to_json()
else:
response.update({
'method': call['method'],
'result': call_result,
})
if 'error' in response or response['id'] is not None:
responses.append(response)
if len(responses) == 1:
return web.json_response(responses[0])
return web.json_response(responses)
async def list_methods(request):
return sorted(request.config_dict['RPC_METHOD_REGISTRY'].keys())
def add_rpc_methods(app, methods):
for name, method in methods:
app['RPC_METHOD_REGISTRY'][name] = method
def setup(app):
app['RPC_METHOD_REGISTRY'] = {}
app.add_rpc_methods = MethodType(add_rpc_methods, app)
app.add_routes([
web.post('/backend/rpc', post_rpc),
])
app.add_rpc_methods([
('system.list_methods', list_methods),
])

View File

@@ -0,0 +1,190 @@
# -*- coding: utf-8 -*-
import asyncio
import copy
import logging
LOGGER = logging.getLogger('homehub.lib.services')
class BaseServiceError(Exception):
pass
class ServiceData:
def __init__(self, data=None, exception=None):
super(ServiceData, self).__init__()
self.data = data
self.exception = exception
def raise_if_needed(self):
if self.exception is not None:
raise self.exception
def to_json(self):
result = {}
if self.data is not None:
result['data'] = self.data
if self.exception is not None:
result['error'] = self.exception.args[0]
return result
class BaseService:
IS_GLOBAL = False
INSTANCE = None
def __init__(self, app, instance, characteristics):
super(BaseService, self).__init__()
self.app = app
self.instance = instance
self.characteristics = characteristics
self.state = self.app['STATE'].get()['backend'].get(
self.state_key(), None,
)
@staticmethod
def service(klass, app, instance, characteristics):
if klass.IS_GLOBAL:
if klass.INSTANCE is None:
klass.INSTANCE = klass(app, None, characteristics)
return klass.INSTANCE
else:
return klass(app, instance, characteristics)
def state_key(self):
return '{kind}.{instance}'.format(
kind=self.KIND, instance=self.instance,
)
async def current_data(self):
return None
async def start(self):
raise NotImplementedError('TODO')
async def stop(self):
raise NotImplementedError('TODO')
async def shutdown(self):
await self.stop()
async def notify(self):
data = ServiceData()
try:
if asyncio.iscoroutinefunction(self.current_data):
data = await self.current_data()
else:
data = self.current_data()
except Exception as exc:
data = ServiceData(exception=exc)
LOGGER.debug('BaseService.notify(): {kind} {instance} {data}'.format(
kind=self.KIND,
instance=self.instance,
data=str(data.to_json()),
))
await self.app.notify_subscribers({
'type': 'SERVICE_NOTIFICATION',
'kind': self.KIND,
'instance': self.instance if not self.IS_GLOBAL else None,
'data': data.to_json(),
})
def set_state(self, state):
self.state = state
new_state = copy.deepcopy(self.app['STATE'].get())
new_state['backend'][self.state_key()] = state
self.app['STATE'].save(new_state)
def _get_service_key(request, kind, instance):
klass = request.config_dict['SETTINGS'].SERVICES.get(kind, None)
if not klass:
raise BaseServiceError(f'Unknown service: {kind}')
if klass.IS_GLOBAL:
instance = None
return f'{kind}.{instance}'
def _lookup_service(request, kind, instance):
service_key = _get_service_key(request, kind, instance)
return request.config_dict['SERVICES'].get(service_key, None)
async def start_service(request, kind, instance, characteristics):
data = None
klass = request.config_dict['SETTINGS'].SERVICES.get(kind, None)
if not klass:
raise BaseServiceError(f'Unknown service: {kind}')
else:
service = _lookup_service(request, kind, instance)
if not service:
service_key = _get_service_key(request, kind, instance)
service = BaseService.service(
klass, request.app, instance, characteristics,
)
await service.start()
request.config_dict['SERVICES'][service_key] = service
data = await service.current_data()
data.raise_if_needed()
return data.data
async def stop_service(request, kind, instance):
result = 'ok'
service_key = _get_service_key(request, kind, instance)
if service_key not in request.config_dict['SERVICES']:
raise BaseServiceError(f'Unknown service: {instance} of {kind}')
else:
service = request.config_dict['SERVICES'].pop(service_key)
await service.stop()
return result
async def use_service(request, kind, instance, capability, params):
result = None
service = _lookup_service(request, kind, instance)
if not service:
raise BaseServiceError(f'Unknown service: {instance} of {kind}')
else:
capability_handler = getattr(service, capability, None)
if not capability_handler:
raise BaseServiceError(f'Unknown capability: {kind}.{capability}')
else:
result = await capability_handler(*params)
return result
def setup(app):
app['SERVICES'] = {}
app.add_rpc_methods([
('services.start', start_service),
('services.stop', stop_service),
('services.use', use_service),
])
async def on_cleanup(app):
for service in app['SERVICES'].values():
await service.shutdown()
app.on_cleanup.append(on_cleanup)

View File

@@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-
import codecs
import concurrent.futures
import copy
import json
import logging
import os
LOGGER = logging.getLogger('homehub.lib.state_store')
class StateStore:
def __init__(self, state_path):
self.state = {'backend': {}, 'frontend': {}}
self.state_path = state_path
self.threadpool = None
def load_state(self):
if self.state_path is not None and os.path.isfile(self.state_path):
with codecs.open(self.state_path, 'r', 'utf-8') as state_f:
self.state = json.load(state_f)
async def on_app_startup(self, app):
LOGGER.debug('Initializing threadpool...')
self.threadpool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
async def on_app_cleanup(self, app):
if self.threadpool is not None:
LOGGER.debug('Shutting down threadpool...')
self.threadpool.shutdown()
def get(self):
return self.state
def do_save(self, state):
if self.state_path is not None:
with codecs.open(self.state_path, 'w', 'utf-8') as state_f:
state_f.write(json.dumps(state, indent=2))
def save(self, state):
self.state = state
self.threadpool.submit(self.do_save, state)
async def get_frontend_state(request):
return request.config_dict['STATE'].get()['frontend']
async def save_frontend_state(request, new_state):
state = copy.deepcopy(request.config_dict['STATE'].get())
state['frontend'] = new_state
request.config_dict['STATE'].save(state)
await request.app.notify_subscribers({
'type': 'STATE_NOTIFICATION',
'data': new_state,
})
return True
def setup(app):
store = StateStore(app['SETTINGS'].STATE_PATH)
store.load_state()
app.on_startup.append(store.on_app_startup)
app.on_cleanup.append(store.on_app_cleanup)
app.add_rpc_methods([
('state.get_frontend', get_frontend_state),
('state.save_frontend', save_frontend_state),
])
app['STATE'] = store

View File

@@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
import datetime
import logging
from types import MethodType
from aiohttp import WSMsgType, web
LOGGER = logging.getLogger('homehub.lib.websocket')
async def get_websocket(request):
response = web.WebSocketResponse()
await response.prepare(request)
response['SUBSCRIBER_ID'] = datetime.datetime.now().isoformat()
request.config_dict['SUBSCRIBERS'].append(response)
async for msg in response:
if msg.type == WSMsgType.TEXT:
LOGGER.debug('Received message: {data}'.format(data=msg.data))
elif msg.type == WSMsgType.ERROR:
LOGGER.error('WebSocket exception', exc_info=response.exception())
LOGGER.debug('WebSocket connection closed.')
request.config_dict['SUBSCRIBERS'].remove(response)
return response
async def notify_subscribers(app, payload):
for subscriber in app['SUBSCRIBERS']:
LOGGER.debug('Notifying subscriber: {subscriber_id}, closed={closed}'.format(
subscriber_id=subscriber['SUBSCRIBER_ID'],
closed=subscriber.closed,
))
try:
if not subscriber.closed:
await subscriber.send_json(payload)
except Exception as exc:
LOGGER.error(
'Error notifying subscriber: {subscriber_id}, closed={closed}'.format(
subscriber_id=subscriber['SUBSCRIBER_ID'],
closed=subscriber.closed,
),
exc_info=exc,
)
def setup(app):
app['SUBSCRIBERS'] = []
app.notify_subscribers = MethodType(notify_subscribers, app)
app.add_routes([
web.get('/backend/websocket', get_websocket),
])
async def on_shutdown(app):
for subscriber in app['SUBSCRIBERS']:
if not subscriber.closed:
await subscriber.close()
app.on_shutdown.append(on_shutdown)

View File

@@ -0,0 +1,9 @@
from homehub_backend.defs import services as services_defs
from .uptime import UptimeService
from .weather import WeatherService
SERVICES = {
services_defs.kServiceUptime: UptimeService,
services_defs.kServiceWeather: WeatherService,
}

View File

@@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
import asyncio
import datetime
import logging
import aiojobs.aiohttp
from homehub_backend.defs import kServiceUptime
from homehub_backend.lib.services import BaseService, ServiceData
LOGGER = logging.getLogger('homehub.services.uptime')
class UptimeService(BaseService):
KIND = kServiceUptime
IS_GLOBAL = True
def __init__(self, app, instance, characteristics):
super(UptimeService, self).__init__(app, instance, characteristics)
self.uptime = 0
self.job = None
def update_uptime(self):
delta = datetime.datetime.now() - self.app['STARTUP_TS']
self.uptime = delta.seconds
async def current_data(self):
return ServiceData(data=self.uptime)
async def worker(self):
while True:
await asyncio.sleep(60)
self.update_uptime()
await self.notify()
if self.job.closed:
break
async def start(self):
LOGGER.debug('UptimeService.start()')
if not self.job:
self.update_uptime()
scheduler = aiojobs.aiohttp.get_scheduler_from_app(self.app)
self.job = await scheduler.spawn(self.worker())
async def stop(self):
LOGGER.debug('UptimeService.stop()')
async def shutdown(self):
LOGGER.debug('UptimeService.shutdown()')
if self.job is not None and not self.job.closed:
await self.job.close()

View File

@@ -0,0 +1,102 @@
# -*- coding: utf-8 -*-
import asyncio
import logging
import aiohttp
import aiojobs.aiohttp
from homehub_backend.defs import kServiceWeather
from homehub_backend.lib.services import BaseService, ServiceData
LOGGER = logging.getLogger('homehub.services.weather')
class WeatherService(BaseService):
KIND = kServiceWeather
API_KEY_SETTINGS_KEY = 'WEATHER_SERVICE_API_KEY'
API_URL = 'https://api.openweathermap.org/data/2.5/weather'
def __init__(self, app, instance, characteristics):
super(WeatherService, self).__init__(app, instance, characteristics)
self._current_data = None
self.api_key = None
self.job = None
self.session = None
async def update_weather(self):
LOGGER.debug('WeatherService.update_weather(): {city}'.format(
city=self.characteristics['city'],
))
params = {
'APPID': self.api_key,
'q': self.characteristics['city'],
'units': self.characteristics['units'],
}
result = ServiceData()
response = None
try:
response = await self.session.get(self.API_URL, params=params)
if response.status != 200:
raise RuntimeError('HTTP {status} {reason}'.format(
status=response.status,
reason=response.reason,
))
else:
result.data = await response.json()
except Exception as exc:
LOGGER.error('Unhandled exception!', exc_info=exc)
result.exception = exc
finally:
if response:
response.release()
self._current_data = result
async def current_data(self):
if self._current_data is None:
return ServiceData()
return self._current_data
async def worker(self):
while True:
await asyncio.sleep(60 * 10)
await self.update_weather()
await self.notify()
if self.job.closed:
break
async def start(self):
LOGGER.debug('WeatherService.start()')
self.api_key = getattr(
self.app['SETTINGS'], self.API_KEY_SETTINGS_KEY, None,
)
assert self.api_key is not None, 'API key is missing'
assert self.characteristics['city'] is not None, 'City is missing'
assert self.characteristics['city'] != '', 'City is empty'
if not self.session:
self.session = aiohttp.ClientSession()
if not self._current_data:
await self.update_weather()
if not self.job:
scheduler = aiojobs.aiohttp.get_scheduler_from_app(self.app)
self.job = await scheduler.spawn(self.worker())
async def stop(self):
LOGGER.debug('WeatherService.stop()')
if self.session and not self.session.closed:
await self.session.close()
if self.job is not None and not self.job.closed:
await self.job.close()

View File

@@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
import os
from homehub_backend.services import SERVICES # noqa: F401
DEBUG = False
TESTING = False
STATE_PATH = os.path.join(os.getcwd(), 'state.json')
WEATHER_SERVICE_API_KEY = None

View File

@@ -0,0 +1 @@
state.json

View File

@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
from homehub_backend.app import create_app
async def greet(request, who, how='Hello,'):
return f'{how} {who}!'
async def fial(request):
raise RuntimeError('FIAL')
def create_testing_app(loop=None):
app = create_app(loop=None)
app.add_rpc_methods([
('testing.greet', greet),
('testing.fial', fial),
])
return app

View File

@@ -0,0 +1,45 @@
# -*- coding: utf-8 -*-
from unittest import mock
from homehub_backend.lib import services
class FakeService(services.BaseService):
KIND = 'testing.FakeService'
@classmethod
def make_data(cls):
return services.ServiceData(data='spam')
async def current_data(self):
return self.make_data()
start = mock.AsyncMock()
stop = mock.AsyncMock()
async def capability(self, param):
return param
class FakeGlobalService(services.BaseService):
IS_GLOBAL = True
KIND = 'testing.FakeGlobalService'
@classmethod
def reset(cls):
if cls.INSTANCE:
cls.INSTANCE.start.reset_mock()
cls.INSTANCE.stop.reset_mock()
cls.INSTANCE = None
def __init__(self, *args, **kwargs):
super(FakeGlobalService, self).__init__(*args, **kwargs)
self.data = services.ServiceData(data='spam')
async def current_data(self):
return self.data
start = mock.AsyncMock()
stop = mock.AsyncMock()

View File

@@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
import os
from homehub_backend.testing import services
DEBUG = False
TESTING = True
STATE_PATH = os.path.join(os.path.dirname(__file__), 'state.json')
WEATHER_SERVICE_API_KEY = 'thisisntright'
SERVICES = {}
SERVICES[services.FakeService.KIND] = services.FakeService
SERVICES[services.FakeGlobalService.KIND] = services.FakeGlobalService

View File

@@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
import pytest
from homehub_backend.testing.app import create_testing_app
@pytest.fixture
def homehub_app(loop):
return create_testing_app(loop=loop)
@pytest.fixture
def homehub_client(loop, homehub_app, aiohttp_client):
return loop.run_until_complete(aiohttp_client(homehub_app()))

View File

@@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
from homehub_backend.lib import rpc
class Test_BaseJSONRPCError:
def test_to_json(self):
exception = rpc.BaseJSONRPCError(data='spam')
exception.ERROR_CODE = -32604
exception.ERROR_MESSAGE = 'Test error'
result = exception.to_json()
assert result['code'] == exception.ERROR_CODE
assert result['message'] == exception.ERROR_MESSAGE
assert result['data'] == exception.data
def test_to_json_without_data(self):
exception = rpc.BaseJSONRPCError()
exception.ERROR_CODE = -32604
exception.ERROR_MESSAGE = 'Test error'
result = exception.to_json()
assert result['code'] == exception.ERROR_CODE
assert result['message'] == exception.ERROR_MESSAGE
assert 'data' not in result

View File

@@ -0,0 +1,182 @@
# -*- coding: utf-8 -*-
from homehub_backend.lib import rpc
class Test_PostRPC:
def _valid_call(self, **kwargs):
result = {
'jsonrpc': '2.0',
'id': 'testing',
'method': 'testing.greet',
}
result.update(kwargs)
return result
async def test_parse_error(self, homehub_client):
response = await homehub_client.post('/backend/rpc', data=b'spam')
assert response.status == 200
data = await response.json()
assert 'result' not in data
assert data['error']['code'] == rpc.JSONRPCParseError.ERROR_CODE
async def test_empty_call(self, homehub_client):
response = await homehub_client.post('/backend/rpc', json={})
assert response.status == 200
data = await response.json()
assert 'result' not in data
assert data['error']['code'] == rpc.JSONRPCInvalidRequestError.ERROR_CODE
async def test_empty_batch(self, homehub_client):
response = await homehub_client.post('/backend/rpc', json=[])
assert response.status == 200
data = await response.json()
assert 'result' not in data
assert data['error']['code'] == rpc.JSONRPCInvalidRequestError.ERROR_CODE
async def test_call_not_dict(self, homehub_client):
response = await homehub_client.post('/backend/rpc', json='"spam"')
assert response.status == 200
data = await response.json()
assert 'result' not in data
assert data['error']['code'] == rpc.JSONRPCInvalidRequestError.ERROR_CODE
async def test_call_without_jsonrpc(self, homehub_client):
call = self._valid_call()
call.pop('jsonrpc')
response = await homehub_client.post('/backend/rpc', json=call)
assert response.status == 200
data = await response.json()
assert 'result' not in data
assert data['error']['code'] == rpc.JSONRPCInvalidRequestError.ERROR_CODE
async def test_call_without_method(self, homehub_client):
call = self._valid_call()
call.pop('method')
response = await homehub_client.post('/backend/rpc', json=call)
assert response.status == 200
data = await response.json()
assert 'result' not in data
assert data['error']['code'] == rpc.JSONRPCInvalidRequestError.ERROR_CODE
async def test_method_not_found(self, homehub_client):
call = self._valid_call()
call['method'] = 'testing.idontexist'
response = await homehub_client.post('/backend/rpc', json=call)
assert response.status == 200
data = await response.json()
assert 'result' not in data
assert data['error']['code'] == rpc.JSONRPCMethodNotFoundError.ERROR_CODE
async def test_invalid_params(self, homehub_client):
call = self._valid_call()
call['params'] = 'spam'
response = await homehub_client.post('/backend/rpc', json=call)
assert response.status == 200
data = await response.json()
assert 'result' not in data
assert data['error']['code'] == rpc.JSONRPCInvalidParamsError.ERROR_CODE
assert 'data' not in data['error']
async def test_call_with_method_params_error(self, homehub_client):
call = self._valid_call()
response = await homehub_client.post('/backend/rpc', json=call)
assert response.status == 200
data = await response.json()
assert 'result' not in data
assert data['error']['code'] == rpc.JSONRPCInvalidParamsError.ERROR_CODE
assert data['error']['data'] == (
"greet() missing 1 required positional argument: 'who'"
)
async def test_method_exception(self, homehub_client):
call = self._valid_call()
call['method'] = 'testing.fial'
response = await homehub_client.post('/backend/rpc', json=call)
assert response.status == 200
data = await response.json()
assert 'result' not in data
assert data['error']['code'] == rpc.JSONRPCInternalErrorError.ERROR_CODE
assert data['error']['data'] == 'FIAL'
async def test_call_with_args(self, homehub_client):
call = self._valid_call()
call['params'] = ['aiohttp-jsonrpc']
response = await homehub_client.post('/backend/rpc', json=call)
assert response.status == 200
data = await response.json()
assert 'error' not in data
assert data['result'] == 'Hello, aiohttp-jsonrpc!'
async def test_call_with_kwargs(self, homehub_client):
call = self._valid_call()
call['params'] = {
'who': 'aiohttp-jsonrpc',
'how': 'Hi,',
}
response = await homehub_client.post('/backend/rpc', json=call)
assert response.status == 200
data = await response.json()
assert 'error' not in data
assert data['result'] == 'Hi, aiohttp-jsonrpc!'
async def test_result(self, homehub_client):
call = self._valid_call()
call['params'] = ['aiohttp-jsonrpc']
response = await homehub_client.post('/backend/rpc', json=call)
assert response.status == 200
data = await response.json()
assert 'error' not in data
assert data['jsonrpc'] == call['jsonrpc']
assert data['id'] == call['id']
assert data['method'] == call['method']
async def test_batch(self, homehub_client):
call_without_id = self._valid_call(params=['World'])
call_without_id.pop('id')
batch = [
self._valid_call(params=['aiohttp-jsonrpc']),
self._valid_call(id='testing2', method='testing.fial', params=[]),
call_without_id,
]
response = await homehub_client.post('/backend/rpc', json=batch)
assert response.status == 200
data = await response.json()
assert isinstance(data, list) is True
assert len(data) == 2
first_result, second_result = data
assert first_result['id'] == batch[0]['id']
assert 'error' not in first_result
assert first_result['result'] == 'Hello, aiohttp-jsonrpc!'
assert second_result['id'] == batch[1]['id']
assert 'result' not in second_result
assert second_result['error']['code'] == rpc.JSONRPCInternalErrorError.ERROR_CODE
assert second_result['error']['data'] == 'FIAL'

View File

@@ -0,0 +1,204 @@
# -*- coding: utf-8 -*-
from unittest import mock
from homehub_backend.app import app
from homehub_backend.lib import services
from homehub_backend.testing.services import FakeService, FakeGlobalService
class Test_BaseService:
def tearDown(self):
app['SUBSCRIBERS'] = []
@mock.patch.object(app['STATE'], 'get')
def test_init(self, mock_app_state_get):
mock_app_state_get.return_value = {
'backend': {
'testing.FakeService.fake_instance': 'fake_state',
},
}
instance = 'fake_instance'
characteristics = {'spam': True}
service = FakeService(app, instance, characteristics)
assert service.app == app
assert service.instance == instance
assert service.characteristics == characteristics
assert service.state == 'fake_state'
@mock.patch.object(app['STATE'], 'get')
def test_service_not_global(self, mock_app_state_get):
mock_app_state_get.return_value = {
'backend': {
'testing.FakeService.fake_instance': 'fake_state',
},
}
instance = 'fake_instance'
characteristics = {'spam': True}
service = services.BaseService.service(
FakeService, app, instance, characteristics,
)
assert FakeService.INSTANCE is None
assert service.app == app
assert service.instance == instance
assert service.characteristics == characteristics
assert service.state == 'fake_state'
@mock.patch.object(app['STATE'], 'get')
def test_service_global(self, mock_app_state_get):
mock_app_state_get.return_value = {
'backend': {
'testing.FakeGlobalService.None': 'fake_state',
},
}
instance = 'fake_instance'
characteristics = {'spam': True}
service = services.BaseService.service(
FakeGlobalService, app, instance, characteristics,
)
assert FakeGlobalService.INSTANCE == service
assert service.app == app
assert service.instance is None
assert service.characteristics == characteristics
assert service.state == 'fake_state'
@mock.patch.object(app['STATE'], 'get')
def test_state_key(self, mock_app_state_get):
mock_app_state_get.return_value = {
'backend': {
'testing.FakeService.fake_instance': 'fake_state',
},
}
instance = 'fake_instance'
characteristics = {'spam': True}
service = FakeService(app, instance, characteristics)
result = service.state_key()
assert result == 'testing.FakeService.fake_instance'
async def test_current_data(self):
with mock.patch.object(app['STATE'], 'get') as mock_app_state_get:
mock_app_state_get.return_value = {
'backend': {
'testing.FakeService.fake_instance': 'fake_state',
},
}
instance = 'fake_instance'
characteristics = {'spam': True}
service = FakeService(app, instance, characteristics)
result = await service.current_data()
assert isinstance(result, services.ServiceData)
assert result.data == 'spam'
async def test_shutdown(self):
with mock.patch.object(app['STATE'], 'get') as mock_app_state_get:
mock_app_state_get.return_value = {
'backend': {
'testing.FakeService.fake_instance': 'fake_state',
},
}
instance = 'fake_instance'
characteristics = {'spam': True}
service = FakeService(app, instance, characteristics)
with mock.patch.object(service, 'stop'):
await service.shutdown()
assert service.stop.called is True
async def test_notify(self):
with mock.patch.object(app['STATE'], 'get') as mock_app_state_get:
mock_app_state_get.return_value = {
'backend': {
'testing.FakeService.fake_instance': 'fake_state',
},
}
instance = 'fake_instance'
characteristics = {'spam': True}
service = FakeService(app, instance, characteristics)
with mock.patch.object(app, 'notify_subscribers'):
await service.notify()
app.notify_subscribers.assert_called_with({
'type': 'SERVICE_NOTIFICATION',
'kind': FakeService.KIND,
'instance': 'fake_instance',
'data': service.make_data().to_json(),
})
async def test_notify_global(self):
with mock.patch.object(app['STATE'], 'get') as mock_app_state_get:
mock_app_state_get.return_value = {
'backend': {
'testing.FakeService.fake_instance': 'fake_state',
},
}
instance = 'fake_instance'
characteristics = {'spam': True}
service = FakeGlobalService(app, instance, characteristics)
with mock.patch.object(app, 'notify_subscribers'):
await service.notify()
app.notify_subscribers.assert_called_with({
'type': 'SERVICE_NOTIFICATION',
'kind': FakeGlobalService.KIND,
'instance': None,
'data': service.data.to_json(),
})
async def test_notify_exception(self):
with mock.patch.object(app['STATE'], 'get') as mock_app_state_get:
mock_app_state_get.return_value = {
'backend': {
'testing.FakeService.fake_instance': 'fake_state',
},
}
instance = 'fake_instance'
characteristics = {'spam': True}
service = FakeService(app, instance, characteristics)
with mock.patch.object(service, 'current_data') as mock_current_data:
mock_current_data.side_effect = RuntimeError('TODO')
with mock.patch.object(app, 'notify_subscribers'):
await service.notify()
app.notify_subscribers.assert_called_with({
'type': 'SERVICE_NOTIFICATION',
'kind': FakeService.KIND,
'instance': 'fake_instance',
'data': {
'error': 'TODO',
},
})
@mock.patch.object(app['STATE'], 'get')
@mock.patch.object(app['STATE'], 'save')
def test_set_state(self, mock_app_state_save, mock_app_state_get):
mock_app_state_get.return_value = {
'backend': {
'testing.FakeService.fake_instance': 'fake_state',
},
}
instance = 'fake_instance'
characteristics = {'spam': True}
service = FakeService(app, instance, characteristics)
service.set_state('spam')
assert service.state == 'spam'
mock_app_state_save.assert_called_with({
'backend': {
'testing.FakeService.fake_instance': 'spam',
},
})

View File

@@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-
from homehub_backend.lib import services
class Test_ServiceData:
def test_init(self):
exc = RuntimeError('TODO')
service_data = services.ServiceData(data='spam', exception=exc)
assert service_data.data == 'spam'
assert service_data.exception == exc
def test_raise_if_needed_raise(self):
exc = RuntimeError('TODO')
service_data = services.ServiceData(data='spam', exception=exc)
raised_exception = None
try:
service_data.raise_if_needed()
except Exception as _exc:
raised_exception = _exc
assert raised_exception == exc, 'Did not raise?'
def test_raise_if_needed_dont_raise(self):
service_data = services.ServiceData(data='spam', exception=None)
raised_exception = None
try:
service_data.raise_if_needed()
except Exception as _exc:
raised_exception = _exc
assert raised_exception is None, f'Raised {raised_exception}'
def test_to_json(self):
exc = RuntimeError('TODO')
service_data = services.ServiceData(data='spam', exception=exc)
result = service_data.to_json()
assert result['data'] == service_data.data
assert result['error'] == exc.args[0]
def test_to_json_no_data(self):
exc = RuntimeError('TODO')
service_data = services.ServiceData(data=None, exception=exc)
result = service_data.to_json()
assert 'data' not in result
assert result['error'] == exc.args[0]
def test_to_json_no_exception(self):
service_data = services.ServiceData(data='spam', exception=None)
result = service_data.to_json()
assert result['data'] == service_data.data
assert 'error' not in result

View File

@@ -0,0 +1,109 @@
# -*- coding: utf-8 -*-
from unittest import mock
from homehub_backend.lib import services
from homehub_backend.testing import services as testing_services
class Test_StartService:
def teardown_method(self):
testing_services.FakeGlobalService.reset()
async def test_unknown_service(self, homehub_client):
response = await homehub_client.post('/backend/rpc', json={
'jsonrpc': '2.0',
'id': 'testing',
'method': 'services.start',
'params': ['IDontExist', 'fake_instance', {'spam': True}],
})
assert response.status == 200
data = await response.json()
assert 'result' not in data
assert data['error']['data'] == 'Unknown service: IDontExist'
async def test_not_global(self, homehub_client):
response = await homehub_client.post('/backend/rpc', json={
'jsonrpc': '2.0',
'id': 'testing',
'method': 'services.start',
'params': ['testing.FakeService', 'fake_instance', {'spam': True}],
})
assert response.status == 200
data = await response.json()
assert data['result'] == 'spam'
service = homehub_client.server.app['SERVICES'].get(
'testing.FakeService.fake_instance', None,
)
assert service is not None
assert service.characteristics == {'spam': True}
assert service.start.called is True
async def test_global(self, homehub_client):
response = await homehub_client.post('/backend/rpc', json={
'jsonrpc': '2.0',
'id': 'testing',
'method': 'services.start',
'params': [
'testing.FakeGlobalService', 'fake_instance', {'spam': True},
],
})
assert response.status == 200
data = await response.json()
assert data['result'] == 'spam'
service = homehub_client.server.app['SERVICES'].get(
'testing.FakeGlobalService.None', None,
)
assert service is not None
assert service.characteristics == {'spam': True}
assert service.start.call_count == 1
async def test_global_ensure_one_instance(self, homehub_client):
response = await homehub_client.post('/backend/rpc', json={
'jsonrpc': '2.0',
'id': 'testing',
'method': 'services.start',
'params': [
'testing.FakeGlobalService', 'fake_instance', {'spam': True},
],
})
assert response.status == 200
service = homehub_client.server.app['SERVICES'].get(
'testing.FakeGlobalService.None', None,
)
response = await homehub_client.post('/backend/rpc', json={
'jsonrpc': '2.0',
'id': 'testing',
'method': 'services.start',
'params': [
'testing.FakeGlobalService', 'fake_instance', {'spam': True},
],
})
assert response.status == 200
assert testing_services.FakeGlobalService.INSTANCE == service
assert service.start.call_count == 1
@mock.patch.object(testing_services.FakeService, 'make_data')
async def test_data_error(self, mock_service_make_data, homehub_client):
mock_service_make_data.return_value = services.ServiceData(
exception=RuntimeError('TODO'),
)
response = await homehub_client.post('/backend/rpc', json={
'jsonrpc': '2.0',
'id': 'testing',
'method': 'services.start',
'params': ['testing.FakeService', 'fake_instance', {'spam': True}],
})
assert response.status == 200
data = await response.json()
assert 'result' not in data
assert data['error']['data'] == 'TODO'

View File

@@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-
from homehub_backend.testing import services as testing_services
class Test_StopService:
async def test_unknown_service_instance(self, homehub_client):
response = await homehub_client.post('/backend/rpc', json={
'jsonrpc': '2.0',
'id': 'testing',
'method': 'services.stop',
'params': ['testing.FakeService', 'idontexist'],
})
assert response.status == 200
data = await response.json()
assert 'result' not in data
assert data['error']['data'] == (
'Unknown service: idontexist of testing.FakeService'
)
async def test_unknown_service_kind(self, homehub_client):
response = await homehub_client.post('/backend/rpc', json={
'jsonrpc': '2.0',
'id': 'testing',
'method': 'services.stop',
'params': ['IDontExist', 'fake_instance'],
})
assert response.status == 200
data = await response.json()
assert 'result' not in data
assert data['error']['data'] == 'Unknown service: IDontExist'
async def test_ok(self, homehub_client):
service = testing_services.FakeService(
homehub_client.server.app, 'fake_instance', {},
)
homehub_client.server.app['SERVICES'].update({
'testing.FakeService.fake_instance': service,
})
response = await homehub_client.post('/backend/rpc', json={
'jsonrpc': '2.0',
'id': 'testing',
'method': 'services.stop',
'params': ['testing.FakeService', 'fake_instance'],
})
assert response.status == 200
data = await response.json()
assert data['result'] == 'ok'
assert 'error' not in data
assert service.stop.called is True
assert len(homehub_client.server.app['SERVICES']) == 0

View File

@@ -0,0 +1,86 @@
# -*- coding: utf-8 -*-
from homehub_backend.testing import services as testing_services
class Test_UseService:
async def test_unknown_service_instance(self, homehub_client):
response = await homehub_client.post('/backend/rpc', json={
'jsonrpc': '2.0',
'id': 'testing',
'method': 'services.use',
'params': [
'testing.FakeService', 'idontexist', 'capability',
['It works!'],
],
})
assert response.status == 200
data = await response.json()
assert 'result' not in data
assert data['error']['data'] == (
'Unknown service: idontexist of testing.FakeService'
)
async def test_unknown_service_kind(self, homehub_client):
response = await homehub_client.post('/backend/rpc', json={
'jsonrpc': '2.0',
'id': 'testing',
'method': 'services.use',
'params': [
'IDontExist', 'fake_instance', 'capability',
['It works!'],
],
})
assert response.status == 200
data = await response.json()
assert 'result' not in data
assert data['error']['data'] == 'Unknown service: IDontExist'
async def test_unknown_capability(self, homehub_client):
service = testing_services.FakeService(
homehub_client.server.app, 'fake_instance', {},
)
homehub_client.server.app['SERVICES'].update({
'testing.FakeService.fake_instance': service,
})
response = await homehub_client.post('/backend/rpc', json={
'jsonrpc': '2.0',
'id': 'testing',
'method': 'services.use',
'params': [
'testing.FakeService', 'fake_instance', 'idontexist',
['It works!'],
],
})
assert response.status == 200
data = await response.json()
assert 'result' not in data
assert data['error']['data'] == (
'Unknown capability: testing.FakeService.idontexist'
)
async def test_ok(self, homehub_client):
service = testing_services.FakeService(
homehub_client.server.app, 'fake_instance', {},
)
homehub_client.server.app['SERVICES'].update({
'testing.FakeService.fake_instance': service,
})
response = await homehub_client.post('/backend/rpc', json={
'jsonrpc': '2.0',
'id': 'testing',
'method': 'services.use',
'params': [
'testing.FakeService', 'fake_instance', 'capability',
['It works!'],
],
})
assert response.status == 200
data = await response.json()
assert data['result'] == 'It works!'
assert 'error' not in data

View File

@@ -0,0 +1,135 @@
# -*- coding: utf-8 -*-
import codecs
import concurrent.futures
import json
import os
import tempfile
from unittest import mock
from homehub_backend.lib import state_store
class Test_StateStore:
def setup_method(self):
fh, self.state_path = tempfile.mkstemp()
os.close(fh)
def teardown_method(self):
os.remove(self.state_path)
def test_init(self):
store = state_store.StateStore(self.state_path)
assert store.state == {'backend': {}, 'frontend': {}}
assert store.state_path == self.state_path
assert store.threadpool is None
def test_load_state(self):
# Given
saved_state = {
'backend': {
'spam': True,
},
'frontend': {
'eggs': True,
},
}
with codecs.open(self.state_path, 'w', 'utf-8') as state_f:
state_f.write(json.dumps(saved_state))
store = state_store.StateStore(self.state_path)
# When
store.load_state()
# Then
assert store.state == saved_state
async def test_on_app_startup(self, homehub_app):
# Given
fake_threadpool_executor = mock.Mock(
spec=concurrent.futures.ThreadPoolExecutor,
)
with mock.patch.object(concurrent.futures, 'ThreadPoolExecutor') as mock_threadpool_executor:
mock_threadpool_executor.return_value = fake_threadpool_executor
store = state_store.StateStore(self.state_path)
# When
await store.on_app_startup(homehub_app)
# Then
assert store.threadpool == fake_threadpool_executor
mock_threadpool_executor.assert_called_with(max_workers=1)
async def test_on_app_cleanup(self, homehub_app):
# Given
store = state_store.StateStore(self.state_path)
store.threadpool = mock.Mock(
spec=concurrent.futures.ThreadPoolExecutor,
)
# When
await store.on_app_cleanup(homehub_app)
# Then
assert store.threadpool.shutdown.called is True
def test_get(self):
# Given
store = state_store.StateStore(self.state_path)
# When
result = store.get()
# Then
assert result is store.state
def test_do_save(self):
# Given
state_to_save = {
'backend': {
'spam': True,
},
'frontend': {
'eggs': True,
},
}
store = state_store.StateStore(self.state_path)
# When
store.do_save(state_to_save)
# Then
saved_state = None
with codecs.open(self.state_path, 'r', 'utf-8') as state_f:
saved_state = json.load(state_f)
assert saved_state == state_to_save
def test_save(self):
# Given
state_to_save = {
'backend': {
'spam': True,
},
'frontend': {
'eggs': True,
},
}
store = state_store.StateStore(self.state_path)
store.threadpool = mock.Mock(
spec=concurrent.futures.ThreadPoolExecutor,
)
# When
store.save(state_to_save)
# Then
assert store.state == state_to_save
store.threadpool.submit.assert_called_with(
store.do_save, state_to_save,
)

View File

@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
from unittest import mock
async def test_get_frontend_state(homehub_app, homehub_client):
# Given
fake_state = {
'backend': {
'spam': True,
},
'frontend': {
'eggs': True,
},
}
with mock.patch.object(homehub_app['STATE'], 'get') as mock_state_get:
mock_state_get.return_value = fake_state
# When
response = await homehub_client.post('/backend/rpc', json={
'jsonrpc': '2.0',
'id': 'testing',
'method': 'state.get_frontend',
'params': [],
})
# Then
assert response.status == 200
data = await response.json()
assert data['result'] == fake_state['frontend']
assert 'error' not in data

View File

@@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
from unittest import mock
async def test_save_frontend_state(homehub_app, homehub_client):
# Given
fake_state = {
'backend': {
'spam': True,
},
'frontend': {
'eggs': True,
},
}
payload = {'new': True}
state_to_save = {
'backend': {
'spam': True,
},
'frontend': payload,
}
with mock.patch.object(homehub_app['STATE'], 'get') as mock_state_get:
with mock.patch.object(homehub_app['STATE'], 'save'):
with mock.patch.object(homehub_app, 'notify_subscribers'):
mock_state_get.return_value = fake_state
# When
response = await homehub_client.post('/backend/rpc', json={
'jsonrpc': '2.0',
'id': 'testing',
'method': 'state.save_frontend',
'params': [payload],
})
# Then
assert response.status == 200
data = await response.json()
assert data['result'] is True
assert 'error' not in data
homehub_app['STATE'].save.assert_called_with(state_to_save)
homehub_app.notify_subscribers.assert_called_with({
'type': 'STATE_NOTIFICATION',
'data': payload,
})

View File

@@ -0,0 +1,43 @@
# -*- coding: utf-8 -*-
from unittest import mock
from aiohttp.web import WebSocketResponse
from homehub_backend.app import app
from homehub_backend.lib import websocket
async def test_notify_subscribers():
# Given
error_subscriber = WebSocketResponse()
error_subscriber['SUBSCRIBER_ID'] = 'test_notify_subscribers.error_subscriber'
app['SUBSCRIBERS'].append(error_subscriber)
ok_subscriber = WebSocketResponse()
ok_subscriber['SUBSCRIBER_ID'] = 'test_notify_subscribers.ok_subscriber'
app['SUBSCRIBERS'].append(ok_subscriber)
closed_subscriber = WebSocketResponse()
closed_subscriber['SUBSCRIBER_ID'] = 'test_notify_subscribers.closed_subscriber'
closed_subscriber._closed = True
app['SUBSCRIBERS'].append(closed_subscriber)
payload = {'spam': True}
with mock.patch.object(error_subscriber, 'send_json') as error_subscriber_send_json:
with mock.patch.object(ok_subscriber, 'send_json'):
with mock.patch.object(closed_subscriber, 'send_json'):
error_subscriber_send_json.side_effect = RuntimeError('FIAL')
# When
await websocket.notify_subscribers(app, payload)
# Then
error_subscriber.send_json.assert_called_with(payload)
ok_subscriber.send_json.assert_called_with(payload)
assert not closed_subscriber.send_json.called
# After
app['SUBSCRIBERS'].remove(error_subscriber)
app['SUBSCRIBERS'].remove(ok_subscriber)
app['SUBSCRIBERS'].remove(closed_subscriber)

View File

@@ -0,0 +1,105 @@
# -*- coding: utf-8 -*-
import datetime
from unittest import mock
import aiojobs
from homehub_backend.services import uptime
class Test_UptimeService:
def test_init(self, homehub_app):
service = uptime.UptimeService(homehub_app, 'testing', {})
assert service.uptime == 0
assert service.job is None
def test_update_uptime(self, homehub_app):
homehub_app['STARTUP_TS'] = datetime.datetime(1987, 10, 3, 8, 0, 0)
fake_now = homehub_app['STARTUP_TS'] + datetime.timedelta(minutes=15)
service = uptime.UptimeService(homehub_app, 'testing', {})
with mock.patch('datetime.datetime') as mock_datetime:
mock_datetime.now.return_value = fake_now
service.update_uptime()
assert service.uptime == 900
async def test_current_data(self, homehub_app):
service = uptime.UptimeService(homehub_app, 'testing', {})
service.uptime = 42
result = await service.current_data()
assert result.data == 42
async def test_worker(self, homehub_app):
service = uptime.UptimeService(homehub_app, 'testing', {})
service.job = mock.Mock(spec=aiojobs._job.Job)
service.job.closed = True
service.update_uptime = mock.Mock()
service.notify = mock.AsyncMock()
with mock.patch('asyncio.sleep') as mock_asyncio_sleep:
await service.worker()
mock_asyncio_sleep.assert_called_with(60)
assert service.update_uptime.called is True
assert service.notify.called is True
async def test_start(self, homehub_app):
service = uptime.UptimeService(homehub_app, 'testing', {})
service.update_uptime = mock.Mock()
fake_worker = mock.AsyncMock()
service.worker = mock.Mock()
service.worker.return_value = fake_worker
with mock.patch('aiojobs.aiohttp.get_scheduler_from_app') as mock_get_scheduler:
fake_scheduler = mock.Mock(spec=aiojobs._scheduler.Scheduler)
fake_scheduler.spawn.return_value = mock.Mock(
spec=aiojobs._job.Job,
)
mock_get_scheduler.return_value = fake_scheduler
await service.start()
assert service.update_uptime.called is True
mock_get_scheduler.assert_called_with(homehub_app)
fake_scheduler.spawn.assert_called_with(fake_worker)
async def test_start_already_started(self, homehub_app):
service = uptime.UptimeService(homehub_app, 'testing', {})
service.job = mock.Mock(spec=aiojobs._job.Job)
service.update_uptime = mock.Mock()
fake_worker = mock.AsyncMock()
service.worker = mock.Mock()
service.worker.return_value = fake_worker
with mock.patch('aiojobs.aiohttp.get_scheduler_from_app') as mock_get_scheduler:
fake_scheduler = mock.Mock(spec=aiojobs._scheduler.Scheduler)
fake_scheduler.spawn.return_value = mock.Mock(
spec=aiojobs._job.Job,
)
mock_get_scheduler.return_value = fake_scheduler
await service.start()
assert not service.update_uptime.called
assert not mock_get_scheduler.called
assert not fake_scheduler.spawn.called
async def test_shutdown(self, homehub_app):
service = uptime.UptimeService(homehub_app, 'testing', {})
service.job = mock.Mock(spec=aiojobs._job.Job)
service.job.closed = False
await service.shutdown()
assert service.job.close.called is True
async def test_shutdown_job_already_closed(self, homehub_app):
service = uptime.UptimeService(homehub_app, 'testing', {})
service.job = mock.Mock(spec=aiojobs._job.Job)
service.job.closed = True
await service.shutdown()
assert not service.job.close.called

View File

@@ -0,0 +1,214 @@
# -*- coding: utf-8 -*-
from unittest import mock
import aiohttp
import aiojobs
from homehub_backend.lib.services import ServiceData
from homehub_backend.services import weather
class Test_WeatherService:
def _valid_characteristics(self):
return {
'city': 'Wroclaw,PL',
'units': 'metric',
}
def test_init(self, homehub_app):
service = weather.WeatherService(
homehub_app, 'testing', self._valid_characteristics(),
)
assert service._current_data is None
assert service.api_key is None
assert service.job is None
assert service.session is None
async def test_update_weather(self, homehub_app):
characteristics = self._valid_characteristics()
service = weather.WeatherService(
homehub_app, 'testing', characteristics,
)
service.api_key = 'api_key'
service.session = mock.Mock(spec=aiohttp.ClientSession)
fake_data = {'spam': True}
fake_response = mock.Mock(spec=aiohttp.ClientResponse)
fake_response.status = 200
fake_response.json.return_value = fake_data
service.session.get = mock.AsyncMock()
service.session.get.return_value = fake_response
await service.update_weather()
assert service._current_data.data == fake_data
assert service._current_data.exception is None
service.session.get.assert_called_with(service.API_URL, params={
'APPID': 'api_key',
'q': characteristics['city'],
'units': characteristics['units'],
})
assert fake_response.release.called is True
async def test_update_weather_non_ok_response(self, homehub_app):
characteristics = self._valid_characteristics()
service = weather.WeatherService(
homehub_app, 'testing', characteristics,
)
service.api_key = 'api_key'
service.session = mock.Mock(spec=aiohttp.ClientSession)
fake_response = mock.Mock(spec=aiohttp.ClientResponse)
fake_response.status = 400
fake_response.reason = 'Bad Request'
fake_response.json.return_value = None
service.session.get = mock.AsyncMock()
service.session.get.return_value = fake_response
await service.update_weather()
assert service._current_data.data is None
assert service._current_data.exception is not None
assert isinstance(service._current_data.exception, RuntimeError) is True
assert service._current_data.exception.args[0] == 'HTTP 400 Bad Request'
assert fake_response.release.called is True
async def test_update_weather_request_exception(self, homehub_app):
characteristics = self._valid_characteristics()
service = weather.WeatherService(
homehub_app, 'testing', characteristics,
)
service.api_key = 'api_key'
service.session = mock.Mock(spec=aiohttp.ClientSession)
service.session.get = mock.AsyncMock()
exception = RuntimeError('FIAL')
service.session.get.side_effect = exception
await service.update_weather()
assert service._current_data.data is None
assert service._current_data.exception == exception
async def test_current_data(self, homehub_app):
characteristics = self._valid_characteristics()
service = weather.WeatherService(
homehub_app, 'testing', characteristics,
)
service._current_data = ServiceData(data='spam')
result = await service.current_data()
assert result is service._current_data
async def test_current_data_empty(self, homehub_app):
characteristics = self._valid_characteristics()
service = weather.WeatherService(
homehub_app, 'testing', characteristics,
)
result = await service.current_data()
assert result != service._current_data
assert result.data is None
assert result.exception is None
async def test_worker(self, homehub_app):
characteristics = self._valid_characteristics()
service = weather.WeatherService(
homehub_app, 'testing', characteristics,
)
service.job = mock.Mock(spec=aiojobs._job.Job)
service.job.closed = True
service.update_weather = mock.AsyncMock()
service.notify = mock.AsyncMock()
with mock.patch('asyncio.sleep') as mock_asyncio_sleep:
await service.worker()
mock_asyncio_sleep.assert_called_with(600)
assert service.update_weather.called is True
assert service.notify.called is True
async def test_start(self, homehub_app):
characteristics = self._valid_characteristics()
service = weather.WeatherService(
homehub_app, 'testing', characteristics,
)
service.update_weather = mock.AsyncMock()
fake_worker = mock.AsyncMock()
service.worker = mock.Mock()
service.worker.return_value = fake_worker
with mock.patch('aiojobs.aiohttp.get_scheduler_from_app') as mock_get_scheduler:
with mock.patch('aiohttp.ClientSession') as mock_session:
fake_scheduler = mock.Mock(spec=aiojobs._scheduler.Scheduler)
fake_scheduler.spawn.return_value = mock.Mock(
spec=aiojobs._job.Job,
)
mock_get_scheduler.return_value = fake_scheduler
fake_session = mock.Mock(spec=aiohttp.ClientSession)
mock_session.return_value = fake_session
await service.start()
assert service.api_key == homehub_app['SETTINGS'].WEATHER_SERVICE_API_KEY
assert service.session == fake_session
assert service.update_weather.called is True
mock_get_scheduler.assert_called_with(homehub_app)
fake_scheduler.spawn.assert_called_with(fake_worker)
async def test_start_already_started(self, homehub_app):
characteristics = self._valid_characteristics()
service = weather.WeatherService(
homehub_app, 'testing', characteristics,
)
service.update_weather = mock.AsyncMock()
fake_worker = mock.AsyncMock()
service.worker = mock.Mock()
service.worker.return_value = fake_worker
with mock.patch('aiojobs.aiohttp.get_scheduler_from_app') as mock_get_scheduler:
with mock.patch('aiohttp.ClientSession') as mock_session:
fake_scheduler = mock.Mock(spec=aiojobs._scheduler.Scheduler)
mock_get_scheduler.return_value = fake_scheduler
fake_session = mock.Mock(spec=aiohttp.ClientSession)
mock_session.return_value = fake_session
service._current_data = ServiceData(data='spam')
service.session = fake_session
service.job = mock.Mock(spec=aiojobs._job.Job)
await service.start()
assert not mock_session.called
assert not service.update_weather.called
assert not mock_get_scheduler.called
assert not fake_scheduler.spawn.called
async def test_stop(self, homehub_app):
characteristics = self._valid_characteristics()
service = weather.WeatherService(
homehub_app, 'testing', characteristics,
)
service.session = mock.Mock(spec=aiohttp.ClientSession)
service.session.closed = False
service.job = mock.Mock(spec=aiojobs._job.Job)
service.job.closed = False
await service.stop()
assert service.session.close.called is True
assert service.job.close.called is True
async def test_stop_already_stopped(self, homehub_app):
characteristics = self._valid_characteristics()
service = weather.WeatherService(
homehub_app, 'testing', characteristics,
)
service.session = mock.Mock(spec=aiohttp.ClientSession)
service.session.closed = True
service.job = mock.Mock(spec=aiojobs._job.Job)
service.job.closed = True
await service.stop()
assert service.session.close.called is False
assert service.job.close.called is False

View File

@@ -0,0 +1,10 @@
-r requirements.txt
aiohttp-devtools==0.13.1
flake8==3.8.3
flake8-commas==2.0.0
pycodestyle==2.6.0
pytest==6.0.1
pytest-aiohttp==0.3.0
pytest-asyncio==0.14.0
pytest-env==0.6.2
twine==2.0.0

View File

@@ -0,0 +1,2 @@
aiohttp==3.6.2
aiojobs==0.2.2

View File

@@ -0,0 +1,10 @@
[flake8]
exclude = build/
ignore = E402
max-line-length = 120
[tool:pytest]
testpaths =
homehub_backend/tests
env =
HOMEHUB_SETTINGS_MODULE=homehub_backend.testing.settings

View File

@@ -0,0 +1,57 @@
import io
import os
import re
from setuptools import find_packages
from setuptools import setup
PROJECT_ROOT = os.path.dirname(__file__)
def _process_requirements(requirements):
return list(
filter(lambda x: x != '', map(lambda x: x.strip(), requirements)),
)
def _process_packages(packages):
return list(
map(lambda x: 'homehub_backend.{package}'.format(package=x), packages),
)
with io.open('README.md', 'rt', encoding='utf8') as f:
readme = f.read()
with io.open('requirements.txt', 'rt', encoding='utf8') as f:
requirements = _process_requirements(f.read().split('\n'))
with io.open('homehub_backend/__init__.py', 'rt', encoding='utf8') as f:
version = re.search(r"""__version__ = '(.*?)'""", f.read()).group(1)
packages = ['homehub_backend']
packages.extend(_process_packages(find_packages(
'homehub_backend', exclude=[
'testing', 'testing.*', 'tests', 'tests.*',
],
)))
setup(
name='homehub_backend',
version=version,
url='https://www.bthlabs.pl/',
license='Apache License Version 2.0',
author='BTHLabs',
author_email='contact@bthlabs.pl',
maintainer='BTHLabs',
maintainer_email='contact@bthlabs.pl',
description='BTHLabs HomeHub - Backend Application',
long_description=readme,
classifiers=[
'License :: OSI Approved :: Apache Software License',
],
packages=packages,
include_package_data=True,
python_requires='>=3.7',
install_requires=requirements,
)