├── .editorconfig ├── .eslintrc.json ├── .github ├── ISSUE_TEMPLATE │ ├── report-a-bug.md │ └── request-a-feature.md ├── dependabot.yml └── workflows │ ├── CI.yml │ ├── CodeQL.yml │ ├── Sonar.yml │ └── release.yml ├── .gitignore ├── App.Tests ├── APFormTests.ts ├── AccessPointTests.ts ├── ActionsTests.ts ├── AppTests.ts ├── AppViewModelTests.ts ├── BackgroundFormTests.ts ├── ColorConverterTests.ts ├── ColorTests.ts ├── CompareTests.ts ├── DataPointTests.ts ├── DebugPanelTests.ts ├── FactoryTests.ts ├── FileFormTests.ts ├── FileLoaderTests.ts ├── HeaderMenuTests.ts ├── MainAreaTests.ts ├── MockFactory.ts ├── ModeFormTests.ts ├── PointTests.ts ├── ReadingTests.ts ├── RenderFactoryTests.ts ├── RendererTests.ts ├── SignalServiceTests.ts ├── SignalTests.ts ├── StatusTests.ts ├── TriangulationTests.ts ├── WiFiIconTests.ts ├── WiFiStatusTests.ts └── vite.config.js ├── App ├── AccessPoint.ts ├── AccessPointGrouping.ts ├── AppViewModel.ts ├── Color.ts ├── ColorConverter.ts ├── Compare.ts ├── Factory.ts ├── FileLoader.ts ├── Message.ts ├── Mode.ts ├── Point.ts ├── Reading.ts ├── RenderFactory.ts ├── Renderer.ts ├── Shaders.ts ├── SharedState.ts ├── Signal.ts ├── SignalService.ts ├── Triangulation.ts ├── actions.vue ├── ap-form.vue ├── app.css ├── app.ts ├── app.vue ├── background-form.vue ├── data-point.vue ├── debug-panel.vue ├── file-form.vue ├── header-menu.vue ├── index.html ├── main-area.vue ├── mode-form.vue ├── status.vue ├── wifi-icon.vue └── wifi-status.vue ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Core.Tests ├── AppHelpersTests.cs ├── AppTests.cs ├── CommandServiceTests.cs ├── Core.Tests.csproj ├── LogHelpersTests.cs ├── SignalHubTests.cs └── SignalServiceTests.cs ├── Core ├── App.cs ├── AppHelpers.cs ├── BrowserLauncher.cs ├── CommandService.cs ├── Core.csproj ├── Frequency.cs ├── IBrowserLauncher.cs ├── ICommandService.cs ├── ISignalHub.cs ├── ISignalParser.cs ├── ISignalReader.cs ├── LogHelpers.cs ├── Message.cs ├── PosixSignalReader.cs ├── Signal.cs ├── SignalHub.cs ├── SignalService.cs └── appsettings.json ├── LICENSE ├── Linux.Tests ├── Linux.Tests.csproj ├── LinuxBrowserLauncherTests.cs ├── LinuxSignalParserTests.cs ├── LinuxSignalReaderTests.cs ├── ProgramTests.cs └── iwlist-output.txt ├── Linux ├── Linux.csproj ├── LinuxBrowserLauncher.cs ├── LinuxSignalParser.cs ├── LinuxSignalReader.cs ├── Patterns.cs └── Program.cs ├── Mac.Tests ├── Mac.Tests.csproj ├── MacBrowserLauncherTests.cs ├── MacSignalParserTests.cs ├── MacSignalReaderTests.cs ├── ProgramTests.cs └── system_profiler-output.txt ├── Mac ├── Mac.csproj ├── MacBrowserLauncher.cs ├── MacSignalParser.cs ├── MacSignalReader.cs └── Program.cs ├── README.md ├── SECURITY.md ├── WiFiSurveyor.sln ├── Windows.Tests ├── ProgramTests.cs ├── Windows.Tests.csproj ├── WindowsBrowserLauncherTests.cs ├── WindowsSignalParserTests.cs └── WindowsSignalReaderTests.cs ├── Windows ├── IWiFiAdapter.cs ├── IWiFiAvailableNetwork.cs ├── IWiFiNetworkReport.cs ├── Program.cs ├── Windows.csproj ├── WindowsAdapter.cs ├── WindowsAvailableNetwork.cs ├── WindowsBrowserLauncher.cs ├── WindowsNetworkReport.cs ├── WindowsSignalParser.cs └── WindowsSignalReader.cs ├── package.json ├── release ├── sonar-project.properties ├── tsconfig.json ├── vite.config.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | indent_style = tab 4 | trim_trailing_whitespace = true 5 | insert_final_newline = false 6 | max_line_length = off 7 | 8 | [{*.yml,*.yaml}] 9 | indent_style = space 10 | indent_size = 2 11 | 12 | [*.cs] 13 | csharp_prefer_braces = true 14 | csharp_style_expression_bodied_methods = true 15 | csharp_style_expression_bodied_constructors = true 16 | csharp_style_expression_bodied_operators = true 17 | csharp_style_expression_bodied_properties = true 18 | csharp_style_expression_bodied_indexers = true 19 | csharp_style_expression_bodied_accessors = true 20 | csharp_style_expression_bodied_lambdas = true 21 | csharp_style_expression_bodied_local_functions = true 22 | csharp_style_pattern_local_over_anonymous_function = false 23 | csharp_style_namespace_declarations = file_scoped:warning 24 | dotnet_diagnostic.S101.severity = suggestion 25 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true 4 | }, 5 | "extends": [ 6 | "eslint:recommended", 7 | "plugin:vue/recommended", 8 | "plugin:@typescript-eslint/recommended" 9 | ], 10 | "ignorePatterns": [ 11 | "dist/", 12 | "publish/", 13 | "Core/" 14 | ], 15 | "parserOptions": { 16 | "ecmaVersion": 6, 17 | "parser": "@typescript-eslint/parser", 18 | "sourceType": "module" 19 | }, 20 | "plugins": [ 21 | "vue", 22 | "@typescript-eslint" 23 | ], 24 | "rules": { 25 | "indent": [ 26 | "warn", 27 | "tab", 28 | { 29 | "SwitchCase": 1 30 | } 31 | ], 32 | "quotes": [ 33 | "warn", 34 | "double" 35 | ], 36 | "@typescript-eslint/no-explicit-any": [ 37 | "off" 38 | ], 39 | "@typescript-eslint/no-inferrable-types": [ 40 | "off" 41 | ], 42 | "@typescript-eslint/semi": [ 43 | "warn", 44 | "always" 45 | ], 46 | "vue/component-definition-name-casing": [ 47 | "off" 48 | ], 49 | "vue/html-indent": [ 50 | "warn", 51 | "tab" 52 | ], 53 | "vue/html-self-closing": [ 54 | "warn", 55 | { 56 | "html": { 57 | "normal": "never", 58 | "void": "always" 59 | } 60 | } 61 | ], 62 | "vue/max-attributes-per-line": [ 63 | "off" 64 | ], 65 | "vue/multi-word-component-names": [ 66 | "off" 67 | ], 68 | "vue/prop-name-casing": [ 69 | "off" 70 | ] 71 | }, 72 | "overrides": [ 73 | { 74 | "files": [ 75 | "*.vue" 76 | ], 77 | "parser": "vue-eslint-parser" 78 | } 79 | ] 80 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/report-a-bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Report a Bug 3 | about: Describe a problem 4 | 5 | --- 6 | 7 | **Steps to reproduce** 8 | 1. login as X 9 | 1. click X 10 | 1. etc. 11 | 12 | **Expected behavior** 13 | - this should happen 14 | - and this 15 | - etc. 16 | 17 | **Observed behavior** 18 | - this happened instead 19 | - field X got set wrong 20 | - etc. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/request-a-feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Request a Feature 3 | about: Suggest new functionality for the app 4 | 5 | --- 6 | 7 | As a ___type_of_user___, I want to ___accomplish_this_goal___, so that ___reason_why_this_would_help___. 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | 13 | - package-ecosystem: "nuget" # See documentation for possible values 14 | directory: "/" # Location of package manifests 15 | schedule: 16 | interval: "daily" 17 | -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [ push, pull_request ] 3 | 4 | defaults: 5 | run: 6 | shell: bash 7 | 8 | jobs: 9 | Server: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Setup .NET 16 | uses: actions/setup-dotnet@v4 17 | with: 18 | dotnet-version: 9.0.x 19 | 20 | - name: Install dependencies 21 | run: dotnet restore 22 | 23 | - name: Build 24 | run: dotnet build --no-restore 25 | 26 | - name: Test 27 | run: dotnet test --no-build --verbosity normal 28 | 29 | App: 30 | runs-on: ubuntu-latest 31 | 32 | steps: 33 | - uses: actions/checkout@v4 34 | 35 | - name: Setup Node.JS 36 | uses: actions/setup-node@v4 37 | with: 38 | node-version: 22 39 | 40 | - name: Install dependencies 41 | run: yarn install 42 | 43 | - name: Build 44 | run: yarn build 45 | 46 | - name: Test 47 | run: yarn test -------------------------------------------------------------------------------- /.github/workflows/CodeQL.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '45 7 * * *' 22 | 23 | defaults: 24 | run: 25 | shell: bash 26 | 27 | jobs: 28 | analyze: 29 | name: Analyze 30 | runs-on: ubuntu-latest 31 | 32 | permissions: 33 | actions: read 34 | contents: read 35 | security-events: write 36 | 37 | strategy: 38 | fail-fast: false 39 | matrix: 40 | language: [ 'csharp', 'javascript' ] 41 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 42 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 43 | 44 | steps: 45 | - name: Checkout repository 46 | uses: actions/checkout@v4 47 | 48 | # Initializes the CodeQL tools for scanning. 49 | - name: Initialize CodeQL 50 | uses: github/codeql-action/init@v3 51 | with: 52 | languages: ${{ matrix.language }} 53 | # If you wish to specify custom queries, you can do so here or in a config file. 54 | # By default, queries listed here will override any specified in a config file. 55 | # Prefix the list here with "+" to use these queries and those in the config file. 56 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 57 | 58 | - name: Setup .NET 59 | uses: actions/setup-dotnet@v4 60 | with: 61 | dotnet-version: 9.0.x 62 | 63 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 64 | # If this step fails, then you should remove it and run the build manually (see below) 65 | - name: Autobuild 66 | uses: github/codeql-action/autobuild@v3 67 | 68 | # ℹ️ Command-line programs to run using the OS shell. 69 | # 📚 https://git.io/JvXDl 70 | 71 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 72 | # and modify them (or add more) to build your code if your project 73 | # uses a compiled language 74 | 75 | #- run: | 76 | # make bootstrap 77 | # make release 78 | 79 | - name: Perform CodeQL Analysis 80 | uses: github/codeql-action/analyze@v3 81 | -------------------------------------------------------------------------------- /.github/workflows/Sonar.yml: -------------------------------------------------------------------------------- 1 | name: Sonar 2 | on: push 3 | 4 | defaults: 5 | run: 6 | shell: bash 7 | 8 | jobs: 9 | Code-Quality: 10 | runs-on: ubuntu-latest 11 | if: github.actor != 'dependabot[bot]' 12 | 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | 19 | - name: Setup .NET 20 | uses: actions/setup-dotnet@v4 21 | with: 22 | dotnet-version: 9.0.x 23 | 24 | - name: Install Java 25 | uses: actions/setup-java@v4 26 | with: 27 | distribution: microsoft 28 | java-version: 21 29 | 30 | - name: Install Sonar Scanner 31 | run: dotnet tool install --global dotnet-sonarscanner 32 | 33 | - name: Install dependencies 34 | run: dotnet restore 35 | 36 | - name: Start Sonar Analysis 37 | run: dotnet-sonarscanner begin -d:sonar.host.url="https://sonarcloud.io" -d:sonar.login="${{ secrets.SONAR_TOKEN }}" -o:"ecoapm" -k:"ecoAPM_WiFiSurveyor-Server" -d:sonar.cs.vstest.reportsPaths="*.Tests/**/results.trx" -d:sonar.cs.opencover.reportsPaths="*.Tests/**/coverage.opencover.xml" -d:sonar.exclusions="App*/**" 38 | 39 | - name: Build 40 | run: dotnet build --no-restore 41 | env: 42 | SONAR_DOTNET_ENABLE_CONCURRENT_EXECUTION: true 43 | 44 | - name: Test 45 | run: dotnet test --no-build --logger "trx;LogFileName=results.trx" --collect:"XPlat Code Coverage" -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover 46 | 47 | - name: Remove client-side Sonar properties 48 | run: rm sonar-project.properties 49 | 50 | - name: Finish Sonar Analysis 51 | run: dotnet-sonarscanner end -d:sonar.login="${{ secrets.SONAR_TOKEN }}" 52 | env: 53 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 54 | 55 | App-Code-Quality: 56 | runs-on: ubuntu-latest 57 | if: github.actor != 'dependabot[bot]' 58 | 59 | steps: 60 | - name: Checkout code 61 | uses: actions/checkout@v4 62 | with: 63 | fetch-depth: 0 64 | 65 | - name: Install Node.js 66 | uses: actions/setup-node@v4 67 | with: 68 | node-version: 18 69 | 70 | - name: Install c8 71 | run: yarn global add c8 72 | 73 | - name: Install dependencies 74 | run: yarn 75 | 76 | - name: Build 77 | run: yarn build 78 | 79 | - name: Get code coverage 80 | run: c8 -r lcov yarn test -s 81 | 82 | - name: SonarCloud Analysis 83 | uses: sonarsource/sonarcloud-github-action@master 84 | env: 85 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 86 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 87 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | 7 | defaults: 8 | run: 9 | shell: bash 10 | 11 | jobs: 12 | Publish: 13 | runs-on: windows-latest 14 | 15 | permissions: 16 | contents: write 17 | 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | with: 22 | ref: ${{ github.ref }} 23 | 24 | - name: Setup .NET 25 | uses: actions/setup-dotnet@v4 26 | with: 27 | dotnet-version: 9.0.x 28 | 29 | - name: Run server tests 30 | run: dotnet test 31 | 32 | - name: Install app dependencies 33 | run: yarn install 34 | 35 | - name: Run app tests 36 | run: yarn test 37 | 38 | - name: Build app 39 | run: yarn build 40 | 41 | - name: Publish Linux 42 | run: dotnet publish Linux --sc -c Release -r linux-x64 -o publish/Linux 43 | 44 | - name: Publish Mac 45 | run: dotnet publish Mac --sc -c Release -r osx-x64 -o publish/Mac 46 | 47 | - name: Publish Windows 48 | run: dotnet publish Windows --sc -c Release -r win-x64 -o publish/Windows 49 | 50 | - name: Package Linux release 51 | working-directory: publish/Linux 52 | run: tar -Jcvf ../WiFiSurveyor.$(echo ${{ github.ref }} | sed 's/refs\/tags\///').Linux.tar.xz * 53 | env: 54 | XZ_OPT: -9 55 | 56 | - name: Package Mac release 57 | working-directory: publish/Mac 58 | run: tar -Jcvf ../WiFiSurveyor.$(echo ${{ github.ref }} | sed 's/refs\/tags\///').Mac.tar.xz * 59 | env: 60 | XZ_OPT: -9 61 | 62 | - name: Package Windows release 63 | shell: pwsh 64 | working-directory: publish/Windows 65 | run: Compress-Archive -Path * -DestinationPath ../WiFiSurveyor.$(echo ${{ github.ref }} | sed 's/refs\/tags\///').Windows.zip 66 | 67 | - name: Create release 68 | uses: softprops/action-gh-release@v1 69 | with: 70 | files: | 71 | publish/WiFiSurveyor.*.Linux.tar.xz 72 | publish/WiFiSurveyor.*.Mac.tar.xz 73 | publish/WiFiSurveyor.*.Windows.zip 74 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Aa][Rr][Mm]/ 27 | [Aa][Rr][Mm]64/ 28 | bld/ 29 | [Bb]in/ 30 | [Oo]bj/ 31 | [Ll]og/ 32 | [Ll]ogs/ 33 | [Dd]ist/ 34 | 35 | # Visual Studio 2015/2017 cache/options directory 36 | .vs/ 37 | .vscode/ 38 | .idea/ 39 | 40 | # Uncomment if you have tasks that create the project's static files in wwwroot 41 | wwwroot/ 42 | 43 | # Visual Studio 2017 auto generated files 44 | Generated\ Files/ 45 | 46 | # MSTest test Results 47 | [Tt]est[Rr]esult*/ 48 | [Bb]uild[Ll]og.* 49 | 50 | # NUnit 51 | *.VisualState.xml 52 | TestResult.xml 53 | nunit-*.xml 54 | 55 | # Build Results of an ATL Project 56 | [Dd]ebugPS/ 57 | [Rr]eleasePS/ 58 | dlldata.c 59 | 60 | # Benchmark Results 61 | BenchmarkDotNet.Artifacts/ 62 | 63 | # .NET Core 64 | project.lock.json 65 | project.fragment.lock.json 66 | artifacts/ 67 | 68 | # StyleCop 69 | StyleCopReport.xml 70 | 71 | # Files built by Visual Studio 72 | *_i.c 73 | *_p.c 74 | *_h.h 75 | *.ilk 76 | *.meta 77 | *.obj 78 | *.iobj 79 | *.pch 80 | *.pdb 81 | *.ipdb 82 | *.pgc 83 | *.pgd 84 | *.rsp 85 | *.sbr 86 | *.tlb 87 | *.tli 88 | *.tlh 89 | *.tmp 90 | *.tmp_proj 91 | *_wpftmp.csproj 92 | *.log 93 | *.vspscc 94 | *.vssscc 95 | .builds 96 | *.pidb 97 | *.svclog 98 | *.scc 99 | 100 | # Chutzpah Test files 101 | _Chutzpah* 102 | 103 | # Visual C++ cache files 104 | ipch/ 105 | *.aps 106 | *.ncb 107 | *.opendb 108 | *.opensdf 109 | *.sdf 110 | *.cachefile 111 | *.VC.db 112 | *.VC.VC.opendb 113 | 114 | # Visual Studio profiler 115 | *.psess 116 | *.vsp 117 | *.vspx 118 | *.sap 119 | 120 | # Visual Studio Trace Files 121 | *.e2e 122 | 123 | # TFS 2012 Local Workspace 124 | $tf/ 125 | 126 | # Guidance Automation Toolkit 127 | *.gpState 128 | 129 | # ReSharper is a .NET coding add-in 130 | _ReSharper*/ 131 | *.[Rr]e[Ss]harper 132 | *.DotSettings.user 133 | 134 | # TeamCity is a build add-in 135 | _TeamCity* 136 | 137 | # DotCover is a Code Coverage Tool 138 | *.dotCover 139 | 140 | # AxoCover is a Code Coverage Tool 141 | .axoCover/* 142 | !.axoCover/settings.json 143 | 144 | # Visual Studio code coverage results 145 | *.coverage 146 | *.coveragexml 147 | 148 | # NCrunch 149 | _NCrunch_* 150 | .*crunch*.local.xml 151 | nCrunchTemp_* 152 | 153 | # MightyMoose 154 | *.mm.* 155 | AutoTest.Net/ 156 | 157 | # Web workbench (sass) 158 | .sass-cache/ 159 | 160 | # Installshield output folder 161 | [Ee]xpress/ 162 | 163 | # DocProject is a documentation generator add-in 164 | DocProject/buildhelp/ 165 | DocProject/Help/*.HxT 166 | DocProject/Help/*.HxC 167 | DocProject/Help/*.hhc 168 | DocProject/Help/*.hhk 169 | DocProject/Help/*.hhp 170 | DocProject/Help/Html2 171 | DocProject/Help/html 172 | 173 | # Click-Once directory 174 | publish/ 175 | 176 | # Publish Web Output 177 | *.[Pp]ublish.xml 178 | *.azurePubxml 179 | # Note: Comment the next line if you want to checkin your web deploy settings, 180 | # but database connection strings (with potential passwords) will be unencrypted 181 | *.pubxml 182 | *.publishproj 183 | 184 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 185 | # checkin your Azure Web App publish settings, but sensitive information contained 186 | # in these scripts will be unencrypted 187 | PublishScripts/ 188 | 189 | # NuGet Packages 190 | *.nupkg 191 | # NuGet Symbol Packages 192 | *.snupkg 193 | # The packages folder can be ignored because of Package Restore 194 | **/[Pp]ackages/* 195 | # except build/, which is used as an MSBuild target. 196 | !**/[Pp]ackages/build/ 197 | # Uncomment if necessary however generally it will be regenerated when needed 198 | #!**/[Pp]ackages/repositories.config 199 | # NuGet v3's project.json files produces more ignorable files 200 | *.nuget.props 201 | *.nuget.targets 202 | 203 | # Microsoft Azure Build Output 204 | csx/ 205 | *.build.csdef 206 | 207 | # Microsoft Azure Emulator 208 | ecf/ 209 | rcf/ 210 | 211 | # Windows Store app package directories and files 212 | AppPackages/ 213 | BundleArtifacts/ 214 | Package.StoreAssociation.xml 215 | _pkginfo.txt 216 | *.appx 217 | *.appxbundle 218 | *.appxupload 219 | 220 | # Visual Studio cache files 221 | # files ending in .cache can be ignored 222 | *.[Cc]ache 223 | # but keep track of directories ending in .cache 224 | !?*.[Cc]ache/ 225 | 226 | # Others 227 | ClientBin/ 228 | ~$* 229 | *~ 230 | *.dbmdl 231 | *.dbproj.schemaview 232 | *.jfm 233 | *.pfx 234 | *.publishsettings 235 | orleans.codegen.cs 236 | 237 | # Including strong name files can present a security risk 238 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 239 | #*.snk 240 | 241 | # Since there are multiple workflows, uncomment next line to ignore bower_components 242 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 243 | #bower_components/ 244 | 245 | # RIA/Silverlight projects 246 | Generated_Code/ 247 | 248 | # Backup & report files from converting an old project file 249 | # to a newer Visual Studio version. Backup files are not needed, 250 | # because we have git ;-) 251 | _UpgradeReport_Files/ 252 | Backup*/ 253 | UpgradeLog*.XML 254 | UpgradeLog*.htm 255 | ServiceFabricBackup/ 256 | *.rptproj.bak 257 | 258 | # SQL Server files 259 | *.mdf 260 | *.ldf 261 | *.ndf 262 | 263 | # Business Intelligence projects 264 | *.rdl.data 265 | *.bim.layout 266 | *.bim_*.settings 267 | *.rptproj.rsuser 268 | *- [Bb]ackup.rdl 269 | *- [Bb]ackup ([0-9]).rdl 270 | *- [Bb]ackup ([0-9][0-9]).rdl 271 | 272 | # Microsoft Fakes 273 | FakesAssemblies/ 274 | 275 | # GhostDoc plugin setting file 276 | *.GhostDoc.xml 277 | 278 | # Node.js Tools for Visual Studio 279 | .ntvs_analysis.dat 280 | node_modules/ 281 | 282 | # Visual Studio 6 build log 283 | *.plg 284 | 285 | # Visual Studio 6 workspace options file 286 | *.opt 287 | 288 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 289 | *.vbw 290 | 291 | # Visual Studio LightSwitch build output 292 | **/*.HTMLClient/GeneratedArtifacts 293 | **/*.DesktopClient/GeneratedArtifacts 294 | **/*.DesktopClient/ModelManifest.xml 295 | **/*.Server/GeneratedArtifacts 296 | **/*.Server/ModelManifest.xml 297 | _Pvt_Extensions 298 | 299 | # Paket dependency manager 300 | .paket/paket.exe 301 | paket-files/ 302 | 303 | # FAKE - F# Make 304 | .fake/ 305 | 306 | # CodeRush personal settings 307 | .cr/personal 308 | 309 | # Python Tools for Visual Studio (PTVS) 310 | __pycache__/ 311 | *.pyc 312 | 313 | # Cake - Uncomment if you are using it 314 | # tools/** 315 | # !tools/packages.config 316 | 317 | # Tabs Studio 318 | *.tss 319 | 320 | # Telerik's JustMock configuration file 321 | *.jmconfig 322 | 323 | # BizTalk build output 324 | *.btp.cs 325 | *.btm.cs 326 | *.odx.cs 327 | *.xsd.cs 328 | 329 | # OpenCover UI analysis results 330 | OpenCover/ 331 | 332 | # Azure Stream Analytics local run output 333 | ASALocalRun/ 334 | 335 | # MSBuild Binary and Structured Log 336 | *.binlog 337 | 338 | # NVidia Nsight GPU debugger configuration file 339 | *.nvuser 340 | 341 | # MFractors (Xamarin productivity tool) working folder 342 | .mfractor/ 343 | 344 | # Local History for Visual Studio 345 | .localhistory/ 346 | 347 | # BeatPulse healthcheck temp database 348 | healthchecksdb 349 | 350 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 351 | MigrationBackup/ 352 | 353 | # Ionide (cross platform F# VS Code tools) working folder 354 | .ionide/ -------------------------------------------------------------------------------- /App.Tests/APFormTests.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestSuite } from "xunit.ts"; 2 | import APForm from "../App/ap-form.vue"; 3 | import { shallowMount as mount } from "@vue/test-utils"; 4 | import Reading from "../App/Reading"; 5 | import Point from "../App/Point"; 6 | import Signal from "../App/Signal"; 7 | import AppViewModel from "../App/AppViewModel"; 8 | 9 | export default class APFormTests extends TestSuite { 10 | 11 | private static readonly signals = [ 12 | new Signal("mac2", "ssid2", 2, 1, -30), 13 | new Signal("mac5", "ssid2", 2, 11, -50), 14 | new Signal("mac3", "ssid2", 5, 36, -30), 15 | new Signal("mac4", "ssid2", 5, 157, -60), 16 | new Signal("mac1", "ssid1", 2, 1, -40), 17 | new Signal("mac7", "ssid1", 5, 36, -70), 18 | new Signal("mac6", "ssid1", 5, 157, -90), 19 | new Signal("mac8", "ssid1", 2, 11, -80), 20 | ]; 21 | 22 | @Test() 23 | async accessPointsAreSortedWhenGroupedBySSIDAndFrequency() { 24 | //arrange 25 | const state = new AppViewModel(); 26 | state.current = new Reading(0, new Point(0, 0), APFormTests.signals); 27 | const component = mount(APForm, { data: () => ({ state: state }) }); 28 | 29 | //act 30 | const options = component.findAll("option").map(o => o.text()); 31 | 32 | //assert 33 | this.assert.contains("ssid1", options); 34 | this.assert.contains("ssid2", options); 35 | } 36 | 37 | @Test() 38 | async accessPointsAreSortedWhenGroupedBySSID() { 39 | //arrange 40 | const state = new AppViewModel(); 41 | state.current = new Reading(0, new Point(0, 0), APFormTests.signals); 42 | const component = mount(APForm, { data: () => ({ state: state }) }); 43 | 44 | //act 45 | component.get("#group-by-frequency").setChecked(false); 46 | await component.vm.$nextTick(); 47 | 48 | //assert 49 | const options = component.findAll("option").map(o => o.text()); 50 | this.assert.contains("ssid1 @ 2 GHz", options); 51 | this.assert.contains("ssid1 @ 5 GHz", options); 52 | this.assert.contains("ssid2 @ 2 GHz", options); 53 | this.assert.contains("ssid2 @ 5 GHz", options); 54 | } 55 | 56 | @Test() 57 | async accessPointsAreSortedWhenNotGrouped() { 58 | //arrange 59 | const state = new AppViewModel(); 60 | state.current = new Reading(0, new Point(0, 0), APFormTests.signals); 61 | const component = mount(APForm, { data: () => ({ state: state }) }); 62 | 63 | //act 64 | component.get("#group-by-ssid").setChecked(false); 65 | await component.vm.$nextTick(); 66 | 67 | //assert 68 | const options = component.findAll("option").map(o => o.text()); 69 | this.assert.contains("ssid1 @ 2 GHz (mac1)", options); 70 | this.assert.contains("ssid1 @ 2 GHz (mac8)", options); 71 | this.assert.contains("ssid1 @ 5 GHz (mac6)", options); 72 | this.assert.contains("ssid1 @ 5 GHz (mac7)", options); 73 | this.assert.contains("ssid2 @ 2 GHz (mac2)", options); 74 | this.assert.contains("ssid2 @ 2 GHz (mac5)", options); 75 | this.assert.contains("ssid2 @ 5 GHz (mac3)", options); 76 | this.assert.contains("ssid2 @ 5 GHz (mac4)", options); 77 | } 78 | 79 | @Test() 80 | async selectingAccessPointSetsState() { 81 | //arrange 82 | const state = new AppViewModel(); 83 | const component = mount(APForm, { data: () => ({ state: state }) }); 84 | 85 | //act 86 | const option = component.get("option:first-child"); 87 | option.setSelected(); 88 | 89 | //assert 90 | this.assert.stringContains(state.selected?.ssid ?? "", option.text()); 91 | } 92 | 93 | @Test() 94 | async groupBySSIDEnablesGroupByFrequency() { 95 | //arrange 96 | const state = new AppViewModel(); 97 | const component = mount(APForm, { data: () => ({ state: state }) }); 98 | 99 | //act 100 | component.get("#group-by-ssid").setChecked(true); 101 | await component.vm.$nextTick(); 102 | 103 | //assert 104 | const checkbox = component.get("#group-by-frequency"); 105 | this.assert.undefined(checkbox.attributes("disabled")); 106 | } 107 | 108 | @Test() 109 | async disablingGroupBySSIDDisablesAndUnchecksGroupByFrequency() { 110 | //arrange 111 | const state = new AppViewModel(); 112 | const component = mount(APForm, { data: () => ({ state: state }) }); 113 | 114 | //act 115 | component.get("#group-by-ssid").setChecked(false); 116 | await component.vm.$nextTick(); 117 | 118 | //assert 119 | const checkbox = component.get("#group-by-frequency"); 120 | this.assert.notNull(checkbox.attributes("disabled")); 121 | this.assert.undefined(checkbox.attributes("checked")); 122 | } 123 | 124 | @Test() 125 | async combinesDuplicateLabels() { 126 | //arrange 127 | const state = new AppViewModel(); 128 | state.current = new Reading(0, new Point(0, 0), APFormTests.signals); 129 | state.readings = [ 130 | new Reading(1, new Point(3, 4), APFormTests.signals), 131 | new Reading(2, new Point(5, 6), APFormTests.signals) 132 | ]; 133 | const component = mount(APForm, { data: () => ({ state: state }) }); 134 | 135 | //act 136 | const signals = component.vm.access_points; 137 | 138 | //assert 139 | this.assert.equal(2, signals.length); 140 | } 141 | } -------------------------------------------------------------------------------- /App.Tests/AccessPointTests.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestSuite } from "xunit.ts"; 2 | import AccessPoint from "../App/AccessPoint"; 3 | 4 | export default class AccessPointTests extends TestSuite { 5 | @Test() 6 | async canCreateAccessPoint() { 7 | //arrange 8 | const ssid = "test", frequency = 2, mac = "ab:cd:ef"; 9 | 10 | //act 11 | const ap = new AccessPoint(ssid, frequency, mac); 12 | 13 | //assert 14 | this.assert.equal("test", ap.ssid); 15 | this.assert.equal(2, ap.frequency); 16 | this.assert.equal("ab:cd:ef", ap.mac); 17 | } 18 | 19 | @Test() 20 | async canCreateAggregateAP() { 21 | //arrange 22 | const ssid = "test"; 23 | 24 | //act 25 | const ap = new AccessPoint(ssid); 26 | 27 | //assert 28 | this.assert.equal("test", ap.ssid); 29 | this.assert.null(ap.frequency); 30 | this.assert.null(ap.mac); 31 | } 32 | 33 | @Test() 34 | async canGetLabel() { 35 | //arrange 36 | const ap = new AccessPoint("test", 2, "ab:cd:ef"); 37 | 38 | //act 39 | const label = ap.label(); 40 | 41 | //assert 42 | this.assert.equal("test @ 2 GHz (ab:cd:ef)", label); 43 | } 44 | 45 | @Test() 46 | async canGetFrequencyGroupedLabel() { 47 | //arrange 48 | const ap = new AccessPoint("test", 2); 49 | 50 | //act 51 | const label = ap.label(); 52 | 53 | //assert 54 | this.assert.equal("test @ 2 GHz", label); 55 | } 56 | 57 | @Test() 58 | async canGetSSIDGroupedLabel() { 59 | //arrange 60 | const ap = new AccessPoint("test"); 61 | 62 | //act 63 | const label = ap.label(); 64 | 65 | //assert 66 | this.assert.equal("test", label); 67 | } 68 | 69 | @Test() 70 | async canCompareLabels() { 71 | //arrange 72 | const ap1 = new AccessPoint("test1"); 73 | const ap2 = new AccessPoint("test2"); 74 | const ap1again = new AccessPoint("test1"); 75 | 76 | //act 77 | const less = ap1.compareTo(ap2); 78 | const greater = ap2.compareTo(ap1); 79 | const equal = ap1.compareTo(ap1again); 80 | 81 | //assert 82 | this.assert.equal(-1, less); 83 | this.assert.equal(1, greater); 84 | this.assert.equal(0, equal); 85 | } 86 | } -------------------------------------------------------------------------------- /App.Tests/ActionsTests.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestSuite } from "xunit.ts"; 2 | import Actions from "../App/actions.vue"; 3 | import { shallowMount as mount } from "@vue/test-utils"; 4 | import AppViewModel from "../App/AppViewModel"; 5 | import Mockito from "ts-mockito"; 6 | import Reading from "../App/Reading"; 7 | 8 | export default class ActionsTests extends TestSuite { 9 | @Test() 10 | async undoIsEnabledWhenReadingsExist() { 11 | //arrange 12 | const state = new AppViewModel(); 13 | state.readings = [ Mockito.instance(Mockito.mock(Reading)) ]; 14 | const component = mount(Actions, { data: () => ({ state: state }) }); 15 | 16 | //act 17 | const button = component.get("#undo"); 18 | 19 | //assert 20 | this.assert.undefined(button.attributes("disabled")); 21 | } 22 | 23 | @Test() 24 | async undoIsDisabledWhenReadingsAreEmpty() { 25 | //arrange 26 | const state = new AppViewModel(); 27 | state.readings = []; 28 | const component = mount(Actions, { data: () => ({ state: state }) }); 29 | 30 | //act 31 | const button = component.get("#undo"); 32 | 33 | //assert 34 | this.assert.notNull(button.attributes("disabled")); 35 | } 36 | 37 | @Test() 38 | async clickingUndoRemovesLastReading() { 39 | //arrange 40 | const state = new AppViewModel(); 41 | state.readings = [ Mockito.instance(Mockito.mock(Reading)), Mockito.instance(Mockito.mock(Reading)) ]; 42 | const component = mount(Actions, { data: () => ({ state: state }) }); 43 | global.confirm = () => true; 44 | 45 | //act 46 | await component.get("#undo").trigger("click"); 47 | 48 | //assert 49 | this.assert.count(1, state.readings); 50 | } 51 | 52 | @Test() 53 | async cancellingUndoDoesNotRemoveLastReading() { 54 | //arrange 55 | const state = new AppViewModel(); 56 | state.readings = [ Mockito.instance(Mockito.mock(Reading)), Mockito.instance(Mockito.mock(Reading)) ]; 57 | const component = mount(Actions, { data: () => ({ state: state }) }); 58 | global.confirm = () => false; 59 | 60 | //act 61 | await component.get("#undo").trigger("click"); 62 | 63 | //assert 64 | this.assert.count(2, state.readings); 65 | } 66 | 67 | @Test() 68 | async resetIsEnabledWhenReadingsExist() { 69 | //arrange 70 | const state = new AppViewModel(); 71 | state.readings = [ Mockito.instance(Mockito.mock(Reading)) ]; 72 | const component = mount(Actions, { data: () => ({ state: state }) }); 73 | 74 | //act 75 | const button = component.get("#reset"); 76 | 77 | //assert 78 | this.assert.undefined(button.attributes("disabled")); 79 | } 80 | 81 | @Test() 82 | async resetIsDisabledWhenReadingsAreEmpty() { 83 | //arrange 84 | const state = new AppViewModel(); 85 | state.readings = []; 86 | const component = mount(Actions, { data: () => ({ state: state }) }); 87 | 88 | //act 89 | const button = component.get("#reset"); 90 | 91 | //assert 92 | this.assert.notNull(button.attributes("disabled")); 93 | } 94 | 95 | @Test() 96 | async clickingResetClearsReadings() { 97 | //arrange 98 | const state = new AppViewModel(); 99 | state.readings = [ Mockito.instance(Mockito.mock(Reading)) ]; 100 | const component = mount(Actions, { data: () => ({ state: state }) }); 101 | global.confirm = () => true; 102 | 103 | //act 104 | await component.get("#reset").trigger("click"); 105 | 106 | //assert 107 | this.assert.empty(state.readings); 108 | } 109 | 110 | @Test() 111 | async cancellingResetDoesNotClearReadings() { 112 | //arrange 113 | const state = new AppViewModel(); 114 | state.readings = [ Mockito.instance(Mockito.mock(Reading)) ]; 115 | const component = mount(Actions, { data: () => ({ state: state }) }); 116 | global.confirm = () => false; 117 | 118 | //act 119 | await component.get("#reset").trigger("click"); 120 | 121 | //assert 122 | this.assert.notEmpty(state.readings); 123 | } 124 | 125 | @Test() 126 | async clickingDebugSetsFlag() { 127 | //arrange 128 | const state = new AppViewModel(); 129 | state.readings = [ Mockito.instance(Mockito.mock(Reading)) ]; 130 | const component = mount(Actions, { data: () => ({ state: state }) }); 131 | 132 | //act 133 | const checkbox = component.get("#debug"); 134 | await checkbox.trigger("click"); 135 | await checkbox.trigger("change"); 136 | 137 | //assert 138 | this.assert.true(state.debug); 139 | } 140 | } -------------------------------------------------------------------------------- /App.Tests/AppTests.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestSuite } from "xunit.ts"; 2 | import App from "../App/app.vue"; 3 | import { shallowMount as mount } from "@vue/test-utils"; 4 | import Mockito from "ts-mockito"; 5 | import Renderer from "../App/Renderer"; 6 | import MockFactory from "./MockFactory"; 7 | import { Mode } from "../App/Mode"; 8 | import AccessPoint from "../App/AccessPoint"; 9 | 10 | export default class AppTests extends TestSuite { 11 | @Test() 12 | async canCreateApp() { 13 | //arrange 14 | const signal_service = MockFactory.signalService(); 15 | 16 | const canvas = MockFactory.canvas(); 17 | const renderer = new Renderer(Mockito.instance(canvas)); 18 | 19 | const file_loader = MockFactory.fileLoader(); 20 | 21 | //act 22 | const component = mount(App, { 23 | global: { 24 | provide: { 25 | signal_service: () => Mockito.instance(signal_service), 26 | renderer: () => renderer, 27 | file_loader: () => Mockito.instance(file_loader) 28 | } 29 | } 30 | }); 31 | 32 | //assert 33 | this.assert.notEmpty([...component.html()]); 34 | } 35 | 36 | @Test() 37 | async canGetStatusFromSignalService() { 38 | //arrange 39 | const signal_service = MockFactory.signalService(); 40 | Mockito.when(signal_service.status).thenReturn("test message"); 41 | 42 | const canvas = MockFactory.canvas(); 43 | const renderer = new Renderer(Mockito.instance(canvas)); 44 | 45 | const file_loader = MockFactory.fileLoader(); 46 | 47 | //act 48 | const component = mount(App, { 49 | global: { 50 | provide: { 51 | signal_service: () => Mockito.instance(signal_service), 52 | renderer: () => renderer, 53 | file_loader: () => Mockito.instance(file_loader) 54 | } 55 | } 56 | }); 57 | await component.vm.$nextTick(); 58 | 59 | //assert 60 | this.assert.stringContains("test message", component.html()); 61 | } 62 | 63 | @Test() 64 | async showsDefaultStatusWhenLoading() { 65 | //arrange 66 | const canvas = MockFactory.canvas(); 67 | const renderer = new Renderer(Mockito.instance(canvas)); 68 | 69 | //act 70 | const component = mount(App, { 71 | global: { 72 | provide: { 73 | signal_service: () => null, 74 | renderer: () => renderer, 75 | file_loader: () => null 76 | } 77 | } 78 | }); 79 | 80 | //assert 81 | this.assert.stringContains("loading", component.vm.connection_status); 82 | } 83 | 84 | @Test() 85 | async changingSelectedAPRerenders() { 86 | //arrange 87 | const renderer = Mockito.mock(); 88 | const component = mount(App, { 89 | global: { 90 | provide: { 91 | signal_service: () => null, 92 | renderer: () => Mockito.instance(renderer), 93 | file_loader: () => null 94 | } 95 | } 96 | }); 97 | 98 | //act 99 | component.vm.$data.selected = new AccessPoint("test"); 100 | await component.vm.$nextTick(); 101 | 102 | //assert 103 | Mockito.verify(renderer.render(Mockito.anything(), Mockito.anything(), Mockito.anything())).atLeast(1); 104 | } 105 | 106 | @Test() 107 | async changingModeRerenders() { 108 | //arrange 109 | const renderer = Mockito.mock(); 110 | const component = mount(App, { 111 | global: { 112 | provide: { 113 | signal_service: () => null, 114 | renderer: () => Mockito.instance(renderer), 115 | file_loader: () => null 116 | } 117 | } 118 | }); 119 | 120 | //act 121 | component.vm.$data.mode = Mode.SNR; 122 | await component.vm.$nextTick(); 123 | 124 | //assert 125 | Mockito.verify(renderer.render(Mode.SNR, Mockito.anything(), Mockito.anything())).atLeast(1); 126 | } 127 | } -------------------------------------------------------------------------------- /App.Tests/AppViewModelTests.ts: -------------------------------------------------------------------------------- 1 | import {Test, TestSuite} from "xunit.ts"; 2 | import AppViewModel from "../App/AppViewModel"; 3 | import Mockito from "ts-mockito"; 4 | import FileLoader from "../App/FileLoader"; 5 | 6 | export default class AppViewModelTests extends TestSuite { 7 | @Test() 8 | async canSetBackgroundFromFileLoaderData() { 9 | //arrange 10 | const file = Mockito.mock(); 11 | 12 | const loader = Mockito.mock(); 13 | Mockito.when(loader.loadData(file)).thenResolve("data:image/png;base64,abc123"); 14 | const vm = new AppViewModel(); 15 | vm.file_loader = Mockito.instance(loader); 16 | 17 | const files = Mockito.mock(); 18 | Mockito.when(files.length).thenReturn(1); 19 | Mockito.when(files.item(0)).thenReturn(file); 20 | 21 | //act 22 | await vm.setBackground(Mockito.instance(files)); 23 | 24 | //assert 25 | this.assert.equal("data:image/png;base64,abc123", vm.background); 26 | } 27 | 28 | private readonly data: object = { 29 | name: "Test", 30 | readings: [ 31 | { 32 | id: 1, 33 | location: {x: 123, y: 234}, 34 | signals: [ 35 | { 36 | ssid: "Wi-Fi", 37 | mac: "ab-cd-ef-12-34", 38 | frequency: 2, 39 | channel: 1, 40 | strength: -64 41 | } 42 | ] 43 | } 44 | ] 45 | }; 46 | 47 | @Test() 48 | async canLoadPreviouslySavedData() { 49 | //arrange 50 | const mockFile = Mockito.mock(); 51 | const file = Mockito.instance(mockFile); 52 | 53 | const loader = Mockito.mock(); 54 | Mockito.when(loader.loadJSON(file)).thenResolve(this.data); 55 | const vm = new AppViewModel(); 56 | vm.file_loader = Mockito.instance(loader); 57 | 58 | const files = Mockito.mock(); 59 | Mockito.when(files.length).thenReturn(1); 60 | Mockito.when(files.item(0)).thenReturn(file); 61 | 62 | //act 63 | await vm.load(Mockito.instance(files)); 64 | 65 | //assert 66 | this.assert.equal("Test", vm.name); 67 | this.assert.equal(123, vm.readings[0].location.x); 68 | this.assert.equal(234, vm.readings[0].location.y); 69 | this.assert.equal("Wi-Fi", vm.readings[0].signals[0].ssid); 70 | this.assert.equal("ab-cd-ef-12-34", vm.readings[0].signals[0].mac); 71 | this.assert.equal(2, vm.readings[0].signals[0].frequency); 72 | this.assert.equal(1, vm.readings[0].signals[0].channel); 73 | this.assert.equal(-64, vm.readings[0].signals[0].strength); 74 | } 75 | } -------------------------------------------------------------------------------- /App.Tests/BackgroundFormTests.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestSuite } from "xunit.ts"; 2 | import { shallowMount as mount } from "@vue/test-utils"; 3 | import BackgroundForm from "../App/background-form.vue"; 4 | import AppViewModel from "../App/AppViewModel"; 5 | 6 | export default class BackgroundFormTests extends TestSuite { 7 | @Test() 8 | async selectingBackgroundFileSetsValue() { 9 | //arrange 10 | const state = new AppViewModel(); 11 | state.background = "old.png"; 12 | const component = mount(BackgroundForm, { data: () => ({ state: state }) }); 13 | 14 | //act 15 | await component.get("#background-file").setValue(""); 16 | await component.get("#background-file").trigger("change"); 17 | 18 | //assert 19 | this.assert.equal("", state.background); 20 | } 21 | 22 | @Test() 23 | async pixelateCheckboxSetsValue() { 24 | //arrange 25 | const state = new AppViewModel(); 26 | const component = mount(BackgroundForm, { data: () => ({ state: state }) }); 27 | 28 | //act 29 | await component.get("#pixelate").trigger("click"); 30 | await component.get("#pixelate").trigger("change"); 31 | 32 | //assert 33 | this.assert.false(state.pixelated); 34 | } 35 | } -------------------------------------------------------------------------------- /App.Tests/ColorConverterTests.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestSuite } from "xunit.ts"; 2 | import ColorConverter from "../App/ColorConverter"; 3 | import Color from "../App/Color"; 4 | 5 | export default class ColorConverterTests extends TestSuite { 6 | @Test() 7 | async goodSignalIsGreen() { 8 | //arrange 9 | const dBm = -40; 10 | 11 | //act 12 | const color = ColorConverter.fromSignal(dBm); 13 | 14 | //assert 15 | this.assert.equal(new Color(0, 255, 0).toRGB(), color.toRGB()); 16 | } 17 | 18 | @Test() 19 | async mediumSignalIsYellow() { 20 | //arrange 21 | const dBm = -60; 22 | 23 | //act 24 | const color = ColorConverter.fromSignal(dBm); 25 | 26 | //assert 27 | this.assert.equal(new Color(255, 255, 0).toRGB(), color.toRGB()); 28 | } 29 | 30 | @Test() 31 | async BadSignalIsRed() { 32 | //arrange 33 | const dBm = -80; 34 | 35 | //act 36 | const color = ColorConverter.fromSignal(dBm); 37 | 38 | //assert 39 | this.assert.equal(new Color(255, 0, 0).toRGB(), color.toRGB()); 40 | } 41 | 42 | @Test() 43 | async goodSNRIsGreen() { 44 | //arrange 45 | const dB = 50; 46 | 47 | //act 48 | const color = ColorConverter.fromSNR(dB); 49 | 50 | //assert 51 | this.assert.equal(new Color(0, 255, 0).toRGB(), color.toRGB()); 52 | } 53 | 54 | @Test() 55 | async mediumSNRIsYellow() { 56 | //arrange 57 | const dB = 20; 58 | 59 | //act 60 | const color = ColorConverter.fromSNR(dB); 61 | 62 | //assert 63 | this.assert.equal(new Color(255, 255, 0).toRGB(), color.toRGB()); 64 | } 65 | 66 | @Test() 67 | async BadSNRIsRed() { 68 | //arrange 69 | const dB = 0; 70 | 71 | //act 72 | const color = ColorConverter.fromSNR(dB); 73 | 74 | //assert 75 | this.assert.equal(new Color(255, 0, 0).toRGB(), color.toRGB()); 76 | } 77 | } -------------------------------------------------------------------------------- /App.Tests/ColorTests.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestSuite } from "xunit.ts"; 2 | import Color from "../App/Color"; 3 | 4 | export default class ColorTests extends TestSuite { 5 | @Test() 6 | async canCreateColor() { 7 | //arrange 8 | const red = 12, green = 23, blue = 34, alpha = 45; 9 | 10 | //act 11 | const color = new Color(red, green, blue, alpha); 12 | 13 | //assert 14 | this.assert.equal(12, color.red); 15 | this.assert.equal(23, color.green); 16 | this.assert.equal(34, color.blue); 17 | this.assert.equal(45, color.alpha); 18 | } 19 | 20 | @Test() 21 | async componentsAreClamped() { 22 | //arrange 23 | const too_low = -1; 24 | const too_high = 256; 25 | 26 | //act 27 | const color = new Color(too_low, too_high, too_low, too_high); 28 | 29 | //assert 30 | this.assert.equal(0, color.red); 31 | this.assert.equal(255, color.green); 32 | this.assert.equal(0, color.blue); 33 | this.assert.equal(255, color.alpha); 34 | } 35 | 36 | @Test() 37 | async componentsAreFloored() { 38 | //arrange 39 | const low = 127.25; 40 | const mid = 127.5; 41 | const high = 127.75; 42 | 43 | //act 44 | const color = new Color(low, mid, high, mid); 45 | 46 | //assert 47 | this.assert.equal(127, color.red); 48 | this.assert.equal(127, color.green); 49 | this.assert.equal(127, color.blue); 50 | this.assert.equal(127, color.alpha); 51 | } 52 | 53 | @Test() 54 | async canConvertToRGB() { 55 | //arrange 56 | const color = new Color(12, 23, 34); 57 | 58 | //act 59 | const rgb_css_string = color.toRGB(); 60 | 61 | //assert 62 | this.assert.equal("rgb(12, 23, 34)", rgb_css_string); 63 | } 64 | 65 | @Test() 66 | async canConvertToRGBA() { 67 | //arrange 68 | const color = new Color(12, 23, 34, 63); 69 | 70 | //act 71 | const rgba_css_string = color.toRGBA(); 72 | 73 | //assert 74 | this.assert.equal("rgba(12, 23, 34, 0.25)", rgba_css_string); 75 | } 76 | 77 | @Test() 78 | async canConvertAlphaFloatCorrectly() { 79 | //act 80 | const clear = new Color(12, 23, 34, 0).toRGBA(); 81 | const half = new Color(12, 23, 34, 127).toRGBA(); 82 | const full = new Color(12, 23, 34, 255).toRGBA(); 83 | 84 | //assert 85 | this.assert.equal("rgba(12, 23, 34, 0)", clear); 86 | this.assert.equal("rgba(12, 23, 34, 0.5)", half); 87 | this.assert.equal("rgba(12, 23, 34, 1)", full); 88 | } 89 | 90 | @Test() 91 | async canConvertToHEX() { 92 | //arrange 93 | const color = new Color(127, 63, 255); 94 | 95 | //act 96 | const hex_css_string = color.toHEX(); 97 | 98 | //assert 99 | this.assert.equal("#7f3fff", hex_css_string); 100 | } 101 | 102 | @Test() 103 | async canConvertToHEXA() { 104 | //arrange 105 | const color = new Color(127, 63, 31); 106 | 107 | //act 108 | const hex_css_string = color.toHEXA(); 109 | 110 | //assert 111 | this.assert.equal("#7f3f1fff", hex_css_string); 112 | } 113 | 114 | @Test() 115 | async canConvertBlackToHEXA() { 116 | //arrange 117 | const color = new Color(0, 0, 0); 118 | 119 | //act 120 | const hex_css_string = color.toHEXA(); 121 | 122 | //assert 123 | this.assert.equal("#000000ff", hex_css_string); 124 | } 125 | } -------------------------------------------------------------------------------- /App.Tests/CompareTests.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestSuite } from "xunit.ts"; 2 | import Compare from "../App/Compare"; 3 | 4 | export default class CompareTests extends TestSuite { 5 | @Test() 6 | async canCompareNumbers() { 7 | //arrange 8 | const number1 = 1; 9 | const number2 = 2; 10 | const number1again = 1; 11 | 12 | //act 13 | const less = Compare.numbers(number1, number2); 14 | const greater = Compare.numbers(number2, number1); 15 | const equal = Compare.numbers(number1, number1again); 16 | 17 | //assert 18 | this.assert.equal(-1, less); 19 | this.assert.equal(1, greater); 20 | this.assert.equal(0, equal); 21 | } 22 | 23 | @Test() 24 | async canCompareStrings() { 25 | //arrange 26 | const string1 = "test1"; 27 | const string2 = "test2"; 28 | const string1again = "test1"; 29 | 30 | //act 31 | const less = Compare.strings(string1, string2); 32 | const greater = Compare.strings(string2, string1); 33 | const equal = Compare.strings(string1, string1again); 34 | 35 | //assert 36 | this.assert.equal(-1, less); 37 | this.assert.equal(1, greater); 38 | this.assert.equal(0, equal); 39 | } 40 | } -------------------------------------------------------------------------------- /App.Tests/DataPointTests.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestSuite } from "xunit.ts"; 2 | import DataPoint from "../App/data-point.vue"; 3 | import { shallowMount as mount } from "@vue/test-utils"; 4 | import Reading from "../App/Reading"; 5 | import Point from "../App/Point"; 6 | import Signal from "../App/Signal"; 7 | import AccessPoint from "../App/AccessPoint"; 8 | import AppViewModel from "../App/AppViewModel"; 9 | 10 | export default class DataPointTests extends TestSuite { 11 | @Test() 12 | async showsSignalOnDataPoint() { 13 | //arrange 14 | const reading = new Reading(1, new Point(0, 0), [ new Signal("mac", "test", 2, 1, -30) ]); 15 | const selected = new AccessPoint("test"); 16 | 17 | //act 18 | const component = mount(DataPoint, { propsData: { reading: reading, selected: selected } }); 19 | 20 | //assert 21 | this.assert.equal("-30 dBm", component.text()); 22 | } 23 | 24 | @Test() 25 | async showsNoSignalWhenNoSignal() { 26 | //arrange 27 | const reading = new Reading(1, new Point(0, 0), []); 28 | const selected = new AccessPoint("test"); 29 | 30 | //act 31 | const component = mount(DataPoint, { propsData: { reading: reading, selected: selected } }); 32 | 33 | //assert 34 | this.assert.equal("(no signal)", component.text()); 35 | } 36 | 37 | @Test() 38 | async positionMatchesReadingLocation() { 39 | //arrange 40 | const reading = new Reading(1, new Point(12, 34), [ new Signal("mac", "test", 2, 1, -30) ]); 41 | const selected = new AccessPoint("test"); 42 | 43 | //act 44 | const component = mount(DataPoint, { propsData: { reading: reading, selected: selected } }); 45 | 46 | //assert 47 | this.assert.stringContains("left: 12px;", component.html()); 48 | this.assert.stringContains("top: 34px;", component.html()); 49 | } 50 | 51 | @Test() 52 | async colorMatchesReadingStrength() { 53 | //arrange 54 | const reading = new Reading(1, new Point(12, 34), [ new Signal("mac", "test", 2, 1, -30) ]); 55 | const selected = new AccessPoint("test"); 56 | 57 | //act 58 | const component = mount(DataPoint, { propsData: { reading: reading, selected: selected } }); 59 | 60 | //assert 61 | this.assert.stringContains("background-color: rgb(0, 127, 0);", component.html()); 62 | } 63 | 64 | @Test() 65 | async clickingDeletesReading() { 66 | //arrange 67 | const state = new AppViewModel(); 68 | state.readings = [ 69 | new Reading(1, new Point(0, 0), []), 70 | new Reading(2, new Point(0, 0), []), 71 | new Reading(3, new Point(0, 0), []) 72 | ]; 73 | const reading = new Reading(1, new Point(12, 34), [ new Signal("mac", "test", 2, 1, -30) ]); 74 | const selected = new AccessPoint("test"); 75 | const component = mount(DataPoint, { 76 | data: () => ({ state: state }), 77 | propsData: { index: 1, reading: reading, selected: selected } 78 | }); 79 | global.confirm = () => true; 80 | 81 | //act 82 | await component.get(".delete").trigger("click"); 83 | 84 | //assert 85 | this.assert.count(2, state.readings); 86 | this.assert.equal(1, state.readings[0].id); 87 | this.assert.equal(3, state.readings[1].id); 88 | } 89 | 90 | @Test() 91 | async cancellingConfirmDoesNotDeleteReading() { 92 | //arrange 93 | const state = new AppViewModel(); 94 | state.readings = [ 95 | new Reading(1, new Point(0, 0), []), 96 | new Reading(2, new Point(0, 0), []), 97 | new Reading(3, new Point(0, 0), []) 98 | ]; 99 | const reading = new Reading(1, new Point(12, 34), [ new Signal("mac", "test", 2, 1, -30) ]); 100 | const selected = new AccessPoint("test"); 101 | const component = mount(DataPoint, { 102 | data: () => ({ state: state }), 103 | propsData: { index: 1, reading: reading, selected: selected } 104 | }); 105 | global.confirm = () => false; 106 | 107 | //act 108 | await component.get(".delete").trigger("click"); 109 | 110 | //assert 111 | this.assert.count(3, state.readings); 112 | } 113 | } -------------------------------------------------------------------------------- /App.Tests/DebugPanelTests.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestSuite } from "xunit.ts"; 2 | import DebugPanel from "../App/debug-panel.vue"; 3 | import { shallowMount as mount } from "@vue/test-utils"; 4 | import Signal from "../App/Signal"; 5 | import AppViewModel from "../App/AppViewModel"; 6 | import Reading from "../App/Reading"; 7 | import Point from "../App/Point"; 8 | import { Mode } from "../App/Mode"; 9 | 10 | export default class DebugPanelTests extends TestSuite { 11 | @Test() 12 | async displayedWhenEnabled() { 13 | //arrange 14 | const state = new AppViewModel(); 15 | state.debug = true; 16 | 17 | //act 18 | const component = mount(DebugPanel, { data: () => ({ state: state }) }); 19 | 20 | //assert 21 | this.assert.undefined(component.attributes("style")); 22 | } 23 | 24 | @Test() 25 | async notDisplayedWhenNotEnabled() { 26 | //arrange 27 | const state = new AppViewModel(); 28 | state.debug = false; 29 | 30 | //act 31 | const component = mount(DebugPanel, { data: () => ({ state: state }) }); 32 | 33 | //assert 34 | this.assert.stringContains("display: none;", component.attributes("style")); 35 | } 36 | 37 | @Test() 38 | async canSortSignalsByStrength() { 39 | //arrange 40 | const signals = [ 41 | new Signal("mac1", "ssid1", 2, 1, -50), 42 | new Signal("mac2", "ssid2", 2, 1, -40) 43 | ]; 44 | const state = new AppViewModel(); 45 | state.current = new Reading(0, new Point(0, 0), signals); 46 | const component = mount(DebugPanel, { data: () => ({ state: state }) }); 47 | 48 | //act 49 | const sorted_signals = component.findAll("table tbody tr td:nth-child(6)").map(s => s.text()); 50 | 51 | //assert 52 | this.assert.equal("-40 dBm", sorted_signals[0]); 53 | this.assert.equal("-50 dBm", sorted_signals[1]); 54 | } 55 | 56 | @Test() 57 | async canSortSignalsBySNR() { 58 | //arrange 59 | const signals = [ 60 | new Signal("mac1", "ssid1", 2, 5, -40), 61 | new Signal("mac2", "ssid2", 2, 1, -50), 62 | new Signal("mac3", "ssid3", 2, 8, -60) 63 | ]; 64 | const state = new AppViewModel(); 65 | state.current = new Reading(0, new Point(0, 0), signals); 66 | state.mode = Mode.SNR; 67 | const component = mount(DebugPanel, { data: () => ({ state: state }) }); 68 | 69 | //act 70 | const sorted_snr = component.findAll("table tbody tr td:nth-child(7)").map(s => s.text()); 71 | 72 | //assert 73 | this.assert.equal("50 dB", sorted_snr[0]); 74 | this.assert.equal("20 dB", sorted_snr[1]); 75 | this.assert.equal("-20 dB", sorted_snr[2]); 76 | } 77 | 78 | @Test() 79 | async canGetColorForSignal() { 80 | //arrange 81 | const signals = [ 82 | new Signal("mac", "ssid", 2, 1, -40) 83 | ]; 84 | const state = new AppViewModel(); 85 | state.current = new Reading(0, new Point(0, 0), signals); 86 | const component = mount(DebugPanel, { data: () => ({ state: state }) }); 87 | 88 | //act 89 | const style = component.get("td[style]").attributes("style"); 90 | 91 | //assert 92 | this.assert.equal("background-color: rgb(0, 255, 0);", style); 93 | } 94 | } -------------------------------------------------------------------------------- /App.Tests/FactoryTests.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestSuite } from "xunit.ts"; 2 | import Factory from "../App/Factory"; 3 | import Signal from "../App/Signal"; 4 | import SignalService from "../App/SignalService"; 5 | import MockFactory from "./MockFactory"; 6 | import Mockito from "ts-mockito"; 7 | import Renderer from "../App/Renderer"; 8 | import FileLoader from "../App/FileLoader"; 9 | 10 | export default class FactoryTests extends TestSuite { 11 | @Test() 12 | async canCreateSignalService() { 13 | //arrange 14 | const signals: Signal[] = []; 15 | 16 | //act 17 | const signal_service = Factory.signalService("http://localhost", signals); 18 | 19 | //assert 20 | this.assert.instanceOf(SignalService, signal_service); 21 | } 22 | 23 | @Test() 24 | async canCreateRenderer() { 25 | //arrange 26 | const canvas = MockFactory.canvas(); 27 | 28 | //act 29 | const renderer = Factory.renderer(Mockito.instance(canvas)); 30 | 31 | //assert 32 | this.assert.instanceOf(Renderer, renderer); 33 | } 34 | 35 | @Test() 36 | async canCreateFileLoader() { 37 | //act 38 | const loader = Factory.fileLoader(); 39 | 40 | //assert 41 | this.assert.instanceOf(FileLoader, loader); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /App.Tests/FileFormTests.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestSuite } from "xunit.ts"; 2 | import { shallowMount as mount } from "@vue/test-utils"; 3 | import AppViewModel from "../App/AppViewModel"; 4 | import FileForm from "../App/file-form.vue"; 5 | 6 | export default class FileFormTests extends TestSuite { 7 | @Test() 8 | async canGetObjectURL() { 9 | //arrange 10 | const state = new AppViewModel(); 11 | state.name = "Test"; 12 | global.URL.createObjectURL = () => JSON.stringify(state); 13 | 14 | const component = mount(FileForm, { data: () => ({ state: state }) }); 15 | 16 | //act 17 | const json = component.vm.objectURL(); 18 | 19 | //assert 20 | this.assert.stringContains("\"name\":\"Test\"", json); 21 | } 22 | } -------------------------------------------------------------------------------- /App.Tests/FileLoaderTests.ts: -------------------------------------------------------------------------------- 1 | import {Test, TestSuite} from "xunit.ts"; 2 | import FileLoader from "../App/FileLoader"; 3 | const jsdom = require("jsdom"); 4 | const { JSDOM } = jsdom; 5 | const { window } = new JSDOM(); 6 | 7 | export default class FileLoaderTests extends TestSuite { 8 | @Test() 9 | async canLoadDataURL() { 10 | //arrange 11 | const reader = new FileReader(); 12 | const loader = new FileLoader(reader); 13 | 14 | const blob = ["abc123"]; 15 | const file = new window.File(blob, "test.txt", { type: "text/plain" }); 16 | 17 | //act 18 | const base64 = await loader.loadData(file); 19 | 20 | //assert 21 | this.assert.equal("data:text/plain;base64,YWJjMTIz", base64); 22 | } 23 | 24 | @Test() 25 | async canLoadJSON() { 26 | //arrange 27 | const reader = new FileReader(); 28 | const loader = new FileLoader(reader); 29 | 30 | const blob = ["{\"abc\":123}"]; 31 | const file = new window.File(blob, "test.json", { type: "application/json" }); 32 | 33 | //act 34 | const json = await loader.loadJSON(file) as { abc: number }; 35 | 36 | //assert 37 | this.assert.equal(123, json.abc); 38 | } 39 | } -------------------------------------------------------------------------------- /App.Tests/HeaderMenuTests.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestSuite } from "xunit.ts"; 2 | import HeaderMenu from "../App/header-menu.vue"; 3 | import { shallowMount as mount } from "@vue/test-utils"; 4 | import AppViewModel from "../App/AppViewModel"; 5 | import AccessPoint from "../App/AccessPoint"; 6 | import Reading from "../App/Reading"; 7 | import Point from "../App/Point"; 8 | import Signal from "../App/Signal"; 9 | 10 | export default class HeaderMenuTests extends TestSuite { 11 | @Test() 12 | async canGetCurrentSignal() { 13 | //arrange 14 | const signals = [ 15 | new Signal("123abc", "test", 2, 1, -10) 16 | ]; 17 | const state = new AppViewModel(); 18 | state.current = new Reading(0, new Point(0, 0), signals); 19 | state.selected = new AccessPoint("test"); 20 | const component = mount(HeaderMenu, { data: () => ({ state: state }) }); 21 | 22 | //act 23 | const current_signal = component.vm.current_signal; 24 | 25 | //assert 26 | this.assert.equal(-10, current_signal); 27 | } 28 | } -------------------------------------------------------------------------------- /App.Tests/MainAreaTests.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestSuite } from "xunit.ts"; 2 | import MainArea from "../App/main-area.vue"; 3 | import { shallowMount as mount } from "@vue/test-utils"; 4 | import Reading from "../App/Reading"; 5 | import Point from "../App/Point"; 6 | import AppViewModel from "../App/AppViewModel"; 7 | 8 | export default class MainAreaTests extends TestSuite { 9 | @Test() 10 | async showsDataPointForEachReading() { 11 | //arrange 12 | const state = new AppViewModel(); 13 | state.readings = [ 14 | new Reading(1, new Point(2, 3), []), 15 | new Reading(2, new Point(3, 4), []) 16 | ]; 17 | 18 | //act 19 | const component = mount(MainArea, { data: () => ({ state: state }) }); 20 | 21 | //assert 22 | this.assert.equal(2, component.findAll("data-point-stub").length); 23 | } 24 | 25 | @Test() 26 | async backgroundIsSetFromData() { 27 | //arrange 28 | const state = new AppViewModel(); 29 | state.background = "test.png"; 30 | 31 | //act 32 | const component = mount(MainArea, { data: () => ({ state: state }) }); 33 | 34 | //assert 35 | this.assert.stringContains("background-image: url(test.png);", component.get(".background").attributes("style")); 36 | } 37 | 38 | @Test() 39 | async backgroundIsPixelatedWhenFlagSet() { 40 | //arrange 41 | const state = new AppViewModel(); 42 | state.pixelated = true; 43 | 44 | //act 45 | const component = mount(MainArea, { data: () => ({ state: state }) }); 46 | 47 | //assert 48 | this.assert.stringContains("pixelated", component.get(".background").attributes("class")); 49 | } 50 | 51 | @Test() 52 | async backgroundIsNotPixelatedWhenFlagNotSet() { 53 | //arrange 54 | const state = new AppViewModel(); 55 | state.pixelated = false; 56 | 57 | //act 58 | const component = mount(MainArea, { data: () => ({ state: state }) }); 59 | 60 | //assert 61 | this.assert.stringDoesNotContain("pixelated", component.get(".background").attributes("class")); 62 | } 63 | 64 | @Test() 65 | async clickingCanvasAddsReading() { 66 | //arrange 67 | const state = new AppViewModel(); 68 | const component = mount(MainArea, { data: () => ({ state: state }), propsData: { enabled: true } }); 69 | 70 | //act 71 | await component.get("canvas").trigger("click"); 72 | await component.vm.$nextTick(); 73 | 74 | //assert 75 | this.assert.equal(1, state.readings.length); 76 | } 77 | 78 | @Test() 79 | async clickingCanvasDoesNotAddReadingWhenDisabled() { 80 | //arrange 81 | const state = new AppViewModel(); 82 | const component = mount(MainArea, { data: () => ({ state: state }) }); 83 | 84 | //act 85 | await component.get("canvas").trigger("click"); 86 | await component.vm.$nextTick(); 87 | 88 | //assert 89 | this.assert.empty(state.readings); 90 | } 91 | } -------------------------------------------------------------------------------- /App.Tests/MockFactory.ts: -------------------------------------------------------------------------------- 1 | import Mockito from "ts-mockito"; 2 | import SignalService from "../App/SignalService"; 3 | import FileLoader from "../App/FileLoader"; 4 | 5 | export default class MockFactory { 6 | static signalService(): SignalService { 7 | const signal_service = Mockito.mock(); 8 | Mockito.when(signal_service.status).thenReturn(""); 9 | Mockito.when(signal_service.last_updated).thenReturn(""); 10 | return signal_service; 11 | } 12 | 13 | static webGL2RenderingContext(): WebGL2RenderingContext { 14 | const shader = Mockito.mock(); 15 | const program = Mockito.mock(); 16 | 17 | const gl = Mockito.mock(); 18 | Mockito.when(gl.createShader(Mockito.anything())).thenReturn(Mockito.instance(shader)); 19 | Mockito.when(gl.createProgram()).thenReturn(Mockito.instance(program)); 20 | 21 | return gl; 22 | } 23 | 24 | static canvas(): HTMLCanvasElement { 25 | const gl = MockFactory.webGL2RenderingContext(); 26 | 27 | const canvas = Mockito.mock(); 28 | Mockito.when(canvas.getContext("webgl2")).thenReturn(Mockito.instance(gl)); 29 | 30 | return canvas; 31 | } 32 | 33 | static fileLoader(): FileLoader { 34 | return Mockito.mock(); 35 | } 36 | } -------------------------------------------------------------------------------- /App.Tests/ModeFormTests.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestSuite } from "xunit.ts"; 2 | import { shallowMount as mount } from "@vue/test-utils"; 3 | import ModeForm from "../App/mode-form.vue"; 4 | import AppViewModel from "../App/AppViewModel"; 5 | import { Mode } from "../App/Mode"; 6 | 7 | export default class ModeFormTests extends TestSuite { 8 | @Test() 9 | async canSetModeToSignal() { 10 | //arrange 11 | const state = new AppViewModel(); 12 | const component = mount(ModeForm, { data: () => ({ state: state }) }); 13 | 14 | //act 15 | await component.get("#mode-signal").setChecked(); 16 | 17 | //assert 18 | this.assert.equal(Mode.Signal, state.mode); 19 | } 20 | 21 | @Test() 22 | async canSetModeToSNR() { 23 | //arrange 24 | const state = new AppViewModel(); 25 | const component = mount(ModeForm, { data: () => ({ state: state }) }); 26 | 27 | //act 28 | await component.get("#mode-snr").setChecked() 29 | 30 | //assert 31 | this.assert.equal(Mode.SNR, state.mode); 32 | } 33 | } -------------------------------------------------------------------------------- /App.Tests/PointTests.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestSuite } from "xunit.ts"; 2 | import Point from "../App/Point"; 3 | 4 | export default class PointTests extends TestSuite { 5 | @Test() 6 | async canCreatePoint() { 7 | //arrange 8 | const x = 12, y = 23; 9 | 10 | //act 11 | const point = new Point(x, y); 12 | 13 | //assert 14 | this.assert.equal(12, point.x); 15 | this.assert.equal(23, point.y); 16 | } 17 | } -------------------------------------------------------------------------------- /App.Tests/ReadingTests.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestSuite } from "xunit.ts"; 2 | import Reading from "../App/Reading"; 3 | import Point from "../App/Point"; 4 | import Signal from "../App/Signal"; 5 | import AccessPoint from "../App/AccessPoint"; 6 | 7 | export default class ReadingTests extends TestSuite { 8 | private static readonly signals: Signal[] = [ 9 | new Signal("mac1", "ssid1", 2, 1, -35), 10 | new Signal("mac2", "ssid1", 2, 11, -30), 11 | new Signal("mac3", "ssid1", 5, 36, -45), 12 | new Signal("mac4", "ssid1", 5, 157, -40), 13 | new Signal("mac5", "ssid2", 2, 1, -65), 14 | new Signal("mac6", "ssid2", 2, 11, -60), 15 | new Signal("mac7", "ssid2", 5, 36, -50), 16 | new Signal("mac8", "ssid2", 5, 157, -55) 17 | ]; 18 | 19 | @Test() 20 | async canGetSignalForSingleAP() { 21 | //arrange 22 | const reading = new Reading(1, new Point(2, 3), ReadingTests.signals); 23 | const access_point = new AccessPoint("ssid1", 5, "mac3"); 24 | 25 | //act 26 | const strength = reading.signalFor(access_point); 27 | 28 | //assert 29 | this.assert.equal(-45, strength); 30 | } 31 | 32 | @Test() 33 | async canGetSignalForSSIDOnSingleFrequency() { 34 | //arrange 35 | const reading = new Reading(1, new Point(2, 3), ReadingTests.signals); 36 | const access_point = new AccessPoint("ssid1", 5); 37 | 38 | //act 39 | const strength = reading.signalFor(access_point); 40 | 41 | //assert 42 | this.assert.equal(-40, strength); 43 | } 44 | 45 | @Test() 46 | async canGetSignalForSSID() { 47 | //arrange 48 | const reading = new Reading(1, new Point(2, 3), ReadingTests.signals); 49 | const access_point = new AccessPoint("ssid2"); 50 | 51 | //act 52 | const strength = reading.signalFor(access_point); 53 | 54 | //assert 55 | this.assert.equal(-50, strength); 56 | } 57 | 58 | @Test() 59 | async canGetSNRForSingleAP() { 60 | //arrange 61 | const reading = new Reading(1, new Point(2, 3), ReadingTests.signals); 62 | const access_point = new AccessPoint("ssid1", 5, "mac3"); 63 | 64 | //act 65 | const snr = reading.snrFor(access_point); 66 | 67 | //assert 68 | this.assert.equal(5, snr); 69 | } 70 | 71 | @Test() 72 | async canGetSNRForSSIDOnSingleFrequency() { 73 | //arrange 74 | const reading = new Reading(1, new Point(2, 3), ReadingTests.signals); 75 | const access_point = new AccessPoint("ssid1", 5); 76 | 77 | //act 78 | const snr = reading.snrFor(access_point); 79 | 80 | //assert 81 | this.assert.equal(15, snr); 82 | } 83 | 84 | @Test() 85 | async canGetSNRForSSID() { 86 | //arrange 87 | const reading = new Reading(1, new Point(2, 3), ReadingTests.signals); 88 | const access_point = new AccessPoint("ssid1"); 89 | 90 | //act 91 | const snr = reading.snrFor(access_point); 92 | 93 | //assert 94 | this.assert.equal(30, snr); 95 | } 96 | } -------------------------------------------------------------------------------- /App.Tests/RenderFactoryTests.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestSuite } from "xunit.ts"; 2 | import RenderFactory from "../App/RenderFactory"; 3 | import Mockito from "ts-mockito"; 4 | import MockFactory from "./MockFactory"; 5 | 6 | export default class RenderFactoryTests extends TestSuite { 7 | @Test() 8 | async canGetContextFromCanvas() { 9 | //arrange 10 | const context = Mockito.mock(); 11 | const instance = Mockito.instance(context); 12 | 13 | const canvas = Mockito.mock(); 14 | Mockito.when(canvas.getContext("webgl2")).thenReturn(instance); 15 | 16 | //act 17 | const gl = RenderFactory.getContext(Mockito.instance(canvas)); 18 | 19 | //assert 20 | this.assert.equal(instance, gl); 21 | } 22 | 23 | @Test() 24 | async shaderProgramIsCompiled() { 25 | //arrange 26 | const gl = MockFactory.webGL2RenderingContext(); 27 | 28 | //act 29 | const shader_program = RenderFactory.getShaderProgram(Mockito.instance(gl)); 30 | 31 | //assert 32 | Mockito.verify(gl.linkProgram(shader_program)).once(); 33 | Mockito.verify(gl.useProgram(shader_program)).once(); 34 | } 35 | } -------------------------------------------------------------------------------- /App.Tests/RendererTests.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestSuite } from "xunit.ts"; 2 | import Renderer from "../App/Renderer"; 3 | import Mockito from "ts-mockito"; 4 | import Reading from "../App/Reading"; 5 | import Point from "../App/Point"; 6 | import AccessPoint from "../App/AccessPoint"; 7 | import MockFactory from "./MockFactory"; 8 | import { Mode } from "../App/Mode"; 9 | 10 | export default class RendererTests extends TestSuite { 11 | @Test() 12 | async renderDrawsTrianglesWithCorrectNumberOfVertices() { 13 | //arrange 14 | const gl = MockFactory.webGL2RenderingContext(); 15 | const canvas = Mockito.mock(); 16 | Mockito.when(canvas.getContext("webgl2")).thenReturn(Mockito.instance(gl)); 17 | 18 | const renderer = new Renderer(Mockito.instance(canvas)); 19 | 20 | //act 21 | const readings = [ 22 | new Reading(1, new Point(2, 1), []), 23 | new Reading(1, new Point(4, 2), []), 24 | new Reading(1, new Point(3, 3), []), 25 | new Reading(1, new Point(4, 4), []) 26 | ]; 27 | const access_point = new AccessPoint("test"); 28 | renderer.render(Mode.Signal, readings, access_point); 29 | 30 | //assert 31 | Mockito.verify(gl.drawArrays(Mockito.anything(), Mockito.anything(), 6)).once(); 32 | } 33 | 34 | @Test() 35 | async canClearCanvas() { 36 | //arrange 37 | const gl = MockFactory.webGL2RenderingContext(); 38 | const canvas = Mockito.mock(); 39 | Mockito.when(canvas.getContext("webgl2")).thenReturn(Mockito.instance(gl)); 40 | 41 | const renderer = new Renderer(Mockito.instance(canvas)); 42 | 43 | //act 44 | renderer.clear(); 45 | 46 | //assert 47 | Mockito.verify(gl.clear(Mockito.anything())).once(); 48 | } 49 | } -------------------------------------------------------------------------------- /App.Tests/SignalServiceTests.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestSuite } from "xunit.ts"; 2 | import SignalService from "../App/SignalService"; 3 | import Signal from "../App/Signal"; 4 | import Message from "../App/Message"; 5 | import { HubConnection } from "@microsoft/signalr"; 6 | import Mockito from "ts-mockito"; 7 | 8 | export default class SignalServiceTests extends TestSuite { 9 | @Test() 10 | async startConnectsToHub() { 11 | //arrange 12 | const connection = Mockito.mock(); 13 | const signals: Signal[] = []; 14 | const service = new SignalService(Mockito.instance(connection), signals); 15 | 16 | //act 17 | await service.start(); 18 | 19 | //assert 20 | Mockito.verify(connection.start()).once(); 21 | } 22 | 23 | @Test() 24 | async signalsAreUpdatedOnNewMessage() { 25 | //arrange 26 | const connection = Mockito.mock(); 27 | Mockito.when(connection.start()).thenResolve(); 28 | 29 | const signal1 = new Signal("mac1", "ssid1", 2, 1, -50); 30 | const signal2 = new Signal("mac2", "ssid2", 2, 1, -40); 31 | const signals = [signal1, signal2]; 32 | const service = new SignalService(Mockito.instance(connection), signals); 33 | 34 | const message = new Message(); 35 | const new1 = new Signal("mac1", "ssid1", 2, 1, -45); 36 | const new2 = new Signal("mac2", "ssid2", 2, 1, -44); 37 | message.signals = [new1, new2]; 38 | 39 | //act 40 | service.update(message); 41 | 42 | //assert 43 | this.assert.equal(-45, signals[0].strength); 44 | this.assert.equal(-44, signals[1].strength); 45 | } 46 | } -------------------------------------------------------------------------------- /App.Tests/SignalTests.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestSuite } from "xunit.ts"; 2 | import Signal from "../App/Signal"; 3 | 4 | export default class SignalTests extends TestSuite { 5 | @Test() 6 | async canCreateSignal() { 7 | //arrange 8 | const mac = "12:34:56:78:90:ab", ssid = "test", frequency = 2, channel = 1, strength = -50; 9 | 10 | //act 11 | const signal = new Signal(mac, ssid, frequency, channel, strength); 12 | 13 | //assert 14 | this.assert.equal("12:34:56:78:90:ab", signal.mac); 15 | this.assert.equal("test", signal.ssid); 16 | this.assert.equal(2, signal.frequency); 17 | this.assert.equal(-50, signal.strength); 18 | } 19 | 20 | @Test() 21 | async canCompareSignals() { 22 | //arrange 23 | const s1 = new Signal("12:34:56:78:90:ab", "test1", 2, 1, -20); 24 | const s2 = new Signal("12:34:56:78:90:ac", "test1", 2, 11, -20); 25 | const s3 = new Signal("12:34:56:78:90:ad", "test2", 2, 6, -20); 26 | const s4 = new Signal("12:34:56:78:90:ae", "test2", 2, 11, -40); 27 | const s5 = new Signal("12:34:56:78:90:af", "test2", 5, 36, -40); 28 | 29 | //act 30 | const less1 = s1.compareTo(s2); 31 | const less2 = s1.compareTo(s3); 32 | const less3 = s1.compareTo(s4); 33 | const less4 = s4.compareTo(s5); 34 | const greater1 = s2.compareTo(s1); 35 | const greater2 = s3.compareTo(s1); 36 | const greater3 = s4.compareTo(s1); 37 | const greater4 = s5.compareTo(s1); 38 | 39 | //assert 40 | this.assert.equal(-1, less1); 41 | this.assert.equal(-1, less2); 42 | this.assert.equal(-1, less3); 43 | this.assert.equal(-1, less4); 44 | this.assert.equal(1, greater1); 45 | this.assert.equal(1, greater2); 46 | this.assert.equal(1, greater3); 47 | this.assert.equal(1, greater4); 48 | } 49 | 50 | @Test() 51 | async canGetSNRFromStrongestNeighbor() { 52 | //arrange 53 | const signals = [ 54 | new Signal("12:34:56:78:90:ab", "test1", 2, 1, -20), 55 | new Signal("12:34:56:78:90:ab", "test2", 2, 2, -20), 56 | new Signal("12:34:56:78:90:ac", "test3", 2, 2, -60), 57 | new Signal("12:34:56:78:90:ad", "test4", 2, 3, -50), 58 | new Signal("12:34:56:78:90:ae", "test5", 2, 4, -40), 59 | new Signal("12:34:56:78:90:af", "test6", 2, 5, -30) 60 | ]; 61 | 62 | //act 63 | const snr = signals[0].snr(signals); 64 | 65 | //assert 66 | this.assert.equal(20, snr); 67 | } 68 | 69 | @Test() 70 | async canGetSNRWhenNoNeighbors() { 71 | //arrange 72 | const signals = [ 73 | new Signal("12:34:56:78:90:ab", "test1", 2, 1, -20), 74 | new Signal("12:34:56:78:90:ac", "test1", 2, 5, -30) 75 | ]; 76 | 77 | //act 78 | const snr = signals[0].snr(signals); 79 | 80 | //assert 81 | this.assert.equal(80, snr); 82 | } 83 | } -------------------------------------------------------------------------------- /App.Tests/StatusTests.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestSuite } from "xunit.ts"; 2 | import Status from "../App/status.vue"; 3 | import { shallowMount as mount } from "@vue/test-utils"; 4 | 5 | export default class StatusTests extends TestSuite { 6 | @Test() 7 | async showsStatusWhenOneExists() { 8 | //arrange 9 | const status = "test message"; 10 | 11 | //act 12 | const component = mount(Status, { propsData: { status: status } }); 13 | 14 | //assert 15 | this.assert.equal("test message", component.text()); 16 | } 17 | 18 | @Test() 19 | async componentDoesNotDisplayWhenNoStatus() { 20 | //arrange 21 | const status = ""; 22 | 23 | //act 24 | const component = mount(Status, { propsData: { status: status } }); 25 | 26 | //assert 27 | this.assert.empty(component.text()); 28 | } 29 | } -------------------------------------------------------------------------------- /App.Tests/TriangulationTests.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestSuite } from "xunit.ts"; 2 | import Reading from "../App/Reading"; 3 | import Point from "../App/Point"; 4 | import Triangulation from "../App/Triangulation"; 5 | import Signal from "../App/Signal"; 6 | import AccessPoint from "../App/AccessPoint"; 7 | import { Mode } from "../App/Mode"; 8 | 9 | export default class TriangulationTests extends TestSuite { 10 | @Test() 11 | async canConvertReadingsToVertexCoordinates() { 12 | //arrange 13 | const readings = [ 14 | new Reading(1, new Point(2, 1), []), 15 | new Reading(2, new Point(4, 2), []), 16 | new Reading(3, new Point(3, 3), []) 17 | ]; 18 | 19 | //act 20 | const triangulation = new Triangulation(Mode.Signal, readings, new AccessPoint("test")); 21 | 22 | //assert 23 | const expected = [ 24 | 4, 2, 25 | 2, 1, 26 | 3, 3 27 | ]; 28 | this.assert.equal(expected, triangulation.vertex_coordinates); 29 | } 30 | 31 | @Test() 32 | async canConvertSignalsToColorParts() { 33 | //arrange 34 | const readings = [ 35 | new Reading(1, new Point(2, 1), [new Signal("12:34:56", "test", 2, 1, -40)]), 36 | new Reading(2, new Point(4, 2), [new Signal("12:34:56", "test", 2, 1, -60)]), 37 | new Reading(3, new Point(3, 3), [new Signal("12:34:56", "test", 2, 1, -80)]) 38 | ]; 39 | const access_point = new AccessPoint("test"); 40 | 41 | //act 42 | const triangulation = new Triangulation(Mode.Signal, readings, access_point); 43 | 44 | //assert 45 | const expected = [ 46 | 255, 255, 0, 191, 47 | 0, 255, 0, 191, 48 | 255, 0, 0, 191 49 | ]; 50 | this.assert.equal(expected, triangulation.vertex_color_parts); 51 | } 52 | 53 | @Test() 54 | async canConvertSNRToColorParts() { 55 | //arrange 56 | const readings = [ 57 | new Reading(1, new Point(2, 1), [new Signal("12:34:56", "test", 2, 1, -30)]), 58 | new Reading(2, new Point(4, 2), [new Signal("12:34:56", "test", 2, 1, -50)]), 59 | new Reading(3, new Point(3, 3), [new Signal("12:34:56", "test", 2, 1, -80)]) 60 | ]; 61 | const access_point = new AccessPoint("test"); 62 | 63 | //act 64 | const triangulation = new Triangulation(Mode.SNR, readings, access_point); 65 | 66 | //assert 67 | const expected = [ 68 | 0, 255, 0, 191, 69 | 0, 127, 0, 191, 70 | 255, 255, 0, 191 71 | ]; 72 | this.assert.equal(expected, triangulation.vertex_color_parts); 73 | } 74 | } -------------------------------------------------------------------------------- /App.Tests/WiFiIconTests.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestSuite } from "xunit.ts"; 2 | import { shallowMount as mount } from "@vue/test-utils"; 3 | import WiFiIcon from "../App/wifi-icon.vue"; 4 | 5 | export default class WiFiIconTests extends TestSuite { 6 | @Test() 7 | async colorGetsPassedToSVG() { 8 | //arrange 9 | const color = "rgba(0, 255, 0, 1)"; 10 | 11 | //act 12 | const component = mount(WiFiIcon, { propsData: { color: color } }); 13 | 14 | //assert 15 | this.assert.stringContains("style=\"fill: rgba(0, 255, 0, 1);\"", component.html()); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /App.Tests/WiFiStatusTests.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestSuite } from "xunit.ts"; 2 | import { shallowMount as mount } from "@vue/test-utils"; 3 | import WiFiStatus from "../App/wifi-status.vue"; 4 | import { Mode } from "../App/Mode"; 5 | 6 | export default class WiFiStatusTests extends TestSuite { 7 | @Test() 8 | async showsIndicatorWhenSignalFound() { 9 | //arrange 10 | const signal = -40; 11 | const units = Mode.Signal; 12 | 13 | //act 14 | const component = mount(WiFiStatus, { propsData: { value: signal, units: units } }); 15 | 16 | //assert 17 | this.assert.stringContains("rgba(0, 255, 0, 1)", component.html()); 18 | this.assert.equal("-40 dBm", component.text()); 19 | } 20 | 21 | @Test() 22 | async showsCorrectUnits() { 23 | //arrange 24 | const value = 50; 25 | const units = Mode.SNR; 26 | 27 | //act 28 | const component = mount(WiFiStatus, { propsData: { value: value, units: units } }); 29 | 30 | //assert 31 | this.assert.stringContains("rgba(0, 255, 0, 1)", component.html()); 32 | this.assert.equal("50 dB", component.text()); 33 | } 34 | 35 | @Test() 36 | async showsIndicatorWhenNoSignal() { 37 | //arrange 38 | const signal = null; 39 | const units = Mode.Signal; 40 | 41 | //act 42 | const component = mount(WiFiStatus, { propsData: { value: signal, units: units } }); 43 | 44 | //assert 45 | this.assert.stringContains("rgba(0, 0, 0, 0.5)", component.html()); 46 | this.assert.stringContains("no signal", component.text()); 47 | } 48 | 49 | @Test() 50 | async colorIsFullByDefault() { 51 | //arrange 52 | const color = "rgba(0, 255, 0, 1)"; 53 | 54 | //act 55 | const component = mount(WiFiStatus, { propsData: { color: color } }); 56 | 57 | //assert 58 | this.assert.stringDoesNotContain("fading", component.html()); 59 | } 60 | 61 | @Test() 62 | async colorFadesAsReadingGoesStale() { 63 | //arrange 64 | const component = mount(WiFiStatus, { propsData: { color: "rgba(0, 255, 0, 1)", last_updated: "earlier" } }); 65 | 66 | //act 67 | await component.setProps({ last_updated: "now" }); 68 | await component.vm.$nextTick(); 69 | await new Promise((resolve) => setTimeout(() => resolve(), 100)); 70 | 71 | //assert 72 | this.assert.stringContains("fading", component.html()); 73 | } 74 | } -------------------------------------------------------------------------------- /App.Tests/vite.config.js: -------------------------------------------------------------------------------- 1 | import {defineConfig} from "vite"; 2 | import vue from "@vitejs/plugin-vue"; 3 | import path from "path"; 4 | import fs from "fs"; 5 | 6 | const files = fs.readdirSync(__dirname) 7 | .filter(file => file.match("\\.ts$")) 8 | .map(file => path.resolve(__dirname, file)); 9 | 10 | export default defineConfig({ 11 | root: "App.Tests", 12 | plugins: [vue()], 13 | logLevel: "Warn", 14 | build: { 15 | lib: { 16 | entry: "", 17 | formats: ["cjs"], 18 | fileName: "[name]" 19 | }, 20 | outDir: "../dist/tests", 21 | emptyOutDir: true, 22 | minify: false, 23 | sourcemap: true, 24 | rollupOptions: { 25 | input: files, 26 | output: { 27 | intro: "require('global-jsdom/register');" 28 | }, 29 | external: ["xunit.ts", "ts-mockito", "vue", "@vue/test-utils"] 30 | } 31 | } 32 | }); -------------------------------------------------------------------------------- /App/AccessPoint.ts: -------------------------------------------------------------------------------- 1 | import Compare from "./Compare"; 2 | 3 | export default class AccessPoint { 4 | readonly ssid: string; 5 | readonly frequency: number | null; 6 | readonly mac: string | null; 7 | 8 | constructor(ssid: string, frequency: number | null = null, mac: string | null = null) { 9 | this.ssid = ssid; 10 | this.frequency = frequency; 11 | this.mac = mac; 12 | } 13 | 14 | label(): string { 15 | return this.ssid 16 | + (this.frequency ? ` @ ${this.frequency} GHz` : "") 17 | + (this.mac ? ` (${this.mac})` : ""); 18 | } 19 | 20 | compareTo(other: AccessPoint): number { 21 | return Compare.strings(this.ssid, other.ssid) 22 | || Compare.numbers(this.frequency ?? 0, other.frequency ?? 0) 23 | || Compare.strings(this.mac ?? "", other.mac ?? ""); 24 | } 25 | } -------------------------------------------------------------------------------- /App/AccessPointGrouping.ts: -------------------------------------------------------------------------------- 1 | export default class AccessPointGrouping { 2 | ssid: boolean = true; 3 | frequency: boolean = true; 4 | 5 | update(): void { 6 | if (!this.ssid) 7 | this.frequency = false; 8 | } 9 | } -------------------------------------------------------------------------------- /App/AppViewModel.ts: -------------------------------------------------------------------------------- 1 | import SignalService from "./SignalService"; 2 | import Renderer from "./Renderer"; 3 | import Reading from "./Reading"; 4 | import AccessPoint from "./AccessPoint"; 5 | import Point from "./Point"; 6 | import AccessPointGrouping from "./AccessPointGrouping"; 7 | import FileLoader from "./FileLoader"; 8 | import Signal from "./Signal"; 9 | import { Mode } from './Mode'; 10 | 11 | export default class AppViewModel { 12 | name: string = ""; 13 | mode: Mode = Mode.Signal; 14 | background: string = ""; 15 | pixelated: boolean = true; 16 | readings: Reading[] = []; 17 | selected: AccessPoint | null = null; 18 | current: Reading = new Reading(0, new Point(0, 0), []); 19 | group_by: AccessPointGrouping = new AccessPointGrouping(); 20 | debug: boolean = false; 21 | 22 | signal_service: SignalService | null = null; 23 | renderer: Renderer | null = null; 24 | file_loader: FileLoader | null = null; 25 | 26 | async load(files: FileList): Promise { 27 | if (files.length !== 1) { 28 | return; 29 | } 30 | 31 | const file = files.item(0); 32 | if (file != null) { 33 | const json = await this.file_loader?.loadJSON(file) as AppViewModel; 34 | this.restore(json); 35 | } 36 | } 37 | 38 | private restore(json: AppViewModel) { 39 | this.name = json.name; 40 | this.background = json.background; 41 | this.pixelated = json.pixelated; 42 | this.selected = json.selected; 43 | 44 | if (json.group_by) { 45 | this.group_by.ssid = json.group_by.ssid; 46 | this.group_by.frequency = json.group_by.frequency; 47 | } 48 | 49 | if (json.readings) { 50 | this.readings = json.readings.map(r => new Reading(r.id, 51 | new Point(r.location.x, r.location.y), 52 | r.signals.map(s => new Signal(s.mac, s.ssid, s.frequency, s.channel, s.strength)))); 53 | } 54 | } 55 | 56 | async setBackground(files: FileList): Promise { 57 | if (files.length !== 1) { 58 | this.background = ""; 59 | return; 60 | } 61 | 62 | const file = files.item(0); 63 | if (file != null) { 64 | this.background = await this.file_loader?.loadData(file) ?? ""; 65 | } 66 | } 67 | 68 | deleteDataPoint(index: number): void { 69 | this.readings.splice(index, 1); 70 | if (this.readings.length >= 3) 71 | this.renderer?.render(this.mode, this.readings, this.selected); 72 | else 73 | this.renderer?.clear(); 74 | } 75 | 76 | clearAllDataPoints(): void { 77 | this.readings = []; 78 | this.renderer?.clear(); 79 | } 80 | } -------------------------------------------------------------------------------- /App/Color.ts: -------------------------------------------------------------------------------- 1 | export default class Color { 2 | red: number = 0; 3 | green: number = 0; 4 | blue: number = 0; 5 | alpha: number = 0; 6 | 7 | constructor(red: number, green: number, blue: number, alpha: number = 255) { 8 | this.red = ~~Color.clamp(red); 9 | this.green = ~~Color.clamp(green); 10 | this.blue = ~~Color.clamp(blue); 11 | this.alpha = ~~Color.clamp(alpha); 12 | } 13 | 14 | private static clamp(value: number): number { 15 | return Math.min(Math.max(value, 0), 255); 16 | } 17 | 18 | toRGB(): string { 19 | return `rgb(${this.red}, ${this.green}, ${this.blue})`; 20 | } 21 | 22 | toRGBA(): string { 23 | return `rgba(${this.red}, ${this.green}, ${this.blue}, ${(this.alpha > 0 ? this.alpha + 1 : this.alpha) / 256})`; 24 | } 25 | 26 | toHEX(): string { 27 | const r = this.red.toString(16); 28 | const g = this.green.toString(16); 29 | const b = this.blue.toString(16); 30 | return `#${r.length == 1 ? "0": ""}${r}${g.length == 1 ? "0": ""}${g}${b.length == 1 ? "0": ""}${b}`; 31 | } 32 | 33 | toHEXA(): string { 34 | return `${this.toHEX()}${this.alpha.toString(16)}`; 35 | } 36 | } -------------------------------------------------------------------------------- /App/ColorConverter.ts: -------------------------------------------------------------------------------- 1 | import Color from "./Color"; 2 | 3 | export default class ColorConverter { 4 | private static readonly signalStops: number[] = [ -20, -40, -60, -80, -100 ]; 5 | private static readonly snrStops: number[] = [ 90, 50, 20, 0, -10 ]; 6 | 7 | static fromSignal(dBm: number | null): Color { 8 | return dBm != null 9 | ? new Color(this.base(dBm, 1, this.signalStops), this.base(dBm, 0, this.signalStops), 0) 10 | : new Color(0, 0, 0, 127); 11 | } 12 | 13 | static fromSNR(dB: number | null): Color { 14 | return dB != null 15 | ? new Color(this.base(dB, 1, this.snrStops), this.base(dB, 0, this.snrStops), 0) 16 | : new Color(0, 0, 0, 127); 17 | } 18 | 19 | private static base(value: number, offset: number, stops: number[]): number { 20 | if (value > stops[offset]) { 21 | return 0; 22 | } 23 | 24 | if (value > stops[1 + offset]) { 25 | return Math.abs(value - stops[offset]) * 255 / (stops[offset] - stops[1 + offset]); 26 | } 27 | 28 | if (value > stops[2 + offset]) { 29 | return 255; 30 | } 31 | 32 | if (value > stops[3 + offset]) { 33 | return Math.abs(value - stops[3 + offset]) * 255 / (stops[2 + offset] - stops[3 + offset]); 34 | } 35 | 36 | return 0; 37 | } 38 | } -------------------------------------------------------------------------------- /App/Compare.ts: -------------------------------------------------------------------------------- 1 | export default class Compare { 2 | static numbers(n1: number, n2: number): number { 3 | return this.base(n1, n2); 4 | } 5 | 6 | static strings(s1: string, s2: string): number { 7 | return this.base(s1, s2); 8 | } 9 | 10 | private static base(x: any, y: any): number { 11 | if (x < y) { 12 | return -1; 13 | } 14 | 15 | if (x > y) { 16 | return 1; 17 | } 18 | 19 | return 0; 20 | } 21 | } -------------------------------------------------------------------------------- /App/Factory.ts: -------------------------------------------------------------------------------- 1 | import SignalService from "./SignalService"; 2 | import {HubConnectionBuilder} from "@microsoft/signalr"; 3 | import Renderer from "./Renderer"; 4 | import Signal from "./Signal"; 5 | import FileLoader from "./FileLoader"; 6 | 7 | export default class Factory { 8 | 9 | static signalService(server: string, signals: Signal[]): SignalService { 10 | const connection = new HubConnectionBuilder() 11 | .withUrl(`${server}/signals`) 12 | .withAutomaticReconnect() 13 | .build(); 14 | 15 | return new SignalService(connection, signals); 16 | } 17 | 18 | static renderer(canvas: HTMLCanvasElement): Renderer { 19 | return new Renderer(canvas); 20 | } 21 | 22 | static fileLoader(): FileLoader { 23 | const file_reader = new FileReader(); 24 | return new FileLoader(file_reader); 25 | } 26 | } -------------------------------------------------------------------------------- /App/FileLoader.ts: -------------------------------------------------------------------------------- 1 | export default class FileLoader { 2 | 3 | private readonly file_reader: FileReader; 4 | 5 | constructor(file_reader: FileReader) { 6 | this.file_reader = file_reader; 7 | } 8 | 9 | async loadJSON(file: Blob): Promise { 10 | return new Promise((resolve, reject) => { 11 | this.file_reader.onerror = () => reject(new Error("Could not read file")); 12 | this.file_reader.onload = () => resolve(JSON.parse(this.file_reader.result as string)); 13 | this.file_reader.readAsText(file); 14 | }); 15 | } 16 | 17 | async loadData(file: Blob): Promise { 18 | return new Promise((resolve, reject) => { 19 | this.file_reader.onerror = () => reject(new Error("Could not read file")); 20 | this.file_reader.onload = () => resolve(this.file_reader.result as string); 21 | this.file_reader.readAsDataURL(file); 22 | }); 23 | } 24 | } -------------------------------------------------------------------------------- /App/Message.ts: -------------------------------------------------------------------------------- 1 | import Signal from "./Signal"; 2 | 3 | export default class Message { 4 | status: string = ""; 5 | signals: Signal[] = []; 6 | lastUpdated: string = ""; 7 | } -------------------------------------------------------------------------------- /App/Mode.ts: -------------------------------------------------------------------------------- 1 | export enum Mode { 2 | Signal = "dBm", 3 | SNR = "dB" 4 | } -------------------------------------------------------------------------------- /App/Point.ts: -------------------------------------------------------------------------------- 1 | export default class Point { 2 | readonly x: number; 3 | readonly y: number; 4 | 5 | constructor(x: number, y: number) { 6 | this.x = x; 7 | this.y = y; 8 | } 9 | } -------------------------------------------------------------------------------- /App/Reading.ts: -------------------------------------------------------------------------------- 1 | import Point from "./Point"; 2 | import Signal from "./Signal"; 3 | import AccessPoint from "./AccessPoint"; 4 | 5 | export default class Reading { 6 | readonly id: number; 7 | readonly location: Point; 8 | readonly signals: Signal[]; 9 | 10 | constructor(id: number, location: Point, signals: Signal[]) { 11 | this.id = id; 12 | this.location = location; 13 | this.signals = signals; 14 | } 15 | 16 | signalFor(access_point: AccessPoint | null): number | null { 17 | return this.mapValue(s => s.strength, access_point); 18 | } 19 | 20 | snrFor(access_point: AccessPoint | null): number | null { 21 | return this.mapValue(s => s.snr(this.signals), access_point); 22 | } 23 | 24 | private mapValue(mapper: (signal: Signal) => number, access_point: AccessPoint | null): number | null { 25 | if (access_point == null) 26 | return null; 27 | 28 | const values = this.signals 29 | .filter(signal => (signal.ssid == access_point.ssid) 30 | && (access_point.frequency == null || signal.frequency == access_point.frequency) 31 | && (access_point.mac == null || signal.mac == access_point.mac)) 32 | .map(mapper); 33 | 34 | return values.length > 0 ? Math.max(...values) : null; 35 | } 36 | } -------------------------------------------------------------------------------- /App/RenderFactory.ts: -------------------------------------------------------------------------------- 1 | import Shaders from "./Shaders"; 2 | 3 | export default class RenderFactory { 4 | static getContext(canvas: HTMLCanvasElement): WebGL2RenderingContext { 5 | const gl = canvas.getContext("webgl2"); 6 | if (gl == null) 7 | throw new Error("WebGL2 not supported"); 8 | 9 | gl.enable(gl.BLEND); 10 | gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); 11 | gl.clearColor(1, 1, 1, 0); 12 | 13 | return gl; 14 | } 15 | 16 | static getShaderProgram(gl: WebGL2RenderingContext): WebGLProgram { 17 | const shader_program = gl.createProgram(); 18 | if (shader_program == null) 19 | throw new Error("Could not create shader program"); 20 | 21 | const vertex_shader = RenderFactory.getVertexShader(gl); 22 | gl.attachShader(shader_program, vertex_shader); 23 | 24 | const fragment_shader = RenderFactory.getFragmentShader(gl); 25 | gl.attachShader(shader_program, fragment_shader); 26 | 27 | gl.linkProgram(shader_program); 28 | gl.useProgram(shader_program); 29 | 30 | return shader_program; 31 | } 32 | 33 | private static getVertexShader(gl: WebGL2RenderingContext): WebGLShader { 34 | const vertex_shader = gl.createShader(gl.VERTEX_SHADER); 35 | if (vertex_shader == null) 36 | throw new Error("Could not create vertex shader"); 37 | 38 | gl.shaderSource(vertex_shader, Shaders.vertex); 39 | gl.compileShader(vertex_shader); 40 | 41 | return vertex_shader; 42 | } 43 | 44 | private static getFragmentShader(gl: WebGL2RenderingContext): WebGLShader { 45 | const fragment_shader = gl.createShader(gl.FRAGMENT_SHADER); 46 | if (fragment_shader == null) 47 | throw new Error("Could not create fragment shader"); 48 | 49 | gl.shaderSource(fragment_shader, Shaders.fragment); 50 | gl.compileShader(fragment_shader); 51 | 52 | return fragment_shader; 53 | } 54 | } -------------------------------------------------------------------------------- /App/Renderer.ts: -------------------------------------------------------------------------------- 1 | import Reading from "./Reading"; 2 | import Triangulation from "./Triangulation"; 3 | import RenderFactory from "./RenderFactory"; 4 | import AccessPoint from "./AccessPoint"; 5 | import { Mode } from "./Mode"; 6 | 7 | export default class Renderer { 8 | private readonly canvas: HTMLCanvasElement; 9 | private readonly gl: WebGL2RenderingContext; 10 | private readonly shader_program: WebGLProgram; 11 | 12 | constructor(canvas: HTMLCanvasElement) { 13 | this.canvas = canvas; 14 | this.gl = RenderFactory.getContext(this.canvas); 15 | this.shader_program = RenderFactory.getShaderProgram(this.gl); 16 | } 17 | 18 | render(mode: Mode, readings: Reading[], access_point: AccessPoint | null): void { 19 | const triangulation = new Triangulation(mode, readings, access_point); 20 | if (triangulation.vertex_coordinates.length === 0 || triangulation.vertex_coordinates.length % 6 !== 0 21 | || triangulation.vertex_color_parts.length === 0 || triangulation.vertex_color_parts.length % 12 !== 0) 22 | return; 23 | 24 | this.setOffset(this.canvas, this.gl, this.shader_program); 25 | this.setCoords(triangulation); 26 | this.setColors(triangulation); 27 | this.gl.drawArrays(this.gl.TRIANGLES, 0, triangulation.vertex_coordinates.length / 2); 28 | } 29 | 30 | clear(): void { 31 | this.gl.clear(this.gl.DEPTH_BUFFER_BIT); 32 | } 33 | 34 | private setOffset(canvas: HTMLCanvasElement, gl: WebGL2RenderingContext, shader_program: WebGLProgram): void { 35 | const offset_uniform = gl.getUniformLocation(shader_program, "u_offset"); 36 | gl.uniform2f(offset_uniform, canvas.clientWidth, canvas.clientHeight); 37 | } 38 | 39 | private setCoords(triangulation: Triangulation): void { 40 | this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.gl.createBuffer()); 41 | this.gl.bufferData(this.gl.ARRAY_BUFFER, new Uint16Array(triangulation.vertex_coordinates), this.gl.STREAM_DRAW); 42 | 43 | const coords_attribute = this.gl.getAttribLocation(this.shader_program, "a_coords"); 44 | this.gl.vertexAttribPointer(coords_attribute, 2, this.gl.UNSIGNED_SHORT, false, 0, 0); 45 | this.gl.enableVertexAttribArray(coords_attribute); 46 | } 47 | 48 | private setColors(triangulation: Triangulation): void { 49 | this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.gl.createBuffer()); 50 | this.gl.bufferData(this.gl.ARRAY_BUFFER, new Uint8Array(triangulation.vertex_color_parts), this.gl.STREAM_DRAW); 51 | 52 | const color_attribute = this.gl.getAttribLocation(this.shader_program, "a_color"); 53 | this.gl.vertexAttribPointer(color_attribute, 4, this.gl.UNSIGNED_BYTE, false, 0, 0); 54 | this.gl.enableVertexAttribArray(color_attribute); 55 | } 56 | } -------------------------------------------------------------------------------- /App/Shaders.ts: -------------------------------------------------------------------------------- 1 | export default class Shaders { 2 | public static readonly fragment: string = ` 3 | precision highp float; 4 | 5 | varying vec4 v_color; 6 | 7 | void main() 8 | { 9 | gl_FragColor = v_color / vec4(255); 10 | } 11 | `; 12 | 13 | public static readonly vertex: string = ` 14 | precision highp float; 15 | 16 | uniform vec2 u_offset; 17 | attribute vec2 a_coords; 18 | attribute vec4 a_color; 19 | varying vec4 v_color; 20 | 21 | void main() 22 | { 23 | v_color = a_color; 24 | 25 | mat4 scale_and_translate = mat4 26 | ( 27 | 2.0 / u_offset.x, 0, 0, -1.0, 28 | 0, -2.0 / u_offset.y, 0, 1.0, 29 | 0, 0, 1.0, 0, 30 | 0, 0, 0, 1.0 31 | ); 32 | 33 | gl_Position = vec4(a_coords, 0, 1) * scale_and_translate; 34 | } 35 | `; 36 | } -------------------------------------------------------------------------------- /App/SharedState.ts: -------------------------------------------------------------------------------- 1 | import AppViewModel from "./AppViewModel"; 2 | 3 | export default new AppViewModel(); -------------------------------------------------------------------------------- /App/Signal.ts: -------------------------------------------------------------------------------- 1 | import Compare from "./Compare"; 2 | 3 | export default class Signal { 4 | readonly mac: string; 5 | readonly ssid: string; 6 | readonly frequency: number; 7 | readonly channel: number; 8 | readonly strength: number; 9 | 10 | private static readonly noiseFloor = -100; 11 | 12 | constructor(mac: string, ssid: string, frequency: number, channel: number, strength: number) { 13 | this.mac = mac; 14 | this.ssid = ssid || "[hidden]"; 15 | this.frequency = frequency; 16 | this.channel = channel; 17 | this.strength = strength; 18 | } 19 | 20 | compareTo(other: Signal): number { 21 | return Compare.numbers(other.strength, this.strength) 22 | || Compare.strings(this.ssid, other.ssid) 23 | || Compare.numbers(this.frequency, other.frequency) 24 | || Compare.strings(this.mac, other.mac); 25 | } 26 | 27 | snr(others: Signal[]): number { 28 | const noise = this.noise(others); 29 | return this.strength - noise; 30 | } 31 | 32 | private noise(others: Signal[]): number { 33 | const neighbors = others.filter(s => s.mac != this.mac 34 | && s.frequency == this.frequency 35 | && s.channel - 4 < this.channel 36 | && s.channel + 4 > this.channel 37 | ); 38 | const strengths = neighbors.map(s => s.strength); 39 | return strengths.length > 0 40 | ? Math.max(...strengths) 41 | : Signal.noiseFloor; 42 | } 43 | } -------------------------------------------------------------------------------- /App/SignalService.ts: -------------------------------------------------------------------------------- 1 | import { HubConnection } from "@microsoft/signalr"; 2 | import Message from "./Message"; 3 | import Signal from "./Signal"; 4 | 5 | export default class SignalService { 6 | private static readonly error_message = "Could not connect to server, please try restarting"; 7 | private readonly connection: HubConnection; 8 | readonly signals: Signal[]; 9 | status: string = "Connecting..."; 10 | last_updated: string = ""; 11 | 12 | constructor(connection: HubConnection, signals: Signal[]) { 13 | this.connection = connection; 14 | this.signals = signals; 15 | } 16 | 17 | async start() { 18 | this.connection.onreconnecting(() => this.status = "Reconnecting..."); 19 | this.connection.onreconnected(() => this.status = ""); 20 | this.connection.onclose(() => this.status = SignalService.error_message); 21 | 22 | this.connection.on("Update", (message: Message) => this.update(message)); 23 | 24 | try { 25 | await this.connection.start(); 26 | this.status = ""; 27 | } 28 | catch { 29 | this.status = SignalService.error_message; 30 | } 31 | } 32 | 33 | update(message: Message): void { 34 | this.last_updated = message.lastUpdated; 35 | this.status = message.status; 36 | 37 | this.signals.splice(0); 38 | message.signals.forEach(data => { 39 | const signal = new Signal(data.mac, data.ssid, data.frequency, data.channel, data.strength); 40 | this.signals.push(signal); 41 | }); 42 | } 43 | } -------------------------------------------------------------------------------- /App/Triangulation.ts: -------------------------------------------------------------------------------- 1 | import Delaunator from "delaunator"; 2 | import ColorConverter from "./ColorConverter"; 3 | import Reading from "./Reading"; 4 | import AccessPoint from "./AccessPoint"; 5 | import { Mode } from "./Mode"; 6 | 7 | export default class Triangulation { 8 | readonly vertex_coordinates: number[] = []; 9 | readonly vertex_color_parts: number[] = []; 10 | 11 | constructor(mode: Mode, readings: Reading[], access_point: AccessPoint | null) { 12 | const points = readings.map(reading => reading.location); 13 | const delauney = Delaunator.from(points, p => p.x, p => p.y); 14 | 15 | delauney.triangles.forEach(index => { 16 | const reading = readings[index]; 17 | this.vertex_coordinates.push(reading.location.x); 18 | this.vertex_coordinates.push(reading.location.y); 19 | 20 | const signal = mode == Mode.Signal 21 | ? reading.signalFor(access_point) 22 | : reading.snrFor(access_point); 23 | 24 | const color = mode == Mode.Signal 25 | ? ColorConverter.fromSignal(signal) 26 | : ColorConverter.fromSNR(signal); 27 | 28 | this.vertex_color_parts.push(color.red); 29 | this.vertex_color_parts.push(color.green); 30 | this.vertex_color_parts.push(color.blue); 31 | this.vertex_color_parts.push((color.alpha > 0 ? color.alpha + 1 : color.alpha) * 0.75 - (color.alpha > 0 ? 1 : 0)); 32 | }); 33 | } 34 | } -------------------------------------------------------------------------------- /App/actions.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 38 | 39 | -------------------------------------------------------------------------------- /App/ap-form.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 60 | 61 | -------------------------------------------------------------------------------- /App/app.css: -------------------------------------------------------------------------------- 1 | @import url("@fontsource/noto-sans/300.css"); 2 | 3 | :root { 4 | font-size: 1rem; 5 | --ecogreen: #538d43; 6 | --ecogreen-dark: #477939; 7 | --ecogreen-darker: #3f6c33; 8 | --ecogreen-darkest: #2b4922; 9 | } 10 | 11 | * { 12 | font-family: "Roboto", sans-serif; 13 | font-weight: 300; 14 | } 15 | 16 | body { 17 | margin: auto; 18 | } 19 | 20 | app-title { 21 | height: 100vh; 22 | text-align: center; 23 | display: flex; 24 | flex-direction: column; 25 | justify-content: center; 26 | align-items: center; 27 | } 28 | 29 | app-title header { 30 | border-radius: 1rem; 31 | padding: 0 2rem; 32 | background: var(--ecogreen-dark) linear-gradient(var(--ecogreen), var(--ecogreen-dark)); 33 | box-shadow: 0 0 1rem rgba(0, 0, 0, 0.5); 34 | } 35 | 36 | app-title h1, 37 | app-title h2, 38 | app-title h3 { 39 | font-family: "Noto Sans", sans-serif; 40 | color: white; 41 | margin: 0.5rem; 42 | } -------------------------------------------------------------------------------- /App/app.ts: -------------------------------------------------------------------------------- 1 | import { createApp, h } from "vue"; 2 | import App from "./app.vue"; 3 | import Factory from "./Factory"; 4 | import Signal from "./Signal"; 5 | 6 | const local = process.env.NODE_ENV === "production"; 7 | const server = local ? "" : "http://localhost:5000"; 8 | 9 | const app = createApp({ 10 | provide: () => ({ 11 | signal_service: (signals: Signal[]) => Factory.signalService(server, signals), 12 | renderer: Factory.renderer, 13 | file_loader: Factory.fileLoader 14 | }), 15 | render: () => h(App) 16 | }); 17 | app.mount("app"); -------------------------------------------------------------------------------- /App/app.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 68 | 69 | -------------------------------------------------------------------------------- /App/background-form.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 26 | 27 | -------------------------------------------------------------------------------- /App/data-point.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 59 | 60 | -------------------------------------------------------------------------------- /App/debug-panel.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 83 | 84 | -------------------------------------------------------------------------------- /App/file-form.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 42 | 43 | -------------------------------------------------------------------------------- /App/header-menu.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 50 | 51 | -------------------------------------------------------------------------------- /App/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Wi-Fi Surveyor 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |

Wi-Fi Surveyor

16 |

by ecoAPM

17 |

Loading...

18 |
19 |
20 |
21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /App/main-area.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 64 | 65 | -------------------------------------------------------------------------------- /App/mode-form.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 30 | 31 | -------------------------------------------------------------------------------- /App/status.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 19 | 20 | -------------------------------------------------------------------------------- /App/wifi-icon.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 24 | 25 | -------------------------------------------------------------------------------- /App/wifi-status.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 61 | 62 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | Steve@ecoAPM.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # ecoAPM Contribution Guidelines 2 | 3 | First of all, thank you for your interest in contributing! 4 | 5 | This document represents a general set of guidelines to help make the process of community contributions as smooth as possible for all parties involved. 6 | 7 | #### Please read the [Code of Conduct](CODE_OF_CONDUCT.md) prior to participating 8 | - This is the standard "Contributor Covenant" used throughout all ecoAPM codebases, and widely across the OSS landscape 9 | - Building a strong, professional, caring, and empathetic community is paramount in our goal as an OSS company 10 | 11 | #### Discussions about changes should happen in an issue before creating a pull request 12 | - While a change may make sense for a specific use case, it may not match the larger goals of the project as initially formulated by the original contributor 13 | - Prior discussion can help give direction to how a feature or bug fix could best be implemented to meet everyone's needs 14 | 15 | #### Follow the standard issue template formats for reporting bugs and requesting new features 16 | - These make reading, understanding, and triaging issues much easier 17 | 18 | #### Commit quality code with detailed documentation to help maximize PR review effectiveness 19 | - All new or modified functionality should have unit tests covering the logic involved 20 | - All PR checks (e.g. automated tests, code quality analysis, etc.) should be passing before a PR is reviewed 21 | - Commit messages should be English (Canadian/UK/US are all acceptable) in the present tense using an imperative form (see existing commits for examples) 22 | - Please do not reference GitHub issue numbers or PR numbers in git commit messages 23 | 24 | #### Multiple smaller, atomic PRs are preferable to single larger monolithic PRs 25 | - This may take longer to get the full changeset merged, but will provide for a much smoother feedback process 26 | - Please reference any related issue numbers in the body of all PR descriptions so that GitHub links them together 27 | -------------------------------------------------------------------------------- /Core.Tests/AppHelpersTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Hosting; 2 | using Microsoft.AspNetCore.Hosting.Server; 3 | using Microsoft.AspNetCore.Hosting.Server.Features; 4 | using Microsoft.Extensions.DependencyInjection; 5 | using Microsoft.Extensions.Hosting; 6 | using NSubstitute; 7 | using Xunit; 8 | 9 | namespace WiFiSurveyor.Core.Tests; 10 | 11 | public sealed class AppHelpersTests 12 | { 13 | private static readonly ICollection Addresses = ["http://localhost:1234"]; 14 | 15 | [Fact] 16 | public void CanRegisterSharedServices() 17 | { 18 | //arrange 19 | var services = new ServiceCollection(); 20 | 21 | //act 22 | services.AddPosixHandlers(); 23 | 24 | //assert 25 | var types = services.Select(s => s.ImplementationType).ToArray(); 26 | Assert.Contains(typeof(CommandService), types); 27 | Assert.Contains(typeof(SignalService), types); 28 | } 29 | 30 | [Fact] 31 | public async Task LaunchesBrowserInProd() 32 | { 33 | //arrange 34 | var env = Substitute.For(); 35 | env.EnvironmentName.Returns(Environments.Production); 36 | 37 | var launcher = Substitute.For(); 38 | 39 | var feature = Substitute.For(); 40 | feature.Addresses.Returns(Addresses); 41 | 42 | var server = Substitute.For(); 43 | server.Features.Get().Returns(feature); 44 | 45 | var app = Substitute.For(); 46 | app.Services.GetService().Returns(launcher); 47 | app.Services.GetService().Returns(server); 48 | 49 | //act 50 | _ = app.Run(env); 51 | 52 | //assert 53 | await app.StopAsync(); 54 | launcher.Received().Run(Arg.Any()); 55 | } 56 | 57 | [Fact] 58 | public async Task DoesNotLaunchBrowserInDev() 59 | { 60 | //arrange 61 | var env = Substitute.For(); 62 | env.EnvironmentName.Returns(Environments.Development); 63 | 64 | var launcher = Substitute.For(); 65 | 66 | var feature = Substitute.For(); 67 | feature.Addresses.Returns(Addresses); 68 | 69 | var server = Substitute.For(); 70 | server.Features.Get().Returns(feature); 71 | 72 | var app = Substitute.For(); 73 | app.Services.GetService().Returns(launcher); 74 | app.Services.GetService().Returns(server); 75 | 76 | //act 77 | _ = app.Run(env); 78 | 79 | //assert 80 | await app.StopAsync(); 81 | launcher.DidNotReceive().Run(Arg.Any()); 82 | } 83 | } -------------------------------------------------------------------------------- /Core.Tests/AppTests.cs: -------------------------------------------------------------------------------- 1 | using Xunit; 2 | 3 | namespace WiFiSurveyor.Core.Tests; 4 | 5 | public sealed class AppTests 6 | { 7 | [Theory] 8 | [InlineData("dev", "Development")] 9 | [InlineData("", "Production")] 10 | public void DevFlagSetsEnvironment(string arg, string expected) 11 | { 12 | //arrange 13 | var args = new[] { arg }; 14 | 15 | //act 16 | var app = new App(_ => { }, args); 17 | 18 | //assert 19 | Assert.Equal(expected, app.Environment.EnvironmentName); 20 | } 21 | } -------------------------------------------------------------------------------- /Core.Tests/CommandServiceTests.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using Microsoft.Extensions.Logging; 3 | using NSubstitute; 4 | using Xunit; 5 | 6 | namespace WiFiSurveyor.Core.Tests; 7 | 8 | public sealed class CommandServiceTests 9 | { 10 | [Fact] 11 | public async Task CanReadFromStdOut() 12 | { 13 | //arrange 14 | var logger = Substitute.For(); 15 | var service = new CommandService(Process.Start, logger); 16 | var info = new ProcessStartInfo("ls"); 17 | 18 | //act 19 | var output = await service.Run(info); 20 | 21 | //assert 22 | Assert.Contains("WiFiSurveyor.Core.Tests.dll", output); 23 | } 24 | 25 | [Fact] 26 | public async Task FailureToLaunchIsHandled() 27 | { 28 | //arrange 29 | 30 | var logger = Substitute.For(); 31 | var service = new CommandService(_ => null!, logger); 32 | var info = new ProcessStartInfo("not installed"); 33 | 34 | //act 35 | var task = service.Run(info); 36 | await task; 37 | 38 | //assert 39 | Assert.True(task.IsCompletedSuccessfully); 40 | } 41 | 42 | [Fact] 43 | public async Task HangingProcessIsHandled() 44 | { 45 | //arrange 46 | var logger = Substitute.For(); 47 | var service = new CommandService(Process.Start, logger, TimeSpan.Zero); 48 | var info = new ProcessStartInfo("sleep", "1"); 49 | 50 | //act 51 | var task = service.Run(info); 52 | await task; 53 | 54 | //assert 55 | Assert.True(task.IsCompletedSuccessfully); 56 | } 57 | } -------------------------------------------------------------------------------- /Core.Tests/Core.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net9.0 4 | WiFiSurveyor.Core.Tests 5 | WiFiSurveyor.Core.Tests 6 | latest 7 | enable 8 | enable 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /Core.Tests/LogHelpersTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Logging; 2 | using NSubstitute; 3 | using Xunit; 4 | 5 | namespace WiFiSurveyor.Core.Tests; 6 | 7 | public sealed class LogHelpersTests 8 | { 9 | [Fact] 10 | public void LogsIfEnabled() 11 | { 12 | //arrange 13 | var logger = Substitute.For(); 14 | logger.IsEnabled(LogLevel.Debug).Returns(true); 15 | 16 | //act 17 | logger.LogIf(LogLevel.Debug, "test"); 18 | 19 | //assert 20 | logger.Received().Log(LogLevel.Debug, "test"); 21 | } 22 | 23 | [Fact] 24 | public void DoesNotLogIfNotEnabled() 25 | { 26 | //arrange 27 | var logger = Substitute.For(); 28 | logger.IsEnabled(LogLevel.Debug).Returns(false); 29 | 30 | //act 31 | logger.LogIf(LogLevel.Debug, "test"); 32 | 33 | //assert 34 | logger.DidNotReceive().Log(LogLevel.Debug, "test"); 35 | } 36 | } -------------------------------------------------------------------------------- /Core.Tests/SignalHubTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.SignalR; 2 | using NSubstitute; 3 | using Xunit; 4 | 5 | namespace WiFiSurveyor.Core.Tests; 6 | 7 | public sealed class SignalHubTests 8 | { 9 | [Fact] 10 | public async Task SendsUpdateToAllClients() 11 | { 12 | //arrange 13 | var context = Substitute.For>(); 14 | context.Clients.All.Returns(Substitute.For()); 15 | 16 | var hub = new SignalHub(context); 17 | 18 | //act 19 | await hub.SendMessage(new Message()); 20 | 21 | //assert 22 | await context.Clients.All.Received().SendCoreAsync("Update", Arg.Is(array => array[0] is Message)); 23 | } 24 | } -------------------------------------------------------------------------------- /Core.Tests/SignalServiceTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Logging; 2 | using NSubstitute; 3 | using Xunit; 4 | 5 | namespace WiFiSurveyor.Core.Tests; 6 | 7 | public sealed class SignalServiceTests 8 | { 9 | [Fact] 10 | public async Task SetsStatusOnException() 11 | { 12 | //arrange 13 | var reader = Substitute.For>(); 14 | reader.When(r => r.Read()).Throw(new Exception("unit test exception")); 15 | var parser = Substitute.For>(); 16 | var hub = Substitute.For(); 17 | var logger = Substitute.For(); 18 | var service = new SignalService(reader, parser, hub, logger); 19 | 20 | //act 21 | await service.StartAsync(CancellationToken.None); 22 | 23 | //assert 24 | await hub.Received().SendMessage(Arg.Is(m => m.Status == "unit test exception")); 25 | } 26 | 27 | [Fact] 28 | public async Task StopsReadingOnStop() 29 | { 30 | //arrange 31 | var reader = Substitute.For>(); 32 | var parser = Substitute.For>(); 33 | var hub = Substitute.For(); 34 | var logger = Substitute.For(); 35 | var service = new SignalService(reader, parser, hub, logger); 36 | var task = service.StartAsync(CancellationToken.None); 37 | 38 | //act 39 | await service.StopAsync(CancellationToken.None); 40 | await task; 41 | 42 | //assert 43 | Assert.True(task.IsCompletedSuccessfully); 44 | } 45 | 46 | [Fact] 47 | public async Task SetsSignalsOnUpdate() 48 | { 49 | //arrange 50 | var reader = Substitute.For>(); 51 | var parser = Substitute.For>(); 52 | var hub = Substitute.For(); 53 | var signals = new List 54 | { 55 | new() 56 | { 57 | SSID = "UnitTest", 58 | Frequency = Frequency._2_4_GHz, 59 | Strength = -30 60 | } 61 | }; 62 | parser.Parse(Arg.Any()).Returns(signals); 63 | var logger = Substitute.For(); 64 | var service = new SignalService(reader, parser, hub, logger); 65 | 66 | //act 67 | await service.StartAsync(CancellationToken.None); 68 | 69 | //assert 70 | await hub.Received().SendMessage(Arg.Is(m => m.Signals.Equals(signals))); 71 | } 72 | } -------------------------------------------------------------------------------- /Core/App.cs: -------------------------------------------------------------------------------- 1 | namespace WiFiSurveyor.Core; 2 | 3 | public sealed class App 4 | { 5 | private readonly IHost _app; 6 | public IWebHostEnvironment Environment { get; } 7 | 8 | private const string BaseURL = "http://127.0.0.1:0"; 9 | 10 | public App(Action addHandlers, string[] args) 11 | { 12 | var options = new WebApplicationOptions 13 | { 14 | Args = args, 15 | EnvironmentName = args.All(a => a != "dev") 16 | ? Environments.Production 17 | : Environments.Development, 18 | WebRootPath = Path.Combine(AppContext.BaseDirectory, "wwwroot", "_content", "WiFiSurveyor.Core") 19 | }; 20 | 21 | var builder = WebApplication.CreateBuilder(options); 22 | Environment = builder.Environment; 23 | 24 | if (Environment.IsProduction()) 25 | { 26 | builder.WebHost.UseUrls(BaseURL); 27 | } 28 | 29 | builder.Services.AddCommonServices(); 30 | addHandlers(builder.Services); 31 | 32 | var app = builder.Build(); 33 | app.AddMiddleware(Environment); 34 | 35 | _app = app; 36 | } 37 | 38 | public async Task Run() 39 | => await _app.Run(Environment); 40 | } -------------------------------------------------------------------------------- /Core/AppHelpers.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using Microsoft.AspNetCore.Hosting.Server; 3 | using Microsoft.AspNetCore.Hosting.Server.Features; 4 | 5 | namespace WiFiSurveyor.Core; 6 | 7 | public static class AppHelpers 8 | { 9 | public static void AddCommonServices(this IServiceCollection services) 10 | { 11 | services.AddSingleton(s => s.GetService()!.CreateLogger("WiFiSurveyor")); 12 | services.AddSingleton>(Process.Start); 13 | services.AddSingleton(); 14 | services.AddCors(); 15 | services.AddLogging(); 16 | services.AddResponseCompression(); 17 | services.AddSignalR(); 18 | } 19 | 20 | public static void AddMiddleware(this IApplicationBuilder app, IHostEnvironment env) 21 | { 22 | var port = env.IsProduction() ? 3000 : 5173; 23 | app.UseCors(builder => builder.AllowCredentials().AllowAnyHeader().WithOrigins($"http://localhost:{port}")); 24 | app.UseResponseCompression(); 25 | app.UseDefaultFiles(); 26 | app.UseStaticFiles(); 27 | app.UseRouting(); 28 | app.UseEndpoints(builder => builder.MapHub("/signals")); 29 | } 30 | 31 | public static void AddPosixHandlers(this IServiceCollection services) 32 | { 33 | services.AddSingleton(); 34 | services.AddHostedService>(); 35 | } 36 | 37 | public static async Task Run(this IHost app, IWebHostEnvironment env) 38 | { 39 | await app.StartAsync(); 40 | 41 | if (!env.IsDevelopment()) 42 | { 43 | app.LaunchBrowser(); 44 | } 45 | 46 | await app.WaitForShutdownAsync(); 47 | } 48 | 49 | private static void LaunchBrowser(this IHost app) 50 | { 51 | var address = app.Services.GetRequiredService() 52 | .Features.Get()! 53 | .Addresses.First(); 54 | 55 | var launcher = app.Services.GetRequiredService(); 56 | 57 | launcher.Run(address); 58 | } 59 | } -------------------------------------------------------------------------------- /Core/BrowserLauncher.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | 3 | namespace WiFiSurveyor.Core; 4 | 5 | public abstract class BrowserLauncher : IBrowserLauncher 6 | { 7 | private readonly string _baseArgs; 8 | private readonly string _command; 9 | private readonly Func _start; 10 | 11 | protected BrowserLauncher(Func start, string command, string baseArgs = "") 12 | { 13 | _start = start; 14 | _command = command; 15 | _baseArgs = baseArgs; 16 | } 17 | 18 | public void Run(string url) 19 | => _start(new ProcessStartInfo(_command, $"{_baseArgs} {url}".Trim())); 20 | } -------------------------------------------------------------------------------- /Core/CommandService.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | 3 | namespace WiFiSurveyor.Core; 4 | 5 | public sealed class CommandService : ICommandService 6 | { 7 | private readonly ILogger _logger; 8 | private readonly Func _startProcess; 9 | private readonly TimeSpan _timeout; 10 | 11 | public CommandService(Func startProcess, ILogger logger, TimeSpan? timeout = null) 12 | { 13 | _startProcess = startProcess; 14 | _logger = logger; 15 | _timeout = timeout ?? TimeSpan.FromSeconds(10); 16 | } 17 | 18 | public async Task Run(ProcessStartInfo info) 19 | { 20 | info.RedirectStandardOutput = true; 21 | _logger.LogIf(LogLevel.Debug, "{now}: Starting \"{cmd} {args}\"...", DateTime.Now, info.FileName, info.Arguments); 22 | 23 | var process = _startProcess(info); 24 | if (process == null) 25 | { 26 | _logger.LogIf(LogLevel.Warning, "{now}: Could not start {cmd}", DateTime.Now, info.FileName); 27 | return await Task.FromResult(string.Empty); 28 | } 29 | 30 | _logger.LogIf(LogLevel.Debug, "{now}: \"{cmd} {args}\" started", DateTime.Now, info.FileName, info.Arguments); 31 | var complete = process.WaitForExit(Convert.ToUInt16(_timeout.TotalMilliseconds)); 32 | 33 | if (complete) 34 | { 35 | _logger.LogIf(LogLevel.Debug, "{now}: Process ended successfully", DateTime.Now); 36 | } 37 | else 38 | { 39 | _logger.LogIf(LogLevel.Warning, "{now}: Process not complete after {time}, forcing to end...", DateTime.Now, _timeout); 40 | process.Kill(true); 41 | } 42 | 43 | return await process.StandardOutput.ReadToEndAsync(); 44 | } 45 | } -------------------------------------------------------------------------------- /Core/Core.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 2.0.0-beta.2 4 | net9.0 5 | Library 6 | WiFiSurveyor.Core 7 | WiFiSurveyor.Core 8 | latest 9 | enable 10 | enable 11 | 12 | -------------------------------------------------------------------------------- /Core/Frequency.cs: -------------------------------------------------------------------------------- 1 | namespace WiFiSurveyor.Core; 2 | 3 | public enum Frequency 4 | { 5 | _2_4_GHz = 2, 6 | _5_GHz = 5 7 | } -------------------------------------------------------------------------------- /Core/IBrowserLauncher.cs: -------------------------------------------------------------------------------- 1 | namespace WiFiSurveyor.Core; 2 | 3 | public interface IBrowserLauncher 4 | { 5 | void Run(string url); 6 | } -------------------------------------------------------------------------------- /Core/ICommandService.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | 3 | namespace WiFiSurveyor.Core; 4 | 5 | public interface ICommandService 6 | { 7 | Task Run(ProcessStartInfo info); 8 | } -------------------------------------------------------------------------------- /Core/ISignalHub.cs: -------------------------------------------------------------------------------- 1 | namespace WiFiSurveyor.Core; 2 | 3 | public interface ISignalHub 4 | { 5 | Task SendMessage(Message message); 6 | } -------------------------------------------------------------------------------- /Core/ISignalParser.cs: -------------------------------------------------------------------------------- 1 | namespace WiFiSurveyor.Core; 2 | 3 | public interface ISignalParser 4 | { 5 | IReadOnlyList Parse(T results); 6 | } -------------------------------------------------------------------------------- /Core/ISignalReader.cs: -------------------------------------------------------------------------------- 1 | namespace WiFiSurveyor.Core; 2 | 3 | public interface ISignalReader 4 | { 5 | Task Read(); 6 | } -------------------------------------------------------------------------------- /Core/LogHelpers.cs: -------------------------------------------------------------------------------- 1 | namespace WiFiSurveyor.Core; 2 | 3 | public static class LogHelpers 4 | { 5 | public static void LogIf(this ILogger logger, LogLevel level, string message, params object[] args) 6 | { 7 | if (logger.IsEnabled(level)) 8 | { 9 | #pragma warning disable CA2254 10 | logger.Log(level, message, args); 11 | #pragma warning restore CA2254 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /Core/Message.cs: -------------------------------------------------------------------------------- 1 | namespace WiFiSurveyor.Core; 2 | 3 | public sealed class Message 4 | { 5 | public string Status { get; init; } = string.Empty; 6 | public IReadOnlyList Signals { get; init; } = new List(); 7 | public DateTime LastUpdated { get; } = DateTime.Now; 8 | } -------------------------------------------------------------------------------- /Core/PosixSignalReader.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel; 2 | using System.Diagnostics; 3 | using System.Reflection; 4 | 5 | namespace WiFiSurveyor.Core; 6 | 7 | public abstract class PosixSignalReader : ISignalReader 8 | { 9 | private readonly ICommandService _commandService; 10 | 11 | protected PosixSignalReader(ICommandService commandService) 12 | => _commandService = commandService; 13 | 14 | protected abstract ProcessStartInfo Info { get; } 15 | 16 | public async Task Read() 17 | { 18 | try 19 | { 20 | return await _commandService.Run(Info); 21 | } 22 | catch (Win32Exception e) 23 | { 24 | switch (e.NativeErrorCode) 25 | { 26 | case 2: 27 | var msg = $"Executable \"{Info.FileName}\" was not found. Please ensure \"wireless-tools\" is installed and \"{Assembly.GetExecutingAssembly().GetName().Name}\" is running as root."; 28 | throw new FileNotFoundException(msg, Info.FileName, e); 29 | default: 30 | throw; 31 | } 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /Core/Signal.cs: -------------------------------------------------------------------------------- 1 | namespace WiFiSurveyor.Core; 2 | 3 | public struct Signal 4 | { 5 | public string MAC { get; set; } 6 | public string SSID { get; set; } 7 | public Frequency Frequency { get; set; } 8 | public byte Channel { get; set; } 9 | public short Strength { get; set; } 10 | } -------------------------------------------------------------------------------- /Core/SignalHub.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.SignalR; 2 | 3 | namespace WiFiSurveyor.Core; 4 | 5 | public sealed class SignalHub : Hub, ISignalHub 6 | { 7 | private readonly IHubContext _context; 8 | 9 | public SignalHub(IHubContext context) 10 | => _context = context; 11 | 12 | public async Task SendMessage(Message message) 13 | => await _context.Clients.All.SendAsync("Update", message); 14 | } -------------------------------------------------------------------------------- /Core/SignalService.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | 3 | namespace WiFiSurveyor.Core; 4 | 5 | public sealed class SignalService : BackgroundService 6 | { 7 | private const ushort interval_ms = 1_000; 8 | private readonly ILogger _logger; 9 | private readonly ISignalHub _signalHub; 10 | private readonly ISignalParser _signalParser; 11 | private readonly ISignalReader _signalReader; 12 | 13 | public SignalService(ISignalReader reader, ISignalParser parser, ISignalHub hub, ILogger logger) 14 | { 15 | _signalReader = reader; 16 | _signalParser = parser; 17 | _signalHub = hub; 18 | _logger = logger; 19 | } 20 | 21 | protected override async Task ExecuteAsync(CancellationToken stoppingToken) 22 | { 23 | while (!stoppingToken.IsCancellationRequested) 24 | { 25 | await GetSignals(); 26 | await Task.Delay(interval_ms, stoppingToken); 27 | } 28 | } 29 | 30 | private async Task GetSignals() 31 | { 32 | try 33 | { 34 | _logger.LogIf(LogLevel.Debug, "{time}: Receiving Wi-Fi signals...", DateTime.Now); 35 | var results = await _signalReader.Read(); 36 | var signals = _signalParser.Parse(results); 37 | 38 | var message = new Message { Signals = signals }; 39 | _logger.LogIf(LogLevel.Debug, "{time}: {signalData}", message.LastUpdated, JsonSerializer.Serialize(signals)); 40 | 41 | await _signalHub.SendMessage(message); 42 | } 43 | catch (Exception e) 44 | { 45 | var message = new Message { Status = e.Message }; 46 | await _signalHub.SendMessage(message); 47 | _logger.LogIf(LogLevel.Error, "{updated}: {status}", message.LastUpdated, message.Status); 48 | _logger.LogIf(LogLevel.Debug, "{exception}", e.ToString()); 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /Core/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information" 5 | } 6 | } 7 | } -------------------------------------------------------------------------------- /Linux.Tests/Linux.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net9.0 4 | WiFiSurveyor.Linux.Tests 5 | WiFiSurveyor.Linux.Tests 6 | enable 7 | enable 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /Linux.Tests/LinuxBrowserLauncherTests.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using Xunit; 3 | 4 | namespace WiFiSurveyor.Linux.Tests; 5 | 6 | public sealed class LinuxBrowserLauncherTests 7 | { 8 | [Fact] 9 | public void RunsCorrectProgram() 10 | { 11 | //arrange 12 | ProcessStartInfo info = null!; 13 | 14 | Process? Start(ProcessStartInfo i) 15 | { 16 | info = i; 17 | return null; 18 | } 19 | 20 | var launcher = new LinuxBrowserLauncher(Start); 21 | 22 | //act 23 | launcher.Run("http://localhost"); 24 | 25 | //assert 26 | Assert.Equal("xdg-open", info.FileName); 27 | } 28 | } -------------------------------------------------------------------------------- /Linux.Tests/LinuxSignalParserTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Logging; 2 | using NSubstitute; 3 | using WiFiSurveyor.Core; 4 | using Xunit; 5 | 6 | namespace WiFiSurveyor.Linux.Tests; 7 | 8 | public sealed class LinuxSignalParserTests 9 | { 10 | [Fact] 11 | public async Task ResultsAreParsedIntoSignals() 12 | { 13 | //arrange 14 | var logger = Substitute.For(); 15 | var signalParser = new LinuxSignalParser(logger); 16 | var contents = await File.ReadAllTextAsync("iwlist-output.txt"); 17 | 18 | //act 19 | var signals = signalParser.Parse(contents).ToList(); 20 | 21 | //assert 22 | Assert.Equal("Net1", signals[0].SSID); 23 | Assert.Equal(Frequency._2_4_GHz, signals[0].Frequency); 24 | Assert.Equal(1, signals[0].Channel); 25 | Assert.Equal(-65, signals[0].Strength); 26 | 27 | Assert.Equal("Net1", signals[1].SSID); 28 | Assert.Equal(Frequency._5_GHz, signals[1].Frequency); 29 | Assert.Equal(36, signals[1].Channel); 30 | Assert.Equal(-83, signals[1].Strength); 31 | 32 | Assert.Equal("Net2🏎", signals[2].SSID); 33 | Assert.Equal(Frequency._2_4_GHz, signals[2].Frequency); 34 | Assert.Equal(5, signals[2].Channel); 35 | Assert.Equal(-72, signals[2].Strength); 36 | 37 | Assert.Equal("Net3", signals[3].SSID); 38 | Assert.Equal(Frequency._2_4_GHz, signals[3].Frequency); 39 | Assert.Equal(6, signals[3].Channel); 40 | Assert.Equal(-90, signals[3].Strength); 41 | } 42 | 43 | [Fact] 44 | public async Task IgnoresInvalidResults() 45 | { 46 | //arrange 47 | var logger = Substitute.For(); 48 | var signalParser = new LinuxSignalParser(logger); 49 | var contents = await File.ReadAllTextAsync("iwlist-output.txt"); 50 | contents = contents 51 | .Replace("-65 dBm", "X") 52 | .Replace("-72 dBm", "X") 53 | .Replace("-90 dBm", "X"); 54 | 55 | //act 56 | var signals = signalParser.Parse(contents); 57 | 58 | //assert 59 | Assert.Equal(-83, signals.Single().Strength); 60 | } 61 | } -------------------------------------------------------------------------------- /Linux.Tests/LinuxSignalReaderTests.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel; 2 | using System.Diagnostics; 3 | using NSubstitute; 4 | using WiFiSurveyor.Core; 5 | using Xunit; 6 | 7 | namespace WiFiSurveyor.Linux.Tests; 8 | 9 | public sealed class LinuxSignalReaderTests 10 | { 11 | [Fact] 12 | public async Task ReturnsOutputFromProcess() 13 | { 14 | //arrange 15 | var commandService = Substitute.For(); 16 | commandService.Run(Arg.Any()).Returns("file contents"); 17 | var reader = new LinuxSignalReader(commandService); 18 | 19 | //act 20 | var results = await reader.Read(); 21 | 22 | //assert 23 | Assert.Equal("file contents", results); 24 | } 25 | 26 | [Fact] 27 | public async Task ReturnsDecentMessageWhenNotFound() 28 | { 29 | //arrange 30 | var exception = new Win32Exception(2, "x"); 31 | var commandService = Substitute.For(); 32 | commandService.When(c => c.Run(Arg.Any())).Throw(exception); 33 | var reader = new LinuxSignalReader(commandService); 34 | 35 | try 36 | { 37 | //act 38 | await reader.Read(); 39 | } 40 | catch (Exception e) 41 | { 42 | //assert 43 | Assert.Contains("\"wireless-tools\" is installed", e.Message); 44 | Assert.Contains("running as root", e.Message); 45 | } 46 | } 47 | 48 | [Fact] 49 | public async Task OtherExceptionsAreThrown() 50 | { 51 | //arrange 52 | var exception = new Win32Exception(1, "other error"); 53 | var commandService = Substitute.For(); 54 | commandService.When(c => c.Run(Arg.Any())).Throw(exception); 55 | var reader = new LinuxSignalReader(commandService); 56 | 57 | try 58 | { 59 | //act 60 | await reader.Read(); 61 | } 62 | catch (Exception e) 63 | { 64 | //assert 65 | Assert.Contains("other error", e.Message); 66 | } 67 | } 68 | } -------------------------------------------------------------------------------- /Linux.Tests/ProgramTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | using WiFiSurveyor.Core; 3 | using Xunit; 4 | 5 | namespace WiFiSurveyor.Linux.Tests; 6 | 7 | public class ProgramTests 8 | { 9 | [Fact] 10 | public void AllServicesAreDefined() 11 | { 12 | //arrange 13 | var services = new ServiceCollection(); 14 | 15 | //act 16 | services.AddLinuxHandlers(); 17 | 18 | //assert 19 | var types = services.Select(s => s.ServiceType).ToArray(); 20 | Assert.Contains(typeof(ISignalParser), types); 21 | Assert.Contains(typeof(ISignalReader), types); 22 | Assert.Contains(typeof(IBrowserLauncher), types); 23 | } 24 | } -------------------------------------------------------------------------------- /Linux.Tests/iwlist-output.txt: -------------------------------------------------------------------------------- 1 | wlan0 Scan completed : 2 | Cell 01 - Address: MAC1 3 | Channel:1 4 | Frequency:2.412 GHz (Channel 1) 5 | Quality=45/70 Signal level=-65 dBm 6 | Encryption key:on 7 | ESSID:"Net1" 8 | Bit Rates:1 Mb/s; 2 Mb/s; 5.5 Mb/s; 11 Mb/s; 18 Mb/s 9 | 24 Mb/s; 36 Mb/s; 54 Mb/s 10 | Bit Rates:6 Mb/s; 9 Mb/s; 12 Mb/s; 48 Mb/s 11 | Mode:Master 12 | Extra:tsf=00000246c2e95dc9 13 | Extra: Last beacon: 3724ms ago 14 | IE: Unknown: 00064465734E6574 15 | IE: Unknown: 010882848B962430486C 16 | IE: Unknown: 030101 17 | IE: Unknown: 2A0100 18 | IE: Unknown: 2F0100 19 | IE: IEEE 802.11i/WPA2 Version 1 20 | Group Cipher : CCMP 21 | Pairwise Ciphers (1) : CCMP 22 | Authentication Suites (1) : PSK 23 | IE: Unknown: 32040C121860 24 | IE: Unknown: 0B0508002B0000 25 | IE: Unknown: 2D1AEF1917FFFFFF0001000000000000000000000000000000000000 26 | IE: Unknown: 3D16010D1600000000000000000000000000000000000000 27 | IE: Unknown: 7F080400080000000040 28 | IE: Unknown: DD090010180208001C0000 29 | IE: Unknown: DD180050F2020101840003A4000027A4000042435E0062322F00 30 | IE: Unknown: 46057208010000 31 | IE: Unknown: DD1F00904C0408BF0CB259820FEAFF0000EAFF0000C0050003000000C303010202 32 | Cell 02 - Address: MAC2 33 | Channel:36 34 | Frequency:5.18 GHz (Channel 36) 35 | Quality=27/70 Signal level=-83 dBm 36 | Encryption key:on 37 | ESSID:"Net1" 38 | Bit Rates:6 Mb/s; 9 Mb/s; 12 Mb/s; 18 Mb/s; 24 Mb/s 39 | 36 Mb/s; 48 Mb/s; 54 Mb/s 40 | Mode:Master 41 | Extra:tsf=000000b671668a43 42 | Extra: Last beacon: 3370ms ago 43 | IE: Unknown: 00064465734E6574 44 | IE: Unknown: 01088C129824B048606C 45 | IE: IEEE 802.11i/WPA2 Version 1 46 | Group Cipher : CCMP 47 | Pairwise Ciphers (1) : CCMP 48 | Authentication Suites (1) : PSK 49 | IE: Unknown: 0B050200050000 50 | IE: Unknown: 2D1AEF0917FFFFFF0000000000000000000000000000000000000000 51 | IE: Unknown: 3D16240D0400000000000000000000000000000000000000 52 | IE: Unknown: 7F080400080000000040 53 | IE: Unknown: BF0CB259820FEAFF0000EAFF0000 54 | IE: Unknown: C005012A000000 55 | IE: Unknown: C30402020202 56 | IE: Unknown: DD090010180202001C0000 57 | IE: Unknown: DD180050F2020101840003A4000027A400004243BC0062326600 58 | IE: Unknown: 46057208010000 59 | Cell 03 - Address: MAC3 60 | Channel:5 61 | Frequency:2.432 GHz (Channel 5) 62 | Quality=38/70 Signal level=-72 dBm 63 | Encryption key:on 64 | ESSID:"Net2🏎" 65 | Bit Rates:1 Mb/s; 2 Mb/s; 5.5 Mb/s; 11 Mb/s; 18 Mb/s 66 | 24 Mb/s; 36 Mb/s; 54 Mb/s 67 | Bit Rates:6 Mb/s; 9 Mb/s; 12 Mb/s; 48 Mb/s 68 | Mode:Master 69 | Extra:tsf=00000108efae4e93 70 | Extra: Last beacon: 3639ms ago 71 | IE: Unknown: 000A48656C6C6F4461766964 72 | IE: Unknown: 010882848B962430486C 73 | IE: Unknown: 030105 74 | IE: Unknown: 23021800 75 | IE: Unknown: 2A0104 76 | IE: Unknown: 32040C121860 77 | IE: IEEE 802.11i/WPA2 Version 1 78 | Group Cipher : CCMP 79 | Pairwise Ciphers (1) : CCMP 80 | Authentication Suites (1) : PSK 81 | IE: Unknown: 0B050100120000 82 | IE: Unknown: 2D1AEF1117FFFFFFFF00000000000000000000000000000000000000 83 | IE: Unknown: 3D1605080000000000000000000000000000000000000000 84 | IE: Unknown: 4A0E14000A002C01C800140005001900 85 | IE: Unknown: 7F080500088001000040 86 | IE: Unknown: FF20230900001200100220024000408300000C00AAFFAAFF3B1CC7711CC7711CC771 87 | IE: Unknown: FF072404000001FCFF 88 | IE: Unknown: FF022700 89 | IE: Unknown: FF0E260800A40820A408404308603208 90 | IE: Unknown: DD7C0050F204104A0001101044000102103B00010310470010D9823B698350025C5833297A259CFED01021000D4E4554474541522C20496E632E10230005434158383010240005434158383010420004313233341054000800060050F2040001101100054341583830100800026008103C0001031049000600372A000120 91 | IE: Unknown: DD1E00904C0418BF0CB169830FAAFF0000AAFF0000C0050005000000C3020033 92 | IE: Unknown: DD090010180201001C0000 93 | IE: Unknown: DD180050F2020101880003A4000027A4000042435E0062322F00 94 | IE: Unknown: 6C027F00 95 | IE: Unknown: DD07506F9A16010100 96 | Cell 04 - Address: MAC4 97 | Channel:6 98 | Frequency:2.437 GHz (Channel 6) 99 | Quality=20/70 Signal level=-90 dBm 100 | Encryption key:on 101 | ESSID:"Net3" 102 | Bit Rates:1 Mb/s; 2 Mb/s; 5.5 Mb/s; 11 Mb/s; 18 Mb/s 103 | 24 Mb/s; 36 Mb/s; 54 Mb/s 104 | Bit Rates:6 Mb/s; 9 Mb/s; 12 Mb/s; 48 Mb/s 105 | Mode:Master 106 | Extra:tsf=00000015cb08ccc4 107 | Extra: Last beacon: 3528ms ago 108 | IE: Unknown: 000B4C616D622046616D696C79 109 | IE: Unknown: 010882848B962430486C 110 | IE: Unknown: 030106 111 | IE: Unknown: 2A0104 112 | IE: Unknown: 2F0104 113 | IE: IEEE 802.11i/WPA2 Version 1 114 | Group Cipher : CCMP 115 | Pairwise Ciphers (1) : CCMP 116 | Authentication Suites (1) : PSK 117 | IE: Unknown: 32040C121860 118 | IE: Unknown: 2D1AFC191BFFFF000000000000000000000000000000000000000000 119 | IE: Unknown: 3D1606080400000000000000000000000000000000000000 120 | IE: Unknown: 4A0E14000A002C01C800140005001900 121 | IE: Unknown: 7F0101 122 | IE: Unknown: DDAE0050F204104A0001101044000102103B00010310470010500102538AA0644130D63ACD3802D8B3102100154153555354654B20436F6D707574657220496E632E1023001C57692D46692050726F74656374656420536574757020526F757465721024000852542D4E313244311042001135343A61303A35303A64633A38343A32381054000800060050F20400011011000852542D4E31324431100800022008103C0001011049000600372A000120 123 | IE: Unknown: DD090010180203F02C0000 124 | IE: Unknown: DD180050F2020101800003A4000027A4000042435E0062322F00 -------------------------------------------------------------------------------- /Linux/Linux.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 2.0.0-beta.2 4 | Exe 5 | net9.0 6 | WiFiSurveyor 7 | WiFiSurveyor.Linux 8 | linux-x64 9 | true 10 | enable 11 | enable 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /Linux/LinuxBrowserLauncher.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using WiFiSurveyor.Core; 3 | 4 | namespace WiFiSurveyor.Linux; 5 | 6 | public sealed class LinuxBrowserLauncher : BrowserLauncher 7 | { 8 | public LinuxBrowserLauncher(Func start) : base(start, "xdg-open") 9 | { 10 | } 11 | } -------------------------------------------------------------------------------- /Linux/LinuxSignalParser.cs: -------------------------------------------------------------------------------- 1 | using WiFiSurveyor.Core; 2 | 3 | namespace WiFiSurveyor.Linux; 4 | 5 | public sealed class LinuxSignalParser : ISignalParser 6 | { 7 | private readonly ILogger _logger; 8 | 9 | public LinuxSignalParser(ILogger logger) 10 | => _logger = logger; 11 | 12 | public IReadOnlyList Parse(string results) 13 | => results 14 | .Split(" Cell ") 15 | .Skip(1) 16 | .Select(GetSignal) 17 | .Where(s => s is not null) 18 | .Cast() 19 | .ToArray(); 20 | 21 | private Signal? GetSignal(string result) 22 | { 23 | try 24 | { 25 | var mac = Patterns.Address().Match(result).Groups[1].Value; 26 | var ssid = Patterns.SSID().Match(result).Groups[1].Value; 27 | var freq = Patterns.Frequency().Match(result).Groups[1].Value; 28 | var channel = Patterns.Channel().Match(result).Groups[1].Value; 29 | var dbm = Patterns.Signal().Match(result).Groups[1].Value; 30 | 31 | return new Signal 32 | { 33 | MAC = mac, 34 | SSID = ssid.Replace(@"\x00", ""), 35 | Frequency = freq == "2" ? Frequency._2_4_GHz : Frequency._5_GHz, 36 | Channel = byte.Parse(channel), 37 | Strength = short.Parse(dbm) 38 | }; 39 | } 40 | catch (Exception e) 41 | { 42 | _logger.LogIf(LogLevel.Warning, "{now}: Could not parse signal data -- {result}", DateTime.Now, result); 43 | _logger.LogIf(LogLevel.Debug, "{exception}", e.ToString()); 44 | return null; 45 | } 46 | } 47 | } -------------------------------------------------------------------------------- /Linux/LinuxSignalReader.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using WiFiSurveyor.Core; 3 | 4 | namespace WiFiSurveyor.Linux; 5 | 6 | public sealed class LinuxSignalReader : PosixSignalReader 7 | { 8 | public LinuxSignalReader(ICommandService commandService) : base(commandService) 9 | { 10 | } 11 | 12 | protected override ProcessStartInfo Info 13 | => new("/sbin/iwlist", "wlan0 scanning"); 14 | } -------------------------------------------------------------------------------- /Linux/Patterns.cs: -------------------------------------------------------------------------------- 1 | using System.Text.RegularExpressions; 2 | 3 | namespace WiFiSurveyor.Linux; 4 | 5 | public static partial class Patterns 6 | { 7 | [GeneratedRegex("Address: (.+)")] 8 | public static partial Regex Address(); 9 | 10 | [GeneratedRegex("SSID:\"(.+)\"")] 11 | public static partial Regex SSID(); 12 | 13 | [GeneratedRegex("Frequency:(\\d)")] 14 | public static partial Regex Frequency(); 15 | 16 | [GeneratedRegex("Channel:(\\d+)")] 17 | public static partial Regex Channel(); 18 | 19 | [GeneratedRegex("Signal level=(-\\d+)")] 20 | public static partial Regex Signal(); 21 | } -------------------------------------------------------------------------------- /Linux/Program.cs: -------------------------------------------------------------------------------- 1 | using WiFiSurveyor.Core; 2 | 3 | namespace WiFiSurveyor.Linux; 4 | 5 | public static class Program 6 | { 7 | public static void AddLinuxHandlers(this IServiceCollection services) 8 | { 9 | services.AddPosixHandlers(); 10 | services.AddSingleton(); 11 | services.AddSingleton, LinuxSignalReader>(); 12 | services.AddSingleton, LinuxSignalParser>(); 13 | } 14 | 15 | public static async Task Main(string[] args) 16 | => await new App(AddLinuxHandlers, args).Run(); 17 | } -------------------------------------------------------------------------------- /Mac.Tests/Mac.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net9.0 4 | WiFiSurveyor.Mac.Tests 5 | WiFiSurveyor.Mac.Tests 6 | enable 7 | enable 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /Mac.Tests/MacBrowserLauncherTests.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using Xunit; 3 | 4 | namespace WiFiSurveyor.Mac.Tests; 5 | 6 | public sealed class MacBrowserLauncherTests 7 | { 8 | [Fact] 9 | public void RunsCorrectProgram() 10 | { 11 | //arrange 12 | ProcessStartInfo info = null!; 13 | 14 | Process? Start(ProcessStartInfo i) 15 | { 16 | info = i; 17 | return null; 18 | } 19 | var launcher = new MacBrowserLauncher(Start); 20 | 21 | //act 22 | launcher.Run("http://localhost"); 23 | 24 | //assert 25 | Assert.Equal("open", info.FileName); 26 | } 27 | } -------------------------------------------------------------------------------- /Mac.Tests/MacSignalParserTests.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | using Microsoft.Extensions.Logging; 5 | using NSubstitute; 6 | using WiFiSurveyor.Core; 7 | using Xunit; 8 | 9 | namespace WiFiSurveyor.Mac.Tests; 10 | 11 | public sealed class MacSignalParserTests 12 | { 13 | [Fact] 14 | public async Task ResultsAreParsedIntoSignals() 15 | { 16 | //arrange 17 | var logger = Substitute.For(); 18 | var signalParser = new MacSignalParser(logger); 19 | var contents = await File.ReadAllTextAsync("system_profiler-output.txt"); 20 | 21 | //act 22 | var signals = signalParser.Parse(contents).ToList(); 23 | 24 | //assert 25 | Assert.Equal("net1", signals[0].SSID); 26 | Assert.Empty(signals[0].MAC); 27 | Assert.Equal(Frequency._2_4_GHz, signals[0].Frequency); 28 | Assert.Equal(9, signals[0].Channel); 29 | Assert.Equal(-39, signals[0].Strength); 30 | 31 | Assert.Equal("ssid🏎2", signals[1].SSID); 32 | Assert.Empty(signals[1].MAC); 33 | Assert.Equal(Frequency._5_GHz, signals[1].Frequency); 34 | Assert.Equal(44, signals[1].Channel); 35 | Assert.Equal(-50, signals[1].Strength); 36 | 37 | Assert.Equal("access_point_3", signals[2].SSID); 38 | Assert.Empty(signals[2].MAC); 39 | Assert.Equal(Frequency._5_GHz, signals[2].Frequency); 40 | Assert.Equal(149, signals[2].Channel); 41 | Assert.Equal(-42, signals[2].Strength); 42 | 43 | Assert.Equal("wap-4", signals[3].SSID); 44 | Assert.Empty(signals[3].MAC); 45 | Assert.Equal(Frequency._2_4_GHz, signals[3].Frequency); 46 | Assert.Equal(11, signals[3].Channel); 47 | Assert.Equal(-90, signals[3].Strength); 48 | } 49 | } -------------------------------------------------------------------------------- /Mac.Tests/MacSignalReaderTests.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using System.Threading.Tasks; 3 | using NSubstitute; 4 | using WiFiSurveyor.Core; 5 | using Xunit; 6 | 7 | namespace WiFiSurveyor.Mac.Tests; 8 | 9 | public sealed class MacSignalReaderTests 10 | { 11 | [Fact] 12 | public async Task ReturnsOutputFromProcess() 13 | { 14 | //arrange 15 | var commandService = Substitute.For(); 16 | commandService.Run(Arg.Any()).Returns("file contents"); 17 | var reader = new MacSignalReader(commandService); 18 | 19 | //act 20 | var results = await reader.Read(); 21 | 22 | //assert 23 | Assert.Equal("file contents", results); 24 | } 25 | } -------------------------------------------------------------------------------- /Mac.Tests/ProgramTests.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using WiFiSurveyor.Core; 4 | using Xunit; 5 | 6 | namespace WiFiSurveyor.Mac.Tests; 7 | 8 | public class ProgramTests 9 | { 10 | [Fact] 11 | public void AllServicesAreDefined() 12 | { 13 | //arrange 14 | var services = new ServiceCollection(); 15 | 16 | //act 17 | services.AddMacHandlers(); 18 | 19 | //assert 20 | var types = services.Select(s => s.ServiceType).ToArray(); 21 | Assert.Contains(typeof(ISignalParser), types); 22 | Assert.Contains(typeof(ISignalReader), types); 23 | Assert.Contains(typeof(IBrowserLauncher), types); 24 | } 25 | } -------------------------------------------------------------------------------- /Mac.Tests/system_profiler-output.txt: -------------------------------------------------------------------------------- 1 | { 2 | "SPAirPortDataType" : [ 3 | { 4 | "spairport_airport_interfaces" : [ 5 | { 6 | "_name" : "en0", 7 | "spairport_airport_other_local_wireless_networks" : [ 8 | { 9 | "_name" : "net1", 10 | "spairport_network_channel" : "9 (2GHz, 40MHz)", 11 | "spairport_network_phymode" : "802.11", 12 | "spairport_network_type" : "spairport_network_type_station", 13 | "spairport_security_mode" : "pairport_security_mode_wpa3_transition", 14 | "spairport_signal_noise" : "-39 dBm / -90 dBm" 15 | }, 16 | { 17 | "_name" : "ssid🏎2", 18 | "spairport_network_channel" : "44 (5GHz, 80MHz)", 19 | "spairport_network_phymode" : "802.11", 20 | "spairport_network_type" : "spairport_network_type_station", 21 | "spairport_security_mode" : "pairport_security_mode_wpa3_transition", 22 | "spairport_signal_noise" : "-50 dBm / -90 dBm" 23 | }, 24 | { 25 | "_name" : "access_point_3", 26 | "spairport_network_channel" : "149 (5GHz, 80MHz)", 27 | "spairport_network_phymode" : "802.11", 28 | "spairport_network_type" : "spairport_network_type_station", 29 | "spairport_security_mode" : "pairport_security_mode_wpa3_transition", 30 | "spairport_signal_noise" : "-42 dBm / -90 dBm" 31 | }, 32 | { 33 | "_name" : "wap-4", 34 | "spairport_network_channel" : "11 (2GHz, 40MHz)", 35 | "spairport_network_phymode" : "802.11", 36 | "spairport_network_type" : "spairport_network_type_station", 37 | "spairport_security_mode" : "spairport_security_mode_wpa2_personal", 38 | "spairport_signal_noise" : "-90 dBm / -90 dBm" 39 | } 40 | ], 41 | "spairport_caps_airdrop" : "spairport_caps_supported", 42 | "spairport_caps_autounlock" : "spairport_caps_supported", 43 | "spairport_caps_wow" : "spairport_caps_supported", 44 | "spairport_current_network_information" : { 45 | "_name" : "net1", 46 | "spairport_network_channel" : "149 (5GHz, 80MHz)", 47 | "spairport_network_country_code" : "US", 48 | "spairport_network_mcs" : 7, 49 | "spairport_network_phymode" : "802.11ac", 50 | "spairport_network_rate" : 975, 51 | "spairport_network_type" : "spairport_network_type_station", 52 | "spairport_security_mode" : "pairport_security_mode_wpa3_transition", 53 | "spairport_signal_noise" : "-48 dBm / -90 dBm" 54 | }, 55 | "spairport_status_information" : "spairport_status_connected", 56 | "spairport_supported_channels" : [ 57 | "1 (2GHz)", 58 | "2 (2GHz)", 59 | "3 (2GHz)", 60 | "4 (2GHz)", 61 | "5 (2GHz)", 62 | "6 (2GHz)", 63 | "7 (2GHz)", 64 | "8 (2GHz)", 65 | "9 (2GHz)", 66 | "10 (2GHz)", 67 | "11 (2GHz)", 68 | "12 (2GHz)", 69 | "13 (2GHz)", 70 | "36 (5GHz)", 71 | "40 (5GHz)", 72 | "44 (5GHz)", 73 | "48 (5GHz)", 74 | "52 (5GHz)", 75 | "56 (5GHz)", 76 | "60 (5GHz)", 77 | "64 (5GHz)", 78 | "100 (5GHz)", 79 | "104 (5GHz)", 80 | "108 (5GHz)", 81 | "112 (5GHz)", 82 | "116 (5GHz)", 83 | "120 (5GHz)", 84 | "124 (5GHz)", 85 | "128 (5GHz)", 86 | "132 (5GHz)", 87 | "136 (5GHz)", 88 | "140 (5GHz)", 89 | "144 (5GHz)", 90 | "149 (5GHz)", 91 | "153 (5GHz)", 92 | "157 (5GHz)", 93 | "161 (5GHz)", 94 | "165 (5GHz)" 95 | ], 96 | "spairport_supported_phymodes" : "802.11 a/b/g/n/ac", 97 | "spairport_wireless_card_type" : "spairport_wireless_card_type_wifi (0x14E4, 0x7BF)", 98 | "spairport_wireless_country_code" : "US", 99 | "spairport_wireless_firmware_version" : "wl0: Jul 10 2023 12:30:19 version 9.30.503.0.32.5.92 FWID 01-88a8883", 100 | "spairport_wireless_locale" : "ETSI", 101 | "spairport_wireless_mac_address" : "f0:18:98:2b:b7:78" 102 | }, 103 | { 104 | "_name" : "awdl0", 105 | "spairport_current_network_information" : { 106 | "spairport_network_type" : "spairport_network_type_station" 107 | }, 108 | "spairport_supported_channels" : [ 109 | "1 (2GHz)", 110 | "2 (2GHz)", 111 | "3 (2GHz)", 112 | "4 (2GHz)", 113 | "5 (2GHz)", 114 | "6 (2GHz)", 115 | "7 (2GHz)", 116 | "8 (2GHz)", 117 | "9 (2GHz)", 118 | "10 (2GHz)", 119 | "11 (2GHz)", 120 | "12 (2GHz)", 121 | "13 (2GHz)", 122 | "36 (5GHz)", 123 | "40 (5GHz)", 124 | "44 (5GHz)", 125 | "48 (5GHz)", 126 | "52 (5GHz)", 127 | "56 (5GHz)", 128 | "60 (5GHz)", 129 | "64 (5GHz)", 130 | "100 (5GHz)", 131 | "104 (5GHz)", 132 | "108 (5GHz)", 133 | "112 (5GHz)", 134 | "116 (5GHz)", 135 | "120 (5GHz)", 136 | "124 (5GHz)", 137 | "128 (5GHz)", 138 | "132 (5GHz)", 139 | "136 (5GHz)", 140 | "140 (5GHz)", 141 | "144 (5GHz)", 142 | "149 (5GHz)", 143 | "153 (5GHz)", 144 | "157 (5GHz)", 145 | "161 (5GHz)", 146 | "165 (5GHz)" 147 | ], 148 | "spairport_wireless_mac_address" : "5e:01:d5:ed:24:a0" 149 | } 150 | ], 151 | "spairport_software_information" : { 152 | "spairport_corewlan_version" : "16.0 (1657)", 153 | "spairport_corewlankit_version" : "16.0 (1657)", 154 | "spairport_diagnostics_version" : "11.0 (1163)", 155 | "spairport_extra_version" : "17.0 (1728)", 156 | "spairport_family_version" : "12.0 (1200.13.0)", 157 | "spairport_profiler_version" : "15.0 (1502)", 158 | "spairport_utility_version" : "6.3.9 (639.23)" 159 | } 160 | } 161 | ] 162 | } -------------------------------------------------------------------------------- /Mac/Mac.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 2.0.0-beta.2 4 | Exe 5 | net9.0 6 | WiFiSurveyor 7 | WiFiSurveyor.Mac 8 | osx-x64 9 | true 10 | enable 11 | enable 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /Mac/MacBrowserLauncher.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using WiFiSurveyor.Core; 3 | 4 | namespace WiFiSurveyor.Mac; 5 | 6 | public sealed class MacBrowserLauncher : BrowserLauncher 7 | { 8 | public MacBrowserLauncher(Func start) : base(start, "open") 9 | { 10 | } 11 | } -------------------------------------------------------------------------------- /Mac/MacSignalParser.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | using System.Text.Json; 3 | using WiFiSurveyor.Core; 4 | 5 | namespace WiFiSurveyor.Mac; 6 | 7 | public sealed class MacSignalParser : ISignalParser 8 | { 9 | private readonly ILogger _logger; 10 | 11 | public MacSignalParser(ILogger logger) 12 | => _logger = logger; 13 | 14 | public IReadOnlyList Parse(string results) 15 | => JsonSerializer.Deserialize(results) 16 | .GetProperty("SPAirPortDataType").EnumerateArray().First() 17 | .GetProperty("spairport_airport_interfaces").EnumerateArray().First() 18 | .GetProperty("spairport_airport_other_local_wireless_networks").EnumerateArray() 19 | .Select(j => GetSignal(j)) 20 | .Where(s => s is not null) 21 | .Cast() 22 | .ToArray(); 23 | 24 | private Signal? GetSignal(JsonElement json) 25 | { 26 | try 27 | { 28 | return new Signal 29 | { 30 | SSID = GetString(json, "_name"), 31 | MAC = string.Empty, 32 | Strength = GetStrength(GetString(json, "spairport_signal_noise")), 33 | Channel = GetChannel(GetString(json, "spairport_network_channel")), 34 | Frequency = GetFrequency(GetString(json, "spairport_network_channel")) 35 | }; 36 | } 37 | catch (Exception e) 38 | { 39 | _logger.LogIf(LogLevel.Warning, "{now}: Could not parse signal data -- {data}", DateTime.Now, json.ToString()); 40 | _logger.LogIf(LogLevel.Debug, "{exception}", e.ToString()); 41 | return null; 42 | } 43 | } 44 | 45 | private static string GetString(JsonElement json, string property) 46 | => json.GetProperty(property).GetString() ?? string.Empty; 47 | 48 | private static short GetStrength(string value) 49 | => short.Parse(value.Split(' ')[0]); 50 | 51 | private static Frequency GetFrequency(string value) 52 | => GetChannel(value) < 32 53 | ? Frequency._2_4_GHz 54 | : Frequency._5_GHz; 55 | 56 | private static byte GetChannel(string value) 57 | => byte.Parse(value.Split(' ')[0]); 58 | } -------------------------------------------------------------------------------- /Mac/MacSignalReader.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using WiFiSurveyor.Core; 3 | 4 | namespace WiFiSurveyor.Mac; 5 | 6 | public sealed class MacSignalReader : PosixSignalReader 7 | { 8 | public MacSignalReader(ICommandService commandService) : base(commandService) 9 | { 10 | } 11 | 12 | protected override ProcessStartInfo Info => new("system_profiler", "SPAirPortDataType -detailLevel full -json"); 13 | } -------------------------------------------------------------------------------- /Mac/Program.cs: -------------------------------------------------------------------------------- 1 | using WiFiSurveyor.Core; 2 | 3 | namespace WiFiSurveyor.Mac; 4 | 5 | public static class Program 6 | { 7 | public static void AddMacHandlers(this IServiceCollection services) 8 | { 9 | services.AddPosixHandlers(); 10 | services.AddSingleton(); 11 | services.AddSingleton, MacSignalReader>(); 12 | services.AddSingleton, MacSignalParser>(); 13 | } 14 | 15 | public static async Task Main(string[] args) 16 | => await new App(AddMacHandlers, args).Run(); 17 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Wi-Fi Surveyor 2 | 3 | [![Latest Release](https://img.shields.io/github/v/release/ecoAPM/WiFiSurveyor?label=Install&logo=github&include_prereleases)](https://github.com/ecoAPM/WiFiSurveyor/releases) 4 | [![Build Status](https://github.com/ecoAPM/WiFiSurveyor/workflows/CI/badge.svg)](https://github.com/ecoAPM/WiFiSurveyor/actions) 5 | 6 | App 7 | [![App Coverage](https://sonarcloud.io/api/project_badges/measure?project=ecoAPM_WiFiSurveyor-App&metric=coverage)](https://sonarcloud.io/summary/overall?id=ecoAPM_WiFiSurveyor-App) 8 | [![App Maintainability](https://sonarcloud.io/api/project_badges/measure?project=ecoAPM_WiFiSurveyor-App&metric=sqale_rating)](https://sonarcloud.io/summary/overall?id=ecoAPM_WiFiSurveyor-App) 9 | [![App Reliability](https://sonarcloud.io/api/project_badges/measure?project=ecoAPM_WiFiSurveyor-App&metric=reliability_rating)](https://sonarcloud.io/summary/overall?id=ecoAPM_WiFiSurveyor-App) 10 | [![App Security](https://sonarcloud.io/api/project_badges/measure?project=ecoAPM_WiFiSurveyor-App&metric=security_rating)](https://sonarcloud.io/summary/overall?id=ecoAPM_WiFiSurveyor-App) 11 | 12 | Server 13 | [![Server Coverage](https://sonarcloud.io/api/project_badges/measure?project=ecoAPM_WiFiSurveyor-Server&metric=coverage)](https://sonarcloud.io/summary/overall?id=ecoAPM_WiFiSurveyor-Server) 14 | [![Server Maintainability](https://sonarcloud.io/api/project_badges/measure?project=ecoAPM_WiFiSurveyor-Server&metric=sqale_rating)](https://sonarcloud.io/summary/overall?id=ecoAPM_WiFiSurveyor-Server) 15 | [![Server Reliability](https://sonarcloud.io/api/project_badges/measure?project=ecoAPM_WiFiSurveyor-Server&metric=reliability_rating)](https://sonarcloud.io/summary/overall?id=ecoAPM_WiFiSurveyor-Server) 16 | [![Server Security](https://sonarcloud.io/api/project_badges/measure?project=ecoAPM_WiFiSurveyor-Server&metric=security_rating)](https://sonarcloud.io/summary/overall?id=ecoAPM_WiFiSurveyor-Server) 17 | 18 | Visualize Wi-Fi signal strength over a geographic area 19 | 20 | ## Quick Start 21 | 22 | 1. Download and extract the [latest release](https://github.com/ecoAPM/WiFiSurveyor/releases) for your operating system 23 | 24 | 1. Extract the archive to your directory of choice 25 | 26 | 1. Launch the executable for your OS: 27 | - `WiFiSurveyor.exe` on Windows 28 | - `WiFiSurveyor` on MacOS 29 | - `sudo ./WiFiSurveyor` on Linux (must be `root`) 30 | 31 | 1. Wait for the app to appear in your browser 32 | 33 | 1. Under "Background", select a floorplan or map image representing the area to survey 34 | 35 | 1. Select your SSID from the "Access Point" dropdown menu 36 | 37 | 1. Traverse the area to survey, clicking on corresponding map points that represent your location 38 | 39 | 1. Once data has been collected, select other access points or change filters to display updated coverage 40 | 41 | 1. Save your data to be loaded again later, or shared with other users 42 | 43 | ### Access Point Filters 44 | 45 | - The default selection of both "Group by SSID" and "Combine 2.4 + 5GHz" will show one option per SSID 46 | 47 | - Selecting only "Group by SSID" will show one option for each frequency that an SSID receives 48 | 49 | - Unselecting both "Group by SSID" (which also disables "Combine 2.4 + 5GHz") will show every device for every SSID available, on both frequencies 50 | 51 | ### Selecting a background 52 | 53 | - Supports all file types used for CSS `background-image` 54 | 55 | - The "Pixelate" option is good for floor plans with low resolutions (less than 1px/in²) so straight lines maintain hard edges 56 | 57 | ### Saving/loading data 58 | 59 | - Saves all signal data as a JSON file 60 | 61 | - Loading this file again will restore all data points and signal info from the file 62 | 63 | - See limitation about browser window sizes below 64 | 65 | ## Limitations 66 | 67 | Contibutions are welcome for improving the following: 68 | 69 | - Linux uses the device named `wlan0` 70 | 71 | - Windows uses the "first" Wi-Fi adapter 72 | 73 | - Resizing the browser window will not scale readings with the background image: once you start taking readings, don't resize your window (rotating your device and rotating back should be OK) 74 | 75 | ## Contributing 76 | 77 | ### Requirements 78 | 79 | - .NET SDK 80 | - Node.JS with `yarn` 81 | 82 | ### Building from source 83 | 84 | - Run `dotnet run --project {Linux|Mac|Windows} -- dev` (with `sudo` for Linux) from the repo root directory to start the back-end server 85 | - Run `yarn dev` from the repo root directory to start the front-end development server 86 | - Browse to `http://localhost:3000` 87 | - Back-end and front-end can be stopped and restarted independently during inner dev loop 88 | 89 | ### Running tests 90 | 91 | - Run `dotnet test` from the repo root directory for back-end tests 92 | - Run `yarn test` from the repo root directory for front-end tests 93 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Security updates are generally only applied to the newest release, and backported on an as-needed basis where appropriate. 6 | 7 | ## Reporting a Vulnerability 8 | 9 | Unless a vulnerability is deemed critical or exposes PII, please create an issue in this repository using the "Report a Bug" template. 10 | 11 | For critical vulnerabilities, or those that expose PII, please email info@ecoAPM.com so that the issue can be fixed confidentially, prior to public disclosure. 12 | -------------------------------------------------------------------------------- /WiFiSurveyor.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.30309.148 5 | MinimumVisualStudioVersion = 15.0.26124.0 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core", "Core\Core.csproj", "{32A1DEAC-739C-45CF-9610-F8802949CE7A}" 7 | EndProject 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core.Tests", "Core.Tests\Core.Tests.csproj", "{1BA8141D-4C31-4109-8C4F-F691B47C8939}" 9 | EndProject 10 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Windows", "Windows\Windows.csproj", "{9ADF6A9D-682B-46D2-8708-E6F061C4D06E}" 11 | EndProject 12 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Windows.Tests", "Windows.Tests\Windows.Tests.csproj", "{762D9455-9433-4243-BB5A-30F456B70B7F}" 13 | EndProject 14 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Linux", "Linux\Linux.csproj", "{4162C6A4-0C05-4370-BFCD-D629D023B647}" 15 | EndProject 16 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Linux.Tests", "Linux.Tests\Linux.Tests.csproj", "{331829F2-A6F6-4DB5-9CA9-33D2C2ADA668}" 17 | EndProject 18 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mac", "Mac\Mac.csproj", "{95EE03F0-8D38-4851-BCB7-332AC9D0EC4A}" 19 | EndProject 20 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mac.Tests", "Mac.Tests\Mac.Tests.csproj", "{D6C6BF3B-74A4-4A04-9056-FEB146A9E326}" 21 | EndProject 22 | Global 23 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 24 | Debug|Any CPU = Debug|Any CPU 25 | Debug|x64 = Debug|x64 26 | Debug|x86 = Debug|x86 27 | Release|Any CPU = Release|Any CPU 28 | Release|x64 = Release|x64 29 | Release|x86 = Release|x86 30 | EndGlobalSection 31 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 32 | {32A1DEAC-739C-45CF-9610-F8802949CE7A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 33 | {32A1DEAC-739C-45CF-9610-F8802949CE7A}.Debug|Any CPU.Build.0 = Debug|Any CPU 34 | {32A1DEAC-739C-45CF-9610-F8802949CE7A}.Debug|x64.ActiveCfg = Debug|Any CPU 35 | {32A1DEAC-739C-45CF-9610-F8802949CE7A}.Debug|x64.Build.0 = Debug|Any CPU 36 | {32A1DEAC-739C-45CF-9610-F8802949CE7A}.Debug|x86.ActiveCfg = Debug|Any CPU 37 | {32A1DEAC-739C-45CF-9610-F8802949CE7A}.Debug|x86.Build.0 = Debug|Any CPU 38 | {32A1DEAC-739C-45CF-9610-F8802949CE7A}.Release|Any CPU.ActiveCfg = Release|Any CPU 39 | {32A1DEAC-739C-45CF-9610-F8802949CE7A}.Release|Any CPU.Build.0 = Release|Any CPU 40 | {32A1DEAC-739C-45CF-9610-F8802949CE7A}.Release|x64.ActiveCfg = Release|Any CPU 41 | {32A1DEAC-739C-45CF-9610-F8802949CE7A}.Release|x64.Build.0 = Release|Any CPU 42 | {32A1DEAC-739C-45CF-9610-F8802949CE7A}.Release|x86.ActiveCfg = Release|Any CPU 43 | {32A1DEAC-739C-45CF-9610-F8802949CE7A}.Release|x86.Build.0 = Release|Any CPU 44 | {1BA8141D-4C31-4109-8C4F-F691B47C8939}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 45 | {1BA8141D-4C31-4109-8C4F-F691B47C8939}.Debug|Any CPU.Build.0 = Debug|Any CPU 46 | {1BA8141D-4C31-4109-8C4F-F691B47C8939}.Debug|x64.ActiveCfg = Debug|Any CPU 47 | {1BA8141D-4C31-4109-8C4F-F691B47C8939}.Debug|x64.Build.0 = Debug|Any CPU 48 | {1BA8141D-4C31-4109-8C4F-F691B47C8939}.Debug|x86.ActiveCfg = Debug|Any CPU 49 | {1BA8141D-4C31-4109-8C4F-F691B47C8939}.Debug|x86.Build.0 = Debug|Any CPU 50 | {1BA8141D-4C31-4109-8C4F-F691B47C8939}.Release|Any CPU.ActiveCfg = Release|Any CPU 51 | {1BA8141D-4C31-4109-8C4F-F691B47C8939}.Release|Any CPU.Build.0 = Release|Any CPU 52 | {1BA8141D-4C31-4109-8C4F-F691B47C8939}.Release|x64.ActiveCfg = Release|Any CPU 53 | {1BA8141D-4C31-4109-8C4F-F691B47C8939}.Release|x64.Build.0 = Release|Any CPU 54 | {1BA8141D-4C31-4109-8C4F-F691B47C8939}.Release|x86.ActiveCfg = Release|Any CPU 55 | {1BA8141D-4C31-4109-8C4F-F691B47C8939}.Release|x86.Build.0 = Release|Any CPU 56 | {9ADF6A9D-682B-46D2-8708-E6F061C4D06E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 57 | {9ADF6A9D-682B-46D2-8708-E6F061C4D06E}.Debug|Any CPU.Build.0 = Debug|Any CPU 58 | {9ADF6A9D-682B-46D2-8708-E6F061C4D06E}.Debug|x64.ActiveCfg = Debug|Any CPU 59 | {9ADF6A9D-682B-46D2-8708-E6F061C4D06E}.Debug|x64.Build.0 = Debug|Any CPU 60 | {9ADF6A9D-682B-46D2-8708-E6F061C4D06E}.Debug|x86.ActiveCfg = Debug|Any CPU 61 | {9ADF6A9D-682B-46D2-8708-E6F061C4D06E}.Debug|x86.Build.0 = Debug|Any CPU 62 | {9ADF6A9D-682B-46D2-8708-E6F061C4D06E}.Release|Any CPU.ActiveCfg = Release|Any CPU 63 | {9ADF6A9D-682B-46D2-8708-E6F061C4D06E}.Release|Any CPU.Build.0 = Release|Any CPU 64 | {9ADF6A9D-682B-46D2-8708-E6F061C4D06E}.Release|x64.ActiveCfg = Release|Any CPU 65 | {9ADF6A9D-682B-46D2-8708-E6F061C4D06E}.Release|x64.Build.0 = Release|Any CPU 66 | {9ADF6A9D-682B-46D2-8708-E6F061C4D06E}.Release|x86.ActiveCfg = Release|Any CPU 67 | {9ADF6A9D-682B-46D2-8708-E6F061C4D06E}.Release|x86.Build.0 = Release|Any CPU 68 | {762D9455-9433-4243-BB5A-30F456B70B7F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 69 | {762D9455-9433-4243-BB5A-30F456B70B7F}.Debug|Any CPU.Build.0 = Debug|Any CPU 70 | {762D9455-9433-4243-BB5A-30F456B70B7F}.Debug|x64.ActiveCfg = Debug|Any CPU 71 | {762D9455-9433-4243-BB5A-30F456B70B7F}.Debug|x64.Build.0 = Debug|Any CPU 72 | {762D9455-9433-4243-BB5A-30F456B70B7F}.Debug|x86.ActiveCfg = Debug|Any CPU 73 | {762D9455-9433-4243-BB5A-30F456B70B7F}.Debug|x86.Build.0 = Debug|Any CPU 74 | {762D9455-9433-4243-BB5A-30F456B70B7F}.Release|Any CPU.ActiveCfg = Release|Any CPU 75 | {762D9455-9433-4243-BB5A-30F456B70B7F}.Release|Any CPU.Build.0 = Release|Any CPU 76 | {762D9455-9433-4243-BB5A-30F456B70B7F}.Release|x64.ActiveCfg = Release|Any CPU 77 | {762D9455-9433-4243-BB5A-30F456B70B7F}.Release|x64.Build.0 = Release|Any CPU 78 | {762D9455-9433-4243-BB5A-30F456B70B7F}.Release|x86.ActiveCfg = Release|Any CPU 79 | {762D9455-9433-4243-BB5A-30F456B70B7F}.Release|x86.Build.0 = Release|Any CPU 80 | {4162C6A4-0C05-4370-BFCD-D629D023B647}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 81 | {4162C6A4-0C05-4370-BFCD-D629D023B647}.Debug|Any CPU.Build.0 = Debug|Any CPU 82 | {4162C6A4-0C05-4370-BFCD-D629D023B647}.Debug|x64.ActiveCfg = Debug|Any CPU 83 | {4162C6A4-0C05-4370-BFCD-D629D023B647}.Debug|x64.Build.0 = Debug|Any CPU 84 | {4162C6A4-0C05-4370-BFCD-D629D023B647}.Debug|x86.ActiveCfg = Debug|Any CPU 85 | {4162C6A4-0C05-4370-BFCD-D629D023B647}.Debug|x86.Build.0 = Debug|Any CPU 86 | {4162C6A4-0C05-4370-BFCD-D629D023B647}.Release|Any CPU.ActiveCfg = Release|Any CPU 87 | {4162C6A4-0C05-4370-BFCD-D629D023B647}.Release|Any CPU.Build.0 = Release|Any CPU 88 | {4162C6A4-0C05-4370-BFCD-D629D023B647}.Release|x64.ActiveCfg = Release|Any CPU 89 | {4162C6A4-0C05-4370-BFCD-D629D023B647}.Release|x64.Build.0 = Release|Any CPU 90 | {4162C6A4-0C05-4370-BFCD-D629D023B647}.Release|x86.ActiveCfg = Release|Any CPU 91 | {4162C6A4-0C05-4370-BFCD-D629D023B647}.Release|x86.Build.0 = Release|Any CPU 92 | {331829F2-A6F6-4DB5-9CA9-33D2C2ADA668}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 93 | {331829F2-A6F6-4DB5-9CA9-33D2C2ADA668}.Debug|Any CPU.Build.0 = Debug|Any CPU 94 | {331829F2-A6F6-4DB5-9CA9-33D2C2ADA668}.Debug|x64.ActiveCfg = Debug|Any CPU 95 | {331829F2-A6F6-4DB5-9CA9-33D2C2ADA668}.Debug|x64.Build.0 = Debug|Any CPU 96 | {331829F2-A6F6-4DB5-9CA9-33D2C2ADA668}.Debug|x86.ActiveCfg = Debug|Any CPU 97 | {331829F2-A6F6-4DB5-9CA9-33D2C2ADA668}.Debug|x86.Build.0 = Debug|Any CPU 98 | {331829F2-A6F6-4DB5-9CA9-33D2C2ADA668}.Release|Any CPU.ActiveCfg = Release|Any CPU 99 | {331829F2-A6F6-4DB5-9CA9-33D2C2ADA668}.Release|Any CPU.Build.0 = Release|Any CPU 100 | {331829F2-A6F6-4DB5-9CA9-33D2C2ADA668}.Release|x64.ActiveCfg = Release|Any CPU 101 | {331829F2-A6F6-4DB5-9CA9-33D2C2ADA668}.Release|x64.Build.0 = Release|Any CPU 102 | {331829F2-A6F6-4DB5-9CA9-33D2C2ADA668}.Release|x86.ActiveCfg = Release|Any CPU 103 | {331829F2-A6F6-4DB5-9CA9-33D2C2ADA668}.Release|x86.Build.0 = Release|Any CPU 104 | {95EE03F0-8D38-4851-BCB7-332AC9D0EC4A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 105 | {95EE03F0-8D38-4851-BCB7-332AC9D0EC4A}.Debug|Any CPU.Build.0 = Debug|Any CPU 106 | {95EE03F0-8D38-4851-BCB7-332AC9D0EC4A}.Debug|x64.ActiveCfg = Debug|Any CPU 107 | {95EE03F0-8D38-4851-BCB7-332AC9D0EC4A}.Debug|x64.Build.0 = Debug|Any CPU 108 | {95EE03F0-8D38-4851-BCB7-332AC9D0EC4A}.Debug|x86.ActiveCfg = Debug|Any CPU 109 | {95EE03F0-8D38-4851-BCB7-332AC9D0EC4A}.Debug|x86.Build.0 = Debug|Any CPU 110 | {95EE03F0-8D38-4851-BCB7-332AC9D0EC4A}.Release|Any CPU.ActiveCfg = Release|Any CPU 111 | {95EE03F0-8D38-4851-BCB7-332AC9D0EC4A}.Release|Any CPU.Build.0 = Release|Any CPU 112 | {95EE03F0-8D38-4851-BCB7-332AC9D0EC4A}.Release|x64.ActiveCfg = Release|Any CPU 113 | {95EE03F0-8D38-4851-BCB7-332AC9D0EC4A}.Release|x64.Build.0 = Release|Any CPU 114 | {95EE03F0-8D38-4851-BCB7-332AC9D0EC4A}.Release|x86.ActiveCfg = Release|Any CPU 115 | {95EE03F0-8D38-4851-BCB7-332AC9D0EC4A}.Release|x86.Build.0 = Release|Any CPU 116 | {D6C6BF3B-74A4-4A04-9056-FEB146A9E326}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 117 | {D6C6BF3B-74A4-4A04-9056-FEB146A9E326}.Debug|Any CPU.Build.0 = Debug|Any CPU 118 | {D6C6BF3B-74A4-4A04-9056-FEB146A9E326}.Debug|x64.ActiveCfg = Debug|Any CPU 119 | {D6C6BF3B-74A4-4A04-9056-FEB146A9E326}.Debug|x64.Build.0 = Debug|Any CPU 120 | {D6C6BF3B-74A4-4A04-9056-FEB146A9E326}.Debug|x86.ActiveCfg = Debug|Any CPU 121 | {D6C6BF3B-74A4-4A04-9056-FEB146A9E326}.Debug|x86.Build.0 = Debug|Any CPU 122 | {D6C6BF3B-74A4-4A04-9056-FEB146A9E326}.Release|Any CPU.ActiveCfg = Release|Any CPU 123 | {D6C6BF3B-74A4-4A04-9056-FEB146A9E326}.Release|Any CPU.Build.0 = Release|Any CPU 124 | {D6C6BF3B-74A4-4A04-9056-FEB146A9E326}.Release|x64.ActiveCfg = Release|Any CPU 125 | {D6C6BF3B-74A4-4A04-9056-FEB146A9E326}.Release|x64.Build.0 = Release|Any CPU 126 | {D6C6BF3B-74A4-4A04-9056-FEB146A9E326}.Release|x86.ActiveCfg = Release|Any CPU 127 | {D6C6BF3B-74A4-4A04-9056-FEB146A9E326}.Release|x86.Build.0 = Release|Any CPU 128 | EndGlobalSection 129 | GlobalSection(SolutionProperties) = preSolution 130 | HideSolutionNode = FALSE 131 | EndGlobalSection 132 | GlobalSection(ExtensibilityGlobals) = postSolution 133 | SolutionGuid = {DF54E9AB-2181-4B69-ADE6-9529922646E3} 134 | EndGlobalSection 135 | EndGlobal 136 | -------------------------------------------------------------------------------- /Windows.Tests/ProgramTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | using WiFiSurveyor.Core; 3 | using Xunit; 4 | 5 | namespace WiFiSurveyor.Windows.Tests; 6 | 7 | public class ProgramTests 8 | { 9 | [Fact] 10 | public void AllServicesAreDefined() 11 | { 12 | //arrange 13 | var services = new ServiceCollection(); 14 | 15 | //act 16 | services.AddWindowsHandlers(); 17 | 18 | //assert 19 | var types = services.Select(s => s.ServiceType).ToArray(); 20 | Assert.Contains(typeof(ISignalParser), types); 21 | Assert.Contains(typeof(ISignalReader), types); 22 | Assert.Contains(typeof(IBrowserLauncher), types); 23 | } 24 | } -------------------------------------------------------------------------------- /Windows.Tests/Windows.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net9.0-windows10.0.17763.0 4 | true 5 | WiFiSurveyor.Windows.Tests 6 | WiFiSurveyor.Windows.Tests 7 | enable 8 | enable 9 | true 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /Windows.Tests/WindowsBrowserLauncherTests.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using Xunit; 3 | 4 | namespace WiFiSurveyor.Windows.Tests; 5 | 6 | public sealed class WindowsBrowserLauncherTests 7 | { 8 | [Fact] 9 | public void RunsCorrectProgram() 10 | { 11 | //arrange 12 | ProcessStartInfo info = null!; 13 | 14 | Process? Start(ProcessStartInfo i) 15 | { 16 | info = i; 17 | return null; 18 | } 19 | var launcher = new WindowsBrowserLauncher(Start); 20 | 21 | //act 22 | launcher.Run("http://localhost"); 23 | 24 | //assert 25 | Assert.Equal("cmd", info.FileName); 26 | } 27 | } -------------------------------------------------------------------------------- /Windows.Tests/WindowsSignalParserTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Logging; 2 | using NSubstitute; 3 | using WiFiSurveyor.Core; 4 | using Xunit; 5 | 6 | namespace WiFiSurveyor.Windows.Tests; 7 | 8 | public sealed class WindowsSignalParserTests 9 | { 10 | [Fact] 11 | public void ResultsAreParsedIntoSignals() 12 | { 13 | //arrange 14 | var network1 = Substitute.For(); 15 | network1.Ssid.Returns("Net1"); 16 | network1.ChannelCenterFrequencyInKilohertz.Returns(2_412_000); 17 | network1.NetworkRssiInDecibelMilliwatts.Returns(-65); 18 | 19 | var network2 = Substitute.For(); 20 | network2.Ssid.Returns("Net1"); 21 | network2.ChannelCenterFrequencyInKilohertz.Returns(5_180_000); 22 | network2.NetworkRssiInDecibelMilliwatts.Returns(-83); 23 | 24 | var network3 = Substitute.For(); 25 | network3.Ssid.Returns("Net2🏎"); 26 | network3.ChannelCenterFrequencyInKilohertz.Returns(2_462_000); 27 | network3.NetworkRssiInDecibelMilliwatts.Returns(-72); 28 | 29 | var network4 = Substitute.For(); 30 | network4.Ssid.Returns("Net3"); 31 | network4.ChannelCenterFrequencyInKilohertz.Returns(2_437_000); 32 | network4.NetworkRssiInDecibelMilliwatts.Returns(-90); 33 | 34 | var networks = new[] 35 | { 36 | network1, 37 | network2, 38 | network3, 39 | network4 40 | }; 41 | 42 | var report = Substitute.For(); 43 | report.AvailableNetworks().Returns(networks); 44 | 45 | var logger = Substitute.For(); 46 | var signalParser = new WindowsSignalParser(logger); 47 | 48 | //act 49 | var signals = signalParser.Parse(report).ToList(); 50 | 51 | //assert 52 | Assert.Equal("Net1", signals[0].SSID); 53 | Assert.Equal(Frequency._2_4_GHz, signals[0].Frequency); 54 | Assert.Equal(1, signals[0].Channel); 55 | Assert.Equal(-65, signals[0].Strength); 56 | 57 | Assert.Equal("Net1", signals[1].SSID); 58 | Assert.Equal(Frequency._5_GHz, signals[1].Frequency); 59 | Assert.Equal(36, signals[1].Channel); 60 | Assert.Equal(-83, signals[1].Strength); 61 | 62 | Assert.Equal("Net2🏎", signals[2].SSID); 63 | Assert.Equal(Frequency._2_4_GHz, signals[2].Frequency); 64 | Assert.Equal(11, signals[2].Channel); 65 | Assert.Equal(-72, signals[2].Strength); 66 | 67 | Assert.Equal("Net3", signals[3].SSID); 68 | Assert.Equal(Frequency._2_4_GHz, signals[3].Frequency); 69 | Assert.Equal(6, signals[3].Channel); 70 | Assert.Equal(-90, signals[3].Strength); 71 | } 72 | 73 | [Fact] 74 | public void IgnoresInvalidResults() 75 | { 76 | //arrange 77 | var network1 = Substitute.For(); 78 | network1.Ssid.Returns("Net1"); 79 | network1.NetworkRssiInDecibelMilliwatts.Returns(double.MinValue); 80 | 81 | var network2 = Substitute.For(); 82 | network2.Ssid.Returns("Net2"); 83 | network2.ChannelCenterFrequencyInKilohertz.Returns(2_412_000); 84 | network2.NetworkRssiInDecibelMilliwatts.Returns(-50); 85 | 86 | var networks = new[] 87 | { 88 | network1, 89 | network2 90 | }; 91 | 92 | var report = Substitute.For(); 93 | report.AvailableNetworks().Returns(networks); 94 | 95 | var logger = Substitute.For(); 96 | var signalParser = new WindowsSignalParser(logger); 97 | 98 | //act 99 | var result = signalParser.Parse(report); 100 | 101 | //assert 102 | Assert.Equal("Net2", result.Single().SSID); 103 | } 104 | } -------------------------------------------------------------------------------- /Windows.Tests/WindowsSignalReaderTests.cs: -------------------------------------------------------------------------------- 1 | using NSubstitute; 2 | using Xunit; 3 | 4 | namespace WiFiSurveyor.Windows.Tests; 5 | 6 | public sealed class WindowsSignalReaderTests 7 | { 8 | [Fact] 9 | public async Task ReturnsOutputFromProcess() 10 | { 11 | //arrange 12 | var report = Substitute.For(); 13 | var adapter = Substitute.For(); 14 | adapter.NetworkReport.Returns(report); 15 | var reader = new WindowsSignalReader(() => Task.FromResult(adapter)); 16 | 17 | //act 18 | var results = await reader.Read(); 19 | 20 | //assert 21 | Assert.Equal(report, results); 22 | } 23 | } -------------------------------------------------------------------------------- /Windows/IWiFiAdapter.cs: -------------------------------------------------------------------------------- 1 | namespace WiFiSurveyor.Windows; 2 | 3 | public interface IWiFiAdapter 4 | { 5 | IWiFiNetworkReport NetworkReport { get; } 6 | Task ScanAsync(); 7 | } -------------------------------------------------------------------------------- /Windows/IWiFiAvailableNetwork.cs: -------------------------------------------------------------------------------- 1 | namespace WiFiSurveyor.Windows; 2 | 3 | public interface IWiFiAvailableNetwork 4 | { 5 | string Bssid { get; } 6 | string Ssid { get; } 7 | int ChannelCenterFrequencyInKilohertz { get; } 8 | double NetworkRssiInDecibelMilliwatts { get; } 9 | } -------------------------------------------------------------------------------- /Windows/IWiFiNetworkReport.cs: -------------------------------------------------------------------------------- 1 | namespace WiFiSurveyor.Windows; 2 | 3 | public interface IWiFiNetworkReport 4 | { 5 | IReadOnlyList AvailableNetworks(); 6 | } -------------------------------------------------------------------------------- /Windows/Program.cs: -------------------------------------------------------------------------------- 1 | using WiFiSurveyor.Core; 2 | 3 | namespace WiFiSurveyor.Windows; 4 | 5 | public static class Program 6 | { 7 | public static void AddWindowsHandlers(this IServiceCollection services) 8 | { 9 | services.AddSingleton>>(WindowsAdapter.Default); 10 | services.AddSingleton(); 11 | services.AddSingleton, WindowsSignalReader>(); 12 | services.AddSingleton, WindowsSignalParser>(); 13 | services.AddHostedService>(); 14 | } 15 | 16 | public static async Task Main(string[] args) 17 | => await new App(AddWindowsHandlers, args).Run(); 18 | } -------------------------------------------------------------------------------- /Windows/Windows.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 2.0.0-beta.2 4 | Exe 5 | net9.0-windows10.0.17763.0 6 | true 7 | WiFiSurveyor 8 | WiFiSurveyor.Windows 9 | win-x64 10 | true 11 | enable 12 | enable 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /Windows/WindowsAdapter.cs: -------------------------------------------------------------------------------- 1 | using Windows.Devices.WiFi; 2 | 3 | namespace WiFiSurveyor.Windows; 4 | 5 | public sealed class WindowsAdapter : IWiFiAdapter 6 | { 7 | private readonly WiFiAdapter _adapter; 8 | 9 | public WindowsAdapter(WiFiAdapter adapter) 10 | => _adapter = adapter; 11 | 12 | public async Task ScanAsync() 13 | => await _adapter.ScanAsync(); 14 | 15 | public IWiFiNetworkReport NetworkReport 16 | => new WindowsNetworkReport(_adapter.NetworkReport); 17 | 18 | public static async Task Default() 19 | { 20 | var adapters = await WiFiAdapter.FindAllAdaptersAsync(); 21 | if (!adapters.Any()) 22 | { 23 | throw new KeyNotFoundException("No Wi-Fi adapters found"); 24 | } 25 | 26 | return new WindowsAdapter(adapters[0]); 27 | } 28 | } -------------------------------------------------------------------------------- /Windows/WindowsAvailableNetwork.cs: -------------------------------------------------------------------------------- 1 | using Windows.Devices.WiFi; 2 | 3 | namespace WiFiSurveyor.Windows; 4 | 5 | public sealed class WindowsAvailableNetwork : IWiFiAvailableNetwork 6 | { 7 | private readonly WiFiAvailableNetwork _network; 8 | 9 | public WindowsAvailableNetwork(WiFiAvailableNetwork network) 10 | => _network = network; 11 | 12 | public string Bssid 13 | => _network.Bssid; 14 | 15 | public string Ssid 16 | => _network.Ssid; 17 | 18 | public int ChannelCenterFrequencyInKilohertz 19 | => _network.ChannelCenterFrequencyInKilohertz; 20 | 21 | public double NetworkRssiInDecibelMilliwatts 22 | => _network.NetworkRssiInDecibelMilliwatts; 23 | } -------------------------------------------------------------------------------- /Windows/WindowsBrowserLauncher.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using WiFiSurveyor.Core; 3 | 4 | namespace WiFiSurveyor.Windows; 5 | 6 | public sealed class WindowsBrowserLauncher : BrowserLauncher 7 | { 8 | public WindowsBrowserLauncher(Func start) : base(start, "cmd", "/c start") 9 | { 10 | } 11 | } -------------------------------------------------------------------------------- /Windows/WindowsNetworkReport.cs: -------------------------------------------------------------------------------- 1 | using Windows.Devices.WiFi; 2 | 3 | namespace WiFiSurveyor.Windows; 4 | 5 | public sealed class WindowsNetworkReport : IWiFiNetworkReport 6 | { 7 | private readonly WiFiNetworkReport _report; 8 | 9 | public WindowsNetworkReport(WiFiNetworkReport report) 10 | => _report = report; 11 | 12 | public IReadOnlyList AvailableNetworks() 13 | => _report.AvailableNetworks 14 | .Select(GetNetwork) 15 | .ToArray(); 16 | 17 | private static IWiFiAvailableNetwork GetNetwork(WiFiAvailableNetwork network) 18 | => new WindowsAvailableNetwork(network); 19 | } -------------------------------------------------------------------------------- /Windows/WindowsSignalParser.cs: -------------------------------------------------------------------------------- 1 | using WiFiSurveyor.Core; 2 | 3 | namespace WiFiSurveyor.Windows; 4 | 5 | public sealed class WindowsSignalParser : ISignalParser 6 | { 7 | private readonly ILogger _logger; 8 | 9 | public WindowsSignalParser(ILogger logger) 10 | => _logger = logger; 11 | 12 | public IReadOnlyList Parse(IWiFiNetworkReport results) 13 | => results.AvailableNetworks() 14 | .Select(GetSignal) 15 | .Where(s => s is not null) 16 | .Cast() 17 | .ToArray(); 18 | 19 | private Signal? GetSignal(IWiFiAvailableNetwork result) 20 | { 21 | try 22 | { 23 | return new Signal 24 | { 25 | MAC = result.Bssid, 26 | SSID = result.Ssid, 27 | Frequency = GetFrequency(result.ChannelCenterFrequencyInKilohertz), 28 | Channel = GetChannel(result.ChannelCenterFrequencyInKilohertz), 29 | Strength = Convert.ToInt16(result.NetworkRssiInDecibelMilliwatts) 30 | }; 31 | } 32 | catch (Exception e) 33 | { 34 | _logger.LogIf(LogLevel.Warning, "{now}: Could not parse signal data -- {result}", DateTime.Now, result); 35 | _logger.LogIf(LogLevel.Debug, "{exception}", e.ToString()); 36 | return null; 37 | } 38 | } 39 | 40 | private static byte GetChannel(int kHz) 41 | { 42 | var baseFreq = GetFrequency(kHz) == Frequency._5_GHz ? 5_000_000 : 2_407_000; 43 | var channel = (kHz - baseFreq) / 5_000; 44 | return Convert.ToByte(channel); 45 | } 46 | 47 | private static Frequency GetFrequency(int kHz) 48 | => kHz / 1_000_000 == 5 49 | ? Frequency._5_GHz 50 | : Frequency._2_4_GHz; 51 | } -------------------------------------------------------------------------------- /Windows/WindowsSignalReader.cs: -------------------------------------------------------------------------------- 1 | using WiFiSurveyor.Core; 2 | 3 | namespace WiFiSurveyor.Windows; 4 | 5 | public sealed class WindowsSignalReader : ISignalReader 6 | { 7 | private readonly Func> _newAdapter; 8 | private IWiFiAdapter? _adapter; 9 | 10 | public WindowsSignalReader(Func> adapterFactory) 11 | => _newAdapter = adapterFactory; 12 | 13 | public async Task Read() 14 | { 15 | _adapter ??= await _newAdapter(); 16 | await _adapter.ScanAsync(); 17 | return _adapter.NetworkReport; 18 | } 19 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wifisurveyor", 3 | "version": "2.0.0-beta.2", 4 | "description": "Visualize Wi-Fi signal strength over a geographic area", 5 | "main": "app.js", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build --outDir ../Core/wwwroot --emptyOutDir --assetsDir .", 9 | "test": "vite build App.Tests && xunit dist/tests" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+ssh://git@github.com/ecoAPM/WiFiSurveyor.git" 14 | }, 15 | "keywords": [ 16 | "WiFi", 17 | "Wi-Fi", 18 | "HeatMap", 19 | "Heat", 20 | "Map", 21 | "Signal", 22 | "Strength", 23 | "dBm", 24 | "SNR", 25 | "dB" 26 | ], 27 | "author": "Steve Desmond, ecoAPM (https://ecoAPM.com)", 28 | "license": "GPL-3.0-or-later", 29 | "bugs": { 30 | "url": "https://github.com/ecoAPM/WiFiSurveyor/issues" 31 | }, 32 | "homepage": "https://github.com/ecoAPM/WiFiSurveyor#readme", 33 | "devDependencies": { 34 | "@fontsource/noto-sans": "5.2.7", 35 | "@fontsource/roboto": "5.2.5", 36 | "@microsoft/signalr": "8.0.7", 37 | "@types/delaunator": "5.0.3", 38 | "@types/jsdom": "21.1.7", 39 | "@types/node": "22.15.29", 40 | "@typescript-eslint/eslint-plugin": "8.33.1", 41 | "@typescript-eslint/parser": "8.33.1", 42 | "@vitejs/plugin-vue": "5.2.4", 43 | "@vue/component-compiler-utils": "3.3.0", 44 | "@vue/test-utils": "2.4.6", 45 | "delaunator": "5.0.1", 46 | "eslint": "9.28.0", 47 | "eslint-plugin-vue": "10.1.0", 48 | "global-jsdom": "26.0.0", 49 | "jsdom": "26.1.0", 50 | "ts-mockito": "2.6.1", 51 | "typescript": "5.8.3", 52 | "vite": "6.3.5", 53 | "vue": "3.5.16", 54 | "vue-eslint-parser": "10.1.3", 55 | "xunit.ts": "2.0.0" 56 | }, 57 | "browserslist": [ 58 | "Firefox ESR" 59 | ] 60 | } 61 | -------------------------------------------------------------------------------- /release: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | sed -i 's/"version": ".*"/"version": "'$1'"/' package.json 4 | find \ 5 | | grep \.csproj$ \ 6 | | xargs sed -i 's/.*<\/PackageVersion>/'$1'<\/PackageVersion>/' 7 | 8 | git commit -am "Release $1" 9 | git push 10 | 11 | git tag $1 12 | git push --tags -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.organization=ecoapm 2 | sonar.projectKey=ecoAPM_WiFiSurveyor-App 3 | sonar.sources=App 4 | sonar.tests=App.Tests 5 | sonar.testExecutionReportPaths=sonar.xml 6 | sonar.javascript.lcov.reportPaths=coverage/lcov.info -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "experimentalDecorators": true, 5 | "lib": [ 6 | "DOM" 7 | ], 8 | "module": "CommonJS", 9 | "outDir": "Server/wwwroot", 10 | "sourceMap": true, 11 | "strict": true, 12 | "target": "ES2015", 13 | "typeRoots": [ 14 | "node_modules/@types" 15 | ] 16 | } 17 | } -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import vue from "@vitejs/plugin-vue"; 3 | 4 | export default defineConfig({ 5 | root: "App", 6 | plugins: [ 7 | vue() 8 | ] 9 | }); --------------------------------------------------------------------------------