├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .dockerignore ├── .github └── workflows │ └── main.yml ├── .gitignore ├── Dockerfile ├── LICENSE.md ├── README.md ├── Unifi ├── DataAccess │ ├── Api.cs │ └── SourceDAO.cs ├── IServiceCollectionExt.cs ├── Liasons │ ├── MQTTLiason.cs │ └── SourceLiason.cs ├── Models │ ├── Options │ │ ├── MQTTOpts.cs │ │ ├── SharedOpts.cs │ │ └── SourceOpts.cs │ ├── Shared │ │ ├── Resource.cs │ │ └── SlugMapping.cs │ └── Source │ │ ├── Clients.cs │ │ ├── Payload.cs │ │ └── Response.cs ├── Program.cs └── Unifi.csproj ├── UnifiTest ├── Liasons │ ├── MQTTLiasonTest.cs │ └── SourceLiasonTest.cs ├── UnifiTest.csproj └── coverlet.runsettings └── vendor └── .retain /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | #------------------------------------------------------------------------------------------------------------- 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. 4 | #------------------------------------------------------------------------------------------------------------- 5 | 6 | ARG DOTNETCORE_VERSION=7.0 7 | FROM mcr.microsoft.com/dotnet/sdk:${DOTNETCORE_VERSION} 8 | 9 | # This Dockerfile adds a non-root 'vscode' user with sudo access. However, for Linux, 10 | # this user's GID/UID must match your local user UID/GID to avoid permission issues 11 | # with bind mounts. Update USER_UID / USER_GID if yours is not 1000. See 12 | # https://aka.ms/vscode-remote/containers/non-root-user for details. 13 | ARG USERNAME=vscode 14 | ARG USER_UID=1000 15 | ARG USER_GID=$USER_UID 16 | 17 | # [Optional] Version of Node.js to install. 18 | ARG INSTALL_NODE="false" 19 | ARG NODE_VERSION="lts/*" 20 | ENV NVM_DIR=/home/vscode/.nvm 21 | 22 | # [Optional] Install the Azure CLI 23 | ARG INSTALL_AZURE_CLI="false" 24 | 25 | # Avoid warnings by switching to noninteractive 26 | ENV DEBIAN_FRONTEND=noninteractive 27 | 28 | # Configure apt and install packages 29 | RUN apt-get update \ 30 | && apt-get -y install --no-install-recommends apt-utils dialog 2>&1 \ 31 | # 32 | # Verify git, process tools, lsb-release (common in install instructions for CLIs) installed 33 | && apt-get -y install git iproute2 procps apt-transport-https gnupg2 curl lsb-release vim ssh mosquitto mosquitto-clients \ 34 | # 35 | # Create a non-root user to use if preferred - see https://aka.ms/vscode-remote/containers/non-root-user. 36 | && groupadd --gid $USER_GID $USERNAME \ 37 | && useradd -s /bin/bash --uid $USER_UID --gid $USER_GID -m $USERNAME \ 38 | # [Optional] Add sudo support for the non-root user 39 | && apt-get install -y sudo \ 40 | && echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME\ 41 | && chmod 0440 /etc/sudoers.d/$USERNAME \ 42 | # 43 | # [Optional] Install Node.js for ASP.NET Core Web Applicationss 44 | && if [ "$INSTALL_NODE" = "true" ]; then \ 45 | # 46 | # Install nvm and Node 47 | mkdir ${NVM_DIR} \ 48 | && curl -so- https://raw.githubusercontent.com/creationix/nvm/v0.34.0/install.sh | bash 2>&1 \ 49 | && chown -R vscode:vscode ${NVM_DIR} \ 50 | && /bin/bash -c "source $NVM_DIR/nvm.sh \ 51 | && nvm install ${NODE_VERSION} \ 52 | && nvm alias default ${NODE_VERSION}" 2>&1 \ 53 | && INIT_STRING='[ -s "$NVM_DIR/nvm.sh" ] && \\. "$NVM_DIR/nvm.sh" && [ -s "$NVM_DIR/bash_completion" ] && \\. "$NVM_DIR/bash_completion"' \ 54 | && echo $INIT_STRING >> /home/vscode/.bashrc \ 55 | && echo $INIT_STRING >> /home/vscode/.zshrc \ 56 | && echo $INIT_STRING >> /root/.zshrc \ 57 | # 58 | # Install yarn 59 | && curl -sS https://dl.yarnpkg.com/$(lsb_release -is | tr '[:upper:]' '[:lower:]')/pubkey.gpg | apt-key add - 2>/dev/null \ 60 | && echo "deb https://dl.yarnpkg.com/$(lsb_release -is | tr '[:upper:]' '[:lower:]')/ stable main" | tee /etc/apt/sources.list.d/yarn.list \ 61 | && apt-get update \ 62 | && apt-get -y install --no-install-recommends yarn; \ 63 | fi \ 64 | # 65 | # [Optional] Install the Azure CLI 66 | && if [ "$INSTALL_AZURE_CLI" = "true" ]; then \ 67 | echo "deb [arch=amd64] https://packages.microsoft.com/repos/azure-cli/ $(lsb_release -cs) main" > /etc/apt/sources.list.d/azure-cli.list \ 68 | && curl -sL https://packages.microsoft.com/keys/microsoft.asc | apt-key add - 2>/dev/null \ 69 | && apt-get update \ 70 | && apt-get install -y azure-cli; \ 71 | fi \ 72 | # 73 | # Clean up 74 | && apt-get autoremove -y \ 75 | && apt-get clean -y \ 76 | && rm -rf /var/lib/apt/lists/* 77 | 78 | # Switch back to dialog for any ad-hoc use of apt-get 79 | ENV DEBIAN_FRONTEND= 80 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "twomqtt", 3 | "dockerFile": "Dockerfile", 4 | "settings": { 5 | "csharpsortusings.sort.usings.splitGroups": false 6 | }, 7 | "remoteUser": "vscode", 8 | "extensions": [ 9 | "ms-dotnettools.csharp", 10 | "k--kato.docomment", 11 | "jongrant.csharpsortusings" 12 | ] 13 | } -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | bin 3 | obj -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Main Workflow 2 | on: 3 | push: 4 | branches: 5 | - main 6 | workflow_dispatch: 7 | inputs: 8 | tag: 9 | description: container image tag 10 | default: dev 11 | jobs: 12 | build: 13 | name: Build and Test 14 | runs-on: ubuntu-latest 15 | outputs: 16 | version: ${{ steps.version.outputs.number }} 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v3 20 | 21 | - name: Setup dotnet 22 | uses: actions/setup-dotnet@v3 23 | with: 24 | dotnet-version: '7.0.x' 25 | 26 | - name: Build Project 27 | run: dotnet build -c Release -o output Unifi 28 | 29 | - name: Test Project 30 | run: dotnet test --collect:"XPlat Code Coverage" UnifiTest 31 | 32 | - name: Obtain Version 33 | id: version 34 | run: echo "number=$(./output/Unifi -- version)" >> $GITHUB_OUTPUT 35 | 36 | - name: Upload coverage 37 | run: bash <(curl -s https://codecov.io/bash) 38 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' 39 | 40 | containerize: 41 | name: Upload Docker Images 42 | runs-on: ubuntu-latest 43 | needs: build 44 | steps: 45 | - name: Checkout code 46 | uses: actions/checkout@v3 47 | 48 | - name: Login to Dockerhub 49 | uses: docker/login-action@v2 50 | with: 51 | username: ${{ secrets.DOCKER_USERNAME }} 52 | password: ${{ secrets.DOCKER_PASSWORD }} 53 | 54 | - name: Login to GHCR 55 | uses: docker/login-action@v2 56 | with: 57 | registry: ghcr.io 58 | username: ${{ secrets.DOCKER_USERNAME }} 59 | password: ${{ secrets.GITHUB_TOKEN }} 60 | 61 | - name: Set up Docker Context for Buildx 62 | id: buildx-context 63 | run: | 64 | docker context create builders 65 | 66 | - name: Set up Docker Buildx 67 | uses: docker/setup-buildx-action@v2 68 | with: 69 | endpoint: builders 70 | 71 | - name: Setup Build Version 72 | id: build_version 73 | run: | 74 | BUILD_VERSION="${{ needs.build.outputs.version }}" 75 | echo "number=${BUILD_VERSION:1}" >> $GITHUB_OUTPUT 76 | 77 | - name: Build and Push (Dev) 78 | if: github.event_name == 'workflow_dispatch' 79 | uses: docker/build-push-action@v3 80 | with: 81 | push: true 82 | platforms: "linux/amd64,linux/arm64,linux/arm/v7" 83 | build-args: | 84 | BUILD_VERSION=${{ steps.build_version.outputs.number }} 85 | tags: "mannkind/unifi2mqtt:dev" 86 | 87 | - name: Build and Push (Main) 88 | if: github.ref == 'refs/heads/main' 89 | uses: docker/build-push-action@v3 90 | with: 91 | push: true 92 | platforms: "linux/amd64,linux/arm64,linux/arm/v7" 93 | build-args: | 94 | BUILD_VERSION=${{ steps.build_version.outputs.number }} 95 | tags: "mannkind/unifi2mqtt:latest,mannkind/unifi2mqtt:${{ needs.build.outputs.version }},ghcr.io/mannkind/unifi2mqtt:latest,ghcr.io/mannkind/unifi2mqtt:${{ needs.build.outputs.version }}" 96 | 97 | release: 98 | name: Release 99 | runs-on: ubuntu-latest 100 | needs: [build, containerize] 101 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' 102 | steps: 103 | - name: Checkout code 104 | uses: actions/checkout@v3 105 | - name: Tag Revision 106 | run: | 107 | git tag -f ${{ needs.build.outputs.version }} 108 | - name: Push Release 109 | run: | 110 | git push --tags 111 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | bin 3 | obj 4 | output 5 | TestResults -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # $BUILDPLATFORM ensures the native build platform is utilized 2 | ARG BUILDPLATFORM=linux/amd64 3 | FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:7.0 as build 4 | WORKDIR /src 5 | # Only fetch dependencies once 6 | # Find the non-test csproj file, move it to the appropriate folder, and restore project deps 7 | COPY Unifi/*.csproj ./Unifi/ 8 | RUN mkdir -p vendor && dotnet restore Unifi 9 | COPY . ./ 10 | # Build the app 11 | # Find the non-test csproj file, build that project 12 | ARG BUILD_VERSION=0.0.0.0 13 | RUN dotnet build -o output -c Release --no-restore -p:Version=$BUILD_VERSION Unifi 14 | 15 | FROM mcr.microsoft.com/dotnet/runtime:7.0 AS runtime 16 | COPY --from=build /src/output app 17 | ENTRYPOINT ["dotnet", "./app/Unifi.dll"] 18 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright © 2020 Dustin Brewer 4 | 5 | Permission is hereby granted, free of charge, to any person 6 | obtaining a copy of this software and associated documentation 7 | files (the “Software”), to deal in the Software without 8 | restriction, including without limitation the rights to use, 9 | copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the 11 | Software is furnished to do so, subject to the following 12 | conditions: 13 | 14 | The above copyright notice and this permission notice shall be 15 | included in all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, 18 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 19 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 20 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 21 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 22 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 23 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 24 | OTHER DEALINGS IN THE SOFTWARE. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # unifi2mqtt 2 | 3 | [![Software 4 | License](https://img.shields.io/badge/License-MIT-orange.svg?style=flat-square)](https://github.com/mannkind/unifi2mqtt/blob/main/LICENSE.md) 5 | [![Build Status](https://github.com/mannkind/unifi2mqtt/workflows/Main%20Workflow/badge.svg)](https://github.com/mannkind/unifi2mqtt/actions) 6 | [![Coverage Status](https://img.shields.io/codecov/c/github/mannkind/unifi2mqtt/main.svg)](http://codecov.io/github/mannkind/unifi2mqtt?branch=main) 7 | 8 | An experiment to publish device statuses from the Unifi Controller to MQTT. 9 | 10 | ## Use 11 | 12 | The application can be locally built using `dotnet build` or you can utilize the multi-architecture Docker image(s). 13 | 14 | ### Example 15 | 16 | ```bash 17 | docker run \ 18 | -e UNIFI__HOST="https://unifi-controller.dns.name:8443" \ 19 | -e UNIFI__USERNAME="unifiUsername" \ 20 | -e UNIFI__PASSWORD="unifiPassword" \ 21 | -e UNIFI__AWAYTIMEOUT="0.00:05:01" \ 22 | -e UNIFI__RESOURCES__0__MACAddress="11:22:33:44:55:66" \ 23 | -e UNIFI__RESOURCES__0__Slug="identifierSlug" \ 24 | mannkind/unifi2mqtt:latest 25 | ``` 26 | 27 | OR 28 | 29 | ```bash 30 | UNIFI__HOST="https://unifi-controller.dns.name:8443" \ 31 | UNIFI__USERNAME="unifiUsername" \ 32 | UNIFI__PASSWORD="unifiPassword" \ 33 | UNIFI__AWAYTIMEOUT="0.00:05:01" \ 34 | UNIFI__RESOURCES__0__MACAddress="11:22:33:44:55:66" \ 35 | UNIFI__RESOURCES__0__Slug="identifierSlug" \ 36 | ./unifi2mqtt 37 | ``` 38 | 39 | 40 | ## Configuration 41 | 42 | Configuration happens via environmental variables 43 | 44 | ```bash 45 | UNIFI__HOST - The Unifi Controller Host URL 46 | UNIFI__USERNAME - The Unifi Controller Username 47 | UNIFI__PASSWORD - The Unifi Controller Password 48 | UNIFI__AWAYTIMEOUT - [OPTIONAL] The delay between last seeing a device and marking it as away, defaults to "0.00:05:01" 49 | UNIFI__POLLINGINTERVAL - [OPTIONAL] The delay between device lookups, defaults to "0.00:00:11" 50 | UNIFI__DISABLESSLVALIDATION - [OPTIONAL] The flag that disables SSL validation, defaults to true 51 | UNIFI__ASDEVICETRACKER - [OPTIONAL] The flag that switches to device_tracker 52 | UNIFI__RESOURCES__#__MACAddress - The n-th iteration of a mac address for a specific device 53 | UNIFI__RESOURCES__#__Slug - The n-th iteration of a slug to identify the specific mac address 54 | UNIFI__MQTT__TOPICPREFIX - [OPTIONAL] The MQTT topic on which to publish the collection lookup results, defaults to "home/unifi" 55 | UNIFI__MQTT__DISCOVERYENABLED - [OPTIONAL] The MQTT discovery flag for Home Assistant, defaults to false 56 | UNIFI__MQTT__DISCOVERYPREFIX - [OPTIONAL] The MQTT discovery prefix for Home Assistant, defaults to "homeassistant" 57 | UNIFI__MQTT__DISCOVERYNAME - [OPTIONAL] The MQTT discovery name for Home Assistant, defaults to "unifi" 58 | UNIFI__MQTT__BROKER - [OPTIONAL] The MQTT broker, defaults to "test.mosquitto.org" 59 | UNIFI__MQTT__USERNAME - [OPTIONAL] The MQTT username, default to "" 60 | UNIFI__MQTT__PASSWORD - [OPTIONAL] The MQTT password, default to "" 61 | ``` 62 | 63 | ## Prior Implementations 64 | 65 | ### Golang 66 | * Last Commit: [c39d32c5d0721d32f8ebf089b796461f514b4d71](https://github.com/mannkind/unifi2mqtt/commit/c39d32c5d0721d32f8ebf089b796461f514b4d71) 67 | * Last Docker Image: [mannkind/unifi2mqtt:v0.8.20061.0158](https://hub.docker.com/layers/mannkind/unifi2mqtt/v0.8.20061.0158/images/sha256-7020736d44b64fe8b9cbc87887f20216b4539c32c9a5ae6145c10fe3c233b5bf?context=explore) -------------------------------------------------------------------------------- /Unifi/DataAccess/Api.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Net.Http; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using Newtonsoft.Json; 7 | using Unifi.Models.Source; 8 | 9 | namespace Unifi.DataAccess; 10 | 11 | /// 12 | /// Hopefully this exists only until KoenZomers.UniFi.Api is updated. 13 | /// 14 | public class Api 15 | { 16 | public Api(Uri host, string site, IHttpClientFactory httpClientFactory) 17 | { 18 | this.Host = host; 19 | this.Site = site; 20 | this.HttpClientFactory = httpClientFactory; 21 | } 22 | 23 | public async Task Authenticate(string username, string password, CancellationToken cancellationToken = default) 24 | { 25 | using var dclient = this.HttpClientFactory.CreateClient(nameof(ApiControllerDetection)); 26 | var detectResp = await dclient.GetAsync(this.Host, cancellationToken); 27 | this.UniFiOS = detectResp.StatusCode == System.Net.HttpStatusCode.OK; 28 | 29 | using var data = new StringContent(JsonConvert.SerializeObject(new 30 | { 31 | username = username, 32 | password = password, 33 | remember = false, 34 | }), null, "application/json"); 35 | 36 | using var client = this.HttpClientFactory.CreateClient(nameof(Api)); 37 | var resp = await client.PostAsync($"{this.Host}{this.MapUrl($"api/login")}", data, cancellationToken); 38 | 39 | return resp.IsSuccessStatusCode; 40 | } 41 | 42 | public async Task> GetActiveClients(CancellationToken cancellation = default) 43 | { 44 | using var client = this.HttpClientFactory.CreateClient(nameof(Api)); 45 | var resp = await client.GetAsync($"{this.Host}{this.MapUrl($"api/s/{this.Site}/stat/sta")}", cancellation); 46 | var result = await resp.Content.ReadAsStringAsync(cancellation); 47 | var objs = JsonConvert.DeserializeObject>(result); 48 | 49 | return objs.Data; 50 | } 51 | 52 | /// 53 | /// 54 | /// 55 | private readonly Uri Host; 56 | 57 | /// 58 | /// 59 | /// 60 | private readonly string Site; 61 | 62 | /// 63 | /// 64 | /// 65 | private readonly IHttpClientFactory HttpClientFactory; 66 | 67 | /// 68 | /// 69 | /// 70 | private bool UniFiOS; 71 | 72 | private string MapUrl(string url) 73 | { 74 | if (!this.UniFiOS) 75 | { 76 | return url; 77 | } 78 | 79 | if (url == "api/login") 80 | { 81 | return "api/auth/login"; 82 | } 83 | 84 | return $"proxy/network/{url}"; 85 | } 86 | } 87 | 88 | /// 89 | /// Hopefully this exists only until KoenZomers.UniFi.Api is updated. 90 | /// 91 | public class ApiControllerDetection { } 92 | -------------------------------------------------------------------------------- /Unifi/DataAccess/SourceDAO.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.Threading; 7 | using System.Threading.Tasks; 8 | using Microsoft.Extensions.Caching.Memory; 9 | using Microsoft.Extensions.Logging; 10 | using Microsoft.Extensions.Primitives; 11 | using Newtonsoft.Json; 12 | using TwoMQTT.Interfaces; 13 | using Unifi.Models.Shared; 14 | using Unifi.Models.Source; 15 | 16 | namespace Unifi.DataAccess; 17 | 18 | public interface ISourceDAO : IPollingSourceDAO 19 | { 20 | } 21 | 22 | /// 23 | /// An class representing a managed way to interact with a source. 24 | /// 25 | public class SourceDAO : ISourceDAO 26 | { 27 | /// 28 | /// Initializes a new instance of the SourceDAO class. 29 | /// 30 | /// 31 | /// 32 | /// 33 | /// 34 | /// 35 | /// 36 | /// 37 | public SourceDAO(ILogger logger, IMemoryCache cache, Api unifiClient, string username, string password, TimeSpan awayTimeout) 38 | { 39 | this.Logger = logger; 40 | this.Cache = cache; 41 | this.Username = username; 42 | this.Password = password; 43 | this.AwayTimeout = awayTimeout; 44 | this.UnifiClient = unifiClient; 45 | } 46 | 47 | /// 48 | public async Task FetchOneAsync(SlugMapping data, 49 | CancellationToken cancellationToken = default) 50 | { 51 | try 52 | { 53 | return await this.FetchAsync(data.MACAddress, cancellationToken); 54 | } 55 | catch (Exception e) 56 | { 57 | var msg = e switch 58 | { 59 | HttpRequestException => "Unable to fetch from the Unifi API", 60 | JsonException => "Unable to deserialize response from the Unifi API", 61 | _ => "Unable to send to the Unifi API" 62 | }; 63 | this.Logger.LogError(msg + "; {exception}", e); 64 | this.IsLoggedIn = false; 65 | return null; 66 | } 67 | } 68 | 69 | /// 70 | /// The logger used internally. 71 | /// 72 | private readonly ILogger Logger; 73 | 74 | /// 75 | /// The internal cache. 76 | /// 77 | private readonly IMemoryCache Cache; 78 | 79 | /// 80 | /// The Username to access the source. 81 | /// 82 | private readonly string Username; 83 | 84 | /// 85 | /// The Password to access the source. 86 | /// 87 | private readonly string Password; 88 | 89 | /// 90 | /// The client to access the source. 91 | /// 92 | private readonly Api UnifiClient; 93 | 94 | /// 95 | /// An internal timeout for how long until a device is considered away. 96 | /// 97 | private readonly TimeSpan AwayTimeout; 98 | 99 | /// 100 | /// The semaphore to limit how many times the source api is called. 101 | /// 102 | private readonly SemaphoreSlim ClientsSemaphore = new SemaphoreSlim(1, 1); 103 | 104 | /// 105 | /// A flag that indicates if logged into the source. 106 | /// 107 | private bool IsLoggedIn = false; 108 | 109 | /// 110 | /// 111 | /// 112 | /// 113 | /// 114 | /// 115 | private readonly ConcurrentDictionary LastSeen = new ConcurrentDictionary(); 116 | 117 | /// 118 | /// 119 | /// 120 | private const string ACTIVECLIENTS = "CLIENTS"; 121 | 122 | /// 123 | /// Fetch one response from the source 124 | /// 125 | /// 126 | /// 127 | /// 128 | private async Task FetchAsync(string macAddress, 129 | CancellationToken cancellationToken = default) 130 | { 131 | this.Logger.LogDebug("Started finding {macAddress} from Unifi", macAddress); 132 | var clients = await this.AllClientsAsync(cancellationToken); 133 | if (clients == null) 134 | { 135 | this.Logger.LogDebug("Unable to find {macAddress} from Unifi", macAddress); 136 | return null; 137 | } 138 | 139 | var client = clients.FirstOrDefault(x => x.MacAddress.Equals(macAddress, StringComparison.OrdinalIgnoreCase)); 140 | if (client != null) 141 | { 142 | this.LastSeen[macAddress] = DateTime.Now; 143 | } 144 | 145 | var dt = DateTime.MinValue; 146 | if (this.LastSeen.ContainsKey(macAddress)) 147 | { 148 | dt = this.LastSeen[macAddress]; 149 | } 150 | 151 | this.Logger.LogDebug("{macAddress} found in controller: {found}; last seen at {dt}; presence: {state}", macAddress, client != null, dt, dt > (DateTime.Now - this.AwayTimeout)); 152 | return new Response 153 | { 154 | MACAddress = macAddress, 155 | State = dt > (DateTime.Now - this.AwayTimeout), 156 | }; 157 | } 158 | 159 | /// 160 | /// Fetch one response from the source 161 | /// 162 | /// 163 | /// 164 | /// 165 | private async Task> AllClientsAsync(CancellationToken cancellationToken = default) 166 | { 167 | this.Logger.LogDebug("Started fetching all clients from Unifi"); 168 | await this.ClientsSemaphore.WaitAsync(); 169 | 170 | try 171 | { 172 | // Check cache first to avoid hammering the API 173 | if (this.Cache.TryGetValue(ACTIVECLIENTS, out IEnumerable cachedObj)) 174 | { 175 | this.Logger.LogDebug("Found all clients in the cache"); 176 | return cachedObj; 177 | } 178 | 179 | if (!this.IsLoggedIn) 180 | { 181 | this.IsLoggedIn = await this.UnifiClient.Authenticate(this.Username, this.Password); 182 | } 183 | 184 | var clients = await this.UnifiClient.GetActiveClients(); 185 | 186 | this.Logger.LogDebug("Caching {count} clients", clients.Count); 187 | var cts = new CancellationTokenSource(new TimeSpan(0, 0, 9)); 188 | var cacheOpts = new MemoryCacheEntryOptions() 189 | .AddExpirationToken(new CancellationChangeToken(cts.Token)); 190 | this.Cache.Set(ACTIVECLIENTS, clients, cacheOpts); 191 | return clients; 192 | } 193 | finally 194 | { 195 | this.ClientsSemaphore.Release(); 196 | } 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /Unifi/IServiceCollectionExt.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net.Http; 3 | using System.Threading; 4 | using Microsoft.Extensions.DependencyInjection; 5 | using Microsoft.Extensions.Options; 6 | 7 | /// 8 | /// Extensions for classes implementing IServiceCollection 9 | /// 10 | public static class IServiceCollectionExt 11 | { 12 | /// 13 | /// Hopefully this exists only until KoenZomers.UniFi.Api is updated. 14 | /// 15 | /// 16 | /// 17 | /// 18 | public static IServiceCollection AddTypeNamedHttpClient(this IServiceCollection services, bool allowAutoRedirect = true, TimeSpan? lifetime = null) 19 | where T : class => 20 | services 21 | .AddHttpClient(typeof(T).Name) 22 | .ConfigurePrimaryHttpMessageHandler((x) => 23 | { 24 | var opts = x.GetRequiredService>(); 25 | return SetupHttpClientHandler(allowAutoRedirect, !opts.Value.DisableSslValidation); 26 | }) 27 | .SetHandlerLifetime(lifetime ?? TimeSpan.FromMinutes(2)) 28 | .Services; 29 | 30 | public static HttpClientHandler SetupHttpClientHandler(bool allowAutoRedirect = true, bool validateSSL = false) 31 | { 32 | var handler = new HttpClientHandler 33 | { 34 | AllowAutoRedirect = allowAutoRedirect 35 | }; 36 | 37 | if (!validateSSL) 38 | { 39 | handler.ClientCertificateOptions = ClientCertificateOption.Manual; 40 | handler.ServerCertificateCustomValidationCallback = (httpRequestMessage, cert, cetChain, policyErrors) => true; 41 | } 42 | 43 | return handler; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Unifi/Liasons/MQTTLiason.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using System.Reflection; 4 | using Microsoft.Extensions.Logging; 5 | using Microsoft.Extensions.Options; 6 | using TwoMQTT; 7 | using TwoMQTT.Interfaces; 8 | using TwoMQTT.Liasons; 9 | using TwoMQTT.Models; 10 | using TwoMQTT.Utils; 11 | using Unifi.Models.Options; 12 | using Unifi.Models.Shared; 13 | 14 | namespace Unifi.Liasons; 15 | 16 | /// 17 | /// An class representing a managed way to interact with MQTT. 18 | /// 19 | public class MQTTLiason : MQTTLiasonBase, IMQTTLiason 20 | { 21 | /// 22 | /// 23 | /// 24 | /// 25 | /// 26 | /// 27 | public MQTTLiason(ILogger logger, IMQTTGenerator generator, IOptions sharedOpts) : 28 | base(logger, generator, sharedOpts) 29 | { 30 | this.AsDeviceTracker = sharedOpts.Value.AsDeviceTracker; 31 | } 32 | 33 | /// 34 | public IEnumerable<(string topic, string payload)> MapData(Resource input) 35 | { 36 | var results = new List<(string, string)>(); 37 | var slug = this.Questions 38 | .Where(x => x.MACAddress == input.Mac) 39 | .Select(x => x.Slug) 40 | .FirstOrDefault() ?? string.Empty; 41 | 42 | if (string.IsNullOrEmpty(slug)) 43 | { 44 | this.Logger.LogDebug("Unable to find slug for {macAddress}", input.Mac); 45 | return results; 46 | } 47 | 48 | this.Logger.LogDebug("Found slug {slug} for incoming data for {macAddress}", slug, input.Mac); 49 | results.AddRange(new[] 50 | { 51 | (this.Generator.StateTopic(slug), this.AsState(input.State)), 52 | } 53 | ); 54 | 55 | return results; 56 | } 57 | 58 | /// 59 | public IEnumerable<(string slug, string sensor, string type, MQTTDiscovery discovery)> Discoveries() 60 | { 61 | var discoveries = new List<(string, string, string, MQTTDiscovery)>(); 62 | var assembly = Assembly.GetAssembly(typeof(Program))?.GetName() ?? new AssemblyName(); 63 | var mapping = new[] 64 | { 65 | new { Sensor = string.Empty, Type = this.AsDeviceTracker ? Const.DEVICE_TRACKER : Const.BINARY_SENSOR }, 66 | }; 67 | 68 | foreach (var input in this.Questions) 69 | { 70 | foreach (var map in mapping) 71 | { 72 | this.Logger.LogDebug("Generating discovery for {macAddress} - {sensor}", input.MACAddress, map.Sensor); 73 | var discovery = this.Generator.BuildDiscovery(input.Slug, map.Sensor, assembly, false); 74 | discovery = this.AsDiscovery(discovery); 75 | 76 | discoveries.Add((input.Slug, map.Sensor, map.Type, discovery)); 77 | } 78 | } 79 | 80 | return discoveries; 81 | } 82 | 83 | /// 84 | /// 85 | /// 86 | private readonly bool AsDeviceTracker; 87 | 88 | private string AsState(bool input) => 89 | this.AsDeviceTracker 90 | ? this.Generator.BooleanHomeNotHome(input) 91 | : this.Generator.BooleanOnOff(input); 92 | 93 | private MQTTDiscovery AsDiscovery(MQTTDiscovery input) => 94 | this.AsDeviceTracker 95 | ? input with 96 | { 97 | PayloadHome = this.Generator.BooleanHomeNotHome(true), 98 | PayloadNotHome = this.Generator.BooleanHomeNotHome(false), 99 | SourceType = "router", 100 | } 101 | : input with 102 | { 103 | DeviceClass = "presence", 104 | }; 105 | } 106 | -------------------------------------------------------------------------------- /Unifi/Liasons/SourceLiason.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using System.Threading.Tasks; 3 | using Microsoft.Extensions.Logging; 4 | using Microsoft.Extensions.Options; 5 | using TwoMQTT.Interfaces; 6 | using TwoMQTT.Liasons; 7 | using Unifi.DataAccess; 8 | using Unifi.Models.Options; 9 | using Unifi.Models.Shared; 10 | using Unifi.Models.Source; 11 | 12 | namespace Unifi.Liasons; 13 | 14 | /// 15 | /// A class representing a managed way to interact with a source. 16 | /// 17 | public class SourceLiason : PollingSourceLiasonBase, ISourceLiason 18 | { 19 | public SourceLiason(ILogger logger, ISourceDAO sourceDAO, 20 | IOptions opts, IOptions sharedOpts) : 21 | base(logger, sourceDAO, sharedOpts) 22 | { 23 | this.Logger.LogInformation( 24 | "Host: {host}\n" + 25 | "Username: {username}\n" + 26 | "Password: {password}\n" + 27 | "Site: {site}\n" + 28 | "AwayTimeout: {awayTimeout}\n" + 29 | "PollingInterval: {pollingInterval}\n" + 30 | "AsDeviceTracker: {asDeviceTracker}\n" + 31 | "Resources: {@resources}\n" + 32 | "", 33 | opts.Value.Host, 34 | opts.Value.Username, 35 | (!string.IsNullOrEmpty(opts.Value.Password) ? "" : string.Empty), 36 | opts.Value.Site, 37 | opts.Value.AwayTimeout, 38 | opts.Value.PollingInterval, 39 | sharedOpts.Value.AsDeviceTracker, 40 | sharedOpts.Value.Resources 41 | ); 42 | } 43 | 44 | /// 45 | protected override async Task FetchOneAsync(SlugMapping key, CancellationToken cancellationToken) 46 | { 47 | var result = await this.SourceDAO.FetchOneAsync(key, cancellationToken); 48 | return result switch 49 | { 50 | Response => new Resource 51 | { 52 | Mac = result.MACAddress, 53 | State = result.State, 54 | }, 55 | _ => null, 56 | }; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Unifi/Models/Options/MQTTOpts.cs: -------------------------------------------------------------------------------- 1 | using TwoMQTT.Models; 2 | 3 | namespace Unifi.Models.Options; 4 | 5 | /// 6 | /// The sink options 7 | /// 8 | public record MQTTOpts : MQTTManagerOptions 9 | { 10 | public const string Section = "Unifi:MQTT"; 11 | public const string TopicPrefixDefault = "home/unifi"; 12 | public const string DiscoveryNameDefault = "unifi"; 13 | } 14 | -------------------------------------------------------------------------------- /Unifi/Models/Options/SharedOpts.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using TwoMQTT.Interfaces; 3 | using Unifi.Models.Shared; 4 | 5 | namespace Unifi.Models.Options; 6 | 7 | /// 8 | /// The shared options across the application 9 | /// 10 | public record SharedOpts : ISharedOpts 11 | { 12 | public const string Section = "Unifi"; 13 | 14 | /// 15 | /// 16 | /// 17 | /// 18 | /// 19 | public List Resources { get; init; } = new(); 20 | 21 | /// 22 | /// 23 | /// 24 | /// 25 | public bool AsDeviceTracker { get; init; } = false; 26 | } 27 | -------------------------------------------------------------------------------- /Unifi/Models/Options/SourceOpts.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.ComponentModel.DataAnnotations; 3 | 4 | namespace Unifi.Models.Options; 5 | 6 | /// 7 | /// The source options 8 | /// 9 | public record SourceOpts 10 | { 11 | public const string Section = "Unifi"; 12 | 13 | /// 14 | /// 15 | /// 16 | /// 17 | [Required(ErrorMessage = Section + ":" + nameof(Host) + " is missing")] 18 | public string Host { get; init; } = "https://unifi.local:8443"; 19 | 20 | /// 21 | /// 22 | /// 23 | /// 24 | [Required(ErrorMessage = Section + ":" + nameof(Username) + " is missing")] 25 | public string Username { get; init; } = "unifi"; 26 | 27 | /// 28 | /// 29 | /// 30 | /// 31 | [Required(ErrorMessage = Section + ":" + nameof(Password) + " is missing")] 32 | public string Password { get; init; } = "unifi"; 33 | 34 | /// 35 | /// 36 | /// 37 | /// 38 | [Required(ErrorMessage = Section + ":" + nameof(Site) + " is missing")] 39 | public string Site { get; init; } = "default"; 40 | 41 | /// 42 | /// 43 | /// 44 | /// 45 | [Required(ErrorMessage = Section + ":" + nameof(AwayTimeout) + " is missing")] 46 | public TimeSpan AwayTimeout { get; init; } = new(0, 5, 1); 47 | 48 | /// 49 | /// 50 | /// 51 | /// 52 | [Required(ErrorMessage = Section + ":" + nameof(PollingInterval) + " is missing")] 53 | public TimeSpan PollingInterval { get; init; } = new(0, 0, 11); 54 | 55 | /// 56 | /// 57 | /// 58 | /// 59 | [Required(ErrorMessage = Section + ":" + nameof(DisableSslValidation) + " is missing")] 60 | public bool DisableSslValidation { get; init; } = true; 61 | } 62 | -------------------------------------------------------------------------------- /Unifi/Models/Shared/Resource.cs: -------------------------------------------------------------------------------- 1 | namespace Unifi.Models.Shared; 2 | 3 | /// 4 | /// The shared resource across the application 5 | /// 6 | public record Resource 7 | { 8 | /// 9 | /// 10 | /// 11 | /// 12 | public string Mac { get; init; } = string.Empty; 13 | 14 | /// 15 | /// 16 | /// 17 | /// 18 | public bool State { get; init; } = false; 19 | } 20 | -------------------------------------------------------------------------------- /Unifi/Models/Shared/SlugMapping.cs: -------------------------------------------------------------------------------- 1 | namespace Unifi.Models.Shared; 2 | 3 | /// 4 | /// The shared key info => slug mapping across the application 5 | /// 6 | public record SlugMapping 7 | { 8 | /// 9 | /// 10 | /// 11 | /// 12 | public string MACAddress { get; init; } = string.Empty; 13 | 14 | /// 15 | /// 16 | /// 17 | /// 18 | public string Slug { get; init; } = string.Empty; 19 | } 20 | -------------------------------------------------------------------------------- /Unifi/Models/Source/Clients.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | 3 | namespace Unifi.Models.Source; 4 | 5 | /// 6 | /// Hopefully this exists only until KoenZomers.UniFi.Api is updated. 7 | /// 8 | public record Clients 9 | { 10 | [JsonProperty(PropertyName = "mac")] 11 | public string MacAddress { get; set; } = string.Empty; 12 | } 13 | -------------------------------------------------------------------------------- /Unifi/Models/Source/Payload.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Newtonsoft.Json; 3 | 4 | namespace Unifi.Models.Source; 5 | 6 | /// 7 | /// Hopefully this exists only until KoenZomers.UniFi.Api is updated. 8 | /// 9 | public record Payload 10 | { 11 | [JsonProperty(PropertyName = "data")] 12 | public List Data { get; init; } = new(); 13 | } 14 | -------------------------------------------------------------------------------- /Unifi/Models/Source/Response.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Unifi.Models.Source; 4 | 5 | /// 6 | /// The response from the source 7 | /// 8 | public record Response 9 | { 10 | /// 11 | /// 12 | /// 13 | /// 14 | public string MACAddress { get; init; } = string.Empty; 15 | 16 | /// 17 | /// 18 | /// 19 | /// 20 | public bool State { get; init; } = false; 21 | } 22 | -------------------------------------------------------------------------------- /Unifi/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Net.Http; 4 | using System.Threading.Tasks; 5 | using Microsoft.Extensions.Caching.Memory; 6 | using Microsoft.Extensions.DependencyInjection; 7 | using Microsoft.Extensions.Hosting; 8 | using Microsoft.Extensions.Logging; 9 | using Microsoft.Extensions.Options; 10 | using TwoMQTT; 11 | using TwoMQTT.Extensions; 12 | using TwoMQTT.Interfaces; 13 | using TwoMQTT.Managers; 14 | using Unifi.DataAccess; 15 | using Unifi.Liasons; 16 | using Unifi.Models.Options; 17 | using Unifi.Models.Shared; 18 | 19 | await ConsoleProgram. 20 | ExecuteAsync(args, 21 | envs: new Dictionary() 22 | { 23 | { 24 | $"{MQTTOpts.Section}:{nameof(MQTTOpts.TopicPrefix)}", 25 | MQTTOpts.TopicPrefixDefault 26 | }, 27 | { 28 | $"{MQTTOpts.Section}:{nameof(MQTTOpts.DiscoveryName)}", 29 | MQTTOpts.DiscoveryNameDefault 30 | }, 31 | }, 32 | configureServices: (HostBuilderContext context, IServiceCollection services) => 33 | { 34 | services 35 | .AddMemoryCache() 36 | .AddOptions(SharedOpts.Section, context.Configuration) 37 | .AddOptions(SourceOpts.Section, context.Configuration) 38 | .AddOptions(MQTTOpts.Section, context.Configuration) 39 | .AddSingleton(x => 40 | { 41 | var opts = x.GetRequiredService>(); 42 | return new ThrottleManager(opts.Value.PollingInterval); 43 | }) 44 | .AddTypeNamedHttpClient(allowAutoRedirect: false) 45 | .AddTypeNamedHttpClient(lifetime: System.Threading.Timeout.InfiniteTimeSpan) 46 | .AddSingleton(x => 47 | { 48 | var opts = x.GetRequiredService>(); 49 | var hcf = x.GetRequiredService(); // Hopefully this only exists until KoenZomers.UniFi.Api is updated. 50 | return new Api(new Uri(opts.Value.Host), opts.Value.Site, hcf); 51 | }) 52 | .AddSingleton(x => 53 | { 54 | var logger = x.GetRequiredService>(); 55 | var cache = x.GetRequiredService(); 56 | var api = x.GetRequiredService(); 57 | var opts = x.GetRequiredService>(); 58 | return new SourceDAO(logger, 59 | cache, 60 | api, 61 | opts.Value.Username, 62 | opts.Value.Password, 63 | opts.Value.AwayTimeout); 64 | }); 65 | }); -------------------------------------------------------------------------------- /Unifi/Unifi.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 0.14.$([System.DateTime]::UtcNow.ToString(yy))$([System.DateTime]::UtcNow.DayOfYear.ToString(000)).$([System.DateTime]::UtcNow.ToString(HHmm))$([System.Math]::Floor($([MSBuild]::Divide($([System.DateTime]::UtcNow.Second), 6)))) 5 | Exe 6 | net7.0 7 | enable 8 | $(RestoreSources);../vendor;https://api.nuget.org/v3/index.json 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /UnifiTest/Liasons/MQTTLiasonTest.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using Microsoft.Extensions.Logging; 3 | using Microsoft.Extensions.Options; 4 | using Microsoft.VisualStudio.TestTools.UnitTesting; 5 | using Moq; 6 | using TwoMQTT.Interfaces; 7 | using Unifi.Liasons; 8 | using Unifi.Models.Options; 9 | using Unifi.Models.Shared; 10 | 11 | namespace UnifiTest.Liasons; 12 | 13 | [TestClass] 14 | public class MQTTLiasonTest 15 | { 16 | [TestMethod] 17 | public void MapDataTest() 18 | { 19 | var tests = new[] { 20 | new { 21 | Q = new SlugMapping { MACAddress = BasicMACAddress, Slug = BasicSlug }, 22 | Resource = new Resource { Mac = BasicMACAddress, State = BasicState }, 23 | Expected = new { MACAddress = BasicMACAddress, State = BasicStateString, Slug = BasicSlug, Found = true } 24 | }, 25 | new { 26 | Q = new SlugMapping { MACAddress = BasicMACAddress, Slug = BasicSlug }, 27 | Resource = new Resource { Mac = $"{BasicMACAddress}-fake" , State = BasicState }, 28 | Expected = new { MACAddress = string.Empty, State = string.Empty, Slug = string.Empty, Found = false } 29 | }, 30 | }; 31 | 32 | foreach (var test in tests) 33 | { 34 | var logger = new Mock>(); 35 | var generator = new Mock(); 36 | var sharedOpts = Options.Create(new SharedOpts 37 | { 38 | Resources = new[] { test.Q }.ToList(), 39 | }); 40 | 41 | generator.Setup(x => x.BuildDiscovery(It.IsAny(), It.IsAny(), It.IsAny(), false)) 42 | .Returns(new TwoMQTT.Models.MQTTDiscovery()); 43 | generator.Setup(x => x.StateTopic(test.Q.Slug, It.IsAny())) 44 | .Returns($"totes/{test.Q.Slug}/topic/{nameof(Resource.State)}"); 45 | generator.Setup(x => x.BooleanOnOff(BasicState)).Returns(BasicStateString); 46 | 47 | var mqttLiason = new MQTTLiason(logger.Object, generator.Object, sharedOpts); 48 | var results = mqttLiason.MapData(test.Resource); 49 | var actual = results.FirstOrDefault(x => x.topic?.Contains(nameof(Resource.State)) ?? false); 50 | 51 | Assert.AreEqual(test.Expected.Found, results.Any(), "The mapping should exist if found."); 52 | if (test.Expected.Found) 53 | { 54 | Assert.IsTrue(actual.topic.Contains(test.Expected.Slug), "The topic should contain the expected MACAddress."); 55 | Assert.AreEqual(test.Expected.State, actual.payload, "The payload be the expected State."); 56 | } 57 | } 58 | } 59 | 60 | [TestMethod] 61 | public void DiscoveriesTest() 62 | { 63 | var tests = new[] { 64 | new { 65 | Q = new SlugMapping { MACAddress = BasicMACAddress, Slug = BasicSlug }, 66 | Resource = new Resource { Mac = BasicMACAddress, State = BasicState }, 67 | Expected = new { MACAddress = BasicMACAddress, State = BasicState, Slug = BasicSlug } 68 | }, 69 | }; 70 | 71 | foreach (var test in tests) 72 | { 73 | var logger = new Mock>(); 74 | var generator = new Mock(); 75 | var sharedOpts = Options.Create(new SharedOpts 76 | { 77 | Resources = new[] { test.Q }.ToList(), 78 | }); 79 | 80 | generator.Setup(x => x.BuildDiscovery(test.Q.Slug, It.IsAny(), It.IsAny(), false)) 81 | .Returns(new TwoMQTT.Models.MQTTDiscovery()); 82 | 83 | var mqttLiason = new MQTTLiason(logger.Object, generator.Object, sharedOpts); 84 | var results = mqttLiason.Discoveries(); 85 | var result = results.FirstOrDefault(); 86 | 87 | Assert.IsNotNull(result, "A discovery should exist."); 88 | } 89 | } 90 | 91 | private static string BasicSlug = "totallyaslug"; 92 | private static bool BasicState = true; 93 | private static string BasicStateString = "ON"; 94 | private static string BasicMACAddress = "AA:BB:CC:DD:EE:FF"; 95 | } 96 | -------------------------------------------------------------------------------- /UnifiTest/Liasons/SourceLiasonTest.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using Microsoft.Extensions.Logging; 7 | using Microsoft.Extensions.Options; 8 | using Microsoft.VisualStudio.TestTools.UnitTesting; 9 | using Moq; 10 | using Unifi.DataAccess; 11 | using Unifi.Liasons; 12 | using Unifi.Models.Options; 13 | using Unifi.Models.Shared; 14 | 15 | namespace UnifiTest.Liasons; 16 | 17 | [TestClass] 18 | public class SourceLiasonTest 19 | { 20 | [TestMethod] 21 | public async Task FetchAllAsyncTest() 22 | { 23 | var tests = new[] { 24 | new { 25 | Q = new SlugMapping { MACAddress = BasicMACAddress, Slug = BasicSlug }, 26 | Expected = new { MACAddress = BasicMACAddress, State = BasicState } 27 | }, 28 | }; 29 | 30 | foreach (var test in tests) 31 | { 32 | var logger = new Mock>(); 33 | var sourceDAO = new Mock(); 34 | var opts = Options.Create(new SourceOpts()); 35 | var sharedOpts = Options.Create(new SharedOpts 36 | { 37 | Resources = new[] { test.Q }.ToList(), 38 | }); 39 | 40 | sourceDAO.Setup(x => x.FetchOneAsync(test.Q, It.IsAny())) 41 | .ReturnsAsync(new Unifi.Models.Source.Response 42 | { 43 | MACAddress = test.Expected.MACAddress, 44 | State = test.Expected.State, 45 | }); 46 | 47 | var sourceLiason = new SourceLiason(logger.Object, sourceDAO.Object, opts, sharedOpts); 48 | await foreach (var result in sourceLiason.ReceiveDataAsync()) 49 | { 50 | Assert.AreEqual(test.Expected.MACAddress, result.Mac); 51 | Assert.AreEqual(test.Expected.State, result.State); 52 | } 53 | } 54 | } 55 | 56 | private static string BasicSlug = "totallyaslug"; 57 | private static bool BasicState = true; 58 | private static string BasicMACAddress = "AA:BB:CC:DD:EE:FF"; 59 | } 60 | -------------------------------------------------------------------------------- /UnifiTest/UnifiTest.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | false 6 | $(RestoreSources);../vendor;https://api.nuget.org/v3/index.json 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /UnifiTest/coverlet.runsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Unifi.DataAccess.*,Unifi.Liasons.* 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /vendor/.retain: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mannkind/unifi2mqtt/37144e6bad1f610e03b3a70248724dac8a09be2b/vendor/.retain --------------------------------------------------------------------------------