├── .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 | [](https://github.com/mannkind/unifi2mqtt/blob/main/LICENSE.md)
5 | [](https://github.com/mannkind/unifi2mqtt/actions)
6 | [](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
--------------------------------------------------------------------------------