forked from platypush/platypush
HTTP backend dependencies moved from optional to required
If Platypush is supposed to work also without a manually created `config.yaml`, and the HTTP backend is enabled by default in that configuration, then Flask and companions should be among the required dependencies.
This commit is contained in:
parent
371fd7e46b
commit
fee5fc4ae0
4 changed files with 132 additions and 61 deletions
|
@ -91,14 +91,16 @@ class HttpBackend(Backend):
|
||||||
other music plugin enabled. -->
|
other music plugin enabled. -->
|
||||||
<Music class="col-3" />
|
<Music class="col-3" />
|
||||||
|
|
||||||
<!-- Show current date, time and weather. It requires a `weather` plugin or backend enabled -->
|
<!-- Show current date, time and weather.
|
||||||
|
It requires a `weather` plugin or backend enabled -->
|
||||||
<DateTimeWeather class="col-3" />
|
<DateTimeWeather class="col-3" />
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<!-- Display the following widgets on a second row -->
|
<!-- Display the following widgets on a second row -->
|
||||||
<Row>
|
<Row>
|
||||||
<!-- Show a carousel of images from a local folder. For security reasons, the folder must be
|
<!-- Show a carousel of images from a local folder. For security reasons, the folder must be
|
||||||
explicitly exposed as an HTTP resource through the backend `resource_dirs` attribute. -->
|
explicitly exposed as an HTTP resource through the backend
|
||||||
|
`resource_dirs` attribute. -->
|
||||||
<ImageCarousel class="col-6" img-dir="/mnt/hd/photos/carousel" />
|
<ImageCarousel class="col-6" img-dir="/mnt/hd/photos/carousel" />
|
||||||
|
|
||||||
<!-- Show the news headlines parsed from a list of RSS feed and stored locally through the
|
<!-- Show the news headlines parsed from a list of RSS feed and stored locally through the
|
||||||
|
@ -151,11 +153,7 @@ class HttpBackend(Backend):
|
||||||
|
|
||||||
Requires:
|
Requires:
|
||||||
|
|
||||||
* **flask** (``pip install flask``)
|
* **gunicorn** (``pip install gunicorn``) - optional, to run the Platypush webapp over uWSGI.
|
||||||
* **bcrypt** (``pip install bcrypt``)
|
|
||||||
* **magic** (``pip install python-magic``), optional, for MIME type
|
|
||||||
support if you want to enable media streaming
|
|
||||||
* **gunicorn** (``pip install gunicorn``) - optional but recommended.
|
|
||||||
|
|
||||||
By default the Platypush web server will run in a
|
By default the Platypush web server will run in a
|
||||||
process spawned on the fly by the HTTP backend. However, being a
|
process spawned on the fly by the HTTP backend. However, being a
|
||||||
|
@ -174,12 +172,22 @@ class HttpBackend(Backend):
|
||||||
_DEFAULT_HTTP_PORT = 8008
|
_DEFAULT_HTTP_PORT = 8008
|
||||||
_DEFAULT_WEBSOCKET_PORT = 8009
|
_DEFAULT_WEBSOCKET_PORT = 8009
|
||||||
|
|
||||||
def __init__(self, port=_DEFAULT_HTTP_PORT,
|
def __init__(
|
||||||
|
self,
|
||||||
|
port=_DEFAULT_HTTP_PORT,
|
||||||
websocket_port=_DEFAULT_WEBSOCKET_PORT,
|
websocket_port=_DEFAULT_WEBSOCKET_PORT,
|
||||||
bind_address='0.0.0.0',
|
bind_address='0.0.0.0',
|
||||||
disable_websocket=False, resource_dirs=None,
|
disable_websocket=False,
|
||||||
ssl_cert=None, ssl_key=None, ssl_cafile=None, ssl_capath=None,
|
resource_dirs=None,
|
||||||
maps=None, run_externally=False, uwsgi_args=None, **kwargs):
|
ssl_cert=None,
|
||||||
|
ssl_key=None,
|
||||||
|
ssl_cafile=None,
|
||||||
|
ssl_capath=None,
|
||||||
|
maps=None,
|
||||||
|
run_externally=False,
|
||||||
|
uwsgi_args=None,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
:param port: Listen port for the web server (default: 8008)
|
:param port: Listen port for the web server (default: 8008)
|
||||||
:type port: int
|
:type port: int
|
||||||
|
@ -246,26 +254,37 @@ class HttpBackend(Backend):
|
||||||
self.bind_address = bind_address
|
self.bind_address = bind_address
|
||||||
|
|
||||||
if resource_dirs:
|
if resource_dirs:
|
||||||
self.resource_dirs = {name: os.path.abspath(
|
self.resource_dirs = {
|
||||||
os.path.expanduser(d)) for name, d in resource_dirs.items()}
|
name: os.path.abspath(os.path.expanduser(d))
|
||||||
|
for name, d in resource_dirs.items()
|
||||||
|
}
|
||||||
else:
|
else:
|
||||||
self.resource_dirs = {}
|
self.resource_dirs = {}
|
||||||
|
|
||||||
self.active_websockets = set()
|
self.active_websockets = set()
|
||||||
self.run_externally = run_externally
|
self.run_externally = run_externally
|
||||||
self.uwsgi_args = uwsgi_args or []
|
self.uwsgi_args = uwsgi_args or []
|
||||||
self.ssl_context = get_ssl_server_context(ssl_cert=ssl_cert,
|
self.ssl_context = (
|
||||||
|
get_ssl_server_context(
|
||||||
|
ssl_cert=ssl_cert,
|
||||||
ssl_key=ssl_key,
|
ssl_key=ssl_key,
|
||||||
ssl_cafile=ssl_cafile,
|
ssl_cafile=ssl_cafile,
|
||||||
ssl_capath=ssl_capath) \
|
ssl_capath=ssl_capath,
|
||||||
if ssl_cert else None
|
)
|
||||||
|
if ssl_cert
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
if self.uwsgi_args:
|
if self.uwsgi_args:
|
||||||
self.uwsgi_args = [str(_) for _ in self.uwsgi_args] + \
|
self.uwsgi_args = [str(_) for _ in self.uwsgi_args] + [
|
||||||
['--module', 'platypush.backend.http.uwsgi', '--enable-threads']
|
'--module',
|
||||||
|
'platypush.backend.http.uwsgi',
|
||||||
|
'--enable-threads',
|
||||||
|
]
|
||||||
|
|
||||||
self.local_base_url = '{proto}://localhost:{port}'.\
|
self.local_base_url = '{proto}://localhost:{port}'.format(
|
||||||
format(proto=('https' if ssl_cert else 'http'), port=self.port)
|
proto=('https' if ssl_cert else 'http'), port=self.port
|
||||||
|
)
|
||||||
|
|
||||||
self._websocket_lock_timeout = 10
|
self._websocket_lock_timeout = 10
|
||||||
self._websocket_lock = threading.RLock()
|
self._websocket_lock = threading.RLock()
|
||||||
|
@ -275,7 +294,7 @@ class HttpBackend(Backend):
|
||||||
self.logger.warning('Use cURL or any HTTP client to query the HTTP backend')
|
self.logger.warning('Use cURL or any HTTP client to query the HTTP backend')
|
||||||
|
|
||||||
def on_stop(self):
|
def on_stop(self):
|
||||||
""" On backend stop """
|
"""On backend stop"""
|
||||||
super().on_stop()
|
super().on_stop()
|
||||||
self.logger.info('Received STOP event on HttpBackend')
|
self.logger.info('Received STOP event on HttpBackend')
|
||||||
|
|
||||||
|
@ -284,7 +303,9 @@ class HttpBackend(Backend):
|
||||||
self.server_proc.kill()
|
self.server_proc.kill()
|
||||||
self.server_proc.wait(timeout=10)
|
self.server_proc.wait(timeout=10)
|
||||||
if self.server_proc.poll() is not None:
|
if self.server_proc.poll() is not None:
|
||||||
self.logger.info('HTTP server process may be still alive at termination')
|
self.logger.info(
|
||||||
|
'HTTP server process may be still alive at termination'
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self.logger.info('HTTP server process terminated')
|
self.logger.info('HTTP server process terminated')
|
||||||
else:
|
else:
|
||||||
|
@ -293,17 +314,25 @@ class HttpBackend(Backend):
|
||||||
if self.server_proc.is_alive():
|
if self.server_proc.is_alive():
|
||||||
self.server_proc.kill()
|
self.server_proc.kill()
|
||||||
if self.server_proc.is_alive():
|
if self.server_proc.is_alive():
|
||||||
self.logger.info('HTTP server process may be still alive at termination')
|
self.logger.info(
|
||||||
|
'HTTP server process may be still alive at termination'
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self.logger.info('HTTP server process terminated')
|
self.logger.info('HTTP server process terminated')
|
||||||
|
|
||||||
if self.websocket_thread and self.websocket_thread.is_alive() and self._websocket_loop:
|
if (
|
||||||
|
self.websocket_thread
|
||||||
|
and self.websocket_thread.is_alive()
|
||||||
|
and self._websocket_loop
|
||||||
|
):
|
||||||
self._websocket_loop.stop()
|
self._websocket_loop.stop()
|
||||||
self.logger.info('HTTP websocket service terminated')
|
self.logger.info('HTTP websocket service terminated')
|
||||||
|
|
||||||
def _acquire_websocket_lock(self, ws):
|
def _acquire_websocket_lock(self, ws):
|
||||||
try:
|
try:
|
||||||
acquire_ok = self._websocket_lock.acquire(timeout=self._websocket_lock_timeout)
|
acquire_ok = self._websocket_lock.acquire(
|
||||||
|
timeout=self._websocket_lock_timeout
|
||||||
|
)
|
||||||
if not acquire_ok:
|
if not acquire_ok:
|
||||||
raise TimeoutError('Websocket lock acquire timeout')
|
raise TimeoutError('Websocket lock acquire timeout')
|
||||||
|
|
||||||
|
@ -313,13 +342,19 @@ class HttpBackend(Backend):
|
||||||
finally:
|
finally:
|
||||||
self._websocket_lock.release()
|
self._websocket_lock.release()
|
||||||
|
|
||||||
acquire_ok = self._websocket_locks[addr].acquire(timeout=self._websocket_lock_timeout)
|
acquire_ok = self._websocket_locks[addr].acquire(
|
||||||
|
timeout=self._websocket_lock_timeout
|
||||||
|
)
|
||||||
if not acquire_ok:
|
if not acquire_ok:
|
||||||
raise TimeoutError('Websocket on address {} not ready to receive data'.format(addr))
|
raise TimeoutError(
|
||||||
|
'Websocket on address {} not ready to receive data'.format(addr)
|
||||||
|
)
|
||||||
|
|
||||||
def _release_websocket_lock(self, ws):
|
def _release_websocket_lock(self, ws):
|
||||||
try:
|
try:
|
||||||
acquire_ok = self._websocket_lock.acquire(timeout=self._websocket_lock_timeout)
|
acquire_ok = self._websocket_lock.acquire(
|
||||||
|
timeout=self._websocket_lock_timeout
|
||||||
|
)
|
||||||
if not acquire_ok:
|
if not acquire_ok:
|
||||||
raise TimeoutError('Websocket lock acquire timeout')
|
raise TimeoutError('Websocket lock acquire timeout')
|
||||||
|
|
||||||
|
@ -327,12 +362,15 @@ class HttpBackend(Backend):
|
||||||
if addr in self._websocket_locks:
|
if addr in self._websocket_locks:
|
||||||
self._websocket_locks[addr].release()
|
self._websocket_locks[addr].release()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning('Unhandled exception while releasing websocket lock: {}'.format(str(e)))
|
self.logger.warning(
|
||||||
|
'Unhandled exception while releasing websocket lock: {}'.format(str(e))
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
self._websocket_lock.release()
|
self._websocket_lock.release()
|
||||||
|
|
||||||
def notify_web_clients(self, event):
|
def notify_web_clients(self, event):
|
||||||
""" Notify all the connected web clients (over websocket) of a new event """
|
"""Notify all the connected web clients (over websocket) of a new event"""
|
||||||
|
|
||||||
async def send_event(ws):
|
async def send_event(ws):
|
||||||
try:
|
try:
|
||||||
self._acquire_websocket_lock(ws)
|
self._acquire_websocket_lock(ws)
|
||||||
|
@ -349,26 +387,35 @@ class HttpBackend(Backend):
|
||||||
try:
|
try:
|
||||||
loop.run_until_complete(send_event(_ws))
|
loop.run_until_complete(send_event(_ws))
|
||||||
except ConnectionClosed:
|
except ConnectionClosed:
|
||||||
self.logger.warning('Websocket client {} connection lost'.format(_ws.remote_address))
|
self.logger.warning(
|
||||||
|
'Websocket client {} connection lost'.format(_ws.remote_address)
|
||||||
|
)
|
||||||
self.active_websockets.remove(_ws)
|
self.active_websockets.remove(_ws)
|
||||||
if _ws.remote_address in self._websocket_locks:
|
if _ws.remote_address in self._websocket_locks:
|
||||||
del self._websocket_locks[_ws.remote_address]
|
del self._websocket_locks[_ws.remote_address]
|
||||||
|
|
||||||
def websocket(self):
|
def websocket(self):
|
||||||
""" Websocket main server """
|
"""Websocket main server"""
|
||||||
set_thread_name('WebsocketServer')
|
set_thread_name('WebsocketServer')
|
||||||
|
|
||||||
async def register_websocket(websocket, path):
|
async def register_websocket(websocket, path):
|
||||||
address = websocket.remote_address if websocket.remote_address \
|
address = (
|
||||||
|
websocket.remote_address
|
||||||
|
if websocket.remote_address
|
||||||
else '<unknown client>'
|
else '<unknown client>'
|
||||||
|
)
|
||||||
|
|
||||||
self.logger.info('New websocket connection from {} on path {}'.format(address, path))
|
self.logger.info(
|
||||||
|
'New websocket connection from {} on path {}'.format(address, path)
|
||||||
|
)
|
||||||
self.active_websockets.add(websocket)
|
self.active_websockets.add(websocket)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await websocket.recv()
|
await websocket.recv()
|
||||||
except ConnectionClosed:
|
except ConnectionClosed:
|
||||||
self.logger.info('Websocket client {} closed connection'.format(address))
|
self.logger.info(
|
||||||
|
'Websocket client {} closed connection'.format(address)
|
||||||
|
)
|
||||||
self.active_websockets.remove(websocket)
|
self.active_websockets.remove(websocket)
|
||||||
if address in self._websocket_locks:
|
if address in self._websocket_locks:
|
||||||
del self._websocket_locks[address]
|
del self._websocket_locks[address]
|
||||||
|
@ -379,8 +426,13 @@ class HttpBackend(Backend):
|
||||||
|
|
||||||
self._websocket_loop = get_or_create_event_loop()
|
self._websocket_loop = get_or_create_event_loop()
|
||||||
self._websocket_loop.run_until_complete(
|
self._websocket_loop.run_until_complete(
|
||||||
websocket_serve(register_websocket, self.bind_address, self.websocket_port,
|
websocket_serve(
|
||||||
**websocket_args))
|
register_websocket,
|
||||||
|
self.bind_address,
|
||||||
|
self.websocket_port,
|
||||||
|
**websocket_args
|
||||||
|
)
|
||||||
|
)
|
||||||
self._websocket_loop.run_forever()
|
self._websocket_loop.run_forever()
|
||||||
|
|
||||||
def _start_web_server(self):
|
def _start_web_server(self):
|
||||||
|
@ -415,8 +467,9 @@ class HttpBackend(Backend):
|
||||||
self.websocket_thread.start()
|
self.websocket_thread.start()
|
||||||
|
|
||||||
if not self.run_externally:
|
if not self.run_externally:
|
||||||
self.server_proc = Process(target=self._start_web_server(),
|
self.server_proc = Process(
|
||||||
name='WebServer')
|
target=self._start_web_server(), name='WebServer'
|
||||||
|
)
|
||||||
self.server_proc.start()
|
self.server_proc.start()
|
||||||
self.server_proc.join()
|
self.server_proc.join()
|
||||||
elif self.uwsgi_args:
|
elif self.uwsgi_args:
|
||||||
|
@ -424,9 +477,11 @@ class HttpBackend(Backend):
|
||||||
self.logger.info('Starting uWSGI with arguments {}'.format(uwsgi_cmd))
|
self.logger.info('Starting uWSGI with arguments {}'.format(uwsgi_cmd))
|
||||||
self.server_proc = subprocess.Popen(uwsgi_cmd)
|
self.server_proc = subprocess.Popen(uwsgi_cmd)
|
||||||
else:
|
else:
|
||||||
self.logger.info('The web server is configured to be launched externally but ' +
|
self.logger.info(
|
||||||
'no uwsgi_args were provided. Make sure that you run another external service' +
|
'The web server is configured to be launched externally but '
|
||||||
'for the webserver (e.g. nginx)')
|
+ 'no uwsgi_args were provided. Make sure that you run another external service'
|
||||||
|
+ 'for the webserver (e.g. nginx)'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# vim:sw=4:ts=4:et:
|
# vim:sw=4:ts=4:et:
|
||||||
|
|
|
@ -2,9 +2,6 @@ manifest:
|
||||||
events: {}
|
events: {}
|
||||||
install:
|
install:
|
||||||
pip:
|
pip:
|
||||||
- flask
|
|
||||||
- bcrypt
|
|
||||||
- python-magic
|
|
||||||
- gunicorn
|
- gunicorn
|
||||||
package: platypush.backend.http
|
package: platypush.backend.http
|
||||||
type: backend
|
type: backend
|
||||||
|
|
|
@ -20,3 +20,4 @@ zeroconf
|
||||||
paho-mqtt
|
paho-mqtt
|
||||||
websocket-client
|
websocket-client
|
||||||
croniter
|
croniter
|
||||||
|
python-magic
|
||||||
|
|
42
setup.py
42
setup.py
|
@ -17,7 +17,7 @@ def readfile(fname):
|
||||||
def pkg_files(dir):
|
def pkg_files(dir):
|
||||||
paths = []
|
paths = []
|
||||||
# noinspection PyShadowingNames
|
# noinspection PyShadowingNames
|
||||||
for (path, dirs, files) in os.walk(dir):
|
for (path, _, files) in os.walk(dir):
|
||||||
for file in files:
|
for file in files:
|
||||||
paths.append(os.path.join('..', path, file))
|
paths.append(os.path.join('..', path, file))
|
||||||
return paths
|
return paths
|
||||||
|
@ -68,17 +68,21 @@ setup(
|
||||||
'pyjwt',
|
'pyjwt',
|
||||||
'marshmallow',
|
'marshmallow',
|
||||||
'frozendict',
|
'frozendict',
|
||||||
|
'flask',
|
||||||
|
'bcrypt',
|
||||||
|
'python-magic',
|
||||||
],
|
],
|
||||||
|
|
||||||
extras_require={
|
extras_require={
|
||||||
# Support for thread custom name
|
# Support for thread custom name
|
||||||
'threadname': ['python-prctl'],
|
'threadname': ['python-prctl'],
|
||||||
# Support for Kafka backend and plugin
|
# Support for Kafka backend and plugin
|
||||||
'kafka': ['kafka-python'],
|
'kafka': ['kafka-python'],
|
||||||
# Support for Pushbullet backend and plugin
|
# Support for Pushbullet backend and plugin
|
||||||
'pushbullet': ['pushbullet.py @ https://github.com/rbrcsk/pushbullet.py/tarball/master'],
|
'pushbullet': [
|
||||||
# Support for HTTP backend
|
'pushbullet.py @ https://github.com/rbrcsk/pushbullet.py/tarball/master'
|
||||||
'http': ['flask', 'bcrypt', 'python-magic', 'gunicorn'],
|
],
|
||||||
|
# Support for HTTP backend over uWSGI
|
||||||
|
'http': ['gunicorn'],
|
||||||
# Support for MQTT backends
|
# Support for MQTT backends
|
||||||
'mqtt': ['paho-mqtt'],
|
'mqtt': ['paho-mqtt'],
|
||||||
# Support for RSS feeds parser
|
# Support for RSS feeds parser
|
||||||
|
@ -90,7 +94,11 @@ setup(
|
||||||
# Support for MPD/Mopidy music server plugin and backend
|
# Support for MPD/Mopidy music server plugin and backend
|
||||||
'mpd': ['python-mpd2'],
|
'mpd': ['python-mpd2'],
|
||||||
# Support for Google text2speech plugin
|
# Support for Google text2speech plugin
|
||||||
'google-tts': ['oauth2client', 'google-api-python-client', 'google-cloud-texttospeech'],
|
'google-tts': [
|
||||||
|
'oauth2client',
|
||||||
|
'google-api-python-client',
|
||||||
|
'google-cloud-texttospeech',
|
||||||
|
],
|
||||||
# Support for OMXPlayer plugin
|
# Support for OMXPlayer plugin
|
||||||
'omxplayer': ['omxplayer-wrapper'],
|
'omxplayer': ['omxplayer-wrapper'],
|
||||||
# Support for YouTube
|
# Support for YouTube
|
||||||
|
@ -138,7 +146,8 @@ setup(
|
||||||
# Support for web media subtitles
|
# Support for web media subtitles
|
||||||
'subtitles': [
|
'subtitles': [
|
||||||
'webvtt-py',
|
'webvtt-py',
|
||||||
'python-opensubtitles @ https://github.com/agonzalezro/python-opensubtitles/tarball/master'],
|
'python-opensubtitles @ https://github.com/agonzalezro/python-opensubtitles/tarball/master',
|
||||||
|
],
|
||||||
# Support for mpv player plugin
|
# Support for mpv player plugin
|
||||||
'mpv': ['python-mpv'],
|
'mpv': ['python-mpv'],
|
||||||
# Support for NFC tags
|
# Support for NFC tags
|
||||||
|
@ -156,14 +165,21 @@ setup(
|
||||||
# Support for Dropbox integration
|
# Support for Dropbox integration
|
||||||
'dropbox': ['dropbox'],
|
'dropbox': ['dropbox'],
|
||||||
# Support for Leap Motion backend
|
# Support for Leap Motion backend
|
||||||
'leap': ['leap-sdk @ https://github.com/BlackLight/leap-sdk-python3/tarball/master'],
|
'leap': [
|
||||||
|
'leap-sdk @ https://github.com/BlackLight/leap-sdk-python3/tarball/master'
|
||||||
|
],
|
||||||
# Support for Flic buttons
|
# Support for Flic buttons
|
||||||
'flic': ['flic @ https://github.com/50ButtonsEach/fliclib-linux-hci/tarball/master'],
|
'flic': [
|
||||||
|
'flic @ https://github.com/50ButtonsEach/fliclib-linux-hci/tarball/master'
|
||||||
|
],
|
||||||
# Support for Alexa/Echo plugin
|
# Support for Alexa/Echo plugin
|
||||||
'alexa': ['avs @ https://github.com/BlackLight/avs/tarball/master'],
|
'alexa': ['avs @ https://github.com/BlackLight/avs/tarball/master'],
|
||||||
# Support for bluetooth devices
|
# Support for bluetooth devices
|
||||||
'bluetooth': ['pybluez', 'gattlib',
|
'bluetooth': [
|
||||||
'pyobex @ https://github.com/BlackLight/PyOBEX/tarball/master'],
|
'pybluez',
|
||||||
|
'gattlib',
|
||||||
|
'pyobex @ https://github.com/BlackLight/PyOBEX/tarball/master',
|
||||||
|
],
|
||||||
# Support for TP-Link devices
|
# Support for TP-Link devices
|
||||||
'tplink': ['pyHS100'],
|
'tplink': ['pyHS100'],
|
||||||
# Support for PMW3901 2-Dimensional Optical Flow Sensor
|
# Support for PMW3901 2-Dimensional Optical Flow Sensor
|
||||||
|
@ -231,7 +247,9 @@ setup(
|
||||||
# Support for Twilio integration
|
# Support for Twilio integration
|
||||||
'twilio': ['twilio'],
|
'twilio': ['twilio'],
|
||||||
# Support for DHT11/DHT22/AM2302 temperature/humidity sensors
|
# Support for DHT11/DHT22/AM2302 temperature/humidity sensors
|
||||||
'dht': ['Adafruit_Python_DHT @ git+https://github.com/adafruit/Adafruit_Python_DHT'],
|
'dht': [
|
||||||
|
'Adafruit_Python_DHT @ git+https://github.com/adafruit/Adafruit_Python_DHT'
|
||||||
|
],
|
||||||
# Support for LCD display integration
|
# Support for LCD display integration
|
||||||
'lcd': ['RPi.GPIO', 'RPLCD'],
|
'lcd': ['RPi.GPIO', 'RPLCD'],
|
||||||
# Support for IMAP mail integration
|
# Support for IMAP mail integration
|
||||||
|
|
Loading…
Reference in a new issue