Slide 1

Slide 1 text

Creating a Voice- Driven TV Remote with Azure and Alexa Greg Shackles Principal Engineer, Olo @gregshackles.com @gshackles

Slide 2

Slide 2 text

Amazon Echo Logitech Harmony Elite & Hub

Slide 3

Slide 3 text

Logitech’s Alexa Skill Alexa, turn on the TV Alexa, turn on ESPN Alexa, turn on AppleTV

Slide 4

Slide 4 text

We Can Do Better

Slide 5

Slide 5 text

Project Goals Change channels by searching listings Finally use that Raspberry Pi I bought 5 years ago No other servers/infrastructure to manage Use F# and Azure As cheap as possible

Slide 6

Slide 6 text

Getting the Data

Slide 7

Slide 7 text

XML TV Listings $20 / year xmltvlistings.com

Slide 8

Slide 8 text

Azure SQL Database Smallest instance $5 / month

Slide 9

Slide 9 text

Channel Format CBS (WCBS) New York, NY WCBS-TV1 2 newyork.cbslocal.com/

Slide 10

Slide 10 text

Parsing the Data type Channels = XmlProvider<"./channels.xml"> let doc = Channels.Parse (File.ReadAllText "./channels.xml") type TvChannel = { XmlTvId: string; FullName: string; DisplayName: string; Number: int }

Slide 11

Slide 11 text

Loading Into SQL doc.Channels |> Array.map (fun channel -> match channel.DisplayNames with | [| fullName; displayName; number |] -> Some({ XmlTvId = channel.Id FullName = fullName.String.Value DisplayName = Regex.Replace(displayName.String.Value, "\\-HD$", "", RegexOptions.None) Number = number.Number.Value }) | _ -> None ) |> Array.choose id |> Array.filter (fun channel -> channel.Number > 500 && channel.Number < 1000) |> Array.iter (fun channel -> use cmd = new SqlCommandProvider< "INSERT INTO Channel VALUES (@xmlTvId, @displayName, @fullName, @number)", "name=TVListings">() cmd.Execute(xmlTvId = channel.XmlTvId, displayName = channel.DisplayName, fullName = channel.FullName, number = channel.Number) |> ignore printfn "Added channel: %s" channel.DisplayName )

Slide 12

Slide 12 text

DownloadLineup open FSharp.Data let Run(timerTrigger: TimerInfo, log: TraceWriter, lineupsBlob: byref) = let lineupUrl = sprintf "https://www.xmltvlistings.com/xmltv/get/%s/%s/2/0" (Environment.GetEnvironmentVariable("XmlTvApiKey")) (Environment.GetEnvironmentVariable("XmlTvLineupId")) let xml = Http.RequestString(lineupUrl, responseEncodingOverride = "utf-8") lineupsBlob <- xml sprintf "Successfully downloaded XML, length: %i" xml.Length |> log.Info Runs every morning

Slide 13

Slide 13 text

ImportLineup type Listings = XmlProvider<"D:\\home\\site\\wwwroot\\ImportLineup\\schedule-sample.xml"> let addShow (log: TraceWriter) (channelLookup: IDictionary) (show: Listings.Programme) = match channelLookup.TryGetValue show.Channel with | (true, channelId) -> let title = match (show.Title.Value, show.SubTitle) with | "Movie", Some(subtitle) -> subtitle.Value.Trim() | title, _ -> title.Trim() let description = if show.Desc.IsSome then show.Desc.Value.Value else "" Database.addShow title show.Start show.Stop channelId description show.Category.Value sprintf "Added show : %s" title |> log.Info | (false, _) -> () Runs when a new file arrives in blob storage

Slide 14

Slide 14 text

Adding Search

Slide 15

Slide 15 text

Azure Search Service Created indexes for channels shows Both sourced from SQL Server Free tier: 50MB, 10K documents

Slide 16

Slide 16 text

Searching for a Show

Slide 17

Slide 17 text

Search Service Usage

Slide 18

Slide 18 text

Re-Indexing

Slide 19

Slide 19 text

Modifying ImportLineup { "type": "queue", "name": "outputQueueItem", "queueName": "rebuild-requests", "connection": "tvlistingstorage_STORAGE", "direction": "out" } let Run(xml: string, name: string, log: TraceWriter, outputQueueItem: byref) = // ... outputQueueItem <- name Write to queue when it finishes importing:

Slide 20

Slide 20 text

RebuildSearchIndex type SearchRequest = JsonProvider<""" {"filter": "ChannelId eq 125", "top": 100, "search": "seinf type SearchResults = JsonProvider<""" {"value": [{ "ShowId": "abc" }]} """> type DeleteRequest = JsonProvider<""" {"value": [{ "@search.action": "delete", "ShowId": "abc" }] let search query filter count = SearchRequest.Root(filter, count, query).JsonValue.ToString() |> postJson "indexes('shows')/docs/search" |> SearchResults.Parse |> fun results -> results.Value let rec clearIndex() = let results = search "" "" 1000 if not <| Seq.isEmpty results then results |> Array.map (fun result -> DeleteRequest.Value("delete", result.ShowId)) |> fun ops -> DeleteRequest.Root(ops).JsonValue.ToString() |> postJson "indexes('shows')/docs/index" |> ignore clearIndex() let rebuildIndex() = postJson "indexers/shows-indexer/run" "" |> ignore

Slide 21

Slide 21 text

The Device API

Slide 22

Slide 22 text

Harmony API Node server Query/control Harmony Hubs Protocols HTTP MQTT github.com/maddox/harmony-api

Slide 23

Slide 23 text

Exposing the API Registered shackles.house Certificate through Let's Encrypt No-IP $35 / year for custom domain support Agent running on the Raspberry Pi Proxy through NGINX

Slide 24

Slide 24 text

Let's Encrypt certbot-auto certonly -a webroot --webroot-path=/var/www/html -d shackles.house -d www.shackles.house 0 3 * * 1 /usr/bin/certbot-auto renew >> /var/log/certbot-renew.log 5 3 * * 1 /etc/init.d/nginx restart Renew the certificate every week:

Slide 25

Slide 25 text

NGINX Configuration limit_req_zone $binary_remote_addr zone=harmonyzone:10m rate=1r/s; server { listen 9000 ssl default_server; listen [::]:9000 ssl default_server; # security configuration omitted, see blog for details if ($http_authorization != 'letmein') { return 401; } location / { limit_req zone=harmonyzone burst=5; proxy_pass http://127.0.0.1:8282; proxy_redirect off; } }

Slide 26

Slide 26 text

Alexa, tell the TV to mute

Slide 27

Slide 27 text

DirectCommand Intent { "intents": [ { "intent": "DirectCommand", "slots": [ { "name": "command", "type": "command_name" }, { "name": "target", "type": "command_target" } ] } ] } DirectCommand {command} DirectCommand {command} the {target} DirectCommand {command} {target} Sample utterances:

Slide 28

Slide 28 text

RemoteSkill let executeCommand commandSlug = let url = sprintf "%s/commands/%s" baseUrl commandSlug let authHeader = "Authorization", apiKey Http.RequestString(url, headers = [authHeader], httpMethod = "POST") |> ignore let handleDirectCommand (intent: Intent) = match (Commands.getCommand intent.Slots.["command"].Value) with | Some(command) -> Commands.executeCommand command.Slug buildResponse "OK" true | None -> buildResponse "Sorry, that command is not available right now" true let handleIntent (intent: Intent) = match intent.Name with | "DirectCommand" -> handleDirectCommand intent | _ -> buildResponse "Sorry, I'm not sure how to do that" true

Slide 29

Slide 29 text

RemoteSkill type RemoteSpeechlet(log: TraceWriter) = inherit Speechlet() override this.OnIntent(request: IntentRequest, session: Session): SpeechletResponse = sprintf "OnIntent: request %s, session %s" request.RequestId session.SessionId |> log.Info handleIntent request.Intent let Run(req: HttpRequestMessage, log: TraceWriter) = let speechlet = RemoteSpeechlet log speechlet.GetResponse req AlexaSkillsKit: github.com/AreYouFreeBusy/AlexaSkillsKit.NET

Slide 30

Slide 30 text

Alexa, tell the TV to turn on the Yankees game

Slide 31

Slide 31 text

Changing the Channel let changeChannel number = string number |> Seq.map string |> Seq.iter executeCommand executeCommand "select"

Slide 32

Slide 32 text

Querying Search Service type SearchRequest = JsonProvider<""" {"filter": "ChannelId eq 125", "top": 100, "search": "sei type ShowSearchResults = JsonProvider<""" {"value": [{"@search.score": 2.5086286, "ShowId": "10 "StartTime": "20170102200000", "EndTime": "Description": "Yada yada yada", "Categor type ChannelSearchResults = JsonProvider<""" {"value": [{"@search.score": 1, "ChannelId": "150" "DisplayName": "CBSSN-HD", "FullName": let private search index (request: SearchRequest.Root) = let url = sprintf "%s/indexes('%s')/docs/search?api-version=2015-02-28" baseUrl index let apiKeyHeader = "api-key", apiKey let json = request.JsonValue.ToString() Http.RequestString(url, body = TextRequest json, headers = [ ContentType HttpContentTypes.Json; apiKeyHeader ]) let private searchShows request = search "shows" request |> ShowSearchResults.Parse let private searchChannels request = search "channels" request |> ChannelSearchResults.Parse

Slide 33

Slide 33 text

Finding a Show let findShowOnNow (name: string) = let timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss") let filter = sprintf "StartTime lt '%s' and EndTime gt '%s'" timestamp timestamp SearchRequest.Root(filter, 1, name) |> searchShows |> fun result -> Array.tryPick Some result.Value

Slide 34

Slide 34 text

WatchShow Intent { "intent": "WatchShow", "slots": [ { "name": "name", "type": "show_name" } ] } WatchShow turn on {name} WatchShow watch {name} WatchShow switch to {name} Sample utterances:

Slide 35

Slide 35 text

RemoteSkill let handleWatchShow (intent: Intent) = Search.findShowOnNow intent.Slots.["name"].Value |> function | Some(show) -> Search.findChannel show.ChannelId |> function | Some(channel) -> Commands.changeChannel channel buildResponse "OK" true | None -> buildResponse "Sorry, I could not find the channel for that show" true | None -> buildResponse "Sorry, I could not find that show" true let handleIntent (intent: Intent) = match intent.Name with | "DirectCommand" -> handleDirectCommand intent | "WatchShow" -> handleWatchShow intent | _ -> buildResponse "Sorry, I'm not sure how to do that" true

Slide 36

Slide 36 text

Switching from HTTP to MQTT

Slide 37

Slide 37 text

The HTTP API Had Some Downsides Exposed a public API to my living room Performance kind of sucked A lot of moving parts No-IP NGINX Let's Encrypt

Slide 38

Slide 38 text

MQTT Message Queue Telemetry Transport Lightweight publish/subscribe protocol Built on TCP protocol Commonly used in IoT scenarios Supported by Azure IoT Hub

Slide 39

Slide 39 text

IoT Hub Bridge const mqttServer = require('net').createServer(broker.handle); mqttServer.listen(1883, () => console.log('MQTT broker listening')); function onAzureConnect(err) { azureClient.on('error', console.error); azureClient.on('disconnect', function () { azureClient.removeAllListeners(); azureClient.open(onAzureConnect); }); function sendCommand(command) { const [topic, payload] = command.split(';'); broker.publish({ topic, payload }); } azureClient.on('message', msg => msg.data.toString() .split('\n') .forEach((command, index) => setTimeout( () => sendCommand(command), index * CommandDelayMs))); } azureClient.open(onAzureConnect);

Slide 40

Slide 40 text

Sending a Command

Slide 41

Slide 41 text

RemoteSkill let executeCommands commandSlugs = async { commandSlugs |> Seq.map (fun slug -> sprintf "harmony-api/hubs/living-room/command;%s" slug) |> String.concat "\n" |> Encoding.ASCII.GetBytes |> fun bytes -> new Message(bytes) |> fun message -> serviceClient.SendAsync("harmony-bridge", message) |> Async.AwaitIAsyncResult |> Async.Ignore |> ignore } |> Async.RunSynchronously let executeCommand commandSlug = executeCommands [commandSlug]

Slide 42

Slide 42 text

Persisting Available Commands CREATE TABLE AvailableCommand( AvailableCommandId int IDENTITY(1,1) NOT NULL, Name nvarchar(32) NOT NULL, Slug nvarchar(16) NOT NULL, Label nvarchar(32) NOT NULL ) broker.on('publish', packet => { if (packet.topic !== 'harmony-api/hubs/living-room/current_activity') { return; } request({ url: 'http://localhost:8282/hubs/living-room/commands', json: true }) .then(res => updateAvailableCommands(res.commands)); });

Slide 43

Slide 43 text

Monitoring Performance

Slide 44

Slide 44 text

Application Insights

Slide 45

Slide 45 text

Timing Sub-Operations let private telemetryClient = TelemetryClient(InstrumentationKey = instrumentationKey) let setOperationId operationId = telemetryClient.Context.Operation.Id <- operationId let startOperation (name:string) = telemetryClient.StartOperation(name) let private searchShows request = use operation = Telemetry.startOperation "ShowSearch" search "shows" request |> ShowSearchResults.Parse let private searchChannels request = use operation = Telemetry.startOperation "ChannelSearch" search "channels" request |> ChannelSearchResults.Parse let executeCommand commandSlug = use operation = Telemetry.startOperation "ExecuteCommand" // ...

Slide 46

Slide 46 text

Viewing Performance

Slide 47

Slide 47 text

What's The Cost?

Slide 48

Slide 48 text

Total: $7.56 Costs Component Monthly Cost XML TV Listings $1.67 SQL Server $4.99 Storage $0.63 App Service $0.27 IoT Hub Free Search Service Free Application Insights Free

Slide 49

Slide 49 text

Let's Check It Out

Slide 50

Slide 50 text

Questions? Greg Shackles Principal Engineer, Olo gregshackles.com @gshackles gregshackles.com/tag/remote