From 264f03a22f084aac5a68c41120c50a19fd2e2971 Mon Sep 17 00:00:00 2001 From: Chris Kaczor Date: Mon, 15 Jul 2019 20:51:25 -0400 Subject: [PATCH] Initial commit from private --- .dockerignore | 9 + .gitattributes | 63 ++++ .gitignore | 6 + Dockerfile-HubService | 17 + Dockerfile-WeatherSerialReader | 16 + Dockerfile-WeatherService | 17 + Hub/Hub.sln | 31 ++ Hub/Hub.sln.DotSettings.user | 3 + Hub/Service/.vscode/launch.json | 33 ++ Hub/Service/.vscode/tasks.json | 35 ++ Hub/Service/Controllers/ValuesController.cs | 42 +++ Hub/Service/Hubs/WeatherHub.cs | 19 ++ Hub/Service/Program.cs | 18 + Hub/Service/Properties/launchSettings.json | 30 ++ Hub/Service/Service.csproj | 21 ++ Hub/Service/Service.csproj.user | 9 + Hub/Service/Startup.cs | 30 ++ Hub/Service/appsettings.Development.json | 9 + Hub/Service/appsettings.json | 8 + Hub/Service/deploy/service.yaml | 44 +++ Weather/Arduino/Weather.ino | 317 ++++++++++++++++++ Weather/Arduino/makefile | 11 + Weather/Arduino/publish.bat | 5 + Weather/Models/Models.csproj | 15 + Weather/Models/WeatherMessageData.cs | 121 +++++++ Weather/SerialReader/.vscode/launch.json | 30 ++ Weather/SerialReader/.vscode/tasks.json | 35 ++ Weather/SerialReader/Program.cs | 174 ++++++++++ .../Properties/launchSettings.json | 12 + Weather/SerialReader/SerialReader.csproj | 27 ++ Weather/SerialReader/SerialReader.csproj.user | 6 + .../SerialReader.sln.DotSettings.user | 5 + Weather/SerialReader/appsettings.json | 11 + Weather/SerialReader/deploy/queue.yaml | 68 ++++ Weather/SerialReader/deploy/service.yaml | 45 +++ Weather/Service/.vscode/launch.json | 30 ++ Weather/Service/.vscode/tasks.json | 35 ++ .../Service/Controllers/ValuesController.cs | 31 ++ Weather/Service/Data/Database.cs | 81 +++++ .../Service/Data/Resources/CreateReading.sql | 6 + Weather/Service/Data/Resources/Schema.sql | 24 ++ Weather/Service/MessageHandler.cs | 119 +++++++ Weather/Service/Program.cs | 22 ++ .../Service/Properties/launchSettings.json | 13 + Weather/Service/ResourceReader.cs | 26 ++ Weather/Service/Service.csproj | 33 ++ Weather/Service/Service.csproj.user | 9 + Weather/Service/Startup.cs | 31 ++ Weather/Service/appsettings.Development.json | 10 + Weather/Service/appsettings.json | 18 + Weather/Service/deploy/database.yaml | 68 ++++ Weather/Service/deploy/service.yaml | 59 ++++ Weather/Weather.sln | 37 ++ Weather/Weather.sln.DotSettings | 7 + Weather/Weather.sln.DotSettings.user | 5 + 55 files changed, 2006 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 Dockerfile-HubService create mode 100644 Dockerfile-WeatherSerialReader create mode 100644 Dockerfile-WeatherService create mode 100644 Hub/Hub.sln create mode 100644 Hub/Hub.sln.DotSettings.user create mode 100644 Hub/Service/.vscode/launch.json create mode 100644 Hub/Service/.vscode/tasks.json create mode 100644 Hub/Service/Controllers/ValuesController.cs create mode 100644 Hub/Service/Hubs/WeatherHub.cs create mode 100644 Hub/Service/Program.cs create mode 100644 Hub/Service/Properties/launchSettings.json create mode 100644 Hub/Service/Service.csproj create mode 100644 Hub/Service/Service.csproj.user create mode 100644 Hub/Service/Startup.cs create mode 100644 Hub/Service/appsettings.Development.json create mode 100644 Hub/Service/appsettings.json create mode 100644 Hub/Service/deploy/service.yaml create mode 100644 Weather/Arduino/Weather.ino create mode 100644 Weather/Arduino/makefile create mode 100644 Weather/Arduino/publish.bat create mode 100644 Weather/Models/Models.csproj create mode 100644 Weather/Models/WeatherMessageData.cs create mode 100644 Weather/SerialReader/.vscode/launch.json create mode 100644 Weather/SerialReader/.vscode/tasks.json create mode 100644 Weather/SerialReader/Program.cs create mode 100644 Weather/SerialReader/Properties/launchSettings.json create mode 100644 Weather/SerialReader/SerialReader.csproj create mode 100644 Weather/SerialReader/SerialReader.csproj.user create mode 100644 Weather/SerialReader/SerialReader.sln.DotSettings.user create mode 100644 Weather/SerialReader/appsettings.json create mode 100644 Weather/SerialReader/deploy/queue.yaml create mode 100644 Weather/SerialReader/deploy/service.yaml create mode 100644 Weather/Service/.vscode/launch.json create mode 100644 Weather/Service/.vscode/tasks.json create mode 100644 Weather/Service/Controllers/ValuesController.cs create mode 100644 Weather/Service/Data/Database.cs create mode 100644 Weather/Service/Data/Resources/CreateReading.sql create mode 100644 Weather/Service/Data/Resources/Schema.sql create mode 100644 Weather/Service/MessageHandler.cs create mode 100644 Weather/Service/Program.cs create mode 100644 Weather/Service/Properties/launchSettings.json create mode 100644 Weather/Service/ResourceReader.cs create mode 100644 Weather/Service/Service.csproj create mode 100644 Weather/Service/Service.csproj.user create mode 100644 Weather/Service/Startup.cs create mode 100644 Weather/Service/appsettings.Development.json create mode 100644 Weather/Service/appsettings.json create mode 100644 Weather/Service/deploy/database.yaml create mode 100644 Weather/Service/deploy/service.yaml create mode 100644 Weather/Weather.sln create mode 100644 Weather/Weather.sln.DotSettings create mode 100644 Weather/Weather.sln.DotSettings.user diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..df2e0fe --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.dockerignore +.env +.git +.gitignore +.vs +.vscode +*/bin +*/obj +**/.toolstarget \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1ff0c42 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,63 @@ +############################################################################### +# Set default behavior to automatically normalize line endings. +############################################################################### +* text=auto + +############################################################################### +# Set default behavior for command prompt diff. +# +# This is need for earlier builds of msysgit that does not have it on by +# default for csharp files. +# Note: This is only used by command line +############################################################################### +#*.cs diff=csharp + +############################################################################### +# Set the merge driver for project and solution files +# +# Merging from the command prompt will add diff markers to the files if there +# are conflicts (Merging from VS is not affected by the settings below, in VS +# the diff markers are never inserted). Diff markers may cause the following +# file extensions to fail to load in VS. An alternative would be to treat +# these files as binary and thus will always conflict and require user +# intervention with every merge. To do so, just uncomment the entries below +############################################################################### +#*.sln merge=binary +#*.csproj merge=binary +#*.vbproj merge=binary +#*.vcxproj merge=binary +#*.vcproj merge=binary +#*.dbproj merge=binary +#*.fsproj merge=binary +#*.lsproj merge=binary +#*.wixproj merge=binary +#*.modelproj merge=binary +#*.sqlproj merge=binary +#*.wwaproj merge=binary + +############################################################################### +# behavior for image files +# +# image files are treated as binary by default. +############################################################################### +#*.jpg binary +#*.png binary +#*.gif binary + +############################################################################### +# diff behavior for common document formats +# +# Convert binary document formats to text before diffing them. This feature +# is only available from the command line. Turn it on by uncommenting the +# entries below. +############################################################################### +#*.doc diff=astextplain +#*.DOC diff=astextplain +#*.docx diff=astextplain +#*.DOCX diff=astextplain +#*.dot diff=astextplain +#*.DOT diff=astextplain +#*.pdf diff=astextplain +#*.PDF diff=astextplain +#*.rtf diff=astextplain +#*.RTF diff=astextplain diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..78a8ea5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +bin/ +log/ +obj/ +.vs/ + +Private/ \ No newline at end of file diff --git a/Dockerfile-HubService b/Dockerfile-HubService new file mode 100644 index 0000000..2e65d28 --- /dev/null +++ b/Dockerfile-HubService @@ -0,0 +1,17 @@ +FROM microsoft/dotnet:2.2-aspnetcore-runtime-stretch-slim AS base +WORKDIR /app +EXPOSE 80 + +FROM microsoft/dotnet:2.2-sdk-stretch AS build +WORKDIR /src +COPY ["Hub/Service/Service.csproj", "Hub/Service/"] +COPY ["Weather/Models/Models.csproj", "Weather/Models/"] +RUN dotnet restore "Hub/Service/Service.csproj" +COPY . . +WORKDIR "/src/Hub/Service" +RUN dotnet publish "Service.csproj" -c Release -o /app + +FROM base AS final +WORKDIR /app +COPY --from=build /app . +ENTRYPOINT ["dotnet", "Hub.Service.dll"] \ No newline at end of file diff --git a/Dockerfile-WeatherSerialReader b/Dockerfile-WeatherSerialReader new file mode 100644 index 0000000..5e3c2ee --- /dev/null +++ b/Dockerfile-WeatherSerialReader @@ -0,0 +1,16 @@ +FROM mcr.microsoft.com/dotnet/core/aspnet:3.0.0-preview6-buster-slim-arm32v7 AS base +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/core/sdk:3.0.100-preview6-buster AS build +WORKDIR /src +COPY ["Weather/SerialReader/SerialReader.csproj", "Weather/SerialReader/"] +COPY ["Weather/Models/Models.csproj", "Weather/Models/"] +RUN dotnet restore -r linux-arm "Weather/SerialReader/SerialReader.csproj" +COPY . . +WORKDIR "/src/Weather/SerialReader" +RUN dotnet publish -r linux-arm --self-contained=false "SerialReader.csproj" -c Release -o /app + +FROM base AS final +WORKDIR /app +COPY --from=build /app . +ENTRYPOINT ["dotnet", "Weather.SerialReader.dll"] \ No newline at end of file diff --git a/Dockerfile-WeatherService b/Dockerfile-WeatherService new file mode 100644 index 0000000..6308f3c --- /dev/null +++ b/Dockerfile-WeatherService @@ -0,0 +1,17 @@ +FROM microsoft/dotnet:2.2-aspnetcore-runtime-stretch-slim AS base +WORKDIR /app +EXPOSE 80 + +FROM microsoft/dotnet:2.2-sdk-stretch AS build +WORKDIR /src +COPY ["Weather/Service/Service.csproj", "Weather/Service/"] +COPY ["Weather/Models/Models.csproj", "Weather/Models/"] +RUN dotnet restore "Weather/Service/Service.csproj" +COPY . . +WORKDIR "/src/Weather/Service" +RUN dotnet publish "Service.csproj" -c Release -o /app + +FROM base AS final +WORKDIR /app +COPY --from=build /app . +ENTRYPOINT ["dotnet", "Weather.Service.dll"] \ No newline at end of file diff --git a/Hub/Hub.sln b/Hub/Hub.sln new file mode 100644 index 0000000..02f35eb --- /dev/null +++ b/Hub/Hub.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.28711.60 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Service", "Service\Service.csproj", "{11E9A9F4-9348-402E-8ADF-942F66965D32}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Models", "..\Weather\Models\Models.csproj", "{7DE44178-B63E-4CC4-88AE-B322274EDE26}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {11E9A9F4-9348-402E-8ADF-942F66965D32}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {11E9A9F4-9348-402E-8ADF-942F66965D32}.Debug|Any CPU.Build.0 = Debug|Any CPU + {11E9A9F4-9348-402E-8ADF-942F66965D32}.Release|Any CPU.ActiveCfg = Release|Any CPU + {11E9A9F4-9348-402E-8ADF-942F66965D32}.Release|Any CPU.Build.0 = Release|Any CPU + {7DE44178-B63E-4CC4-88AE-B322274EDE26}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7DE44178-B63E-4CC4-88AE-B322274EDE26}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7DE44178-B63E-4CC4-88AE-B322274EDE26}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7DE44178-B63E-4CC4-88AE-B322274EDE26}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {3A02B8D9-C20F-42A8-BEFD-F67E6BF56F16} + EndGlobalSection +EndGlobal diff --git a/Hub/Hub.sln.DotSettings.user b/Hub/Hub.sln.DotSettings.user new file mode 100644 index 0000000..aa5711b --- /dev/null +++ b/Hub/Hub.sln.DotSettings.user @@ -0,0 +1,3 @@ + + SOLUTION + 2 \ No newline at end of file diff --git a/Hub/Service/.vscode/launch.json b/Hub/Service/.vscode/launch.json new file mode 100644 index 0000000..f45784f --- /dev/null +++ b/Hub/Service/.vscode/launch.json @@ -0,0 +1,33 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": ".NET Core Launch (web)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "${workspaceFolder}/bin/Debug/netcoreapp3.0/Service.dll", + "args": [], + "cwd": "${workspaceFolder}", + "stopAtEntry": false, + "launchBrowser": { + "enabled": false + }, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "sourceFileMap": { + "/Views": "${workspaceFolder}/Views" + } + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach", + "processId": "${command:pickProcess}" + } + ] +} \ No newline at end of file diff --git a/Hub/Service/.vscode/tasks.json b/Hub/Service/.vscode/tasks.json new file mode 100644 index 0000000..e872c92 --- /dev/null +++ b/Hub/Service/.vscode/tasks.json @@ -0,0 +1,35 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/Service.csproj" + ], + "problemMatcher": "$msCompile", + "group": { + "kind": "build", + "isDefault": true + } + }, + { + "label": "publish", + "type": "shell", + "presentation": { + "reveal": "always", + "panel": "shared" + }, + "options": { + "cwd": "${workspaceFolder}" + }, + "windows": { + "command": "${cwd}\\publish.bat" + }, + "problemMatcher": [], + "group": "build" + } + ] +} \ No newline at end of file diff --git a/Hub/Service/Controllers/ValuesController.cs b/Hub/Service/Controllers/ValuesController.cs new file mode 100644 index 0000000..fe3f0e7 --- /dev/null +++ b/Hub/Service/Controllers/ValuesController.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.Mvc; +using System.Collections.Generic; + +namespace Hub.Service.Controllers +{ + [Route("api/[controller]")] + [ApiController] + public class ValuesController : ControllerBase + { + // GET api/values + [HttpGet] + public ActionResult> Get() + { + return new[] { "value1", "value2" }; + } + + // GET api/values/5 + [HttpGet("{id}")] + public ActionResult Get(int id) + { + return "value"; + } + + // POST api/values + [HttpPost] + public void Post([FromBody] string value) + { + } + + // PUT api/values/5 + [HttpPut("{id}")] + public void Put(int id, [FromBody] string value) + { + } + + // DELETE api/values/5 + [HttpDelete("{id}")] + public void Delete(int id) + { + } + } +} diff --git a/Hub/Service/Hubs/WeatherHub.cs b/Hub/Service/Hubs/WeatherHub.cs new file mode 100644 index 0000000..b600005 --- /dev/null +++ b/Hub/Service/Hubs/WeatherHub.cs @@ -0,0 +1,19 @@ +using JetBrains.Annotations; +using Microsoft.AspNetCore.SignalR; +using System; +using System.Threading.Tasks; + +namespace Hub.Service.Hubs +{ + [UsedImplicitly] + public class WeatherHub : Microsoft.AspNetCore.SignalR.Hub + { + [UsedImplicitly] + public async Task SendLatestReading(string message) + { + Console.WriteLine(message); + + await Clients.Others.SendAsync("LatestReading", message); + } + } +} \ No newline at end of file diff --git a/Hub/Service/Program.cs b/Hub/Service/Program.cs new file mode 100644 index 0000000..062d712 --- /dev/null +++ b/Hub/Service/Program.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Hosting; + +namespace Hub.Service +{ + public static class Program + { + public static void Main(string[] args) + { + CreateWebHostBuilder(args).Build().Run(); + } + + private static IWebHostBuilder CreateWebHostBuilder(string[] args) + { + return WebHost.CreateDefaultBuilder(args).UseStartup(); + } + } +} \ No newline at end of file diff --git a/Hub/Service/Properties/launchSettings.json b/Hub/Service/Properties/launchSettings.json new file mode 100644 index 0000000..e3f2ce2 --- /dev/null +++ b/Hub/Service/Properties/launchSettings.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:53872", + "sslPort": 0 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "api/values", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Service": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "api/values", + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/Hub/Service/Service.csproj b/Hub/Service/Service.csproj new file mode 100644 index 0000000..07a5ce4 --- /dev/null +++ b/Hub/Service/Service.csproj @@ -0,0 +1,21 @@ + + + + true + netcoreapp2.2 + InProcess + Hub.Service + Hub.Service + + + + + + + + + + + + + diff --git a/Hub/Service/Service.csproj.user b/Hub/Service/Service.csproj.user new file mode 100644 index 0000000..f0bf193 --- /dev/null +++ b/Hub/Service/Service.csproj.user @@ -0,0 +1,9 @@ + + + + ProjectDebugger + + + Service + + \ No newline at end of file diff --git a/Hub/Service/Startup.cs b/Hub/Service/Startup.cs new file mode 100644 index 0000000..a567048 --- /dev/null +++ b/Hub/Service/Startup.cs @@ -0,0 +1,30 @@ +using Hub.Service.Hubs; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; + +namespace Hub.Service +{ + public class Startup + { + public void ConfigureServices(IServiceCollection services) + { + services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2); + + services.AddSignalR().AddJsonProtocol(options => { options.PayloadSerializerSettings.ContractResolver = new Newtonsoft.Json.Serialization.DefaultContractResolver(); }); + } + + public void Configure(IApplicationBuilder applicationBuilder, IHostingEnvironment environment) + { + if (environment.IsDevelopment()) + { + applicationBuilder.UseDeveloperExceptionPage(); + } + + applicationBuilder.UseSignalR(routes => { routes.MapHub("/weatherHub"); }); + + applicationBuilder.UseMvc(); + } + } +} \ No newline at end of file diff --git a/Hub/Service/appsettings.Development.json b/Hub/Service/appsettings.Development.json new file mode 100644 index 0000000..f999bc2 --- /dev/null +++ b/Hub/Service/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + } +} \ No newline at end of file diff --git a/Hub/Service/appsettings.json b/Hub/Service/appsettings.json new file mode 100644 index 0000000..def9159 --- /dev/null +++ b/Hub/Service/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/Hub/Service/deploy/service.yaml b/Hub/Service/deploy/service.yaml new file mode 100644 index 0000000..38bf8ef --- /dev/null +++ b/Hub/Service/deploy/service.yaml @@ -0,0 +1,44 @@ +--- +kind: Deployment +apiVersion: apps/v1 +metadata: + name: hub-service + namespace: home-monitor + labels: + app: hub-service +spec: + replicas: 1 + selector: + matchLabels: + app: hub-service + template: + metadata: + labels: + app: hub-service + spec: + containers: + - name: hub-service + image: ckaczor/home-monitor-hub-service:latest + terminationMessagePath: "/dev/termination-log" + terminationMessagePolicy: File + imagePullPolicy: Always + securityContext: + privileged: true + restartPolicy: Always + terminationGracePeriodSeconds: 30 + dnsPolicy: ClusterFirst + nodeSelector: + kubernetes.io/hostname: kubernetes + schedulerName: default-scheduler +--- +kind: Service +apiVersion: v1 +metadata: + name: hub-service +spec: + ports: + - name: client + port: 80 + selector: + app: hub-service + type: ClusterIP \ No newline at end of file diff --git a/Weather/Arduino/Weather.ino b/Weather/Arduino/Weather.ino new file mode 100644 index 0000000..b8424cd --- /dev/null +++ b/Weather/Arduino/Weather.ino @@ -0,0 +1,317 @@ +#include // I2C needed for sensors +#include "SparkFunMPL3115A2.h" // Pressure sensor - Search "SparkFun MPL3115" and install from Library Manager +#include "SparkFun_Si7021_Breakout_Library.h" // Humidity sensor - Search "SparkFun Si7021" and install from Library Manager +#include // Needed for GPS +#include // GPS parsing - Available from https://github.com/mikalhart/TinyGPSPlus + +static const int RXPin = 5; // GPS is attached to pin 5 (RX into GPS) +static const int TXPin = 4; // GPS is attached to pin 4 (TX from GPS) + +TinyGPSPlus gps; // GPS module +SoftwareSerial ss(RXPin, TXPin); // Software serial port for GPS +MPL3115A2 pressureSensor; // Instance of the pressure sensor +Weather humiditySensor; // Instance of the humidity sensor + +// Digital I/O pins +const byte WSPEED = 3; // Wind speed switch +const byte RAIN = 2; // Rain switch +const byte STAT1 = 7; // Blue status light +const byte STAT2 = 8; // Green status light +const byte GPS_PWRCTL = 6; // Pulling this pin low puts GPS to sleep but maintains RTC and RAM + +// Analog I/O pins +const byte REFERENCE_3V3 = A3; // 3.3V reference +const byte LIGHT = A1; // Light level +const byte BATT = A2; // Battery level +const byte WDIR = A0; // Wind direction + +//-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-= +// Global Variables +//-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-= + +long lastSecond; // The millis counter to see when a second rolls by +long lastWindCheck = 0; // Time of the last wind check +int winddir = 0; // Instantaneous wind direction [0-360] +float windspeedmph = 0; // Instantaneous wind speed [mph] +float humidity = 0; // Instantaneous humidity [%] +float tempH = 0; // Instantaneous temperature from humidity sensor [F] +float tempP = 0; // Instantaneous temperature from pressure sensor [F] +float pressure = 0; // Instantaneous pressure [pascals] + +float batt_lvl = 0; // Battery level [Analog value from 0 to 1023] +float light_lvl = 0; // Light level [Analog value from 0 to 1023] + +// volatiles are subject to modification by IRQs +volatile unsigned long raintime, rainlast, raininterval, rain; +volatile long lastWindIRQ = 0; +volatile byte windClicks = 0; + +//-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-= + +//Interrupt routines (these are called by the hardware interrupts, not by the main code) +//-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-= +void rainIRQ() +// Count rain gauge bucket tips as they occur +// Activated by the magnet and reed switch in the rain gauge, attached to input D2 +{ + raintime = millis(); // grab current time + raininterval = raintime - rainlast; // calculate interval between this and last event + + if (raininterval > 10) // ignore switch-bounce glitches less than 10mS after initial edge + { + rain += 0.011; //Each dump is 0.011" of water + rainlast = raintime; // set up for next event + } +} + +void wspeedIRQ() +// Activated by the magnet in the anemometer (2 ticks per rotation), attached to input D3 +{ + if (millis() - lastWindIRQ > 10) // Ignore switch-bounce glitches less than 10ms (142MPH max reading) after the reed switch closes + { + lastWindIRQ = millis(); //Grab the current time + windClicks++; //There is 1.492MPH for each click per second. + } +} + + +void setup() +{ + Serial.begin(9600); + + Serial.println("Board starting"); + + ss.begin(9600); //Begin listening to GPS over software serial at 9600. This should be the default baud of the module. + + pinMode(STAT1, OUTPUT); //Status LED Blue + pinMode(STAT2, OUTPUT); //Status LED Green + + pinMode(GPS_PWRCTL, OUTPUT); + digitalWrite(GPS_PWRCTL, HIGH); //Pulling this pin low puts GPS to sleep but maintains RTC and RAM + + pinMode(WSPEED, INPUT_PULLUP); // input from wind meters windspeed sensor + pinMode(RAIN, INPUT_PULLUP); // input from wind meters rain gauge sensor + + pinMode(REFERENCE_3V3, INPUT); + pinMode(LIGHT, INPUT); + + //Configure the pressure sensor + pressureSensor.begin(); // Get sensor online + pressureSensor.setModeBarometer(); // Measure pressure in Pascals from 20 to 110 kPa + pressureSensor.setOversampleRate(7); // Set Oversample to the recommended 128 + pressureSensor.enableEventFlags(); // Enable all three pressure and temp event flags + + //Configure the humidity sensor + humiditySensor.begin(); + + lastSecond = millis(); + + // attach external interrupt pins to IRQ functions + attachInterrupt(0, rainIRQ, FALLING); + attachInterrupt(1, wspeedIRQ, FALLING); + + // turn on interrupts + interrupts(); + + Serial.println("Board ready"); +} + +void loop() +{ + //Keep track of which minute it is + if (millis() - lastSecond >= 1000) + { + digitalWrite(STAT1, HIGH); //Blink stat LED + + lastSecond += 1000; + + //Go calc all the various sensors + calcWeather(); + + //Report all readings every second + printWeather(); + + digitalWrite(STAT1, LOW); //Turn off stat LED + } + + smartdelay(800); //Wait 1 second, and gather GPS data +} + +//While we delay for a given amount of time, gather GPS data +static void smartdelay(unsigned long ms) +{ + unsigned long start = millis(); + do + { + while (ss.available()) + gps.encode(ss.read()); + } while (millis() - start < ms); +} + + +//Calculates each of the variables that wunderground is expecting +void calcWeather() +{ + //Calc the wind speed and direction every second for 120 second to get 2 minute average + float currentSpeed = get_wind_speed(); + windspeedmph = currentSpeed; //update global variable for windspeed when using the printWeather() function + //float currentSpeed = random(5); //For testing + int currentDirection = get_wind_direction(); + + //Calc winddir + winddir = get_wind_direction(); + + //Calc windspeed + //windspeedmph = get_wind_speed(); //This is calculated in the main loop on line 196 + + //Calc humidity + humidity = humiditySensor.getRH(); + tempH = humiditySensor.readTempF(); + + //Calc tempf from pressure sensor + tempP = pressureSensor.readTempF(); + + //Calc pressure + pressure = pressureSensor.readPressure(); + + //Calc light level + light_lvl = get_light_level(); + + //Calc battery level + batt_lvl = get_battery_level(); +} + +//Returns the voltage of the light sensor based on the 3.3V rail +//This allows us to ignore what VCC might be (an Arduino plugged into USB has VCC of 4.5 to 5.2V) +float get_light_level() +{ + float operatingVoltage = analogRead(REFERENCE_3V3); + + float lightSensor = analogRead(LIGHT); + + operatingVoltage = 3.3 / operatingVoltage; //The reference voltage is 3.3V + + lightSensor = operatingVoltage * lightSensor; + + return (lightSensor); +} + +//Returns the voltage of the raw pin based on the 3.3V rail +//This allows us to ignore what VCC might be (an Arduino plugged into USB has VCC of 4.5 to 5.2V) +//Battery level is connected to the RAW pin on Arduino and is fed through two 5% resistors: +//3.9K on the high side (R1), and 1K on the low side (R2) +float get_battery_level() +{ + float operatingVoltage = analogRead(REFERENCE_3V3); + + float rawVoltage = analogRead(BATT); + + operatingVoltage = 3.30 / operatingVoltage; //The reference voltage is 3.3V + + rawVoltage = operatingVoltage * rawVoltage; //Convert the 0 to 1023 int to actual voltage on BATT pin + + rawVoltage *= 4.90; //(3.9k+1k)/1k - multiple BATT voltage by the voltage divider to get actual system voltage + + return (rawVoltage); +} + +//Returns the instataneous wind speed +float get_wind_speed() +{ + float deltaTime = millis() - lastWindCheck; //750ms + + deltaTime /= 1000.0; //Covert to seconds + + float windSpeed = (float)windClicks / deltaTime; //3 / 0.750s = 4 + + windClicks = 0; //Reset and start watching for new wind + lastWindCheck = millis(); + + windSpeed *= 1.492; //4 * 1.492 = 5.968MPH + + /* Serial.println(); + Serial.print("Windspeed:"); + Serial.println(windSpeed);*/ + + return (windSpeed); +} + +//Read the wind direction sensor, return heading in degrees +int get_wind_direction() +{ + unsigned int adc; + + adc = analogRead(WDIR); // get the current reading from the sensor + + // The following table is ADC readings for the wind direction sensor output, sorted from low to high. + // Each threshold is the midpoint between adjacent headings. The output is degrees for that ADC reading. + // Note that these are not in compass degree order! See Weather Meters datasheet for more information. + + if (adc < 380) return (113); + if (adc < 393) return (68); + if (adc < 414) return (90); + if (adc < 456) return (158); + if (adc < 508) return (135); + if (adc < 551) return (203); + if (adc < 615) return (180); + if (adc < 680) return (23); + if (adc < 746) return (45); + if (adc < 801) return (248); + if (adc < 833) return (225); + if (adc < 878) return (338); + if (adc < 913) return (0); + if (adc < 940) return (293); + if (adc < 967) return (315); + if (adc < 990) return (270); + return (-1); // error, disconnected? +} + + +//Prints the various variables directly to the port +//I don't like the way this function is written but Arduino doesn't support floats under sprintf +void printWeather() +{ + //Serial.println(); + Serial.print("$,winddir="); + Serial.print(winddir); + Serial.print(",windspeedmph="); + Serial.print(windspeedmph, 1); + Serial.print(",humidity="); + Serial.print(humidity, 1); + Serial.print(",tempH="); + Serial.print(tempH, 1); + Serial.print(",tempP="); + Serial.print(tempP, 1); + Serial.print(",rain="); + Serial.print(rain, 2); + Serial.print(",pressure="); + Serial.print(pressure, 2); + Serial.print(",batt_lvl="); + Serial.print(batt_lvl, 2); + Serial.print(",light_lvl="); + Serial.print(light_lvl, 2); + + Serial.print(",lat="); + Serial.print(gps.location.lat(), 6); + Serial.print(",lng="); + Serial.print(gps.location.lng(), 6); + Serial.print(",altitude="); + Serial.print(gps.altitude.meters()); + Serial.print(",sats="); + Serial.print(gps.satellites.value()); + + char sz[32]; + Serial.print(",date="); + sprintf(sz, "%02d/%02d/%02d", gps.date.month(), gps.date.day(), gps.date.year()); + Serial.print(sz); + + Serial.print(",time="); + sprintf(sz, "%02d:%02d:%02d", gps.time.hour(), gps.time.minute(), gps.time.second()); + Serial.print(sz); + + Serial.print(","); + Serial.println("#"); + +} + + diff --git a/Weather/Arduino/makefile b/Weather/Arduino/makefile new file mode 100644 index 0000000..5feeba6 --- /dev/null +++ b/Weather/Arduino/makefile @@ -0,0 +1,11 @@ +weather: + mkdir -p bin/ + arduino-cli compile -b arduino:avr:uno Weather.ino -o bin/Weather + +install: + sudo service weather stop + arduino-cli upload -b arduino:avr:uno -p /dev/ttyACM0 -i bin/Weather + sudo service weather start + +clean: + rm -rf bin/ \ No newline at end of file diff --git a/Weather/Arduino/publish.bat b/Weather/Arduino/publish.bat new file mode 100644 index 0000000..6957a90 --- /dev/null +++ b/Weather/Arduino/publish.bat @@ -0,0 +1,5 @@ +SET REMOTE=ckaczor@172.23.10.6 + +plink %REMOTE% mkdir -p Weather/Arduino + +pscp -v -r makefile Weather.ino %REMOTE%:Weather/Arduino diff --git a/Weather/Models/Models.csproj b/Weather/Models/Models.csproj new file mode 100644 index 0000000..3b147d2 --- /dev/null +++ b/Weather/Models/Models.csproj @@ -0,0 +1,15 @@ + + + + true + netcoreapp2.2 + Weather.Models + Weather.Models + + + + + + + + diff --git a/Weather/Models/WeatherMessageData.cs b/Weather/Models/WeatherMessageData.cs new file mode 100644 index 0000000..f36b701 --- /dev/null +++ b/Weather/Models/WeatherMessageData.cs @@ -0,0 +1,121 @@ +using JetBrains.Annotations; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using System; +using System.Globalization; +using System.Linq; + +namespace Weather.Models +{ + public enum MessageType + { + Text, + Data + } + + [PublicAPI] + public enum WindDirection + { + None = -1, + North = 0, + East = 90, + South = 180, + West = 270, + NorthEast = 45, + SouthEast = 135, + SouthWest = 225, + NorthWest = 315, + NorthNorthEast = 23, + EastNorthEast = 68, + EastSouthEast = 113, + SouthSouthEast = 158, + SouthSouthWest = 203, + WestSouthWest = 248, + WestNorthWest = 293, + NorthNorthWest = 338 + } + + public class WeatherMessage + { + [JsonConverter(typeof(StringEnumConverter))] + public MessageType Type { get; set; } + + public DateTimeOffset Timestamp { get; set; } + + [JsonConverter(typeof(StringEnumConverter))] + public WindDirection WindDirection { get; set; } + + public double WindSpeed { get; set; } + + public double Humidity { get; set; } + + public double HumidityTemperature { get; set; } + + public double Rain { get; set; } + + public double Pressure { get; set; } + + public double PressureTemperature { get; set; } + + public double BatteryLevel { get; set; } + + public double LightLevel { get; set; } + + public double Latitude { get; set; } + + public double Longitude { get; set; } + + public double Altitude { get; set; } + + public int SatelliteCount { get; set; } + + public DateTimeOffset GpsTimestamp { get; set; } + + public string Message { get; set; } + + public WeatherMessage() + { + Type = MessageType.Text; + Timestamp = DateTimeOffset.UtcNow; + } + + public WeatherMessage(string message) + { + Type = MessageType.Data; + Timestamp = DateTimeOffset.UtcNow; + + var messageParts = message.Split(',').ToList(); + + messageParts.RemoveAt(0); + messageParts.RemoveAt(messageParts.Count - 1); + + var messageValues = messageParts.Select(m => m.Split('=')).ToDictionary(a => a[0], a => a[1]); + + WindDirection = Enum.Parse(messageValues[@"winddir"]); + WindSpeed = double.Parse(messageValues[@"windspeedmph"]); + Humidity = double.Parse(messageValues[@"humidity"]); + HumidityTemperature = double.Parse(messageValues[@"tempH"]); + Rain = double.Parse(messageValues[@"rain"]); + Pressure = double.Parse(messageValues[@"pressure"]); + PressureTemperature = double.Parse(messageValues[@"tempP"]); + BatteryLevel = double.Parse(messageValues[@"batt_lvl"]); + LightLevel = double.Parse(messageValues[@"light_lvl"]); + Latitude = double.Parse(messageValues[@"lat"]); + Longitude = double.Parse(messageValues[@"lng"]); + Altitude = double.Parse(messageValues[@"altitude"]); + SatelliteCount = int.Parse(messageValues[@"sats"]); + + DateTimeOffset.TryParseExact($"{messageValues[@"date"]} {messageValues[@"time"]}", "MM/dd/yyyy HH:mm:ss", null, DateTimeStyles.None, out var gpsTimestamp); + GpsTimestamp = gpsTimestamp; + } + + [PublicAPI] + public static WeatherMessage Parse(string message) + { + if (message.StartsWith("$") && message.EndsWith("#")) + return new WeatherMessage(message); + + return new WeatherMessage { Message = message }; + } + } +} \ No newline at end of file diff --git a/Weather/SerialReader/.vscode/launch.json b/Weather/SerialReader/.vscode/launch.json new file mode 100644 index 0000000..991d6c7 --- /dev/null +++ b/Weather/SerialReader/.vscode/launch.json @@ -0,0 +1,30 @@ +{ + // 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 + "version": "0.2.0", + "configurations": [ + { + "name": ".NET Core Launch (remote console)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "publish", + "program": "dotnet", + "args": [ + "/home/ckaczor/Weather/SerialReader/SerialReader.dll" + ], + "cwd": "/home/ckaczor/Weather/SerialReader", + "stopAtEntry": false, + "console": "internalConsole", + "pipeTransport": { + "pipeCwd": "${workspaceFolder}", + "pipeProgram": "C:\\Program Files (x86)\\PuTTY\\PLINK.EXE", + "pipeArgs": [ + "root@172.23.10.6" + ], + "debuggerPath": "/home/ckaczor/vsdbg/vsdbg" + }, + "internalConsoleOptions": "openOnSessionStart" + } + ] +} \ No newline at end of file diff --git a/Weather/SerialReader/.vscode/tasks.json b/Weather/SerialReader/.vscode/tasks.json new file mode 100644 index 0000000..ea611cb --- /dev/null +++ b/Weather/SerialReader/.vscode/tasks.json @@ -0,0 +1,35 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/SerialReader.csproj" + ], + "problemMatcher": "$msCompile", + "group": { + "kind": "build", + "isDefault": true + } + }, + { + "label": "publish", + "type": "shell", + "presentation": { + "reveal": "always", + "panel": "shared" + }, + "options": { + "cwd": "${workspaceFolder}" + }, + "windows": { + "command": "${cwd}\\publish.bat" + }, + "problemMatcher": [], + "group": "build" + } + ] +} \ No newline at end of file diff --git a/Weather/SerialReader/Program.cs b/Weather/SerialReader/Program.cs new file mode 100644 index 0000000..d98f540 --- /dev/null +++ b/Weather/SerialReader/Program.cs @@ -0,0 +1,174 @@ +using Microsoft.Extensions.Configuration; +using Newtonsoft.Json; +using RabbitMQ.Client; +using System; +using System.IO.Ports; +using System.Linq; +using System.Text; +using System.Threading; +using Weather.Models; + +namespace Weather.SerialReader +{ + internal static class Program + { + private static IConfiguration _configuration; + + private static readonly CancellationTokenSource CancellationTokenSource = new CancellationTokenSource(); + + private static DateTime _lastLogTime = DateTime.MinValue; + private static long _messageCount; + private static bool _boardStarting; + + private static void Main() + { + WriteLog("Starting"); + + Console.CancelKeyPress += OnCancelKeyPress; + + _configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json", false, false) + .AddEnvironmentVariables() + .Build(); + + var baudRate = int.Parse(_configuration["Weather:Port:BaudRate"]); + + while (!CancellationTokenSource.Token.IsCancellationRequested) + { + try + { + WriteLog("Starting main loop"); + + Thread.Sleep(1000); + + var port = GetPort(); + + if (port == null) + continue; + + using var serialPort = new SerialPort(port, baudRate) { NewLine = "\r\n" }; + + WriteLog("Opening serial port"); + + serialPort.Open(); + + _boardStarting = false; + + var factory = new ConnectionFactory + { + HostName = _configuration["Weather:Queue:Host"], + UserName = _configuration["Weather:Queue:User"], + Password = _configuration["Weather:Queue:Password"] + }; + + WriteLog("Connecting to queue server"); + + using var connection = factory.CreateConnection(); + using var model = connection.CreateModel(); + + WriteLog("Declaring queue"); + + model.QueueDeclare(_configuration["Weather:Queue:Name"], true, false, false, null); + + WriteLog("Starting serial read loop"); + + ReadSerial(serialPort, model); + } + catch (Exception exception) + { + WriteLog($"Exception: {exception}"); + } + } + + WriteLog("Exiting"); + } + + private static void ReadSerial(SerialPort serialPort, IModel model) + { + while (!CancellationTokenSource.Token.IsCancellationRequested) + { + try + { + var message = serialPort.ReadLine(); + + if (!_boardStarting) + { + _boardStarting = message.Contains("Board starting"); + + if (_boardStarting) + WriteLog("Board starting"); + } + + if (!_boardStarting) + { + WriteLog($"Message received but board not starting: {message}"); + continue; + } + + var weatherMessage = WeatherMessage.Parse(message); + + var messageString = JsonConvert.SerializeObject(weatherMessage); + + var body = Encoding.UTF8.GetBytes(messageString); + + var properties = model.CreateBasicProperties(); + + properties.Persistent = true; + + model.BasicPublish(string.Empty, _configuration["Weather:Queue:Name"], properties, body); + + _messageCount++; + + if ((DateTime.Now - _lastLogTime).TotalMinutes < 1) + continue; + + WriteLog($"Number of messages received since {_lastLogTime} = {_messageCount}"); + + _lastLogTime = DateTime.Now; + _messageCount = 0; + } + catch (TimeoutException) + { + WriteLog("Serial port read timeout"); + } + } + } + + private static void OnCancelKeyPress(object sender, ConsoleCancelEventArgs args) + { + args.Cancel = true; + CancellationTokenSource.Cancel(); + } + + private static string GetPort() + { + var portPrefix = _configuration["Weather:Port:Prefix"]; + + while (!CancellationTokenSource.Token.IsCancellationRequested) + { + WriteLog($"Checking for port starting with: {portPrefix}"); + + var ports = SerialPort.GetPortNames(); + + var port = ports.FirstOrDefault(p => p.StartsWith(portPrefix)); + + if (port != null) + { + WriteLog($"Port found: {port}"); + return port; + } + + WriteLog("Port not found - waiting"); + + Thread.Sleep(1000); + } + + return null; + } + + private static void WriteLog(string message) + { + Console.WriteLine(message); + } + } +} \ No newline at end of file diff --git a/Weather/SerialReader/Properties/launchSettings.json b/Weather/SerialReader/Properties/launchSettings.json new file mode 100644 index 0000000..2c4feb9 --- /dev/null +++ b/Weather/SerialReader/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "SerialReader": { + "commandName": "Project", + "launchBrowser": false, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "http://localhost:62648/" + } + } +} \ No newline at end of file diff --git a/Weather/SerialReader/SerialReader.csproj b/Weather/SerialReader/SerialReader.csproj new file mode 100644 index 0000000..426948d --- /dev/null +++ b/Weather/SerialReader/SerialReader.csproj @@ -0,0 +1,27 @@ + + + + Exe + netcoreapp3.0 + + + + true + Weather.SerialReader + Weather.SerialReader + + + + + + + + + + + + + + + + diff --git a/Weather/SerialReader/SerialReader.csproj.user b/Weather/SerialReader/SerialReader.csproj.user new file mode 100644 index 0000000..cff74a9 --- /dev/null +++ b/Weather/SerialReader/SerialReader.csproj.user @@ -0,0 +1,6 @@ + + + + IIS Express + + \ No newline at end of file diff --git a/Weather/SerialReader/SerialReader.sln.DotSettings.user b/Weather/SerialReader/SerialReader.sln.DotSettings.user new file mode 100644 index 0000000..fe9aee4 --- /dev/null +++ b/Weather/SerialReader/SerialReader.sln.DotSettings.user @@ -0,0 +1,5 @@ + + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + 2 \ No newline at end of file diff --git a/Weather/SerialReader/appsettings.json b/Weather/SerialReader/appsettings.json new file mode 100644 index 0000000..ca2e7a7 --- /dev/null +++ b/Weather/SerialReader/appsettings.json @@ -0,0 +1,11 @@ +{ + "Weather": { + "Port": { + "Prefix": "/dev/ttyACM", + "BaudRate": 9600 + }, + "Queue": { + "Name": "weather" + } + } +} \ No newline at end of file diff --git a/Weather/SerialReader/deploy/queue.yaml b/Weather/SerialReader/deploy/queue.yaml new file mode 100644 index 0000000..5850866 --- /dev/null +++ b/Weather/SerialReader/deploy/queue.yaml @@ -0,0 +1,68 @@ +--- +kind: StatefulSet +apiVersion: apps/v1 +metadata: + name: weather-queue + namespace: home-monitor + labels: + app: weather-queue +spec: + replicas: 1 + selector: + matchLabels: + app: weather-queue + serviceName: weather-queue + template: + metadata: + labels: + app: weather-queue + spec: + containers: + - name: weather-queue + image: rabbitmq:3.7.16-management-alpine + terminationMessagePath: "/dev/termination-log" + terminationMessagePolicy: File + imagePullPolicy: IfNotPresent + env: + - name: RABBITMQ_DEFAULT_USER + valueFrom: + secretKeyRef: + name: weather-queue-credentials + key: username + - name: RABBITMQ_DEFAULT_PASS + valueFrom: + secretKeyRef: + name: weather-queue-credentials + key: password + volumeMounts: + - name: data + mountPath: /var/lib/rabbitmq + restartPolicy: Always + terminationGracePeriodSeconds: 30 + dnsPolicy: ClusterFirst + nodeSelector: + kubernetes.io/hostname: weather + schedulerName: default-scheduler + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: [ "ReadWriteOnce" ] + storageClassName: local-path + resources: + requests: + storage: 1Gi +--- +kind: Service +apiVersion: v1 +metadata: + name: weather-queue +spec: + ports: + - name: client + port: 5672 + - name: http + port: 15672 + selector: + app: weather-queue + type: ClusterIP \ No newline at end of file diff --git a/Weather/SerialReader/deploy/service.yaml b/Weather/SerialReader/deploy/service.yaml new file mode 100644 index 0000000..e493502 --- /dev/null +++ b/Weather/SerialReader/deploy/service.yaml @@ -0,0 +1,45 @@ +--- +kind: Deployment +apiVersion: apps/v1 +metadata: + name: weather-serial-reader + namespace: home-monitor + labels: + app: weather-serial-reader +spec: + replicas: 1 + selector: + matchLabels: + app: weather-serial-reader + template: + metadata: + labels: + app: weather-serial-reader + spec: + containers: + - name: weather-serial-reader + image: ckaczor/home-monitor-weather-serialreader:latest + terminationMessagePath: "/dev/termination-log" + terminationMessagePolicy: File + imagePullPolicy: Always + securityContext: + privileged: true + env: + - name: Weather__Queue__Host + value: weather-queue + - name: Weather__Queue__User + valueFrom: + secretKeyRef: + name: weather-queue-credentials + key: username + - name: Weather__Queue__Password + valueFrom: + secretKeyRef: + name: weather-queue-credentials + key: password + restartPolicy: Always + terminationGracePeriodSeconds: 30 + dnsPolicy: ClusterFirst + nodeSelector: + kubernetes.io/hostname: weather + schedulerName: default-scheduler \ No newline at end of file diff --git a/Weather/Service/.vscode/launch.json b/Weather/Service/.vscode/launch.json new file mode 100644 index 0000000..1cfddc9 --- /dev/null +++ b/Weather/Service/.vscode/launch.json @@ -0,0 +1,30 @@ +{ + // 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 + "version": "0.2.0", + "configurations": [ + { + "name": ".NET Core Launch (remote console)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "publish", + "program": "dotnet", + "args": [ + "/home/ckaczor/Weather/Service/Weather.Service.dll" + ], + "cwd": "/home/ckaczor/Weather/Service", + "stopAtEntry": false, + "console": "internalConsole", + "pipeTransport": { + "pipeCwd": "${workspaceFolder}", + "pipeProgram": "C:\\Program Files (x86)\\PuTTY\\PLINK.EXE", + "pipeArgs": [ + "root@172.23.10.6" + ], + "debuggerPath": "/home/ckaczor/vsdbg/vsdbg" + }, + "internalConsoleOptions": "openOnSessionStart" + } + ] +} \ No newline at end of file diff --git a/Weather/Service/.vscode/tasks.json b/Weather/Service/.vscode/tasks.json new file mode 100644 index 0000000..e872c92 --- /dev/null +++ b/Weather/Service/.vscode/tasks.json @@ -0,0 +1,35 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/Service.csproj" + ], + "problemMatcher": "$msCompile", + "group": { + "kind": "build", + "isDefault": true + } + }, + { + "label": "publish", + "type": "shell", + "presentation": { + "reveal": "always", + "panel": "shared" + }, + "options": { + "cwd": "${workspaceFolder}" + }, + "windows": { + "command": "${cwd}\\publish.bat" + }, + "problemMatcher": [], + "group": "build" + } + ] +} \ No newline at end of file diff --git a/Weather/Service/Controllers/ValuesController.cs b/Weather/Service/Controllers/ValuesController.cs new file mode 100644 index 0000000..0f2c55b --- /dev/null +++ b/Weather/Service/Controllers/ValuesController.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Mvc; +using System.Collections.Generic; + +namespace Weather.Service.Controllers +{ + [Route("api/[controller]")] + [ApiController] + public class ValuesController : ControllerBase + { + [HttpGet] + public ActionResult> Get() + { + return new[] { "value1", "value2" }; + } + + [HttpGet("{id}")] + public ActionResult Get(int id) + { + return "value"; + } + + [HttpPost] + public void Post([FromBody] string value) { } + + [HttpPut("{id}")] + public void Put(int id, [FromBody] string value) { } + + [HttpDelete("{id}")] + public void Delete(int id) { } + } +} \ No newline at end of file diff --git a/Weather/Service/Data/Database.cs b/Weather/Service/Data/Database.cs new file mode 100644 index 0000000..ba66037 --- /dev/null +++ b/Weather/Service/Data/Database.cs @@ -0,0 +1,81 @@ +using Dapper; +using Microsoft.Extensions.Configuration; +using Npgsql; +using Weather.Models; + +namespace Weather.Service.Data +{ + public class Database + { + private readonly IConfiguration _configuration; + + public Database(IConfiguration configuration) + { + _configuration = configuration; + } + + public void EnsureDatabase() + { + var connectionStringBuilder = new NpgsqlConnectionStringBuilder + { + Host = _configuration["Weather:Database:Host"], + Username = _configuration["Weather:Database:User"], + Password = _configuration["Weather:Database:Password"], + Database = "postgres" + }; + + using (var connection = new NpgsqlConnection(connectionStringBuilder.ConnectionString)) + { + var command = new NpgsqlCommand { Connection = connection }; + + connection.Open(); + + // Check to see if the database exists + command.CommandText = $"SELECT TRUE from pg_database WHERE datname='{_configuration["Weather:Database:Name"]}'"; + var databaseExists = (bool?)command.ExecuteScalar(); + + // Create database if needed + if (!databaseExists.GetValueOrDefault(false)) + { + command.CommandText = $"CREATE DATABASE {_configuration["Weather:Database:Name"]}"; + command.ExecuteNonQuery(); + } + + // Switch to the database now that we're sure it exists + connection.ChangeDatabase(_configuration["Weather:Database:Name"]); + + var schema = ResourceReader.GetString("Weather.Service.Data.Resources.Schema.sql"); + + // Make sure the database is up to date + command.CommandText = schema; + command.ExecuteNonQuery(); + } + } + + private NpgsqlConnection CreateConnection() + { + var connectionStringBuilder = new NpgsqlConnectionStringBuilder + { + Host = _configuration["Weather:Database:Host"], + Username = _configuration["Weather:Database:User"], + Password = _configuration["Weather:Database:Password"], + Database = _configuration["Weather:Database:Name"] + }; + + var connection = new NpgsqlConnection(connectionStringBuilder.ConnectionString); + connection.Open(); + + return connection; + } + + public void StoreWeatherData(WeatherMessage weatherMessage) + { + using (var connection = CreateConnection()) + { + var query = ResourceReader.GetString("Weather.Service.Data.Resources.CreateReading.sql"); + + connection.Execute(query, weatherMessage); + } + } + } +} \ No newline at end of file diff --git a/Weather/Service/Data/Resources/CreateReading.sql b/Weather/Service/Data/Resources/CreateReading.sql new file mode 100644 index 0000000..18ef1b1 --- /dev/null +++ b/Weather/Service/Data/Resources/CreateReading.sql @@ -0,0 +1,6 @@ +INSERT INTO weather_reading (timestamp, wind_direction, wind_speed, humidity, humidity_temperature, rain, pressure, + pressure_temperature, battery_level, light_level, latitude, longitude, altitude, + satellite_count, gps_timestamp) +VALUES (:timestamp, :windDirection, :windSpeed, :humidity, :humidityTemperature, :rain, :pressure, :pressureTemperature, + :batteryLevel, :lightLevel, :latitude, :longitude, :altitude, :satelliteCount, :gpsTimestamp) +ON CONFLICT DO NOTHING \ No newline at end of file diff --git a/Weather/Service/Data/Resources/Schema.sql b/Weather/Service/Data/Resources/Schema.sql new file mode 100644 index 0000000..85a76ca --- /dev/null +++ b/Weather/Service/Data/Resources/Schema.sql @@ -0,0 +1,24 @@ +CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE; + +CREATE TABLE IF NOT EXISTS weather_reading +( + timestamp timestamptz NOT NULL + CONSTRAINT weather_reading_pk + PRIMARY KEY, + wind_direction int NOT NULL, + wind_speed double precision NOT NULL, + humidity double precision NOT NULL, + humidity_temperature double precision NOT NULL, + rain double precision NOT NULL, + pressure double precision NOT NULL, + pressure_temperature double precision NOT NULL, + battery_level double precision NOT NULL, + light_level double precision NOT NULL, + latitude double precision NOT NULL, + longitude double precision NOT NULL, + altitude double precision NOT NULL, + satellite_count double precision NOT NULL, + gps_timestamp timestamptz NOT NULL +); + +SELECT create_hypertable('weather_reading', 'timestamp', if_not_exists => TRUE); \ No newline at end of file diff --git a/Weather/Service/MessageHandler.cs b/Weather/Service/MessageHandler.cs new file mode 100644 index 0000000..8c31611 --- /dev/null +++ b/Weather/Service/MessageHandler.cs @@ -0,0 +1,119 @@ +using JetBrains.Annotations; +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Newtonsoft.Json; +using RabbitMQ.Client; +using RabbitMQ.Client.Events; +using System; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Weather.Models; +using Weather.Service.Data; + +namespace Weather.Service +{ + [UsedImplicitly] + public class MessageHandler : IHostedService + { + private readonly IConfiguration _configuration; + private readonly Database _database; + + private IConnection _queueConnection; + private IModel _queueModel; + + private HubConnection _hubConnection; + + private static DateTime _lastLogTime = DateTime.MinValue; + private static long _messageCount; + + public MessageHandler(IConfiguration configuration, Database database) + { + _configuration = configuration; + _database = database; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + WriteLog("MessageHandler: Start"); + + var factory = new ConnectionFactory + { + HostName = _configuration["Weather:Queue:Host"], + UserName = _configuration["Weather:Queue:User"], + Password = _configuration["Weather:Queue:Password"] + }; + + _queueConnection = factory.CreateConnection(); + _queueModel = _queueConnection.CreateModel(); + + _queueModel.QueueDeclare(_configuration["Weather:Queue:Name"], true, false, false, null); + + var consumer = new EventingBasicConsumer(_queueModel); + consumer.Received += DeviceEventHandler; + + _queueModel.BasicConsume(_configuration["Weather:Queue:Name"], true, consumer); + + _hubConnection = new HubConnectionBuilder().WithUrl(_configuration["Hub:Weather"]).Build(); + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + WriteLog("MessageHandler: Stop"); + + _hubConnection.StopAsync(cancellationToken).Wait(cancellationToken); + + _queueModel.Close(); + _queueConnection.Close(); + + return Task.CompletedTask; + } + + private void DeviceEventHandler(object model, BasicDeliverEventArgs eventArgs) + { + var body = eventArgs.Body; + var message = Encoding.UTF8.GetString(body); + + _messageCount++; + + if ((DateTime.Now - _lastLogTime).TotalMinutes >= 1) + { + WriteLog($"Number of messages received since {_lastLogTime} = {_messageCount}"); + + _lastLogTime = DateTime.Now; + _messageCount = 0; + } + + var weatherMessage = JsonConvert.DeserializeObject(message); + + if (weatherMessage.Type == MessageType.Text) + { + WriteLog(weatherMessage.Message); + + return; + } + + _database.StoreWeatherData(weatherMessage); + + try + { + if (_hubConnection.State == HubConnectionState.Disconnected) + _hubConnection.StartAsync().Wait(); + + _hubConnection.InvokeAsync("SendLatestReading", message).Wait(); + } + catch (Exception exception) + { + WriteLog($"Hub exception: {exception}"); + } + } + + private static void WriteLog(string message) + { + Console.WriteLine(message); + } + } +} \ No newline at end of file diff --git a/Weather/Service/Program.cs b/Weather/Service/Program.cs new file mode 100644 index 0000000..0a29b4b --- /dev/null +++ b/Weather/Service/Program.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; + +namespace Weather.Service +{ + public static class Program + { + public static void Main(string[] args) + { + CreateWebHostBuilder(args).Build().Run(); + } + + private static IWebHostBuilder CreateWebHostBuilder(string[] args) + { + return WebHost.CreateDefaultBuilder(args).ConfigureAppConfiguration((hostingContext, config) => + { + config.AddEnvironmentVariables(); + }).UseStartup(); + } + } +} \ No newline at end of file diff --git a/Weather/Service/Properties/launchSettings.json b/Weather/Service/Properties/launchSettings.json new file mode 100644 index 0000000..803cbaa --- /dev/null +++ b/Weather/Service/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "Service": { + "commandName": "Project", + "launchUrl": "api/values", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "http://localhost:5001" + } + } +} \ No newline at end of file diff --git a/Weather/Service/ResourceReader.cs b/Weather/Service/ResourceReader.cs new file mode 100644 index 0000000..d609d70 --- /dev/null +++ b/Weather/Service/ResourceReader.cs @@ -0,0 +1,26 @@ +using System; +using System.IO; +using System.Reflection; + +namespace Weather.Service +{ + public static class ResourceReader + { + public static string GetString(string resourceName) + { + var assembly = Assembly.GetExecutingAssembly(); + using (var stream = assembly.GetManifestResourceStream(resourceName)) + { + if (stream == null) + { + throw new Exception($"Resource {resourceName} not found in {assembly.FullName}. Valid resources are: {string.Join(", ", assembly.GetManifestResourceNames())}."); + } + + using (var reader = new StreamReader(stream)) + { + return reader.ReadToEnd(); + } + } + } + } +} \ No newline at end of file diff --git a/Weather/Service/Service.csproj b/Weather/Service/Service.csproj new file mode 100644 index 0000000..5db9fed --- /dev/null +++ b/Weather/Service/Service.csproj @@ -0,0 +1,33 @@ + + + + true + netcoreapp2.2 + InProcess + Weather.Service + Weather.Service + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Weather/Service/Service.csproj.user b/Weather/Service/Service.csproj.user new file mode 100644 index 0000000..f0bf193 --- /dev/null +++ b/Weather/Service/Service.csproj.user @@ -0,0 +1,9 @@ + + + + ProjectDebugger + + + Service + + \ No newline at end of file diff --git a/Weather/Service/Startup.cs b/Weather/Service/Startup.cs new file mode 100644 index 0000000..dd4a26f --- /dev/null +++ b/Weather/Service/Startup.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Weather.Service.Data; + +namespace Weather.Service +{ + public class Startup + { + public void ConfigureServices(IServiceCollection services) + { + services.AddTransient(); + + services.AddHostedService(); + + services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2); + } + + public void Configure(IApplicationBuilder applicationBuilder, IHostingEnvironment environment) + { + if (environment.IsDevelopment()) + applicationBuilder.UseDeveloperExceptionPage(); + + var database = applicationBuilder.ApplicationServices.GetService(); + database.EnsureDatabase(); + + applicationBuilder.UseMvc(); + } + } +} \ No newline at end of file diff --git a/Weather/Service/appsettings.Development.json b/Weather/Service/appsettings.Development.json new file mode 100644 index 0000000..7cf4a21 --- /dev/null +++ b/Weather/Service/appsettings.Development.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + } +} + diff --git a/Weather/Service/appsettings.json b/Weather/Service/appsettings.json new file mode 100644 index 0000000..514246e --- /dev/null +++ b/Weather/Service/appsettings.json @@ -0,0 +1,18 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning" + } + }, + "Weather": { + "Database": { + "Name": "weather" + }, + "Queue": { + "Name": "weather" + } + }, + "Hub": { + "Weather": "http://hub-server/weatherHub" + } +} \ No newline at end of file diff --git a/Weather/Service/deploy/database.yaml b/Weather/Service/deploy/database.yaml new file mode 100644 index 0000000..7e17ffe --- /dev/null +++ b/Weather/Service/deploy/database.yaml @@ -0,0 +1,68 @@ +--- +kind: StatefulSet +apiVersion: apps/v1 +metadata: + name: weather-database + namespace: home-monitor + labels: + app: weather-database +spec: + replicas: 1 + selector: + matchLabels: + app: weather-database + serviceName: weather-database + template: + metadata: + labels: + app: weather-database + spec: + containers: + - name: weather-database + image: timescale/timescaledb + terminationMessagePath: "/dev/termination-log" + terminationMessagePolicy: File + imagePullPolicy: IfNotPresent + env: + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: weather-database-credentials + key: username + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: weather-database-credentials + key: password + - name: POSTGRES_DB + value: weather + volumeMounts: + - name: data + mountPath: /var/lib/postgresql/data + restartPolicy: Always + terminationGracePeriodSeconds: 30 + dnsPolicy: ClusterFirst + nodeSelector: + kubernetes.io/hostname: kubernetes + schedulerName: default-scheduler + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: [ "ReadWriteOnce" ] + storageClassName: local-path + resources: + requests: + storage: 4Gi +--- +kind: Service +apiVersion: v1 +metadata: + name: weather-database +spec: + ports: + - name: client + port: 5432 + selector: + app: weather-database + type: ClusterIP \ No newline at end of file diff --git a/Weather/Service/deploy/service.yaml b/Weather/Service/deploy/service.yaml new file mode 100644 index 0000000..5a846ba --- /dev/null +++ b/Weather/Service/deploy/service.yaml @@ -0,0 +1,59 @@ +--- +kind: Deployment +apiVersion: apps/v1 +metadata: + name: weather-service + namespace: home-monitor + labels: + app: weather-service +spec: + replicas: 1 + selector: + matchLabels: + app: weather-service + template: + metadata: + labels: + app: weather-service + spec: + containers: + - name: weather-service + image: ckaczor/home-monitor-weather-service:latest + terminationMessagePath: "/dev/termination-log" + terminationMessagePolicy: File + imagePullPolicy: Always + securityContext: + privileged: true + env: + - name: Weather__Queue__Host + value: weather-queue + - name: Weather__Queue__User + valueFrom: + secretKeyRef: + name: weather-queue-credentials + key: username + - name: Weather__Queue__Password + valueFrom: + secretKeyRef: + name: weather-queue-credentials + key: password + - name: Weather__Database__Host + value: weather-database + - name: Weather__Database__User + valueFrom: + secretKeyRef: + name: weather-database-credentials + key: username + - name: Weather__Database__Password + valueFrom: + secretKeyRef: + name: weather-database-credentials + key: password + - name: Hub__Weather + value: http://hub-service/weatherHub + restartPolicy: Always + terminationGracePeriodSeconds: 30 + dnsPolicy: ClusterFirst + nodeSelector: + kubernetes.io/hostname: kubernetes + schedulerName: default-scheduler \ No newline at end of file diff --git a/Weather/Weather.sln b/Weather/Weather.sln new file mode 100644 index 0000000..10f6825 --- /dev/null +++ b/Weather/Weather.sln @@ -0,0 +1,37 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.28705.295 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SerialReader", "SerialReader\SerialReader.csproj", "{63142748-213D-4BD3-9A15-8E5C405718B4}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Models", "Models\Models.csproj", "{39E5DE26-CD8D-47AF-AB94-6ACB0AF24EA1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Service", "Service\Service.csproj", "{914B9DB9-3BCD-4B55-8289-2E59D6CA96BA}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {63142748-213D-4BD3-9A15-8E5C405718B4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {63142748-213D-4BD3-9A15-8E5C405718B4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {63142748-213D-4BD3-9A15-8E5C405718B4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {63142748-213D-4BD3-9A15-8E5C405718B4}.Release|Any CPU.Build.0 = Release|Any CPU + {39E5DE26-CD8D-47AF-AB94-6ACB0AF24EA1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {39E5DE26-CD8D-47AF-AB94-6ACB0AF24EA1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {39E5DE26-CD8D-47AF-AB94-6ACB0AF24EA1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {39E5DE26-CD8D-47AF-AB94-6ACB0AF24EA1}.Release|Any CPU.Build.0 = Release|Any CPU + {914B9DB9-3BCD-4B55-8289-2E59D6CA96BA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {914B9DB9-3BCD-4B55-8289-2E59D6CA96BA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {914B9DB9-3BCD-4B55-8289-2E59D6CA96BA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {914B9DB9-3BCD-4B55-8289-2E59D6CA96BA}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {7E56B149-7297-42A1-9607-6B611D7EB09B} + EndGlobalSection +EndGlobal diff --git a/Weather/Weather.sln.DotSettings b/Weather/Weather.sln.DotSettings new file mode 100644 index 0000000..33b9601 --- /dev/null +++ b/Weather/Weather.sln.DotSettings @@ -0,0 +1,7 @@ + + True + True + True + True + True + True \ No newline at end of file diff --git a/Weather/Weather.sln.DotSettings.user b/Weather/Weather.sln.DotSettings.user new file mode 100644 index 0000000..fe9aee4 --- /dev/null +++ b/Weather/Weather.sln.DotSettings.user @@ -0,0 +1,5 @@ + + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + 2 \ No newline at end of file