This commit is contained in:
2021-08-24 14:35:11 +03:00
parent d8fba9335d
commit 4f37cb59b1
107 changed files with 3941 additions and 66 deletions

View File

@@ -0,0 +1,23 @@
FROM python:3.8-slim
RUN set -x \
# Install required system packages
&& apt-get update && apt-get install -y --no-install-recommends \
jq \
tzdata \
portaudio19-dev \
libffi-dev \
libssl-dev
# install python packages
COPY requirements.txt .
RUN pip install --upgrade -r requirements.txt
EXPOSE 5000/tcp
VOLUME [ "/data" ]
WORKDIR /usr/src/app
COPY /app /usr/src/app/
CMD [ "python", "/usr/src/app/main.py" ]

View File

@@ -0,0 +1,57 @@
# Marcelveldt's Hassio Add-ons: Google Assistant Webserver
## About
Webservice for the Google Assistant SDK
Allow you to send (broadcast) commands to Google Assistant
## Installation
The installation of this add-on is pretty straightforward and not different in
comparison to installing any other Hass.io add-on.
1. [Add my Hass.io add-ons repository][repository] to your Hass.io instance.
1. Install the "Google Assistant Webserver" add-on.
1. Start the "Google Assistant Webserver" add-on.
1. Check the logs of the add-on to see if everything went well.
1. At the first start, you will need to authenticate with Google, use the "Open Web UI" button for that.
1. Ready to go!
## Usage in HomeAssistant
Once you've set-up the webserver, you can add the component to HomeAssistant as notify component (for the broadcasts) and as script for the custom actions.
### Broadcast component
```yaml
notify:
- name: Google Assistant
platform: rest
resource: http://YOUR_HASS_IP_HERE:5000/broadcast
```
### Script component
```yaml
# define as rest_command in configuration
rest_command:
- google_assistant_command:
url: 'http://YOUR_HASS_IP_HERE:5000/command?message={{ command }}'
# example usage in script
script:
- google_cmd_test:
service: rest_command.google_assistant_command
data:
command: "some command you want to throw at the assistant"
```
[repository]: https://github.com/marcelveldt/hassio-addons-repo

View File

@@ -0,0 +1,108 @@
"""Google Assistant Text Assistant."""
import json
import logging
import os
import sys
from pathlib import Path
import google.auth.transport.grpc
import google.auth.transport.requests
import google.oauth2.credentials
from aiohttp import web
from google.assistant.embedded.v1alpha2 import (embedded_assistant_pb2,
embedded_assistant_pb2_grpc)
import assistant_helpers
ASSISTANT_API_ENDPOINT = 'embeddedassistant.googleapis.com'
DEFAULT_GRPC_DEADLINE = 60 * 3 + 5
PLAYING = embedded_assistant_pb2.ScreenOutConfig.PLAYING
class GoogleTextAssistant(object):
"""Sample Assistant that supports text based conversations.
Args:
language_code: language for the conversation.
device_model_id: identifier of the device model.
device_id: identifier of the registered device instance.
display: enable visual display of assistant response.
cred_json: Filename of jsonfile containing credentials.
deadline_sec: gRPC deadline in seconds for Google Assistant API call.
"""
def __init__(self, language_code, device_model_id, device_id,
cred_json:Path, display = True, deadline_sec = DEFAULT_GRPC_DEADLINE):
self.language_code = language_code
self.device_model_id = device_model_id
self.device_id = device_id
self.conversation_state = None
# Force reset of first conversation.
self.is_new_conversation = True
self.display = display
# open credentials
with open(cred_json, 'r') as _file:
credentials = google.oauth2.credentials.Credentials(token=None, **json.load(_file))
http_request = google.auth.transport.requests.Request()
credentials.refresh(http_request)
# Create an authorized gRPC channel.
grpc_channel = google.auth.transport.grpc.secure_authorized_channel(
credentials, http_request, ASSISTANT_API_ENDPOINT)
self.assistant = embedded_assistant_pb2_grpc.EmbeddedAssistantStub(
grpc_channel
)
self.deadline = deadline_sec
def __enter__(self):
return self
def __exit__(self, etype, e, traceback):
if e:
return False
def assist(self, text_query):
"""Send a text request to the Assistant and playback the response.
"""
def iter_assist_requests():
config = embedded_assistant_pb2.AssistConfig(
audio_out_config=embedded_assistant_pb2.AudioOutConfig(
encoding='LINEAR16',
sample_rate_hertz=16000,
volume_percentage=0,
),
dialog_state_in=embedded_assistant_pb2.DialogStateIn(
language_code=self.language_code,
conversation_state=self.conversation_state,
is_new_conversation=self.is_new_conversation,
),
device_config=embedded_assistant_pb2.DeviceConfig(
device_id=self.device_id,
device_model_id=self.device_model_id,
),
text_query=text_query,
)
# Continue current conversation with later requests.
self.is_new_conversation = False
if self.display:
config.screen_out_config.screen_mode = PLAYING
req = embedded_assistant_pb2.AssistRequest(config=config)
# This can be used to output the assistant request
# assistant_helpers.log_assist_request_without_audio(req)
yield req
text_response = None
html_response = None
for resp in self.assistant.Assist(iter_assist_requests(),
self.deadline):
# This can be used to output the assistant response
# assistant_helpers.log_assist_response_without_audio(resp)
if resp.screen_out.data:
html_response = resp.screen_out.data
if resp.dialog_state_out.conversation_state:
conversation_state = resp.dialog_state_out.conversation_state
self.conversation_state = conversation_state
if resp.dialog_state_out.supplemental_display_text:
text_response = resp.dialog_state_out.supplemental_display_text()
return text_response, html_response

View File

@@ -0,0 +1,54 @@
# Copyright (C) 2017 Google Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Helper functions for the Google Assistant API."""
import logging
from google.assistant.embedded.v1alpha2 import embedded_assistant_pb2
def log_assist_request_without_audio(assist_request):
"""Log AssistRequest fields without audio data."""
if logging.getLogger().isEnabledFor(logging.DEBUG):
resp_copy = embedded_assistant_pb2.AssistRequest()
resp_copy.CopyFrom(assist_request)
if len(resp_copy.audio_in) > 0:
size = len(resp_copy.audio_in)
resp_copy.ClearField('audio_in')
logging.debug('AssistRequest: audio_in (%d bytes)',
size)
return
logging.debug('AssistRequest: %s', resp_copy)
def log_assist_response_without_audio(assist_response):
"""Log AssistResponse fields without audio data."""
if logging.getLogger().isEnabledFor(logging.DEBUG):
resp_copy = embedded_assistant_pb2.AssistResponse()
resp_copy.CopyFrom(assist_response)
has_audio_data = (resp_copy.HasField('audio_out') and
len(resp_copy.audio_out.audio_data) > 0)
if has_audio_data:
size = len(resp_copy.audio_out.audio_data)
resp_copy.audio_out.ClearField('audio_data')
if resp_copy.audio_out.ListFields():
logging.debug('AssistResponse: %s audio_data (%d bytes)',
resp_copy,
size)
else:
logging.debug('AssistResponse: audio_data (%d bytes)',
size)
return
logging.debug('AssistResponse: %s', resp_copy)

View File

@@ -0,0 +1,89 @@
<!DOCTYPE html>
<html>
<head>
<link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/@mdi/font@4.x/css/materialdesignicons.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/vuetify@2.x/dist/vuetify.min.css" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui">
</head>
<body>
<div id="app">
<v-app>
<v-main>
<v-container fluid fill-height>
<v-layout align-center justify-center>
<v-flex sm8>
<v-card class="elevation-12">
<v-toolbar dark color="black">
<v-toolbar-title>Authenticate Google Assistant Webserver</v-toolbar-title>
<v-spacer></v-spacer>
</v-toolbar>
<v-card-text>
<h2>Step 1</h2>
<span>In order to use the Google Assistant webserver, you need to authenticate your Google account (once).</span>
<span>Please click the button below to start the authentication process.</span>
<span>Once you received the authentication token, come back to this page to submit it.</span>
<br /><br />
<v-btn @click="this.window.open('[[AUTH_URL]]', '_blank');" class="mr-4">Authenticate</v-btn>
<br /><br />
<h2>Step 2</h2>
<span>Once you received the token, paste it below and click submit.</span>
<v-form ref="form" v-model="valid" method="post">
<v-text-field
v-model="token"
prepend-icon="mdi-lock"
name="token"
label="Token"
type="text"
required
:rules="[v => !!v || 'Token is required']"
></v-text-field>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-alert type="success" v-if="success"
>Authentication finished. You can now issue commands to Google Assistant.
</v-alert>
<v-btn type="submit" v-else :disabled="!valid" color="success" @click="submit"
class="mr-4">Submit</v-btn>
</v-card-actions>
</v-card>
</v-flex>
</v-layout>
</v-container>
</v-main>
</v-app>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@2.x/dist/vue.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vuetify@2.x/dist/vuetify.js"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script>
new Vue({
el: '#app',
vuetify: new Vuetify(),
data: () => ({
token: '',
valid: true,
success: false
}),
methods: {
submit () {
const formData = new FormData()
formData.append('token', this.token)
axios.post(window.location + 'token', formData)
.then(function (response) {
this.success = true
}.bind(this))
.catch(function (error) {
console.log('error', error)
});
}
}
})
</script>
</body>
</html>

View File

@@ -0,0 +1,61 @@
"""Handler for authentication."""
import json
import sys
from pathlib import Path
from aiohttp import web
from google.oauth2.credentials import Credentials
from requests_oauthlib import OAuth2Session
class AuthHandler:
"""Some logic to handle granting access to Google."""
def __init__(self, user_data:dict, cred_file:Path):
"""Initialize."""
self.cred_file = cred_file
self.user_data = user_data
self.oauth2 = OAuth2Session(
self.user_data["client_id"],
redirect_uri="urn:ietf:wg:oauth:2.0:oob",
scope="https://www.googleapis.com/auth/assistant-sdk-prototype",
)
self.auth_url, _ = self.oauth2.authorization_url(
self.user_data["auth_uri"], access_type="offline", prompt="consent"
)
async def token(self, request):
"""Read access token and process it."""
form = await request.post()
token = form["token"]
self.oauth2.fetch_token(
self.user_data["token_uri"], client_secret=self.user_data["client_secret"], code=token
)
# create credentials
credentials = Credentials(
self.oauth2.token["access_token"],
refresh_token=self.oauth2.token.get("refresh_token"),
token_uri=self.user_data["token_uri"],
client_id=self.user_data["client_id"],
client_secret=self.user_data["client_secret"],
scopes=self.oauth2.scope,
)
# write credentials json file
with self.cred_file.open("w") as json_file:
json_file.write(
json.dumps(
{
"refresh_token": credentials.refresh_token,
"token_uri": credentials.token_uri,
"client_id": credentials.client_id,
"client_secret": credentials.client_secret,
"scopes": [credentials.scopes],
}
)
)
return web.Response(text="Authentication successfull")

View File

@@ -0,0 +1 @@
{"installed":{"client_id":"848329555010-ue91trunkjk1dk48s6dov8csgthqnu54.apps.googleusercontent.com","project_id":"hass-assistant-229621","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_secret":"HHrDEJXTLgKfdUai-i7Icsws","redirect_uris":["urn:ietf:wg:oauth:2.0:oob","http://localhost"]}}

View File

@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html>
<head>
<link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/@mdi/font@4.x/css/materialdesignicons.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/vuetify@2.x/dist/vuetify.min.css" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui">
</head>
<body>
<div id="app">
<v-app>
<v-main>
<v-container fluid fill-height>
<v-layout align-center justify-center>
<v-flex sm8>
<v-card class="elevation-12">
<v-toolbar dark color="black">
<v-toolbar-title>Google Assistant Webserver</v-toolbar-title>
<v-spacer></v-spacer>
</v-toolbar>
<v-card-text>
<h2>Broadcast</h2>
<v-text-field
v-model="broadcast"
label="Broadcast message"
type="text"
></v-text-field>
<v-btn @click="submitBroadcast()" class="mr-4">Send broadcast</v-btn>
<br />
<br />
<h2>Send command</h2>
<v-text-field
v-model="command"
label="Command to send to Google Assistant (e.g. turn off the lights)"
type="text"
></v-text-field>
<v-btn @click="submitCommand()" class="mr-4">Send command</v-btn>
<v-card-actions>
<v-spacer></v-spacer>
<a @click="openAuth()">Authenticate</a>
</v-card-actions>
</v-card-text>
</v-card>
</v-flex>
</v-layout>
</v-container>
</v-main>
</v-app>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@2.x/dist/vue.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vuetify@2.x/dist/vuetify.js"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script>
new Vue({
el: '#app',
vuetify: new Vuetify(),
data: () => ({
broadcast: '',
command: ''
}),
methods: {
async submitBroadcast () {
const res = await axios.get(window.location + 'broadcast', { params: { message: this.broadcast } })
alert('Message submitted ' + res.data)
},
async submitCommand () {
const res = await axios.get(window.location + 'command', { params: { message: this.command } })
alert('Message submitted ' + res.data)
},
openAuth () {
window.open(window.location + 'auth')
}
}
})
</script>
</body>
</html>

View File

@@ -0,0 +1,67 @@
"""Main entrypoint: webserver handling commands to google assistant."""
import json
import logging
import os
from pathlib import Path
from aiohttp import web
from assistant import GoogleTextAssistant
from auth import AuthHandler
LOGGER = logging.getLogger()
CLIENT_JSON = Path(os.path.join(os.path.dirname(os.path.abspath(__file__)), "client_secrets.json"))
CRED_JSON = Path("/data/cred.json")
if not os.path.isdir("/data"):
CRED_JSON = Path(os.path.join(os.path.dirname(os.path.abspath(__file__)), "cred.json"))
routes = web.RouteTableDef()
@routes.get("/broadcast")
async def broadcast_message(request):
message = request.query.get("message", default="This is a test!")
text_query = "broadcast " + message
with GoogleTextAssistant("en-US", "HA_GA", "HA_GA_TEXT_SERVER", CRED_JSON) as assistant:
response_text, response_html = assistant.assist(text_query=text_query)
return web.Response(text=response_text)
@routes.get("/command")
async def command(request):
message = request.query.get("message", default="This is a test!")
with GoogleTextAssistant("en-US", "HA_GA", "HA_GA_TEXT_SERVER", CRED_JSON) as assistant:
response_text, response_html = assistant.assist(text_query=message)
return web.Response(text=response_text)
@routes.get("/")
async def index(request):
"""Landingpage."""
if not CRED_JSON.exists():
raise web.HTTPFound("/auth")
html_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "index.html")
return web.FileResponse(html_file)
@routes.get("/auth")
async def auth(request):
"""Authenticate with google."""
html_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "auth.html")
with open(html_file) as _file:
html = _file.read()
html = html.replace("[[AUTH_URL]]", request.app["auth"].auth_url)
return web.Response(text=html, content_type="text/html")
app = web.Application()
app.add_routes(routes)
with CLIENT_JSON.open("r") as data:
user_data = json.load(data)["installed"]
auth = AuthHandler(user_data, CRED_JSON)
app.router.add_post("/token", auth.token)
app["auth"] = auth
web.run_app(app, port=5000)

View File

@@ -0,0 +1,20 @@
{
"name": "Google Assistant Webserver",
"version": "0.0.10",
"description": "Webservice for the Google Assistant SDK - Allow you to send (broadcast) commands to Google Assistant",
"slug": "google_assistant_webserver",
"startup": "application",
"boot": "auto",
"arch": ["armhf", "amd64", "aarch64"],
"devices": ["/dev/snd:/dev/snd:rwm"],
"ports": {
"5000/tcp": 5000
},
"ingress": true,
"ingress_port": 5000,
"webui": "http://[HOST]:[PORT:5000]",
"options": {
},
"schema": {
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -0,0 +1,4 @@
aiohttp[speedups]
google-assistant-sdk[samples]
google-auth-oauthlib[tool]
uvloop