.
675 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | This repository is no longer matained. Use at your own risk.
2 |
3 | ---
4 |
5 | # Google Assistant Webserver in a Docker container
6 |
7 | ### March 25th 2020 - Update
8 |
9 | I had issues with my project and starting fresh seemed to fix it.
10 |
11 | - Pull the new container image version
12 | - Recreate the Google Actions Project following googles new documentation https://developers.google.com/assistant/sdk/guides/service/python
13 | - Once setup, authenticated and showing up in Google Assistant settings on my phone I had to join the new device to my home in the Google Home app. Then it picked up on my Device Address for broadcasts.
14 |
15 | -------------------------------------------------
16 |
17 | ## What is this?
18 |
19 | This is a emulated Google Assistant with a webserver attached to take commands over HTTP packaged in a Docker container. The container consists of the Google Assistant SDK, python scripts that provide the Flask REST API / OAuth authentication and modifications that base it from the Google Assistant library.
20 |
21 | I did not write this code, I simply pulled pieces and modified them to work together. AndBobsYourUncle wrote Google Assistant webserver Hassio add-on which this is largely based on. Chocomega provided the modifications that based it off the Google Assistant libraries.
22 |
23 | How does this differ from AndBobsYourUncle's [Google Assistant Webserver](https://community.home-assistant.io/t/community-hass-io-add-on-google-assistant-webserver-broadcast-messages-without-interrupting-music/37274)? This project is modified, running based on the Google Assistant libraries not the Google Assistant Service which allows for additional functionality such as remote media casting (Casting Spotify) [See the table here](https://community.home-assistant.io/t/community-hass-io-add-on-google-assistant-webserver-broadcast-messages-without-interrupting-music/37274/343). However this method requires a mic and speaker audio device or an emulated dummy on the host machine.
24 |
25 | * [AndBobsYourUncle Home Assistant forum post](https://community.home-assistant.io/t/community-hass-io-add-on-google-assistant-webserver-broadcast-messages-without-interrupting-music/37274) and [Hassio Add-on Github repository](https://github.com/AndBobsYourUncle/hassio-addons)
26 | * [Chocomega modifications](https://community.home-assistant.io/t/community-hass-io-add-on-google-assistant-webserver-broadcast-messages-without-interrupting-music/37274/234)
27 | * [Google Assistant Library docs](https://developers.google.com/assistant/sdk/guides/library/python/)
28 |
29 | Interested in [Docker](https://www.docker.com/) but never used it before? Checkout my blog post: [Docker In Your HomeLab - Getting Started](https://borked.io/2019/02/13/docker-in-your-homelab.html).
30 |
31 | ## Setup
32 |
33 | * Prerequisite - Mic and speaker audio device configured on the host machine. [Michal_Ciemiega](https://community.home-assistant.io/t/google-assistant-webserver-in-a-docker-container/88820/17?u=robwolff3) pointed out that a real soundcard is not necessary with: `sudo modprobe snd-dummy`. If you're not sure or need help you can follow [Googles Configure and Test the Audio documentation](https://developers.google.com/assistant/sdk/guides/library/python/embed/audio?hardware=ubuntu).
34 | 1. Go the **_Configure a Developer Project and Account Settings_** page of the **_Embed the Google Assistant_** procedure in the [Library docs](https://developers.google.com/assistant/sdk/guides/library/python/embed/config-dev-project-and-account).
35 | 2. Follow the steps through to **_Register the Device Model_** and take note of the project id and the device model id.
36 | 3. **_Download OAuth 2.0 Credentials_** file, rename it to `client_secret.json`, create a configuration directory `/home/$USER/docker/config/gawebserver/config` and move the file there.
37 | 4. Create an additional folder `/home/$USER/docker/config/gawebserver/assistant` the Google Assistant SDK will cache files here that need to persist through container recreation.
38 | 5. In a Docker configuration below, fill out the `DEVICE_MODEL_ID` and `PROJECT_ID` environment variables with the values from previous steps. Lastly change the volume to mount your config and assistant directories to `/config` and `/root/.config/google-assistant-library/assistant`
39 |
40 | ## First Run
41 |
42 | * Start the container using Docker Run or Docker Compose. It will start listening on ports 9324 and 5000. Browse to the container on port 9324 (`http://containerip:9324`) where you will see **_Get token from google: Authentication_**.
43 | * Follow the URL, authenticate with Google, return the string from Google to the container web page and click connect. _The page will error out and that is normal_, the container is now up and running.
44 | * To get broadcast messages working an address needs to be set, the same as your other broadcast devices. In the Google Home app go to **_Account > Settings > Assistant_**. At the bottom select your ga-webserver and set the applicable address. There you can also set the default audio and video casting devices.
45 |
46 | ### Docker Run
47 |
48 | ```bash
49 | $ docker run -d --name=gawebserver \
50 | --restart on-failure \
51 | -v /home/$USER/docker/config/gawebserver/config:/config \
52 | -v /home/$USER/docker/config/gawebserver/assistant:/root/.config/google-assistant-library/assistant \
53 | -p 9324:9324 \
54 | -p 5000:5000 \
55 | -e CLIENT_SECRET=client_secret.json \
56 | -e DEVICE_MODEL_ID=device_model_id \
57 | -e PROJECT_ID=project_id \
58 | -e PYTHONIOENCODING=utf-8 \
59 | --device /dev/snd:/dev/snd:rwm \
60 | robwolff3/ga-webserver
61 | ```
62 |
63 | ### Docker Compose
64 |
65 | ```yml
66 | version: "3.7"
67 | services:
68 | gawebserver:
69 | container_name: gawebserver
70 | image: robwolff3/ga-webserver
71 | restart: on-failure
72 | volumes:
73 | - /home/$USER/docker/config/gawebserver/config:/config
74 | - /home/$USER/docker/config/gawebserver/assistant:/root/.config/google-assistant-library/assistant
75 | ports:
76 | - 9324:9324
77 | - 5000:5000
78 | environment:
79 | - CLIENT_SECRET=client_secret.json
80 | - DEVICE_MODEL_ID=device_model_id
81 | - PROJECT_ID=project_id
82 | - PYTHONIOENCODING=utf-8
83 | devices:
84 | - "/dev/snd:/dev/snd:rwm"
85 | ```
86 |
87 | ## Test it
88 |
89 | * Test out your newly created ga-webserver by sending it a command through your web browser.
90 | * Send a command `http://containerip:5000/command?message=Play Careless Whisper by George Michael on Kitchen Stereo`
91 | * Broadcast a message `http://containerip:5000/broadcast_message?message=Alexa order 500 pool noodles`
92 |
93 | Not sure why a command isn't working? See what happened in your [Google Account Activity](https://myactivity.google.com/item?restrict=assist&embedded=1&utm_source=opa&utm_medium=er&utm_campaign=) or under **_My Activity_** in the Google Home App.
94 |
95 | ## Home Assistant
96 |
97 | Here is an example how I use the ga-webserver in [Home Assistant](https://www.home-assistant.io/) to broadcast over my Google Assistants when my dishwasher has finished.
98 |
99 | #### configuration.yaml
100 |
101 | ```yml
102 | notify:
103 | - name: ga_broadcast
104 | platform: rest
105 | resource: http://containerip:5000/broadcast_message
106 | - name: ga_command
107 | platform: rest
108 | resource: http://containerip:5000/command
109 | ```
110 |
111 | #### automations.yaml
112 |
113 | ```yml
114 | - alias: Broadcast the dishwasher has finished
115 | initial_state: True
116 | trigger:
117 | - platform: state
118 | entity_id: input_select.dishwasher_status
119 | to: 'Off'
120 | action:
121 | - service: notify.ga_broadcast
122 | data:
123 | message: "The Dishwasher has finished."
124 | ```
125 |
126 | [My Home Assistant Configuration repository](https://github.com/robwolff3/homeassistant-config).
127 |
128 | ## Known Issues and Troubleshooting
129 |
130 | * _There are duplicate devices in the Google Home app_ - This happens every time the container is recreated, it looses its `device_id` stored in the container. This is fixed with my add step 4 under Setup. Once the container stores its new `device_id` there it will persist through container recreation.
131 | * Error: _UnicodeEncodeError: 'ascii' codec can't encode character_ - [zewelor](https://github.com/robwolff3/google-assistant-webserver/issues/1) discovered this issue of a Wrong UTF8 encoding setting in the locale env. He solved this by adding the environment variable `PYTHONIOENCODING=utf-8` to the Docker configuration.
132 | * If it was working and then all the sudden stopped then you may need to re-authenticate. Stop the container, delete the `access_token.json` file from the configuration directory, repeat the **First Run** procedure above.
133 | * Having other problems? Check the container logs: `docker logs -f gawebserver`
134 |
--------------------------------------------------------------------------------
/assistant_helpers.py:
--------------------------------------------------------------------------------
1 | # Copyright (C) 2017 Google Inc.
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | """Helper functions for the Google Assistant API."""
16 |
17 | import logging
18 |
19 | from google.assistant.embedded.v1alpha2 import embedded_assistant_pb2
20 |
21 |
22 | def log_assist_request_without_audio(assist_request):
23 | """Log AssistRequest fields without audio data."""
24 | if logging.getLogger().isEnabledFor(logging.DEBUG):
25 | resp_copy = embedded_assistant_pb2.AssistRequest()
26 | resp_copy.CopyFrom(assist_request)
27 | if len(resp_copy.audio_in) > 0:
28 | size = len(resp_copy.audio_in)
29 | resp_copy.ClearField('audio_in')
30 | logging.debug('AssistRequest: audio_in (%d bytes)',
31 | size)
32 | return
33 | logging.debug('AssistRequest: %s', resp_copy)
34 |
35 |
36 | def log_assist_response_without_audio(assist_response):
37 | """Log AssistResponse fields without audio data."""
38 | if logging.getLogger().isEnabledFor(logging.DEBUG):
39 | resp_copy = embedded_assistant_pb2.AssistResponse()
40 | resp_copy.CopyFrom(assist_response)
41 | has_audio_data = (resp_copy.HasField('audio_out') and
42 | len(resp_copy.audio_out.audio_data) > 0)
43 | if has_audio_data:
44 | size = len(resp_copy.audio_out.audio_data)
45 | resp_copy.audio_out.ClearField('audio_data')
46 | if resp_copy.audio_out.ListFields():
47 | logging.debug('AssistResponse: %s audio_data (%d bytes)',
48 | resp_copy,
49 | size)
50 | else:
51 | logging.debug('AssistResponse: audio_data (%d bytes)',
52 | size)
53 | return
54 | logging.debug('AssistResponse: %s', resp_copy)
55 |
--------------------------------------------------------------------------------
/gawebserver.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 |
3 | # Copyright (C) 2017 Google Inc.
4 | #
5 | # Licensed under the Apache License, Version 2.0 (the "License");
6 | # you may not use this file except in compliance with the License.
7 | # You may obtain a copy of the License at
8 | #
9 | # http://www.apache.org/licenses/LICENSE-2.0
10 | #
11 | # Unless required by applicable law or agreed to in writing, software
12 | # distributed under the License is distributed on an "AS IS" BASIS,
13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | # See the License for the specific language governing permissions and
15 | # limitations under the License.
16 |
17 |
18 | from __future__ import print_function
19 |
20 | import argparse
21 | import json
22 | import os.path
23 | import pathlib2 as pathlib
24 |
25 | import google.oauth2.credentials
26 |
27 | from google.assistant.library import Assistant
28 | from google.assistant.library.event import EventType
29 | from google.assistant.library.file_helpers import existing_file
30 | from google.assistant.library.device_helpers import register_device
31 |
32 | from flask import Flask, request
33 | from flask_restful import Resource, Api
34 |
35 | import threading
36 |
37 | app = Flask(__name__)
38 | api = Api(app)
39 |
40 | def start_server():
41 | app.run(host='0.0.0.0')
42 |
43 | try:
44 | FileNotFoundError
45 | except NameError:
46 | FileNotFoundError = IOError
47 |
48 |
49 | WARNING_NOT_REGISTERED = """
50 | This device is not registered. This means you will not be able to use
51 | Device Actions or see your device in Assistant Settings. In order to
52 | register this device follow instructions at:
53 |
54 | https://developers.google.com/assistant/sdk/guides/library/python/embed/register-device
55 | """
56 |
57 |
58 | def process_event(event):
59 | """Pretty prints events.
60 |
61 | Prints all events that occur with two spaces between each new
62 | conversation and a single space between turns of a conversation.
63 |
64 | Args:
65 | event(event.Event): The current event to process.
66 | """
67 | if event.type == EventType.ON_CONVERSATION_TURN_STARTED:
68 | print()
69 |
70 | print(event)
71 |
72 | if (event.type == EventType.ON_CONVERSATION_TURN_FINISHED and
73 | event.args and not event.args['with_follow_on_turn']):
74 | print()
75 | if event.type == EventType.ON_DEVICE_ACTION:
76 | for command, params in event.actions:
77 | print('Do command', command, 'with params', str(params))
78 |
79 |
80 | def main():
81 | parser = argparse.ArgumentParser(
82 | formatter_class=argparse.RawTextHelpFormatter)
83 | parser.add_argument('--device-model-id', '--device_model_id', type=str,
84 | metavar='DEVICE_MODEL_ID', required=False,
85 | help='the device model ID registered with Google')
86 | parser.add_argument('--project-id', '--project_id', type=str,
87 | metavar='PROJECT_ID', required=False,
88 | help='the project ID used to register this device')
89 | parser.add_argument('--device-config', type=str,
90 | metavar='DEVICE_CONFIG_FILE',
91 | default=os.path.join(
92 | os.path.expanduser('~/.config'),
93 | 'googlesamples-assistant',
94 | 'device_config_library.json'
95 | ),
96 | help='path to store and read device configuration')
97 | parser.add_argument('--credentials', type=existing_file,
98 | metavar='OAUTH2_CREDENTIALS_FILE',
99 | default=os.path.join(
100 | os.path.expanduser('~/.config'),
101 | 'google-oauthlib-tool',
102 | 'credentials.json'
103 | ),
104 | help='path to store and read OAuth2 credentials')
105 | parser.add_argument('-v', '--version', action='version',
106 | version='%(prog)s ' + Assistant.__version_str__())
107 |
108 | args = parser.parse_args()
109 | with open(args.credentials, 'r') as f:
110 | credentials = google.oauth2.credentials.Credentials(token=None,
111 | **json.load(f))
112 |
113 | device_model_id = None
114 | last_device_id = None
115 | try:
116 | with open(args.device_config) as f:
117 | device_config = json.load(f)
118 | device_model_id = device_config['model_id']
119 | last_device_id = device_config.get('last_device_id', None)
120 | except FileNotFoundError:
121 | pass
122 |
123 | if not args.device_model_id and not device_model_id:
124 | raise Exception('Missing --device-model-id option')
125 |
126 | # Re-register if "device_model_id" is given by the user and it differs
127 | # from what we previously registered with.
128 | should_register = (
129 | args.device_model_id and args.device_model_id != device_model_id)
130 |
131 | device_model_id = args.device_model_id or device_model_id
132 |
133 | with Assistant(credentials, device_model_id) as assistant:
134 | events = assistant.start()
135 |
136 | device_id = assistant.device_id
137 | print('device_model_id:', device_model_id)
138 | print('device_id:', device_id + '\n')
139 |
140 | # Re-register if "device_id" is different from the last "device_id":
141 | if should_register or (device_id != last_device_id):
142 | if args.project_id:
143 | register_device(args.project_id, credentials,
144 | device_model_id, device_id)
145 | pathlib.Path(os.path.dirname(args.device_config)).mkdir(
146 | exist_ok=True)
147 | with open(args.device_config, 'w') as f:
148 | json.dump({
149 | 'last_device_id': device_id,
150 | 'model_id': device_model_id,
151 | }, f)
152 | else:
153 | print(WARNING_NOT_REGISTERED)
154 |
155 | assistant.set_mic_mute(True)
156 |
157 | class BroadcastMessage(Resource):
158 | def get(self):
159 | message = request.args.get('message', default = 'This is a test!')
160 | text_query = 'broadcast ' + message
161 | assistant.send_text_query(text_query)
162 | return {'status': 'OK'}
163 |
164 | api.add_resource(BroadcastMessage, '/broadcast_message')
165 |
166 | class Command(Resource):
167 | def get(self):
168 | message = request.args.get('message', default = 'This is a test!')
169 | assistant.send_text_query(message)
170 | return {'status': 'OK'}
171 |
172 | api.add_resource(Command, '/command')
173 |
174 | server = threading.Thread(target=start_server,args=())
175 | server.setDaemon(True)
176 | server.start()
177 |
178 | for event in events:
179 | process_event(event)
180 |
181 | if __name__ == '__main__':
182 | main()
--------------------------------------------------------------------------------
/oauth.py:
--------------------------------------------------------------------------------
1 | """Run small webservice for oath."""
2 | import json
3 | import sys
4 | from pathlib import Path
5 |
6 | import cherrypy
7 | from requests_oauthlib import OAuth2Session
8 | from google.oauth2.credentials import Credentials
9 |
10 |
11 | class oauth2Site(object):
12 | """Website for handling oauth2."""
13 |
14 | def __init__(self, user_data, cred_file):
15 | """Init webpage."""
16 | self.cred_file = cred_file
17 | self.user_data = user_data
18 |
19 | self.oauth2 = OAuth2Session(
20 | self.user_data['client_id'],
21 | redirect_uri='urn:ietf:wg:oauth:2.0:oob',
22 | scope="https://www.googleapis.com/auth/assistant-sdk-prototype"
23 | )
24 |
25 | self.auth_url, _ = self.oauth2.authorization_url(self.user_data['auth_uri'], access_type='offline', prompt='consent')
26 |
27 | @cherrypy.expose
28 | def index(self):
29 | """Landingpage."""
30 | return str("""
31 |
32 |
33 |
34 | Get token from google: Authentication
35 |
36 |
40 |
41 | """).format(url=self.auth_url)
42 |
43 | @cherrypy.expose
44 | def token(self, token):
45 | """Read access token and process it."""
46 | self.oauth2.fetch_token(self.user_data['token_uri'], client_secret=self.user_data['client_secret'], code=token)
47 |
48 | # create credentials
49 | credentials = Credentials(
50 | self.oauth2.token['access_token'],
51 | refresh_token=self.oauth2.token.get('refresh_token'),
52 | token_uri=self.user_data['token_uri'],
53 | client_id=self.user_data['client_id'],
54 | client_secret=self.user_data['client_secret'],
55 | scopes=self.oauth2.scope
56 | )
57 |
58 | # write credentials json file
59 | with self.cred_file.open('w') as json_file:
60 | json_file.write(json.dumps({
61 | 'refresh_token': credentials.refresh_token,
62 | 'token_uri': credentials.token_uri,
63 | 'client_id': credentials.client_id,
64 | 'client_secret': credentials.client_secret,
65 | 'scopes': credentials.scopes,
66 | }))
67 |
68 | sys.exit(0)
69 |
70 |
71 | if __name__ == '__main__':
72 | oauth_json = Path(sys.argv[1])
73 | cred_json = Path(sys.argv[2])
74 |
75 | with oauth_json.open('r') as data:
76 | user_data = json.load(data)['installed']
77 |
78 | cherrypy.config.update({'server.socket_port': 9324, 'server.socket_host': '0.0.0.0'})
79 | cherrypy.quickstart(oauth2Site(user_data, cred_json))
--------------------------------------------------------------------------------
/run.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -e
3 |
4 | ACCESS_TOKEN=access_token.json
5 |
6 | if [ ! -f "/config/$ACCESS_TOKEN" ] && [ -f "/config/$CLIENT_SECRET" ]; then
7 | echo "[Info] Start WebUI for handling oauth2"
8 | python3 /oauth.py "/config/$CLIENT_SECRET" "/config/$ACCESS_TOKEN"
9 | elif [ ! -f "/config/$ACCESS_TOKEN" ]; then
10 | echo "[Error] You need initialize GoogleAssistant with a client secret json!"
11 | exit 1
12 | fi
13 |
14 | exec python3 /gawebserver.py --credentials "/config/$ACCESS_TOKEN" --project-id "$PROJECT_ID" --device-model-id "$DEVICE_MODEL_ID" < /dev/null
15 |
--------------------------------------------------------------------------------