From 2d14463132e69cd18488aea34ec9120209edd4f7 Mon Sep 17 00:00:00 2001 From: Chris Kaczor Date: Wed, 17 Aug 2022 16:27:28 -0400 Subject: [PATCH] Add initial device status service --- DeviceStatus/Arduino/Main/Main.ino | 162 ++++++++++++++++++ DeviceStatus/Arduino/Main/arduino_secrets.h | 2 + DeviceStatus/DeviceStatus.sln | 25 +++ DeviceStatus/DeviceStatus.sln.DotSettings | 2 + DeviceStatus/Service/.vscode/launch.json | 35 ++++ DeviceStatus/Service/.vscode/tasks.json | 41 +++++ .../Service/Controllers/StatusController.cs | 21 +++ DeviceStatus/Service/Device.cs | 18 ++ DeviceStatus/Service/DeviceRepository.cs | 18 ++ DeviceStatus/Service/Dockerfile | 19 ++ DeviceStatus/Service/MessageHandler.cs | 76 ++++++++ DeviceStatus/Service/Program.cs | 26 +++ .../Service/Properties/launchSettings.json | 30 ++++ DeviceStatus/Service/Service.csproj | 17 ++ .../Service/appsettings.Development.json | 11 ++ DeviceStatus/Service/appsettings.json | 12 ++ .../Service/deploy/azure-pipelines.yml | 68 ++++++++ DeviceStatus/Service/deploy/manifest.yaml | 65 +++++++ 18 files changed, 648 insertions(+) create mode 100644 DeviceStatus/Arduino/Main/Main.ino create mode 100644 DeviceStatus/Arduino/Main/arduino_secrets.h create mode 100644 DeviceStatus/DeviceStatus.sln create mode 100644 DeviceStatus/DeviceStatus.sln.DotSettings create mode 100644 DeviceStatus/Service/.vscode/launch.json create mode 100644 DeviceStatus/Service/.vscode/tasks.json create mode 100644 DeviceStatus/Service/Controllers/StatusController.cs create mode 100644 DeviceStatus/Service/Device.cs create mode 100644 DeviceStatus/Service/DeviceRepository.cs create mode 100644 DeviceStatus/Service/Dockerfile create mode 100644 DeviceStatus/Service/MessageHandler.cs create mode 100644 DeviceStatus/Service/Program.cs create mode 100644 DeviceStatus/Service/Properties/launchSettings.json create mode 100644 DeviceStatus/Service/Service.csproj create mode 100644 DeviceStatus/Service/appsettings.Development.json create mode 100644 DeviceStatus/Service/appsettings.json create mode 100644 DeviceStatus/Service/deploy/azure-pipelines.yml create mode 100644 DeviceStatus/Service/deploy/manifest.yaml diff --git a/DeviceStatus/Arduino/Main/Main.ino b/DeviceStatus/Arduino/Main/Main.ino new file mode 100644 index 0000000..94ab672 --- /dev/null +++ b/DeviceStatus/Arduino/Main/Main.ino @@ -0,0 +1,162 @@ +#include +#include + +#include "arduino_secrets.h" + +char ssid[] = SECRET_SSID; +char pass[] = SECRET_PASS; + +WiFiClient wifiClient; +MqttClient mqttClient(wifiClient); + +const char broker[] = "172.23.10.51"; +int port = 1883; + +const long interval = 500; // Interval for sending messages (milliseconds) +unsigned long previousMilliseconds = 0; + +int washerPin = 0; +int dryerPin = 1; + +int lastWasherValue = -1; +int lastDryerValue = -1; + +const char washerTopic[] = "washer"; +const char dryerTopic[] = "dryer"; + +void connectNetwork() { + Serial.print("Attempting to connect to WPA SSID: "); + Serial.println(ssid); + + while (WiFi.begin(ssid, pass) != WL_CONNECTED) { + Serial.print("."); + delay(5000); + } + + Serial.println("Connected to the network"); + Serial.println(); +} + +bool checkNetwork() { + if (!WiFi.status() == WL_DISCONNECTED) { + WiFi.disconnect(); + + Serial.print("Attempting to reconnect to WPA SSID: "); + Serial.println(ssid); + + if (!WiFi.begin(ssid, pass) != WL_CONNECTED) { + Serial.println("Network reconnection failed"); + return false; + } + + Serial.println("Network reconnected"); + return true; + } + + return true; +} + +void connectBroker() { + Serial.print("Attempting to connect to the MQTT broker: "); + Serial.println(broker); + + while (!mqttClient.connect(broker, port)) { + Serial.print("."); + delay(5000); + } + + Serial.print("Connected to the MQTT broker: "); + Serial.println(broker); +} + +bool checkBroker() { + if (!mqttClient.connected()) { + Serial.print("Attempting to reconnect to the MQTT broker: "); + Serial.println(broker); + + if (!mqttClient.connect(broker, port)) { + Serial.println("Broker reconnection failed"); + return false; + } + + Serial.println("Broker reconnected"); + + outputValue(washerTopic, lastWasherValue); + outputValue(dryerTopic, lastDryerValue); + + return true; + } + + return true; +} + +void setupDevices() { + pinMode(washerPin, INPUT_PULLUP); + pinMode(dryerPin, INPUT_PULLUP); + + lastWasherValue = digitalRead(washerPin); + lastDryerValue = digitalRead(dryerPin); + + outputValue(washerTopic, lastWasherValue); + outputValue(dryerTopic, lastDryerValue); +} + +void outputValue(char topic[], int value) { + Serial.print("Sending message to topic: "); + Serial.print(topic); + Serial.print(" "); + Serial.println(value == 1 ? 0 : 1); + + mqttClient.beginMessage(topic); + mqttClient.print(value == 1 ? 0 : 1); + mqttClient.endMessage(); +} + +void setup() { + Serial.begin(9600); + + while (!Serial) { + ; // wait for serial port to connect. Needed for native USB port only + } + + connectNetwork(); + connectBroker(); + + setupDevices(); + + Serial.println(); +} + +void loop() { + mqttClient.poll(); + + unsigned long currentMilliseconds = millis(); + + if (currentMilliseconds - previousMilliseconds >= interval) { + previousMilliseconds = currentMilliseconds; + + if (!checkNetwork()) { + return; + } + + if (!checkBroker()) { + return; + } + + int washerValue = digitalRead(washerPin); + + if (washerValue != lastWasherValue) { + lastWasherValue = washerValue; + + outputValue(washerTopic, washerValue); + } + + int dryerValue = digitalRead(dryerPin); + + if (dryerValue != lastDryerValue) { + lastDryerValue = dryerValue; + + outputValue(dryerTopic, dryerValue); + } + } +} \ No newline at end of file diff --git a/DeviceStatus/Arduino/Main/arduino_secrets.h b/DeviceStatus/Arduino/Main/arduino_secrets.h new file mode 100644 index 0000000..a36e69b --- /dev/null +++ b/DeviceStatus/Arduino/Main/arduino_secrets.h @@ -0,0 +1,2 @@ +#define SECRET_SSID "" +#define SECRET_PASS "" \ No newline at end of file diff --git a/DeviceStatus/DeviceStatus.sln b/DeviceStatus/DeviceStatus.sln new file mode 100644 index 0000000..5c7020f --- /dev/null +++ b/DeviceStatus/DeviceStatus.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.3.32811.315 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Service", "Service\Service.csproj", "{2EAE02A0-3657-419F-AF3E-BF0B942FAE0D}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {2EAE02A0-3657-419F-AF3E-BF0B942FAE0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2EAE02A0-3657-419F-AF3E-BF0B942FAE0D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2EAE02A0-3657-419F-AF3E-BF0B942FAE0D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2EAE02A0-3657-419F-AF3E-BF0B942FAE0D}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {A63ACB0D-1EE1-4C2A-A084-1704568325FE} + EndGlobalSection +EndGlobal diff --git a/DeviceStatus/DeviceStatus.sln.DotSettings b/DeviceStatus/DeviceStatus.sln.DotSettings new file mode 100644 index 0000000..0afe5da --- /dev/null +++ b/DeviceStatus/DeviceStatus.sln.DotSettings @@ -0,0 +1,2 @@ + + True \ No newline at end of file diff --git a/DeviceStatus/Service/.vscode/launch.json b/DeviceStatus/Service/.vscode/launch.json new file mode 100644 index 0000000..bfe824e --- /dev/null +++ b/DeviceStatus/Service/.vscode/launch.json @@ -0,0 +1,35 @@ +{ + "version": "0.2.0", + "configurations": [ + { + // Use IntelliSense to find out which attributes exist for C# debugging + // Use hover for the description of the existing attributes + // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md + "name": ".NET Core Launch (web)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + // If you have changed target frameworks, make sure to update the program path. + "program": "${workspaceFolder}/bin/Debug/net6.0/Service.dll", + "args": [], + "cwd": "${workspaceFolder}", + "stopAtEntry": false, + // Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser + "serverReadyAction": { + "action": "openExternally", + "pattern": "\\bNow listening on:\\s+(https?://\\S+)" + }, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "sourceFileMap": { + "/Views": "${workspaceFolder}/Views" + } + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach" + } + ] +} \ No newline at end of file diff --git a/DeviceStatus/Service/.vscode/tasks.json b/DeviceStatus/Service/.vscode/tasks.json new file mode 100644 index 0000000..23d770d --- /dev/null +++ b/DeviceStatus/Service/.vscode/tasks.json @@ -0,0 +1,41 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/Service.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "publish", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/Service.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "watch", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "--project", + "${workspaceFolder}/Service.csproj" + ], + "problemMatcher": "$msCompile" + } + ] +} \ No newline at end of file diff --git a/DeviceStatus/Service/Controllers/StatusController.cs b/DeviceStatus/Service/Controllers/StatusController.cs new file mode 100644 index 0000000..2608c35 --- /dev/null +++ b/DeviceStatus/Service/Controllers/StatusController.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Service.Controllers +{ + [Route("[controller]")] + [ApiController] + public class StatusController : ControllerBase + { + private readonly DeviceRepository _deviceRepository; + public StatusController(DeviceRepository deviceRepository) + { + _deviceRepository = deviceRepository; + } + + [HttpGet("recent")] + public ActionResult> GetRecent() + { + return _deviceRepository.Values; + } + } +} diff --git a/DeviceStatus/Service/Device.cs b/DeviceStatus/Service/Device.cs new file mode 100644 index 0000000..c303726 --- /dev/null +++ b/DeviceStatus/Service/Device.cs @@ -0,0 +1,18 @@ +namespace Service; + +public class Device +{ + public string Name { get; } + public bool Status { get; set; } + + public Device(string name, string statusString) + { + Name = name; + Update(statusString); + } + + public void Update(string statusString) + { + Status = statusString == "1"; + } +} \ No newline at end of file diff --git a/DeviceStatus/Service/DeviceRepository.cs b/DeviceStatus/Service/DeviceRepository.cs new file mode 100644 index 0000000..b48b35d --- /dev/null +++ b/DeviceStatus/Service/DeviceRepository.cs @@ -0,0 +1,18 @@ +namespace Service; + +public class DeviceRepository : Dictionary +{ + public void HandleDeviceMessage(string name, string value) + { + if (ContainsKey(name)) + { + this[name].Update(value); + } + else + { + var device = new Device(name, value); + + this[name] = device; + } + } +} \ No newline at end of file diff --git a/DeviceStatus/Service/Dockerfile b/DeviceStatus/Service/Dockerfile new file mode 100644 index 0000000..859d5bc --- /dev/null +++ b/DeviceStatus/Service/Dockerfile @@ -0,0 +1,19 @@ +FROM mcr.microsoft.com/dotnet/aspnet:6.0-alpine AS base +WORKDIR /app +EXPOSE 80 +EXPOSE 1883 + +FROM mcr.microsoft.com/dotnet/sdk:6.0-alpine AS build +WORKDIR /src +COPY ["./Service.csproj", "./"] +RUN dotnet restore "Service.csproj" +COPY . . +WORKDIR "/src" +RUN dotnet publish "Service.csproj" -c Release -o /app + +FROM base AS final +WORKDIR /app +COPY --from=build /app . +RUN apk add --no-cache icu-libs +ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false +ENTRYPOINT ["dotnet", "ChrisKaczor.HomeMonitor.DeviceStatus.Service.dll"] \ No newline at end of file diff --git a/DeviceStatus/Service/MessageHandler.cs b/DeviceStatus/Service/MessageHandler.cs new file mode 100644 index 0000000..20d443c --- /dev/null +++ b/DeviceStatus/Service/MessageHandler.cs @@ -0,0 +1,76 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.SignalR.Client; +using MQTTnet; +using MQTTnet.Server; + +namespace Service; + +public class MessageHandler : IHostedService +{ + private MqttServer? _mqttServer; + private HubConnection? _hubConnection; + + private readonly IConfiguration _configuration; + private readonly DeviceRepository _deviceRepository; + + public MessageHandler(IConfiguration configuration, DeviceRepository deviceRepository) + { + _configuration = configuration; + _deviceRepository = deviceRepository; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + if (!string.IsNullOrEmpty(_configuration["Hub:DeviceStatus"])) + _hubConnection = new HubConnectionBuilder().WithUrl(_configuration["Hub:DeviceStatus"]).Build(); + + var mqttFactory = new MqttFactory(); + + var mqttServerOptions = new MqttServerOptionsBuilder().WithDefaultEndpoint().Build(); + + _mqttServer = mqttFactory.CreateMqttServer(mqttServerOptions); + _mqttServer.InterceptingPublishAsync += OnInterceptingPublishAsync; + + await _mqttServer.StartAsync(); + } + + private async Task OnInterceptingPublishAsync(InterceptingPublishEventArgs arg) + { + _deviceRepository.HandleDeviceMessage(arg.ApplicationMessage.Topic, arg.ApplicationMessage.ConvertPayloadToString()); + + Console.WriteLine(arg.ApplicationMessage.Topic); + Console.WriteLine(arg.ApplicationMessage.ConvertPayloadToString()); + + if (_hubConnection == null) + return; + + try + { + if (_hubConnection.State == HubConnectionState.Disconnected) + _hubConnection.StartAsync().Wait(); + + var json = JsonSerializer.Serialize(_deviceRepository[arg.ApplicationMessage.Topic]); + + await _hubConnection.InvokeAsync("SendLatestStatus", json); + } + catch (Exception exception) + { + WriteLog($"Hub exception: {exception}"); + } + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + if (_hubConnection != null) + await _hubConnection.StopAsync(cancellationToken); + + if (_mqttServer != null) + await _mqttServer.StopAsync(); + } + + private static void WriteLog(string message) + { + Console.WriteLine(message); + } +} \ No newline at end of file diff --git a/DeviceStatus/Service/Program.cs b/DeviceStatus/Service/Program.cs new file mode 100644 index 0000000..4e6f879 --- /dev/null +++ b/DeviceStatus/Service/Program.cs @@ -0,0 +1,26 @@ +using Service; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddControllers(); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddHostedService(); + +builder.Services.AddSingleton(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); \ No newline at end of file diff --git a/DeviceStatus/Service/Properties/launchSettings.json b/DeviceStatus/Service/Properties/launchSettings.json new file mode 100644 index 0000000..1083940 --- /dev/null +++ b/DeviceStatus/Service/Properties/launchSettings.json @@ -0,0 +1,30 @@ +{ + "profiles": { + "Service": { + "commandName": "Project", + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:5229" + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + }, + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:50399", + "sslPort": 0 + } + } +} \ No newline at end of file diff --git a/DeviceStatus/Service/Service.csproj b/DeviceStatus/Service/Service.csproj new file mode 100644 index 0000000..8c98fb2 --- /dev/null +++ b/DeviceStatus/Service/Service.csproj @@ -0,0 +1,17 @@ + + + + net6.0 + enable + enable + + + + + + + + + + + diff --git a/DeviceStatus/Service/appsettings.Development.json b/DeviceStatus/Service/appsettings.Development.json new file mode 100644 index 0000000..5be2108 --- /dev/null +++ b/DeviceStatus/Service/appsettings.Development.json @@ -0,0 +1,11 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "Hub": { + "DeviceStatus": "http://localhost:5000/device-status" + } +} diff --git a/DeviceStatus/Service/appsettings.json b/DeviceStatus/Service/appsettings.json new file mode 100644 index 0000000..3ba4f12 --- /dev/null +++ b/DeviceStatus/Service/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Hub": { + "DeviceStatus": "http://hub-server/device-status" + } +} diff --git a/DeviceStatus/Service/deploy/azure-pipelines.yml b/DeviceStatus/Service/deploy/azure-pipelines.yml new file mode 100644 index 0000000..814c937 --- /dev/null +++ b/DeviceStatus/Service/deploy/azure-pipelines.yml @@ -0,0 +1,68 @@ +name: $(Rev:r) + +pr: none + +trigger: + batch: 'true' + branches: + include: + - master + paths: + include: + - DeviceStatus/Service + +stages: +- stage: Build + jobs: + - job: Build + pool: + vmImage: 'ubuntu-latest' + steps: + - task: Docker@0 + displayName: 'Build an image' + inputs: + containerregistrytype: 'Container Registry' + dockerRegistryConnection: 'Docker Hub' + dockerFile: 'DeviceStatus/Service/Dockerfile' + imageName: 'ckaczor/home-monitor-device-status-service:$(Build.BuildNumber)' + includeLatestTag: true + - task: Docker@0 + displayName: 'Push an image' + inputs: + containerregistrytype: 'Container Registry' + dockerRegistryConnection: 'Docker Hub' + action: 'Push an image' + imageName: 'ckaczor/home-monitor-device-status-service:$(Build.BuildNumber)' + includeLatestTag: true + - task: Bash@3 + inputs: + targetType: 'inline' + script: 'sed -i s/#BUILD_BUILDNUMBER#/$BUILD_BUILDNUMBER/ DeviceStatus/Service/deploy/manifest.yaml' + - task: PublishBuildArtifacts@1 + inputs: + PathtoPublish: 'DeviceStatus/Service/deploy/manifest.yaml' + ArtifactName: 'Manifest' + publishLocation: 'Container' + +- stage: Deploy + jobs: + - job: Deploy + pool: + vmImage: 'ubuntu-latest' + steps: + - task: DownloadBuildArtifacts@0 + inputs: + artifactName: 'Manifest' + buildType: 'current' + downloadType: 'single' + downloadPath: '$(System.ArtifactsDirectory)' + - task: Kubernetes@1 + inputs: + connectionType: 'Kubernetes Service Connection' + kubernetesServiceEndpoint: 'Kubernetes' + namespace: 'home-monitor' + command: 'apply' + useConfigurationFile: true + configuration: '$(System.ArtifactsDirectory)/Manifest/manifest.yaml' + secretType: 'dockerRegistry' + containerRegistryType: 'Container Registry' \ No newline at end of file diff --git a/DeviceStatus/Service/deploy/manifest.yaml b/DeviceStatus/Service/deploy/manifest.yaml new file mode 100644 index 0000000..517fcd3 --- /dev/null +++ b/DeviceStatus/Service/deploy/manifest.yaml @@ -0,0 +1,65 @@ +--- +kind: Deployment +apiVersion: apps/v1 +metadata: + name: device-status-service + namespace: home-monitor + labels: + app: device-status-service +spec: + replicas: 1 + selector: + matchLabels: + app: device-status-service + template: + metadata: + labels: + app: device-status-service + spec: + containers: + - name: device-status-service + image: ckaczor/home-monitor-device-status-service:#BUILD_BUILDNUMBER# + terminationMessagePath: "/dev/termination-log" + terminationMessagePolicy: File + imagePullPolicy: Always + securityContext: + privileged: true + env: + - name: Hub__DeviceStatus + value: http://hub-service/device-status + restartPolicy: Always + terminationGracePeriodSeconds: 30 + dnsPolicy: ClusterFirst + nodeSelector: + kubernetes.io/hostname: kubernetes + schedulerName: default-scheduler +--- +kind: Service +apiVersion: v1 +metadata: + name: device-status-service +spec: + ports: + - name: client + port: 80 + selector: + app: device-status-service + type: ClusterIP +--- +kind: Ingress +apiVersion: extensions/v1beta1 +metadata: + name: device-status + namespace: home-monitor + annotations: + kubernetes.io/ingress.class: traefik + nginx.ingress.kubernetes.io/ssl-redirect: 'false' + traefik.frontend.rule.type: PathPrefixStrip +spec: + rules: + - http: + paths: + - path: "/api/device-status" + backend: + serviceName: device-status-service + servicePort: 80 \ No newline at end of file