├── .github ├── dependabot.yml └── workflows │ ├── build_tests.yml │ ├── dev2master.yml │ ├── license_tests.yml │ ├── notify_matrix.yml │ ├── publish_alpha.yml │ ├── publish_build.yml │ ├── publish_docker.yml │ ├── publish_major.yml │ └── publish_minor.yml ├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── ovos_local_backend ├── __init__.py ├── __main__.py ├── backend │ ├── __init__.py │ ├── admin.py │ ├── auth.py │ ├── crud.py │ ├── decorators.py │ ├── device.py │ ├── external_apis.py │ ├── precise.py │ └── stt.py ├── database.py ├── ovos_backend.conf ├── session.py ├── utils │ ├── __init__.py │ ├── geolocate.py │ └── mail.py └── version.py ├── requirements └── requirements.txt ├── scripts ├── bump_alpha.py ├── bump_build.py ├── bump_major.py ├── bump_minor.py ├── entrypoints.sh └── remove_alpha.py └── setup.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" # See documentation for possible values 9 | directory: "/requirements" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/build_tests.yml: -------------------------------------------------------------------------------- 1 | name: Run Build Tests 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | branches: 8 | - dev 9 | paths-ignore: 10 | - 'ovos_local_backend/version.py' 11 | - 'test/**' 12 | - 'examples/**' 13 | - '.github/**' 14 | - '.gitignore' 15 | - 'LICENSE' 16 | - 'CHANGELOG.md' 17 | - 'MANIFEST.in' 18 | - 'readme.md' 19 | - 'scripts/**' 20 | workflow_dispatch: 21 | 22 | jobs: 23 | build_tests: 24 | strategy: 25 | max-parallel: 2 26 | matrix: 27 | python-version: [ 3.7, 3.8, 3.9, "3.10" ] 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: actions/checkout@v2 31 | - name: Setup Python 32 | uses: actions/setup-python@v1 33 | with: 34 | python-version: ${{ matrix.python-version }} 35 | - name: Install Build Tools 36 | run: | 37 | python -m pip install build wheel 38 | - name: Install System Dependencies 39 | run: | 40 | sudo apt-get update 41 | sudo apt install python3-dev swig libssl-dev 42 | - name: Build Source Packages 43 | run: | 44 | python setup.py sdist 45 | - name: Build Distribution Packages 46 | run: | 47 | python setup.py bdist_wheel 48 | - name: Install package 49 | run: | 50 | pip install . -------------------------------------------------------------------------------- /.github/workflows/dev2master.yml: -------------------------------------------------------------------------------- 1 | # This workflow will generate a distribution and upload it to PyPI 2 | 3 | name: Push dev -> master 4 | on: 5 | workflow_dispatch: 6 | 7 | jobs: 8 | build_and_publish: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | with: 13 | fetch-depth: 0 # otherwise, there would be errors pushing refs to the destination repository. 14 | ref: dev 15 | - name: Push dev -> master 16 | uses: ad-m/github-push-action@master 17 | 18 | with: 19 | github_token: ${{ secrets.GITHUB_TOKEN }} 20 | branch: master -------------------------------------------------------------------------------- /.github/workflows/license_tests.yml: -------------------------------------------------------------------------------- 1 | name: Run License Tests 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | branches: 8 | - dev 9 | workflow_dispatch: 10 | 11 | jobs: 12 | license_tests: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Setup Python 17 | uses: actions/setup-python@v1 18 | with: 19 | python-version: 3.8 20 | - name: Install Build Tools 21 | run: | 22 | python -m pip install build wheel 23 | - name: Install System Dependencies 24 | run: | 25 | sudo apt-get update 26 | sudo apt install python3-dev swig libssl-dev 27 | - name: Install core repo 28 | run: | 29 | pip install . 30 | - name: Get explicit and transitive dependencies 31 | run: | 32 | pip freeze > requirements-all.txt 33 | - name: Check python 34 | id: license_check_report 35 | uses: pilosus/action-pip-license-checker@v0.5.0 36 | with: 37 | requirements: 'requirements-all.txt' 38 | fail: 'Copyleft,Other,Error' 39 | fails-only: true 40 | exclude: '^(tqdm).*' 41 | exclude-license: '^(Mozilla).*$' 42 | - name: Print report 43 | if: ${{ always() }} 44 | run: echo "${{ steps.license_check_report.outputs.report }}" -------------------------------------------------------------------------------- /.github/workflows/notify_matrix.yml: -------------------------------------------------------------------------------- 1 | name: Notify Matrix Chat 2 | 3 | # only trigger on pull request closed events 4 | on: 5 | pull_request: 6 | types: [ closed ] 7 | 8 | jobs: 9 | merge_job: 10 | # this job will only run if the PR has been merged 11 | if: github.event.pull_request.merged == true 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Send message to Matrix bots channel 16 | id: matrix-chat-message 17 | uses: fadenb/matrix-chat-message@v0.0.6 18 | with: 19 | homeserver: 'matrix.org' 20 | token: ${{ secrets.MATRIX_TOKEN }} 21 | channel: '!WjxEKjjINpyBRPFgxl:krbel.duckdns.org' 22 | message: | 23 | new ovos-local-backend PR merged! https://github.com/OpenVoiceOS/OVOS-local-backend/pull/${{ github.event.number }} 24 | -------------------------------------------------------------------------------- /.github/workflows/publish_alpha.yml: -------------------------------------------------------------------------------- 1 | # This workflow will generate a distribution and upload it to PyPI 2 | 3 | name: Publish Alpha Build ...aX 4 | on: 5 | push: 6 | branches: 7 | - dev 8 | paths-ignore: 9 | - 'ovos_local_backend/version.py' 10 | - 'test/**' 11 | - 'examples/**' 12 | - '.github/**' 13 | - '.gitignore' 14 | - 'LICENSE' 15 | - 'CHANGELOG.md' 16 | - 'MANIFEST.in' 17 | - 'readme.md' 18 | - 'scripts/**' 19 | workflow_dispatch: 20 | 21 | jobs: 22 | build_and_publish: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v2 26 | with: 27 | ref: dev 28 | fetch-depth: 0 # otherwise, there would be errors pushing refs to the destination repository. 29 | - name: Setup Python 30 | uses: actions/setup-python@v1 31 | with: 32 | python-version: 3.8 33 | - name: Install Build Tools 34 | run: | 35 | python -m pip install build wheel 36 | - name: Increment Version 37 | run: | 38 | VER=$(python setup.py --version) 39 | python scripts/bump_alpha.py 40 | - name: "Generate release changelog" 41 | uses: heinrichreimer/github-changelog-generator-action@v2.3 42 | with: 43 | token: ${{ secrets.GITHUB_TOKEN }} 44 | id: changelog 45 | - name: Commit to dev 46 | uses: stefanzweifel/git-auto-commit-action@v4 47 | with: 48 | commit_message: Increment Version 49 | branch: dev 50 | - name: version 51 | run: echo "::set-output name=version::$(python setup.py --version)" 52 | id: version 53 | - name: Create Release 54 | id: create_release 55 | uses: actions/create-release@v1 56 | env: 57 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token 58 | with: 59 | tag_name: V${{ steps.version.outputs.version }} 60 | release_name: Release ${{ steps.version.outputs.version }} 61 | body: | 62 | Changes in this Release 63 | ${{ steps.changelog.outputs.changelog }} 64 | draft: false 65 | prerelease: true 66 | - name: Build Distribution Packages 67 | run: | 68 | python setup.py bdist_wheel 69 | - name: Publish to Test PyPI 70 | uses: pypa/gh-action-pypi-publish@master 71 | with: 72 | password: ${{secrets.PYPI_TOKEN}} -------------------------------------------------------------------------------- /.github/workflows/publish_build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will generate a distribution and upload it to PyPI 2 | 3 | name: Publish Build Release ..X 4 | on: 5 | workflow_dispatch: 6 | 7 | jobs: 8 | build_and_publish: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | with: 13 | ref: dev 14 | fetch-depth: 0 # otherwise, there would be errors pushing refs to the destination repository. 15 | - name: Setup Python 16 | uses: actions/setup-python@v1 17 | with: 18 | python-version: 3.8 19 | - name: Install Build Tools 20 | run: | 21 | python -m pip install build wheel 22 | - name: Remove alpha (declare stable) 23 | run: | 24 | VER=$(python setup.py --version) 25 | python scripts/remove_alpha.py 26 | - name: "Generate release changelog" 27 | uses: heinrichreimer/github-changelog-generator-action@v2.3 28 | with: 29 | token: ${{ secrets.GITHUB_TOKEN }} 30 | id: changelog 31 | - name: Commit to dev 32 | uses: stefanzweifel/git-auto-commit-action@v4 33 | with: 34 | commit_message: Declare alpha stable 35 | branch: dev 36 | - name: Push dev -> master 37 | uses: ad-m/github-push-action@master 38 | with: 39 | github_token: ${{ secrets.GITHUB_TOKEN }} 40 | branch: master 41 | force: true 42 | - name: version 43 | run: echo "::set-output name=version::$(python setup.py --version)" 44 | id: version 45 | - name: Create Release 46 | id: create_release 47 | uses: actions/create-release@v1 48 | env: 49 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token 50 | with: 51 | tag_name: V${{ steps.version.outputs.version }} 52 | release_name: Release ${{ steps.version.outputs.version }} 53 | body: | 54 | Changes in this Release 55 | ${{ steps.changelog.outputs.changelog }} 56 | draft: false 57 | prerelease: false 58 | - name: Build Distribution Packages 59 | run: | 60 | python setup.py bdist_wheel 61 | - name: Prepare next Build version 62 | run: echo "::set-output name=version::$(python setup.py --version)" 63 | id: alpha 64 | - name: Increment Version ${{ steps.alpha.outputs.version }}Alpha0 65 | run: | 66 | VER=$(python setup.py --version) 67 | python scripts/bump_build.py 68 | - name: Commit to dev 69 | uses: stefanzweifel/git-auto-commit-action@v4 70 | with: 71 | commit_message: Prepare Next Version 72 | branch: dev 73 | - name: Publish to Test PyPI 74 | uses: pypa/gh-action-pypi-publish@master 75 | with: 76 | password: ${{secrets.PYPI_TOKEN}} -------------------------------------------------------------------------------- /.github/workflows/publish_docker.yml: -------------------------------------------------------------------------------- 1 | name: Publish Local Backend Container 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - master 7 | 8 | env: 9 | REGISTRY: ghcr.io 10 | IMAGE_NAME: OpenVoiceOS/local-backend 11 | 12 | jobs: 13 | build_and_publish_docker: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: read 17 | packages: write 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v2 21 | with: 22 | ref: ${{ github.ref }} 23 | 24 | - name: Log in to the Container registry 25 | uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 26 | with: 27 | registry: ${{ env.REGISTRY }} 28 | username: ${{ github.actor }} 29 | password: ${{ secrets.GITHUB_TOKEN }} 30 | 31 | - name: Extract metadata for Docker 32 | id: meta 33 | uses: docker/metadata-action@v2 34 | with: 35 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 36 | tags: | 37 | type=ref,event=branch 38 | - name: Build and push Docker image 39 | uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc 40 | with: 41 | context: . 42 | file: Dockerfile 43 | push: true 44 | tags: ${{ steps.meta.outputs.tags }} 45 | labels: ${{ steps.meta.outputs.labels }} 46 | -------------------------------------------------------------------------------- /.github/workflows/publish_major.yml: -------------------------------------------------------------------------------- 1 | # This workflow will generate a distribution and upload it to PyPI 2 | 3 | name: Publish Major Release X.0.0 4 | on: 5 | workflow_dispatch: 6 | 7 | jobs: 8 | build_and_publish: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | with: 13 | ref: dev 14 | fetch-depth: 0 # otherwise, there would be errors pushing refs to the destination repository. 15 | - name: Setup Python 16 | uses: actions/setup-python@v1 17 | with: 18 | python-version: 3.8 19 | - name: Install Build Tools 20 | run: | 21 | python -m pip install build wheel 22 | - name: Remove alpha (declare stable) 23 | run: | 24 | VER=$(python setup.py --version) 25 | python scripts/remove_alpha.py 26 | - name: "Generate release changelog" 27 | uses: heinrichreimer/github-changelog-generator-action@v2.3 28 | with: 29 | token: ${{ secrets.GITHUB_TOKEN }} 30 | id: changelog 31 | - name: Commit to dev 32 | uses: stefanzweifel/git-auto-commit-action@v4 33 | with: 34 | commit_message: Declare alpha stable 35 | branch: dev 36 | - name: Push dev -> master 37 | uses: ad-m/github-push-action@master 38 | with: 39 | github_token: ${{ secrets.GITHUB_TOKEN }} 40 | branch: master 41 | force: true 42 | - name: version 43 | run: echo "::set-output name=version::$(python setup.py --version)" 44 | id: version 45 | - name: Create Release 46 | id: create_release 47 | uses: actions/create-release@v1 48 | env: 49 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token 50 | with: 51 | tag_name: V${{ steps.version.outputs.version }} 52 | release_name: Release ${{ steps.version.outputs.version }} 53 | body: | 54 | Changes in this Release 55 | ${{ steps.changelog.outputs.changelog }} 56 | draft: false 57 | prerelease: false 58 | - name: Build Distribution Packages 59 | run: | 60 | python setup.py bdist_wheel 61 | - name: Prepare next Major version 62 | run: echo "::set-output name=version::$(python setup.py --version)" 63 | id: alpha 64 | - name: Increment Version ${{ steps.alpha.outputs.version }}Alpha0 65 | run: | 66 | VER=$(python setup.py --version) 67 | python scripts/bump_major.py 68 | - name: Commit to dev 69 | uses: stefanzweifel/git-auto-commit-action@v4 70 | with: 71 | commit_message: Prepare Next Version 72 | branch: dev 73 | - name: Publish to Test PyPI 74 | uses: pypa/gh-action-pypi-publish@master 75 | with: 76 | password: ${{secrets.PYPI_TOKEN}} -------------------------------------------------------------------------------- /.github/workflows/publish_minor.yml: -------------------------------------------------------------------------------- 1 | # This workflow will generate a distribution and upload it to PyPI 2 | 3 | name: Publish Minor Release .X.0 4 | on: 5 | workflow_dispatch: 6 | 7 | jobs: 8 | build_and_publish: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | with: 13 | ref: dev 14 | fetch-depth: 0 # otherwise, there would be errors pushing refs to the destination repository. 15 | - name: Setup Python 16 | uses: actions/setup-python@v1 17 | with: 18 | python-version: 3.8 19 | - name: Install Build Tools 20 | run: | 21 | python -m pip install build wheel 22 | - name: Remove alpha (declare stable) 23 | run: | 24 | VER=$(python setup.py --version) 25 | python scripts/remove_alpha.py 26 | - name: "Generate release changelog" 27 | uses: heinrichreimer/github-changelog-generator-action@v2.3 28 | with: 29 | token: ${{ secrets.GITHUB_TOKEN }} 30 | id: changelog 31 | - name: Commit to dev 32 | uses: stefanzweifel/git-auto-commit-action@v4 33 | with: 34 | commit_message: Declare alpha stable 35 | branch: dev 36 | - name: Push dev -> master 37 | uses: ad-m/github-push-action@master 38 | with: 39 | github_token: ${{ secrets.GITHUB_TOKEN }} 40 | branch: master 41 | force: true 42 | - name: version 43 | run: echo "::set-output name=version::$(python setup.py --version)" 44 | id: version 45 | - name: Create Release 46 | id: create_release 47 | uses: actions/create-release@v1 48 | env: 49 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token 50 | with: 51 | tag_name: V${{ steps.version.outputs.version }} 52 | release_name: Release ${{ steps.version.outputs.version }} 53 | body: | 54 | Changes in this Release 55 | ${{ steps.changelog.outputs.changelog }} 56 | draft: false 57 | prerelease: false 58 | - name: Build Distribution Packages 59 | run: | 60 | python setup.py bdist_wheel 61 | - name: Prepare next Minor version 62 | run: echo "::set-output name=version::$(python setup.py --version)" 63 | id: alpha 64 | - name: Increment Version ${{ steps.alpha.outputs.version }}Alpha0 65 | run: | 66 | VER=$(python setup.py --version) 67 | python scripts/bump_minor.py 68 | - name: Commit to dev 69 | uses: stefanzweifel/git-auto-commit-action@v4 70 | with: 71 | commit_message: Prepare Next Version 72 | branch: dev 73 | - name: Publish to Test PyPI 74 | uses: pypa/gh-action-pypi-publish@master 75 | with: 76 | password: ${{secrets.PYPI_TOKEN}} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dev.env 2 | .dev_opts.json 3 | .idea 4 | *.code-workspace 5 | *.pyc 6 | *.swp 7 | *~ 8 | *.egg-info/ 9 | build 10 | dist 11 | .coverage 12 | /htmlcov 13 | .installed 14 | .mypy_cache 15 | .vscode 16 | .theia 17 | .venv/ 18 | 19 | # Created by unit tests 20 | .pytest_cache/ 21 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [V0.2.0a22](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.2.0a22) (2024-01-31) 4 | 5 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.2.0a21...V0.2.0a22) 6 | 7 | **Merged pull requests:** 8 | 9 | - Update oauthlib requirement from ~=3.0 to ~=3.2 in /requirements [\#74](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/74) ([dependabot[bot]](https://github.com/apps/dependabot)) 10 | 11 | ## [V0.2.0a21](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.2.0a21) (2024-01-31) 12 | 13 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.2.0a20...V0.2.0a21) 14 | 15 | **Closed issues:** 16 | 17 | - \[OAUTH\] crud endpoints `token_id` [\#73](https://github.com/OpenVoiceOS/ovos-personal-backend/issues/73) 18 | 19 | **Merged pull requests:** 20 | 21 | - Update ovos-backend-client requirement from \>=0.0.6a10,~=0.0 to ~=0.1 in /requirements [\#75](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/75) ([dependabot[bot]](https://github.com/apps/dependabot)) 22 | 23 | ## [V0.2.0a20](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.2.0a20) (2023-08-29) 24 | 25 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.2.0a19...V0.2.0a20) 26 | 27 | **Fixed bugs:** 28 | 29 | - Oauth fixes [\#71](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/71) ([forslund](https://github.com/forslund)) 30 | 31 | ## [V0.2.0a19](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.2.0a19) (2023-07-15) 32 | 33 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.2.0a18...V0.2.0a19) 34 | 35 | **Merged pull requests:** 36 | 37 | - Readme: Correct path for config [\#70](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/70) ([forslund](https://github.com/forslund)) 38 | 39 | ## [V0.2.0a18](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.2.0a18) (2023-06-16) 40 | 41 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.2.0a17...V0.2.0a18) 42 | 43 | **Fixed bugs:** 44 | 45 | - updated requirements [\#69](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/69) ([builderjer](https://github.com/builderjer)) 46 | 47 | ## [V0.2.0a17](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.2.0a17) (2023-06-12) 48 | 49 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.2.0a16...V0.2.0a17) 50 | 51 | ## [V0.2.0a16](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.2.0a16) (2023-06-12) 52 | 53 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.2.0a15...V0.2.0a16) 54 | 55 | ## [V0.2.0a15](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.2.0a15) (2023-06-09) 56 | 57 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.2.0a14...V0.2.0a15) 58 | 59 | **Breaking changes:** 60 | 61 | - refactor/stt - deprecate OPM -\> ovos-stt-server only [\#68](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/68) ([JarbasAl](https://github.com/JarbasAl)) 62 | 63 | ## [V0.2.0a14](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.2.0a14) (2023-06-09) 64 | 65 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.2.0a13...V0.2.0a14) 66 | 67 | **Breaking changes:** 68 | 69 | - feat/own config file [\#67](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/67) ([JarbasAl](https://github.com/JarbasAl)) 70 | 71 | ## [V0.2.0a13](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.2.0a13) (2023-06-08) 72 | 73 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.2.0a12...V0.2.0a13) 74 | 75 | **Implemented enhancements:** 76 | 77 | - feat/admin\_update\_backend\_config [\#66](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/66) ([JarbasAl](https://github.com/JarbasAl)) 78 | 79 | ## [V0.2.0a12](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.2.0a12) (2023-06-08) 80 | 81 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.2.0a11...V0.2.0a12) 82 | 83 | **Implemented enhancements:** 84 | 85 | - ovos\_config [\#24](https://github.com/OpenVoiceOS/ovos-personal-backend/issues/24) 86 | 87 | ## [V0.2.0a11](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.2.0a11) (2023-06-08) 88 | 89 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.2.0a10...V0.2.0a11) 90 | 91 | **Breaking changes:** 92 | 93 | - refactor/cfg [\#65](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/65) ([JarbasAl](https://github.com/JarbasAl)) 94 | 95 | ## [V0.2.0a10](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.2.0a10) (2023-06-08) 96 | 97 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.2.0a9...V0.2.0a10) 98 | 99 | ## [V0.2.0a9](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.2.0a9) (2023-06-07) 100 | 101 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.2.0a8...V0.2.0a9) 102 | 103 | **Fixed bugs:** 104 | 105 | - unify apis [\#64](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/64) ([JarbasAl](https://github.com/JarbasAl)) 106 | 107 | ## [V0.2.0a8](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.2.0a8) (2023-05-22) 108 | 109 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.2.0a7...V0.2.0a8) 110 | 111 | **Fixed bugs:** 112 | 113 | - fix/database issues [\#55](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/55) ([JarbasAl](https://github.com/JarbasAl)) 114 | 115 | ## [V0.2.0a7](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.2.0a7) (2023-05-22) 116 | 117 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.2.0a6...V0.2.0a7) 118 | 119 | **Fixed bugs:** 120 | 121 | - `ovos-backend-client` requirement missing [\#58](https://github.com/OpenVoiceOS/ovos-personal-backend/issues/58) 122 | - fix geolocation [\#60](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/60) ([emphasize](https://github.com/emphasize)) 123 | 124 | **Closed issues:** 125 | 126 | - Geolocation bug [\#56](https://github.com/OpenVoiceOS/ovos-personal-backend/issues/56) 127 | 128 | ## [V0.2.0a6](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.2.0a6) (2023-05-18) 129 | 130 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.2.0a5...V0.2.0a6) 131 | 132 | **Fixed bugs:** 133 | 134 | - geolocate fix [\#59](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/59) ([emphasize](https://github.com/emphasize)) 135 | 136 | ## [V0.2.0a5](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.2.0a5) (2023-04-07) 137 | 138 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.2.0a4...V0.2.0a5) 139 | 140 | ## [V0.2.0a4](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.2.0a4) (2023-04-07) 141 | 142 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.2.0a3...V0.2.0a4) 143 | 144 | **Fixed bugs:** 145 | 146 | - sql continued - missed PR review [\#54](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/54) ([JarbasAl](https://github.com/JarbasAl)) 147 | 148 | ## [V0.2.0a3](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.2.0a3) (2023-04-07) 149 | 150 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.2.0a2...V0.2.0a3) 151 | 152 | ## [V0.2.0a2](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.2.0a2) (2023-04-07) 153 | 154 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.1.6a9...V0.2.0a2) 155 | 156 | **Breaking changes:** 157 | 158 | - feat/sql\_alchemy [\#51](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/51) ([JarbasAl](https://github.com/JarbasAl)) 159 | 160 | ## [V0.1.6a9](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.1.6a9) (2023-03-30) 161 | 162 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.1.6a8...V0.1.6a9) 163 | 164 | **Breaking changes:** 165 | 166 | - feat/no\_more\_selene [\#53](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/53) ([JarbasAl](https://github.com/JarbasAl)) 167 | 168 | **Closed issues:** 169 | 170 | - Personal-backend manager [\#48](https://github.com/OpenVoiceOS/ovos-personal-backend/issues/48) 171 | 172 | ## [V0.1.6a8](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.1.6a8) (2023-02-27) 173 | 174 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.1.6a7...V0.1.6a8) 175 | 176 | **Merged pull requests:** 177 | 178 | - add backend manager to docker composition [\#49](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/49) ([emphasize](https://github.com/emphasize)) 179 | 180 | ## [V0.1.6a7](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.1.6a7) (2023-02-15) 181 | 182 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.1.6a6...V0.1.6a7) 183 | 184 | ## [V0.1.6a6](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.1.6a6) (2022-12-05) 185 | 186 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.1.6a5...V0.1.6a6) 187 | 188 | **Merged pull requests:** 189 | 190 | - port shared oauth db utils [\#46](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/46) ([JarbasAl](https://github.com/JarbasAl)) 191 | 192 | ## [V0.1.6a5](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.1.6a5) (2022-11-14) 193 | 194 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.1.6a4...V0.1.6a5) 195 | 196 | **Merged pull requests:** 197 | 198 | - Bug/default ww [\#45](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/45) ([builderjer](https://github.com/builderjer)) 199 | 200 | ## [V0.1.6a4](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.1.6a4) (2022-11-09) 201 | 202 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.1.6a3...V0.1.6a4) 203 | 204 | **Merged pull requests:** 205 | 206 | - Feat/workflows [\#44](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/44) ([JarbasAl](https://github.com/JarbasAl)) 207 | - Fix local api key usage [\#42](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/42) ([builderjer](https://github.com/builderjer)) 208 | 209 | ## [V0.1.6a3](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.1.6a3) (2022-10-17) 210 | 211 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.1.6a2...V0.1.6a3) 212 | 213 | **Fixed bugs:** 214 | 215 | - Fix/selene UUID [\#43](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/43) ([JarbasAl](https://github.com/JarbasAl)) 216 | 217 | ## [V0.1.6a2](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.1.6a2) (2022-10-15) 218 | 219 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.1.6a1...V0.1.6a2) 220 | 221 | **Fixed bugs:** 222 | 223 | - Fix weather one call from remote device [\#41](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/41) ([builderjer](https://github.com/builderjer)) 224 | 225 | ## [V0.1.6a1](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.1.6a1) (2022-10-15) 226 | 227 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.1.5...V0.1.6a1) 228 | 229 | **Fixed bugs:** 230 | 231 | - fix/handle\_ipgeo\_failures [\#40](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/40) ([JarbasAl](https://github.com/JarbasAl)) 232 | 233 | ## [V0.1.5](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.1.5) (2022-10-10) 234 | 235 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.1.5a5...V0.1.5) 236 | 237 | **Closed issues:** 238 | 239 | - ModuleNotFoundError: No module named 'selene\_api' [\#38](https://github.com/OpenVoiceOS/ovos-personal-backend/issues/38) 240 | 241 | ## [V0.1.5a5](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.1.5a5) (2022-10-10) 242 | 243 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.1.5a4...V0.1.5a5) 244 | 245 | **Fixed bugs:** 246 | 247 | - fix/import [\#39](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/39) ([JarbasAl](https://github.com/JarbasAl)) 248 | 249 | ## [V0.1.5a4](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.1.5a4) (2022-10-04) 250 | 251 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.1.5a3...V0.1.5a4) 252 | 253 | **Implemented enhancements:** 254 | 255 | - implement oauth endpoints [\#23](https://github.com/OpenVoiceOS/ovos-personal-backend/issues/23) 256 | - refactor/selene\_api -\> ovos-backend-client [\#35](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/35) ([JarbasAl](https://github.com/JarbasAl)) 257 | 258 | ## [V0.1.5a3](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.1.5a3) (2022-10-04) 259 | 260 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.1.5a2...V0.1.5a3) 261 | 262 | **Implemented enhancements:** 263 | 264 | - feat/oauth [\#36](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/36) ([JarbasAl](https://github.com/JarbasAl)) 265 | 266 | ## [V0.1.5a2](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.1.5a2) (2022-10-03) 267 | 268 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.1.5a1...V0.1.5a2) 269 | 270 | **Implemented enhancements:** 271 | 272 | - feat/ww\_tags [\#37](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/37) ([JarbasAl](https://github.com/JarbasAl)) 273 | 274 | ## [V0.1.5a1](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.1.5a1) (2022-09-23) 275 | 276 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.1.4...V0.1.5a1) 277 | 278 | ## [V0.1.4](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.1.4) (2022-09-23) 279 | 280 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.1.2a15...V0.1.4) 281 | 282 | **Implemented enhancements:** 283 | 284 | - feat/geolocation\_providers [\#34](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/34) ([JarbasAl](https://github.com/JarbasAl)) 285 | 286 | ## [V0.1.2a15](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.1.2a15) (2022-09-21) 287 | 288 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.1.2a14...V0.1.2a15) 289 | 290 | **Fixed bugs:** 291 | 292 | - Refactor/3rdparty [\#33](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/33) ([JarbasAl](https://github.com/JarbasAl)) 293 | 294 | ## [V0.1.2a14](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.1.2a14) (2022-09-19) 295 | 296 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.1.2a13...V0.1.2a14) 297 | 298 | ## [V0.1.2a13](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.1.2a13) (2022-09-18) 299 | 300 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.1.2a12...V0.1.2a13) 301 | 302 | **Implemented enhancements:** 303 | 304 | - Feat/tts ww options from cfg [\#32](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/32) ([JarbasAl](https://github.com/JarbasAl)) 305 | 306 | ## [V0.1.2a12](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.1.2a12) (2022-09-18) 307 | 308 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.1.2a11...V0.1.2a12) 309 | 310 | **Implemented enhancements:** 311 | 312 | - feat/ tts + ww config backend side [\#31](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/31) ([JarbasAl](https://github.com/JarbasAl)) 313 | 314 | ## [V0.1.2a11](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.1.2a11) (2022-09-10) 315 | 316 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.1.2a10...V0.1.2a11) 317 | 318 | **Implemented enhancements:** 319 | 320 | - selene proxy [\#20](https://github.com/OpenVoiceOS/ovos-personal-backend/issues/20) 321 | - Feat/precise upload v2 [\#30](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/30) ([JarbasAl](https://github.com/JarbasAl)) 322 | 323 | ## [V0.1.2a10](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.1.2a10) (2022-09-09) 324 | 325 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.1.2a9...V0.1.2a10) 326 | 327 | **Implemented enhancements:** 328 | 329 | - support ovos backend services [\#19](https://github.com/OpenVoiceOS/ovos-personal-backend/issues/19) 330 | - feat/selene\_callout\_proxy [\#29](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/29) ([JarbasAl](https://github.com/JarbasAl)) 331 | 332 | ## [V0.1.2a9](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.1.2a9) (2022-09-07) 333 | 334 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.1.2a8...V0.1.2a9) 335 | 336 | **Implemented enhancements:** 337 | 338 | - initial integration with ovos api [\#26](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/26) ([JarbasAl](https://github.com/JarbasAl)) 339 | 340 | ## [V0.1.2a8](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.1.2a8) (2022-09-07) 341 | 342 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.1.2a7...V0.1.2a8) 343 | 344 | **Fixed bugs:** 345 | 346 | - explicitly use https for wolfram alpha api [\#28](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/28) ([JarbasAl](https://github.com/JarbasAl)) 347 | 348 | ## [V0.1.2a7](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.1.2a7) (2022-09-07) 349 | 350 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.1.2a6...V0.1.2a7) 351 | 352 | **Implemented enhancements:** 353 | 354 | - skip\_auth flag [\#27](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/27) ([JarbasAl](https://github.com/JarbasAl)) 355 | 356 | ## [V0.1.2a6](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.1.2a6) (2022-09-07) 357 | 358 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.1.2a5...V0.1.2a6) 359 | 360 | **Implemented enhancements:** 361 | 362 | - implement skill settings endpoints [\#21](https://github.com/OpenVoiceOS/ovos-personal-backend/issues/21) 363 | - Feat/shared settings [\#25](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/25) ([JarbasAl](https://github.com/JarbasAl)) 364 | 365 | ## [V0.1.2a5](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.1.2a5) (2022-08-25) 366 | 367 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.1.2a4...V0.1.2a5) 368 | 369 | **Implemented enhancements:** 370 | 371 | - feat/device\_db [\#22](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/22) ([JarbasAl](https://github.com/JarbasAl)) 372 | 373 | **Fixed bugs:** 374 | 375 | - Check for outdated config file and insert new keys [\#7](https://github.com/OpenVoiceOS/ovos-personal-backend/issues/7) 376 | 377 | ## [V0.1.2a4](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.1.2a4) (2022-04-30) 378 | 379 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.1.2a2...V0.1.2a4) 380 | 381 | **Implemented enhancements:** 382 | 383 | - cleanup + docker [\#18](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/18) ([JarbasAl](https://github.com/JarbasAl)) 384 | 385 | **Closed issues:** 386 | 387 | - Run without STT or Wake Word Detection? [\#15](https://github.com/OpenVoiceOS/ovos-personal-backend/issues/15) 388 | 389 | **Merged pull requests:** 390 | 391 | - notify matrix chat on PR merged [\#17](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/17) ([JarbasAl](https://github.com/JarbasAl)) 392 | 393 | ## [V0.1.2a2](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.1.2a2) (2022-02-25) 394 | 395 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.1.2a1...V0.1.2a2) 396 | 397 | **Merged pull requests:** 398 | 399 | - Feat/release workflow [\#14](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/14) ([JarbasAl](https://github.com/JarbasAl)) 400 | 401 | ## [V0.1.2a1](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.1.2a1) (2022-02-24) 402 | 403 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.1.2...V0.1.2a1) 404 | 405 | ## [V0.1.2](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.1.2) (2022-02-24) 406 | 407 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.1.3a0...V0.1.2) 408 | 409 | ## [V0.1.3a0](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.1.3a0) (2022-02-24) 410 | 411 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/V0.1.2a3...V0.1.3a0) 412 | 413 | ## [V0.1.2a3](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/V0.1.2a3) (2022-02-24) 414 | 415 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/v0.1.0...V0.1.2a3) 416 | 417 | **Implemented enhancements:** 418 | 419 | - migrate STT to ovos plugin manager [\#6](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/6) ([JarbasAl](https://github.com/JarbasAl)) 420 | 421 | **Fixed bugs:** 422 | 423 | - Installation fails at setup.py timezonefinder [\#4](https://github.com/OpenVoiceOS/ovos-personal-backend/issues/4) 424 | - add owm onecall api endpoint, so weather works again! [\#5](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/5) ([JarbasAl](https://github.com/JarbasAl)) 425 | 426 | **Merged pull requests:** 427 | 428 | - Feat/release workflow [\#13](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/13) ([NeonJarbas](https://github.com/NeonJarbas)) 429 | - bump versions and add publishing workflows [\#12](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/12) ([NeonJarbas](https://github.com/NeonJarbas)) 430 | - refactor/bump\_requests [\#9](https://github.com/OpenVoiceOS/ovos-personal-backend/pull/9) ([JarbasAl](https://github.com/JarbasAl)) 431 | 432 | ## [v0.1.0](https://github.com/OpenVoiceOS/ovos-personal-backend/tree/v0.1.0) (2021-01-09) 433 | 434 | [Full Changelog](https://github.com/OpenVoiceOS/ovos-personal-backend/compare/014389065d3e5c66b6cb85e6e77359b6705406fe...v0.1.0) 435 | 436 | **Fixed bugs:** 437 | 438 | - Backend will not start [\#2](https://github.com/OpenVoiceOS/ovos-personal-backend/issues/2) 439 | - Gui: Could not conect: conection Refused [\#1](https://github.com/OpenVoiceOS/ovos-personal-backend/issues/1) 440 | 441 | 442 | 443 | \* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)* 444 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:latest 2 | 3 | RUN apt-get update && \ 4 | apt-get install -y git python3 python3-dev python3-pip curl build-essential libffi-dev python3-numpy rustc flac libmysqlclient-dev 5 | 6 | RUN pip3 install SpeechRecognition==3.8.1 7 | 8 | COPY . /tmp/ovos-backend 9 | RUN pip3 install /tmp/ovos-backend[mysql] 10 | 11 | RUN pip3 install git+https://github.com/OpenVoiceOS/ovos-backend-manager 12 | 13 | CMD ./tmp/ovos-backend/scripts/entrypoints.sh 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2022 Casimiro Ferreira 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | 204 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OVOS Personal Backend 2 | 3 | Personal backend for OpenVoiceOS, written in flask 4 | 5 | This allows you to manage multiple devices from a single location 6 | 7 | :warning: there are no user accounts :warning: 8 | 9 | Documentation can be found at https://openvoiceos.github.io/community-docs/personal_backend 10 | 11 | NOTES: 12 | - this backend moved to SQL databases on release 0.2.0, json databases from older version are not compatible 13 | - at the time of writing, backend manager does not yet work with this backend version 14 | - backend-client now includes a CRUD api to interact with databases https://github.com/OpenVoiceOS/ovos-backend-client/pull/30 15 | 16 | 17 | ## Install 18 | 19 | from pip 20 | 21 | ```bash 22 | pip install ovos-local-backend 23 | ``` 24 | 25 | 26 | ## Companion projects 27 | 28 | - [ovos-backend-client](https://github.com/OpenVoiceOS/ovos-backend-client) - reference python library to interact with backend 29 | - [ovos-backend-manager](https://github.com/OpenVoiceOS/ovos-backend-manager) - graphical interface to manage all things backend 30 | - [ovos-stt-plugin-selene](https://github.com/OpenVoiceOS/ovos-stt-plugin-selene) - stt plugin for selene/local backend (DEPRECATED) 31 | 32 | You can use this backend as a STT server proxy via [ovos-stt-plugin-server](https://github.com/OpenVoiceOS/ovos-stt-plugin-server), eg `https://your_backend.org/stt` 33 | 34 | 35 | ## Configuration 36 | 37 | configure backend by editing/creating ```~/.config/ovos_backend/ovos_backend.conf``` 38 | 39 | 40 | ```json 41 | { 42 | "lang": "en-us", 43 | "date_format": "DMY", 44 | "system_unit": "metric", 45 | "time_format": "full", 46 | "location": { 47 | "city": {"...": "..."}, 48 | "coordinate": {"...": "..."}, 49 | "timezone": {"...": "..."} 50 | }, 51 | 52 | "stt_servers": ["https://stt.openvoiceos.org/stt"], 53 | 54 | "server": { 55 | "admin_key": "leave empty to DISABLE admin api", 56 | "port": 6712, 57 | "database": "sqlite:////home/user/.local/share/ovos_backend.db", 58 | "skip_auth": false, 59 | "geolocate": true, 60 | "override_location": false, 61 | "version": "v1" 62 | }, 63 | 64 | "listener": { 65 | "record_utterances": false, 66 | "record_wakewords": false 67 | }, 68 | 69 | "microservices": { 70 | "wolfram_key": "$KEY", 71 | "owm_key": "$KEY", 72 | "email": { 73 | "recipient": "", 74 | "smtp": { 75 | "username": "", 76 | "password": "", 77 | "host": "smtp.mailprovider.com", 78 | "port": 465 79 | } 80 | } 81 | } 82 | 83 | } 84 | ``` 85 | 86 | database can be sqlite or mysql 87 | eg. `mysql+mysqldb://scott:tiger@192.168.0.134/test?ssl_ca=/path/to/ca.pem&ssl_cert=/path/to/client-cert.pem&ssl_key=/path/to/client-key.pem` 88 | 89 | 90 | ## Docker 91 | 92 | There is also a docker container you can use 93 | 94 | ```bash 95 | docker run -p 8086:6712 -d --restart always --name local_backend ghcr.io/openvoiceos/local-backend:dev 96 | ``` 97 | -------------------------------------------------------------------------------- /ovos_local_backend/__init__.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | 3 | from ovos_config.utils import init_module_config 4 | 5 | init_module_config("ovos_local_backend", 6 | "ovos_local_backend", 7 | {"config_filename": "ovos_backend.conf", 8 | "base_folder" :"ovos_backend", 9 | "default_config_path": f"{os.path.dirname(__file__)}/ovos_backend.conf"}) 10 | 11 | from ovos_local_backend.backend import start_backend 12 | -------------------------------------------------------------------------------- /ovos_local_backend/__main__.py: -------------------------------------------------------------------------------- 1 | from ovos_local_backend import start_backend 2 | from ovos_config import Configuration 3 | 4 | 5 | def main(): 6 | import argparse 7 | 8 | parser = argparse.ArgumentParser() 9 | parser.add_argument("--flask-port", help="Mock backend port number", 10 | default=Configuration()["server"].get("port", 6712)) 11 | parser.add_argument("--flask-host", help="Mock backend host", 12 | default="127.0.0.1") 13 | args = parser.parse_args() 14 | start_backend(args.flask_port, args.flask_host) 15 | 16 | 17 | if __name__ == "__main__": 18 | main() 19 | -------------------------------------------------------------------------------- /ovos_local_backend/backend/__init__.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | # 13 | 14 | from flask import Flask 15 | from ovos_config import Configuration 16 | from ovos_local_backend.database import connect_db 17 | 18 | API_VERSION = Configuration()["server"]["version"] 19 | 20 | 21 | def create_app(): 22 | app = Flask(__name__) 23 | 24 | app, db = connect_db(app) 25 | 26 | from ovos_local_backend.utils import nice_json 27 | from ovos_local_backend.backend.decorators import noindex 28 | from ovos_local_backend.backend.auth import get_auth_routes 29 | from ovos_local_backend.backend.device import get_device_routes 30 | from ovos_local_backend.backend.stt import get_stt_routes 31 | from ovos_local_backend.backend.precise import get_precise_routes 32 | from ovos_local_backend.backend.external_apis import get_services_routes 33 | from ovos_local_backend.backend.admin import get_admin_routes 34 | from ovos_local_backend.backend.crud import get_database_crud 35 | 36 | app = get_auth_routes(app) 37 | app = get_device_routes(app) 38 | app = get_stt_routes(app) 39 | app = get_precise_routes(app) 40 | app = get_services_routes(app) 41 | app = get_admin_routes(app) 42 | app = get_database_crud(app) 43 | 44 | @app.route("/", methods=['GET']) 45 | @noindex 46 | def hello(): 47 | return nice_json({ 48 | "message": "Welcome to OpenVoiceOS personal backend", 49 | "donate": "https://openvoiceos.org" 50 | }) 51 | 52 | return app 53 | 54 | 55 | def start_backend(port=Configuration()["server"].get("port", 6712), host="127.0.0.1"): 56 | app = create_app() 57 | app.run(port=port, use_reloader=False, host=host) 58 | return app 59 | 60 | 61 | if __name__ == "__main__": 62 | start_backend() 63 | -------------------------------------------------------------------------------- /ovos_local_backend/backend/admin.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | # 13 | import time 14 | 15 | import flask 16 | 17 | import ovos_local_backend.database as db 18 | from ovos_local_backend.backend import API_VERSION 19 | from ovos_local_backend.backend.decorators import noindex, requires_admin 20 | from ovos_local_backend.utils import generate_code 21 | from ovos_local_backend.utils import nice_json 22 | from ovos_local_backend.utils.geolocate import get_request_location 23 | from ovos_config import LocalConf, USER_CONFIG, Configuration 24 | 25 | 26 | def get_admin_routes(app): 27 | 28 | @app.route("/" + API_VERSION + "/admin/config", methods=['POST', 'GET']) 29 | @requires_admin 30 | @noindex 31 | def update_config(): 32 | if flask.request.method == 'GET': 33 | return nice_json(Configuration()) 34 | cfg = LocalConf(USER_CONFIG) 35 | cfg.merge(flask.request.json["config"]) 36 | cfg.store() 37 | Configuration.reload() 38 | return nice_json(cfg) 39 | 40 | @app.route("/" + API_VERSION + "/admin//pair", methods=['GET']) 41 | @requires_admin 42 | @noindex 43 | def pair_device(uuid): 44 | code = generate_code() 45 | token = f"{code}:{uuid}" 46 | # add device to db 47 | entry = db.get_device(uuid) 48 | if not entry: 49 | location = get_request_location() 50 | db.add_device(uuid, token, location=location) 51 | else: 52 | db.update_device(uuid, token=token) 53 | 54 | device = {"uuid": uuid, 55 | "expires_at": time.time() + 99999999999999, 56 | "accessToken": token, 57 | "refreshToken": token} 58 | return nice_json(device) 59 | 60 | @app.route("/" + API_VERSION + "/admin//device", methods=['PUT']) 61 | @requires_admin 62 | @noindex 63 | def set_device(uuid): 64 | device_data = db.update_device(uuid, **flask.request.json) 65 | return nice_json(device_data) 66 | 67 | @app.route("/" + API_VERSION + "/admin//location", methods=['PUT']) 68 | @requires_admin 69 | @noindex 70 | def set_location(uuid): 71 | device_data = db.update_device(uuid, location=flask.request.json) 72 | return nice_json(device_data) 73 | 74 | @app.route("/" + API_VERSION + "/admin//prefs", methods=['PUT']) 75 | @requires_admin 76 | @noindex 77 | def set_prefs(uuid): 78 | device_data = db.update_device(uuid, **flask.request.json) 79 | return nice_json(device_data) 80 | 81 | return app 82 | -------------------------------------------------------------------------------- /ovos_local_backend/backend/auth.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | # 13 | 14 | import os 15 | import time 16 | 17 | import requests 18 | import flask 19 | from oauthlib.oauth2 import WebApplicationClient 20 | 21 | from ovos_local_backend.backend import API_VERSION 22 | from ovos_local_backend.backend.decorators import noindex, requires_auth 23 | from ovos_local_backend.database import update_oauth_application, add_oauth_token, get_oauth_application, get_oauth_token 24 | 25 | from ovos_local_backend.utils import nice_json 26 | 27 | os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1' 28 | 29 | 30 | def get_auth_routes(app): 31 | @app.route(f"/{API_VERSION}/auth/token", methods=['GET']) 32 | @requires_auth 33 | @noindex 34 | def token(): 35 | """ device is asking for access token, it was created during auto-pairing 36 | we simplify things and use a deterministic access token, shared with pairing token 37 | in selene access token would be refreshed here 38 | """ 39 | token = flask.request.headers.get('Authorization', '').replace("Bearer ", "") 40 | uuid = token.split(":")[-1] 41 | device = {"uuid": uuid, 42 | "expires_at": time.time() + 999999999999999999, 43 | "accessToken": token, 44 | "refreshToken": token} 45 | return nice_json(device) 46 | 47 | @app.route(f"/{API_VERSION}/auth//auth_url", methods=['GET']) 48 | @requires_auth 49 | @noindex 50 | def register_oauth_application(oauth_id): 51 | """ send auth url to user to confirm authorization, 52 | once user opens it callback is triggered 53 | """ 54 | auth = flask.request.headers.get('Authorization', '').replace("Bearer ", "") 55 | uid = auth.split(":")[-1] 56 | if uid != '': 57 | token_id = f"{uid}|{oauth_id}" 58 | else: 59 | token_id = oauth_id 60 | 61 | params = dict(flask.request.args) 62 | callback_endpoint = f"{flask.request.host_url}{API_VERSION}/auth/callback/{token_id}" 63 | client = WebApplicationClient(params["client_id"]) 64 | request_uri = client.prepare_request_uri( 65 | params["auth_endpoint"], 66 | redirect_uri=callback_endpoint, 67 | scope=params["scope"], 68 | ) 69 | 70 | update_oauth_application(token_id=token_id, 71 | client_id=params["client_id"], 72 | client_secret=params["client_secret"], 73 | auth_endpoint=params["auth_endpoint"], 74 | token_endpoint=params["token_endpoint"], 75 | refresh_endpoint=params["refresh_endpoint"], 76 | callback_endpoint=callback_endpoint, 77 | scope=params["scope"]) 78 | 79 | return request_uri, 200 80 | 81 | @app.route(f"/{API_VERSION}/auth/callback/", methods=['GET']) 82 | @noindex 83 | def oauth_callback(token_id): 84 | """ user completed oauth, save token to db 85 | """ 86 | params = dict(flask.request.args) 87 | code = params["code"] 88 | 89 | data = get_oauth_application(token_id) 90 | client_id = data.client_id 91 | client_secret = data.client_secret 92 | token_endpoint = data.token_endpoint 93 | 94 | # Prepare and send a request to get tokens! Yay tokens! 95 | client = WebApplicationClient(client_id) 96 | token_url, headers, body = client.prepare_token_request( 97 | token_endpoint, 98 | authorization_response=flask.request.url, 99 | redirect_url=flask.request.base_url, 100 | code=code 101 | ) 102 | token_response = requests.post( 103 | token_url, 104 | headers=headers, 105 | data=body, 106 | auth=(client_id, client_secret), 107 | ).json() 108 | 109 | add_oauth_token(token_id, token_response) 110 | return nice_json(params) 111 | 112 | @app.route(f"/{API_VERSION}/device//token/", methods=['GET']) 113 | @requires_auth 114 | @noindex 115 | def oauth_token(uuid, oauth_id): 116 | """a device is requesting a token for a previously approved OAuth app""" 117 | token_id = f"@{uuid}|{oauth_id}" 118 | data = get_oauth_token(token_id) 119 | return nice_json(data) 120 | 121 | return app 122 | -------------------------------------------------------------------------------- /ovos_local_backend/backend/crud.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | # 13 | 14 | import base64 15 | 16 | import flask 17 | 18 | import ovos_local_backend.database as db 19 | from ovos_local_backend.backend import API_VERSION 20 | from ovos_local_backend.backend.decorators import noindex, requires_admin 21 | 22 | 23 | def get_database_crud(app): 24 | 25 | # DATABASE - (backend manager uses these) 26 | @app.route("/" + API_VERSION + "/admin//skill_settings", 27 | methods=['POST']) 28 | @requires_admin 29 | @noindex 30 | def create_skill_settings(uuid): 31 | data = flask.request.json 32 | skill_id = data.pop("skill_id") 33 | device = db.get_device(uuid) 34 | if not device: 35 | return {"error": f"unknown uuid: {uuid}"} 36 | if device.isolated_skills: 37 | remote_id = f"@{uuid}|{skill_id}" 38 | else: 39 | remote_id = skill_id 40 | entry = db.add_skill_settings(remote_id, **data) 41 | if not entry: 42 | return {"error": "entry not found"} 43 | return entry.serialize() 44 | 45 | @app.route("/" + API_VERSION + "/admin//skill_settings/list", 46 | methods=['GET']) 47 | @requires_admin 48 | @noindex 49 | def list_skill_settings(uuid): 50 | entries = db.get_skill_settings_for_device(uuid) 51 | return [e.serialize() for e in entries] 52 | 53 | @app.route("/" + API_VERSION + "/admin//skill_settings/", 54 | methods=['GET', "PUT", "DELETE"]) 55 | @requires_admin 56 | @noindex 57 | def get_skill_settings(uuid, skill_id): 58 | device = db.get_device(uuid) 59 | if not device: 60 | return {"error": f"unknown uuid: {uuid}"} 61 | if device.isolated_skills: 62 | remote_id = f"@{uuid}|{skill_id}" 63 | else: 64 | remote_id = skill_id 65 | if flask.request.method == 'DELETE': 66 | success = db.delete_skill_settings(remote_id) 67 | return {"success": success} 68 | elif flask.request.method == 'PUT': 69 | entry = db.update_skill_settings(remote_id, **flask.request.json) 70 | else: # GET 71 | entry = db.get_skill_settings(remote_id) 72 | if not entry: 73 | return {"error": "entry not found"} 74 | return entry.serialize() 75 | 76 | @app.route("/" + API_VERSION + "/admin/skill_settings", 77 | methods=['POST']) 78 | @requires_admin 79 | @noindex 80 | def create_shared_skill_settings(): 81 | data = flask.request.json 82 | skill_id = data.pop("skill_id") 83 | entry = db.add_skill_settings(skill_id, **data) 84 | if not entry: 85 | return {"error": "entry not found"} 86 | return entry.serialize() 87 | 88 | @app.route("/" + API_VERSION + "/admin/skill_settings/list", 89 | methods=['GET']) 90 | @requires_admin 91 | @noindex 92 | def list_shared_skill_settings(): 93 | entries = db.list_skill_settings() 94 | return [e.serialize() for e in entries] 95 | 96 | @app.route("/" + API_VERSION + "/admin/skill_settings/", 97 | methods=['GET', "PUT", "DELETE"]) 98 | @requires_admin 99 | @noindex 100 | def get_shared_skill_settings(skill_id): 101 | if flask.request.method == 'DELETE': 102 | success = db.delete_skill_settings(skill_id) 103 | return {"success": success} 104 | elif flask.request.method == 'PUT': 105 | entry = db.update_skill_settings(skill_id, **flask.request.json) 106 | else: # GET 107 | entry = db.get_skill_settings(skill_id) 108 | if not entry: 109 | return {"error": "entry not found"} 110 | return entry.serialize() 111 | 112 | @app.route("/" + API_VERSION + "/admin/oauth_apps", 113 | methods=['POST']) 114 | @requires_admin 115 | @noindex 116 | def create_oauth_app(): 117 | entry = db.add_oauth_application(**flask.request.json) 118 | if not entry: 119 | return {"error": "entry not found"} 120 | return entry.serialize() 121 | 122 | @app.route("/" + API_VERSION + "/admin/oauth_apps/list", 123 | methods=['GET']) 124 | @requires_admin 125 | @noindex 126 | def list_oauth_apps(): 127 | entries = db.list_oauth_applications() 128 | return [e.serialize() for e in entries] 129 | 130 | @app.route("/" + API_VERSION + "/admin/oauth_apps/", 131 | methods=['GET', "PUT", "DELETE"]) 132 | @requires_admin 133 | @noindex 134 | def get_oauth_apps(token_id): 135 | if flask.request.method == 'DELETE': 136 | success = db.delete_oauth_application(token_id) 137 | return {"success": success} 138 | elif flask.request.method == 'PUT': 139 | entry = db.update_oauth_application(token_id, **flask.request.json) 140 | else: # GET 141 | entry = db.get_oauth_application(token_id) 142 | if not entry: 143 | return {"error": "entry not found"} 144 | return entry.serialize() 145 | 146 | @app.route("/" + API_VERSION + "/admin/oauth_toks", 147 | methods=['POST']) 148 | @requires_admin 149 | @noindex 150 | def create_oauth_toks(): 151 | entry = db.add_oauth_token(**flask.request.json) 152 | if not entry: 153 | return {"error": "entry not found"} 154 | return entry.serialize() 155 | 156 | @app.route("/" + API_VERSION + "/admin/oauth_toks/list", 157 | methods=['GET']) 158 | @requires_admin 159 | @noindex 160 | def list_oauth_toks(): 161 | entries = db.list_oauth_tokens() 162 | return [e.serialize() for e in entries] 163 | 164 | @app.route("/" + API_VERSION + "/admin/oauth_toks/", 165 | methods=['GET', "PUT", "DELETE"]) 166 | @requires_admin 167 | @noindex 168 | def get_oauth_toks(token_id): 169 | if flask.request.method == 'DELETE': 170 | success = db.delete_oauth_token(token_id) 171 | return {"success": success} 172 | elif flask.request.method == 'PUT': 173 | entry = db.update_oauth_token(token_id, **flask.request.json) 174 | else: # GET 175 | entry = db.get_oauth_token(token_id) 176 | if not entry: 177 | return {"error": "entry not found"} 178 | return entry.serialize() 179 | 180 | @app.route("/" + API_VERSION + "/admin/voice_recs/", 181 | methods=['POST']) 182 | @requires_admin 183 | @noindex 184 | def create_voice_rec(uuid): 185 | # b64 decode bytes before saving 186 | data = flask.request.json 187 | audio_b64 = data.pop("audio_b64") 188 | data["byte_data"] = base64.b64decode(audio_b64) 189 | entry = db.add_stt_recording(uuid, **data) 190 | if not entry: 191 | return {"error": "entry not found"} 192 | return entry.serialize() 193 | 194 | @app.route("/" + API_VERSION + "/admin/voice_recs/list", 195 | methods=['GET']) 196 | @requires_admin 197 | @noindex 198 | def list_voice_recs(): 199 | entries = db.list_stt_recordings() 200 | return [e.serialize() for e in entries] 201 | 202 | @app.route("/" + API_VERSION + "/admin/voice_recs/", 203 | methods=['GET', "PUT", "DELETE"]) 204 | @requires_admin 205 | @noindex 206 | def get_voice_rec(recording_id): 207 | # rec_id = f"@{uuid}|{transcription}|{count}" 208 | if flask.request.method == 'DELETE': 209 | success = db.delete_stt_recording(recording_id) 210 | return {"success": success} 211 | elif flask.request.method == 'PUT': 212 | entry = db.update_stt_recording(recording_id, **flask.request.json) 213 | else: # GET 214 | entry = db.get_stt_recording(recording_id) 215 | if not entry: 216 | return {"error": "entry not found"} 217 | return entry.serialize() 218 | 219 | @app.route("/" + API_VERSION + "/admin/ww_recs/", 220 | methods=['POST']) 221 | @requires_admin 222 | @noindex 223 | def create_ww_rec(uuid): 224 | # b64 decode bytes before saving 225 | data = flask.request.json 226 | audio_b64 = data.pop("audio_b64") 227 | data["byte_data"] = base64.b64decode(audio_b64) 228 | entry = db.add_ww_recording(uuid, **data) 229 | if not entry: 230 | return {"error": "entry not found"} 231 | return entry.serialize() 232 | 233 | @app.route("/" + API_VERSION + "/admin/ww_recs/list", 234 | methods=['GET']) 235 | @requires_admin 236 | @noindex 237 | def list_ww_recs(): 238 | entries = db.list_ww_recordings() 239 | return [e.serialize() for e in entries] 240 | 241 | @app.route("/" + API_VERSION + "/admin/ww_recs/", 242 | methods=['GET', "PUT", "DELETE"]) 243 | @requires_admin 244 | @noindex 245 | def get_ww_rec(recording_id): 246 | # rec_id = f"@{uuid}|{transcription}|{count}" 247 | if flask.request.method == 'DELETE': 248 | success = db.delete_ww_recording(recording_id) 249 | return {"success": success} 250 | elif flask.request.method == 'PUT': 251 | entry = db.update_ww_recording(recording_id, **flask.request.json) 252 | else: # GET 253 | entry = db.get_ww_recording(recording_id) 254 | if not entry: 255 | return {"error": "entry not found"} 256 | return entry.serialize() 257 | 258 | @app.route("/" + API_VERSION + "/admin/metrics/", 259 | methods=['POST']) 260 | @requires_admin 261 | @noindex 262 | def create_metric(uuid): 263 | entry = db.add_metric(uuid, **flask.request.json) 264 | if not entry: 265 | return {"error": "entry not found"} 266 | return entry.serialize() 267 | 268 | @app.route("/" + API_VERSION + "/admin/metrics/list", 269 | methods=['GET']) 270 | @requires_admin 271 | @noindex 272 | def list_metrics(): 273 | entries = db.list_metrics() 274 | return [e.serialize() for e in entries] 275 | 276 | @app.route("/" + API_VERSION + "/admin/metrics/", 277 | methods=['GET', "PUT", "DELETE"]) 278 | @requires_admin 279 | @noindex 280 | def get_metric(metric_id): 281 | # metric_id = f"@{uuid}|{name}|{count}" 282 | if flask.request.method == 'DELETE': 283 | success = db.delete_metric(metric_id) 284 | return {"success": success} 285 | elif flask.request.method == 'PUT': 286 | entry = db.update_metric(metric_id, flask.request.json) 287 | else: # GET 288 | entry = db.get_metric(metric_id) 289 | if not entry: 290 | return {"error": "entry not found"} 291 | return entry.serialize() 292 | 293 | @app.route("/" + API_VERSION + "/admin/devices", 294 | methods=['POST']) 295 | @requires_admin 296 | @noindex 297 | def create_device(): 298 | kwargs = flask.request.json 299 | uuid = kwargs.pop("uuid") 300 | entry = db.get_device(uuid) 301 | if entry: 302 | entry = db.update_device(uuid, **kwargs) 303 | return entry.serialize() 304 | 305 | token = kwargs.pop("token") 306 | entry = db.add_device(uuid, token, **kwargs) 307 | if not entry: 308 | return {"error": "entry not found"} 309 | return entry.serialize() 310 | 311 | @app.route("/" + API_VERSION + "/admin/devices/list", 312 | methods=['GET']) 313 | @requires_admin 314 | @noindex 315 | def list_devices(): 316 | entries = db.list_devices() 317 | return [e.serialize() for e in entries] 318 | 319 | @app.route("/" + API_VERSION + "/admin/devices/", 320 | methods=['GET', "PUT", "DELETE"]) 321 | @requires_admin 322 | @noindex 323 | def get_device(uuid): 324 | if flask.request.method == 'DELETE': 325 | success = db.delete_device(uuid) 326 | return {"success": success} 327 | elif flask.request.method == 'PUT': 328 | entry = db.update_device(uuid, **flask.request.json) 329 | else: # GET 330 | entry = db.get_device(uuid) 331 | if not entry: 332 | return {"error": "entry not found"} 333 | return entry.serialize() 334 | 335 | @app.route("/" + API_VERSION + "/admin/voice_defs", 336 | methods=['POST']) 337 | @requires_admin 338 | @noindex 339 | def create_voice_defs(): 340 | kwargs = flask.request.json 341 | plugin = kwargs.pop("plugin") 342 | lang = kwargs.pop("lang") 343 | tts_config = kwargs.pop("tts_config") 344 | entry = db.add_voice_definition(plugin, lang, tts_config, **kwargs) 345 | if not entry: 346 | return {"error": "entry not found"} 347 | return entry.serialize() 348 | 349 | @app.route("/" + API_VERSION + "/admin/voice_defs/list", 350 | methods=['GET']) 351 | @requires_admin 352 | @noindex 353 | def list_voice_defs(): 354 | entries = db.list_voice_definitions() 355 | return [e.serialize() for e in entries] 356 | 357 | @app.route("/" + API_VERSION + "/admin/voice_defs/", 358 | methods=['GET', "PUT", "DELETE"]) 359 | @requires_admin 360 | @noindex 361 | def get_voice_def(voice_id): 362 | if flask.request.method == 'DELETE': 363 | success = db.delete_voice_definition(voice_id) 364 | return {"success": success} 365 | elif flask.request.method == 'PUT': 366 | entry = db.update_voice_definition(voice_id, **flask.request.json) 367 | else: # GET 368 | entry = db.get_voice_definition(voice_id) 369 | if not entry: 370 | return {"error": "entry not found"} 371 | return entry.serialize() 372 | 373 | @app.route("/" + API_VERSION + "/admin/ww_defs", 374 | methods=['POST']) 375 | @requires_admin 376 | @noindex 377 | def create_ww_def(): 378 | entry = db.add_wakeword_definition(**flask.request.json) 379 | if not entry: 380 | return {"error": "entry not found"} 381 | return entry.serialize() 382 | 383 | @app.route("/" + API_VERSION + "/admin/ww_defs/list", 384 | methods=['GET']) 385 | @requires_admin 386 | @noindex 387 | def list_ww_defs(): 388 | entries = db.list_wakeword_definition() 389 | return [e.serialize() for e in entries] 390 | 391 | @app.route("/" + API_VERSION + "/admin/ww_defs/", 392 | methods=['GET', "PUT", "DELETE"]) 393 | @requires_admin 394 | @noindex 395 | def get_ww_def(ww_id): 396 | if flask.request.method == 'DELETE': 397 | success = db.delete_wakeword_definition(ww_id) 398 | return {"success": success} 399 | elif flask.request.method == 'PUT': 400 | entry = db.update_wakeword_definition(ww_id, **flask.request.json) 401 | else: # GET 402 | entry = db.get_wakeword_definition(ww_id) 403 | if not entry: 404 | return {"error": "entry not found"} 405 | return entry.serialize() 406 | 407 | return app 408 | -------------------------------------------------------------------------------- /ovos_local_backend/backend/decorators.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | # 13 | from functools import wraps 14 | from flask import Response 15 | import flask 16 | from ovos_config import Configuration 17 | 18 | 19 | def check_auth(uid, token): 20 | """This function is called to check if a access token is valid.""" 21 | from ovos_local_backend.database import get_device 22 | 23 | device = get_device(uid) 24 | if device and device.token == token: 25 | return True 26 | return False 27 | 28 | 29 | def requires_opt_in(f): 30 | @wraps(f) 31 | def decorated(*args, **kwargs): 32 | from ovos_local_backend.database import get_device 33 | 34 | auth = flask.request.headers.get('Authorization', '').replace("Bearer ", "") 35 | uuid = kwargs.get("uuid") or auth.split(":")[-1] # this split is only valid here, not selene 36 | device = get_device(uuid) 37 | if device and device.opt_in: 38 | return f(*args, **kwargs) 39 | 40 | return decorated 41 | 42 | 43 | def requires_auth(f): 44 | @wraps(f) 45 | def decorated(*args, **kwargs): 46 | # skip_auth option is usually unsafe 47 | # use cases such as docker or ovos-qubes can not share a identity file between devices 48 | if not Configuration()["server"].get("skip_auth"): 49 | auth = flask.request.headers.get('Authorization', '').replace("Bearer ", "") 50 | uuid = kwargs.get("uuid") or auth.split(":")[-1] # this split is only valid here, not selene 51 | if not auth or not uuid or not check_auth(uuid, auth): 52 | return Response( 53 | 'Could not verify your access level for that URL.\n' 54 | 'You have to authenticate with proper credentials', 401, 55 | {'WWW-Authenticate': 'Basic realm="NOT PAIRED"'}) 56 | return f(*args, **kwargs) 57 | 58 | return decorated 59 | 60 | 61 | def requires_admin(f): 62 | @wraps(f) 63 | def decorated(*args, **kwargs): 64 | admin_key = Configuration()["server"].get("admin_key") 65 | auth = flask.request.headers.get('Authorization', '').replace("Bearer ", "") 66 | if not auth or not admin_key or auth != admin_key: 67 | return Response( 68 | 'Could not verify your access level for that URL.\n' 69 | 'You have to authenticate with proper credentials', 401, 70 | {'WWW-Authenticate': 'Basic realm="NOT ADMIN"'}) 71 | return f(*args, **kwargs) 72 | 73 | return decorated 74 | 75 | 76 | def add_response_headers(headers=None): 77 | """This decorator adds the headers passed in to the response""" 78 | headers = headers or {} 79 | 80 | def decorator(f): 81 | @wraps(f) 82 | def decorated_function(*args, **kwargs): 83 | resp = flask.make_response(f(*args, **kwargs)) 84 | h = resp.headers 85 | for header, value in headers.items(): 86 | h[header] = value 87 | return resp 88 | 89 | return decorated_function 90 | 91 | return decorator 92 | 93 | 94 | def noindex(f): 95 | """This decorator passes X-Robots-Tag: noindex""" 96 | return add_response_headers({'X-Robots-Tag': 'noindex'})(f) 97 | -------------------------------------------------------------------------------- /ovos_local_backend/backend/device.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | # 13 | import time 14 | 15 | import flask 16 | 17 | import ovos_local_backend.database as db 18 | from ovos_local_backend.backend import API_VERSION 19 | from ovos_local_backend.backend.decorators import noindex, requires_auth, requires_opt_in 20 | from ovos_local_backend.database import SkillSettings 21 | from ovos_local_backend.utils import generate_code, nice_json 22 | from ovos_local_backend.utils.geolocate import get_request_location 23 | from ovos_local_backend.utils.mail import send_email 24 | from ovos_config import Configuration 25 | 26 | 27 | @requires_opt_in 28 | def save_metric(uuid, name, data): 29 | db.add_metric(uuid, name, data) 30 | 31 | 32 | def get_device_routes(app): 33 | @app.route(f"/{API_VERSION}/device//settingsMeta", methods=['PUT']) 34 | @requires_auth 35 | def settingsmeta(uuid): 36 | """ new style skill settings meta (upload only) """ 37 | s = SkillSettings.deserialize(flask.request.json) 38 | # ignore s.settings on purpose 39 | db.update_skill_settings(s.remote_id, 40 | metadata_json=s.meta, 41 | display_name=s.display_name) 42 | return nice_json({"success": True, "uuid": uuid}) 43 | 44 | @app.route(f"/{API_VERSION}/device//skill/settings", methods=['GET']) 45 | @requires_auth 46 | def skill_settings_v2(uuid): 47 | """ new style skill settings (download only)""" 48 | return {s.skill_id: s.settings for s in db.get_skill_settings_for_device(uuid)} 49 | 50 | @app.route(f"/{API_VERSION}/device//skill", methods=['GET', 'PUT']) 51 | @requires_auth 52 | def skill_settings(uuid): 53 | """ old style skill settings/settingsmeta - supports 2 way sync 54 | PUT - json for 1 skill 55 | GET - list of all skills """ 56 | if flask.request.method == 'PUT': 57 | s = SkillSettings.deserialize(flask.request.json) 58 | db.update_skill_settings(s.remote_id, 59 | settings_json=s.settings, 60 | metadata_json=s.meta, 61 | display_name=s.display_name) 62 | return nice_json({"success": True, "uuid": uuid}) 63 | else: 64 | return nice_json([s.serialize() for s in db.get_skill_settings_for_device(uuid)]) 65 | 66 | @app.route(f"/{API_VERSION}/device//skillJson", methods=['PUT']) 67 | @requires_auth 68 | def skill_json(uuid): 69 | """ device is communicating to the backend what skills are installed 70 | drop the info and don't track it! maybe if we add a UI and it becomes useful...""" 71 | # everything works in skill settings without using this 72 | data = flask.request.json 73 | # {'blacklist': [], 74 | # 'skills': [{'name': 'fallback-unknown', 75 | # 'origin': 'default', 76 | # 'beta': False, 77 | # 'status': 'active', 78 | # 'installed': 0, 79 | # 'updated': 0, 80 | # 'installation': 'installed', 81 | # 'skill_gid': 'fallback-unknown|21.02'}] 82 | return data 83 | 84 | @app.route(f"/{API_VERSION}/device//location", methods=['GET']) 85 | @requires_auth 86 | @noindex 87 | def location(uuid): 88 | device = db.get_device(uuid) 89 | if device: 90 | return device.location_json 91 | return get_request_location() 92 | 93 | @app.route(f"/{API_VERSION}/device//setting", methods=['GET']) 94 | @requires_auth 95 | @noindex 96 | def setting(uuid=""): 97 | device = db.get_device(uuid) 98 | if device: 99 | return device.selene_settings 100 | return {} 101 | 102 | @app.route(f"/{API_VERSION}/device/", methods=['PATCH', 'GET']) 103 | @requires_auth 104 | @noindex 105 | def get_uuid(uuid): 106 | if flask.request.method == 'PATCH': 107 | # drop the info, we do not track it 108 | data = flask.request.json 109 | # {'coreVersion': '21.2.2', 110 | # 'platform': 'unknown', 111 | # 'platform_build': None, 112 | # 'enclosureVersion': None} 113 | return {} 114 | 115 | # get from local db 116 | device = db.get_device(uuid) 117 | if device: 118 | return device.selene_device 119 | 120 | # dummy valid data 121 | token = flask.request.headers.get('Authorization', '').replace("Bearer ", "") 122 | uuid = token.split(":")[-1] 123 | return { 124 | "description": "unknown", 125 | "uuid": uuid, 126 | "name": "unknown", 127 | # not tracked / meaningless 128 | # just for api compliance with selene 129 | 'coreVersion': "unknown", 130 | 'platform': 'unknown', 131 | 'enclosureVersion': "", 132 | "user": {"uuid": uuid} # users not tracked 133 | } 134 | 135 | @app.route(f"/{API_VERSION}/device/code", methods=['GET']) 136 | @noindex 137 | def code(): 138 | """ device is asking for pairing token 139 | we simplify things and use a deterministic access token, same as pairing token created here 140 | """ 141 | uuid = flask.request.args["state"] 142 | code = generate_code() 143 | 144 | # pairing device with backend 145 | token = f"{code}:{uuid}" 146 | result = {"code": code, "uuid": uuid, "token": token, 147 | # selene api compat 148 | "expiration": 99999999999999, "state": uuid} 149 | return nice_json(result) 150 | 151 | @app.route(f"/{API_VERSION}/device/activate", methods=['POST']) 152 | @noindex 153 | def activate(): 154 | """this is where the device checks if pairing was successful in selene 155 | in local backend we pair the device automatically in this step 156 | in selene this would only succeed after user paired via browser 157 | """ 158 | uuid = flask.request.json["state"] 159 | 160 | # we simplify things and use a deterministic access token, shared with pairing token 161 | token = flask.request.json["token"] 162 | 163 | # add device to db 164 | try: 165 | location = get_request_location() 166 | except: 167 | location = Configuration()["location"] 168 | db.add_device(uuid, token, location=location) 169 | 170 | device = {"uuid": uuid, 171 | "expires_at": time.time() + 99999999999999, 172 | "accessToken": token, 173 | "refreshToken": token} 174 | return nice_json(device) 175 | 176 | @app.route(f"/{API_VERSION}/device//message", methods=['PUT']) 177 | @noindex 178 | @requires_auth 179 | def send_mail(uuid=""): 180 | 181 | data = flask.request.json 182 | skill_id = data["sender"] # TODO - auto append to body ? 183 | 184 | target_email = None 185 | device = db.get_device(uuid) 186 | if device: 187 | target_email = device.email 188 | send_email(data["title"], data["body"], target_email) 189 | 190 | @app.route(f"/{API_VERSION}/device//metric/", methods=['POST']) 191 | @noindex 192 | @requires_auth 193 | def metric(uuid="", name=""): 194 | data = flask.request.json 195 | save_metric(uuid, name, data) 196 | return nice_json({"success": True, 197 | "uuid": uuid, 198 | "metric": data, 199 | "upload_data": {"uploaded": False}}) 200 | 201 | @app.route(f"/{API_VERSION}/device//subscription", methods=['GET']) 202 | @noindex 203 | @requires_auth 204 | def subscription_type(uuid=""): 205 | subscription = {"@type": "free"} 206 | return nice_json(subscription) 207 | 208 | @app.route(f"/{API_VERSION}/device//voice", methods=['GET']) 209 | @noindex 210 | @requires_auth 211 | def get_subscriber_voice_url(uuid=""): 212 | arch = flask.request.args["arch"] 213 | return nice_json({"link": "", "arch": arch}) 214 | 215 | return app 216 | -------------------------------------------------------------------------------- /ovos_local_backend/backend/external_apis.py: -------------------------------------------------------------------------------- 1 | import flask 2 | 3 | from ovos_local_backend.backend import API_VERSION 4 | from ovos_local_backend.backend.decorators import noindex, requires_auth 5 | from ovos_config import Configuration 6 | from ovos_local_backend.database import get_device 7 | from ovos_local_backend.utils import dict_to_camel_case, ExternalApiManager 8 | 9 | 10 | def _get_lang(): 11 | auth = flask.request.headers.get('Authorization', '').replace("Bearer ", "") 12 | uid = auth.split(":")[-1] # this split is only valid here, not selene 13 | device = get_device(uid) 14 | if device: 15 | return device.lang 16 | return Configuration().get("lang", "en-us") 17 | 18 | 19 | def _get_units(): 20 | auth = flask.request.headers.get('Authorization', '').replace("Bearer ", "") 21 | uid = auth.split(":")[-1] # this split is only valid here, not selene 22 | device = get_device(uid) 23 | if device: 24 | return device.system_unit 25 | return Configuration().get("system_unit", "metric") 26 | 27 | 28 | def _get_latlon(): 29 | auth = flask.request.headers.get('Authorization', '').replace("Bearer ", "") 30 | uid = auth.split(":")[-1] # this split is only valid here, not selene 31 | device = get_device(uid) 32 | if device: 33 | loc = device.location_json 34 | else: 35 | loc = Configuration()["location"] 36 | lat = loc["coordinate"]["latitude"] 37 | lon = loc["coordinate"]["longitude"] 38 | return lat, lon 39 | 40 | 41 | def get_services_routes(app): 42 | 43 | apis = ExternalApiManager() 44 | @app.route("/" + API_VERSION + '/geolocation', methods=['GET']) 45 | @noindex 46 | @requires_auth 47 | def geolocation(): 48 | address = flask.request.args["location"] 49 | return apis.geolocate(address) 50 | 51 | @app.route("/" + API_VERSION + '/wolframAlphaSpoken', methods=['GET']) 52 | @noindex 53 | @requires_auth 54 | def wolfie_spoken(): 55 | query = flask.request.args.get("input") or flask.request.args.get("i") 56 | units = flask.request.args.get("units") or _get_units() 57 | return apis.wolfram_spoken(query, units) 58 | 59 | @app.route("/" + API_VERSION + '/wolframAlphaSimple', methods=['GET']) 60 | @noindex 61 | @requires_auth 62 | def wolfie_simple(): 63 | query = flask.request.args.get("input") or flask.request.args.get("i") 64 | units = flask.request.args.get("units") or _get_units() 65 | return apis.wolfram_simple(query, units) 66 | 67 | @app.route("/" + API_VERSION + '/wolframAlphaFull', methods=['GET']) 68 | @noindex 69 | @requires_auth 70 | def wolfie_full(): 71 | query = flask.request.args.get("input") or flask.request.args.get("i") 72 | units = flask.request.args.get("units") or _get_units() 73 | return apis.wolfram_full(query, units) 74 | 75 | @app.route("/" + API_VERSION + '/wa', methods=['GET']) 76 | @noindex 77 | @requires_auth 78 | def wolfie_xml(): 79 | """ old deprecated endpoint with XML results """ 80 | query = flask.request.args["i"] 81 | units = flask.request.args.get("units") or _get_units() 82 | return apis.wolfram_xml(query, units) 83 | 84 | @app.route("/" + API_VERSION + '/owm/forecast/daily', methods=['GET']) 85 | @noindex 86 | @requires_auth 87 | def owm_daily_forecast(): 88 | lang = flask.request.args.get("lang") or _get_lang() 89 | units = flask.request.args.get("units") or _get_units() 90 | lat, lon = flask.request.args.get("lat"), flask.request.args.get("lon") 91 | if not lat or not lon: 92 | lat, lon = _get_latlon() 93 | return apis.owm_daily(lat, lon, units, lang) 94 | 95 | @app.route("/" + API_VERSION + '/owm/forecast', methods=['GET']) 96 | @noindex 97 | @requires_auth 98 | def owm_3h_forecast(): 99 | lang = flask.request.args.get("lang") or _get_lang() 100 | units = flask.request.args.get("units") or _get_units() 101 | lat, lon = flask.request.args.get("lat"), flask.request.args.get("lon") 102 | if not lat or not lon: 103 | lat, lon = _get_latlon() 104 | return apis.owm_hourly(lat, lon, units, lang) 105 | 106 | @app.route("/" + API_VERSION + '/owm/weather', methods=['GET']) 107 | @noindex 108 | @requires_auth 109 | def owm(): 110 | lang = flask.request.args.get("lang") or _get_lang() 111 | units = flask.request.args.get("units") or _get_units() 112 | lat, lon = flask.request.args.get("lat"), flask.request.args.get("lon") 113 | if not lat or not lon: 114 | lat, lon = _get_latlon() 115 | return apis.owm_current(lat, lon, units, lang) 116 | 117 | @app.route("/" + API_VERSION + '/owm/onecall', methods=['GET']) 118 | @noindex 119 | @requires_auth 120 | def owm_onecall(): 121 | units = flask.request.args.get("units") or _get_units() 122 | lang = flask.request.args.get("lang") or _get_lang() 123 | lat, lon = flask.request.args.get("lat"), flask.request.args.get("lon") 124 | if not lat or not lon: 125 | lat, lon = _get_latlon() 126 | data = apis.owm_onecall(lat, lon, units, lang) 127 | # Selene converts the keys from snake_case to camelCase 128 | return dict_to_camel_case(data) 129 | 130 | return app 131 | -------------------------------------------------------------------------------- /ovos_local_backend/backend/precise.py: -------------------------------------------------------------------------------- 1 | import flask 2 | import json 3 | 4 | from ovos_local_backend.backend import API_VERSION 5 | from ovos_local_backend.backend.decorators import noindex, requires_auth, requires_opt_in 6 | from ovos_config import Configuration 7 | from ovos_local_backend.database import add_ww_recording 8 | 9 | 10 | @requires_opt_in 11 | def save_ww_recording(uuid, uploads): 12 | meta = {} 13 | audio = None 14 | for ww_file in uploads: 15 | # Werkzeug FileStorage objects 16 | fn = uploads[ww_file].filename 17 | if fn == 'audio': 18 | audio = uploads[ww_file].read() 19 | if fn == 'metadata': 20 | meta = json.load(uploads[ww_file]) 21 | 22 | if not audio: 23 | return False # TODO - some error? just ignore entry for now 24 | 25 | # classic mycroft devices send 26 | # {"name": "hey-mycroft", 27 | # "engine": "0f4df281688583e010c26831abdc2222", 28 | # "time": "1592192357852", 29 | # "sessionId": "7d18e208-05b5-401e-add6-ee23ae821967", 30 | # "accountId": "0", 31 | # "model": "5223842df0cdee5bca3eff8eac1b67fc"} 32 | 33 | add_ww_recording(uuid, 34 | audio, 35 | meta.get("name", "").replace("_", " "), 36 | meta) 37 | return True 38 | 39 | 40 | def get_precise_routes(app): 41 | @app.route('/precise/upload', methods=['POST']) 42 | @noindex 43 | @requires_auth 44 | def precise_upload(): 45 | success = False 46 | allowed = Configuration()["listener"].get("record_wakewords") 47 | if allowed: 48 | auth = flask.request.headers.get('Authorization', '').replace("Bearer ", "") 49 | uuid = auth.split(":")[-1] # this split is only valid here, not selene 50 | success = save_ww_recording(uuid, flask.request.files) 51 | 52 | return {"success": success, 53 | "sent_to_mycroft": False, 54 | "saved": allowed} 55 | 56 | @app.route(f'/{API_VERSION}/device//wake-word-file', methods=['POST']) 57 | @noindex 58 | @requires_auth 59 | def precise_upload_v2(uuid): 60 | success = False 61 | if 'audio' not in flask.request.files: 62 | return "No Audio to upload", 400 63 | allowed = Configuration()["listener"].get("record_wakewords") 64 | 65 | if allowed: 66 | success = save_ww_recording(uuid, flask.request.files) 67 | 68 | return {"success": success, 69 | "sent_to_mycroft": False, 70 | "saved": allowed} 71 | 72 | return app 73 | -------------------------------------------------------------------------------- /ovos_local_backend/backend/stt.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | # 13 | import json 14 | from tempfile import NamedTemporaryFile 15 | 16 | import flask 17 | import requests 18 | from ovos_config import Configuration 19 | from speech_recognition import Recognizer, AudioFile, AudioData 20 | 21 | from ovos_local_backend.backend import API_VERSION 22 | from ovos_local_backend.backend.decorators import noindex, requires_auth, requires_opt_in 23 | from ovos_local_backend.database import add_stt_recording 24 | 25 | 26 | def transcribe(audio: AudioData, lang: str): 27 | urls = Configuration().get("stt_servers") or ["https://stt.openvoiceos.org/stt"] 28 | 29 | for url in urls: 30 | try: 31 | response = requests.post(url, data=audio.get_wav_data(), 32 | headers={"Content-Type": "audio/wav"}, 33 | params={"lang": lang}) 34 | if response: 35 | return response.text 36 | except: 37 | continue 38 | return "" 39 | 40 | 41 | def bytes2audiodata(data: bytes): 42 | recognizer = Recognizer() 43 | with NamedTemporaryFile() as fp: 44 | fp.write(data) 45 | with AudioFile(fp.name) as source: 46 | audio = recognizer.record(source) 47 | return audio 48 | 49 | 50 | @requires_opt_in # this decorator ensures the uuid opted-in 51 | def save_stt_recording(uuid: str, audio: AudioData, utterance: str): 52 | allowed = Configuration()["listener"].get("record_utterances") or \ 53 | Configuration()["listener"].get("save_utterances") # backwards compat 54 | if allowed: 55 | audio_bytes = audio.get_wav_data() 56 | add_stt_recording(uuid, audio_bytes, utterance) 57 | 58 | 59 | def get_stt_routes(app): 60 | # makes personal backend a valid entry in ovos-stt-plugin-server 61 | # DOES NOT save data 62 | @app.route("/stt", methods=['POST']) 63 | @noindex 64 | def stt_public_server(): 65 | audio_bytes = flask.request.data 66 | lang = str(flask.request.args.get("lang", "en-us")) 67 | audio = bytes2audiodata(audio_bytes) 68 | utterance = transcribe(audio, lang) 69 | return json.dumps([utterance]) 70 | 71 | # DEPRECATED - compat for old selene plugin 72 | # if opt-in saves recordings 73 | @app.route("/" + API_VERSION + "/stt", methods=['POST']) 74 | @noindex 75 | @requires_auth 76 | def stt(): 77 | flac_audio = flask.request.data 78 | lang = str(flask.request.args.get("lang", "en-us")) 79 | audio = bytes2audiodata(flac_audio) 80 | utterance = transcribe(audio, lang) 81 | 82 | auth = flask.request.headers.get('Authorization', '').replace("Bearer ", "") 83 | uuid = auth.split(":")[-1] # this split is only valid here, not selene 84 | save_stt_recording(uuid, audio, utterance) 85 | 86 | return json.dumps([utterance]) 87 | 88 | return app 89 | -------------------------------------------------------------------------------- /ovos_local_backend/database.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | import time 4 | from copy import deepcopy 5 | 6 | from flask_sqlalchemy import SQLAlchemy 7 | from ovos_config import Configuration 8 | from ovos_plugin_manager.tts import get_voice_id 9 | from ovos_plugin_manager.wakewords import get_ww_id 10 | from ovos_utils.xdg_utils import xdg_data_home 11 | from sqlalchemy_json import NestedMutableJson 12 | 13 | # create the extension 14 | db = SQLAlchemy() 15 | 16 | _cfg = Configuration() 17 | _mail_cfg = _cfg["microservices"]["email"] 18 | _loc = _cfg["location"] 19 | _default_voice_id = _cfg["default_values"]["voice_id"] 20 | _default_ww_id = _cfg["default_values"]["ww_id"] 21 | 22 | 23 | def connect_db(app): 24 | # configure the SQLite database, relative to the app instance folder 25 | 26 | # "mysql+mysqldb://scott:tiger@192.168.0.134/test?ssl_ca=/path/to/ca.pem&ssl_cert=/path/to/client-cert.pem&ssl_key=/path/to/client-key.pem" 27 | # "sqlite:///ovos_backend.db" 28 | app.config["SQLALCHEMY_DATABASE_URI"] = _cfg["server"].get("database") or \ 29 | f"sqlite:///{xdg_data_home()}/ovos_backend.db" 30 | print(f"sqlite:///{xdg_data_home()}/ovos_backend.db") 31 | # initialize the app with the extension 32 | db.init_app(app) 33 | 34 | with app.app_context(): 35 | db.create_all() 36 | 37 | return app, db 38 | 39 | 40 | class OAuthToken(db.Model): 41 | # @{uuid}|{oauth_id} -> @{uuid}|spotify 42 | token_id = db.Column(db.String(255), primary_key=True) 43 | data = db.Column(NestedMutableJson, default={}, nullable=False) 44 | 45 | 46 | class OAuthApplication(db.Model): 47 | # @{uuid}|{oauth_id} -> @{uuid}|spotify 48 | token_id = db.Column(db.String(255), primary_key=True) 49 | client_id = db.Column(db.String(255), nullable=False) 50 | auth_endpoint = db.Column(db.String(255), nullable=False) 51 | token_endpoint = db.Column(db.String(255), nullable=False) 52 | callback_endpoint = db.Column(db.String(255), nullable=False) 53 | # afaik some oauth implementations dont require these 54 | scope = db.Column(db.String(255), default="") 55 | client_secret = db.Column(db.String(255)) 56 | refresh_endpoint = db.Column(db.String(255)) 57 | # ovos GUI flag 58 | shell_integration = db.Column(db.Boolean, default=True) 59 | 60 | def serialize(self): 61 | return { 62 | "oauth_service": self.oauth_service, 63 | "client_id": self.client_id, 64 | "client_secret": self.client_secret, 65 | "auth_endpoint": self.auth_endpoint, 66 | "token_endpoint": self.token_endpoint, 67 | "refresh_endpoint": self.refresh_endpoint, 68 | "callback_endpoint": self.callback_endpoint, 69 | "scope": self.scope, 70 | "shell_integration": self.shell_integration 71 | } 72 | 73 | 74 | class VoiceDefinition(db.Model): 75 | voice_id = db.Column(db.String(255), primary_key=True) 76 | name = db.Column(db.String(255), nullable=False) 77 | lang = db.Column(db.String(5), default="en-us", nullable=False) 78 | plugin = db.Column(db.String(255), default="", nullable=False) # "module" in mycroft.conf 79 | tts_config = db.Column(NestedMutableJson, default={}, nullable=False) # arbitrary data for mycroft.conf/OPM 80 | offline = db.Column(db.Boolean, default=False, nullable=False) 81 | # optional metadata 82 | gender = db.Column(db.String(255), default="") 83 | 84 | def serialize(self): 85 | return { 86 | "voice_id": self.voice_id, 87 | "lang": self.lang, 88 | "plugin": self.plugin, 89 | "tts_config": self.tts_config, 90 | "offline": self.offline, 91 | "gender": self.gender 92 | } 93 | 94 | 95 | class WakeWordDefinition(db.Model): 96 | ww_id = db.Column(db.String(255), primary_key=True) 97 | name = db.Column(db.String(255), default="", nullable=False) 98 | lang = db.Column(db.String(5), default="en-us", nullable=False) 99 | plugin = db.Column(db.String(255), default="", nullable=False) # "module" in mycroft.conf 100 | ww_config = db.Column(NestedMutableJson, default={}, nullable=False) # arbitrary data for mycroft.conf/OPM 101 | 102 | def serialize(self): 103 | return { 104 | "ww_id": self.ww_id, 105 | "name": self.name, 106 | "lang": self.lang, 107 | "plugin": self.plugin, 108 | "ww_config": self.ww_config 109 | } 110 | 111 | 112 | class Device(db.Model): 113 | uuid = db.Column(db.String(100), primary_key=True) 114 | token = db.Column(db.String(100)) # access token, sent to device during pairing 115 | 116 | # device backend preferences 117 | name = db.Column(db.String(100)) 118 | placement = db.Column(db.String(50), default="somewhere") # indoor location 119 | isolated_skills = db.Column(db.Boolean, default=False) 120 | opt_in = db.Column(db.Boolean, default=False) 121 | # for sending email api, not registering 122 | email = db.Column(db.String(100), default=_mail_cfg.get("recipient") or _mail_cfg.get("smtp", {}).get("username")) 123 | # remote mycroft.conf settings 124 | date_fmt = db.Column(db.String(5), default=_cfg.get("date_format", "DMY")) 125 | time_fmt = db.Column(db.String(5), default=_cfg.get("time_format", "full")) 126 | system_unit = db.Column(db.String(10), default=_cfg.get("system_unit", "metric")) 127 | lang = db.Column(db.String(5), default=_cfg.get("lang", "en-us")) 128 | 129 | # location fields, explicit so we can query them 130 | city = db.Column(db.String(length=50), default=_loc["city"]["name"]) 131 | state = db.Column(db.String(length=50), default=_loc["city"]["state"]["name"]) 132 | state_code = db.Column(db.String(length=10), default=_loc["city"]["state"]["code"]) 133 | country = db.Column(db.String(length=50), default=_loc["city"]["state"]["country"]["name"]) 134 | country_code = db.Column(db.String(length=10), default=_loc["city"]["state"]["country"]["code"]) 135 | latitude = db.Column(db.Float, default=_loc["coordinate"]["latitude"]) 136 | longitude = db.Column(db.Float, default=_loc["coordinate"]["longitude"]) 137 | tz_code = db.Column(db.String(length=25), default=_loc["timezone"]["name"]) 138 | tz_name = db.Column(db.String(length=15), default=_loc["timezone"]["code"]) 139 | # ww settings 140 | voice_id = db.Column(db.String(255), default=_default_voice_id) 141 | ww_id = db.Column(db.String(255), default=_default_ww_id) 142 | 143 | @property 144 | def location_json(self): 145 | return { 146 | "city": { 147 | "name": self.city, 148 | "state": { 149 | "name": self.state, 150 | "code": self.state_code, 151 | "country": { 152 | "name": self.country, 153 | "code": self.country_code 154 | } 155 | } 156 | }, 157 | "coordinate": { 158 | "latitude": self.latitude, 159 | "longitude": self.longitude 160 | }, 161 | "timezone": { 162 | "code": self.tz_code, 163 | "name": self.tz_name 164 | } 165 | } 166 | 167 | @property 168 | def selene_device(self): 169 | return { 170 | "description": self.placement, 171 | "uuid": self.uuid, 172 | "name": self.name, 173 | 174 | # not tracked / meaningless 175 | # just for api compliance with selene 176 | 'coreVersion': "unknown", 177 | 'platform': 'unknown', 178 | 'enclosureVersion': "", 179 | "user": {"uuid": self.uuid} # users not tracked 180 | } 181 | 182 | @property 183 | def selene_settings(self): 184 | # this endpoint corresponds to a mycroft.conf 185 | # location is usually grabbed in a separate endpoint 186 | # in here we return it in case downstream is 187 | # aware of this and wants to save 1 http call 188 | 189 | hotwords = {} 190 | listener = {} 191 | if self.ww_id: 192 | ww: WakeWordDefinition = get_wakeword_definition(self.ww_id) 193 | if ww: 194 | # NOTE - selene returns the full listener config 195 | # this SHOULD NOT be done, since backend has no clue of hardware downstream 196 | # we return only wake word config 197 | hotwords[ww.name] = ww.ww_config 198 | listener["wakeWord"] = ww.name.replace(" ", "_") 199 | 200 | tts_settings = {} 201 | if self.voice_id: 202 | voice: VoiceDefinition = get_voice_definition(self.voice_id) 203 | if voice: 204 | tts_settings = {"module": voice.plugin, 205 | voice.plugin: voice.tts_config} 206 | 207 | return { 208 | "dateFormat": self.date_fmt, 209 | "optIn": self.opt_in, 210 | "systemUnit": self.system_unit, 211 | "timeFormat": self.time_fmt, 212 | "uuid": self.uuid, 213 | "lang": self.lang, 214 | "location": self.location_json, 215 | "listenerSetting": listener, 216 | "hotwordsSetting": hotwords, # not present in selene, parsed correctly by core 217 | 'ttsSettings': tts_settings 218 | } 219 | 220 | @staticmethod 221 | def deserialize(data): 222 | if isinstance(data, str): 223 | data = json.loads(data) 224 | 225 | lang = data.get("lang") or _cfg.get("lang") or "en-us" 226 | 227 | voice_id = data.get("voice_id") 228 | if not voice_id: 229 | tts_module = data.get("default_tts") 230 | tts_config = data.get("default_tts_cfg") or {} 231 | if tts_module: 232 | voice_id = get_voice_id(tts_module, lang, tts_config) 233 | 234 | ww_id = data.get("ww_id") 235 | if not ww_id: 236 | ww_name = data.get("default_ww") 237 | ww_config = data.get("default_ww_cfg") or {} 238 | ww_module = ww_config.get("module") 239 | if ww_module: 240 | ww_id = get_ww_id(ww_module, ww_name, ww_config) 241 | 242 | loc = data.get("location") or _loc 243 | 244 | email = data.get("email") or \ 245 | _mail_cfg.get("recipient") or \ 246 | _mail_cfg.get("smtp", {}).get("username") 247 | 248 | return update_device(uuid=data["uuid"], 249 | token=data["token"], 250 | lang=data.get("lang") or _cfg.get("lang") or "en-us", 251 | placement=data.get("device_location") or "somewhere", 252 | name=data.get("name") or f"Device-{data['uuid']}", 253 | isolated_skills=data.get("isolated_skills", False), 254 | city=loc["city"]["name"], 255 | state=loc["city"]["state"]["name"], 256 | country=loc["city"]["state"]["country"]["name"], 257 | state_code=loc["city"]["state"]["code"], 258 | country_code=loc["city"]["state"]["country"]["code"], 259 | latitude=loc["coordinate"]["latitude"], 260 | longitude=loc["coordinate"]["longitude"], 261 | tz_name=loc["timezone"]["name"], 262 | tz_code=loc["timezone"]["code"], 263 | opt_in=data.get("opt_in"), 264 | system_unit=data.get("system_unit") or _cfg.get("system_unit") or "metric", 265 | date_fmt=data.get("date_format") or _cfg.get("date_format") or "DMY", 266 | time_fmt=data.get("time_format") or _cfg.get("time_format") or "full", 267 | email=email, 268 | ww_id=ww_id, 269 | voice_id=voice_id) 270 | 271 | def serialize(self): 272 | email = self.email or \ 273 | _mail_cfg.get("recipient") or \ 274 | _mail_cfg.get("smtp", {}).get("username") 275 | 276 | return { 277 | "uuid": self.uuid, 278 | "token": self.token, 279 | "isolated_skills": self.isolated_skills, 280 | "opt_in": self.opt_in, 281 | "name": self.name or f"Device-{self.uuid}", 282 | "device_location": self.placement or "somewhere", 283 | "email": email, 284 | "time_format": self.time_fmt, 285 | "date_format": self.date_fmt, 286 | "system_unit": self.system_unit, 287 | "lang": self.lang or _cfg.get("lang") or "en-us", 288 | "location": self.location_json, 289 | "voice_id": self.voice_id, 290 | "ww_id": self.ww_id 291 | } 292 | 293 | 294 | class SkillSettings(db.Model): 295 | remote_id = db.Column(db.String(255), 296 | primary_key=True) # depends on Device.isolated_skills, @{uuid}|{skill_id} or {skill_id} 297 | display_name = db.Column(db.String(255)) # for friendly UI, default to skill_id 298 | settings = db.Column(NestedMutableJson, nullable=False, default="{}") # actual skill settings file 299 | meta = db.Column(NestedMutableJson, nullable=False, default="{}") # how to display user facing settings editor 300 | 301 | @property 302 | def skill_id(self): 303 | return self.remote_id.split("|", 1)[-1] 304 | 305 | def serialize(self): 306 | # settings meta with updated placeholder values from settings 307 | # old style selene db stored skill settings this way 308 | meta = deepcopy(self.meta) 309 | for idx, section in enumerate(meta.get('sections', [])): 310 | for idx2, field in enumerate(section["fields"]): 311 | if "value" not in field: 312 | continue 313 | if field["name"] in self.settings: 314 | meta['sections'][idx]["fields"][idx2]["value"] = \ 315 | self.settings[field["name"]] 316 | return {'skillMetadata': meta, 317 | "skill_gid": self.remote_id, 318 | "display_name": self.display_name} 319 | 320 | @staticmethod 321 | def deserialize(data): 322 | if isinstance(data, str): 323 | data = json.loads(data) 324 | 325 | skill_json = {} 326 | skill_meta = data.get("skillMetadata") or {} 327 | for s in skill_meta.get("sections", []): 328 | for f in s.get("fields", []): 329 | if "name" in f and "value" in f: 330 | val = f["value"] 331 | if isinstance(val, str): 332 | t = f.get("type", "") 333 | if t == "checkbox": 334 | if val.lower() == "true" or val == "1": 335 | val = True 336 | else: 337 | val = False 338 | elif t == "number": 339 | if val == "False": 340 | val = 0 341 | elif val == "True": 342 | val = 1 343 | else: 344 | val = float(val) 345 | elif val.lower() in ["none", "null", "nan"]: 346 | val = None 347 | elif val == "[]": 348 | val = [] 349 | elif val == "{}": 350 | val = {} 351 | skill_json[f["name"]] = val 352 | 353 | remote_id = data.get("skill_gid") or data.get("identifier") 354 | # this is a mess, possible keys seen by logging data 355 | # - @|XXX 356 | # - @{uuid}|XXX 357 | # - XXX 358 | 359 | # where XXX has been observed to be 360 | # - {skill_id} <- ovos-core 361 | # - {msm_name} <- mycroft-core 362 | # - {mycroft_marketplace_name} <- all default skills 363 | # - {MycroftSkill.name} <- sometimes sent to msm (very uncommon) 364 | # - {skill_id.split(".")[0]} <- fallback msm name 365 | # - XXX|{branch} <- append by msm (?) 366 | # - {whatever we feel like uploading} <- SeleneCloud utils 367 | fields = remote_id.split("|") 368 | skill_id = fields[0] 369 | if len(fields) > 1 and fields[0].startswith("@"): 370 | skill_id = fields[1] 371 | 372 | display_name = data.get("display_name") or \ 373 | skill_id.split(".")[0].replace("-", " ").replace("_", " ").title() 374 | 375 | return update_skill_settings(remote_id, 376 | display_name=display_name, 377 | settings_json=skill_json, 378 | metadata_json=skill_meta) 379 | 380 | 381 | class Metric(db.Model): 382 | # metric_id = f"@{uuid}|{name}|{count}" 383 | metric_id = db.Column(db.String(255), primary_key=True) 384 | metric_type = db.Column(db.String(255), nullable=False) 385 | metadata_json = db.Column(NestedMutableJson, nullable=False) # arbitrary data 386 | timestamp = db.Column(db.Integer) # unix seconds 387 | uuid = db.Column(db.String(255)) 388 | 389 | def serialize(self): 390 | return {"metric_id": self.metric_id, 391 | "metric_type": self.metric_type, 392 | "metadata_json": self.metadata_json, 393 | "uuid": self.uuid, 394 | "timestamp": self.timestamp} 395 | 396 | @staticmethod 397 | def deserialize(data): 398 | return Metric(**data) 399 | 400 | 401 | class UtteranceRecording(db.Model): 402 | # rec_id = f"@{uuid}|{transcription}|{count}" 403 | recording_id = db.Column(db.String(255), primary_key=True) 404 | transcription = db.Column(db.String(255), nullable=False) 405 | metadata_json = db.Column(NestedMutableJson) # arbitrary metadata 406 | sample = db.Column(db.LargeBinary(16777215), nullable=False) # audio data 407 | 408 | timestamp = db.Column(db.Integer, primary_key=True) # unix seconds 409 | uuid = db.Column(db.String(255)) 410 | 411 | def serialize(self): 412 | data = { 413 | "recording_id": self.recording_id, 414 | "transcription": self.transcription, 415 | "metadata_json": self.metadata_json, 416 | "uuid": self.uuid, 417 | "timestamp": self.timestamp, 418 | "audio_b64": base64.encodebytes(self.sample).decode("utf-8") 419 | } 420 | return data 421 | 422 | @staticmethod 423 | def deserialize(data): 424 | b64_data = data.pop("audio_b64") 425 | data["sample"] = base64.decodestring(b64_data) 426 | return UtteranceRecording(**data) 427 | 428 | 429 | class WakeWordRecording(db.Model): 430 | # rec_id = f"@{uuid}|{transcription}|{count}" 431 | recording_id = db.Column(db.String(255), primary_key=True) 432 | transcription = db.Column(db.String(255)) 433 | audio_tag = db.Column(db.String(255)) # "untagged" / "wake_word" / "speech" / "noise" / "silence" 434 | speaker_tag = db.Column(db.String(255)) # "untagged" / "male" / "female" / "children" 435 | metadata_json = db.Column(NestedMutableJson, nullable=False) # arbitrary metadata 436 | sample = db.Column(db.LargeBinary(16777215), nullable=False) # audio data 437 | 438 | timestamp = db.Column(db.Integer, primary_key=True) # unix seconds 439 | uuid = db.Column(db.String(255)) 440 | 441 | def serialize(self): 442 | data = { 443 | "recording_id": self.recording_id, 444 | "transcription": self.transcription, 445 | "audio_tag": self.audio_tag, 446 | "speaker_tag": self.speaker_tag, 447 | "metadata_json": self.metadata_json, 448 | "uuid": self.uuid, 449 | "timestamp": self.timestamp, 450 | "audio_b64": base64.encodebytes(self.sample).decode("utf-8") 451 | } 452 | return data 453 | 454 | @staticmethod 455 | def deserialize(data): 456 | b64_data = data.pop("audio_b64") 457 | data["sample"] = base64.decodestring(b64_data) 458 | return WakeWordRecording(**data) 459 | 460 | 461 | def add_metric(uuid, metric_type, metadata): 462 | count = db.session.query(Metric).count() + 1 463 | metric_id = f"@{uuid}|{metric_type}|{count}" 464 | entry = Metric( 465 | metric_id=metric_id, 466 | metric_type=metric_type, 467 | metadata_json=metadata, 468 | uuid=uuid, 469 | timestamp=time.time() 470 | ) 471 | db.session.add(entry) 472 | db.session.commit() 473 | return entry 474 | 475 | 476 | def get_metric(metric_id): 477 | return Metric.query.filter_by(metric_id=metric_id).first() 478 | 479 | 480 | def delete_metric(metric_id): 481 | entry = get_metric(metric_id) 482 | if not entry: 483 | return False 484 | db.session.delete(entry) 485 | db.session.commit() 486 | return True 487 | 488 | 489 | def update_metric(metric_id, metadata): 490 | metric: Metric = get_metric(metric_id) 491 | if not metric: 492 | uuid, name, count = metric_id.split("|") 493 | uuid = uuid.lstrip("@") 494 | metric = add_metric(uuid, name, metadata) 495 | else: 496 | metric.metadata_json = metadata 497 | db.session.commit() 498 | return metric 499 | 500 | 501 | def list_metrics(): 502 | return Metric.query.all() 503 | 504 | 505 | def add_wakeword_definition(name, lang, ww_config, plugin): 506 | ww_id = get_ww_id(plugin, name, ww_config) 507 | entry = WakeWordDefinition(ww_id=ww_id, lang=lang, name=name, 508 | ww_config=ww_config, plugin=plugin) 509 | db.session.add(entry) 510 | db.session.commit() 511 | return entry 512 | 513 | 514 | def get_wakeword_definition(ww_id): 515 | return WakeWordDefinition.query.filter_by(ww_id=ww_id).first() 516 | 517 | 518 | def delete_wakeword_definition(ww_id): 519 | entry = get_wakeword_definition(ww_id) 520 | if not entry: 521 | return False 522 | db.session.delete(entry) 523 | db.session.commit() 524 | return True 525 | 526 | 527 | def list_wakeword_definition(): 528 | return WakeWordDefinition.query.all() 529 | 530 | 531 | def list_voice_definitions(): 532 | return VoiceDefinition.query.all() 533 | 534 | 535 | def update_wakeword_definition(ww_id, name=None, lang=None, ww_config=None, plugin=None): 536 | ww_def: WakeWordDefinition = get_wakeword_definition(ww_id) 537 | if not ww_def: 538 | ww_def = add_wakeword_definition(ww_id=ww_id, lang=lang, name=name, 539 | ww_config=ww_config, plugin=plugin) 540 | else: 541 | if name: 542 | ww_def.name = name 543 | if plugin: 544 | ww_def.plugin = plugin 545 | if lang: 546 | ww_def.lang = lang 547 | if ww_config: 548 | ww_def.ww_config = ww_config 549 | db.session.commit() 550 | return ww_def 551 | 552 | 553 | def add_device(uuid, token, name=None, device_location="somewhere", opt_in=False, 554 | location=None, lang=None, date_format=None, system_unit=None, 555 | time_format=None, email=None, isolated_skills=False, 556 | ww_id=None, voice_id=None): 557 | lang = lang or _cfg.get("lang") or "en-us" 558 | 559 | email = email or \ 560 | _mail_cfg.get("recipient") or \ 561 | _mail_cfg.get("smtp", {}).get("username") 562 | 563 | loc = location or _loc 564 | entry = Device(uuid=uuid, 565 | token=token, 566 | lang=lang, 567 | placement=device_location, 568 | name=name or f"Device-{uuid}", 569 | isolated_skills=isolated_skills, 570 | city=loc["city"]["name"], 571 | state=loc["city"]["state"]["name"], 572 | country=loc["city"]["state"]["country"]["name"], 573 | state_code=loc["city"]["state"]["code"], 574 | country_code=loc["city"]["state"]["country"]["code"], 575 | latitude=loc["coordinate"]["latitude"], 576 | longitude=loc["coordinate"]["longitude"], 577 | tz_name=loc["timezone"]["name"], 578 | tz_code=loc["timezone"]["code"], 579 | opt_in=opt_in, 580 | system_unit=system_unit or _cfg.get("system_unit") or "metric", 581 | date_fmt=date_format or _cfg.get("date_format") or "DMY", 582 | time_fmt=time_format or _cfg.get("time_format") or "full", 583 | email=email, 584 | ww_id=ww_id, 585 | voice_id=voice_id) 586 | db.session.add(entry) 587 | db.session.commit() 588 | return entry 589 | 590 | 591 | def get_device(uuid) -> Device: 592 | if uuid is None: 593 | return None 594 | return Device.query.filter_by(uuid=uuid).first() 595 | 596 | 597 | def update_device(uuid, **kwargs): 598 | device: Device = Device.query.filter_by(uuid=uuid).first() 599 | if not device: 600 | raise ValueError(f"unknown uuid - {uuid}") 601 | 602 | if "name" in kwargs: 603 | device.name = kwargs["name"] 604 | if "lang" in kwargs: 605 | device.lang = kwargs["lang"] 606 | if "opt_in" in kwargs: 607 | device.opt_in = kwargs["opt_in"] 608 | if "device_location" in kwargs: 609 | device.placement = kwargs["device_location"] 610 | if "placement" in kwargs: 611 | device.placement = kwargs["placement"] 612 | if "email" in kwargs: 613 | device.email = kwargs["email"] 614 | if "isolated_skills" in kwargs: 615 | device.isolated_skills = kwargs["isolated_skills"] 616 | if "location" in kwargs: 617 | loc = kwargs["location"] 618 | if isinstance(loc, str): 619 | loc = json.loads(loc) 620 | if loc: 621 | device.city = loc["city"]["name"] 622 | device.state = loc["city"]["state"]["name"] 623 | device.country = loc["city"]["state"]["country"]["name"] 624 | device.state_code = loc["city"]["state"]["code"] 625 | device.country_code = loc["city"]["state"]["country"]["code"] 626 | device.latitude = loc["coordinate"]["latitude"] 627 | device.longitude = loc["coordinate"]["longitude"] 628 | device.tz_name = loc["timezone"]["name"] 629 | device.tz_code = loc["timezone"]["code"] 630 | if "time_format" in kwargs: 631 | device.time_format = kwargs["time_format"] 632 | if "date_format" in kwargs: 633 | device.date_format = kwargs["date_format"] 634 | if "time_fmt" in kwargs: 635 | device.time_format = kwargs["time_fmt"] 636 | if "date_fmt" in kwargs: 637 | device.date_format = kwargs["date_fmt"] 638 | if "system_unit" in kwargs: 639 | device.system_unit = kwargs["system_unit"] 640 | 641 | if "tts_module" in kwargs: 642 | tts_plug = kwargs["tts_module"] 643 | if "tts_config" in kwargs: 644 | tts_config = kwargs["tts_config"] 645 | elif tts_plug in _cfg["tts"]: 646 | tts_config = _cfg["tts"][tts_plug] 647 | else: 648 | tts_config = {} 649 | voice_id = get_voice_id(tts_plug, device.lang, tts_config) 650 | update_voice_definition(voice_id, 651 | lang=device.lang, 652 | tts_config=tts_config, 653 | plugin=tts_plug) 654 | device.voice_id = voice_id 655 | 656 | if "wake_word" in kwargs: 657 | default_ww = kwargs["wake_word"] 658 | ww_module = kwargs["ww_module"] 659 | if "ww_config" in kwargs: 660 | ww_config = kwargs["ww_config"] 661 | elif default_ww in _cfg["hotwords"]: 662 | ww_config = _cfg["hotwords"][default_ww] 663 | else: 664 | ww_config = {} 665 | ww_id = get_ww_id(ww_module, default_ww, ww_config) 666 | update_wakeword_definition(ww_id, 667 | name=default_ww, 668 | ww_config=ww_config, 669 | plugin=ww_module) 670 | device.ww_id = ww_id 671 | 672 | db.session.commit() 673 | 674 | return device 675 | 676 | 677 | def list_devices(): 678 | return Device.query.all() 679 | 680 | 681 | def delete_device(uuid): 682 | device = get_device(uuid) 683 | if not device: 684 | return False 685 | db.session.delete(device) 686 | db.session.commit() 687 | return True 688 | 689 | 690 | def add_skill_settings(remote_id, display_name=None, 691 | settings_json=None, metadata_json=None): 692 | entry = SkillSettings(remote_id=remote_id, 693 | display_name=display_name, 694 | settings_json=settings_json, 695 | metadata_json=metadata_json) 696 | db.session.add(entry) 697 | db.session.commit() 698 | return entry 699 | 700 | 701 | def list_skill_settings(): 702 | return SkillSettings.query.all() 703 | 704 | 705 | def get_skill_settings(remote_id): 706 | return SkillSettings.query.filter_by(remote_id=remote_id).first() 707 | 708 | 709 | def get_skill_settings_for_device(uuid): 710 | device = get_device(uuid) 711 | if not device or not device.isolated_skills: 712 | return list_skill_settings() 713 | return SkillSettings.query.filter(SkillSettings.remote_id.startswith(f"@{uuid}|")).all() 714 | 715 | 716 | def delete_skill_settings(remote_id): 717 | entry = get_skill_settings(remote_id) 718 | if not entry: 719 | return False 720 | db.session.delete(entry) 721 | db.session.commit() 722 | return True 723 | 724 | 725 | def delete_skill_settings_for_device(uuid): 726 | entry = get_skill_settings_for_device(uuid) 727 | if not entry: 728 | return False 729 | db.session.delete(entry) 730 | db.session.commit() 731 | return True 732 | 733 | 734 | def update_skill_settings(remote_id, display_name=None, 735 | settings_json=None, metadata_json=None): 736 | settings: SkillSettings = get_skill_settings(remote_id) 737 | if not settings: 738 | settings = add_skill_settings(remote_id=remote_id, 739 | display_name=display_name, 740 | settings_json=settings_json, 741 | metadata_json=metadata_json) 742 | 743 | else: 744 | if display_name: 745 | settings.display_name = display_name 746 | if settings_json: 747 | settings.settings = settings_json 748 | if metadata_json: 749 | settings.meta = metadata_json 750 | db.session.commit() 751 | 752 | return settings 753 | 754 | 755 | def add_ww_recording(uuid, byte_data, transcription, meta): 756 | count = db.session.query(WakeWordRecording).count() + 1 757 | rec_id = f"@{uuid}|{transcription}|{count}" 758 | entry = WakeWordRecording( 759 | recording_id=rec_id, 760 | transcription=transcription, 761 | sample=byte_data, 762 | metadata_json=meta, 763 | uuid=uuid, 764 | timestamp=time.time() 765 | ) 766 | db.session.add(entry) 767 | db.session.commit() 768 | return entry 769 | 770 | 771 | def update_ww_recording(rec_id, transcription, metadata): 772 | entry = get_ww_recording(rec_id) 773 | if entry: 774 | if transcription: 775 | entry.transcription = transcription 776 | if metadata: 777 | entry.metadata_json = metadata 778 | db.session.commit() 779 | return entry 780 | 781 | 782 | def get_ww_recording(rec_id): 783 | return WakeWordRecording.query.filter_by(recording_id=rec_id).first() 784 | 785 | 786 | def delete_ww_recording(rec_id): 787 | entry = get_ww_recording(rec_id) 788 | if not entry: 789 | return False 790 | db.session.delete(entry) 791 | db.session.commit() 792 | return True 793 | 794 | 795 | def list_ww_recordings(): 796 | return WakeWordRecording.query.all() 797 | 798 | 799 | def add_stt_recording(uuid, byte_data, transcription, metadata=None): 800 | count = db.session.query(UtteranceRecording).count() + 1 801 | rec_id = f"@{uuid}|{transcription}|{count}" 802 | entry = UtteranceRecording( 803 | recording_id=rec_id, 804 | transcription=transcription, 805 | sample=byte_data, 806 | metadata_json=metadata or {}, 807 | uuid=uuid, 808 | timestamp=time.time() 809 | ) 810 | db.session.add(entry) 811 | db.session.commit() 812 | return entry 813 | 814 | 815 | def update_stt_recording(rec_id, transcription, metadata): 816 | entry = get_stt_recording(rec_id) 817 | if entry: 818 | if transcription: 819 | entry.transcription = transcription 820 | if metadata: 821 | entry.metadata_json = metadata 822 | db.session.commit() 823 | return entry 824 | 825 | 826 | def get_stt_recording(rec_id): 827 | return UtteranceRecording.query.filter_by(recording_id=rec_id).first() 828 | 829 | 830 | def delete_stt_recording(rec_id): 831 | entry = get_stt_recording(rec_id) 832 | if not entry: 833 | return False 834 | db.session.delete(entry) 835 | db.session.commit() 836 | return True 837 | 838 | 839 | def list_stt_recordings(): 840 | return UtteranceRecording.query.all() 841 | 842 | 843 | def add_oauth_token(token_id, token_data): 844 | entry = OAuthToken(token_id=token_id, data=token_data) 845 | db.session.add(entry) 846 | db.session.commit() 847 | return entry 848 | 849 | 850 | def get_oauth_token(token_id): 851 | return OAuthToken.query.filter_by(token_id=token_id).first() 852 | 853 | 854 | def update_oauth_token(token_id, token_data): 855 | entry = get_oauth_token(token_id) 856 | if entry: 857 | entry.data = token_data 858 | db.session.commit() 859 | return entry 860 | 861 | 862 | def delete_oauth_token(token_id): 863 | entry = get_oauth_token(token_id) 864 | if not entry: 865 | return False 866 | db.session.delete(entry) 867 | db.session.commit() 868 | return True 869 | 870 | 871 | def list_oauth_tokens(): 872 | return OAuthToken.query.all() 873 | 874 | 875 | def add_oauth_application(token_id, client_id, client_secret, 876 | auth_endpoint, token_endpoint, refresh_endpoint, 877 | callback_endpoint, scope, shell_integration=True): 878 | entry = OAuthApplication(token_id=token_id, 879 | client_id=client_id, 880 | client_secret=client_secret, 881 | auth_endpoint=auth_endpoint, 882 | token_endpoint=token_endpoint, 883 | refresh_endpoint=refresh_endpoint, 884 | callback_endpoint=callback_endpoint, 885 | scope=scope, 886 | shell_integration=shell_integration) 887 | db.session.add(entry) 888 | db.session.commit() 889 | 890 | return entry 891 | 892 | 893 | def update_oauth_application(token_id=None, client_id=None, client_secret=None, 894 | auth_endpoint=None, token_endpoint=None, refresh_endpoint=None, 895 | callback_endpoint=None, scope=None, shell_integration=None): 896 | entry = get_oauth_application(token_id) 897 | if not entry: 898 | shell_integration = shell_integration or True 899 | entry = add_oauth_application(token_id, client_id, client_secret, 900 | auth_endpoint, token_endpoint, refresh_endpoint, 901 | callback_endpoint, scope, shell_integration) 902 | 903 | if client_id is not None: 904 | entry.client_id = client_id 905 | if client_secret is not None: 906 | entry.client_secret = client_secret 907 | if auth_endpoint is not None: 908 | entry.auth_endpoint = auth_endpoint 909 | if token_endpoint is not None: 910 | entry.token_endpoint = token_endpoint 911 | if refresh_endpoint is not None: 912 | entry.refresh_endpoint = refresh_endpoint 913 | if callback_endpoint is not None: 914 | entry.callback_endpoint = callback_endpoint 915 | if scope is not None: 916 | entry.scope = scope 917 | if shell_integration is not None: 918 | entry.shell_integration = shell_integration 919 | db.session.commit() 920 | return entry 921 | 922 | 923 | def get_oauth_application(token_id): 924 | return OAuthApplication.query.filter_by(token_id=token_id).first() 925 | 926 | 927 | def delete_oauth_application(token_id): 928 | entry = get_oauth_application(token_id) 929 | if not entry: 930 | return False 931 | db.session.delete(entry) 932 | db.session.commit() 933 | return True 934 | 935 | 936 | def list_oauth_applications(): 937 | return OAuthApplication.query.all() 938 | 939 | 940 | def add_voice_definition(plugin, lang, tts_config, 941 | name=None, offline=None, gender=None) -> VoiceDefinition: 942 | voice_id = get_voice_id(plugin, lang, tts_config) 943 | name = name or voice_id 944 | entry = VoiceDefinition(voice_id=voice_id, name=name, lang=lang, plugin=plugin, 945 | tts_config=tts_config, offline=offline, gender=gender) 946 | 947 | db.session.add(entry) 948 | db.session.commit() 949 | return entry 950 | 951 | 952 | def get_voice_definition(voice_id) -> VoiceDefinition: 953 | return VoiceDefinition.query.filter_by(voice_id=voice_id).first() 954 | 955 | 956 | def delete_voice_definition(voice_id): 957 | entry = get_voice_definition(voice_id) 958 | if not entry: 959 | return False 960 | db.session.delete(entry) 961 | db.session.commit() 962 | return True 963 | 964 | 965 | def update_voice_definition(voice_id, name=None, lang=None, plugin=None, 966 | tts_config=None, offline=None, gender=None) -> dict: 967 | voice_def: VoiceDefinition = get_voice_definition(voice_id) 968 | if not voice_def: 969 | if not plugin: 970 | plugin = voice_id.split("_")[0] 971 | if not lang: 972 | lang = voice_id.split("_")[1] 973 | voice_def = add_voice_definition(name=name, lang=lang, plugin=plugin, 974 | tts_config=tts_config, offline=offline, 975 | gender=gender) 976 | else: 977 | if name: 978 | voice_def.name = name 979 | if lang: 980 | voice_def.lang = lang 981 | if plugin: 982 | voice_def.plugin = plugin 983 | if tts_config: 984 | voice_def.tts_config = tts_config 985 | if offline: 986 | voice_def.offline = offline 987 | if gender: 988 | voice_def.gender = gender 989 | db.session.commit() 990 | 991 | return voice_def 992 | -------------------------------------------------------------------------------- /ovos_local_backend/ovos_backend.conf: -------------------------------------------------------------------------------- 1 | { 2 | "lang": "en-us", 3 | "date_format": "DMY", 4 | "system_unit": "metric", 5 | "time_format": "full", 6 | 7 | "location": { 8 | "city": { 9 | "code": "Lawrence", 10 | "name": "Lawrence", 11 | "state": { 12 | "code": "KS", 13 | "name": "Kansas", 14 | "country": { 15 | "code": "US", 16 | "name": "United States" 17 | } 18 | } 19 | }, 20 | "coordinate": { 21 | "latitude": 38.971669, 22 | "longitude": -95.23525 23 | }, 24 | "timezone": { 25 | "code": "America/Chicago", 26 | "name": "Central Standard Time", 27 | "dstOffset": 3600000, 28 | "offset": -21600000 29 | } 30 | }, 31 | 32 | "stt_servers": ["https://stt.openvoiceos.org/stt"], 33 | 34 | "server": { 35 | "admin_key": "", 36 | "port": 6712, 37 | "skip_auth": false, 38 | "geolocate": true, 39 | "override_location": false, 40 | "version": "v1" 41 | }, 42 | 43 | "listener": { 44 | "record_utterances": false, 45 | "record_wakewords": false 46 | }, 47 | 48 | "default_values": { 49 | "ww_id": "", 50 | "voice_id": "", 51 | "stt_id": "" 52 | }, 53 | 54 | "microservices": { 55 | "wolfram_key": "", 56 | "owm_key": "", 57 | "email": { 58 | "recipient": "", 59 | "smtp": { 60 | "username": "", 61 | "password": "", 62 | "host": "", 63 | "port": 465 64 | } 65 | } 66 | } 67 | 68 | } -------------------------------------------------------------------------------- /ovos_local_backend/session.py: -------------------------------------------------------------------------------- 1 | import requests_cache 2 | 3 | SESSION = requests_cache.CachedSession(expire_after=60 * 60, backend="memory") 4 | 5 | -------------------------------------------------------------------------------- /ovos_local_backend/utils/__init__.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License, Version 2.0 (the "License"); 2 | # you may not use this file except in compliance with the License. 3 | # You may obtain a copy of the License at 4 | # 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # 7 | # Unless required by applicable law or agreed to in writing, software 8 | # distributed under the License is distributed on an "AS IS" BASIS, 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | # See the License for the specific language governing permissions and 11 | # limitations under the License. 12 | # 13 | import json 14 | import random 15 | 16 | import flask 17 | 18 | from ovos_backend_client.api import WolframAlphaApi, OpenWeatherMapApi, BackendType, GeolocationApi 19 | from ovos_config import Configuration 20 | 21 | 22 | def generate_code(): 23 | k = "" 24 | while len(k) < 6: 25 | k += random.choice(["A", "B", "C", "D", "E", "F", "G", "H", "I", 26 | "J", "K", "L", "M", "N", "O", "P", "Q", "R", 27 | "S", "T", "U", "Y", "V", "X", "W", "Z", "0", 28 | "1", "2", "3", "4", "5", "6", "7", "8", "9"]) 29 | return k.upper() 30 | 31 | 32 | def nice_json(arg): 33 | response = flask.make_response(json.dumps(arg, sort_keys=True, indent=4)) 34 | response.headers['Content-type'] = "application/json" 35 | return response 36 | 37 | 38 | def to_camel_case(snake_str): 39 | components = snake_str.split('_') 40 | # We capitalize the first letter of each component except the first one 41 | # with the 'title' method and join them together. 42 | return components[0] + ''.join(x.title() for x in components[1:]) 43 | 44 | 45 | def dict_to_camel_case(data): 46 | converted = {} 47 | for k, v in data.items(): 48 | new_k = to_camel_case(k) 49 | if isinstance(v, dict): 50 | v = dict_to_camel_case(v) 51 | if isinstance(v, list): 52 | for idx, item in enumerate(v): 53 | if isinstance(item, dict): 54 | v[idx] = dict_to_camel_case(item) 55 | converted[new_k] = v 56 | return converted 57 | 58 | 59 | class ExternalApiManager: 60 | def __init__(self): 61 | self.config = Configuration().get("microservices", {}) 62 | self.units = Configuration()["system_unit"] 63 | 64 | self.wolfram_key = self.config.get("wolfram_key") 65 | self.owm_key = self.config.get("owm_key") 66 | 67 | @property 68 | def owm(self): 69 | return OpenWeatherMapApi(backend_type=BackendType.OFFLINE, key=self.owm_key) 70 | 71 | @property 72 | def wolfram(self): 73 | return WolframAlphaApi(backend_type=BackendType.OFFLINE, key=self.wolfram_key) 74 | 75 | def geolocate(self, address): 76 | return GeolocationApi(backend_type=BackendType.OFFLINE).get_geolocation(address) 77 | 78 | def wolfram_spoken(self, query, units=None, lat_lon=None): 79 | units = units or self.units 80 | if units != "metric": 81 | units = "imperial" 82 | return self.wolfram.spoken(query, units, lat_lon) 83 | 84 | def wolfram_simple(self, query, units=None, lat_lon=None): 85 | units = units or self.units 86 | if units != "metric": 87 | units = "imperial" 88 | return self.wolfram.simple(query, units, lat_lon) 89 | 90 | def wolfram_full(self, query, units=None, lat_lon=None): 91 | units = units or self.units 92 | if units != "metric": 93 | units = "imperial" 94 | return self.wolfram.full_results(query, units, lat_lon) 95 | 96 | def wolfram_xml(self, query, units=None, lat_lon=None): 97 | units = units or self.units 98 | if units != "metric": 99 | units = "imperial" 100 | return self.wolfram.full_results(query, units, lat_lon, 101 | optional_params={"output": "xml"}) 102 | 103 | def owm_current(self, lat, lon, units, lang="en-us"): 104 | return self.owm.get_current((lat, lon), lang, units) 105 | 106 | def owm_onecall(self, lat, lon, units, lang="en-us"): 107 | return self.owm.get_weather((lat, lon), lang, units) 108 | 109 | def owm_hourly(self, lat, lon, units, lang="en-us"): 110 | return self.owm.get_hourly((lat, lon), lang, units) 111 | 112 | def owm_daily(self, lat, lon, units, lang="en-us"): 113 | return self.owm.get_daily((lat, lon), lang, units) 114 | -------------------------------------------------------------------------------- /ovos_local_backend/utils/geolocate.py: -------------------------------------------------------------------------------- 1 | import flask 2 | 3 | from ovos_backend_client.api import GeolocationApi 4 | from ovos_config import Configuration 5 | 6 | 7 | def get_request_location(): 8 | _cfg = Configuration() 9 | if not flask.request.headers.getlist("X-Forwarded-For"): 10 | ip = flask.request.remote_addr 11 | else: 12 | # TODO http://esd.io/blog/flask-apps-heroku-real-ip-spoofing.html 13 | ip = flask.request.headers.getlist("X-Forwarded-For")[0] 14 | if _cfg["server"].get("override_location", False): 15 | new_location = _cfg["location"] 16 | elif _cfg["server"].get("geolocate", True): 17 | new_location = GeolocationApi().get_ip_geolocation(ip) 18 | else: 19 | new_location = {} 20 | return new_location 21 | -------------------------------------------------------------------------------- /ovos_local_backend/utils/mail.py: -------------------------------------------------------------------------------- 1 | from ovos_config import Configuration 2 | from ovos_utils.smtp_utils import send_smtp 3 | 4 | 5 | def send_email(subject, body, recipient=None): 6 | mail_config = Configuration()["microservices"]["email"] 7 | 8 | smtp_config = mail_config["smtp"] 9 | user = smtp_config["username"] 10 | pswd = smtp_config["password"] 11 | host = smtp_config["host"] 12 | port = smtp_config.get("port", 465) 13 | 14 | recipient = recipient or mail_config.get("recipient") or user 15 | 16 | send_smtp(user, pswd, 17 | user, recipient, 18 | subject, body, 19 | host, port) 20 | 21 | 22 | if __name__ == "__main__": 23 | USER = "JarbasAI" 24 | YOUR_EMAIL_ADDRESS = "jarbasai@mailfence.com" 25 | DESTINATARY_ADDRESS = "casimiro@jarbasai.online" 26 | YOUR_PASSWORD = "a very very strong Password1!" 27 | HOST = "smtp.mailfence.com" 28 | PORT = 465 29 | 30 | subject = 'test again' 31 | body = 'this is a test bruh' 32 | 33 | send_email(USER, YOUR_PASSWORD, 34 | YOUR_EMAIL_ADDRESS, DESTINATARY_ADDRESS, 35 | subject, body, 36 | HOST, PORT) 37 | -------------------------------------------------------------------------------- /ovos_local_backend/version.py: -------------------------------------------------------------------------------- 1 | # The following lines are replaced during the release process. 2 | # START_VERSION_BLOCK 3 | VERSION_MAJOR = 0 4 | VERSION_MINOR = 2 5 | VERSION_BUILD = 0 6 | VERSION_ALPHA = 23 7 | # END_VERSION_BLOCK 8 | -------------------------------------------------------------------------------- /requirements/requirements.txt: -------------------------------------------------------------------------------- 1 | Flask>=0.12 2 | json_database~=0.7 3 | requests>=2.26.0 4 | pyOpenSSL 5 | ovos-utils>=0.0.25 6 | ovos-plugin-manager>=0.0.23 7 | ovos-backend-client~=0.1 8 | ovos-stt-plugin-server 9 | requests_cache 10 | oauthlib~=3.2 11 | Flask-SQLAlchemy 12 | sqlalchemy-json 13 | ovos-config>=0.0.10 14 | 15 | -------------------------------------------------------------------------------- /scripts/bump_alpha.py: -------------------------------------------------------------------------------- 1 | import fileinput 2 | from os.path import join, dirname 3 | 4 | 5 | version_file = join(dirname(dirname(__file__)), "ovos_local_backend", "version.py") 6 | version_var_name = "VERSION_ALPHA" 7 | 8 | with open(version_file, "r", encoding="utf-8") as v: 9 | for line in v.readlines(): 10 | if line.startswith(version_var_name): 11 | version = int(line.split("=")[-1]) 12 | new_version = int(version) + 1 13 | 14 | for line in fileinput.input(version_file, inplace=True): 15 | if line.startswith(version_var_name): 16 | print(f"{version_var_name} = {new_version}") 17 | else: 18 | print(line.rstrip('\n')) 19 | -------------------------------------------------------------------------------- /scripts/bump_build.py: -------------------------------------------------------------------------------- 1 | import fileinput 2 | from os.path import join, dirname 3 | 4 | 5 | version_file = join(dirname(dirname(__file__)), "ovos_local_backend", "version.py") 6 | version_var_name = "VERSION_BUILD" 7 | alpha_var_name = "VERSION_ALPHA" 8 | 9 | with open(version_file, "r", encoding="utf-8") as v: 10 | for line in v.readlines(): 11 | if line.startswith(version_var_name): 12 | version = int(line.split("=")[-1]) 13 | new_version = int(version) + 1 14 | 15 | for line in fileinput.input(version_file, inplace=True): 16 | if line.startswith(version_var_name): 17 | print(f"{version_var_name} = {new_version}") 18 | elif line.startswith(alpha_var_name): 19 | print(f"{alpha_var_name} = 0") 20 | else: 21 | print(line.rstrip('\n')) 22 | -------------------------------------------------------------------------------- /scripts/bump_major.py: -------------------------------------------------------------------------------- 1 | import fileinput 2 | from os.path import join, dirname 3 | 4 | 5 | version_file = join(dirname(dirname(__file__)), "ovos_local_backend", "version.py") 6 | version_var_name = "VERSION_MAJOR" 7 | minor_var_name = "VERSION_MINOR" 8 | build_var_name = "VERSION_BUILD" 9 | alpha_var_name = "VERSION_ALPHA" 10 | 11 | with open(version_file, "r", encoding="utf-8") as v: 12 | for line in v.readlines(): 13 | if line.startswith(version_var_name): 14 | version = int(line.split("=")[-1]) 15 | new_version = int(version) + 1 16 | 17 | for line in fileinput.input(version_file, inplace=True): 18 | if line.startswith(version_var_name): 19 | print(f"{version_var_name} = {new_version}") 20 | elif line.startswith(minor_var_name): 21 | print(f"{minor_var_name} = 0") 22 | elif line.startswith(build_var_name): 23 | print(f"{build_var_name} = 0") 24 | elif line.startswith(alpha_var_name): 25 | print(f"{alpha_var_name} = 0") 26 | else: 27 | print(line.rstrip('\n')) 28 | -------------------------------------------------------------------------------- /scripts/bump_minor.py: -------------------------------------------------------------------------------- 1 | import fileinput 2 | from os.path import join, dirname 3 | 4 | 5 | version_file = join(dirname(dirname(__file__)), "ovos_local_backend", "version.py") 6 | version_var_name = "VERSION_MINOR" 7 | build_var_name = "VERSION_BUILD" 8 | alpha_var_name = "VERSION_ALPHA" 9 | 10 | with open(version_file, "r", encoding="utf-8") as v: 11 | for line in v.readlines(): 12 | if line.startswith(version_var_name): 13 | version = int(line.split("=")[-1]) 14 | new_version = int(version) + 1 15 | 16 | for line in fileinput.input(version_file, inplace=True): 17 | if line.startswith(version_var_name): 18 | print(f"{version_var_name} = {new_version}") 19 | elif line.startswith(build_var_name): 20 | print(f"{build_var_name} = 0") 21 | elif line.startswith(alpha_var_name): 22 | print(f"{alpha_var_name} = 0") 23 | else: 24 | print(line.rstrip('\n')) 25 | -------------------------------------------------------------------------------- /scripts/entrypoints.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ovos-local-backend --flask-host 0.0.0.0 & 4 | 5 | # disabled until it is updated to use new DatabaseApi 6 | # ovos-backend-manager & 7 | 8 | # Wait for any process to exit 9 | wait -n 10 | 11 | # Exit with status of process that exited first 12 | exit $? 13 | -------------------------------------------------------------------------------- /scripts/remove_alpha.py: -------------------------------------------------------------------------------- 1 | import fileinput 2 | from os.path import join, dirname 3 | 4 | 5 | version_file = join(dirname(dirname(__file__)), "ovos_local_backend", "version.py") 6 | 7 | alpha_var_name = "VERSION_ALPHA" 8 | 9 | for line in fileinput.input(version_file, inplace=True): 10 | if line.startswith(alpha_var_name): 11 | print(f"{alpha_var_name} = 0") 12 | else: 13 | print(line.rstrip('\n')) 14 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup 3 | 4 | BASEDIR = os.path.abspath(os.path.dirname(__file__)) 5 | 6 | 7 | def get_version(): 8 | """ Find the version of the package""" 9 | version = None 10 | version_file = os.path.join(BASEDIR, 'ovos_local_backend', 'version.py') 11 | major, minor, build, alpha = (None, None, None, None) 12 | with open(version_file) as f: 13 | for line in f: 14 | if 'VERSION_MAJOR' in line: 15 | major = line.split('=')[1].strip() 16 | elif 'VERSION_MINOR' in line: 17 | minor = line.split('=')[1].strip() 18 | elif 'VERSION_BUILD' in line: 19 | build = line.split('=')[1].strip() 20 | elif 'VERSION_ALPHA' in line: 21 | alpha = line.split('=')[1].strip() 22 | 23 | if ((major and minor and build and alpha) or 24 | '# END_VERSION_BLOCK' in line): 25 | break 26 | version = f"{major}.{minor}.{build}" 27 | if alpha and int(alpha) > 0: 28 | version += f"a{alpha}" 29 | return version 30 | 31 | 32 | def package_files(directory): 33 | paths = [] 34 | for (path, directories, filenames) in os.walk(directory): 35 | for filename in filenames: 36 | paths.append(os.path.join('..', path, filename)) 37 | return paths 38 | 39 | 40 | def required(requirements_file): 41 | """ Read requirements file and remove comments and empty lines. """ 42 | with open(os.path.join(BASEDIR, requirements_file), 'r') as f: 43 | requirements = f.read().splitlines() 44 | if 'MYCROFT_LOOSE_REQUIREMENTS' in os.environ: 45 | print('USING LOOSE REQUIREMENTS!') 46 | requirements = [r.replace('==', '>=').replace('~=', '>=') for r in requirements] 47 | return [pkg for pkg in requirements 48 | if pkg.strip() and not pkg.startswith("#")] 49 | 50 | 51 | setup( 52 | name='ovos-local-backend', 53 | version=get_version(), 54 | packages=['ovos_local_backend', 55 | 'ovos_local_backend.utils', 56 | 'ovos_local_backend.backend'], 57 | install_requires=required("requirements/requirements.txt"), 58 | extras_requires={ 59 | "mysql": ["Flask-MySQLdb", "mysqlclient"] 60 | }, 61 | package_data={'': package_files('ovos_local_backend')}, 62 | include_package_data=True, 63 | url='https://github.com/OpenVoiceOS/OVOS-local-backend', 64 | license='Apache-2.0', 65 | author='jarbasAI', 66 | author_email='jarbasai@mailfence.com', 67 | description='mock mycroft backend', 68 | entry_points={ 69 | 'console_scripts': [ 70 | 'ovos-local-backend=ovos_local_backend.__main__:main' 71 | ] 72 | } 73 | ) 74 | --------------------------------------------------------------------------------