├── .vscode
├── settings.json
├── tasks.json
└── launch.json
├── Puppet.Api
├── Properties
│ ├── serviceDependencies.json
│ └── serviceDependencies.local.json
├── appsettings.Development.json
├── Models
│ └── HubitatNotification.cs
├── Dockerfile
├── Program.cs
├── appsettings.json
├── Puppet.Api.csproj
├── Controllers
│ └── HubitatEventController.cs
└── Startup.cs
├── Puppet.Automation
├── devicemap.json
├── Puppet.Automation.csproj
└── SampleAutomation.cs
├── Puppet.Common
├── Exceptions
│ ├── InvalidMqttTopicException.cs
│ ├── AutomationHubException.cs
│ └── DeviceNotFoundException.cs
├── Events
│ ├── AutomationEventEventArgs.cs
│ ├── MqttMessageReceivedEventArgs.cs
│ ├── ExtensionMethods.cs
│ └── HubEvent.cs
├── Services
│ ├── IMqttService.cs
│ ├── MqttService.cs
│ ├── HomeAutomationPlatform.cs
│ └── Hubitat.cs
├── Notifiers
│ ├── INotifier.cs
│ └── ExtensionMethods.cs
├── Devices
│ ├── IDevice.cs
│ ├── Capability.cs
│ ├── GenericDevice.cs
│ ├── Speaker.cs
│ ├── ContactSensor.cs
│ ├── MotionSensor.cs
│ ├── Lock.cs
│ ├── SwitchRelay.cs
│ ├── DeviceBase.cs
│ ├── ExtensionMethods.cs
│ └── Fan.cs
├── Models
│ ├── SunriseAndSunset.cs
│ └── HubitatDevice.cs
├── Automation
│ ├── IAutomation.cs
│ ├── RunPerDeviceAttribute.cs
│ ├── TriggerDeviceAttribute.cs
│ ├── PowerAllowanceBase.cs
│ ├── AutomationBase.cs
│ ├── TriggeredLightingAutomationBase.cs
│ └── DoorWatcherBase.cs
├── Configuration
│ ├── HubitatOptions.cs
│ └── MqttOptions.cs
├── Telemetry
│ ├── HubitatAccessTokenFilter.cs
│ └── AppInsights.cs
└── Puppet.Common.csproj
├── Puppet.Executive
├── Interfaces
│ └── IExecutive.cs
├── Mqtt
│ └── MqttClientFactory.cs
├── Puppet.Executive.csproj
├── Automation
│ ├── AutomationFactory.cs
│ └── AutomationTaskManager.cs
└── Services
│ └── Executive.cs
├── .dockerignore
├── azure-pipelines.yml
├── Hubitat
├── puppet-virtual-garage-door.groovy
├── puppet-virtual-lock.groovy
├── puppet-aux-endpoint.groovy
└── vlc-thing.groovy
├── publish.bat
├── LICENSE
├── Puppet.sln
├── README.md
└── .gitignore
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "cSpell.words": [
3 | "Alexa",
4 | "Hubitat"
5 | ]
6 | }
--------------------------------------------------------------------------------
/Puppet.Api/Properties/serviceDependencies.json:
--------------------------------------------------------------------------------
1 | {
2 | "dependencies": {
3 | "appInsights1": {
4 | "type": "appInsights"
5 | }
6 | }
7 | }
--------------------------------------------------------------------------------
/Puppet.Api/Properties/serviceDependencies.local.json:
--------------------------------------------------------------------------------
1 | {
2 | "dependencies": {
3 | "appInsights1": {
4 | "type": "appInsights.sdk"
5 | }
6 | }
7 | }
--------------------------------------------------------------------------------
/Puppet.Automation/devicemap.json:
--------------------------------------------------------------------------------
1 | {
2 | "SampleDevices": {
3 | "SampleDoor": "101",
4 | "SampleLight": "102",
5 | "SampleSpeaker": "103"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/Puppet.Common/Exceptions/InvalidMqttTopicException.cs:
--------------------------------------------------------------------------------
1 | using System;
2 |
3 | namespace Puppet.Common.Exceptions
4 | {
5 | public class InvalidMqttTopicException : Exception
6 | {
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/Puppet.Api/appsettings.Development.json:
--------------------------------------------------------------------------------
1 | {
2 | "Logging": {
3 | "LogLevel": {
4 | "Default": "Information",
5 | "Microsoft": "Warning",
6 | "Microsoft.Hosting.Lifetime": "Information"
7 | }
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/Puppet.Common/Events/AutomationEventEventArgs.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Text;
4 |
5 | namespace Puppet.Common.Events
6 | {
7 | public class AutomationEventEventArgs
8 | {
9 | public HubEvent HubEvent { get; set; }
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/Puppet.Common/Exceptions/AutomationHubException.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Linq;
4 | using System.Threading.Tasks;
5 |
6 | namespace Puppet.Common.Exceptions
7 | {
8 | public class AutomationHubException : System.Net.WebException
9 | {
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/Puppet.Api/Models/HubitatNotification.cs:
--------------------------------------------------------------------------------
1 | using Newtonsoft.Json;
2 | using Puppet.Common.Events;
3 |
4 | namespace Puppet.Api.Models
5 | {
6 | public class HubitatNotification
7 | {
8 | [JsonProperty(PropertyName = "content")]
9 | public HubEvent Content { get; set; }
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/Puppet.Common/Services/IMqttService.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Threading.Tasks;
3 | using Puppet.Common.Events;
4 |
5 | namespace Puppet.Common.Services
6 | {
7 | public interface IMqttService
8 | {
9 | Task SendEventToMqttAsync(HubEvent evt);
10 | Task Start();
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/Puppet.Common/Notifiers/INotifier.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Linq;
4 | using System.Text;
5 | using System.Threading.Tasks;
6 |
7 | namespace Puppet.Common.Notifiers
8 | {
9 | public interface INotifier
10 | {
11 | Task SendNotification(string message);
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/Puppet.Common/Events/MqttMessageReceivedEventArgs.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Text;
4 |
5 | namespace Puppet.Common.Events
6 | {
7 | public class MqttMessageReceivedEventArgs
8 | {
9 | public string Topic { get; set; }
10 | public string Payload { get; set; }
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/Puppet.Common/Exceptions/DeviceNotFoundException.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Text;
4 |
5 | namespace Puppet.Common.Exceptions
6 | {
7 | public class DeviceNotFoundException : System.Exception
8 | {
9 | public DeviceNotFoundException(string message) : base(message){}
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/Puppet.Common/Devices/IDevice.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Linq;
4 | using System.Threading.Tasks;
5 |
6 | namespace Puppet.Common.Devices
7 | {
8 | public interface IDevice
9 | {
10 | string Id { get; }
11 | string Name { get; }
12 | string Label { get; }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/Puppet.Common/Models/SunriseAndSunset.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using Newtonsoft.Json;
3 |
4 | namespace Puppet.Common.Models
5 | {
6 | public class SunriseAndSunset
7 | {
8 | [JsonProperty("sunrise")]
9 | public DateTime Sunrise { get; set; }
10 |
11 | [JsonProperty("sunset")]
12 | public DateTime Sunset { get; set; }
13 | }
14 | }
--------------------------------------------------------------------------------
/Puppet.Executive/Interfaces/IExecutive.cs:
--------------------------------------------------------------------------------
1 | using Puppet.Common.Events;
2 |
3 | using System;
4 | using System.Collections.Generic;
5 | using System.Linq;
6 | using System.Text;
7 | using System.Threading.Tasks;
8 |
9 | namespace Puppet.Executive.Interfaces
10 | {
11 | public interface IExecutive
12 | {
13 | public void ProcessEvent(HubEvent evt);
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Puppet.Common/Devices/Capability.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Text;
4 |
5 | namespace Puppet.Common.Devices
6 | {
7 | public enum Capability
8 | {
9 | Switch,
10 | Contact,
11 | Motion,
12 | Lock,
13 | Pushed,
14 | Held,
15 | DoubleTapped,
16 | Door,
17 | Speed
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/Puppet.Common/Devices/GenericDevice.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Text;
4 | using System.Threading.Tasks;
5 | using Puppet.Common.Services;
6 |
7 | namespace Puppet.Common.Devices
8 | {
9 | public class GenericDevice : DeviceBase
10 | {
11 | public GenericDevice(HomeAutomationPlatform hub, string id) : base(hub, id)
12 | {
13 | }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | **/.classpath
2 | **/.dockerignore
3 | **/.env
4 | **/.git
5 | **/.gitignore
6 | **/.project
7 | **/.settings
8 | **/.toolstarget
9 | **/.vs
10 | **/.vscode
11 | **/*.*proj.user
12 | **/*.dbmdl
13 | **/*.jfm
14 | **/azds.yaml
15 | **/bin
16 | **/charts
17 | **/docker-compose*
18 | **/Dockerfile*
19 | **/node_modules
20 | **/npm-debug.log
21 | **/obj
22 | **/secrets.dev.yaml
23 | **/values.dev.yaml
24 | LICENSE
25 | README.md
--------------------------------------------------------------------------------
/Puppet.Common/Automation/IAutomation.cs:
--------------------------------------------------------------------------------
1 | using Puppet.Common.Events;
2 | using System;
3 | using System.Collections.Generic;
4 | using System.Text;
5 | using System.Threading;
6 | using System.Threading.Tasks;
7 |
8 | namespace Puppet.Common.Automation
9 | {
10 | public interface IAutomation
11 | {
12 | ///
13 | /// Handles events coming from the home automation controller.
14 | ///
15 | Task Handle(CancellationToken token);
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/Puppet.Common/Configuration/HubitatOptions.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Text;
4 |
5 | namespace Puppet.Common.Configuration
6 | {
7 | public class HubitatOptions
8 | {
9 | public string HubitatHostNameOrIp { get; set; }
10 | public int MakerApiAppId { get; set; }
11 | public string AccessToken { get; set; }
12 | public int AuxAppId { get; set; }
13 | public string AuxAppAccessToken { get; set; }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/.vscode/tasks.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "2.0.0",
3 | "tasks": [
4 | {
5 | "label": "buildExecutive",
6 | "command": "dotnet",
7 | "type": "process",
8 | "args": [
9 | "build",
10 | "${workspaceFolder}/Puppet.sln"
11 | ],
12 | "problemMatcher": "$msCompile",
13 | "group": {
14 | "kind": "build",
15 | "isDefault": true
16 | }
17 | }
18 | ]
19 | }
--------------------------------------------------------------------------------
/Puppet.Common/Automation/RunPerDeviceAttribute.cs:
--------------------------------------------------------------------------------
1 | using System;
2 |
3 | namespace Puppet.Common.Automation
4 | {
5 | ///
6 | /// Indicates that this IAutomation should run one instance per device listed as a TriggerDevice. Otherwise, the automation is only allowed to have one instance at any time.
7 | ///
8 | [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
9 | public sealed class RunPerDeviceAttribute : Attribute
10 | {
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/azure-pipelines.yml:
--------------------------------------------------------------------------------
1 | # ASP.NET Core
2 | # Build and test ASP.NET Core projects targeting .NET Core.
3 | # Add steps that run tests, create a NuGet package, deploy, and more:
4 | # https://docs.microsoft.com/azure/devops/pipelines/languages/dotnet-core
5 |
6 | trigger:
7 | - master
8 |
9 | pool:
10 | vmImage: 'Ubuntu-16.04'
11 |
12 | variables:
13 | buildConfiguration: 'Release'
14 |
15 | steps:
16 | - script: dotnet build --configuration $(buildConfiguration)
17 | displayName: 'dotnet build $(buildConfiguration)'
18 |
--------------------------------------------------------------------------------
/Puppet.Common/Models/HubitatDevice.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Text;
4 | using Newtonsoft.Json;
5 |
6 | namespace Puppet.Common.Models
7 | {
8 | public class HubitatDevice
9 | {
10 | [JsonProperty(PropertyName = "id")]
11 | public string Id { get; set; }
12 | [JsonProperty(PropertyName = "name")]
13 | public string Name { get; set; }
14 | [JsonProperty(PropertyName = "label")]
15 | public string Label { get; set; }
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/Puppet.Common/Configuration/MqttOptions.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Text;
4 |
5 | namespace Puppet.Common.Configuration
6 | {
7 | public class MqttOptions
8 | {
9 | public bool Enabled { get; set; }
10 | public string ClientId { get; set; }
11 | public string BrokerHostNameOrIp { get; set; }
12 | public int Port { get; set; }
13 | public bool EnableTls { get; set; }
14 | public string UserName { get; set; }
15 | public string Password { get; set; }
16 | public string TopicRoot { get; set; }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/Puppet.Common/Devices/Speaker.cs:
--------------------------------------------------------------------------------
1 | using Puppet.Common.Services;
2 | using System;
3 | using System.Collections.Generic;
4 | using System.Text;
5 | using System.Threading.Tasks;
6 |
7 | namespace Puppet.Common.Devices
8 | {
9 | public class Speaker : DeviceBase
10 | {
11 | public Speaker(HomeAutomationPlatform hub, string id) : base(hub, id)
12 | {
13 | }
14 |
15 | public async Task Speak(string message)
16 | {
17 | Console.WriteLine($"{DateTime.Now} Speaker {this.Id} speaking: {message}");
18 | await _hub.DoAction(this, "speak", new string[] { message });
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/Puppet.Api/Dockerfile:
--------------------------------------------------------------------------------
1 | #See https://aka.ms/containerfastmode to understand how Visual Studio uses this Dockerfile to build your images for faster debugging.
2 |
3 | FROM mcr.microsoft.com/dotnet/aspnet:5.0-buster-slim AS base
4 | WORKDIR /app
5 | EXPOSE 80
6 |
7 | FROM mcr.microsoft.com/dotnet/sdk:5.0-buster-slim AS build
8 | WORKDIR /src
9 | COPY ["Puppet.Api/Puppet.Api.csproj", "Puppet.Api/"]
10 | RUN dotnet restore "Puppet.Api/Puppet.Api.csproj"
11 | COPY . .
12 | WORKDIR "/src/Puppet.Api"
13 | RUN dotnet build "Puppet.Api.csproj" -c Release -o /app/build
14 |
15 | FROM build AS publish
16 | RUN dotnet publish "Puppet.Api.csproj" -c Release -o /app/publish
17 |
18 | FROM base AS final
19 | WORKDIR /app
20 | COPY --from=publish /app/publish .
21 | ENTRYPOINT ["dotnet", "Puppet.Api.dll"]
--------------------------------------------------------------------------------
/Puppet.Api/Program.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.AspNetCore.Hosting;
2 | using Microsoft.Extensions.Configuration;
3 | using Microsoft.Extensions.Hosting;
4 | using Microsoft.Extensions.Logging;
5 |
6 | using System;
7 | using System.Collections.Generic;
8 | using System.Linq;
9 | using System.Threading.Tasks;
10 |
11 | namespace Puppet.Api
12 | {
13 | public class Program
14 | {
15 | public static void Main(string[] args)
16 | {
17 | CreateHostBuilder(args).Build().Run();
18 | }
19 |
20 | public static IHostBuilder CreateHostBuilder(string[] args) =>
21 | Host.CreateDefaultBuilder(args)
22 | .ConfigureWebHostDefaults(webBuilder =>
23 | {
24 | webBuilder.UseStartup();
25 | });
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/Puppet.Common/Notifiers/ExtensionMethods.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Linq;
4 | using System.Text;
5 | using System.Threading.Tasks;
6 |
7 | namespace Puppet.Common.Notifiers
8 | {
9 | public static class ExtensionMethods
10 | {
11 | public static async Task SendMessage(this IEnumerable notifiers, string message)
12 | {
13 | foreach (var n in notifiers)
14 | {
15 | try
16 | {
17 | await n.SendNotification(message);
18 | }
19 | catch (Exception ex)
20 | {
21 | Console.WriteLine($"Notify operation was unsuccessful for speaker {n} -- {ex}");
22 | }
23 | }
24 | }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/Puppet.Common/Automation/TriggerDeviceAttribute.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using Puppet.Common.Devices;
3 |
4 | namespace Puppet.Common.Automation
5 | {
6 | ///
7 | /// Indicates that this IAutomation should be triggered by the specified device's capability.
8 | ///
9 | [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)]
10 | public sealed class TriggerDeviceAttribute : Attribute
11 | {
12 | private readonly string _deviceMappedName;
13 | private readonly Capability _capability;
14 |
15 | public TriggerDeviceAttribute(string deviceMappedName, Capability capability)
16 | {
17 | _deviceMappedName = deviceMappedName;
18 | _capability = capability;
19 | }
20 |
21 | public string DeviceMappedName => _deviceMappedName;
22 | public Capability Capability => _capability;
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Hubitat/puppet-virtual-garage-door.groovy:
--------------------------------------------------------------------------------
1 | /**
2 | * Virtual Garage Door
3 | *
4 | */
5 | metadata {
6 | definition (name: "Puppet Virtual Garage Door", namespace: "camthegeek", author: "Cam Soper") {
7 | capability "Contact Sensor"
8 | capability "Garage Door Control"
9 | command "confirmOpen"
10 | command "confirmClosed"
11 | }
12 | }
13 |
14 | def open() {
15 | log.debug "open()"
16 | if (device.currentState("door").value != "open"){
17 | sendEvent(name: "door", value: "opening")
18 | }
19 | }
20 |
21 | def close() {
22 | log.debug "close()"
23 | if (device.currentState("door").value != "closed"){
24 | sendEvent(name: "door", value: "closing")
25 | }
26 | }
27 |
28 |
29 | def confirmClosed(){
30 | sendEvent(name: "door", value: "closed")
31 | sendEvent(name: "contact", value: "closed")
32 | }
33 |
34 | def confirmOpen(){
35 | sendEvent(name: "door", value: "open")
36 | sendEvent(name: "contact", value: "open")
37 | }
--------------------------------------------------------------------------------
/Puppet.Common/Automation/PowerAllowanceBase.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Threading;
3 | using System.Threading.Tasks;
4 | using Puppet.Common.Devices;
5 | using Puppet.Common.Events;
6 | using Puppet.Common.Services;
7 |
8 | namespace Puppet.Common.Automation
9 | {
10 | ///
11 | /// IAutomation handler for turning off switch devices
12 | /// after a certain number of minutes.
13 | ///
14 | public abstract class PowerAllowanceBase : AutomationBase
15 | {
16 | public TimeSpan HowLong { get; set; }
17 |
18 | public PowerAllowanceBase(HomeAutomationPlatform hub, HubEvent evt) : base(hub, evt)
19 | {
20 |
21 | }
22 |
23 | protected override async Task Handle()
24 | {
25 | if (_evt.IsOnEvent)
26 | {
27 | await WaitForCancellationAsync(HowLong);
28 | SwitchRelay relay = await _hub.GetDeviceById(_evt.DeviceId);
29 | await relay.Off();
30 | }
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/Puppet.Common/Events/ExtensionMethods.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Linq;
4 | using System.Text;
5 | using System.Threading.Tasks;
6 |
7 | namespace Puppet.Common.Events
8 | {
9 | public static class ExtensionMethods
10 | {
11 |
12 | public static ButtonState GetButtonState(this HubEvent evt)
13 | {
14 | if (evt.IsButtonPushedEvent)
15 | {
16 | return ButtonState.Pushed;
17 | }
18 | else if (evt.IsButtonHeldEvent)
19 | {
20 | return ButtonState.Held;
21 | }
22 | else if (evt.IsButtonDoubleTappedEvent)
23 | {
24 | return ButtonState.DoubleTapped;
25 | }
26 | else
27 | {
28 | return ButtonState.Unknown;
29 | }
30 | }
31 | }
32 | public enum ButtonState
33 | {
34 | Pushed,
35 | Held,
36 | DoubleTapped,
37 | Released,
38 | Unknown
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/publish.bat:
--------------------------------------------------------------------------------
1 | @echo off
2 | REM This file automatically deploys the assemblies to my Raspberry Pi.
3 | REM You'll need to set up SSH key authentication and make changes
4 | REM specific to your environment.
5 | REM
6 |
7 | echo Deploying Executive...
8 | scp pi@automation:/home/pi/executive/appsettings.json ./
9 | pushd Puppet.Executive
10 | dotnet publish -r linux-arm -c Release
11 | ssh pi@automation kill $(pidof Puppet.Executive)
12 | ssh pi@automation rm /home/pi/executive/*
13 | scp ./bin/Release/netcoreapp3.1/linux-arm/publish/* pi@automation:/home/pi/executive
14 | popd
15 | scp ./appsettings.json pi@automation:/home/pi/executive/
16 | del appsettings.json
17 | ssh pi@automation chmod 755 /home/pi/executive/Puppet.Executive
18 |
19 | echo Deploying Automation Handlers...
20 | pushd Puppet.Automation
21 | dotnet publish -r linux-arm -c Release
22 | scp ./bin/Release/netcoreapp3.1/linux-arm/publish/Puppet.Automation.* pi@automation:/home/pi/executive
23 | scp ./devicemap.json pi@automation:/home/pi/executive
24 | popd
25 |
26 | echo Rebooting!
27 | ssh pi@automation sudo reboot
--------------------------------------------------------------------------------
/Puppet.Common/Devices/ContactSensor.cs:
--------------------------------------------------------------------------------
1 | using Puppet.Common.Services;
2 | using System;
3 | using System.Collections.Generic;
4 | using System.Linq;
5 | using System.Threading.Tasks;
6 |
7 | namespace Puppet.Common.Devices
8 | {
9 | public enum ContactStatus
10 | {
11 | Open,
12 | Closed,
13 | Unknown
14 | }
15 |
16 | public class ContactSensor : DeviceBase
17 | {
18 | public ContactSensor(HomeAutomationPlatform hub, string id) : base(hub, id)
19 | {
20 | }
21 |
22 | public ContactStatus Status
23 | {
24 | get
25 | {
26 | switch(GetState()["contact"])
27 | {
28 | case "open":
29 | return ContactStatus.Open;
30 |
31 | case "closed":
32 | return ContactStatus.Closed;
33 |
34 | default:
35 | return ContactStatus.Unknown;
36 | }
37 |
38 | }
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/Hubitat/puppet-virtual-lock.groovy:
--------------------------------------------------------------------------------
1 | /**
2 | * Virtual Lock
3 | *
4 | */
5 | metadata {
6 | definition (name: "Puppet Virtual Lock", namespace: "camthegeek", author: "Cam Soper") {
7 | capability "Actuator"
8 | capability "Lock"
9 | command "confirmLocked"
10 | command "confirmUnlocked"
11 | }
12 | }
13 |
14 | def lock() {
15 | log.debug "lock()"
16 | if (device.currentState("lock").value != "locked"){
17 | sendEvent(name: "lock", value: "locking", descriptionText: "${device.displayName} is locking")
18 | }
19 | }
20 |
21 | def unlock() {
22 | log.debug "unlock()"
23 | if (device.currentState("lock").value != "unlocked"){
24 | sendEvent(name: "lock", value: "unlocking", descriptionText: "${device.displayName} is unlocking")
25 | }
26 | }
27 |
28 | def confirmLocked(){
29 | sendEvent(name: "lock", value: "locked", descriptionText: "${device.displayName} is locked")
30 | }
31 |
32 | def confirmUnlocked(){
33 | sendEvent(name: "lock", value: "unlocked", descriptionText: "${device.displayName} is unlocked")
34 | }
--------------------------------------------------------------------------------
/Puppet.Api/appsettings.json:
--------------------------------------------------------------------------------
1 | {
2 | "Hubitat": {
3 | "HubitatHostNameOrIp": "x.x.x.x",
4 | "MakerApiAppId": 111,
5 | "AccessToken": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
6 | "AuxAppId": 111,
7 | "AuxAppAccessToken": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
8 | },
9 | "MQTT": {
10 | "Enabled": false,
11 | "ClientId": "Puppet",
12 | "BrokerHostNameOrIp": "x.x.x.x",
13 | "Port": 11111,
14 | "EnableTls": false,
15 | "UserName": "username",
16 | "Password": "password",
17 | "TopicRoot": "puppet"
18 | },
19 | "FrontDoorLock": {
20 | "SmartThingsIntegrationEndpoint": "uri",
21 | "SmartThingsToken": "token"
22 | },
23 | "HomeAssistant": {
24 | "IntegrationEndpoint": "uri",
25 | "Token": "token"
26 | },
27 | "ApplicationInsights": {
28 | "InstrumentationKey": "key"
29 | },
30 | "GarageDoorConnectionString": "string",
31 | "Logging": {
32 | "LogLevel": {
33 | "Default": "Information",
34 | "Microsoft": "Warning",
35 | "Microsoft.Hosting.Lifetime": "Information"
36 | }
37 | },
38 | "AllowedHosts": "*"
39 | }
40 |
--------------------------------------------------------------------------------
/Puppet.Common/Telemetry/HubitatAccessTokenFilter.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.ApplicationInsights.Channel;
2 | using Microsoft.ApplicationInsights.DataContracts;
3 | using Microsoft.ApplicationInsights.Extensibility;
4 |
5 | namespace Puppet.Common.Telemetry
6 | {
7 | public class HubitatAccessTokenDependencyFilter : ITelemetryProcessor
8 | {
9 | private ITelemetryProcessor Next { get; set; }
10 | private const string ACCESSTOKENKEY = "?access_token=";
11 |
12 | // Link processors to each other in a chain.
13 | public HubitatAccessTokenDependencyFilter(ITelemetryProcessor next)
14 | {
15 | this.Next = next;
16 | }
17 | public void Process(ITelemetry item)
18 | {
19 | var dependency = item as DependencyTelemetry;
20 | if(dependency != null && dependency.Data.Contains(ACCESSTOKENKEY))
21 | {
22 | var data = dependency.Data;
23 | dependency.Data = data.Substring(0, data.IndexOf(ACCESSTOKENKEY));
24 | }
25 | this.Next.Process(item);
26 | }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Cam Soper
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Puppet.Automation/Puppet.Automation.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net5.0
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | PreserveNewest
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/Puppet.Common/Devices/MotionSensor.cs:
--------------------------------------------------------------------------------
1 | using Puppet.Common.Services;
2 | using System;
3 | using System.Collections.Generic;
4 | using System.Text;
5 |
6 | namespace Puppet.Common.Devices
7 | {
8 | public enum MotionStatus
9 | {
10 | Active,
11 | Inactive,
12 | Unknown
13 | }
14 | public class MotionSensor : DeviceBase
15 | {
16 | public MotionSensor(HomeAutomationPlatform hub, string id) : base(hub, id)
17 | {
18 | }
19 |
20 | public MotionStatus Status
21 | {
22 | get
23 | {
24 | switch (GetState()["motion"])
25 | {
26 | case "active":
27 | return MotionStatus.Active;
28 |
29 | case "inactive":
30 | return MotionStatus.Inactive;
31 |
32 | default:
33 | return MotionStatus.Unknown;
34 | }
35 |
36 | }
37 | }
38 |
39 | public bool IsActive
40 | {
41 | get
42 | {
43 | return this.Status == MotionStatus.Active;
44 | }
45 | }
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/Puppet.Api/Puppet.Api.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net5.0
5 | Linux
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/Puppet.Common/Devices/Lock.cs:
--------------------------------------------------------------------------------
1 | using Puppet.Common.Services;
2 | using System;
3 | using System.Collections.Generic;
4 | using System.Text;
5 | using System.Threading.Tasks;
6 |
7 | namespace Puppet.Common.Devices
8 | {
9 | public enum LockStatus
10 | {
11 | Locked,
12 | Unlocked,
13 | Unknown
14 | }
15 | public class LockDevice : DeviceBase
16 | {
17 | public LockDevice(HomeAutomationPlatform hub, string id) : base(hub, id)
18 | {
19 | }
20 |
21 | public async Task Lock()
22 | {
23 | await _hub.DoAction(this, "lock");
24 | }
25 |
26 | public async Task Unlock()
27 | {
28 | await _hub.DoAction(this, "unlock");
29 | }
30 |
31 | public LockStatus Status
32 | {
33 | get
34 | {
35 | switch (GetState()["lock"])
36 | {
37 | case "locked":
38 | return LockStatus.Locked;
39 |
40 | case "unlocked":
41 | return LockStatus.Unlocked;
42 |
43 | default:
44 | return LockStatus.Unknown;
45 | }
46 |
47 | }
48 | }
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/Puppet.Api/Controllers/HubitatEventController.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.AspNetCore.Mvc;
2 | using Microsoft.AspNetCore.WebUtilities;
3 |
4 | using Puppet.Api.Models;
5 | using Puppet.Executive.Interfaces;
6 |
7 | using System;
8 | using System.Collections.Generic;
9 | using System.IO;
10 | using System.Linq;
11 | using System.Net;
12 | using System.Threading.Tasks;
13 |
14 | // For more information on enabling Web API for empty projects, visit https://go.microsoft.com/fwlink/?LinkID=397860
15 |
16 | namespace Puppet.Api.Controllers
17 | {
18 | [Route("api/[controller]")]
19 | [ApiController]
20 | public class HubitatEventController : ControllerBase
21 | {
22 | IExecutive _executive;
23 |
24 | public HubitatEventController(IExecutive executive)
25 | {
26 | _executive = executive;
27 | }
28 |
29 | // GET: api/
30 | [HttpGet]
31 | public ActionResult Get()
32 | {
33 | return new ContentResult() { Content = "☕", StatusCode = 418 };
34 | }
35 |
36 |
37 | // POST api/
38 | [HttpPost]
39 | public void Post([FromBody] HubitatNotification notification)
40 | {
41 | _executive.ProcessEvent(notification.Content);
42 | }
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/Puppet.Common/Puppet.Common.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net5.0
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Use IntelliSense to find out which attributes exist for C# debugging
3 | // Use hover for the description of the existing attributes
4 | // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md
5 | "version": "0.2.0",
6 | "configurations": [
7 | {
8 | "name": "Puppet Executive Launch",
9 | "type": "coreclr",
10 | "request": "launch",
11 | "preLaunchTask": "buildExecutive",
12 | // If you have changed target frameworks, make sure to update the program path.
13 | "program": "${workspaceFolder}/Puppet.Executive/bin/Debug/netcoreapp3.1/Puppet.Executive.dll",
14 | "args": [],
15 | "cwd": "${workspaceFolder}/Puppet.Executive/bin/Debug/netcoreapp3.1/",
16 | // For more information about the 'console' field, see https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md#console-terminal-window
17 | "console":"integratedTerminal",
18 | "stopAtEntry": false,
19 | "internalConsoleOptions":"neverOpen"
20 | },
21 | {
22 | "name": ".NET Core Attach",
23 | "type": "coreclr",
24 | "request": "attach",
25 | "processId": "${command:pickProcess}"
26 | }
27 | ,]
28 | }
--------------------------------------------------------------------------------
/Puppet.Common/Devices/SwitchRelay.cs:
--------------------------------------------------------------------------------
1 |
2 | using Puppet.Common.Services;
3 | using System.Threading.Tasks;
4 |
5 | namespace Puppet.Common.Devices
6 | {
7 | public enum SwitchStatus
8 | {
9 | On,
10 | Off,
11 | Unknown
12 | }
13 |
14 | public class SwitchRelay : DeviceBase
15 | {
16 |
17 | public SwitchRelay(HomeAutomationPlatform hub, string id) : base(hub, id)
18 | {
19 | }
20 |
21 | public SwitchStatus Status
22 | {
23 | get
24 | {
25 | switch (GetState()["switch"])
26 | {
27 | case "on":
28 | return SwitchStatus.On;
29 |
30 | case "off":
31 | return SwitchStatus.Off;
32 |
33 | default:
34 | return SwitchStatus.Unknown;
35 | }
36 |
37 | }
38 | }
39 |
40 | public bool IsOn
41 | {
42 | get
43 | {
44 | return this.Status == SwitchStatus.On;
45 | }
46 | }
47 |
48 | public async Task On()
49 | {
50 | await _hub.DoAction(this, "on");
51 |
52 | }
53 | public async Task Off()
54 | {
55 | await _hub.DoAction(this, "off");
56 | }
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/Puppet.Common/Devices/DeviceBase.cs:
--------------------------------------------------------------------------------
1 | using Puppet.Common.Services;
2 | using System;
3 | using System.Collections.Generic;
4 | using System.Text;
5 | using System.Threading.Tasks;
6 |
7 | namespace Puppet.Common.Devices
8 | {
9 | public abstract class DeviceBase : IDevice
10 | {
11 | internal HomeAutomationPlatform _hub;
12 | internal Dictionary _state;
13 |
14 | public string Id { get; }
15 | public string Name => GetState()["name"];
16 | public string Label => GetState()["label"];
17 |
18 | public DeviceBase(HomeAutomationPlatform hub, string id)
19 | {
20 | _hub = hub;
21 | Id = id;
22 | }
23 |
24 | public async Task RefreshState()
25 | {
26 | _state = await _hub.GetDeviceState(this);
27 | }
28 |
29 | internal Dictionary GetState()
30 | {
31 | if(_state == null)
32 | {
33 | Task.Run(() => RefreshState()).Wait();
34 | }
35 | return _state;
36 | }
37 |
38 | public async Task DoAction(string command, string parameter = null)
39 | {
40 | string[] args = null;
41 | if (parameter != null)
42 | {
43 | args = new string[] { parameter };
44 | }
45 | await _hub.DoAction(this, command, args);
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/Puppet.Executive/Mqtt/MqttClientFactory.cs:
--------------------------------------------------------------------------------
1 | using MQTTnet;
2 | using MQTTnet.Client;
3 | using MQTTnet.Client.Options;
4 | using MQTTnet.Extensions.ManagedClient;
5 | using Puppet.Common.Configuration;
6 | using System;
7 | using System.Threading.Tasks;
8 |
9 | namespace Puppet.Executive.Mqtt
10 | {
11 | public class MqttClientFactory
12 | {
13 | public static async Task GetClient(MqttOptions mqttOptions)
14 | {
15 |
16 |
17 | var clientConnectionOptionsBuilder = new MqttClientOptionsBuilder()
18 | .WithClientId(mqttOptions.ClientId)
19 | .WithTcpServer(mqttOptions.BrokerHostNameOrIp, mqttOptions.Port);
20 |
21 | if (!String.IsNullOrEmpty(mqttOptions.UserName) &&
22 | !String.IsNullOrEmpty(mqttOptions.Password))
23 | {
24 | clientConnectionOptionsBuilder = clientConnectionOptionsBuilder
25 | .WithCredentials(mqttOptions.UserName, mqttOptions.Password);
26 | }
27 |
28 | if (mqttOptions.EnableTls)
29 | {
30 | clientConnectionOptionsBuilder = clientConnectionOptionsBuilder
31 | .WithTls();
32 | }
33 |
34 | var managedClientOptions = new ManagedMqttClientOptionsBuilder()
35 | .WithAutoReconnectDelay(TimeSpan.FromSeconds(5))
36 | .WithClientOptions(clientConnectionOptionsBuilder.Build())
37 | .Build();
38 |
39 | var mqttClient = new MqttFactory().CreateManagedMqttClient();
40 | await mqttClient.StartAsync(managedClientOptions);
41 |
42 | return mqttClient;
43 | }
44 | }
45 |
46 | }
47 |
--------------------------------------------------------------------------------
/Puppet.Executive/Puppet.Executive.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net5.0
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/Hubitat/puppet-aux-endpoint.groovy:
--------------------------------------------------------------------------------
1 | definition(
2 | name: "Puppet Auxilliary Endpoint",
3 | namespace: "camthegeek",
4 | author: "Cam Soper",
5 | description: "Puppet Auxilliary Endpoint",
6 | category: "",
7 | iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png",
8 | iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png",
9 | iconX3Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png")
10 |
11 |
12 | preferences {
13 | page(name: "setupScreen")
14 | }
15 | def setupScreen(){
16 | if(!state.accessToken){
17 | createAccessToken() //be sure to enable OAuth in the app settings or this call will fail
18 | }
19 | return dynamicPage(name: "setupScreen", uninstall: true, install: true){
20 | section ("Notify these phones...") {
21 | input "phones", "capability.notification", multiple: true, required: true
22 | }
23 | section("Where are we speaking?") {
24 | input "speakers", "capability.speechSynthesis", multiple:true, required: true
25 | }
26 | section(){
27 | paragraph("Access token: ${state.accessToken}")
28 | }
29 | }
30 | }
31 |
32 | mappings {
33 | path("/notify") {
34 | action: [
35 | POST: "notifyPhones"
36 | ]
37 | }
38 | path("/suntimes") {
39 | action: [
40 | GET: "getSunTimes"
41 | ]
42 | }
43 | path("/announcement") {
44 | action: [
45 | POST: "announcement"
46 | ]
47 | }
48 | }
49 |
50 | def getSunTimes() {
51 | return getSunriseAndSunset()
52 | }
53 |
54 | void notifyPhones() {
55 | def notificationtext = request.JSON?.notificationText
56 | phones.deviceNotification(notificationtext)
57 | }
58 |
59 | void announcement() {
60 | def notificationtext = request.JSON?.notificationText
61 | speakers.speak(notificationtext)
62 | }
63 |
64 | def installed() {}
65 |
66 | def updated() {}
67 |
--------------------------------------------------------------------------------
/Puppet.Api/Startup.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.ApplicationInsights.AspNetCore.Extensions;
2 | using Microsoft.AspNetCore.Builder;
3 | using Microsoft.AspNetCore.Hosting;
4 | using Microsoft.AspNetCore.Mvc;
5 | using Microsoft.Extensions.Configuration;
6 | using Microsoft.Extensions.DependencyInjection;
7 | using Microsoft.Extensions.Hosting;
8 | using Microsoft.OpenApi.Models;
9 |
10 | using Puppet.Executive.Interfaces;
11 | using Puppet.Executive.Services;
12 |
13 | namespace Puppet.Api
14 | {
15 | public class Startup
16 | {
17 | public Startup(IConfiguration configuration)
18 | {
19 | Configuration = configuration;
20 | }
21 |
22 | public IConfiguration Configuration { get; }
23 |
24 | // This method gets called by the runtime. Use this method to add services to the container.
25 | public void ConfigureServices(IServiceCollection services)
26 | {
27 |
28 | services.AddControllers();
29 | services.AddSwaggerGen(c =>
30 | {
31 | c.SwaggerDoc("v1", new OpenApiInfo { Title = "Puppet.Api", Version = "v1" });
32 | });
33 | services.AddSingleton(typeof(IExecutive), new Puppet.Executive.Services.Executive(Configuration));
34 | services.AddApplicationInsightsTelemetry();
35 | }
36 |
37 | // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
38 | public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
39 | {
40 | if (env.IsDevelopment())
41 | {
42 | app.UseDeveloperExceptionPage();
43 | app.UseSwagger();
44 | app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "Puppet.Api v1"));
45 | }
46 |
47 | app.UseRouting();
48 |
49 | app.UseAuthorization();
50 |
51 | app.UseEndpoints(endpoints =>
52 | {
53 | endpoints.MapControllers();
54 | });
55 | }
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/Puppet.Common/Events/HubEvent.cs:
--------------------------------------------------------------------------------
1 | using Newtonsoft.Json;
2 | using Puppet.Common.Devices;
3 | using System.Collections.Generic;
4 |
5 | namespace Puppet.Common.Events
6 | {
7 | public class HubEvent
8 | {
9 | [JsonProperty(PropertyName = "deviceId")]
10 | public string DeviceId { get; set; }
11 | [JsonProperty(PropertyName = "value")]
12 | public string Value { get; set; }
13 | [JsonProperty(PropertyName = "name")]
14 | public string Name { get; set; }
15 | [JsonProperty(PropertyName = "displayName")]
16 | public string DisplayName { get; set; }
17 | [JsonProperty(PropertyName = "descriptionText")]
18 | public string DescriptionText { get; set; }
19 | [JsonProperty(PropertyName = "unit")]
20 | public string Unit { get; set; }
21 | [JsonProperty(PropertyName = "type")]
22 | public string Type { get; set; }
23 | [JsonProperty(PropertyName = "data")]
24 | public string Data { get; set; }
25 |
26 | public bool IsOpenEvent => Value == "open";
27 | public bool IsClosedEvent => Value == "closed";
28 | public bool IsLockedEvent => Value == "locked";
29 | public bool IsUnLockedEvent => Value == "unlocked";
30 | public bool IsOnEvent => Value == "on";
31 | public bool IsOffEvent => Value == "off";
32 | public bool IsButtonPushedEvent => Name == "pushed";
33 | public bool IsButtonHeldEvent => Name == "held";
34 | public bool IsButtonDoubleTappedEvent => Name == "doubleTapped";
35 | public bool IsActiveEvent => Value == "active";
36 | public bool IsInactiveEvent => Value == "inactive";
37 |
38 | public Dictionary GetDictionary()
39 | {
40 | return new Dictionary()
41 | {
42 | {"Device ID", DeviceId },
43 | {"Description", DescriptionText },
44 | {"Display Name", DisplayName },
45 | {"Name", Name },
46 | {"Data", Data },
47 | {"Unit", Unit },
48 | {"Type", Type },
49 | {"Value", Value },
50 | };
51 | }
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/Puppet.Common/Devices/ExtensionMethods.cs:
--------------------------------------------------------------------------------
1 | using Puppet.Common.Events;
2 |
3 | using System;
4 | using System.Collections.Generic;
5 | using System.Text;
6 | using System.Threading.Tasks;
7 |
8 | namespace Puppet.Common.Devices
9 | {
10 | public static class ExtensionMethods
11 | {
12 | public static async Task Speak(this IEnumerable notifiers, string message)
13 | {
14 | foreach(var s in notifiers)
15 | {
16 | try
17 | {
18 | await s.Speak(message);
19 | }
20 | catch(Exception ex)
21 | {
22 | Console.WriteLine($"Speak operation was unsuccessful for speaker {s.Id} -- {ex}");
23 | }
24 | }
25 | }
26 |
27 | public static bool IsAnyOpen(this List sensors)
28 | {
29 | return !sensors.TrueForAll((s) => s.Status == ContactStatus.Closed);
30 | }
31 |
32 | public static bool IsAnyOn(this List switches)
33 | {
34 | return !switches.TrueForAll((s) => s.Status == SwitchStatus.Off);
35 | }
36 |
37 | public static bool IsTriggerDevice(this IDevice device, HubEvent evt)
38 | {
39 | return (device.Id == evt.DeviceId);
40 | }
41 |
42 | public static async Task Ensure(this SwitchRelay switchRelay, SwitchStatus desiredState)
43 | {
44 | await switchRelay.RefreshState();
45 | if(switchRelay.Status != desiredState && desiredState != SwitchStatus.Unknown)
46 | {
47 | switch (desiredState)
48 | {
49 | case SwitchStatus.Off:
50 | await switchRelay.Off();
51 | break;
52 |
53 | case SwitchStatus.On:
54 | await switchRelay.On();
55 | break;
56 | }
57 | }
58 | }
59 |
60 | public static async Task Ensure(this Fan fan, FanSpeed desiredState)
61 | {
62 | await fan.RefreshState();
63 | if (fan.Status != desiredState && desiredState != FanSpeed.Unknown)
64 | {
65 | await fan.SetSpeed(desiredState);
66 | }
67 | }
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/Puppet.Common/Automation/AutomationBase.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections;
3 | using System.Collections.Generic;
4 | using System.Text;
5 | using System.Linq;
6 | using System.Threading;
7 | using Puppet.Common.Devices;
8 | using Puppet.Common.Events;
9 | using Puppet.Common.Services;
10 | using System.Threading.Tasks;
11 | using Puppet.Common.Models;
12 |
13 | namespace Puppet.Common.Automation
14 | {
15 | public abstract class AutomationBase : IAutomation
16 | {
17 | protected HomeAutomationPlatform _hub;
18 | protected HubEvent _evt;
19 | protected CancellationToken _cancelToken;
20 |
21 | public AutomationBase(HomeAutomationPlatform hub, HubEvent evt)
22 | {
23 | _hub = hub;
24 | _evt = evt;
25 | }
26 |
27 | public async Task Handle(CancellationToken token)
28 | {
29 | _cancelToken = token;
30 | await InitDevices();
31 | await Handle();
32 | }
33 |
34 | protected abstract Task InitDevices();
35 |
36 | protected abstract Task Handle();
37 |
38 |
39 | ///
40 | /// Waits for a specified period time then returns a boolean indicating if the task was cancelled.
41 | ///
42 | /// How long to wait.
43 | protected async Task WaitForCancellationAsync(TimeSpan howLong) => await Task.Delay(howLong, _cancelToken);
44 |
45 | ///
46 | /// Returns a bool indicating if it's currently dark outside based on sunrise and sunset times.
47 | ///
48 | /// Sunrise offset in minutes.
49 | /// Sunset offset in minutes.
50 | protected async Task IsDark(int sunriseOffset = 0, int sunsetOffset = 0)
51 | {
52 | SunriseAndSunset sun = await _hub.GetSunriseAndSunset();
53 | DateTime SunriseWithOffset = sun.Sunrise.AddMinutes(sunriseOffset);
54 | DateTime SunsetWithOffset = sun.Sunset.AddMinutes(sunsetOffset);
55 |
56 | if (DateTime.Now >= SunriseWithOffset &&
57 | DateTime.Now <= SunsetWithOffset)
58 | {
59 | return false;
60 | }
61 | else
62 | {
63 | return true;
64 | }
65 | }
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/Puppet.Executive/Automation/AutomationFactory.cs:
--------------------------------------------------------------------------------
1 | using Puppet.Common.Automation;
2 | using Puppet.Common.Devices;
3 | using Puppet.Common.Events;
4 | using Puppet.Common.Services;
5 | using System;
6 | using System.Collections.Generic;
7 | using System.IO;
8 | using System.Linq;
9 | using System.Reflection;
10 |
11 | namespace Puppet.Executive.Automation
12 | {
13 | public class AutomationFactory
14 | {
15 | static string _cwd = Path.GetDirectoryName(Assembly.GetEntryAssembly().Location);
16 | static string _automationAssembly = Path.Combine(_cwd, "Puppet.Automation.dll");
17 |
18 | ///
19 | /// Figures out the appropriate implementation of IAutomation based on the data in the event and returns it.
20 | ///
21 | ///
22 | ///
23 | /// An IEnumerable<IAutomation> containing the automations to be run for this event.
24 | public static IEnumerable GetAutomations(HubEvent evt, HomeAutomationPlatform hub)
25 | {
26 | /*
27 | * Get the types from the assembly
28 | * where the type implements IAutomation and
29 | * the type has trigger attributes
30 | * where the trigger attribute names a mapped device that matches the device that caused the event
31 | * and the attribute also names a Capability that matches the device that caused the event
32 | * and the count of the matching trigger attributes is greater than 0
33 | */
34 | IEnumerable typeCollection = Assembly.LoadFrom(_automationAssembly).GetTypes()
35 | .Where(t => typeof(IAutomation).IsAssignableFrom(t) &&
36 | (t.GetCustomAttributes()
37 | .Where(a => hub.LookupDeviceId(a.DeviceMappedName) == evt.DeviceId &&
38 | a.Capability.ToString().ToLower() == evt.Name.ToLower()))
39 | .Count() > 0);
40 | foreach (Type automation in typeCollection)
41 | {
42 | var thing = Activator.CreateInstance(automation, new Object[] { hub, evt });
43 | if (thing is IAutomation automationSource)
44 | yield return automationSource;
45 | }
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/Puppet.Common/Telemetry/AppInsights.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Text;
4 | using Microsoft.ApplicationInsights;
5 | using Microsoft.ApplicationInsights.DependencyCollector;
6 | using Microsoft.ApplicationInsights.Extensibility;
7 | using Microsoft.ApplicationInsights.Extensibility.PerfCounterCollector.QuickPulse;
8 | using Microsoft.Extensions.Configuration;
9 | using Puppet.Common.Automation;
10 | using Puppet.Common.Events;
11 |
12 | namespace Puppet.Common.Telemetry
13 | {
14 | public static class AppInsights
15 | {
16 | public static DependencyTrackingTelemetryModule InitializeDependencyTracking(TelemetryConfiguration configuration)
17 | {
18 | configuration.TelemetryProcessorChainBuilder
19 | .Use((next) => new HubitatAccessTokenDependencyFilter(next))
20 | .Build();
21 |
22 | DependencyTrackingTelemetryModule module = new DependencyTrackingTelemetryModule();
23 | module.Initialize(configuration);
24 | return module;
25 | }
26 |
27 | public static TelemetryConfiguration GetTelemetryConfiguration(IConfiguration configuration)
28 | {
29 | TelemetryConfiguration telemetryConfig = TelemetryConfiguration.CreateDefault();
30 | telemetryConfig.InstrumentationKey = configuration["ApplicationInsights:InstrumentationKey"];
31 | telemetryConfig.TelemetryInitializers.Add(new HttpDependenciesParsingTelemetryInitializer());
32 | telemetryConfig.TelemetryInitializers.Add(new OperationCorrelationTelemetryInitializer());
33 | return telemetryConfig;
34 | }
35 |
36 | public static QuickPulseTelemetryModule InitializePerformanceTracking(TelemetryConfiguration configuration)
37 | {
38 | QuickPulseTelemetryProcessor processor = null;
39 |
40 | configuration.TelemetryProcessorChainBuilder
41 | .Use((next) =>
42 | {
43 | processor = new QuickPulseTelemetryProcessor(next);
44 | return processor;
45 | })
46 | .Build();
47 |
48 | QuickPulseTelemetryModule QuickPulse = new QuickPulseTelemetryModule();
49 | QuickPulse.Initialize(configuration);
50 | QuickPulse.RegisterTelemetryProcessor(processor);
51 |
52 | return QuickPulse;
53 | }
54 |
55 | public static TelemetryClient GetTelemetryClient(IConfiguration configuration)
56 | {
57 | return new TelemetryClient(GetTelemetryConfiguration(configuration));
58 | }
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/Puppet.Common/Automation/TriggeredLightingAutomationBase.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Text;
4 | using System.Threading;
5 | using System.Threading.Tasks;
6 | using Puppet.Common.Devices;
7 | using Puppet.Common.Events;
8 | using Puppet.Common.Services;
9 |
10 | namespace Puppet.Common.Automation
11 | {
12 | ///
13 | /// Base class for turning on lights in response to a door opening or other switch being turned on.
14 | ///
15 | public abstract class TriggeredLightingAutomationBase : AutomationBase
16 | {
17 | public TimeSpan DeactivationWait { get; set; }
18 | public bool EnableDeactivation { get; set; }
19 | public List SwitchesToActivate { get; set; }
20 | public TriggeredLightingAutomationBase(HomeAutomationPlatform hub, HubEvent evt) : base(hub, evt)
21 | {
22 | SwitchesToActivate = new List();
23 | }
24 |
25 | protected async override Task Handle()
26 | {
27 | if (SwitchesToActivate.Count == 0)
28 | {
29 | return;
30 | }
31 |
32 | string timeActivatedKey = this.GetType().ToString();
33 | TimeSpan timeToWait = TimeSpan.MinValue;
34 | if (_evt.IsOpenEvent || _evt.IsOnEvent || _evt.IsActiveEvent)
35 | {
36 | foreach (var s in SwitchesToActivate)
37 | {
38 | await s.On();
39 | }
40 | _hub.StateBag.AddOrUpdate(timeActivatedKey, DateTime.Now,
41 | (key, oldvalue) => DateTime.Now);
42 | timeToWait = DeactivationWait;
43 | }
44 | else if (_evt.IsClosedEvent || _evt.IsOffEvent || _evt.IsInactiveEvent)
45 | {
46 |
47 | DateTime LightActivatedTime =
48 | _hub.StateBag.ContainsKey(timeActivatedKey) ? (DateTime)_hub.StateBag[timeActivatedKey] : DateTime.Now;
49 | timeToWait = DeactivationWait - (DateTime.Now - LightActivatedTime);
50 | }
51 |
52 | if (EnableDeactivation && SwitchesToActivate.IsAnyOn())
53 | {
54 | Console.WriteLine($"{DateTime.Now} {this.GetType().ToString()} is turning off lights in {timeToWait.ToString()}");
55 | await WaitForCancellationAsync(timeToWait);
56 | foreach (var s in SwitchesToActivate)
57 | {
58 | await s.Off();
59 | }
60 | _hub.StateBag.Remove(timeActivatedKey, out var ignoreMe);
61 | }
62 | }
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/Puppet.Common/Devices/Fan.cs:
--------------------------------------------------------------------------------
1 | using Puppet.Common.Services;
2 |
3 | using System;
4 | using System.Collections.Generic;
5 | using System.Linq;
6 | using System.Text;
7 | using System.Threading.Tasks;
8 |
9 | namespace Puppet.Common.Devices
10 | {
11 | public enum FanSpeed
12 | {
13 | On,
14 | Off,
15 | Auto,
16 | Low,
17 | MediumLow,
18 | Medium,
19 | MediumHigh,
20 | High,
21 | Unknown
22 | }
23 | public class Fan : DeviceBase
24 | {
25 | public Fan(HomeAutomationPlatform hub, string id) : base(hub, id)
26 | {
27 | }
28 |
29 | public FanSpeed Status
30 | {
31 | get
32 | {
33 | switch (GetState()["speed"])
34 | {
35 | case "on":
36 | return FanSpeed.On;
37 |
38 | case "off":
39 | return FanSpeed.Off;
40 |
41 | case "auto":
42 | return FanSpeed.Auto;
43 |
44 | case "low":
45 | return FanSpeed.Low;
46 |
47 | case "medium-low":
48 | return FanSpeed.MediumLow;
49 |
50 | case "medium":
51 | return FanSpeed.Medium;
52 |
53 | case "medium-high":
54 | return FanSpeed.MediumHigh;
55 |
56 | case "high":
57 | return FanSpeed.High;
58 |
59 | default:
60 | return FanSpeed.Unknown;
61 | }
62 | }
63 | }
64 |
65 | public bool IsOn
66 | {
67 | get
68 | {
69 | return (this.Status != FanSpeed.Off) && (this.Status != FanSpeed.Auto);
70 | }
71 | }
72 |
73 | public async Task SetSpeed(FanSpeed speed)
74 | {
75 | string speedString;
76 |
77 | switch (speed)
78 | {
79 | case FanSpeed.MediumLow:
80 | speedString = "medium-low";
81 | break;
82 |
83 | case FanSpeed.MediumHigh:
84 | speedString = "medium-high";
85 | break;
86 |
87 | case FanSpeed.Unknown:
88 | speedString = "off";
89 | break;
90 |
91 | default:
92 | speedString = speed.ToString().ToLower();
93 | break;
94 | }
95 |
96 | await _hub.DoAction(this, "setSpeed", new string[] { speedString });
97 | }
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/Puppet.sln:
--------------------------------------------------------------------------------
1 |
2 | Microsoft Visual Studio Solution File, Format Version 12.00
3 | # Visual Studio Version 16
4 | VisualStudioVersion = 16.0.28711.60
5 | MinimumVisualStudioVersion = 10.0.40219.1
6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Puppet.Executive", "Puppet.Executive\Puppet.Executive.csproj", "{8E5C5A21-59DA-4CD8-9913-F90A41FBD6C3}"
7 | ProjectSection(ProjectDependencies) = postProject
8 | {5006D4AE-3F37-4CB3-BAE6-E65E1E17EB8C} = {5006D4AE-3F37-4CB3-BAE6-E65E1E17EB8C}
9 | EndProjectSection
10 | EndProject
11 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Puppet.Common", "Puppet.Common\Puppet.Common.csproj", "{0566D83E-694E-4DB1-930B-9DC118842457}"
12 | EndProject
13 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Puppet.Automation", "Puppet.Automation\Puppet.Automation.csproj", "{5006D4AE-3F37-4CB3-BAE6-E65E1E17EB8C}"
14 | EndProject
15 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Puppet.Api", "Puppet.Api\Puppet.Api.csproj", "{D5648997-838A-4903-8E04-707578EF7C96}"
16 | EndProject
17 | Global
18 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
19 | Debug|Any CPU = Debug|Any CPU
20 | Release|Any CPU = Release|Any CPU
21 | EndGlobalSection
22 | GlobalSection(ProjectConfigurationPlatforms) = postSolution
23 | {8E5C5A21-59DA-4CD8-9913-F90A41FBD6C3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
24 | {8E5C5A21-59DA-4CD8-9913-F90A41FBD6C3}.Debug|Any CPU.Build.0 = Debug|Any CPU
25 | {8E5C5A21-59DA-4CD8-9913-F90A41FBD6C3}.Release|Any CPU.ActiveCfg = Release|Any CPU
26 | {8E5C5A21-59DA-4CD8-9913-F90A41FBD6C3}.Release|Any CPU.Build.0 = Release|Any CPU
27 | {0566D83E-694E-4DB1-930B-9DC118842457}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
28 | {0566D83E-694E-4DB1-930B-9DC118842457}.Debug|Any CPU.Build.0 = Debug|Any CPU
29 | {0566D83E-694E-4DB1-930B-9DC118842457}.Release|Any CPU.ActiveCfg = Release|Any CPU
30 | {0566D83E-694E-4DB1-930B-9DC118842457}.Release|Any CPU.Build.0 = Release|Any CPU
31 | {5006D4AE-3F37-4CB3-BAE6-E65E1E17EB8C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
32 | {5006D4AE-3F37-4CB3-BAE6-E65E1E17EB8C}.Debug|Any CPU.Build.0 = Debug|Any CPU
33 | {5006D4AE-3F37-4CB3-BAE6-E65E1E17EB8C}.Release|Any CPU.ActiveCfg = Release|Any CPU
34 | {5006D4AE-3F37-4CB3-BAE6-E65E1E17EB8C}.Release|Any CPU.Build.0 = Release|Any CPU
35 | {D5648997-838A-4903-8E04-707578EF7C96}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
36 | {D5648997-838A-4903-8E04-707578EF7C96}.Debug|Any CPU.Build.0 = Debug|Any CPU
37 | {D5648997-838A-4903-8E04-707578EF7C96}.Release|Any CPU.ActiveCfg = Release|Any CPU
38 | {D5648997-838A-4903-8E04-707578EF7C96}.Release|Any CPU.Build.0 = Release|Any CPU
39 | EndGlobalSection
40 | GlobalSection(SolutionProperties) = preSolution
41 | HideSolutionNode = FALSE
42 | EndGlobalSection
43 | GlobalSection(ExtensibilityGlobals) = postSolution
44 | SolutionGuid = {C8DA42D1-E5A6-414E-B088-1B87CA400D1B}
45 | EndGlobalSection
46 | EndGlobal
47 |
--------------------------------------------------------------------------------
/Puppet.Automation/SampleAutomation.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Threading;
3 | using System.Threading.Tasks;
4 | using Puppet.Common.Automation;
5 | using Puppet.Common.Devices;
6 | using Puppet.Common.Events;
7 | using Puppet.Common.Services;
8 |
9 | namespace Puppet.Automation
10 | {
11 | [TriggerDevice("SampleDevices.SampleDoor", Capability.Contact)]
12 | public class SampleAutomation : AutomationBase
13 | {
14 | readonly TimeSpan _interval = TimeSpan.FromMinutes(5);
15 | SwitchRelay _sampleLight;
16 | Speaker _sampleSpeaker;
17 | const string _timeOpenedKey = "PantryOpenedTime";
18 |
19 | public SampleAutomation(HomeAutomationPlatform hub, HubEvent evt) : base(hub, evt)
20 | { }
21 |
22 | protected override async Task InitDevices()
23 | {
24 | _sampleLight =
25 | await _hub.GetDeviceByMappedName("SampleDevices.SampleLight");
26 | _sampleSpeaker =
27 | await _hub.GetDeviceByMappedName("SampleDevices.SampleSpeaker");
28 | }
29 |
30 | ///
31 | /// Handles door events coming from the home automation controller.
32 | ///
33 | /// A .NET cancellation token received if this handler is to be cancelled.
34 | protected override async Task Handle()
35 | {
36 | if (_evt.IsOpenEvent)
37 | {
38 | // Turn on the light
39 | await _sampleLight.On();
40 |
41 | // Remember when we turned on the light for later (when we respond to an off event)
42 | _hub.StateBag.AddOrUpdate(_timeOpenedKey, DateTime.Now,
43 | (key, oldvalue) => DateTime.Now); // This is the lambda to just update an existing value with the current DateTime
44 |
45 | // Wait a bit...
46 | await WaitForCancellationAsync(_interval);
47 | await _sampleSpeaker.Speak("Please close the door.");
48 |
49 | // Wait a bit more...
50 | await WaitForCancellationAsync(_interval);
51 | await _sampleSpeaker.Speak("I said, please close the door.");
52 |
53 | // Wait a bit longer and then give up...
54 | await WaitForCancellationAsync(_interval);
55 | await _sampleSpeaker.Speak("Okay, I'll turn off the light myself.");
56 | await _sampleLight.Off();
57 | }
58 | else
59 | {
60 | // Has the door been open five minutes?
61 | DateTime PantryOpenTime =
62 | _hub.StateBag.ContainsKey(_timeOpenedKey) ? (DateTime)_hub.StateBag[_timeOpenedKey] : DateTime.Now;
63 | if (DateTime.Now - PantryOpenTime > _interval)
64 | {
65 | // It's been open five minutes, so we've nagged by now.
66 | // It's only polite to thank them for doing what we've asked!
67 | await _sampleSpeaker.Speak("Thank you for closing the pantry door");
68 | }
69 | await _sampleLight.Off();
70 | }
71 | }
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/Puppet.Common/Automation/DoorWatcherBase.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Threading;
4 | using System.Threading.Tasks;
5 | using Puppet.Common.Devices;
6 | using Puppet.Common.Events;
7 | using Puppet.Common.Notifiers;
8 | using Puppet.Common.Services;
9 |
10 | namespace Puppet.Common.Automation
11 | {
12 | public abstract class DoorWatcherBase : AutomationBase
13 | {
14 | public TimeSpan HowLong { get; set; }
15 | public bool MakeAnnouncement { get; set; }
16 | public bool PushNotification { get; set; }
17 | public string NotificationFormat { get; set; }
18 | public int NumberOfNotifications { get; set; }
19 | public bool NotifyOnClose { get; set; }
20 | public INotifier AnnouncementNotifier { get; set; }
21 | public INotifier PushNotifier { get; set; }
22 |
23 | public DoorWatcherBase(HomeAutomationPlatform hub, HubEvent evt) : base(hub, evt)
24 | {}
25 |
26 | protected override async Task Handle()
27 | {
28 | if (!(PushNotification || MakeAnnouncement))
29 | {
30 | return;
31 | }
32 |
33 | if (_evt.IsOpenEvent)
34 | {
35 | if (NumberOfNotifications < 1) NumberOfNotifications = 1;
36 | if (String.IsNullOrEmpty(NotificationFormat)) NotificationFormat = @"{0} has been open {1} minutes.";
37 |
38 | for (int i = 0; i < NumberOfNotifications; i++)
39 | {
40 | await WaitForCancellationAsync(HowLong);
41 | var textToSend = String.Format(NotificationFormat,
42 | _evt.DisplayName, HowLong.TotalMinutes * (i + 1), HowLong.TotalSeconds * (i + 1));
43 | // There's an unused string parameter being passed into String.Format.
44 | // That way the deriving class can set the NotificationFormat to mention
45 | // either "seconds" or "minutes."
46 |
47 | if (MakeAnnouncement)
48 | {
49 | if (AnnouncementNotifier is not null)
50 | {
51 | await AnnouncementNotifier.SendNotification(textToSend);
52 | }
53 | else
54 | {
55 | await _hub.Announce(textToSend);
56 | }
57 | }
58 |
59 | if (PushNotification)
60 | {
61 | if (PushNotifier is not null)
62 | {
63 | await PushNotifier.SendNotification(textToSend);
64 | }
65 | else
66 | {
67 | await _hub.Push(textToSend);
68 | }
69 | }
70 | }
71 | }
72 | else if (_evt.IsClosedEvent && NotifyOnClose)
73 | {
74 | var textToSend = $"{_evt.DisplayName} is closed.";
75 | if (MakeAnnouncement) await _hub.Announce(textToSend);
76 | if (PushNotification) await _hub.Push(textToSend);
77 | }
78 | }
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/Puppet.Common/Services/MqttService.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Reflection;
4 | using System.Threading.Tasks;
5 | using Microsoft.ApplicationInsights;
6 | using Microsoft.ApplicationInsights.DataContracts;
7 | using Microsoft.Extensions.Configuration;
8 | using MQTTnet;
9 | using MQTTnet.Extensions.ManagedClient;
10 | using Puppet.Common.Configuration;
11 | using Puppet.Common.Devices;
12 | using Puppet.Common.Events;
13 | using Puppet.Common.Exceptions;
14 | using Puppet.Common.Telemetry;
15 |
16 | namespace Puppet.Common.Services
17 | {
18 | public class MqttService : IMqttService
19 | {
20 | private readonly IManagedMqttClient _mqttClient;
21 | private readonly HomeAutomationPlatform _hub;
22 | private readonly TelemetryClient _telemetryClient;
23 | private readonly string _topicRoot;
24 | private readonly string _commandTopic;
25 |
26 | public MqttService(IManagedMqttClient mqttClient, MqttOptions mqttOptions, HomeAutomationPlatform hub)
27 | {
28 |
29 | _telemetryClient = hub.TelemetryClient;
30 | _mqttClient = mqttClient;
31 | _hub = hub;
32 | _topicRoot = mqttOptions.TopicRoot;
33 | _commandTopic = $"{mqttOptions.TopicRoot}/command";
34 | _mqttClient.UseApplicationMessageReceivedHandler(async e =>
35 | {
36 | Console.WriteLine($"{DateTime.Now} Received MQTT message. Topic: {e.ApplicationMessage.Topic} Payload: {e.ApplicationMessage.ConvertPayloadToString()}");
37 |
38 | using (var operation =
39 | _telemetryClient.StartOperation($"{this.ToString()}: Message Received"))
40 | {
41 | _telemetryClient.TrackEvent("MQTT Message Received",
42 | new Dictionary()
43 | {
44 | {"Topic", e.ApplicationMessage.Topic },
45 | {"Payload", e.ApplicationMessage.ConvertPayloadToString()},
46 | });
47 | try
48 | {
49 | string parm = null;
50 | string[] tokens = e.ApplicationMessage.Topic.Split('/');
51 | string payload = e.ApplicationMessage.ConvertPayloadToString();
52 |
53 | if (tokens.Length != 4)
54 | {
55 | throw new InvalidMqttTopicException();
56 | }
57 |
58 | GenericDevice device = await _hub.GetDeviceByLabel(tokens[2]);
59 | if (!string.IsNullOrEmpty(payload))
60 | {
61 | parm = payload;
62 | }
63 |
64 | // await device.DoAction(tokens[3], parm);
65 | // Nope. Let's revisit this part.
66 | Console.WriteLine($"No! No MQTT Commands allowed!");
67 | }
68 | catch (Exception ex)
69 | {
70 | operation.Telemetry.Success = false;
71 | _telemetryClient.TrackException(ex);
72 | Console.WriteLine($"{DateTime.Now} {ex}");
73 | }
74 | }
75 | });
76 | }
77 |
78 | public async Task SendEventToMqttAsync(HubEvent evt)
79 | {
80 | await _mqttClient.PublishAsync($"{_topicRoot}/event/{evt.DisplayName}/{evt.Name}", evt.Value);
81 | }
82 |
83 | public async Task Start()
84 | {
85 | await _mqttClient.SubscribeAsync(
86 | new MqttTopicFilterBuilder()
87 | .WithTopic($"{_commandTopic}/#")
88 | .Build());
89 | }
90 |
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/Puppet.Common/Services/HomeAutomationPlatform.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Concurrent;
3 | using System.Collections.Generic;
4 | using System.IO;
5 | using System.Reflection;
6 | using System.Text.Json;
7 | using System.Threading.Tasks;
8 |
9 | using Microsoft.ApplicationInsights;
10 | using Microsoft.Extensions.Configuration;
11 |
12 | using Newtonsoft.Json.Linq;
13 |
14 | using Puppet.Common.Devices;
15 | using Puppet.Common.Events;
16 | using Puppet.Common.Exceptions;
17 | using Puppet.Common.Models;
18 | using Puppet.Common.Notifiers;
19 | using Puppet.Common.Telemetry;
20 |
21 | namespace Puppet.Common.Services
22 | {
23 | ///
24 | /// Base class for Home Automation Platforms.
25 | ///
26 | public abstract class HomeAutomationPlatform
27 | {
28 | const string DEVICE_FILENAME = "devicemap.json";
29 |
30 | JsonElement DeviceMap { get; }
31 |
32 | public TelemetryClient TelemetryClient { get; }
33 | public ConcurrentDictionary StateBag { get; set; }
34 | public IConfiguration Configuration { get; set; }
35 | public abstract Task DoAction(IDevice device, string action, string[] args = null);
36 | protected abstract Task AuxEndpointNotification(string notificationText, bool audioAnnouncement);
37 |
38 | public virtual Task Push(string notificationText)
39 | {
40 | return AuxEndpointNotification(notificationText, false);
41 | }
42 |
43 | public virtual Task Announce(string notificationText)
44 | {
45 | return AuxEndpointNotification(notificationText, true);
46 | }
47 |
48 |
49 |
50 | public HomeAutomationPlatform(IConfiguration configuration)
51 | {
52 |
53 | string cwd = Path.GetDirectoryName(Assembly.GetEntryAssembly().Location);
54 | string deviceMapPath = Path.Combine(cwd, DEVICE_FILENAME);
55 |
56 | if (!File.Exists(deviceMapPath))
57 | {
58 | throw new FileNotFoundException($"Can't find {DEVICE_FILENAME}!");
59 | }
60 |
61 | this.DeviceMap = JsonDocument
62 | .Parse(File.ReadAllText(deviceMapPath))
63 | .RootElement;
64 |
65 | Configuration = configuration;
66 |
67 | TelemetryClient = AppInsights.GetTelemetryClient(configuration);
68 | }
69 |
70 | public abstract Task GetSunriseAndSunset();
71 |
72 | public string LookupDeviceId(string mappedDeviceName)
73 | {
74 | return ParseAndLookupMappedDeviceName(this.DeviceMap, mappedDeviceName);
75 | }
76 | string ParseAndLookupMappedDeviceName(JsonElement map, string mappedDeviceName)
77 | {
78 |
79 | string[] tokens = mappedDeviceName.Split('.');
80 | JsonElement deviceElement = map.Clone();
81 |
82 | try
83 | {
84 | if (tokens.Length > 1)
85 | {
86 | for (int i = 0; i < tokens.Length; i++)
87 | {
88 | deviceElement = deviceElement.GetProperty(tokens[i]);
89 | }
90 | }
91 | }
92 | catch (System.Collections.Generic.KeyNotFoundException)
93 | {
94 | throw new DeviceNotFoundException($"devicemap.json has no mapping for {mappedDeviceName}.");
95 | }
96 | return deviceElement.GetString();
97 | }
98 |
99 | public async Task GetDeviceByMappedName(string mappedDeviceName)
100 | {
101 | return await GetDeviceById(LookupDeviceId(mappedDeviceName));
102 | }
103 |
104 | public async Task GetDeviceById(string deviceId)
105 | {
106 | return await Task.FromResult((T)Activator.CreateInstance(typeof(T), new Object[] { this, deviceId }));
107 | }
108 |
109 | public abstract Task> GetDeviceState(IDevice device);
110 |
111 | public abstract Task GetDeviceByLabel(string label);
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/Puppet.Executive/Automation/AutomationTaskManager.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Linq;
4 | using System.Reflection;
5 | using System.Threading;
6 | using System.Threading.Tasks;
7 | using Microsoft.ApplicationInsights;
8 | using Microsoft.Extensions.Configuration;
9 | using Puppet.Common.Automation;
10 | using Puppet.Common.Telemetry;
11 |
12 | namespace Puppet.Executive.Automation
13 | {
14 | ///
15 | /// This class maintains a collection of AutomationTasks and their
16 | /// CancellationTokenSources. This is so long-running tasks are kept
17 | /// in scope until they're done running, along with their cancellation
18 | /// tokens, so they can be cancelled if another AutomationTask running
19 | /// the same IAutomation comes along.
20 | ///
21 | public class AutomationTaskManager
22 | {
23 | private HashSet _taskList;
24 | private TelemetryClient _telemetryClient;
25 | private Metric _taskCountMetric;
26 |
27 | public AutomationTaskManager(IConfiguration configuration)
28 | {
29 | _telemetryClient = AppInsights.GetTelemetryClient(configuration);
30 | _taskCountMetric = _telemetryClient.GetMetric("AutomationTaskCount");
31 | _taskList = new HashSet();
32 | }
33 |
34 | ///
35 | /// Stores the task/cancellation token source.
36 | ///
37 | /// The task and cancellation token source.
38 | public void Track(Task work, CancellationTokenSource cts, Type automationType, string initiatingDeviceId)
39 | {
40 |
41 | lock (_taskList)
42 | {
43 | _taskList.Add(new AutomationTaskTokenType(work, cts, automationType, initiatingDeviceId));
44 | _taskCountMetric.TrackValue(_taskList.Count);
45 | Console.WriteLine($"{DateTime.Now} Tracking {_taskList.Count} tasks.");
46 | }
47 | }
48 |
49 | ///
50 | /// Clears all completed tasks from the task list.
51 | ///
52 | public void RemoveCompletedTasks()
53 | {
54 | Task.Run(() =>
55 | {
56 | int count = 0;
57 | lock (_taskList)
58 | {
59 | int countBefore = _taskList.Count;
60 | _taskList.RemoveWhere(t => t.Task.IsCompleted);
61 | int countAfter = _taskList.Count;
62 | count = countBefore - countAfter;
63 | if (count > 0)
64 | {
65 | _taskCountMetric.TrackValue(_taskList.Count);
66 | Console.WriteLine($"{DateTime.Now} Removed {count} completed tasks. {_taskList.Count} tasks remain in progress.");
67 | }
68 | }
69 | });
70 | }
71 |
72 | ///
73 | /// Cancels all existing automation tasks with the given type and, if applicable, intiating device.
74 | ///
75 | /// The type of the automation to cancel.
76 | public void CancelRunningInstances(Type automationType, string initiatingDeviceId)
77 | {
78 |
79 | bool perDevice = (automationType.GetCustomAttributes()?.Count() > 0) ? true : false;
80 | Func filterAction;
81 | if (perDevice)
82 | {
83 | filterAction = (t) =>
84 | {
85 | return t.AutomationType == automationType
86 | && t.InitiatingDeviceId == initiatingDeviceId
87 | && !t.Task.IsCompleted
88 | && !t.CTS.IsCancellationRequested;
89 | };
90 | }
91 | else
92 | {
93 | filterAction = (t) =>
94 | {
95 | return t.AutomationType == automationType
96 | && !t.Task.IsCompleted
97 | && !t.CTS.IsCancellationRequested;
98 | };
99 | }
100 |
101 | lock (_taskList)
102 | {
103 | foreach (var i in _taskList.Where(filterAction))
104 | {
105 | i.CTS.Cancel();
106 | }
107 | }
108 | }
109 | }
110 | class AutomationTaskTokenType
111 | {
112 | public Task Task { get; set; }
113 | public CancellationTokenSource CTS { get; set; }
114 | public Type AutomationType { get; set; }
115 | public string InitiatingDeviceId { get; set; }
116 |
117 | public AutomationTaskTokenType(Task task, CancellationTokenSource cts, Type automationType, string initiatingDeviceId)
118 | {
119 | Task = task;
120 | CTS = cts;
121 | AutomationType = automationType;
122 | InitiatingDeviceId = initiatingDeviceId;
123 | }
124 | }
125 | }
126 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Welcome to Puppet
2 |
3 | A .NET Core framework for home automation with Hubitat Elevate.
4 |
5 | If you're looking for the demos and slides from **My House Runs .NET**, [they're on this branch](https://github.com/CamSoper/puppet/tree/presentation).
6 |
7 | The Puppet.Automation project contains a generic sample based on the logic I use for my pantry door. For more sample automations, [see all the Puppet.Automation classes I've created for my house in this branch](https://github.com/CamSoper/puppet/tree/cams-house).
8 |
9 | ## How it works
10 |
11 | 1. An event on one of your devices is triggered.
12 | Example: Your pantry door opens, triggering a contact sensor to send **open**.
13 |
14 | 2. The Puppet Executive process captures that event from Hubitat's websocket interface and raises it as a `System.Event` that can be handled in .NET Core code.
15 |
16 | 3. The Puppet Executive passes event to the `AutomationFactory`. The `AutomationFactory` class instantiates the correct implementation of `IAutomation` based on various hints and returns it to the Executive.
17 | Example: The Executive asks `AutomationFactory` for IAutomation objects that are interested in this event (based on code attributes, like `TriggerDeviceAttribute`).
18 |
19 | 4. The Executive executes the `Handle()` method on each `IAutomation` object. The object can manipulate other devices via the `Hubitat` class, of which an instance can be passed in by the Executive. See the example later in this doc.
20 |
21 | ## Building
22 |
23 | Either build the entire solution with Visual Studio or build the Puppet.Executive project at the command line by going to the project directory and running `dotnet build`.
24 |
25 | ## Setup the Maker API app
26 |
27 | It's built-in to the Hubitat. Make sure you've turned it on and opted-in all your devices you want to access from Puppet.
28 |
29 | ## Running
30 |
31 | Configure *appsettings.json*. At a shell prompt, switch to the Puppet.Executive folder and run `dotnet run`.
32 |
33 | ## Deployment to a Raspberry Pi
34 |
35 | It'll run fine on Windows, which is where I test it. For production I use a Raspberry Pi running Raspbian for my instance.
36 |
37 | 1. Publish Puppet.Executive with the following:
38 | `dotnet publish -r linux-arm`
39 |
40 | This will build a self-contained deployment, including the SDK, so you won't need to install the SDK on the RPi.
41 |
42 | 2. Using your file copy tool of choice, copy the contents, which you'll find several levels deep in the *bin* directory, to a location on your RPi. I chose `/home/pi/executive`.
43 |
44 | 3. SSH to your Raspberry Pi. Give the executables permission to run. For example:
45 | ```
46 | chmod 755 /home/pi/executive/Puppet.Executive
47 | ```
48 |
49 | 4. Test the application by running it manually. Run the Executive with `./Puppet.Executive`. Press **Ctrl+C** to end it after you're satisfied it works.
50 |
51 | 5. Setup crontab on your Raspberry Pi to run the two apps in the background on startup. Run `crontab -e` and add the following line:
52 | ```
53 | @reboot cd /home/pi/executive && ./Puppet.Executive > /home/pi/executive.log
54 | ```
55 |
56 | This will run the app at startup and pipe its output to a log file in the home directory.
57 |
58 | 6. Reboot. `sudo reboot`
59 |
60 | ## Developing new automations
61 |
62 | 1. Add a class to `Puppet.Automation`. Name it whatever you want. I like ending with `Automation` as a convention, but do whatever you like.
63 | 2. Make it implement `Puppet.Common.Automation.IAutomation`.
64 | 3. Give it a constructor that takes a single `Puppet.Common.Services.HomeAutomationPlatform` if you want it to be able to do stuff to other devices on your Hubitat.
65 | 4. Decorate it with attributes to indicate what events the automation is interested in, like `TriggerDeviceAttribute`.
66 | 5. Run it. If your attributes are correct, that automation should get picked up and executed asynchronously.
67 |
68 | ### Example Automation
69 |
70 | ```csharp
71 | namespace Puppet.Automation
72 | {
73 | [TriggerDevice("Lock.FrontDoorDeadbolt", Capability.Lock)]
74 | public class NotifyOnDoorUnlock : AutomationBase
75 | {
76 | public NotifyOnDoorUnlock(HomeAutomationPlatform hub, HubEvent evt) : base (hub,evt)
77 | {
78 | }
79 |
80 | public override Task Handle(CancellationToken token)
81 | {
82 | if(_evt.value == "unlocked")
83 | {
84 | if(_evt.descriptionText.Contains("was unlocked by"))
85 | {
86 | Speaker[] speakers = new Speaker[]{
87 | _hub.GetDeviceByName("Speaker.KitchenSpeaker") as Speaker,
88 | _hub.GetDeviceByName("Speaker.WebhookNotifier") as Speaker
89 | };
90 | speakers.Speak($"{_evt.descriptionText}.");
91 | }
92 | }
93 |
94 | return Task.CompletedTask;
95 | }
96 | }
97 | }
98 | ```
99 |
100 | ## Enjoy!
101 |
102 | I hope you like my work here. If you're having trouble, that's understandable - I wrote it for *me*, and sometimes that means that I don't think through usability stuff. Create an issue in this repo, or, better yet, send me a PR!
103 |
104 | Lastly, please check out my social media presences!
105 |
106 | * [Twitter](https://twitter.com/camsoper)
107 | * [Twitch](https://twitch.tv/CamDoesCoolStuff) (I stream programming stuff)
108 | * [My Twitch Archive on YouTube](https://www.youtube.com/playlist?list=PL7390OIw2znaTPK4GGCtRnoJe1scVl5ZT)
109 |
110 | \- Cam
111 |
--------------------------------------------------------------------------------
/Puppet.Executive/Services/Executive.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.ApplicationInsights;
2 | using Microsoft.ApplicationInsights.Extensibility;
3 |
4 | using Puppet.Common.Events;
5 | using Puppet.Common.Services;
6 | using Puppet.Executive.Automation;
7 |
8 | using System;
9 | using System.Collections.Generic;
10 | using System.IO;
11 | using System.Net.Http;
12 | using System.Threading;
13 | using System.Threading.Tasks;
14 | using Microsoft.Extensions.Configuration;
15 | using Puppet.Common.Configuration;
16 | using Puppet.Executive.Mqtt;
17 | using Puppet.Common.Telemetry;
18 | using Puppet.Common.Automation;
19 | using Microsoft.ApplicationInsights.DataContracts;
20 |
21 | namespace Puppet.Executive.Services
22 | {
23 | public class Executive : Interfaces.IExecutive, IDisposable
24 | {
25 | const string APPSETTINGS_FILENAME = "appsettings.json";
26 |
27 | AutomationTaskManager _taskManager;
28 | HomeAutomationPlatform _hub;
29 | IMqttService _mqtt;
30 | TelemetryClient _telemetryClient;
31 | HttpClient _httpClient;
32 | private bool disposedValue;
33 |
34 | public Executive(IConfiguration configuration)
35 | {
36 | // Create an HttpClient that doesn't validate the server certificate
37 | HttpClientHandler customHttpClientHandler = new HttpClientHandler
38 | {
39 | ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => { return true; }
40 | };
41 |
42 | TelemetryConfiguration telemetryConfig = AppInsights.GetTelemetryConfiguration(configuration);
43 | _telemetryClient = new TelemetryClient(telemetryConfig);
44 |
45 | _httpClient = new HttpClient(customHttpClientHandler);
46 |
47 | // Abstraction representing the home automation system
48 | _hub = new Hubitat(configuration, _httpClient);
49 |
50 | // Start the MQTT service, if applicable.
51 | StartMqttService(configuration);
52 |
53 | // Class to manage long-running tasks
54 | _taskManager = new AutomationTaskManager(configuration);
55 | }
56 |
57 | public void ProcessEvent(HubEvent evt)
58 | {
59 | _telemetryClient.TrackEvent("Hub Event", evt.GetDictionary());
60 |
61 | Task.Run(() => StartRelevantAutomationHandlers(evt));
62 | Task.Run(() => SendEventToMqtt(evt));
63 | }
64 |
65 | private async void StartMqttService(IConfiguration configuration)
66 | {
67 | MqttOptions mqttOptions = configuration.GetSection("MQTT").Get();
68 | if (mqttOptions?.Enabled ?? false)
69 | {
70 | _mqtt = new MqttService(await MqttClientFactory.GetClient(mqttOptions), mqttOptions, _hub);
71 | await _mqtt.Start();
72 | }
73 | }
74 |
75 | private void SendEventToMqtt(HubEvent evt)
76 | {
77 | _mqtt?.SendEventToMqttAsync(evt);
78 | }
79 |
80 | private void StartRelevantAutomationHandlers(HubEvent evt)
81 | {
82 | // Get a reference to the automation
83 | var automations = AutomationFactory.GetAutomations(evt, _hub);
84 |
85 | foreach (IAutomation automation in automations)
86 | {
87 | // If this automation is already running, cancel all running instances
88 | _taskManager.CancelRunningInstances(automation.GetType(), evt.DeviceId);
89 |
90 | // Start a task to handle the automation and a CancellationToken Source
91 | // so we can cancel it later.
92 | CancellationTokenSource cts = new CancellationTokenSource();
93 | Func handleTask = async () =>
94 | {
95 | var startedTime = DateTime.Now;
96 | Console.WriteLine($"{DateTime.Now} {automation} event: {evt.DescriptionText}");
97 |
98 | using (var operation = _telemetryClient.StartOperation(automation.ToString()))
99 | {
100 | _telemetryClient.TrackEvent("Automation Started", evt.GetDictionary());
101 |
102 | try
103 | {
104 | // This runs the Handle method on the automation class
105 | await automation.Handle(cts.Token);
106 | }
107 | catch (TaskCanceledException)
108 | {
109 | TimeSpan executionTime = DateTime.Now - startedTime;
110 | _telemetryClient.TrackEvent($"Automation Cancelled",
111 | new Dictionary()
112 | {
113 | {"WaitTime", executionTime.TotalSeconds.ToString()},
114 | });
115 | Console.WriteLine($"{DateTime.Now} {automation} event from {startedTime} cancelled.");
116 | }
117 | catch (Exception ex)
118 | {
119 | operation.Telemetry.Success = false;
120 | _telemetryClient.TrackException(ex);
121 | Console.WriteLine($"{DateTime.Now} {automation} {ex} {ex.Message}");
122 | }
123 |
124 | _telemetryClient.StopOperation(operation);
125 | }
126 | };
127 |
128 | // Ready... go handle it!
129 | Task work = Task.Run(handleTask, cts.Token);
130 |
131 | // Hold on to the task and its cancellation token source for later.
132 | _taskManager.Track(work, cts, automation.GetType(), evt.DeviceId);
133 | }
134 |
135 | // Let's take this opportunity to get rid of any completed tasks.
136 | _taskManager.RemoveCompletedTasks();
137 | }
138 |
139 | protected virtual void Dispose(bool disposing)
140 | {
141 | if (!disposedValue)
142 | {
143 | if (disposing)
144 | {
145 | _httpClient.Dispose();
146 | }
147 |
148 | // TODO: free unmanaged resources (unmanaged objects) and override finalizer
149 | // TODO: set large fields to null
150 | disposedValue = true;
151 | }
152 | }
153 |
154 | // // TODO: override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources
155 | // ~Executive()
156 | // {
157 | // // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
158 | // Dispose(disposing: false);
159 | // }
160 |
161 | public void Dispose()
162 | {
163 | // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
164 | Dispose(disposing: true);
165 | GC.SuppressFinalize(this);
166 | }
167 | }
168 | }
169 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ## Ignore Visual Studio temporary files, build results, and
2 | ## files generated by popular Visual Studio add-ons.
3 | ##
4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
5 |
6 | # User-specific files
7 | *.suo
8 | *.user
9 | *.userosscache
10 | *.sln.docstates
11 |
12 | # User-specific files (MonoDevelop/Xamarin Studio)
13 | *.userprefs
14 |
15 | # Build results
16 | [Dd]ebug/
17 | [Dd]ebugPublic/
18 | [Rr]elease/
19 | [Rr]eleases/
20 | x64/
21 | x86/
22 | bld/
23 | [Bb]in/
24 | [Oo]bj/
25 | [Ll]og/
26 |
27 | # Visual Studio 2015/2017 cache/options directory
28 | .vs/
29 | # Uncomment if you have tasks that create the project's static files in wwwroot
30 | #wwwroot/
31 |
32 | # Visual Studio 2017 auto generated files
33 | Generated\ Files/
34 |
35 | # MSTest test Results
36 | [Tt]est[Rr]esult*/
37 | [Bb]uild[Ll]og.*
38 |
39 | # NUNIT
40 | *.VisualState.xml
41 | TestResult.xml
42 |
43 | # Build Results of an ATL Project
44 | [Dd]ebugPS/
45 | [Rr]eleasePS/
46 | dlldata.c
47 |
48 | # Benchmark Results
49 | BenchmarkDotNet.Artifacts/
50 |
51 | # .NET Core
52 | project.lock.json
53 | project.fragment.lock.json
54 | artifacts/
55 | **/Properties/launchSettings.json
56 |
57 | # StyleCop
58 | StyleCopReport.xml
59 |
60 | # Files built by Visual Studio
61 | *_i.c
62 | *_p.c
63 | *_i.h
64 | *.ilk
65 | *.meta
66 | *.obj
67 | *.iobj
68 | *.pch
69 | *.pdb
70 | *.ipdb
71 | *.pgc
72 | *.pgd
73 | *.rsp
74 | *.sbr
75 | *.tlb
76 | *.tli
77 | *.tlh
78 | *.tmp
79 | *.tmp_proj
80 | *.log
81 | *.vspscc
82 | *.vssscc
83 | .builds
84 | *.pidb
85 | *.svclog
86 | *.scc
87 |
88 | # Chutzpah Test files
89 | _Chutzpah*
90 |
91 | # Visual C++ cache files
92 | ipch/
93 | *.aps
94 | *.ncb
95 | *.opendb
96 | *.opensdf
97 | *.sdf
98 | *.cachefile
99 | *.VC.db
100 | *.VC.VC.opendb
101 |
102 | # Visual Studio profiler
103 | *.psess
104 | *.vsp
105 | *.vspx
106 | *.sap
107 |
108 | # Visual Studio Trace Files
109 | *.e2e
110 |
111 | # TFS 2012 Local Workspace
112 | $tf/
113 |
114 | # Guidance Automation Toolkit
115 | *.gpState
116 |
117 | # ReSharper is a .NET coding add-in
118 | _ReSharper*/
119 | *.[Rr]e[Ss]harper
120 | *.DotSettings.user
121 |
122 | # JustCode is a .NET coding add-in
123 | .JustCode
124 |
125 | # TeamCity is a build add-in
126 | _TeamCity*
127 |
128 | # DotCover is a Code Coverage Tool
129 | *.dotCover
130 |
131 | # AxoCover is a Code Coverage Tool
132 | .axoCover/*
133 | !.axoCover/settings.json
134 |
135 | # Visual Studio code coverage results
136 | *.coverage
137 | *.coveragexml
138 |
139 | # NCrunch
140 | _NCrunch_*
141 | .*crunch*.local.xml
142 | nCrunchTemp_*
143 |
144 | # MightyMoose
145 | *.mm.*
146 | AutoTest.Net/
147 |
148 | # Web workbench (sass)
149 | .sass-cache/
150 |
151 | # Installshield output folder
152 | [Ee]xpress/
153 |
154 | # DocProject is a documentation generator add-in
155 | DocProject/buildhelp/
156 | DocProject/Help/*.HxT
157 | DocProject/Help/*.HxC
158 | DocProject/Help/*.hhc
159 | DocProject/Help/*.hhk
160 | DocProject/Help/*.hhp
161 | DocProject/Help/Html2
162 | DocProject/Help/html
163 |
164 | # Click-Once directory
165 | publish/
166 |
167 | # Publish Web Output
168 | *.[Pp]ublish.xml
169 | *.azurePubxml
170 | # Note: Comment the next line if you want to checkin your web deploy settings,
171 | # but database connection strings (with potential passwords) will be unencrypted
172 | *.pubxml
173 | *.publishproj
174 |
175 | # Microsoft Azure Web App publish settings. Comment the next line if you want to
176 | # checkin your Azure Web App publish settings, but sensitive information contained
177 | # in these scripts will be unencrypted
178 | PublishScripts/
179 |
180 | # NuGet Packages
181 | *.nupkg
182 | # The packages folder can be ignored because of Package Restore
183 | **/[Pp]ackages/*
184 | # except build/, which is used as an MSBuild target.
185 | !**/[Pp]ackages/build/
186 | # Uncomment if necessary however generally it will be regenerated when needed
187 | #!**/[Pp]ackages/repositories.config
188 | # NuGet v3's project.json files produces more ignorable files
189 | *.nuget.props
190 | *.nuget.targets
191 |
192 | # Microsoft Azure Build Output
193 | csx/
194 | *.build.csdef
195 |
196 | # Microsoft Azure Emulator
197 | ecf/
198 | rcf/
199 |
200 | # Windows Store app package directories and files
201 | AppPackages/
202 | BundleArtifacts/
203 | Package.StoreAssociation.xml
204 | _pkginfo.txt
205 | *.appx
206 |
207 | # Visual Studio cache files
208 | # files ending in .cache can be ignored
209 | *.[Cc]ache
210 | # but keep track of directories ending in .cache
211 | !*.[Cc]ache/
212 |
213 | # Others
214 | ClientBin/
215 | ~$*
216 | *~
217 | *.dbmdl
218 | *.dbproj.schemaview
219 | *.jfm
220 | *.pfx
221 | *.publishsettings
222 | orleans.codegen.cs
223 |
224 | # Including strong name files can present a security risk
225 | # (https://github.com/github/gitignore/pull/2483#issue-259490424)
226 | #*.snk
227 |
228 | # Since there are multiple workflows, uncomment next line to ignore bower_components
229 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
230 | #bower_components/
231 |
232 | # RIA/Silverlight projects
233 | Generated_Code/
234 |
235 | # Backup & report files from converting an old project file
236 | # to a newer Visual Studio version. Backup files are not needed,
237 | # because we have git ;-)
238 | _UpgradeReport_Files/
239 | Backup*/
240 | UpgradeLog*.XML
241 | UpgradeLog*.htm
242 | ServiceFabricBackup/
243 | *.rptproj.bak
244 |
245 | # SQL Server files
246 | *.mdf
247 | *.ldf
248 | *.ndf
249 |
250 | # Business Intelligence projects
251 | *.rdl.data
252 | *.bim.layout
253 | *.bim_*.settings
254 | *.rptproj.rsuser
255 |
256 | # Microsoft Fakes
257 | FakesAssemblies/
258 |
259 | # GhostDoc plugin setting file
260 | *.GhostDoc.xml
261 |
262 | # Node.js Tools for Visual Studio
263 | .ntvs_analysis.dat
264 | node_modules/
265 |
266 | # Visual Studio 6 build log
267 | *.plg
268 |
269 | # Visual Studio 6 workspace options file
270 | *.opt
271 |
272 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
273 | *.vbw
274 |
275 | # Visual Studio LightSwitch build output
276 | **/*.HTMLClient/GeneratedArtifacts
277 | **/*.DesktopClient/GeneratedArtifacts
278 | **/*.DesktopClient/ModelManifest.xml
279 | **/*.Server/GeneratedArtifacts
280 | **/*.Server/ModelManifest.xml
281 | _Pvt_Extensions
282 |
283 | # Paket dependency manager
284 | .paket/paket.exe
285 | paket-files/
286 |
287 | # FAKE - F# Make
288 | .fake/
289 |
290 | # JetBrains Rider
291 | .idea/
292 | *.sln.iml
293 |
294 | # CodeRush
295 | .cr/
296 |
297 | # Python Tools for Visual Studio (PTVS)
298 | __pycache__/
299 | *.pyc
300 |
301 | # Cake - Uncomment if you are using it
302 | # tools/**
303 | # !tools/packages.config
304 |
305 | # Tabs Studio
306 | *.tss
307 |
308 | # Telerik's JustMock configuration file
309 | *.jmconfig
310 |
311 | # BizTalk build output
312 | *.btp.cs
313 | *.btm.cs
314 | *.odx.cs
315 | *.xsd.cs
316 |
317 | # OpenCover UI analysis results
318 | OpenCover/
319 |
320 | # Azure Stream Analytics local run output
321 | ASALocalRun/
322 |
323 | # MSBuild Binary and Structured Log
324 | *.binlog
325 |
326 | # NVidia Nsight GPU debugger configuration file
327 | *.nvuser
328 |
329 | # MFractors (Xamarin productivity tool) working folder
330 | .mfractor/
331 |
--------------------------------------------------------------------------------
/Puppet.Common/Services/Hubitat.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Concurrent;
3 | using System.Collections.Generic;
4 | using System.Linq;
5 | using System.Net.Http;
6 | using System.Net.WebSockets;
7 | using System.Runtime.Caching;
8 | using System.Text;
9 | using System.Text.Json;
10 | using System.Threading;
11 | using System.Threading.Tasks;
12 |
13 | using Microsoft.Extensions.Configuration;
14 |
15 | using Newtonsoft.Json;
16 | using Newtonsoft.Json.Linq;
17 |
18 | using Puppet.Common.Configuration;
19 | using Puppet.Common.Devices;
20 | using Puppet.Common.Events;
21 | using Puppet.Common.Exceptions;
22 | using Puppet.Common.Models;
23 |
24 | namespace Puppet.Common.Services
25 | {
26 | ///
27 | /// Represents a physical Hubitat device.
28 | ///
29 | public class Hubitat : HomeAutomationPlatform
30 | {
31 | readonly string _baseMakerApiAddress;
32 | readonly string _accessToken;
33 | readonly string _baseAuxAppAddress;
34 | readonly string _auxAppAccessToken;
35 | readonly string _websocketUrl;
36 | readonly HttpClient _client;
37 | readonly MemoryCache _cache;
38 |
39 | SunriseAndSunset _sunriseAndSunset;
40 |
41 | public Hubitat(IConfiguration configuration, HttpClient httpClient)
42 | : base(configuration)
43 | {
44 | HubitatOptions hubitatOptions = configuration.GetSection("Hubitat").Get();
45 |
46 | _baseMakerApiAddress = $"https://{hubitatOptions.HubitatHostNameOrIp}/apps/api/{hubitatOptions.MakerApiAppId}/devices";
47 | _accessToken = hubitatOptions.AccessToken;
48 | _websocketUrl = $"wss://{hubitatOptions.HubitatHostNameOrIp}/eventsocket";
49 | _client = httpClient;
50 | _cache = MemoryCache.Default;
51 |
52 | _baseAuxAppAddress = $"https://{hubitatOptions.HubitatHostNameOrIp}/apps/api/{hubitatOptions.AuxAppId}";
53 | _auxAppAccessToken = hubitatOptions.AuxAppAccessToken;
54 |
55 | StateBag = new ConcurrentDictionary();
56 | }
57 |
58 | public override async Task> GetDeviceState(IDevice device)
59 | {
60 | Uri requestUri = new Uri($"{_baseMakerApiAddress}/{device.Id}?access_token={_accessToken}");
61 | Console.WriteLine($"{DateTime.Now} Hubitat Device State Request: {requestUri.ToString().Split('?')[0]}");
62 | using (var result = await _client.GetAsync(requestUri))
63 | {
64 | result.EnsureSuccessStatusCode();
65 |
66 | Dictionary state = new Dictionary();
67 | dynamic rawJson = JObject.Parse(await result.Content.ReadAsStringAsync());
68 |
69 | state.Add("name", rawJson.name.ToString());
70 | state.Add("label", rawJson.label.ToString());
71 | foreach (dynamic attribute in rawJson.attributes)
72 | {
73 | string key = attribute.name.ToString();
74 | if (!state.ContainsKey(key))
75 | {
76 | state.Add(key, attribute.currentValue.ToString());
77 | }
78 | }
79 |
80 | return state;
81 | }
82 | }
83 |
84 | public override async Task DoAction(IDevice device, string action, string[] args = null)
85 | {
86 | string secondary = (args != null) ? $"/{args[0]}" : "";
87 | secondary = secondary.Replace(' ', '-').Replace('?', '.');
88 |
89 | Uri requestUri = new Uri($"{_baseMakerApiAddress}/{device.Id}/{action.Trim()}{secondary.Trim()}?access_token={_accessToken}");
90 | Console.WriteLine($"{DateTime.Now} Hubitat Device Command: {requestUri.ToString().Split('?')[0]}");
91 | using (HttpResponseMessage result = await _client.GetAsync(requestUri))
92 | {
93 | result.EnsureSuccessStatusCode();
94 | }
95 | }
96 |
97 | public override async Task GetDeviceByLabel(string label)
98 | {
99 | var hubitatDevices = await GetHubitatDeviceList();
100 | string deviceId = hubitatDevices.Where(x => x.Label == label).FirstOrDefault()?.Id;
101 | if (deviceId == null)
102 | {
103 | throw new DeviceNotFoundException($"No device was found with the label: \"{label}\"");
104 | }
105 | return await GetDeviceById(deviceId);
106 | }
107 |
108 | private async Task> GetHubitatDeviceList(bool forceRefresh = false)
109 | {
110 | string hubitatDeviceListKey = "hubitat-device-list";
111 | if(_cache.Contains(hubitatDeviceListKey))
112 | {
113 | return _cache.Get(hubitatDeviceListKey) as List;
114 | }
115 |
116 | Uri requestUri = new Uri($"{_baseMakerApiAddress}?access_token={_accessToken}");
117 | Console.WriteLine($"{DateTime.Now} Hubitat Device Command: {requestUri.ToString().Split('?')[0]}");
118 |
119 | using (HttpResponseMessage result = await _client.GetAsync(requestUri))
120 | {
121 | result.EnsureSuccessStatusCode();
122 | List hubitatDevices = JsonConvert.DeserializeObject>(await result.Content.ReadAsStringAsync());
123 | _cache.Add(hubitatDeviceListKey, hubitatDevices, DateTimeOffset.Now.AddMinutes(1));
124 | return hubitatDevices;
125 | }
126 | }
127 | protected override async Task AuxEndpointNotification(string notificationText, bool playAudio)
128 | {
129 | string endpoint = (playAudio) ? "announcement" : "notify";
130 | Uri requestUri = new Uri($"{_baseAuxAppAddress}/{endpoint}?access_token={_auxAppAccessToken}");
131 |
132 | Console.WriteLine($"{DateTime.Now} Hubitat Device Command: {requestUri.ToString().Split('?')[0]}");
133 | string jsonText = $"{{ \"notificationText\" : \"{notificationText}\" }}";
134 | var request = new StringContent(jsonText, Encoding.UTF8, "application/json");
135 | using (HttpResponseMessage result = await _client.PostAsync(requestUri, request))
136 | {
137 | result.EnsureSuccessStatusCode();
138 | }
139 | }
140 |
141 | public override async Task GetSunriseAndSunset()
142 | {
143 | if (_sunriseAndSunset == null ||
144 | (_sunriseAndSunset?.Sunrise.Date < DateTime.Now.Date))
145 | {
146 | Uri requestUri = new Uri($"{_baseAuxAppAddress}/suntimes?access_token={_auxAppAccessToken}");
147 | Console.WriteLine($"{DateTime.Now} Hubitat Device Command: {requestUri.ToString().Split('?')[0]}");
148 | using (HttpResponseMessage result = await _client.GetAsync(requestUri))
149 | {
150 | result.EnsureSuccessStatusCode();
151 | _sunriseAndSunset = JsonConvert.DeserializeObject(await result.Content.ReadAsStringAsync());
152 | }
153 | }
154 | return _sunriseAndSunset;
155 | }
156 |
157 | }
158 | }
159 |
--------------------------------------------------------------------------------
/Hubitat/vlc-thing.groovy:
--------------------------------------------------------------------------------
1 | /**
2 | * VLC Things. A SmartThings device handler for the VLC media player.
3 | *
4 | * For more information, please visit
5 | *
6 | *
7 | * --------------------------------------------------------------------------
8 | *
9 | * Copyright © 2014 Statusbits.com
10 | *
11 | * This program is free software: you can redistribute it and/or modify it
12 | * under the terms of the GNU General Public License as published by the Free
13 | * Software Foundation, either version 3 of the License, or (at your option)
14 | * any later version.
15 | *
16 | * This program is distributed in the hope that it will be useful, but
17 | * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
18 | * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
19 | * for more details.
20 | *
21 | * You should have received a copy of the GNU General Public License along
22 | * with this program. If not, see .
23 | *
24 | * --------------------------------------------------------------------------
25 | *
26 | * Version 2.0.0 (12/22/2016)
27 | */
28 |
29 | import groovy.json.JsonSlurper
30 |
31 | preferences {
32 | // NOTE: Android client does not accept "defaultValue" attribute!
33 | input("confIpAddr", "string", title:"VLC IP Address",
34 | required:false, displayDuringSetup:true)
35 | input("confTcpPort", "number", title:"VLC TCP Port",
36 | required:false, displayDuringSetup:true)
37 | input("confPassword", "password", title:"VLC Password",
38 | required:false, displayDuringSetup:true)
39 | }
40 |
41 | metadata {
42 | definition (name:"VLC Thing", namespace:"statusbits", author:"geko@statusbits.com") {
43 | capability "Actuator"
44 | capability "Switch"
45 | capability "Music Player"
46 | capability "Speech Synthesis"
47 | capability "Refresh"
48 | capability "Polling"
49 |
50 | // Custom attributes
51 | attribute "connection", "string" // Connection status string
52 |
53 | // Custom commands
54 | command "enqueue", ["string"]
55 | command "seek", ["number"]
56 | command "playTrackAndResume", ["string","number","number"]
57 | command "playTrackAndRestore", ["string","number","number"]
58 | command "playTextAndResume", ["string","number"]
59 | command "playTextAndRestore", ["string","number"]
60 | command "playSoundAndTrack", ["string","number","json_object","number"]
61 | command "testTTS"
62 | }
63 |
64 | }
65 | def installed() {
66 | //log.debug "installed()"
67 | log.info title()
68 |
69 | // Initialize attributes to default values (Issue #18)
70 | sendEvent([name:'status', value:'stopped', displayed:false])
71 | sendEvent([name:'level', value:'0', displayed:false])
72 | sendEvent([name:'mute', value:'unmuted', displayed:false])
73 | sendEvent([name:'trackDescription', value:'', displayed:false])
74 | sendEvent([name:'connection', value:'disconnected', displayed:false])
75 | }
76 |
77 | def updated() {
78 | //log.debug "updated with settings: ${settings}"
79 | log.info title()
80 |
81 | unschedule()
82 |
83 | if (!settings.confIpAddr) {
84 | log.warn "IP address is not set!"
85 | return
86 | }
87 |
88 | def port = settings.confTcpPort
89 | if (!port) {
90 | log.warn "Using default TCP port 8080!"
91 | port = 8080
92 | }
93 |
94 | def dni = createDNI(settings.confIpAddr, port)
95 | device.deviceNetworkId = dni
96 | state.dni = dni
97 | state.hostAddress = "${settings.confIpAddr}:${settings.confTcpPort}"
98 | state.requestTime = 0
99 | state.responseTime = 0
100 | state.updatedTime = 0
101 | state.lastPoll = 0
102 |
103 | if (settings.confPassword) {
104 | state.userAuth = ":${settings.confPassword}".bytes.encodeBase64() as String
105 | } else {
106 | state.userAuth = null
107 | }
108 |
109 | startPollingTask()
110 | //STATE()
111 | }
112 |
113 | def pollingTask() {
114 | //log.debug "pollingTask()"
115 |
116 | state.lastPoll = now()
117 |
118 | // Check connection status
119 | def requestTime = state.requestTime ?: 0
120 | def responseTime = state.responseTime ?: 0
121 | if (requestTime && (requestTime - responseTime) > 10000) {
122 | log.warn "No connection!"
123 | sendEvent([
124 | name: 'connection',
125 | value: 'disconnected',
126 | isStateChange: true,
127 | displayed: true
128 | ])
129 | }
130 |
131 | def updated = state.updatedTime ?: 0
132 | if ((now() - updated) > 10000) {
133 | return apiGetStatus()
134 | }
135 | }
136 |
137 | def parse(String message) {
138 | def msg = stringToMap(message)
139 | if (msg.containsKey("simulator")) {
140 | // simulator input
141 | return parseHttpResponse(msg)
142 | }
143 |
144 | if (!msg.containsKey("headers")) {
145 | log.error "No HTTP headers found in '${message}'"
146 | return null
147 | }
148 |
149 | // parse HTTP response headers
150 | def headers = new String(msg.headers.decodeBase64())
151 | def parsedHeaders = parseHttpHeaders(headers)
152 | log.debug "parsedHeaders: ${parsedHeaders}"
153 | if (parsedHeaders.status != 200) {
154 | log.error "Server error: ${parsedHeaders.reason}"
155 | return null
156 | }
157 |
158 | // parse HTTP response body
159 | if (!msg.body) {
160 | log.error "No HTTP body found in '${message}'"
161 | return null
162 | }
163 |
164 | def body = new String(msg.body.decodeBase64())
165 | //log.debug "body: ${body}"
166 | def slurper = new JsonSlurper()
167 | return parseHttpResponse(slurper.parseText(body))
168 | }
169 |
170 | // switch.on
171 | def on() {
172 | play()
173 | }
174 |
175 | // switch.off
176 | def off() {
177 | stop()
178 | }
179 |
180 | // MusicPlayer.play
181 | def play() {
182 | //log.debug "play()"
183 |
184 | def command
185 | if (device.currentValue('status') == 'paused') {
186 | command = 'command=pl_forceresume'
187 | } else {
188 | command = 'command=pl_play'
189 | }
190 |
191 | return apiCommand(command, 500)
192 | }
193 |
194 | // MusicPlayer.stop
195 | def stop() {
196 | //log.debug "stop()"
197 | return apiCommand("command=pl_stop", 500)
198 | }
199 |
200 | // MusicPlayer.pause
201 | def pause() {
202 | //log.debug "pause()"
203 | return apiCommand("command=pl_forcepause")
204 | }
205 |
206 | // MusicPlayer.playTrack
207 | def playTrack(uri) {
208 | //log.debug "playTrack(${uri})"
209 | def command = "command=in_play&input=" + URLEncoder.encode(uri, "UTF-8")
210 | return apiCommand(command, 500)
211 | }
212 |
213 | // MusicPlayer.playText
214 | def playText(text) {
215 | log.debug "playText(${text})"
216 | def sound = myTextToSpeech(text)
217 | log.debug "line 278 ${sound}"
218 | return playTrack(sound.uri)
219 | }
220 |
221 | // MusicPlayer.setTrack
222 | def setTrack(name) {
223 | log.warn "setTrack(${name}) not implemented"
224 | return null
225 | }
226 |
227 | // MusicPlayer.resumeTrack
228 | def resumeTrack(name) {
229 | log.warn "resumeTrack(${name}) not implemented"
230 | return null
231 | }
232 |
233 | // MusicPlayer.restoreTrack
234 | def restoreTrack(name) {
235 | log.warn "restoreTrack(${name}) not implemented"
236 | return null
237 | }
238 |
239 | // MusicPlayer.nextTrack
240 | def nextTrack() {
241 | //log.debug "nextTrack()"
242 | return apiCommand("command=pl_next", 500)
243 | }
244 |
245 | // MusicPlayer.previousTrack
246 | def previousTrack() {
247 | //log.debug "previousTrack()"
248 | return apiCommand("command=pl_previous", 500)
249 | }
250 |
251 | // MusicPlayer.setLevel
252 | def setLevel(number) {
253 | //log.debug "setLevel(${number})"
254 |
255 | if (device.currentValue('mute') == 'muted') {
256 | sendEvent(name:'mute', value:'unmuted')
257 | }
258 |
259 | sendEvent(name:"level", value:number)
260 | def volume = ((number * 512) / 100) as int
261 | return apiCommand("command=volume&val=${volume}")
262 | }
263 |
264 | // MusicPlayer.mute
265 | def mute() {
266 | //log.debug "mute()"
267 |
268 | if (device.currentValue('mute') == 'muted') {
269 | return null
270 | }
271 |
272 | state.savedVolume = device.currentValue('level')
273 | sendEvent(name:'mute', value:'muted')
274 | sendEvent(name:'level', value:0)
275 |
276 | return apiCommand("command=volume&val=0")
277 | }
278 |
279 | // MusicPlayer.unmute
280 | def unmute() {
281 | //log.debug "unmute()"
282 |
283 | if (device.currentValue('mute') == 'muted') {
284 | return setLevel(state.savedVolume.toInteger())
285 | }
286 |
287 | return null
288 | }
289 |
290 | // SpeechSynthesis.speak
291 | def speak(text) {
292 | log.debug "speak(${text})"
293 | def sound = myTextToSpeech(text)
294 | return playTrack(sound.uri)
295 | }
296 |
297 | // polling.poll
298 | def poll() {
299 | //log.debug "poll()"
300 | return refresh()
301 | }
302 |
303 | // refresh.refresh
304 | def refresh() {
305 | //log.debug "refresh()"
306 | //STATE()
307 |
308 | if (!updateDNI()) {
309 | sendEvent([
310 | name: 'connection',
311 | value: 'disconnected',
312 | isStateChange: true,
313 | displayed: false
314 | ])
315 |
316 | return null
317 | }
318 |
319 | // Restart polling task if it's not run for 5 minutes
320 | def elapsed = (now() - state.lastPoll) / 1000
321 | if (elapsed > 300) {
322 | log.warn "Restarting polling task..."
323 | unschedule()
324 | startPollingTask()
325 | }
326 |
327 | return apiGetStatus()
328 | }
329 |
330 | def enqueue(uri) {
331 | //log.debug "enqueue(${uri})"
332 | def command = "command=in_enqueue&input=" + URLEncoder.encode(uri, "UTF-8")
333 | return apiCommand(command)
334 | }
335 |
336 | def seek(trackNumber) {
337 | //log.debug "seek(${trackNumber})"
338 | def command = "command=pl_play&id=${trackNumber}"
339 | return apiCommand(command, 500)
340 | }
341 |
342 | def playTrackAndResume(uri, duration, volume = null) {
343 | //log.debug "playTrackAndResume(${uri}, ${duration}, ${volume})"
344 |
345 | // FIXME
346 | return playTrackAndRestore(uri, duration, volume)
347 | }
348 |
349 | def playTrackAndRestore(uri, duration, volume = null) {
350 | //log.debug "playTrackAndRestore(${uri}, ${duration}, ${volume})"
351 |
352 | def currentStatus = device.currentValue('status')
353 | def currentVolume = device.currentValue('level')
354 | def currentMute = device.currentValue('mute')
355 | def actions = []
356 | if (currentStatus == 'playing') {
357 | actions << apiCommand("command=pl_stop")
358 | actions << delayHubAction(500)
359 | }
360 |
361 | if (volume) {
362 | actions << setLevel(volume)
363 | actions << delayHubAction(500)
364 | } else if (currentMute == 'muted') {
365 | actions << unmute()
366 | actions << delayHubAction(500)
367 | }
368 |
369 | def delay = (duration.toInteger() + 1) * 1000
370 | //log.debug "delay = ${delay}"
371 |
372 | actions << playTrack(uri)
373 | actions << delayHubAction(delay)
374 | actions << apiCommand("command=pl_stop")
375 | actions << delayHubAction(500)
376 |
377 | if (currentMute == 'muted') {
378 | actions << mute()
379 | } else if (volume) {
380 | actions << setLevel(currentVolume)
381 | }
382 |
383 | actions << apiGetStatus()
384 | actions = actions.flatten()
385 | //log.debug "actions: ${actions}"
386 |
387 | return actions
388 | }
389 |
390 | def playTextAndResume(text) {
391 | log.debug "playTextAndResume(${text}, ${volume})"
392 | def sound = myTextToSpeech(text)
393 | log.debug "line 393 sound = ${sound}"
394 | return playTrackAndResume(sound.uri, (sound.duration as Integer) + 1, volume)
395 | }
396 |
397 | def playTextAndRestore(text, volume = null) {
398 | //log.debug "playTextAndRestore(${text}, ${volume})"
399 | def sound = myTextToSpeech(text)
400 | return playTrackAndRestore(sound.uri, (sound.duration as Integer) + 1, volume)
401 | }
402 |
403 | def playSoundAndTrack(uri, duration, trackData, volume = null) {
404 | //log.debug "playSoundAndTrack(${uri}, ${duration}, ${trackData}, ${volume})"
405 |
406 | // FIXME
407 | return playTrackAndRestore(uri, duration, volume)
408 | }
409 |
410 | def testTTS() {
411 | //log.debug "testTTS()"
412 | def text = "VLC for Smart Things is brought to you by Statusbits.com"
413 | return playTextAndResume(text)
414 | }
415 |
416 | private startPollingTask() {
417 | //log.debug "startPollingTask()"
418 |
419 | pollingTask()
420 |
421 | Random rand = new Random(now())
422 | def seconds = rand.nextInt(60)
423 | def sched = "${seconds} 0/1 * * * ?"
424 |
425 | //log.debug "Scheduling polling task with \"${sched}\""
426 | schedule(sched, pollingTask)
427 | }
428 |
429 | def apiGet(String path) {
430 | //log.debug "apiGet(${path})"
431 |
432 | if (!updateDNI()) {
433 | return null
434 | }
435 |
436 | state.requestTime = now()
437 | state.responseTime = 0
438 |
439 | def headers = [
440 | HOST: state.hostAddress,
441 | Accept: "*/*"
442 | ]
443 |
444 | if (state.userAuth != null) {
445 | headers['Authorization'] = "Basic ${state.userAuth}"
446 | }
447 |
448 | def httpRequest = [
449 | method: 'GET',
450 | path: path,
451 | headers: headers
452 | ]
453 |
454 | //log.debug "httpRequest: ${httpRequest}"
455 | return new hubitat.device.HubAction(httpRequest)
456 | }
457 |
458 | private def delayHubAction(ms) {
459 | return new hubitat.device.HubAction("delay ${ms}")
460 | }
461 |
462 | private apiGetStatus() {
463 | return apiGet("/requests/status.json")
464 | }
465 |
466 | private apiCommand(command, refresh = 0) {
467 | //log.debug "apiCommand(${command})"
468 |
469 | def actions = [
470 | apiGet("/requests/status.json?${command}")
471 | ]
472 |
473 | if (refresh) {
474 | actions << delayHubAction(refresh)
475 | actions << apiGetStatus()
476 | }
477 |
478 | return actions
479 | }
480 |
481 | private def apiGetPlaylists() {
482 | //log.debug "getPlaylists()"
483 | return apiGet("/requests/playlist.json")
484 | }
485 |
486 | private parseHttpHeaders(String headers) {
487 | def lines = headers.readLines()
488 | def status = lines.remove(0).split()
489 |
490 | def result = [
491 | protocol: status[0],
492 | status: status[1].toInteger(),
493 | reason: status[2]
494 | ]
495 |
496 | return result
497 | }
498 |
499 | private def parseHttpResponse(Map data) {
500 | //log.debug "parseHttpResponse(${data})"
501 |
502 | state.updatedTime = now()
503 | if (!state.responseTime) {
504 | state.responseTime = now()
505 | }
506 |
507 | def events = []
508 |
509 | if (data.containsKey('state')) {
510 | def vlcState = data.state
511 | //log.debug "VLC state: ${vlcState})"
512 | events << createEvent(name:"status", value:vlcState)
513 | if (vlcState == 'stopped') {
514 | events << createEvent([name:'trackDescription', value:''])
515 | }
516 | }
517 |
518 | if (data.containsKey('volume')) {
519 | //log.debug "VLC volume: ${data.volume})"
520 | def volume = ((data.volume.toInteger() * 100) / 512) as int
521 | events << createEvent(name:'level', value:volume)
522 | }
523 |
524 | if (data.containsKey('information')) {
525 | parseTrackInfo(events, data.information)
526 | }
527 |
528 | events << createEvent([
529 | name: 'connection',
530 | value: 'connected',
531 | isStateChange: true,
532 | displayed: false
533 | ])
534 |
535 | //log.debug "events: ${events}"
536 | return events
537 | }
538 |
539 | private def parseTrackInfo(events, Map info) {
540 | //log.debug "parseTrackInfo(${events}, ${info})"
541 |
542 | if (info.containsKey('category') && info.category.containsKey('meta')) {
543 | def meta = info.category.meta
544 | //log.debug "Track info: ${meta})"
545 | if (meta.containsKey('filename')) {
546 | if (meta.filename.contains("//s3.amazonaws.com/smartapp-")) {
547 | log.trace "Skipping event generation for sound file ${meta.filename}"
548 | return
549 | }
550 | }
551 |
552 | def track = ""
553 | if (meta.containsKey('artist')) {
554 | track = "${meta.artist} - "
555 | }
556 | if (meta.containsKey('title')) {
557 | track += meta.title
558 | } else if (meta.containsKey('filename')) {
559 | def parts = meta.filename.tokenize('/');
560 | track += parts.last()
561 | } else {
562 | track += ''
563 | }
564 |
565 | if (track != device.currentState('trackDescription')) {
566 | meta.station = track
567 | events << createEvent(name:'trackDescription', value:track, displayed:false)
568 | // events << createEvent(name:'trackData', value:meta.encodeAsJSON(), displayed:false)
569 | }
570 | }
571 | }
572 |
573 | private def myTextToSpeech(text) {
574 |
575 | def sound = textToSpeech(text)
576 | log.debug "${text}"
577 | log.debug "${sound.uri}"
578 | sound.uri = sound.uri.replace('https:', 'http:')
579 | return sound
580 | }
581 |
582 | private String createDNI(ipaddr, port) {
583 | //log.debug "createDNI(${ipaddr}, ${port})"
584 |
585 | def hexIp = ipaddr.tokenize('.').collect {
586 | String.format('%02X', it.toInteger())
587 | }.join()
588 |
589 | def hexPort = String.format('%04X', port.toInteger())
590 |
591 | return "${hexIp}:${hexPort}"
592 | }
593 |
594 | private updateDNI() {
595 | if (!state.dni) {
596 | log.warn "DNI is not set! Please enter IP address and port in settings."
597 | return false
598 | }
599 |
600 | if (state.dni != device.deviceNetworkId) {
601 | log.warn "Invalid DNI: ${device.deviceNetworkId}!"
602 | device.deviceNetworkId = state.dni
603 | }
604 |
605 | return true
606 | }
607 |
608 | private def title() {
609 | return "VLC Thing. Version 2.0.0 (12/22/2016). Copyright © 2014 Statusbits.com"
610 | }
611 |
612 | private def STATE() {
613 | log.trace "state: ${state}"
614 | log.trace "deviceNetworkId: ${device.deviceNetworkId}"
615 | log.trace "status: ${device.currentValue('status')}"
616 | log.trace "level: ${device.currentValue('level')}"
617 | log.trace "mute: ${device.currentValue('mute')}"
618 | log.trace "trackDescription: ${device.currentValue('trackDescription')}"
619 | log.trace "connection: ${device.currentValue("connection")}"
620 | }
--------------------------------------------------------------------------------