diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..89d5a00 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,21 @@ +# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.238.0/containers/python-3/.devcontainer/base.Dockerfile + +# [Choice] Python version (use -bullseye variants on local arm64/Apple Silicon): 3, 3.10, 3.9, 3.8, 3.7, 3.6, 3-bullseye, 3.10-bullseye, 3.9-bullseye, 3.8-bullseye, 3.7-bullseye, 3.6-bullseye, 3-buster, 3.10-buster, 3.9-buster, 3.8-buster, 3.7-buster, 3.6-buster +ARG VARIANT="3.10-bullseye" +FROM mcr.microsoft.com/vscode/devcontainers/python:0-${VARIANT} + +# [Choice] Node.js version: none, lts/*, 16, 14, 12, 10 +ARG NODE_VERSION="none" +RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi + +# [Optional] If your pip requirements rarely change, uncomment this section to add them to the image. +# COPY requirements.txt /tmp/pip-tmp/ +# RUN pip3 --disable-pip-version-check --no-cache-dir install -r /tmp/pip-tmp/requirements.txt \ +# && rm -rf /tmp/pip-tmp + +# [Optional] Uncomment this section to install additional OS packages. +# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ +# && apt-get -y install --no-install-recommends + +# [Optional] Uncomment this line to install global node packages. +# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g " 2>&1 \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..8703d0c --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,54 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: +// https://github.com/microsoft/vscode-dev-containers/tree/v0.238.0/containers/python-3 +{ + "name": "Python 3", + "build": { + "dockerfile": "Dockerfile", + "context": "..", + "args": { + // Update 'VARIANT' to pick a Python version: 3, 3.10, 3.9, 3.8, 3.7, 3.6 + // Append -bullseye or -buster to pin to an OS version. + // Use -bullseye variants on local on arm64/Apple Silicon. + "VARIANT": "3.10-bullseye", + // Options + "NODE_VERSION": "none" + } + }, + + // Configure tool-specific properties. + "customizations": { + // Configure properties specific to VS Code. + "vscode": { + // Set *default* container specific settings.json values on container create. + "settings": { + "python.defaultInterpreterPath": "/usr/local/bin/python", + "python.linting.enabled": true, + "python.linting.pylintEnabled": true, + "python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8", + "python.formatting.blackPath": "/usr/local/py-utils/bin/black", + "python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf", + "python.linting.banditPath": "/usr/local/py-utils/bin/bandit", + "python.linting.flake8Path": "/usr/local/py-utils/bin/flake8", + "python.linting.mypyPath": "/usr/local/py-utils/bin/mypy", + "python.linting.pycodestylePath": "/usr/local/py-utils/bin/pycodestyle", + "python.linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle", + "python.linting.pylintPath": "/usr/local/py-utils/bin/pylint" + }, + + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "ms-python.python", + "ms-python.vscode-pylance" + ] + } + }, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "pip3 install --user -r requirements.txt", + + // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. + "remoteUser": "vscode" +} diff --git a/.gitignore b/.gitignore index fa3a844..af242cf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,162 @@ +.DS_STORE + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments .env -venv -dist -build -**/*.egg-info \ No newline at end of file +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ \ No newline at end of file diff --git a/README.md b/README.md index 389bd6d..918e2e1 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ NEUROSITY_DEVICE_ID=your device id here We take data privacy very seriously at Neurosity. This is why we have designed the Neurosity OS to require authentication and authorization for streaming data. -When you sign up for an account on the Neurosity mobile app or console.neurosity.co and claim a device you have three new important items: deviceId, email, and password. If your device is not added to your Neurosity account, you will not be able to authenticate with it. +When you sign up for an account on the Neurosity mobile app or [console.neurosity.co](https://console.neurosity.co) and claim a device you have three new important items: deviceId, email, and password. If your device is not added to your Neurosity account, you will not be able to authenticate with it. ```python from neurosity import neurosity_sdk @@ -109,6 +109,8 @@ unsubscribe = neurosity.brainwaves_raw(callback) The code above will output new epochs of 16 samples approximately every 62.5ms (see the `data` property). Here's an example of 1 event: +
Sample data + ``` { label: 'raw', @@ -208,6 +210,8 @@ The code above will output new epochs of 16 samples approximately every 62.5ms ( } ``` +
+ Epochs are pre-filtered on the device's Operating System to give you the cleanest data possible with maximum performance. These filters include: - Notch of `50Hz` or `60Hz` and a bandwidth of `1`. @@ -261,6 +265,9 @@ unsubscribe = neurosity.brainwaves_raw_unfiltered(callback) The code above will output new epochs of 16 samples approximately every 62.5ms (see the `data` property).. Here's an example of 1 event: +
Sample data + + ``` { label: 'rawUnfiltered', @@ -359,6 +366,8 @@ The code above will output new epochs of 16 samples approximately every 62.5ms ( } ``` +
+ ### Power Spectral Density (PSD) ```python @@ -372,6 +381,8 @@ The code above will output new epochs 4 times a second. Every frequency label (e Here's an example of 1 event: +
Sample data + ``` { label: 'psd', @@ -585,6 +596,8 @@ Here's an example of 1 event: } ``` +
+ Please note this data is pre-filtered using the same filters describe under the `raw` data parameter: notch and band pass. ### Power By Band @@ -600,6 +613,8 @@ The code above will output new epochs 4 times a second. Every frequency label (e Here's an example of 1 event: +
Sample data + ``` { label: 'powerByBand', @@ -658,6 +673,8 @@ Here's an example of 1 event: } ``` +
+ Please note this data is pre-filtered using the same filters describe under the `brainwaves_raw` method: notch and band pass. ### Adding Markers diff --git a/neurosity/neurosity.py b/neurosity/neurosity.py index 0d4d30b..6a8981f 100644 --- a/neurosity/neurosity.py +++ b/neurosity/neurosity.py @@ -1,41 +1,64 @@ -import pyrebase +from typing import Callable import atexit +import pyrebase from neurosity.config import PyRebase + class neurosity_sdk: - def __init__(self, options): - if ("device_id" not in options): + """The Official Neurosity Python SDK 🤯""" + + def __init__(self, options: dict) -> None: + """ + Args: + options (dict): Configuration options for device_id and environment + """ + if "device_id" not in options: raise ValueError("Neurosity SDK: A device ID is required to use the SDK") options.setdefault("environment", "production") + self.client_id = None + self.user = None + self.token = None self.options = options - pyrebase_config = PyRebase.STAGING if options["environment"] == "staging" else PyRebase.PRODUCTION + pyrebase_config = ( + PyRebase.STAGING + if options["environment"] == "staging" + else PyRebase.PRODUCTION + ) self.firebase = pyrebase.initialize_app(pyrebase_config) self.auth = self.firebase.auth() self.db = self.firebase.database() self.subscription_ids = [] atexit.register(self.exit_handler) - def exit_handler(self): + def exit_handler(self) -> None: + """Remove the client and all subscriptions""" self.remove_client() self.remove_all_subscriptions() - def get_server_timestamp(self): + def get_server_timestamp(self) -> dict: + """Get the server timestamp""" return {".sv": "timestamp"} - def login(self, credentials): - if (hasattr(self, "user") and hasattr(self, "token")): + def login(self, credentials: dict) -> None: + """ + Args: + credentials (dict): Your email and password + """ + if self.user is not None and self.token is not None: print("Neurosity SDK: The SDK is already authenticated.") return self.user = self.auth.sign_in_with_email_and_password( - credentials["email"], credentials["password"]) - self.token = self.user['idToken'] + credentials["email"], credentials["password"] + ) + self.token = self.user["idToken"] - if (not hasattr(self, "client_id")): + if self.client_id is None: self.add_client() - def add_client(self): + def add_client(self) -> None: + """Add a client""" device_id = self.options["device_id"] clients_path = f"devices/{device_id}/clients" timestamp = self.get_server_timestamp() @@ -43,18 +66,24 @@ def add_client(self): self.client_id = push_result["name"] def remove_client(self): + """Remove the client""" client_id = self.client_id - if(client_id): + if client_id: device_id = self.options["device_id"] client_path = f"devices/{device_id}/clients/{client_id}" self.db.child(client_path).remove(self.token) - # @TODO: handle resnponse - def add_action(self, action): - if ("command" not in action): + # @TODO: handle response + def add_action(self, action: dict): + """ + Args: + action (dict): Dictionary of action data including: + command, action, message + """ + if "command" not in action: raise ValueError("A command is required for actions") - if ("action" not in action): + if "action" not in action: raise ValueError("An action is required for actions") device_id = self.options["device_id"] @@ -66,7 +95,8 @@ def add_action(self, action): push_result = self.db.child(actions_path).push(action, self.token) return push_result - def add_subscription(self, metric, label, atomic): + def add_subscription(self, metric: str, label: str, atomic: bool) -> str: + """Add subscription""" client_id = self.client_id device_id = self.options["device_id"] subscription_id = self.db.generate_key() @@ -81,20 +111,21 @@ def add_subscription(self, metric, label, atomic): "serverType": "firebase", } - self.db.child(subscription_path).set( - subscription_payload, self.token) + self.db.child(subscription_path).set(subscription_payload, self.token) # caching subscription ids locally for unsubscribe teardown on exit self.subscription_ids.append(subscription_id) return subscription_id - def remove_subscription(self, subscription_id): + def remove_subscription(self, subscription_id: str) -> None: + """Remove the given subscription""" device_id = self.options["device_id"] subscription_path = f"devices/{device_id}/subscriptions/{subscription_id}" self.db.child(subscription_path).remove(self.token) - def remove_all_subscriptions(self): + def remove_all_subscriptions(self) -> None: + """Remove all subscriptions""" device_id = self.options["device_id"] subscriptions_path = f"devices/{device_id}/subscriptions" data = {} @@ -104,10 +135,21 @@ def remove_all_subscriptions(self): self.db.child(subscriptions_path).update(data, self.token) - def stream_metric(self, callback, metric, label, atomic): + def stream_metric( + self, callback: Callable, metric: str, label: str, atomic: bool + ) -> Callable: + """ + Args: + callback (Callable): The callback that gets called on each epoch. + Sample data is passed to the callback. + metric (str): The brainwave metric to stream. + label (str): The brainwave metric label, possible values: + 'raw', 'rawUnfiltered', 'psd', 'powerByBand' + atomic (bool): Whether the metric is atomic or not. + """ subscription_id = self.add_subscription(metric, label, atomic) - if (atomic): + if atomic: metric_path = f"metrics/{metric}" else: metric_path = f"metrics/{metric}/{label}" @@ -118,7 +160,10 @@ def teardown(subscription_id): return self.stream_from_path(callback, metric_path, teardown, subscription_id) - def stream_from_path(self, callback, path_name, teardown=None, subscription_id=None): + def stream_from_path( + self, callback: Callable, path_name: str, teardown=None, subscription_id=None + ) -> Callable: + """Stream data from a given path""" device_id = self.options["device_id"] path = f"devices/{device_id}/{path_name}" @@ -127,14 +172,14 @@ def stream_from_path(self, callback, path_name, teardown=None, subscription_id=N initial_message = {} def stream_handler(message): - if (message["path"] == "/"): + if message["path"] == "/": initial_message[message["stream_id"]] = message full_payload = message["data"] else: child = message["path"][1:] full_payload = initial_message[message["stream_id"]]["data"] - if (message["data"] == None): - # delete key is value is `None` + if message["data"] is None: + # delete key if value is `None` full_payload.pop(child, None) else: full_payload[child] = message["data"] @@ -142,75 +187,91 @@ def stream_handler(message): callback(full_payload) stream = self.db.child(path).stream( - stream_handler, self.token, stream_id=stream_id) + stream_handler, self.token, stream_id=stream_id + ) def unsubscribe(): - if (teardown): + if teardown: teardown(stream_id) stream.close() return unsubscribe - def get_from_path(self, path_name): + def get_from_path(self, path_name: str): + """Get data from a given path""" device_id = self.options["device_id"] path = f"devices/{device_id}/{path_name}" snapshot = self.db.child(path).get(self.token) return snapshot.val() - def add_marker(self, label): - if (not label): + def add_marker(self, label: str): + """Add a marker""" + if not label: raise ValueError("A label is required for markers") - return self.add_action({ - "command": "marker", - "action": "add", - "message": { - "label": label, - "timestamp": self.get_server_timestamp() + return self.add_action( + { + "command": "marker", + "action": "add", + "message": {"label": label, "timestamp": self.get_server_timestamp()}, } - }) + ) - def brainwaves_raw(self, callback): + def brainwaves_raw(self, callback: Callable) -> Callable: + """Subscribe to the filtered raw brainwave data.""" return self.stream_metric(callback, "brainwaves", "raw", False) - def brainwaves_raw_unfiltered(self, callback): + def brainwaves_raw_unfiltered(self, callback: Callable) -> Callable: + """Subscribe to the unfiltered raw brainwave data.""" return self.stream_metric(callback, "brainwaves", "rawUnfiltered", False) - def brainwaves_psd(self, callback): + def brainwaves_psd(self, callback: Callable) -> Callable: + """Subscribe to the filtered power spectrum density (PSD) brainwave data.""" return self.stream_metric(callback, "brainwaves", "psd", False) - def brainwaves_power_by_band(self, callback): + def brainwaves_power_by_band(self, callback: Callable) -> Callable: + """Subscribe to the filtered power by band brainwave data.""" return self.stream_metric(callback, "brainwaves", "powerByBand", False) - def signal_quality(self, callback): + def signal_quality(self, callback: Callable) -> Callable: + """Standard deviation based signal quality metrics.""" return self.stream_metric(callback, "signalQuality", None, True) - def accelerometer(self, callback): + def accelerometer(self, callback: Callable) -> Callable: return self.stream_metric(callback, "accelerometer", None, True) - def calm(self, callback): + def calm(self, callback: Callable) -> Callable: + """ + Constantly fires and predicts user's calm level from passive cognitive state. + Calm is a probability from 0.0 to 1.0. + """ return self.stream_metric(callback, "awareness", "calm", False) - def focus(self, callback): + def focus(self, callback: Callable) -> Callable: + """ + Constantly fires and predicts user's focus level from passive cognitive state based on the gamma brainwave between 30 and 44 Hz. + Focus is a probability from 0.0 to 1.0. + """ return self.stream_metric(callback, "awareness", "focus", False) - def kinesis(self, label, callback): + def kinesis(self, label, callback: Callable) -> Callable: + """Fires when a user attempts to trigger a side effect from defined thoughts.""" return self.stream_metric(callback, "kinesis", label, False) - def kinesis_predictions(self, label, callback): + def kinesis_predictions(self, label: str, callback: Callable) -> Callable: return self.stream_metric(callback, "predictions", label, False) - def status(self, callback): + def status(self, callback: Callable) -> dict: return self.stream_from_path(callback, "status") - def settings(self, callback): + def settings(self, callback: Callable) -> dict: return self.stream_from_path(callback, "settings") - def status_once(self): + def status_once(self) -> dict: return self.get_from_path("status") - def settings_once(self): + def settings_once(self) -> dict: return self.get_from_path("settings") - def get_info(self): + def get_info(self) -> dict: return self.get_from_path("info")