├── .devops └── config.ps1 ├── .github ├── config │ └── pr-autoflow.json ├── dependabot.yml └── workflows │ ├── auto_release.yml │ ├── build.yml │ └── dependabot_approve_and_label.yml ├── .gitignore ├── GitVersion.yml ├── LICENSE ├── README.md ├── Solutions ├── .dockerignore ├── .editorconfig ├── Ais.Net.Receiver.Host.Console.RaspberryPi │ ├── aisr.service │ └── docker-compose.yml ├── Ais.Net.Receiver.Host.Console │ ├── Ais.Net.Receiver.Host.Console.csproj │ ├── Ais │ │ └── Net │ │ │ └── Receiver │ │ │ └── Host │ │ │ └── Console │ │ │ ├── Program.cs │ │ │ └── ReceiverHostExtensions.cs │ ├── Dockerfile │ ├── Properties │ │ └── launchSettings.json │ ├── packages.lock.json │ └── settings.json ├── Ais.Net.Receiver.Storage.Azure.Blob │ ├── Ais.Net.Receiver.Storage.Azure.Blob.csproj │ └── Ais │ │ └── Net │ │ └── Receiver │ │ └── Storage │ │ └── Azure │ │ └── Blob │ │ ├── AzureAppendBlobStorageClient.cs │ │ └── Configuration │ │ └── StorageConfig.cs ├── Ais.Net.Receiver.sln ├── Ais.Net.Receiver.sln.DotSettings ├── Ais.Net.Receiver │ ├── Ais.Net.Receiver.csproj │ └── Ais │ │ └── Net │ │ └── Receiver │ │ ├── Configuration │ │ ├── AisConfig.cs │ │ └── LoggerVerbosity.cs │ │ ├── Parser │ │ ├── NmeaMessageExtensions.cs │ │ └── NmeaToAisMessageTypeProcessor.cs │ │ ├── Receiver │ │ ├── FileStreamNmeaReceiver.cs │ │ ├── INmeaReceiver.cs │ │ ├── INmeaStreamReader.cs │ │ ├── NetworkStreamNmeaReceiver.cs │ │ ├── ReceiverHost.cs │ │ └── TcpClientNmeaStreamReader.cs │ │ └── Storage │ │ └── IStorageClient.cs ├── PackageIcon.png ├── docker-compose.dcproj ├── docker-compose.override.yml ├── docker-compose.yml ├── launchSettings.json └── stylecop.json ├── build.ps1 ├── docs └── ReleaseNotes.md └── imm.yaml /.devops/config.ps1: -------------------------------------------------------------------------------- 1 | $devopsExtensions = @( 2 | @{ 3 | Name = "Endjin.RecommendedPractices.Build" 4 | Version = "[1.5.10,2.0)" 5 | Process = "tasks/build.process.ps1" 6 | } 7 | ) 8 | 9 | # Load the tasks and process 10 | . endjin-devops.tasks 11 | 12 | # 13 | # Build process configuration 14 | # 15 | $SolutionToBuild = (Resolve-Path (Join-Path $here "./Solutions/Ais.Net.Receiver.sln")).Path 16 | $SkipBuildModuleVersionCheck = $true # currently doesn't work properly with endjin-devops 17 | 18 | # Set default build task 19 | task . FullBuild 20 | 21 | # 22 | # Build Process Extensibility Points - uncomment and implement as required 23 | # 24 | 25 | # task RunFirst {} 26 | # task PreInit {} 27 | # task PostInit {} 28 | # task PreVersion {} 29 | # task PostVersion {} 30 | # task PreBuild {} 31 | # task PostBuild {} 32 | # task PreTest {} 33 | # task PostTest {} 34 | # task PreTestReport {} 35 | # task PostTestReport {} 36 | # task PreAnalysis {} 37 | # task PostAnalysis {} 38 | # task PrePackage {} 39 | # task PostPackage {} 40 | # task PrePublish {} 41 | # task PostPublish {} 42 | # task RunLast {} -------------------------------------------------------------------------------- /.github/config/pr-autoflow.json: -------------------------------------------------------------------------------- 1 | { 2 | "AUTO_MERGE_PACKAGE_WILDCARD_EXPRESSIONS": "[\"Endjin.*\",\"Corvus.*\"]", 3 | "AUTO_RELEASE_PACKAGE_WILDCARD_EXPRESSIONS": "[\"Corvus.AzureFunctionsKeepAlive\",\"Corvus.Configuration\",\"Corvus.ContentHandling\",\"Corvus.Deployment\",\"Corvus.Deployment.Dataverse\",\"Corvus.DotLiquidAsync\",\"Corvus.EventStore\",\"Corvus.Extensions\",\"Corvus.Extensions.CosmosDb\",\"Corvus.Extensions.Newtonsoft.Json\",\"Corvus.Extensions.System.Text.Json\",\"Corvus.Identity\",\"Corvus.JsonSchema\",\"Corvus.Leasing\",\"Corvus.Monitoring\",\"Corvus.Python\",\"Corvus.Retry\",\"Corvus.Storage\",\"Corvus.Tenancy\"]" 4 | } -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: nuget 4 | directory: /Solutions 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | groups: 9 | microsoft-identity: 10 | patterns: 11 | - Microsoft.Identity.* 12 | microsoft-extensions: 13 | patterns: 14 | - Microsoft.Extensions.* 15 | 16 | -------------------------------------------------------------------------------- /.github/workflows/auto_release.yml: -------------------------------------------------------------------------------- 1 | name: auto_release 2 | on: 3 | pull_request: 4 | types: [closed] 5 | 6 | jobs: 7 | lookup_default_branch: 8 | runs-on: ubuntu-latest 9 | outputs: 10 | branch_name: ${{ steps.lookup_default_branch.outputs.result }} 11 | head_commit: ${{ steps.lookup_default_branch_head.outputs.result }} 12 | steps: 13 | - name: Lookup default branch name 14 | id: lookup_default_branch 15 | uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 16 | with: 17 | github-token: ${{ secrets.GITHUB_TOKEN }} 18 | retries: 6 # final retry should wait 64 seconds 19 | retry-exempt-status-codes: 400,401,404,422 # GH will raise rate limits with 403 & 429 status codes 20 | result-encoding: string 21 | script: | 22 | const repo = await github.rest.repos.get({ 23 | owner: context.payload.repository.owner.login, 24 | repo: context.payload.repository.name 25 | }); 26 | return repo.data.default_branch 27 | - name: Display default_branch_name 28 | run: | 29 | echo "default_branch_name : ${{ steps.lookup_default_branch.outputs.result }}" 30 | 31 | - name: Lookup HEAD commit on default branch 32 | id: lookup_default_branch_head 33 | uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 34 | with: 35 | github-token: ${{ secrets.GITHUB_TOKEN }} 36 | retries: 6 # final retry should wait 64 seconds 37 | retry-exempt-status-codes: 400,401,404,422 # GH will raise rate limits with 403 & 429 status codes 38 | result-encoding: string 39 | script: | 40 | const branch = await github.rest.repos.getBranch({ 41 | owner: context.payload.repository.owner.login, 42 | repo: context.payload.repository.name, 43 | branch: '${{ steps.lookup_default_branch.outputs.result }}' 44 | }); 45 | return branch.data.commit.sha 46 | - name: Display default_branch_name_head 47 | run: | 48 | echo "default_branch_head_commit : ${{ steps.lookup_default_branch_head.outputs.result }}" 49 | 50 | check_for_norelease_label: 51 | runs-on: ubuntu-latest 52 | outputs: 53 | no_release: ${{ steps.check_for_norelease_label.outputs.result }} 54 | steps: 55 | - name: Check for 'no_release' label on PR 56 | id: check_for_norelease_label 57 | uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 58 | with: 59 | github-token: ${{ secrets.GITHUB_TOKEN }} 60 | retries: 6 # final retry should wait 64 seconds 61 | retry-exempt-status-codes: 400,401,404,422 # GH will raise rate limits with 403 & 429 status codes 62 | script: | 63 | const labels = await github.rest.issues.listLabelsOnIssue({ 64 | owner: context.payload.repository.owner.login, 65 | repo: context.payload.repository.name, 66 | issue_number: context.payload.number 67 | }); 68 | core.info("labels: " + JSON.stringify(labels.data)) 69 | if ( labels.data.map(l => l.name).includes("no_release") ) { 70 | core.info("Label found") 71 | if ( labels.data.map(l => l.name).includes("pending_release") ) { 72 | // Remove the 'pending_release' label 73 | await github.rest.issues.removeLabel({ 74 | owner: context.payload.repository.owner.login, 75 | repo: context.payload.repository.name, 76 | issue_number: context.payload.pull_request.number, 77 | name: 'pending_release' 78 | }) 79 | } 80 | 81 | return true 82 | } 83 | return false 84 | - name: Display 'no_release' status 85 | run: | 86 | echo "no_release: ${{ steps.check_for_norelease_label.outputs.result }}" 87 | 88 | check_ready_to_release: 89 | runs-on: ubuntu-latest 90 | needs: [check_for_norelease_label,lookup_default_branch] 91 | if: | 92 | needs.check_for_norelease_label.outputs.no_release == 'false' 93 | outputs: 94 | no_open_prs: ${{ steps.watch_dependabot_prs.outputs.is_complete }} 95 | pending_release_pr_list: ${{ steps.get_release_pending_pr_list.outputs.result }} 96 | ready_to_release: ${{ steps.set_ready_for_release.outputs.result }} 97 | steps: 98 | - name: Get Open PRs 99 | id: get_open_pr_list 100 | uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 101 | with: 102 | github-token: ${{ secrets.GITHUB_TOKEN }} 103 | retries: 6 # final retry should wait 64 seconds 104 | retry-exempt-status-codes: 400,401,404,422 # GH will raise rate limits with 403 & 429 status codes 105 | # find all open PRs that are targetting the default branch (i.e. main/master) 106 | # return their titles, so they can parsed later to determine if they are 107 | # Dependabot PRs and whether we should wait for them to be auto-merged before 108 | # allowing a release event. 109 | script: | 110 | const pulls = await github.rest.pulls.list({ 111 | owner: context.payload.repository.owner.login, 112 | repo: context.payload.repository.name, 113 | state: 'open', 114 | base: '${{ needs.lookup_default_branch.outputs.branch_name }}' 115 | }); 116 | return JSON.stringify(pulls.data.map(p=>p.title)) 117 | result-encoding: string 118 | - name: Display open_pr_list 119 | run: | 120 | cat <`#${p.number} '${p.title}' in ${p.repository_url}`); 137 | core.info(`allPrs: ${JSON.stringify(allPrs)}`); 138 | 139 | releasePendingPrDetails = pulls.data.items. 140 | filter(function (x) { return x.labels.map(l=>l.name).includes('pending_release') }). 141 | filter(function (x) { return !x.labels.map(l=>l.name).includes('no_release') }). 142 | map(p=>`#${p.number} '${p.title}' in ${p.repository_url}`); 143 | core.info(`releasePendingPrDetails: ${JSON.stringify(releasePendingPrDetails)}`); 144 | 145 | const release_pending_prs = pulls.data.items. 146 | filter(function (x) { return x.labels.map(l=>l.name).includes('pending_release') }). 147 | filter(function (x) { return !x.labels.map(l=>l.name).includes('no_release') }). 148 | map(p=>p.number); 149 | core.info(`release_pending_prs: ${JSON.stringify(release_pending_prs)}`); 150 | core.setOutput('is_release_pending', (release_pending_prs.length > 0)); 151 | return JSON.stringify(release_pending_prs); 152 | result-encoding: string 153 | 154 | - name: Display release_pending_pr_list 155 | run: | 156 | cat <> $GITHUB_PATH 224 | - name: Run GitVersion 225 | id: run_gitversion 226 | run: | 227 | pwsh -noprofile -c 'dotnet-gitversion /diag' 228 | 229 | - name: Generate token 230 | id: generate_token 231 | uses: tibdex/github-app-token@32691ba7c9e7063bd457bd8f2a5703138591fa58 # v1.9 232 | with: 233 | app_id: ${{ secrets.ENDJIN_BOT_APP_ID }} 234 | private_key: ${{ secrets.ENDJIN_BOT_PRIVATE_KEY }} 235 | 236 | - name: Create SemVer tag 237 | uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 238 | with: 239 | github-token: ${{ steps.generate_token.outputs.token }} 240 | retries: 6 # final retry should wait 64 seconds 241 | retry-exempt-status-codes: 400,401,404,422 # GH will raise rate limits with 403 & 429 status codes 242 | script: | 243 | const uri_path = '/repos/' + context.payload.repository.owner.login + '/' + context.payload.repository.name + '/git/refs' 244 | const tag = await github.request(('POST ' + uri_path), { 245 | owner: context.payload.repository.owner.login, 246 | repo: context.payload.repository.name, 247 | ref: 'refs/tags/${{ env.GitVersion_MajorMinorPatch }}', 248 | sha: '${{ needs.lookup_default_branch.outputs.head_commit }}' 249 | }) 250 | 251 | - name: Remove 'release_pending' label from PRs 252 | id: remove_pending_release_labels 253 | uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 254 | with: 255 | github-token: '${{ steps.generate_token.outputs.token }}' 256 | retries: 6 # final retry should wait 64 seconds 257 | retry-exempt-status-codes: 400,401,404,422 # GH will raise rate limits with 403 & 429 status codes 258 | script: | 259 | core.info('PRs to unlabel: ${{ needs.check_ready_to_release.outputs.pending_release_pr_list }}') 260 | const pr_list = JSON.parse('${{ needs.check_ready_to_release.outputs.pending_release_pr_list }}') 261 | core.info(`pr_list: ${pr_list}`) 262 | for (const i of pr_list) { 263 | core.info(`Removing label 'pending_release' from issue #${i}`) 264 | github.rest.issues.removeLabel({ 265 | owner: context.payload.repository.owner.login, 266 | repo: context.payload.repository.name, 267 | issue_number: i, 268 | name: 'pending_release' 269 | }); 270 | } 271 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | push: 4 | branches: 5 | - main 6 | tags: 7 | - '*' 8 | pull_request: 9 | branches: 10 | - main 11 | workflow_dispatch: 12 | inputs: 13 | forcePublish: 14 | description: When true the Publish stage will always be run, otherwise it only runs for tagged versions. 15 | required: false 16 | default: false 17 | type: boolean 18 | skipCleanup: 19 | description: When true the pipeline clean-up stage will not be run. For example, the cache used between pipeline stages will be retained. 20 | required: false 21 | default: false 22 | type: boolean 23 | 24 | concurrency: 25 | group: ${{ github.workflow }}-${{ github.sha }} 26 | cancel-in-progress: true 27 | 28 | permissions: 29 | actions: write # enable cache clean-up 30 | checks: write # enable test result annotations 31 | contents: write # enable creating releases 32 | issues: read 33 | packages: write # enable publishing packages 34 | pull-requests: write # enable test result annotations 35 | 36 | # Since we need to build & publish a container image to DockerHub we need to use the shared workflow 37 | # that allows us to run the build inside a single job. When using the multi-job reusable workflow 38 | # we would use ACR Tasks to build the image, but this will not work when publishing to DockerHub. 39 | jobs: 40 | build: 41 | name: Run Build 42 | runs-on: ubuntu-latest 43 | outputs: 44 | semver: ${{ steps.run_build.outputs.semver }} 45 | major: ${{ steps.run_build.outputs.major }} 46 | majorMinor: ${{ steps.run_build.outputs.majorMinor }} 47 | preReleaseTag: ${{ steps.run_build.outputs.preReleaseTag }} 48 | 49 | steps: 50 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2 51 | with: 52 | fetch-depth: 0 53 | submodules: true 54 | 55 | - uses: endjin/Endjin.RecommendedPractices.GitHubActions/actions/prepare-env-vars-and-secrets@main 56 | id: prepareEnvVarsAndSecrets 57 | with: 58 | # We disable the Container part of the build when running in GHA, since we need to use the 59 | # Docker action to build the multi-arch images at the end of the build. 60 | environmentVariablesYaml: | 61 | BUILDVAR_NuGetPublishSource: "${{ startsWith(github.ref, 'refs/tags/') && 'https://api.nuget.org/v3/index.json' || format('https://nuget.pkg.github.com/{0}/index.json', github.repository_owner) }}" 62 | BUILDVAR_ContainerImageVersionOverride: "" 63 | BUILDVAR_DockerRegistryUsername: "${{ secrets.DOCKERHUB_USERNAME }}" 64 | BUILDVAR_SkipContainerImages: "true" 65 | secretsYaml: | 66 | NUGET_API_KEY: "${{ startsWith(github.ref, 'refs/tags/') && secrets.NUGET_APIKEY || secrets.BUILD_PUBLISHER_PAT }}" 67 | DOCKERHUB_ACCESSTOKEN: "${{ secrets.DOCKERHUB_ACCESSTOKEN }}" 68 | secretsEncryptionKey: ${{ secrets.SHARED_WORKFLOW_KEY }} 69 | 70 | - uses: endjin/Endjin.RecommendedPractices.GitHubActions/actions/run-build-process@main 71 | id: run_build 72 | with: 73 | netSdkVersion: '8.0.x' 74 | additionalNetSdkVersion: '9.0.x' 75 | forcePublish: ${{ github.event.inputs.forcePublish == 'true' }} 76 | sbomOutputStorageAccountName: ${{ vars.SBOM_OUTPUT_STORAGE_ACCOUNT_NAME }} 77 | sbomOutputStorageContainerName: ${{ vars.SBOM_OUTPUT_STORAGE_CONTAINER_NAME }} 78 | buildEnv: ${{ steps.prepareEnvVarsAndSecrets.outputs.environmentVariablesYamlBase64 }} 79 | buildSecrets: ${{ steps.prepareEnvVarsAndSecrets.outputs.secretsYamlBase64 }} 80 | buildAzureCredentials: ${{ secrets.AZURE_READER_CREDENTIALS }} 81 | secretsEncryptionKey: ${{ secrets.SHARED_WORKFLOW_KEY }} 82 | token: ${{ secrets.GITHUB_TOKEN }} 83 | 84 | - run: | 85 | & dotnet-gitversion /output json /nofetch /config '${{ github.workspace }}/GitVersion.yml' | Tee-Object -Variable gitVersionOutput 86 | $semVer = $gitVersionOutput | ConvertFrom-Json | Select-Object -ExpandProperty SemVer 87 | Write-Host "Image version tag will be: $semVer" 88 | "SemVer=$semVer" | Out-File -FilePath $env:GITHUB_OUTPUT -Append 89 | id: get_semver 90 | name: Get SemVer 91 | shell: pwsh 92 | 93 | # Additional build steps to produce an ARM version of the container image 94 | - name: Login to Docker Hub 95 | uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 96 | with: 97 | username: ${{ secrets.DOCKERHUB_USERNAME }} 98 | password: ${{ secrets.DOCKERHUB_ACCESSTOKEN }} 99 | 100 | - name: Set up QEMU 101 | uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 102 | 103 | - name: Set up Docker Buildx 104 | uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 105 | 106 | - name: Build ARM-based container image 107 | uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0 108 | with: 109 | context: ./Solutions 110 | file: ./Solutions/Ais.Net.Receiver.Host.Console/Dockerfile 111 | platforms: linux/amd64,linux/arm64 112 | push: ${{ startsWith(github.ref, 'refs/tags/') || github.event.inputs.forcePublish == 'true' }} 113 | tags: "endjin/ais-dotnet-receiver:${{ steps.get_semver.outputs.SemVer }}" 114 | -------------------------------------------------------------------------------- /.github/workflows/dependabot_approve_and_label.yml: -------------------------------------------------------------------------------- 1 | name: approve_and_label 2 | on: 3 | pull_request: 4 | types: [opened, reopened] 5 | 6 | permissions: 7 | contents: write 8 | issues: write 9 | pull-requests: write 10 | 11 | jobs: 12 | evaluate_dependabot_pr: 13 | runs-on: ubuntu-latest 14 | name: Parse Dependabot PR title 15 | # Don't process PRs from forked repos 16 | if: 17 | github.event.pull_request.head.repo.full_name == github.repository 18 | outputs: 19 | dependency_name: ${{ steps.parse_dependabot_pr_automerge.outputs.dependency_name }} 20 | version_from: ${{ steps.parse_dependabot_pr_automerge.outputs.version_from }} 21 | version_to: ${{ steps.parse_dependabot_pr_automerge.outputs.version_to }} 22 | is_auto_merge_candidate: ${{ steps.parse_dependabot_pr_automerge.outputs.is_interesting_package }} 23 | is_auto_release_candidate: ${{ steps.parse_dependabot_pr_autorelease.outputs.is_interesting_package }} 24 | semver_increment: ${{ steps.parse_dependabot_pr_automerge.outputs.semver_increment }} 25 | steps: 26 | - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 #v4.1.1 27 | - name: Read pr-autoflow configuration 28 | id: get_pr_autoflow_config 29 | uses: endjin/pr-autoflow/actions/read-configuration@v4 30 | with: 31 | config_file: .github/config/pr-autoflow.json 32 | - name: Dependabot PR - AutoMerge Candidate 33 | id: parse_dependabot_pr_automerge 34 | uses: endjin/pr-autoflow/actions/dependabot-pr-parser@v4 35 | with: 36 | pr_title: ${{ github.event.pull_request.title }} 37 | package_wildcard_expressions: ${{ steps.get_pr_autoflow_config.outputs.AUTO_MERGE_PACKAGE_WILDCARD_EXPRESSIONS }} 38 | - name: Dependabot PR - AutoRelease Candidate 39 | id: parse_dependabot_pr_autorelease 40 | uses: endjin/pr-autoflow/actions/dependabot-pr-parser@v4 41 | with: 42 | pr_title: ${{ github.event.pull_request.title }} 43 | package_wildcard_expressions: ${{ steps.get_pr_autoflow_config.outputs.AUTO_RELEASE_PACKAGE_WILDCARD_EXPRESSIONS }} 44 | - name: debug 45 | run: | 46 | echo "dependency_name : ${{ steps.parse_dependabot_pr_automerge.outputs.dependency_name }}" 47 | echo "is_interesting_package (merge) : ${{ steps.parse_dependabot_pr_automerge.outputs.is_interesting_package }}" 48 | echo "is_interesting_package (release) : ${{ steps.parse_dependabot_pr_autorelease.outputs.is_interesting_package }}" 49 | echo "semver_increment : ${{ steps.parse_dependabot_pr_automerge.outputs.semver_increment }}" 50 | 51 | approve: 52 | runs-on: ubuntu-latest 53 | needs: evaluate_dependabot_pr 54 | name: Approve auto-mergeable dependabot PRs 55 | if: | 56 | (github.actor == 'dependabot[bot]' || github.actor == 'dependjinbot[bot]' || github.actor == 'nektos/act') && 57 | needs.evaluate_dependabot_pr.outputs.is_auto_merge_candidate == 'True' 58 | steps: 59 | - name: Show PR Details 60 | run: | 61 | echo "<------------------------------------------------>" 62 | echo "dependency_name : ${{needs.evaluate_dependabot_pr.outputs.dependency_name}}" 63 | echo "semver_increment : ${{needs.evaluate_dependabot_pr.outputs.semver_increment}}" 64 | echo "auto_merge : ${{needs.evaluate_dependabot_pr.outputs.is_auto_merge_candidate}}" 65 | echo "auto_release : ${{needs.evaluate_dependabot_pr.outputs.is_auto_release_candidate}}" 66 | echo "from_version : ${{needs.evaluate_dependabot_pr.outputs.version_from}}" 67 | echo "to_version : ${{needs.evaluate_dependabot_pr.outputs.version_to}}" 68 | echo "<------------------------------------------------>" 69 | shell: bash 70 | - name: Approve pull request 71 | if: | 72 | needs.evaluate_dependabot_pr.outputs.is_auto_merge_candidate == 'True' && 73 | (needs.evaluate_dependabot_pr.outputs.semver_increment == 'minor' || needs.evaluate_dependabot_pr.outputs.semver_increment == 'patch') 74 | run: | 75 | gh pr review "${{ github.event.pull_request.html_url }}" --approve -b "Thank you dependabot 🎊" 76 | env: 77 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 78 | - name: 'Update PR body' 79 | if: | 80 | needs.evaluate_dependabot_pr.outputs.is_auto_merge_candidate == 'True' && 81 | (needs.evaluate_dependabot_pr.outputs.semver_increment == 'minor' || needs.evaluate_dependabot_pr.outputs.semver_increment == 'patch') 82 | uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 83 | with: 84 | github-token: '${{ secrets.GITHUB_TOKEN }}' 85 | retries: 6 # final retry should wait 64 seconds 86 | retry-exempt-status-codes: 400,401,404,422 # GH will raise rate limits with 403 & 429 status codes 87 | script: | 88 | await github.rest.pulls.update({ 89 | owner: context.payload.repository.owner.login, 90 | repo: context.payload.repository.name, 91 | pull_number: context.payload.pull_request.number, 92 | body: "Bumps '${{needs.evaluate_dependabot_pr.outputs.dependency_name}}' from ${{needs.evaluate_dependabot_pr.outputs.version_from}} to ${{needs.evaluate_dependabot_pr.outputs.version_to}}" 93 | }) 94 | 95 | check_for_norelease_label: 96 | runs-on: ubuntu-latest 97 | outputs: 98 | no_release: ${{ steps.check_for_norelease_label.outputs.result }} 99 | steps: 100 | - name: Check for 'no_release' label on PR 101 | id: check_for_norelease_label 102 | uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 103 | with: 104 | github-token: ${{ secrets.GITHUB_TOKEN }} 105 | retries: 6 # final retry should wait 64 seconds 106 | retry-exempt-status-codes: 400,401,404,422 # GH will raise rate limits with 403 & 429 status codes 107 | script: | 108 | const labels = await github.rest.issues.listLabelsOnIssue({ 109 | owner: context.payload.repository.owner.login, 110 | repo: context.payload.repository.name, 111 | issue_number: context.payload.number 112 | }); 113 | core.info("labels: " + JSON.stringify(labels.data)) 114 | if ( labels.data.map(l => l.name).includes("no_release") ) { 115 | core.info("Label found") 116 | return true 117 | } 118 | return false 119 | - name: Display 'no_release' status 120 | run: | 121 | echo "no_release: ${{ steps.check_for_norelease_label.outputs.result }}" 122 | 123 | label_auto_merge: 124 | runs-on: ubuntu-latest 125 | needs: 126 | - evaluate_dependabot_pr 127 | - check_for_norelease_label 128 | name: 'Automerge & Label' 129 | steps: 130 | # Get a token for a different identity so any auto-merge that happens in the next step is 131 | # able to trigger other workflows (i.e. our 'auto_release' workflow) 132 | # NOTE: This requires the app details to be defined as 'Dependabot' secrets, rather than 133 | # the usual 'Action' secrets as this workflow is triggered by Dependabot. 134 | - name: Generate token 135 | id: generate_token 136 | uses: tibdex/github-app-token@32691ba7c9e7063bd457bd8f2a5703138591fa58 # v1.9 137 | with: 138 | app_id: ${{ secrets.DEPENDJINBOT_APP_ID }} 139 | private_key: ${{ secrets.DEPENDJINBOT_PRIVATE_KEY }} 140 | # Run the auto-merge in the GitHub App context, so the event can trigger other workflows 141 | - name: 'Set dependabot PR to auto-merge' 142 | if: | 143 | (github.actor == 'dependabot[bot]' || github.actor == 'dependjinbot[bot]' || github.actor == 'nektos/act') && 144 | needs.evaluate_dependabot_pr.outputs.is_auto_merge_candidate == 'True' && 145 | (needs.evaluate_dependabot_pr.outputs.semver_increment == 'minor' || needs.evaluate_dependabot_pr.outputs.semver_increment == 'patch') 146 | run: | 147 | gh pr merge ${{ github.event.pull_request.number }} -R ${{ github.repository }} --auto --squash 148 | env: 149 | GITHUB_TOKEN: '${{ steps.generate_token.outputs.token }}' 150 | - name: 'Label non-dependabot PRs and auto-releasable dependabot PRs with "pending_release"' 151 | if: | 152 | needs.check_for_norelease_label.outputs.no_release == 'false' && 153 | ( 154 | (github.actor != 'dependabot[bot]' && github.actor != 'dependjinbot[bot]') || 155 | needs.evaluate_dependabot_pr.outputs.is_auto_release_candidate == 'True' 156 | ) 157 | uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 158 | with: 159 | github-token: '${{ secrets.GITHUB_TOKEN }}' 160 | retries: 6 # final retry should wait 64 seconds 161 | retry-exempt-status-codes: 400,401,404,422 # GH will raise rate limits with 403 & 429 status codes 162 | script: | 163 | await github.rest.issues.addLabels({ 164 | owner: context.payload.repository.owner.login, 165 | repo: context.payload.repository.name, 166 | issue_number: context.payload.pull_request.number, 167 | labels: ['pending_release'] 168 | }) 169 | -------------------------------------------------------------------------------- /.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/main/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 | # Build results 19 | [Dd]ebug/ 20 | [Dd]ebugPublic/ 21 | [Rr]elease/ 22 | [Rr]eleases/ 23 | x64/ 24 | x86/ 25 | [Ww][Ii][Nn]32/ 26 | [Aa][Rr][Mm]/ 27 | [Aa][Rr][Mm]64/ 28 | bld/ 29 | [Bb]in/ 30 | [Oo]bj/ 31 | [Ll]og/ 32 | [Ll]ogs/ 33 | 34 | # Visual Studio 2015/2017 cache/options directory 35 | .vs/ 36 | # Uncomment if you have tasks that create the project's static files in wwwroot 37 | #wwwroot/ 38 | 39 | # Visual Studio 2017 auto generated files 40 | Generated\ Files/ 41 | 42 | # MSTest test Results 43 | [Tt]est[Rr]esult*/ 44 | [Bb]uild[Ll]og.* 45 | 46 | # NUNIT 47 | *.VisualState.xml 48 | TestResult.xml 49 | nunit-*.xml 50 | 51 | # Build Results of an ATL Project 52 | [Dd]ebugPS/ 53 | [Rr]eleasePS/ 54 | dlldata.c 55 | 56 | # Benchmark Results 57 | BenchmarkDotNet.Artifacts/ 58 | 59 | # .NET Core 60 | project.lock.json 61 | project.fragment.lock.json 62 | artifacts/ 63 | 64 | # ASP.NET Scaffolding 65 | ScaffoldingReadMe.txt 66 | 67 | # StyleCop 68 | StyleCopReport.xml 69 | 70 | # Files built by Visual Studio 71 | *_i.c 72 | *_p.c 73 | *_h.h 74 | *.ilk 75 | *.meta 76 | *.obj 77 | *.iobj 78 | *.pch 79 | *.pdb 80 | *.ipdb 81 | *.pgc 82 | *.pgd 83 | *.rsp 84 | *.sbr 85 | *.tlb 86 | *.tli 87 | *.tlh 88 | *.tmp 89 | *.tmp_proj 90 | *_wpftmp.csproj 91 | *.log 92 | *.tlog 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 | 135 | # TeamCity is a build add-in 136 | _TeamCity* 137 | 138 | # DotCover is a Code Coverage Tool 139 | *.dotCover 140 | 141 | # AxoCover is a Code Coverage Tool 142 | .axoCover/* 143 | !.axoCover/settings.json 144 | # Coverlet is a free, cross platform Code Coverage Tool 145 | coverage*.json 146 | coverage*.xml 147 | coverage*.info 148 | 149 | # Visual Studio code coverage results 150 | *.coverage 151 | *.coveragexml 152 | 153 | # NCrunch 154 | _NCrunch_* 155 | .*crunch*.local.xml 156 | nCrunchTemp_* 157 | 158 | # MightyMoose 159 | *.mm.* 160 | AutoTest.Net/ 161 | 162 | # Web workbench (sass) 163 | .sass-cache/ 164 | 165 | # Installshield output folder 166 | [Ee]xpress/ 167 | 168 | # DocProject is a documentation generator add-in 169 | DocProject/buildhelp/ 170 | DocProject/Help/*.HxT 171 | DocProject/Help/*.HxC 172 | DocProject/Help/*.hhc 173 | DocProject/Help/*.hhk 174 | DocProject/Help/*.hhp 175 | DocProject/Help/Html2 176 | DocProject/Help/html 177 | 178 | # Click-Once directory 179 | publish/ 180 | 181 | # Publish Web Output 182 | *.[Pp]ublish.xml 183 | *.azurePubxml 184 | # Note: Comment the next line if you want to checkin your web deploy settings, 185 | # but database connection strings (with potential passwords) will be unencrypted 186 | *.pubxml 187 | *.publishproj 188 | 189 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 190 | # checkin your Azure Web App publish settings, but sensitive information contained 191 | # in these scripts will be unencrypted 192 | PublishScripts/ 193 | 194 | # NuGet Packages 195 | *.nupkg 196 | # NuGet Symbol Packages 197 | *.snupkg 198 | # The packages folder can be ignored because of Package Restore 199 | **/[Pp]ackages/* 200 | # except build/, which is used as an MSBuild target. 201 | !**/[Pp]ackages/build/ 202 | # Uncomment if necessary however generally it will be regenerated when needed 203 | #!**/[Pp]ackages/repositories.config 204 | # NuGet v3's project.json files produces more ignorable files 205 | *.nuget.props 206 | *.nuget.targets 207 | 208 | # Microsoft Azure Build Output 209 | csx/ 210 | *.build.csdef 211 | 212 | # Microsoft Azure Emulator 213 | ecf/ 214 | rcf/ 215 | 216 | # Windows Store app package directories and files 217 | AppPackages/ 218 | BundleArtifacts/ 219 | Package.StoreAssociation.xml 220 | _pkginfo.txt 221 | *.appx 222 | *.appxbundle 223 | *.appxupload 224 | 225 | # Visual Studio cache files 226 | # files ending in .cache can be ignored 227 | *.[Cc]ache 228 | # but keep track of directories ending in .cache 229 | !?*.[Cc]ache/ 230 | 231 | # Others 232 | ClientBin/ 233 | ~$* 234 | *~ 235 | *.dbmdl 236 | *.dbproj.schemaview 237 | *.jfm 238 | *.pfx 239 | *.publishsettings 240 | orleans.codegen.cs 241 | 242 | # Including strong name files can present a security risk 243 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 244 | #*.snk 245 | 246 | # Since there are multiple workflows, uncomment next line to ignore bower_components 247 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 248 | #bower_components/ 249 | 250 | # RIA/Silverlight projects 251 | Generated_Code/ 252 | 253 | # Backup & report files from converting an old project file 254 | # to a newer Visual Studio version. Backup files are not needed, 255 | # because we have git ;-) 256 | _UpgradeReport_Files/ 257 | Backup*/ 258 | UpgradeLog*.XML 259 | UpgradeLog*.htm 260 | ServiceFabricBackup/ 261 | *.rptproj.bak 262 | 263 | # SQL Server files 264 | *.mdf 265 | *.ldf 266 | *.ndf 267 | 268 | # Business Intelligence projects 269 | *.rdl.data 270 | *.bim.layout 271 | *.bim_*.settings 272 | *.rptproj.rsuser 273 | *- [Bb]ackup.rdl 274 | *- [Bb]ackup ([0-9]).rdl 275 | *- [Bb]ackup ([0-9][0-9]).rdl 276 | 277 | # Microsoft Fakes 278 | FakesAssemblies/ 279 | 280 | # GhostDoc plugin setting file 281 | *.GhostDoc.xml 282 | 283 | # Node.js Tools for Visual Studio 284 | .ntvs_analysis.dat 285 | node_modules/ 286 | 287 | # Visual Studio 6 build log 288 | *.plg 289 | 290 | # Visual Studio 6 workspace options file 291 | *.opt 292 | 293 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 294 | *.vbw 295 | 296 | # Visual Studio 6 auto-generated project file (contains which files were open etc.) 297 | *.vbp 298 | # Visual Studio 6 workspace and project file (working project files containing files to include in project) 299 | *.dsw 300 | *.dsp 301 | # Visual Studio 6 technical files 302 | *.ncb 303 | *.aps 304 | # Visual Studio LightSwitch build output 305 | **/*.HTMLClient/GeneratedArtifacts 306 | **/*.DesktopClient/GeneratedArtifacts 307 | **/*.DesktopClient/ModelManifest.xml 308 | **/*.Server/GeneratedArtifacts 309 | **/*.Server/ModelManifest.xml 310 | _Pvt_Extensions 311 | 312 | # Paket dependency manager 313 | .paket/paket.exe 314 | paket-files/ 315 | 316 | # FAKE - F# Make 317 | .fake/ 318 | 319 | # CodeRush personal settings 320 | 321 | .cr/personal 322 | 323 | # Python Tools for Visual Studio (PTVS) 324 | __pycache__/ 325 | *.pyc 326 | 327 | # Cake - Uncomment if you are using it 328 | # tools/** 329 | # !tools/packages.config 330 | 331 | # Tabs Studio 332 | *.tss 333 | 334 | # Telerik's JustMock configuration file 335 | *.jmconfig 336 | 337 | # BizTalk build output 338 | *.btp.cs 339 | *.btm.cs 340 | *.odx.cs 341 | *.xsd.cs 342 | 343 | # OpenCover UI analysis results 344 | OpenCover/ 345 | 346 | # Azure Stream Analytics local run output 347 | ASALocalRun/ 348 | 349 | # MSBuild Binary and Structured Log 350 | *.binlog 351 | 352 | # NVidia Nsight GPU debugger configuration file 353 | *.nvuser 354 | 355 | # MFractors (Xamarin productivity tool) working folder 356 | .mfractor/ 357 | 358 | # Local History for Visual Studio 359 | .localhistory/ 360 | 361 | # Visual Studio History (VSHistory) files 362 | .vshistory/ 363 | # BeatPulse healthcheck temp database 364 | healthchecksdb 365 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 366 | MigrationBackup/ 367 | # Ionide (cross platform F# VS Code tools) working folder 368 | .ionide/ 369 | # Fody - auto-generated XML schema 370 | FodyWeavers.xsd 371 | # VS Code files for those working on multiple tools 372 | .vscode/* 373 | !.vscode/settings.json 374 | !.vscode/tasks.json 375 | !.vscode/launch.json 376 | !.vscode/extensions.json 377 | *.code-workspace 378 | 379 | # Local History for Visual Studio Code 380 | .history/ 381 | # Windows Installer files from build outputs 382 | *.cab 383 | *.msi 384 | *.msix 385 | *.msm 386 | *.msp 387 | # JetBrains Rider 388 | *.sln.iml 389 | # Build outputs 390 | _codeCoverage/ 391 | _packages/ 392 | *.sbom.* 393 | -------------------------------------------------------------------------------- /GitVersion.yml: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT THIS FILE 2 | # This file was generated by the pr-autoflow mechanism as a result of executing this action: 3 | # https://github.com/endjin/endjin-codeops/actions/workflows/deploy_pr_autoflow.yml 4 | # This repository participates in this mechanism due to an entry in one of these files: 5 | # https://github.com/endjin/endjin-codeops/blob/main/repo-level-processes/config/live 6 | 7 | mode: ContinuousDeployment 8 | branches: 9 | main: 10 | regex: ^main 11 | tag: preview 12 | increment: patch 13 | dependabot-pr: 14 | regex: ^dependabot 15 | tag: dependabot 16 | source-branches: 17 | - develop 18 | - main 19 | - release 20 | - feature 21 | - support 22 | - hotfix 23 | next-version: "0.3" 24 | 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2024 Endjin Limited 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AIS.NET Projects 2 | 3 | | Package | Status | 4 | | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | 5 | | [Ais.Net](https://github.com/ais-dotnet/Ais.Net) | [![#](https://img.shields.io/nuget/v/Ais.Net.svg)](https://www.nuget.org/packages/Ais.Net/) | 6 | | [Ais.Net.Models](https://github.com/ais-dotnet/Ais.Net.Models) | [![#](https://img.shields.io/nuget/v/Ais.Net.Models.svg)](https://www.nuget.org/packages/Ais.Net.Models/) | 7 | | Ais.Net.Receiver | [![#](https://img.shields.io/nuget/v/Ais.Net.Receiver.svg)](https://www.nuget.org/packages/Ais.Net.Receiver/) | 8 | | Ais.Net.Receiver.Storage.Azure.Blob | [![#](https://img.shields.io/nuget/v/Ais.Net.Receiver.Storage.Azure.Blob.svg)](https://www.nuget.org/packages/Ais.Net.Receiver.Storage.Azure.Blob/) | 9 | 10 | The AIS.NET project contains a series of layers, from a low-level high performance NMEA AIS sentence decoder, to a rich high-level C# 9.0 models of AIS message types, a receiver component that can listen to TCP streams of NMEA sentences and expose them as an `IObservable` of raw sentences or an decoded `IObservable`, and finally a Storage Client implementation to persisting the raw NMEA sentence stream to Azure Blob storage for future processing. 11 | 12 | ![https://github.com/ais-dotnet](https://endjincdn.blob.core.windows.net/assets/ais-dotnet-project-layers.png) 13 | 14 | While `Ais.Net` aims for zero allocation, `Ais.Net.Models` and `Ais.Net.Receiver` aim for convenience of a higher level programming model, while still being efficient. `Ais.Net.Receiver` has run on a Raspberry Pi 4 as a systemd service robustly for many years, and uses very little CPU and Memory to ingest all the Norwegian Coastal Administration in real-time. 15 | 16 | ``` 17 | PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND 18 | 679 root 20 0 287176 71536 43744 S 0.3 1.8 77:21.28 dotnet 19 | ``` 20 | 21 | ``` 22 | :~ $ sudo systemctl status aisr 23 | ● aisr.service - aisr 24 | Loaded: loaded (/lib/systemd/system/aisr.service; enabled; vendor preset: enabled) 25 | Active: active (running) since Mon 2025-03-03 12:17:19 GMT; 6 days ago 26 | Main PID: 679 (dotnet) 27 | Tasks: 16 (limit: 4915) 28 | CGroup: /system.slice/aisr.service 29 | └─679 /home/pi/.dotnet/dotnet /home/pi/aisr/Ais.Net.Receiver.Host.Console.dll 30 | ``` 31 | 32 | It is also now available as a docker container for ease of install and management. 33 | 34 | # Ais.Net.Receiver 35 | 36 | A simple .NET AIS Receiver for capturing the Norwegian Coastal Administration's marine Automatic Identification System (AIS) [AIVDM/AIVDO](https://gpsd.gitlab.io/gpsd/AIVDM.html) NMEA message network data (available under [Norwegian license for public data (NLOD)](https://data.norge.no/nlod/en/2.0)) and persisting in Microsoft Azure Blob Storage. 37 | 38 | The [Norwegian Coastal Administration provide a TCP endpoint](https://ais.kystverket.no/) (`153.44.253.27:5631`) for broadcasting their raw AIS AIVDM/AIVDO sentences, captured by over 50 base stations, and covers the area 40-60 nautical miles from the Norwegian coastline. 39 | 40 | This project contains a [NmeaReceiver](https://github.com/ais-dotnet/Ais.Net.Receiver/blob/master/Solutions/Ais.Net.Receiver/Receiver/NmeaReceiver.cs) which consumes the raw NetworkStream, a [NmeaToAisMessageTypeProcessor](https://github.com/ais-dotnet/Ais.Net.Receiver/blob/master/Solutions/Ais.Net.Receiver/Parser/NmeaToAisMessageTypeProcessor.cs), which can decode the raw sentences into `IAisMessage`, and [ReceiverHost](https://github.com/ais-dotnet/Ais.Net.Receiver/blob/master/Solutions/Ais.Net.Receiver/Receiver/ReceiverHost.cs) which manages the process and exposes an `IObservable` for raw sentences and an `IObservable` for decoded messages. `ReceiverHost` can be hosted in a console application or other runtime environments like [Polyglot Notebooks](https://github.com/dotnet/interactive) or even [WASM](#running-as-wasm). 41 | 42 | The project also includes a [demo console](https://github.com/ais-dotnet/Ais.Net.Receiver/blob/master/Solutions/Ais.Net.Receiver.Host.Console/Program.cs) which shows how the various pieces can fit together, including subscribing to the `IObservable` and `IObservable` streams and displaying the results or batch the AIVDM/AIVDO sentences and write them to Azure Blob Storage using the [Append Blob](https://docs.microsoft.com/en-us/rest/api/storageservices/append-block) feature, to create timestamped hour-long rolling logs. 43 | 44 | The purpose of this application is to provide sample data for [Ais.Net](https://github.com/ais-dotnet/Ais.Net) - the .NET Standard, high performance, zero allocation AIS decoder. The majority of raw AIS data is only available via commercial sources, and thus creating AIS datasets large enough to test / benchmark [Ais.Net](https://github.com/ais-dotnet/Ais.Net) is almost impossible. 45 | 46 | The Norwegian Coastal Administration TCP endpoint produces: 47 | 48 | - ~2.9 KB per second 49 | - ~10.3 MB per hour 50 | - ~248 MB per day 51 | - ~1.7 GB per week 52 | - ~7 GB per month 53 | - ~81.4 GB per year 54 | 55 | ## Azure Blob Storage Taxonomy 56 | 57 | The AIS data is stored using the following taxonomy 58 | 59 | `/raw/yyyy/MM/dd/yyyyMMddTHH.nm4` 60 | 61 | An example directory listing, with a user defined container name of `nmea-ais` would look as follows: 62 | 63 | ``` 64 | \---nmea-ais 65 | \---raw 66 | \---2021 67 | \---07 68 | +---12 69 | | 20210712T00.nm4 | 70 | | 20210712T01.mm4 | 71 | | 20210712T02.nm4 | 72 | | 20210712T03.nm4 | 73 | | 20210712T04.nm4 | 74 | | 20210712T05.nm4 | 75 | | 20210712T06.nm4 | 76 | | 20210712T07.nm4 | 77 | | 20210712T08.nm4 | 78 | | 20210712T09.nm4 | 79 | | 20210712T10.nm4 | 80 | | 20210712T11.nm4 | 81 | | 20210712T12.nm4 | 82 | | 20210712T13.nm4 | 83 | | 20210712T14.nm4 | 84 | | 20210712T15.nm4 | 85 | | 20210712T16.nm4 | 86 | | 20210712T17.nm4 | 87 | | 20210712T18.nm4 | 88 | | 20210712T19.nm4 | 89 | | 20210712T20.nm4 | 90 | | 20210712T21.nm4 | 91 | | 20210712T22.nm4 | 92 | | 20210712T23.nm4 | 93 | +---20210713 94 | | 20210713T00.nm4 | 95 | ``` 96 | 97 | ## To Run 98 | 99 | Update the values in the `settings.json` file: 100 | 101 | ```json 102 | { 103 | "Ais": { 104 | "host": "153.44.253.27", 105 | "port": "5631", 106 | "retryAttempts": 5, 107 | "retryPeriodicity": "00:00:00:00.500" 108 | }, 109 | "Storage": { 110 | "connectionString": "", 111 | "containerName": "nmea-ais", 112 | "writeBatchSize": 500 113 | } 114 | } 115 | ``` 116 | 117 | From the command line: `dotnet Ais.Net.Receiver.Host.Console.exe` 118 | 119 | # Raspberry Pi 120 | 121 | You have two options for running the AIS Receiver on a Raspberry Pi: using a Docker container or as a systemd service. 122 | 123 | ## Installation 124 | 125 | The combination of Windows Terminal, .NET and PowerShell make a Raspberry Pi a very productive environment for .NET Devs. 126 | 127 | Install [Windows Terminal](https://github.com/microsoft/terminal). You can download Windows Terminal from the [Microsoft Store](https://www.microsoft.com/en-gb/p/windows-terminal/9n0dx20hk701) or from the [GitHub releases page](https://github.com/microsoft/terminal/releases). 128 | 129 | Open Windows Terminal and use `ssh pi@` to connect to your Pi. 130 | 131 | ### Using Docker 132 | 133 | These steps assume that you have [configured passwordless authentication on your Raspberry Pi](https://endjin.com/blog/2019/09/passwordless-ssh-from-windows-10-to-raspberry-pi). 134 | 135 | Set up the required environment variables on your Raspberry Pi: 136 | 137 | ```bash 138 | ssh user@pi 139 | nano ~/.bashrc 140 | ``` 141 | 142 | Add the following lines to the end of the file: 143 | 144 | ```bash 145 | export AIS_NET_RECEIVER_AZURE_CONNECTION_STRING="" 146 | ``` 147 | 148 | Save & Exit nano. To load the environment variables, then run: 149 | 150 | ```bash 151 | source ~/.bashrc 152 | ``` 153 | 154 | Install Docker & Docker Composer on your Raspberry Pi: 155 | 156 | ```bash 157 | curl -sSL https://get.docker.com | sh 158 | sudo usermod -aG docker $USER 159 | sudo reboot 160 | sudo apt install docker-compose 161 | mkdir aisr 162 | sudo apt-get update && sudo apt-get upgrade && sudo apt autoremove 163 | exit 164 | ``` 165 | 166 | On your host machine, open Windows Terminal: 167 | 168 | ```bash 169 | cd ./Solutions/Ais.Net.Receiver.Host.Console.RaspberryPi 170 | scp .\docker-compose.yml user@pi:~/aisr/ 171 | ssh user@pi 172 | cd aisr 173 | docker-compose up -d 174 | ``` 175 | 176 | This will automatically pull the latest [image from Docker Hub](https://hub.docker.com/r/endjin/ais-dotnet-receiver) and run the AIS Receiver using the Azure Storage Connection String you configured as an environment variable. Use [Azure Storage Explorer](https://azure.microsoft.com/en-us/features/storage-explorer/) to browse to where files are captured. You should see entries added within the first minute of the service starting. 177 | 178 | ### Install as a systemd service 179 | 180 | If you want to run the service as a daemon, you can use SystemD to manage the service. 181 | 182 | #### Install .NET 183 | 184 | Use the following commands to install .NET on your Pi. 185 | 186 | 1. `curl -sSL https://dot.net/v1/dotnet-install.sh | bash /dev/stdin --channel Current` 187 | 1. `echo 'export DOTNET_ROOT=$HOME/.dotnet' >> ~/.bashrc` 188 | 1. `echo 'export PATH=$PATH:$HOME/.dotnet' >> ~/.bashrc` 189 | 1. `source ~/.bashrc` 190 | 1. `dotnet --version` 191 | 192 | #### Install PowerShell 7.x 193 | 194 | Use the following commands to install PowerShell on your Pi. 195 | 196 | 1. Download the latest package `wget https://github.com/PowerShell/PowerShell/releases/download/v7.5.0/powershell-7.5.0-linux-arm64.tar.gz` 197 | 1. Create a directory for it to be unpacked into `mkdir ~/powershell` 198 | 1. Unpack `tar -xvf ./powershell-7.5.0-linux-arm64.tar.gz -C ~/powershell` 199 | 1. Give it executable rights `sudo chmod +x /opt/microsoft/powershell/7/pwsh` 200 | 1. Create a symbolic link `sudo ln -s /opt/microsoft/powershell/7/pwsh /usr/bin/pwsh` 201 | 202 | use the command `pwsh` to enter the PowerShell session. 203 | 204 | #### Install Ais.Net.Receiver.Host.Console 205 | 206 | 2. From the solution root, open a command prompt and type `dotnet publish -c Release .\Solutions\Ais.Net.Receiver.sln` 207 | 3. Add your Azure Blob Storage Account connection string to `settings.json` 208 | 4. Transfer (I use [Beyond Compare](https://www.scootersoftware.com/) as it has native SSH support) the contents of `.\Solutions\Ais.Net.Receiver.Host.Console\bin\Release\net5.0\publish` to a folder called `aisr` in the `home/pi` directory on your Raspberry Pi (assuming you still have the default set up.) 209 | 5. Copy `Solutions\Ais.Net.Receiver.Host.Console.RaspberryPi\aisr.service` to `/lib/systemd/system/aisr.service` 210 | 6. run `sudo chmod 644 /lib/systemd/system/aisr.service` 211 | 7. run `sudo systemctl enable aisr.service` 212 | 8. run `sudo reboot` 213 | 214 | You can use `journalctl -u "aisr"` to view the console output of `Ais.Net.Receiver.Host.Console.dll` 215 | 216 | You can use `sudo systemctl restart aisr` to restart the service. 217 | 218 | If you need to look at / edit the deployed `aisr.service` use `sudo nano /lib/systemd/system/aisr.service` make your edits then use `Ctrl+O` and `Ctrl+X` to save the file and exit. 219 | 220 | Use [Azure Storage Explorer](https://azure.microsoft.com/en-us/features/storage-explorer/) to browse to where files are captured. 221 | 222 | #### Configuration 223 | 224 | Configuration is read from `settings.json` and can also be overridden for local development by using a `settings.local.json` file. 225 | 226 | ```json 227 | { 228 | "Ais": { 229 | "host": "153.44.253.27", 230 | "port": "5631", 231 | "loggerVerbosity": "Minimal", 232 | "statisticsPeriodicity": "00:01:00", 233 | "retryAttempts": 5, 234 | "retryPeriodicity": "00:00:00:00.500" 235 | }, 236 | "Storage": { 237 | "enableCapture": true, 238 | "connectionString": "DefaultEndpointsProtocol=https;AccountName=;AccountKey=", 239 | "containerName": "nmea-ais-dev", 240 | "writeBatchSize": 500 241 | } 242 | } 243 | ``` 244 | 245 | ##### AIS 246 | 247 | These settings control the `ReceiverHost` and its behaviour. 248 | 249 | - `host`: IP Address or FQDN of the AIS Source 250 | - `port`: Port number for the AIS Source 251 | - `loggerVerbosity`: Controls the output to the console. 252 | - `Quiet` = Essential only, 253 | - `Minimal` = Statistics only. Sample rate of statistics controlled by `statisticsPeriodicity`, 254 | - `Normal` = Vessel Names and Positions, 255 | - `Detailed` = NMEA Sentences, 256 | - `Diagnostic` = Messages and Errors 257 | - `statisticsPeriodicity`: TimeSpan defining the sample rate of statistics to display 258 | - `retryAttempts`: Number of retry attempts when a connection error occurs 259 | - `retryPeriodicity`: How long to wait before a retry attempt. 260 | 261 | ##### Storage 262 | 263 | These settings control the capturing NMEA sentences to Azure Blob Storage. 264 | 265 | - `enableCapture`: Whether you want to capture the NMEA sentences and write them to Azure Blob Storage 266 | - `connectionString`: Azure Storage Account Connection String 267 | - `containerName`: Name of the container to capture the NMEA sentences. You can use this to separate a local dev storage container from your production storage container, within the same storage account. 268 | - `writeBatchSize`: How many NMEA sentences to batch before writing to Azure Blob Storage. 269 | 270 | ## Running as WASM 271 | 272 | A [Proof of Concept](https://github.com/endjin/componentize-dotnet-demo?tab=readme-ov-file#aisnetreceiverhostwasi-demo) using [componentize-dotnet](https://github.com/bytecodealliance/componentize-dotnet) and a custom [WASI](https://github.com/WebAssembly/WASI) implementation of `WasiSocketNmeaStreamReader` enables the receiver to run as WASM via Wasmtime. 273 | 274 | ## Licenses 275 | 276 | [![GitHub license](https://img.shields.io/badge/license-Apache%202-blue.svg)](https://raw.githubusercontent.com/ais-dotnet/Ais.Net.Receiver/master/LICENSE) 277 | 278 | AIS.Net.Receiver is also available under the Apache 2.0 open source license. 279 | 280 | The Data ingested by the AIS.Net.Receiver is licensed under the [Norwegian license for public data (NLOD)](https://data.norge.no/nlod/en/2.0). 281 | 282 | ## Project Sponsor 283 | 284 | This project is sponsored by [endjin](https://endjin.com), a UK based Technology Consultancy which specializes in Data & Analytics, AI & Cloud Native App Dev, and is a [.NET Foundation Corporate Sponsor](https://dotnetfoundation.org/membership/corporate-sponsorship). 285 | 286 | > We help small teams achieve big things. 287 | 288 | We produce two free weekly newsletters: 289 | 290 | - [Azure Weekly](https://azureweekly.info) for all things about the Microsoft Azure Platform 291 | - [Power BI Weekly](https://powerbiweekly.info) for all things Power BI, Microsoft Fabric, and Azure Synapse Analytics 292 | 293 | Keep up with everything that's going on at endjin via our [blog](https://endjin.com/blog), follow us on [Twitter](https://twitter.com/endjin), [YouTube](https://www.youtube.com/c/endjin) or [LinkedIn](https://www.linkedin.com/company/endjin). 294 | 295 | We have become the maintainers of a number of popular .NET Open Source Projects: 296 | 297 | - [Reactive Extensions for .NET](https://github.com/dotnet/reactive) 298 | - [Reaqtor](https://github.com/reaqtive) 299 | - [Argotic Syndication Framework](https://github.com/argotic-syndication-framework/) 300 | 301 | And we have over 50 Open Source projects of our own, spread across the following GitHub Orgs: 302 | 303 | - [endjin](https://github.com/endjin/) 304 | - [Corvus](https://github.com/corvus-dotnet) 305 | - [Menes](https://github.com/menes-dotnet) 306 | - [Marain](https://github.com/marain-dotnet) 307 | - [AIS.NET](https://github.com/ais-dotnet) 308 | 309 | And the DevOps tooling we have created for managing all these projects is available on the [PowerShell Gallery](https://www.powershellgallery.com/profiles/endjin). 310 | 311 | For more information about our products and services, or for commercial support of this project, please [contact us](https://endjin.com/contact-us). 312 | 313 | ## Code of conduct 314 | 315 | This project has adopted a code of conduct adapted from the [Contributor Covenant](http://contributor-covenant.org/) to clarify expected behaviour in our community. This code of conduct has been [adopted by many other projects](http://contributor-covenant.org/adopters/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [hello@endjin.com](mailto:hello@endjin.com) with any additional questions or comments. 316 | 317 | ## IP Maturity Model (IMM) 318 | 319 | [![Shared Engineering Standards](https://imm.endjin.com/api/imm/github/ais-dotnet/Ais.Net.Receiver/rule/74e29f9b-6dca-4161-8fdd-b468a1eb185d?nocache=true)](https://imm.endjin.com/api/imm/github/ais-dotnet/Ais.Net.Receiver/rule/74e29f9b-6dca-4161-8fdd-b468a1eb185d?cache=false) 320 | 321 | [![Coding Standards](https://imm.endjin.com/api/imm/github/ais-dotnet/Ais.Net.Receiver/rule/f6f6490f-9493-4dc3-a674-15584fa951d8?cache=false)](https://imm.endjin.com/api/imm/github/ais-dotnet/Ais.Net.Receiver/rule/f6f6490f-9493-4dc3-a674-15584fa951d8?cache=false) 322 | 323 | [![Executable Specifications](https://imm.endjin.com/api/imm/github/ais-dotnet/Ais.Net.Receiver/rule/bb49fb94-6ab5-40c3-a6da-dfd2e9bc4b00?cache=false)](https://imm.endjin.com/api/imm/github/ais-dotnet/Ais.Net.Receiver/rule/bb49fb94-6ab5-40c3-a6da-dfd2e9bc4b00?cache=false) 324 | 325 | [![Code Coverage](https://imm.endjin.com/api/imm/github/ais-dotnet/Ais.Net.Receiver/rule/0449cadc-0078-4094-b019-520d75cc6cbb?cache=false)](https://imm.endjin.com/api/imm/github/ais-dotnet/Ais.Net.Receiver/rule/0449cadc-0078-4094-b019-520d75cc6cbb?cache=false) 326 | 327 | [![Benchmarks](https://imm.endjin.com/api/imm/github/ais-dotnet/Ais.Net.Receiver/rule/64ed80dc-d354-45a9-9a56-c32437306afa?cache=false)](https://imm.endjin.com/api/imm/github/ais-dotnet/Ais.Net.Receiver/rule/64ed80dc-d354-45a9-9a56-c32437306afa?cache=false) 328 | 329 | [![Reference Documentation](https://imm.endjin.com/api/imm/github/ais-dotnet/Ais.Net.Receiver/rule/2a7fc206-d578-41b0-85f6-a28b6b0fec5f?cache=false)](https://imm.endjin.com/api/imm/github/ais-dotnet/Ais.Net.Receiver/rule/2a7fc206-d578-41b0-85f6-a28b6b0fec5f?cache=false) 330 | 331 | [![Design & Implementation Documentation](https://imm.endjin.com/api/imm/github/ais-dotnet/Ais.Net.Receiver/rule/f026d5a2-ce1a-4e04-af15-5a35792b164b?cache=false)](https://imm.endjin.com/api/imm/github/ais-dotnet/Ais.Net.Receiver/rule/f026d5a2-ce1a-4e04-af15-5a35792b164b?cache=false) 332 | 333 | [![How-to Documentation](https://imm.endjin.com/api/imm/github/ais-dotnet/Ais.Net.Receiver/rule/145f2e3d-bb05-4ced-989b-7fb218fc6705?cache=false)](https://imm.endjin.com/api/imm/github/ais-dotnet/Ais.Net.Receiver/rule/145f2e3d-bb05-4ced-989b-7fb218fc6705?cache=false) 334 | 335 | [![Date of Last IP Review](https://imm.endjin.com/api/imm/github/ais-dotnet/Ais.Net.Receiver/rule/da4ed776-0365-4d8a-a297-c4e91a14d646?cache=false)](https://imm.endjin.com/api/imm/github/ais-dotnet/Ais.Net.Receiver/rule/da4ed776-0365-4d8a-a297-c4e91a14d646?cache=false) 336 | 337 | [![Framework Version](https://imm.endjin.com/api/imm/github/ais-dotnet/Ais.Net.Receiver/rule/6c0402b3-f0e3-4bd7-83fe-04bb6dca7924?cache=false)](https://imm.endjin.com/api/imm/github/ais-dotnet/Ais.Net.Receiver/rule/6c0402b3-f0e3-4bd7-83fe-04bb6dca7924?cache=false) 338 | 339 | [![Associated Work Items](https://imm.endjin.com/api/imm/github/ais-dotnet/Ais.Net.Receiver/rule/79b8ff50-7378-4f29-b07c-bcd80746bfd4?cache=false)](https://imm.endjin.com/api/imm/github/ais-dotnet/Ais.Net.Receiver/rule/79b8ff50-7378-4f29-b07c-bcd80746bfd4?cache=false) 340 | 341 | [![Source Code Availability](https://imm.endjin.com/api/imm/github/ais-dotnet/Ais.Net.Receiver/rule/30e1b40b-b27d-4631-b38d-3172426593ca?cache=false)](https://imm.endjin.com/api/imm/github/ais-dotnet/Ais.Net.Receiver/rule/30e1b40b-b27d-4631-b38d-3172426593ca?cache=false) 342 | 343 | [![License](https://imm.endjin.com/api/imm/github/ais-dotnet/Ais.Net.Receiver/rule/d96b5bdc-62c7-47b6-bcc4-de31127c08b7?cache=false)](https://imm.endjin.com/api/imm/github/ais-dotnet/Ais.Net.Receiver/rule/d96b5bdc-62c7-47b6-bcc4-de31127c08b7?cache=false) 344 | 345 | [![Production Use](https://imm.endjin.com/api/imm/github/ais-dotnet/Ais.Net.Receiver/rule/87ee2c3e-b17a-4939-b969-2c9c034d05d7?cache=false)](https://imm.endjin.com/api/imm/github/ais-dotnet/Ais.Net.Receiver/rule/87ee2c3e-b17a-4939-b969-2c9c034d05d7?cache=false) 346 | 347 | [![Insights](https://imm.endjin.com/api/imm/github/ais-dotnet/Ais.Net.Receiver/rule/71a02488-2dc9-4d25-94fa-8c2346169f8b?cache=false)](https://imm.endjin.com/api/imm/github/ais-dotnet/Ais.Net.Receiver/rule/71a02488-2dc9-4d25-94fa-8c2346169f8b?cache=false) 348 | 349 | [![Packaging](https://imm.endjin.com/api/imm/github/ais-dotnet/Ais.Net.Receiver/rule/547fd9f5-9caf-449f-82d9-4fba9e7ce13a?cache=false)](https://imm.endjin.com/api/imm/github/ais-dotnet/Ais.Net.Receiver/rule/547fd9f5-9caf-449f-82d9-4fba9e7ce13a?cache=false) 350 | 351 | [![Deployment](https://imm.endjin.com/api/imm/github/ais-dotnet/Ais.Net.Receiver/rule/edea4593-d2dd-485b-bc1b-aaaf18f098f9?cache=false)](https://imm.endjin.com/api/imm/github/ais-dotnet/Ais.Net.Receiver/rule/edea4593-d2dd-485b-bc1b-aaaf18f098f9?cache=false) 352 | 353 | [![OpenChain](https://imm.endjin.com/api/imm/github/ais-dotnet/Ais.Net.Receiver/rule/66efac1a-662c-40cf-b4ec-8b34c29e9fd7?cache=false)](https://imm.endjin.com/api/imm/github/ais-dotnet/Ais.Net.Receiver/rule/66efac1a-662c-40cf-b4ec-8b34c29e9fd7?cache=false) 354 | -------------------------------------------------------------------------------- /Solutions/.dockerignore: -------------------------------------------------------------------------------- 1 | **/.classpath 2 | **/.dockerignore 3 | **/.env 4 | **/.git 5 | **/.gitignore 6 | **/.project 7 | **/.settings 8 | **/.toolstarget 9 | **/.vs 10 | **/.vscode 11 | **/*.*proj.user 12 | **/*.dbmdl 13 | **/*.jfm 14 | **/azds.yaml 15 | **/bin 16 | **/charts 17 | **/docker-compose* 18 | **/Dockerfile* 19 | **/node_modules 20 | **/npm-debug.log 21 | **/obj 22 | **/secrets.dev.yaml 23 | **/values.dev.yaml 24 | LICENSE 25 | README.md 26 | !**/.gitignore 27 | !.git/HEAD 28 | !.git/config 29 | !.git/packed-refs 30 | !.git/refs/heads/** -------------------------------------------------------------------------------- /Solutions/.editorconfig: -------------------------------------------------------------------------------- 1 | # Note: this file was added to your solution as a result of one or more projects using the Endjin.RecommendedPractices.Build.Common NuGet package. 2 | # You can edit this file (e.g., to remove these comments), and it will not be updated - the package just checks for its presence, and copies 3 | # this file. If you don't want this file (but you want to use the NuGet package that puts it here), add this setting to all projects 4 | # using Endjin.RecommendedPractices.Build.Common: 5 | # true 6 | # and then delete this file. That setting will prevent the package from recreating this file. 7 | 8 | # Code style rules for enforcing the endjin house style 9 | # See: https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-code-style-settings-reference 10 | 11 | # XML project files 12 | [*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}] 13 | indent_size = 2 14 | 15 | # XML config files 16 | [*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}] 17 | indent_size = 2 18 | 19 | # C# files 20 | [*.{cs,csx}] 21 | indent_size = 4 22 | insert_final_newline = false 23 | 24 | # Core editorconfig formatting - indentation 25 | 26 | # use soft tabs (spaces) for indentation 27 | indent_style = space 28 | 29 | # Formatting - indentation options 30 | 31 | csharp_indent_case_contents = true 32 | csharp_indent_switch_labels = true 33 | 34 | # Formatting - new line options 35 | 36 | csharp_new_line_before_catch = true 37 | csharp_new_line_before_else = true 38 | csharp_new_line_before_open_brace = object_collection, object_collection_array_initalizers, accessors, lambdas, control_blocks, methods, properties, types 39 | 40 | # Formatting - spacing options 41 | 42 | csharp_space_after_cast = false 43 | csharp_space_after_colon_in_inheritance_clause = true 44 | csharp_space_after_keywords_in_control_flow_statements = true 45 | csharp_space_before_colon_in_inheritance_clause = true 46 | csharp_space_between_method_call_empty_parameter_list_parentheses = false 47 | csharp_space_between_method_call_name_and_opening_parenthesis = false 48 | csharp_space_between_method_call_parameter_list_parentheses = false 49 | csharp_space_between_method_declaration_empty_parameter_list_parentheses = false 50 | csharp_space_between_method_declaration_parameter_list_parentheses = false 51 | 52 | # Formatting - wrapping options 53 | 54 | csharp_preserve_single_line_blocks = true 55 | csharp_preserve_single_line_statements = true 56 | 57 | 58 | # Style - expression bodied member options 59 | 60 | csharp_style_expression_bodied_accessors = false:none 61 | csharp_style_expression_bodied_constructors = false:none 62 | csharp_style_expression_bodied_methods = false:none 63 | csharp_style_expression_bodied_properties = false:none 64 | 65 | # Style - expression level options 66 | 67 | csharp_style_inlined_variable_declaration = true:suggestion 68 | dotnet_style_predefined_type_for_member_access = true:suggestion 69 | 70 | # Style - implicit and explicit types 71 | 72 | csharp_style_var_for_built_in_types = false:warning 73 | csharp_style_var_when_type_is_apparent = true:warning 74 | csharp_style_var_elsewhere = false:warning 75 | 76 | # Style - language keyword and framework type options 77 | 78 | dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion 79 | 80 | # Style - qualification options 81 | 82 | dotnet_style_qualification_for_field = true:suggestion 83 | dotnet_style_qualification_for_method = true:suggestion 84 | dotnet_style_qualification_for_property = true:suggestion 85 | dotnet_style_qualification_for_event = true:suggestion 86 | 87 | # Style - using directives and namespace declarations 88 | csharp_using_directive_placement = inside_namespace:warning 89 | dotnet_sort_system_directives_first = true 90 | 91 | # Code analyzer rules (formerly in StyleCop.ruleset) 92 | # SA1025 makes it impossible to write decent-looking switch expressions because it 93 | # doesn't allow multiple whitespace characters in a row. There seems to be no way to 94 | # disable this rule just inside switch expressions, so we reluctantly live without it. 95 | dotnet_diagnostic.SA1025.severity = None 96 | # StyleCop's insistence that constructors' summaries must always start with exactly 97 | # the same boilerplate text is not deeply helpful. Saying "Creates a Foo" 98 | # is in most cases better than the "Initializes a new instance of the Foo type" 99 | # that this rule insists on. 100 | dotnet_diagnostic.SA1642.severity = None 101 | 102 | # CLS Compliance - not relevant for most of what we work on. 103 | dotnet_diagnostic.CA1014.severity = None 104 | 105 | # Standard exception constructors - not helpful when the exception absolutely needs certain information 106 | dotnet_diagnostic.CA1032.severity = None 107 | dotnet_diagnostic.RCS1194.severity = None 108 | 109 | # Localize string constants - apps that want localization can turn this back on 110 | dotnet_diagnostic.CA1303.severity = None 111 | -------------------------------------------------------------------------------- /Solutions/Ais.Net.Receiver.Host.Console.RaspberryPi/aisr.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=aisr 3 | After=multi-user.target 4 | StartLimitIntervalSec=500 5 | StartLimitBurst=5 6 | 7 | [Service] 8 | Type=idle 9 | ExecStart=/home/pi/.dotnet/dotnet /home/pi/aisr/Ais.Net.Receiver.Host.Console.dll 10 | Restart=on-failure 11 | RestartSec=5s 12 | 13 | [Install] 14 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /Solutions/Ais.Net.Receiver.Host.Console.RaspberryPi/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | ais_net_receiver: 3 | container_name: ais-dotnet-receiver 4 | image: endjin/ais-dotnet-receiver:0.3.8-dockerize.1 5 | environment: 6 | - Ais__host=153.44.253.27 7 | - Ais__port=5631 8 | - Ais__loggerVerbosity=Minimal 9 | - Ais__statisticsPeriodicity=00:00:01:00 10 | - Ais__retryAttempts=5 11 | - Ais__retryPeriodicity=00:00:00:01 12 | - Storage__enableCapture=true 13 | - Storage__connectionString=${AIS_NET_RECEIVER_AZURE_CONNECTION_STRING} 14 | - Storage__containerName=nmea-ais-dev 15 | - Storage__writeBatchSize=500 16 | dns: 17 | - 8.8.8.8 # Required if you're running on a Raspberry Pi which also hosts pi-hole 18 | restart: unless-stopped -------------------------------------------------------------------------------- /Solutions/Ais.Net.Receiver.Host.Console/Ais.Net.Receiver.Host.Console.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Exe 7 | net8.0 8 | latest 9 | enable 10 | false 11 | 12 | 13 | 14 | true 15 | true 16 | 17 | 18 | 19 | RCS1029;SA1200;SA1313;SA1009;SA1600;SA1591;CS1591; 20 | Linux 21 | docker.io/endjin/ais-dotnet-receiver 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | all 39 | runtime; build; native; contentfiles; analyzers; buildtransitive 40 | 41 | 42 | 43 | 44 | 45 | PreserveNewest 46 | 47 | 48 | 49 | 50 | 51 | Always 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /Solutions/Ais.Net.Receiver.Host.Console/Ais/Net/Receiver/Host/Console/Program.cs: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Endjin Limited. All rights reserved. 3 | // 4 | 5 | using System; 6 | using System.Collections.Generic; 7 | using System.Threading; 8 | using System.Threading.Tasks; 9 | using System.Threading.Tasks.Dataflow; 10 | 11 | using Ais.Net.Models; 12 | using Ais.Net.Models.Abstractions; 13 | using Ais.Net.Receiver.Configuration; 14 | using Ais.Net.Receiver.Receiver; 15 | using Ais.Net.Receiver.Storage; 16 | using Ais.Net.Receiver.Storage.Azure.Blob; 17 | using Ais.Net.Receiver.Storage.Azure.Blob.Configuration; 18 | 19 | using Microsoft.Extensions.Configuration; 20 | 21 | namespace Ais.Net.Receiver.Host.Console; 22 | 23 | /// 24 | /// Host application for the . 25 | /// 26 | public static class Program 27 | { 28 | /// 29 | /// Entry point for the application. 30 | /// 31 | /// Task representing the operation. 32 | public static async Task Main() 33 | { 34 | IConfiguration config = new ConfigurationBuilder() 35 | .AddJsonFile("settings.json", true, true) 36 | .AddJsonFile("settings.local.json", true, true) 37 | .AddEnvironmentVariables() 38 | .Build(); 39 | 40 | AisConfig? aisConfig = config.GetSection("Ais").Get(); 41 | StorageConfig? storageConfig = config.GetSection("Storage").Get(); 42 | 43 | if (aisConfig is null || storageConfig is null) 44 | { 45 | throw new InvalidOperationException("Configuration is invalid."); 46 | } 47 | 48 | INmeaReceiver receiver = new NetworkStreamNmeaReceiver( 49 | aisConfig.Host, 50 | aisConfig.Port, 51 | aisConfig.RetryPeriodicity, 52 | retryAttemptLimit: aisConfig.RetryAttempts); 53 | 54 | // If you wanted to run from a captured stream uncomment this line: 55 | 56 | /* 57 | INmeaReceiver receiver = new FileStreamNmeaReceiver(@"PATH-TO-RECORDING.nm4"); 58 | */ 59 | 60 | ReceiverHost receiverHost = new(receiver); 61 | 62 | if (aisConfig.LoggerVerbosity == LoggerVerbosity.Minimal) 63 | { 64 | receiverHost.GetStreamStatistics(aisConfig.StatisticsPeriodicity) 65 | .Subscribe(statistics => 66 | System.Console.WriteLine($"{DateTime.UtcNow.ToUniversalTime()}: Sentences: {statistics.Sentence} | Messages: {statistics.Message} | Errors: {statistics.Error}")); 67 | } 68 | 69 | if (aisConfig.LoggerVerbosity == LoggerVerbosity.Normal) 70 | { 71 | receiverHost.Messages.VesselNavigationWithNameStream().Subscribe(navigationWithName => 72 | { 73 | (uint mmsi, IVesselNavigation navigation, IVesselName name) = navigationWithName; 74 | string positionText = navigation.Position is null ? "unknown position" : $"{navigation.Position.Latitude},{navigation.Position.Longitude}"; 75 | 76 | System.Console.ForegroundColor = ConsoleColor.Green; 77 | System.Console.WriteLine($"[{mmsi}: '{name.VesselName.CleanVesselName()}'] - [{positionText}] - [{navigation.CourseOverGround ?? 0}]"); 78 | System.Console.ResetColor(); 79 | }); 80 | } 81 | 82 | if (aisConfig.LoggerVerbosity == LoggerVerbosity.Detailed) 83 | { 84 | // Write out the messages as they are received over the wire. 85 | receiverHost.Sentences.Subscribe(System.Console.WriteLine); 86 | } 87 | 88 | if (aisConfig.LoggerVerbosity == LoggerVerbosity.Diagnostic) 89 | { 90 | receiverHost.Messages.Subscribe(System.Console.WriteLine); 91 | 92 | // Write out errors in the console 93 | receiverHost.Errors.Subscribe(error => 94 | { 95 | System.Console.ForegroundColor = ConsoleColor.Red; 96 | System.Console.WriteLine($"Error received: {error.Exception.Message}"); 97 | System.Console.WriteLine($"Bad line: {error.Line}"); 98 | System.Console.ResetColor(); 99 | }); 100 | } 101 | 102 | if (storageConfig.EnableCapture) 103 | { 104 | IStorageClient storageClient = new AzureAppendBlobStorageClient(storageConfig); 105 | BatchBlock batchBlock = new(storageConfig.WriteBatchSize); 106 | ActionBlock> actionBlock = new(storageClient.PersistAsync); 107 | batchBlock.LinkTo(actionBlock); 108 | 109 | // Persist the messages as they are received over the wire. 110 | receiverHost.Sentences.Subscribe(batchBlock.AsObserver()); 111 | } 112 | 113 | CancellationTokenSource cts = new(); 114 | 115 | Task task = receiverHost.StartAsync(cts.Token); 116 | 117 | // If you wanted to cancel the long-running process: 118 | /* cts.Cancel(); */ 119 | 120 | await task; 121 | } 122 | } -------------------------------------------------------------------------------- /Solutions/Ais.Net.Receiver.Host.Console/Ais/Net/Receiver/Host/Console/ReceiverHostExtensions.cs: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Endjin Limited. All rights reserved. 3 | // 4 | 5 | using System; 6 | using System.Reactive.Linq; 7 | using Ais.Net.Models.Abstractions; 8 | using Ais.Net.Receiver.Receiver; 9 | 10 | namespace Ais.Net.Receiver.Host.Console; 11 | 12 | /// 13 | /// Extensions for the and its data streams. 14 | /// 15 | public static class ReceiverHostExtensions 16 | { 17 | /// 18 | /// Calculates statistics about the number of , and 19 | /// generated during the specified . 20 | /// 21 | /// The to extend. 22 | /// The duration statistics should be collected for, before returning. 23 | /// An observable sequence of tuple containing statistics. 24 | public static IObservable<(long Message, long Sentence, long Error)> GetStreamStatistics(this ReceiverHost receiverHost, TimeSpan period) 25 | { 26 | IObservable<(long Messages, long Sentences, long Errors)> runningCounts = 27 | receiverHost.Messages.RunningCount().CombineLatest( 28 | receiverHost.Sentences.RunningCount(), 29 | receiverHost.Errors.RunningCount(), 30 | (messages, sentences, errors) => (messages, sentences, errors)); 31 | 32 | return runningCounts.Buffer(period) 33 | .Select(window => 34 | { 35 | // Handle empty window or window with only one element 36 | if (window.Count == 0) 37 | { 38 | return (Message: 0L, Sentence: 0L, Error: 0L); 39 | } 40 | 41 | if (window.Count == 1) 42 | { 43 | // With only one element, there's no difference to calculate 44 | return (Message: 0L, Sentence: 0L, Error: 0L); 45 | } 46 | 47 | // Normal case with at least two elements 48 | (long Messages, long Sentences, long Errors) first = window[0]; 49 | (long Messages, long Sentences, long Errors) last = window[^1]; 50 | 51 | return ( 52 | Message: last.Messages - first.Messages, 53 | Sentence: last.Sentences - first.Sentences, 54 | Error: last.Errors - first.Errors 55 | ); 56 | }); 57 | } 58 | 59 | /// 60 | /// Groups and combines the AIS Messages so that vessel name and navigation information can be displayed. 61 | /// 62 | /// An observable stream of AIS Messages. 63 | /// An observable sequence of tuple containing vessel information. 64 | public static IObservable<(uint Mmsi, IVesselNavigation Navigation, IVesselName Name)> VesselNavigationWithNameStream(this IObservable messages) 65 | { 66 | // Decode the sentences into messages, and group by the vessel by Id 67 | IObservable> byVessel = messages.GroupBy(m => m.Mmsi); 68 | 69 | // Combine the various message types required to create a stream containing name and navigation 70 | return 71 | from perVesselMessages in byVessel 72 | let vesselNavigationUpdates = perVesselMessages.OfType() 73 | let vesselNames = perVesselMessages.OfType() 74 | let vesselLocationsWithNames = vesselNavigationUpdates.CombineLatest(vesselNames, (navigation, name) => (navigation, name)) 75 | from vesselLocationAndName in vesselLocationsWithNames 76 | select (Mmsi: perVesselMessages.Key, vesselLocationAndName.navigation, vesselLocationAndName.name); 77 | } 78 | 79 | /// 80 | /// Provides a running count of events provided by an observable stream. 81 | /// 82 | /// Type of events to count. 83 | /// Observable stream of events to count. 84 | /// An observable sequence representing the count of events. 85 | private static IObservable RunningCount(this IObservable eventsForCount) 86 | { 87 | return eventsForCount.Scan(0L, (total, _) => total + 1); 88 | } 89 | } -------------------------------------------------------------------------------- /Solutions/Ais.Net.Receiver.Host.Console/Dockerfile: -------------------------------------------------------------------------------- 1 | # See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging. 2 | 3 | # This stage is used when running from VS in fast mode (Default for Debug configuration) 4 | FROM mcr.microsoft.com/dotnet/runtime:8.0-bookworm-slim AS base 5 | USER $APP_UID 6 | WORKDIR /app 7 | 8 | 9 | # This stage is used to build the service project 10 | FROM mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim AS build 11 | ARG BUILD_CONFIGURATION=Release 12 | WORKDIR /src 13 | COPY ["Ais.Net.Receiver.Host.Console/Ais.Net.Receiver.Host.Console.csproj", "Ais.Net.Receiver.Host.Console/"] 14 | COPY ["Ais.Net.Receiver.Storage.Azure.Blob/Ais.Net.Receiver.Storage.Azure.Blob.csproj", "Ais.Net.Receiver.Storage.Azure.Blob/"] 15 | COPY ["Ais.Net.Receiver/Ais.Net.Receiver.csproj", "Ais.Net.Receiver/"] 16 | RUN dotnet restore "./Ais.Net.Receiver.Host.Console/Ais.Net.Receiver.Host.Console.csproj" 17 | COPY . . 18 | WORKDIR "/src/Ais.Net.Receiver.Host.Console" 19 | RUN dotnet build "./Ais.Net.Receiver.Host.Console.csproj" -c $BUILD_CONFIGURATION -o /app/build 20 | 21 | # This stage is used to publish the service project to be copied to the final stage 22 | FROM build AS publish 23 | ARG BUILD_CONFIGURATION=Release 24 | RUN dotnet publish "./Ais.Net.Receiver.Host.Console.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false 25 | 26 | # This stage is used in production or when running from VS in regular mode (Default when not using the Debug configuration) 27 | FROM base AS final 28 | WORKDIR /app 29 | COPY --from=publish /app/publish . 30 | ENTRYPOINT ["dotnet", "Ais.Net.Receiver.Host.Console.dll"] -------------------------------------------------------------------------------- /Solutions/Ais.Net.Receiver.Host.Console/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "Ais.Net.Receiver.Host.Console": { 4 | "commandName": "Project" 5 | }, 6 | "Container (Dockerfile)": { 7 | "commandName": "Docker" 8 | } 9 | } 10 | } -------------------------------------------------------------------------------- /Solutions/Ais.Net.Receiver.Host.Console/packages.lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "dependencies": { 4 | "net8.0": { 5 | "Endjin.RecommendedPractices.GitHub": { 6 | "type": "Direct", 7 | "requested": "[2.1.18, )", 8 | "resolved": "2.1.18", 9 | "contentHash": "5zdIpj3qn+hQKvpkJO77wW74S4w8UKE0l8oDIm6jq70X7O0iNfgD+0FzEKXIYM17JtqnVSVaBd4ZyuDxHqVnLg==", 10 | "dependencies": { 11 | "Endjin.RecommendedPractices": "2.1.18", 12 | "Microsoft.SourceLink.GitHub": "1.1.1" 13 | } 14 | }, 15 | "Microsoft.Extensions.Configuration": { 16 | "type": "Direct", 17 | "requested": "[9.0.3, )", 18 | "resolved": "9.0.3", 19 | "contentHash": "RIEeZxWYm77+OWLwgik7DzSVSONjqkmcbuCb1koZdGAV7BgOUWnLz80VMyHZMw3onrVwFCCMHBBdruBPuQTvkg==", 20 | "dependencies": { 21 | "Microsoft.Extensions.Configuration.Abstractions": "9.0.3", 22 | "Microsoft.Extensions.Primitives": "9.0.3" 23 | } 24 | }, 25 | "Microsoft.Extensions.Configuration.Binder": { 26 | "type": "Direct", 27 | "requested": "[9.0.3, )", 28 | "resolved": "9.0.3", 29 | "contentHash": "ad82pYBUSQbd3WIboxsS1HzFdRuHKRa2CpYwie/o6dZAxUjt62yFwjoVdM7Iw2VO5fHV1rJwa7jJZBNZin0E7Q==", 30 | "dependencies": { 31 | "Microsoft.Extensions.Configuration.Abstractions": "9.0.3" 32 | } 33 | }, 34 | "Microsoft.Extensions.Configuration.EnvironmentVariables": { 35 | "type": "Direct", 36 | "requested": "[9.0.3, )", 37 | "resolved": "9.0.3", 38 | "contentHash": "fo84UIa8aSBG3pOtzLsgkj1YkOVfYFy2YWcRTCevHHAkuVsxnYnKBrcW2pyFgqqfQ/rT8K1nmRXHDdQIZ8PDig==", 39 | "dependencies": { 40 | "Microsoft.Extensions.Configuration": "9.0.3", 41 | "Microsoft.Extensions.Configuration.Abstractions": "9.0.3" 42 | } 43 | }, 44 | "Microsoft.Extensions.Configuration.FileExtensions": { 45 | "type": "Direct", 46 | "requested": "[9.0.3, )", 47 | "resolved": "9.0.3", 48 | "contentHash": "tBNMSDJ2q7WQK2zwPhHY5I/q95t7sf6dT079mGrNm0yOZF/gM9JvR/LtCb/rwhRmh7A6XMnzv5WbpCh9KLq9EQ==", 49 | "dependencies": { 50 | "Microsoft.Extensions.Configuration": "9.0.3", 51 | "Microsoft.Extensions.Configuration.Abstractions": "9.0.3", 52 | "Microsoft.Extensions.FileProviders.Abstractions": "9.0.3", 53 | "Microsoft.Extensions.FileProviders.Physical": "9.0.3", 54 | "Microsoft.Extensions.Primitives": "9.0.3" 55 | } 56 | }, 57 | "Microsoft.Extensions.Configuration.Json": { 58 | "type": "Direct", 59 | "requested": "[9.0.3, )", 60 | "resolved": "9.0.3", 61 | "contentHash": "mjkp3ZwynNacZk4uq93I0DyCY48FZmi3yRV0xlfeDuWh44KcDunPXHwt8IWr4kL7cVM6eiFVe6YTJg97KzUAUA==", 62 | "dependencies": { 63 | "Microsoft.Extensions.Configuration": "9.0.3", 64 | "Microsoft.Extensions.Configuration.Abstractions": "9.0.3", 65 | "Microsoft.Extensions.Configuration.FileExtensions": "9.0.3", 66 | "Microsoft.Extensions.FileProviders.Abstractions": "9.0.3", 67 | "System.Text.Json": "9.0.3" 68 | } 69 | }, 70 | "Microsoft.VisualStudio.Azure.Containers.Tools.Targets": { 71 | "type": "Direct", 72 | "requested": "[1.21.2, )", 73 | "resolved": "1.21.2", 74 | "contentHash": "kN58RveGig9YjWAoYI3flDWC/jWCU0Xzzmp3f49fbnPwZLsVJu9qMt+VSrIz7I3Gn6jkeY1l7cVJopiRDOq3CQ==" 75 | }, 76 | "Roslynator.Analyzers": { 77 | "type": "Direct", 78 | "requested": "[4.13.1, )", 79 | "resolved": "4.13.1", 80 | "contentHash": "KZpLy6ZlCebMk+d/3I5KU2R7AOb4LNJ6tPJqPtvFXmO8bEBHQvCIAvJOnY2tu4C9/aVOROTDYUFADxFqw1gh/g==" 81 | }, 82 | "StyleCop.Analyzers": { 83 | "type": "Direct", 84 | "requested": "[1.2.0-beta.556, )", 85 | "resolved": "1.2.0-beta.556", 86 | "contentHash": "llRPgmA1fhC0I0QyFLEcjvtM2239QzKr/tcnbsjArLMJxJlu0AA5G7Fft0OI30pHF3MW63Gf4aSSsjc5m82J1Q==", 87 | "dependencies": { 88 | "StyleCop.Analyzers.Unstable": "1.2.0.556" 89 | } 90 | }, 91 | "System.Threading.Tasks.Dataflow": { 92 | "type": "Direct", 93 | "requested": "[9.0.3, )", 94 | "resolved": "9.0.3", 95 | "contentHash": "m/1HEjn2ipNAhZIVEaKbfkQZTInHJ5oG92Jiixs3ZOsiuF71QDPwTtem/YjBkRrXeOEqXb2itd06rTyKGY98tg==" 96 | }, 97 | "Ais.Net": { 98 | "type": "Transitive", 99 | "resolved": "0.4.2", 100 | "contentHash": "+wtx8g0YwxEaDbilf4MiED9S4Yxr9J7RHAAMEN48+yB0it10KYdhmtB6dxt7qdCOvdoDnBOumggcGYefWMz+WA==", 101 | "dependencies": { 102 | "System.IO.Pipelines": "4.7.4" 103 | } 104 | }, 105 | "Ais.Net.Models": { 106 | "type": "Transitive", 107 | "resolved": "0.3.1", 108 | "contentHash": "HcqGkzYW9BVZ4s9Jny87cH1c4fDkt3FpYtklH/T+vNFnncJmLDFNbAIOqFnO+JqvygBP6qDWo6WyEylAIFrgwQ==", 109 | "dependencies": { 110 | "Ais.Net": "0.4.2" 111 | } 112 | }, 113 | "Azure.Core": { 114 | "type": "Transitive", 115 | "resolved": "1.44.1", 116 | "contentHash": "YyznXLQZCregzHvioip07/BkzjuWNXogJEVz9T5W6TwjNr17ax41YGzYMptlo2G10oLCuVPoyva62y0SIRDixg==", 117 | "dependencies": { 118 | "Microsoft.Bcl.AsyncInterfaces": "6.0.0", 119 | "System.ClientModel": "1.1.0", 120 | "System.Diagnostics.DiagnosticSource": "6.0.1", 121 | "System.Memory.Data": "6.0.0", 122 | "System.Numerics.Vectors": "4.5.0", 123 | "System.Text.Encodings.Web": "6.0.0", 124 | "System.Text.Json": "6.0.10", 125 | "System.Threading.Tasks.Extensions": "4.5.4" 126 | } 127 | }, 128 | "Azure.Storage.Blobs": { 129 | "type": "Transitive", 130 | "resolved": "12.24.0", 131 | "contentHash": "0SWiMtEYcemn5U69BqVPdqGDwcbl+lsF9L3WFPpqk1Db5g+ytr3L3GmUxMbvvdPNuFwTf03kKtWJpW/qW33T8A==", 132 | "dependencies": { 133 | "Azure.Storage.Common": "12.23.0" 134 | } 135 | }, 136 | "Azure.Storage.Common": { 137 | "type": "Transitive", 138 | "resolved": "12.23.0", 139 | "contentHash": "X/pe1LS3lC6s6MSL7A6FzRfnB6P72rNBt5oSuyan6Q4Jxr+KiN9Ufwqo32YLHOVfPcB8ESZZ4rBDketn+J37Rw==", 140 | "dependencies": { 141 | "Azure.Core": "1.44.1", 142 | "System.IO.Hashing": "6.0.0" 143 | } 144 | }, 145 | "Corvus.Retry": { 146 | "type": "Transitive", 147 | "resolved": "1.0.7", 148 | "contentHash": "CMAb5YGTXzUeNS+9aumQCGUH6MzymbvlsljHAamxNwffmEly8S50UoqdMi6lHlQlkl2bc+xrvIbGnhJnnF6Puw==" 149 | }, 150 | "Endjin.RecommendedPractices": { 151 | "type": "Transitive", 152 | "resolved": "2.1.18", 153 | "contentHash": "AAD5aVVKTdFYsMpdHft4Q4rPdLaBt/IG4K2ozmB0qkotXpIWBhNUDtguBZCkYvTt0o2UXS5fQDP3os86F03lpw==", 154 | "dependencies": { 155 | "Microsoft.Build.Tasks.Git": "1.1.1" 156 | } 157 | }, 158 | "Microsoft.Bcl.AsyncInterfaces": { 159 | "type": "Transitive", 160 | "resolved": "6.0.0", 161 | "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" 162 | }, 163 | "Microsoft.Build.Tasks.Git": { 164 | "type": "Transitive", 165 | "resolved": "1.1.1", 166 | "contentHash": "AT3HlgTjsqHnWpBHSNeR0KxbLZD7bztlZVj7I8vgeYG9SYqbeFGh0TM/KVtC6fg53nrWHl3VfZFvb5BiQFcY6Q==" 167 | }, 168 | "Microsoft.Extensions.Configuration.Abstractions": { 169 | "type": "Transitive", 170 | "resolved": "9.0.3", 171 | "contentHash": "q5qlbm6GRUrle2ZZxy9aqS/wWoc+mRD3JeP6rcpiJTh5XcemYkplAcJKq8lU11ZfPom5lfbZZfnQvDqcUhqD5Q==", 172 | "dependencies": { 173 | "Microsoft.Extensions.Primitives": "9.0.3" 174 | } 175 | }, 176 | "Microsoft.Extensions.FileProviders.Abstractions": { 177 | "type": "Transitive", 178 | "resolved": "9.0.3", 179 | "contentHash": "umczZ3+QPpzlrW/lkvy+IB0p52+qZ5w++aqx2lTCMOaPKzwcbVdrJgiQ3ajw5QWBp7gChLUiCYkSlWUpfjv24g==", 180 | "dependencies": { 181 | "Microsoft.Extensions.Primitives": "9.0.3" 182 | } 183 | }, 184 | "Microsoft.Extensions.FileProviders.Physical": { 185 | "type": "Transitive", 186 | "resolved": "9.0.3", 187 | "contentHash": "th2+tQBV5oWjgKhip9GjiIv2AEK3QvfAO3tZcqV3F3dEt5D6Gb411RntCj1+8GS9HaRRSxjSGx/fCrMqIjkb1Q==", 188 | "dependencies": { 189 | "Microsoft.Extensions.FileProviders.Abstractions": "9.0.3", 190 | "Microsoft.Extensions.FileSystemGlobbing": "9.0.3", 191 | "Microsoft.Extensions.Primitives": "9.0.3" 192 | } 193 | }, 194 | "Microsoft.Extensions.FileSystemGlobbing": { 195 | "type": "Transitive", 196 | "resolved": "9.0.3", 197 | "contentHash": "Rec77KHk4iNpFznHi5/6wF3MlUDcKqg26t8gRYbUm1PSukZ4B6mrXpZsJSNOiwyhhQVkjYbaoZxi5XJgRQ5lFg==" 198 | }, 199 | "Microsoft.Extensions.Primitives": { 200 | "type": "Transitive", 201 | "resolved": "9.0.3", 202 | "contentHash": "yCCJHvBcRyqapMSNzP+kTc57Eaavq2cr5Tmuil6/XVnipQf5xmskxakSQ1enU6S4+fNg3sJ27WcInV64q24JsA==" 203 | }, 204 | "Microsoft.SourceLink.Common": { 205 | "type": "Transitive", 206 | "resolved": "1.1.1", 207 | "contentHash": "WMcGpWKrmJmzrNeuaEb23bEMnbtR/vLmvZtkAP5qWu7vQsY59GqfRJd65sFpBszbd2k/bQ8cs8eWawQKAabkVg==" 208 | }, 209 | "Microsoft.SourceLink.GitHub": { 210 | "type": "Transitive", 211 | "resolved": "1.1.1", 212 | "contentHash": "IaJGnOv/M7UQjRJks7B6p7pbPnOwisYGOIzqCz5ilGFTApZ3ktOR+6zJ12ZRPInulBmdAf1SrGdDG2MU8g6XTw==", 213 | "dependencies": { 214 | "Microsoft.Build.Tasks.Git": "1.1.1", 215 | "Microsoft.SourceLink.Common": "1.1.1" 216 | } 217 | }, 218 | "StyleCop.Analyzers.Unstable": { 219 | "type": "Transitive", 220 | "resolved": "1.2.0.556", 221 | "contentHash": "zvn9Mqs/ox/83cpYPignI8hJEM2A93s2HkHs8HYMOAQW0PkampyoErAiIyKxgTLqbbad29HX/shv/6LGSjPJNQ==" 222 | }, 223 | "System.ClientModel": { 224 | "type": "Transitive", 225 | "resolved": "1.1.0", 226 | "contentHash": "UocOlCkxLZrG2CKMAAImPcldJTxeesHnHGHwhJ0pNlZEvEXcWKuQvVOER2/NiOkJGRJk978SNdw3j6/7O9H1lg==", 227 | "dependencies": { 228 | "System.Memory.Data": "1.0.2", 229 | "System.Text.Json": "6.0.9" 230 | } 231 | }, 232 | "System.Diagnostics.DiagnosticSource": { 233 | "type": "Transitive", 234 | "resolved": "6.0.1", 235 | "contentHash": "KiLYDu2k2J82Q9BJpWiuQqCkFjRBWVq4jDzKKWawVi9KWzyD0XG3cmfX0vqTQlL14Wi9EufJrbL0+KCLTbqWiQ==", 236 | "dependencies": { 237 | "System.Runtime.CompilerServices.Unsafe": "6.0.0" 238 | } 239 | }, 240 | "System.IO.Hashing": { 241 | "type": "Transitive", 242 | "resolved": "6.0.0", 243 | "contentHash": "Rfm2jYCaUeGysFEZjDe7j1R4x6Z6BzumS/vUT5a1AA/AWJuGX71PoGB0RmpyX3VmrGqVnAwtfMn39OHR8Y/5+g==" 244 | }, 245 | "System.IO.Pipelines": { 246 | "type": "Transitive", 247 | "resolved": "9.0.3", 248 | "contentHash": "aP1Qh9llcEmo0qN+VKvVDHFMe5Cqpfb1VjhBO7rjmxCXtLs3IfVSOiNqqLBZ/4Qbcr4J0SDdJq9S7EKAGpnwEA==" 249 | }, 250 | "System.Linq.Async": { 251 | "type": "Transitive", 252 | "resolved": "6.0.1", 253 | "contentHash": "0YhHcaroWpQ9UCot3Pizah7ryAzQhNvobLMSxeDIGmnXfkQn8u5owvpOH0K6EVB+z9L7u6Cc4W17Br/+jyttEQ==", 254 | "dependencies": { 255 | "Microsoft.Bcl.AsyncInterfaces": "6.0.0" 256 | } 257 | }, 258 | "System.Memory.Data": { 259 | "type": "Transitive", 260 | "resolved": "6.0.0", 261 | "contentHash": "ntFHArH3I4Lpjf5m4DCXQHJuGwWPNVJPaAvM95Jy/u+2Yzt2ryiyIN04LAogkjP9DeRcEOiviAjQotfmPq/FrQ==", 262 | "dependencies": { 263 | "System.Text.Json": "6.0.0" 264 | } 265 | }, 266 | "System.Numerics.Vectors": { 267 | "type": "Transitive", 268 | "resolved": "4.5.0", 269 | "contentHash": "QQTlPTl06J/iiDbJCiepZ4H//BVraReU4O4EoRw1U02H5TLUIT7xn3GnDp9AXPSlJUDyFs4uWjWafNX6WrAojQ==" 270 | }, 271 | "System.Reactive": { 272 | "type": "Transitive", 273 | "resolved": "6.0.1", 274 | "contentHash": "rHaWtKDwCi9qJ3ObKo8LHPMuuwv33YbmQi7TcUK1C264V3MFnOr5Im7QgCTdLniztP3GJyeiSg5x8NqYJFqRmg==" 275 | }, 276 | "System.Runtime.CompilerServices.Unsafe": { 277 | "type": "Transitive", 278 | "resolved": "6.0.0", 279 | "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" 280 | }, 281 | "System.Text.Encodings.Web": { 282 | "type": "Transitive", 283 | "resolved": "9.0.3", 284 | "contentHash": "5L+iI4fBMtGwt4FHLQh40/rgdbxnw6lHaLkR3gbaHG97TohzUv+z/oP03drsTR1lKCLhOkp40cFnHYOQLtpT5A==" 285 | }, 286 | "System.Text.Json": { 287 | "type": "Transitive", 288 | "resolved": "9.0.3", 289 | "contentHash": "r2JRkLjsYrq5Dpo7+y3Wa73OfirZPdVhxiTJWwZ+oJM7FOAe0LkM3GlH+pgkNRdd1G1kwUbmRCdmh4uoaWwu1g==", 290 | "dependencies": { 291 | "System.IO.Pipelines": "9.0.3", 292 | "System.Text.Encodings.Web": "9.0.3" 293 | } 294 | }, 295 | "System.Threading.Tasks.Extensions": { 296 | "type": "Transitive", 297 | "resolved": "4.5.4", 298 | "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==" 299 | }, 300 | "ais.net.receiver": { 301 | "type": "Project", 302 | "dependencies": { 303 | "Ais.Net.Models": "[0.3.1, )", 304 | "Corvus.Retry": "[1.0.7, )", 305 | "System.Linq.Async": "[6.0.*, )", 306 | "System.Reactive": "[6.0.1, )" 307 | } 308 | }, 309 | "ais.net.receiver.storage.azure.blob": { 310 | "type": "Project", 311 | "dependencies": { 312 | "Ais.Net.Receiver": "[1.0.0, )", 313 | "Azure.Storage.Blobs": "[12.24.0, )" 314 | } 315 | } 316 | } 317 | } 318 | } -------------------------------------------------------------------------------- /Solutions/Ais.Net.Receiver.Host.Console/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Ais": { 3 | "host": "153.44.253.27", 4 | "port": "5631", 5 | "loggerVerbosity": "Minimal", 6 | "statisticsPeriodicity": "00:00:01:00", 7 | "retryAttempts": 5, 8 | "retryPeriodicity": "00:00:00:01" 9 | }, 10 | "Storage": { 11 | "enableCapture": true, 12 | "connectionString": "", 13 | "containerName": "nmea-ais-dev", 14 | "writeBatchSize": 500 15 | } 16 | } -------------------------------------------------------------------------------- /Solutions/Ais.Net.Receiver.Storage.Azure.Blob/Ais.Net.Receiver.Storage.Azure.Blob.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | net8.0 7 | latest 8 | enable 9 | True 10 | 11 | 12 | 13 | Apache-2.0 14 | An Azure Blob based IStorageClient implmentation for persisting raw NMEA AIS sentences. Sponsored by endjin. 15 | ais;aisvdm;aivdo;nmea;marine;gis;iot;aiforearth;microsoft;azure;blob;storage;endjin 16 | 17 | 18 | 19 | 20 | RCS1029;SA1313;SA1009;SA1600;SA1591;CS1591; 21 | 22 | 23 | 24 | 25 | 26 | all 27 | runtime; build; native; contentfiles; analyzers; buildtransitive 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /Solutions/Ais.Net.Receiver.Storage.Azure.Blob/Ais/Net/Receiver/Storage/Azure/Blob/AzureAppendBlobStorageClient.cs: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Endjin Limited. All rights reserved. 3 | // 4 | 5 | using System; 6 | using System.Collections.Generic; 7 | using System.IO; 8 | using System.Linq; 9 | using System.Text; 10 | using System.Threading.Tasks; 11 | 12 | using Ais.Net.Receiver.Storage.Azure.Blob.Configuration; 13 | 14 | using global::Azure.Storage.Blobs; 15 | using global::Azure.Storage.Blobs.Specialized; 16 | 17 | namespace Ais.Net.Receiver.Storage.Azure.Blob; 18 | 19 | public class AzureAppendBlobStorageClient : IStorageClient 20 | { 21 | private readonly StorageConfig configuration; 22 | private AppendBlobClient? appendBlobClient; 23 | private BlobContainerClient? blobContainerClient; 24 | 25 | public AzureAppendBlobStorageClient(StorageConfig configuration) 26 | { 27 | this.configuration = configuration; 28 | } 29 | 30 | public async Task PersistAsync(IEnumerable messages) 31 | { 32 | await this.InitialiseContainerAsync().ConfigureAwait(false); 33 | await using MemoryStream stream = new (Encoding.UTF8.GetBytes(messages.Aggregate(new StringBuilder(), (sb, a) => sb.AppendLine(string.Join(",", a)), sb => sb.ToString()))); 34 | await this.appendBlobClient!.AppendBlockAsync(stream).ConfigureAwait(false); 35 | } 36 | 37 | private async Task InitialiseContainerAsync() 38 | { 39 | DateTimeOffset timestamp = DateTimeOffset.UtcNow; 40 | 41 | try 42 | { 43 | this.blobContainerClient = new BlobContainerClient( 44 | this.configuration.ConnectionString, 45 | this.configuration.ContainerName); 46 | 47 | this.appendBlobClient = new AppendBlobClient( 48 | this.configuration.ConnectionString, 49 | this.configuration.ContainerName, 50 | $"raw/{timestamp:yyyy}/{timestamp:MM}/{timestamp:dd}/{timestamp:yyyyMMddTHH}.nm4"); 51 | } 52 | catch (Exception e) 53 | { 54 | Console.WriteLine(e); 55 | throw; 56 | } 57 | 58 | try 59 | { 60 | await this.blobContainerClient.CreateIfNotExistsAsync().ConfigureAwait(false); 61 | await this.appendBlobClient.CreateIfNotExistsAsync().ConfigureAwait(false); 62 | } 63 | catch (Exception e) 64 | { 65 | Console.WriteLine(e); 66 | throw; 67 | } 68 | } 69 | } -------------------------------------------------------------------------------- /Solutions/Ais.Net.Receiver.Storage.Azure.Blob/Ais/Net/Receiver/Storage/Azure/Blob/Configuration/StorageConfig.cs: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Endjin Limited. All rights reserved. 3 | // 4 | 5 | // Configuration binding types are typically better off as null-oblivious, because the contents 6 | // of config files are outside the compiler's control. 7 | #nullable disable annotations 8 | 9 | namespace Ais.Net.Receiver.Storage.Azure.Blob.Configuration; 10 | 11 | public class StorageConfig 12 | { 13 | public string ConnectionString { get; set; } 14 | 15 | public string ContainerName { get; set; } 16 | 17 | public bool EnableCapture { get; set; } 18 | 19 | public int WriteBatchSize { get; set; } 20 | } -------------------------------------------------------------------------------- /Solutions/Ais.Net.Receiver.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.0.32126.317 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ais.Net.Receiver", "Ais.Net.Receiver\Ais.Net.Receiver.csproj", "{39A469DF-E331-4A68-ACA3-5E7ECBE2B1FB}" 7 | EndProject 8 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{6C0840FD-F495-41BA-9D68-2B691313D2DC}" 9 | ProjectSection(SolutionItems) = preProject 10 | .editorconfig = .editorconfig 11 | ..\GitVersion.yml = ..\GitVersion.yml 12 | EndProjectSection 13 | EndProject 14 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ais.Net.Receiver.Storage.Azure.Blob", "Ais.Net.Receiver.Storage.Azure.Blob\Ais.Net.Receiver.Storage.Azure.Blob.csproj", "{C6485EBE-8939-4918-AA28-703E024E1B4C}" 15 | EndProject 16 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ais.Net.Receiver.Host.Console", "Ais.Net.Receiver.Host.Console\Ais.Net.Receiver.Host.Console.csproj", "{F7297F19-858C-4663-BA47-D001876F9868}" 17 | EndProject 18 | Project("{E53339B2-1760-4266-BCC7-CA923CBCF16C}") = "docker-compose", "docker-compose.dcproj", "{81DDED9D-158B-E303-5F62-77A2896D2A5A}" 19 | EndProject 20 | Global 21 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 22 | Debug|Any CPU = Debug|Any CPU 23 | Release|Any CPU = Release|Any CPU 24 | EndGlobalSection 25 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 26 | {39A469DF-E331-4A68-ACA3-5E7ECBE2B1FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 27 | {39A469DF-E331-4A68-ACA3-5E7ECBE2B1FB}.Debug|Any CPU.Build.0 = Debug|Any CPU 28 | {39A469DF-E331-4A68-ACA3-5E7ECBE2B1FB}.Release|Any CPU.ActiveCfg = Release|Any CPU 29 | {39A469DF-E331-4A68-ACA3-5E7ECBE2B1FB}.Release|Any CPU.Build.0 = Release|Any CPU 30 | {C6485EBE-8939-4918-AA28-703E024E1B4C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 31 | {C6485EBE-8939-4918-AA28-703E024E1B4C}.Debug|Any CPU.Build.0 = Debug|Any CPU 32 | {C6485EBE-8939-4918-AA28-703E024E1B4C}.Release|Any CPU.ActiveCfg = Release|Any CPU 33 | {C6485EBE-8939-4918-AA28-703E024E1B4C}.Release|Any CPU.Build.0 = Release|Any CPU 34 | {F7297F19-858C-4663-BA47-D001876F9868}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 35 | {F7297F19-858C-4663-BA47-D001876F9868}.Debug|Any CPU.Build.0 = Debug|Any CPU 36 | {F7297F19-858C-4663-BA47-D001876F9868}.Release|Any CPU.ActiveCfg = Release|Any CPU 37 | {F7297F19-858C-4663-BA47-D001876F9868}.Release|Any CPU.Build.0 = Release|Any CPU 38 | {81DDED9D-158B-E303-5F62-77A2896D2A5A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 39 | {81DDED9D-158B-E303-5F62-77A2896D2A5A}.Debug|Any CPU.Build.0 = Debug|Any CPU 40 | {81DDED9D-158B-E303-5F62-77A2896D2A5A}.Release|Any CPU.ActiveCfg = Release|Any CPU 41 | {81DDED9D-158B-E303-5F62-77A2896D2A5A}.Release|Any CPU.Build.0 = Release|Any CPU 42 | EndGlobalSection 43 | GlobalSection(SolutionProperties) = preSolution 44 | HideSolutionNode = FALSE 45 | EndGlobalSection 46 | GlobalSection(ExtensibilityGlobals) = postSolution 47 | SolutionGuid = {B807F890-1DEB-4C00-A4FC-67EC55DE21F5} 48 | EndGlobalSection 49 | EndGlobal 50 | -------------------------------------------------------------------------------- /Solutions/Ais.Net.Receiver.sln.DotSettings: -------------------------------------------------------------------------------- 1 |  2 | True -------------------------------------------------------------------------------- /Solutions/Ais.Net.Receiver/Ais.Net.Receiver.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | net8.0 7 | latest 8 | enable 9 | True 10 | 11 | 12 | 13 | Apache-2.0 14 | Recieves Network streams of NMEA sentences and can decode AIS message types 1,2,3,5,18,19,24 Part 0, and 24 Part 1 into an IObservable of IAisMessage (See Ais.Net.Models). Sponsored by endjin. 15 | ais;aisvdm;aivdo;nmea;marine;gis;iot;aiforearth;endjin 16 | 17 | 18 | 19 | 20 | RCS1029;SA1313;SA1009;SA1600;SA1591;CS1591; 21 | 22 | 23 | 24 | 25 | 26 | 27 | all 28 | runtime; build; native; contentfiles; analyzers; buildtransitive 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /Solutions/Ais.Net.Receiver/Ais/Net/Receiver/Configuration/AisConfig.cs: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Endjin Limited. All rights reserved. 3 | // 4 | 5 | // Configuration binding types are typically better off as null-oblivious, because the contents 6 | // of config files are outside the compiler's control. 7 | #nullable disable annotations 8 | 9 | using System; 10 | 11 | namespace Ais.Net.Receiver.Configuration; 12 | 13 | public class AisConfig 14 | { 15 | public string Host { get; set; } 16 | 17 | public LoggerVerbosity LoggerVerbosity { get; set; } 18 | 19 | public TimeSpan StatisticsPeriodicity { get; set; } 20 | 21 | public int Port { get; set; } 22 | 23 | public int RetryAttempts { get; set; } 24 | 25 | public TimeSpan RetryPeriodicity { get; set; } 26 | } -------------------------------------------------------------------------------- /Solutions/Ais.Net.Receiver/Ais/Net/Receiver/Configuration/LoggerVerbosity.cs: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Endjin Limited. All rights reserved. 3 | // 4 | 5 | namespace Ais.Net.Receiver.Configuration; 6 | 7 | /// 8 | /// Defines the verbosity of the console output. 9 | /// 10 | public enum LoggerVerbosity 11 | { 12 | /// 13 | /// Essential only. 14 | /// 15 | Quiet = 0, 16 | 17 | /// 18 | /// Statistics only. 19 | /// 20 | Minimal = 1, 21 | 22 | /// 23 | /// Vessel Names and Positions. 24 | /// 25 | Normal = 2, 26 | 27 | /// 28 | /// NMEA Sentences. 29 | /// 30 | Detailed = 3, 31 | 32 | /// 33 | /// Messages and Errors. 34 | /// 35 | Diagnostic = 4 36 | } -------------------------------------------------------------------------------- /Solutions/Ais.Net.Receiver/Ais/Net/Receiver/Parser/NmeaMessageExtensions.cs: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Endjin Limited. All rights reserved. 3 | // 4 | 5 | using System; 6 | using System.Linq; 7 | 8 | namespace Ais.Net.Receiver.Parser; 9 | 10 | public static class NmeaMessageExtensions 11 | { 12 | public static bool IsMissingNmeaBlockTags(this string message) 13 | { 14 | return message.AsSpan()[0] == '!'; 15 | } 16 | 17 | public static string PrependNmeaBlockTags(this string message) 18 | { 19 | string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); 20 | 21 | // Some messages are missing NMEA Block Tags - see https://gpsd.gitlab.io/gpsd/AIVDM.html#_nmea_tag_blocks 22 | // s: = source stations - in our case AIS.Net.Receiver = 1000001 23 | // c: = UNIX time in seconds or milliseconds + checksum 24 | return $@"\s:1000001,c:{timestamp}*{NmeaChecksum("c:" + timestamp)}\{message}"; 25 | } 26 | 27 | private static string NmeaChecksum(string s) => s.Aggregate(0, (t, c) => t ^ c).ToString("X2"); 28 | } -------------------------------------------------------------------------------- /Solutions/Ais.Net.Receiver/Ais/Net/Receiver/Parser/NmeaToAisMessageTypeProcessor.cs: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Endjin Limited. All rights reserved. 3 | // 4 | 5 | using System; 6 | using System.Reactive.Subjects; 7 | 8 | using Ais.Net.Models; 9 | using Ais.Net.Models.Abstractions; 10 | 11 | namespace Ais.Net.Receiver.Parser; 12 | 13 | /// 14 | /// Receives AIS messages parsed from an NMEA sentence and converts it into an 15 | /// stream of based types. 16 | /// 17 | public class NmeaToAisMessageTypeProcessor : INmeaAisMessageStreamProcessor 18 | { 19 | private readonly Subject messages = new(); 20 | 21 | public IObservable Messages => this.messages; 22 | 23 | public void OnNext(in NmeaLineParser parsedLine, in ReadOnlySpan asciiPayload, uint padding) 24 | { 25 | int messageType = NmeaPayloadParser.PeekMessageType(asciiPayload, padding); 26 | 27 | try 28 | { 29 | switch (messageType) 30 | { 31 | case >= 1 and <= 3: 32 | { 33 | this.ParseMessageTypes1Through3(asciiPayload, padding, messageType); 34 | return; 35 | } 36 | 37 | case 5: 38 | { 39 | this.ParseMessageType5(asciiPayload, padding); 40 | return; 41 | } 42 | 43 | case 18: 44 | { 45 | this.ParseMessageType18(asciiPayload, padding); 46 | return; 47 | } 48 | 49 | case 19: 50 | { 51 | this.ParseMessageType19(asciiPayload, padding); 52 | return; 53 | } 54 | 55 | case 24: 56 | { 57 | this.ParseMessageType24(asciiPayload, padding); 58 | return; 59 | } 60 | 61 | case 27: 62 | { 63 | this.ParseMessageType27(asciiPayload, padding); 64 | return; 65 | } 66 | } 67 | } 68 | catch (Exception e) 69 | { 70 | Console.WriteLine($"[{messageType}] {e.Message}"); 71 | } 72 | } 73 | 74 | public void OnError(in ReadOnlySpan line, Exception error, int lineNumber) 75 | { 76 | throw new NotImplementedException(); 77 | } 78 | 79 | public void OnCompleted() 80 | { 81 | throw new NotImplementedException(); 82 | } 83 | 84 | public void Progress( 85 | bool done, 86 | int totalNmeaLines, 87 | int totalAisMessages, 88 | int totalTicks, 89 | int nmeaLinesSinceLastUpdate, 90 | int aisMessagesSinceLastUpdate, 91 | int ticksSinceLastUpdate) 92 | { 93 | throw new NotImplementedException(); 94 | } 95 | 96 | private void ParseMessageTypes1Through3(ReadOnlySpan asciiPayload, uint padding, int messageType) 97 | { 98 | NmeaAisPositionReportClassAParser parser = new(asciiPayload, padding); 99 | 100 | AisMessageType1Through3 message = new( 101 | CourseOverGround: parser.CourseOverGround10thDegrees.FromTenthsToDegrees(), 102 | ManoeuvreIndicator: parser.ManoeuvreIndicator, 103 | MessageType: messageType, 104 | Mmsi: parser.Mmsi, 105 | NavigationStatus: parser.NavigationStatus, 106 | Position: Position.From10000thMins(parser.Latitude10000thMins, parser.Longitude10000thMins), 107 | PositionAccuracy: parser.PositionAccuracy, 108 | RadioSlotTimeout: parser.RadioSlotTimeout, 109 | RadioSubMessage: parser.RadioSubMessage, 110 | RadioSyncState: parser.RadioSyncState, 111 | RaimFlag: parser.RaimFlag, 112 | RateOfTurn: parser.RateOfTurn, 113 | RepeatIndicator: parser.RepeatIndicator, 114 | SpareBits145: parser.SpareBits145, 115 | SpeedOverGround: parser.SpeedOverGroundTenths.FromTenths(), 116 | TimeStampSecond: parser.TimeStampSecond, 117 | TrueHeadingDegrees: parser.TrueHeadingDegrees); 118 | 119 | this.messages.OnNext(message); 120 | } 121 | 122 | private void ParseMessageType5(ReadOnlySpan asciiPayload, uint padding) 123 | { 124 | NmeaAisStaticAndVoyageRelatedDataParser parser = new(asciiPayload, padding); 125 | 126 | AisMessageType5 message = new( 127 | AisVersion: parser.AisVersion, 128 | EtaMonth: parser.EtaMonth, 129 | EtaDay: parser.EtaDay, 130 | EtaHour: parser.EtaHour, 131 | EtaMinute: parser.EtaMinute, 132 | Mmsi: parser.Mmsi, 133 | IsDteNotReady: parser.IsDteNotReady, 134 | ImoNumber: parser.ImoNumber, 135 | CallSign: parser.CallSign.TextFieldToString(), 136 | Destination: parser.Destination.TextFieldToString(), 137 | VesselName: parser.VesselName.TextFieldToString(), 138 | ShipType: parser.ShipType, 139 | RepeatIndicator: parser.RepeatIndicator, 140 | DimensionToBow: parser.DimensionToBow, 141 | DimensionToPort: parser.DimensionToPort, 142 | DimensionToStarboard: parser.DimensionToStarboard, 143 | DimensionToStern: parser.DimensionToStern, 144 | Draught10thMetres: parser.Draught10thMetres, 145 | Spare423: parser.Spare423, 146 | PositionFixType: parser.PositionFixType); 147 | 148 | this.messages.OnNext(message); 149 | } 150 | 151 | private void ParseMessageType18(ReadOnlySpan asciiPayload, uint padding) 152 | { 153 | NmeaAisPositionReportClassBParser parser = new(asciiPayload, padding); 154 | 155 | AisMessageType18 message = new( 156 | Mmsi: parser.Mmsi, 157 | Position: Position.From10000thMins(parser.Latitude10000thMins, parser.Longitude10000thMins), 158 | CanAcceptMessage22ChannelAssignment: parser.CanAcceptMessage22ChannelAssignment, 159 | CanSwitchBands: parser.CanSwitchBands, 160 | CsUnit: parser.CsUnit, 161 | HasDisplay: parser.HasDisplay, 162 | IsDscAttached: parser.IsDscAttached, 163 | RadioStatusType: parser.RadioStatusType, 164 | RegionalReserved139: parser.RegionalReserved139, 165 | RegionalReserved38: parser.RegionalReserved38, 166 | CourseOverGround: parser.CourseOverGround10thDegrees.FromTenthsToDegrees(), 167 | PositionAccuracy: parser.PositionAccuracy, 168 | SpeedOverGround: parser.SpeedOverGroundTenths.FromTenths(), 169 | TimeStampSecond: parser.TimeStampSecond, 170 | TrueHeadingDegrees: parser.TrueHeadingDegrees, 171 | IsAssigned: parser.IsAssigned, 172 | RaimFlag: parser.RaimFlag, 173 | RepeatIndicator: parser.RepeatIndicator); 174 | 175 | this.messages.OnNext(message); 176 | } 177 | 178 | private void ParseMessageType19(ReadOnlySpan asciiPayload, uint padding) 179 | { 180 | NmeaAisPositionReportExtendedClassBParser parser = new(asciiPayload, padding); 181 | 182 | Span shipNameAscii = stackalloc byte[(int)parser.ShipName.CharacterCount]; 183 | parser.ShipName.WriteAsAscii(shipNameAscii); 184 | 185 | AisMessageType19 message = new( 186 | Mmsi: parser.Mmsi, 187 | ShipName: shipNameAscii.GetString(), 188 | CourseOverGround: parser.CourseOverGround10thDegrees.FromTenthsToDegrees(), 189 | DimensionToBow: parser.DimensionToBow, 190 | DimensionToPort: parser.DimensionToPort, 191 | DimensionToStarboard: parser.DimensionToStarboard, 192 | DimensionToStern: parser.DimensionToStern, 193 | IsAssigned: parser.IsAssigned, 194 | IsDteNotReady: parser.IsDteNotReady, 195 | PositionAccuracy: parser.PositionAccuracy, 196 | PositionFixType: parser.PositionFixType, 197 | RaimFlag: parser.RaimFlag, 198 | RegionalReserved139: parser.RegionalReserved139, 199 | RegionalReserved38: parser.RegionalReserved38, 200 | RepeatIndicator: parser.RepeatIndicator, 201 | ShipType: parser.ShipType, 202 | Spare308: parser.Spare308, 203 | SpeedOverGround: parser.SpeedOverGroundTenths.FromTenths(), 204 | TimeStampSecond: parser.TimeStampSecond, 205 | TrueHeadingDegrees: parser.TrueHeadingDegrees, 206 | Position: Position.From10000thMins(parser.Latitude10000thMins, parser.Longitude10000thMins)); 207 | 208 | this.messages.OnNext(message); 209 | } 210 | 211 | private void ParseMessageType24(ReadOnlySpan asciiPayload, uint padding) 212 | { 213 | uint part = NmeaAisStaticDataReportParser.GetPartNumber(asciiPayload, padding); 214 | 215 | switch (part) 216 | { 217 | case 0: 218 | { 219 | NmeaAisStaticDataReportParserPartA parser = new(asciiPayload, padding); 220 | 221 | Span vesselNameAscii = stackalloc byte[(int)parser.VesselName.CharacterCount]; 222 | parser.VesselName.WriteAsAscii(vesselNameAscii); 223 | 224 | AisMessageType24Part0 message = new( 225 | Mmsi: parser.Mmsi, 226 | PartNumber: parser.PartNumber, 227 | RepeatIndicator: parser.RepeatIndicator, 228 | Spare160: parser.Spare160); 229 | 230 | this.messages.OnNext(message); 231 | 232 | break; 233 | } 234 | 235 | case 1: 236 | { 237 | NmeaAisStaticDataReportParserPartB parser = new(asciiPayload, padding); 238 | 239 | Span callSignAscii = stackalloc byte[(int)parser.CallSign.CharacterCount]; 240 | parser.CallSign.WriteAsAscii(callSignAscii); 241 | 242 | Span vendorIdRev3Ascii = stackalloc byte[(int)parser.VendorIdRev3.CharacterCount]; 243 | parser.VendorIdRev3.WriteAsAscii(vendorIdRev3Ascii); 244 | 245 | Span vendorIdRev4Ascii = stackalloc byte[(int)parser.VendorIdRev4.CharacterCount]; 246 | parser.VendorIdRev3.WriteAsAscii(vendorIdRev4Ascii); 247 | 248 | AisMessageType24Part1 message = new( 249 | Mmsi: parser.Mmsi, 250 | CallSign: callSignAscii.GetString(), 251 | DimensionToBow: parser.DimensionToBow, 252 | DimensionToPort: parser.DimensionToPort, 253 | DimensionToStarboard: parser.DimensionToStarboard, 254 | DimensionToStern: parser.DimensionToStern, 255 | MothershipMmsi: parser.MothershipMmsi, 256 | PartNumber: parser.PartNumber, 257 | RepeatIndicator: parser.RepeatIndicator, 258 | SerialNumber: parser.SerialNumber, 259 | ShipType: parser.ShipType, 260 | Spare162: parser.Spare162, 261 | UnitModelCode: parser.UnitModelCode, 262 | VendorIdRev3: vendorIdRev3Ascii.GetString(), 263 | VendorIdRev4: vendorIdRev4Ascii.GetString()); 264 | 265 | this.messages.OnNext(message); 266 | break; 267 | } 268 | } 269 | } 270 | 271 | private void ParseMessageType27(ReadOnlySpan asciiPayload, uint padding) 272 | { 273 | NmeaAisLongRangeAisBroadcastParser parser = new(asciiPayload, padding); 274 | 275 | AisMessageType27 message = new( 276 | Mmsi: parser.Mmsi, 277 | Position: Position.From10thMins(parser.Latitude10thMins, parser.Longitude10thMins), 278 | CourseOverGround: parser.CourseOverGroundDegrees, 279 | PositionAccuracy: parser.PositionAccuracy, 280 | SpeedOverGround: parser.SpeedOverGroundTenths.FromTenths(), 281 | RaimFlag: parser.RaimFlag, 282 | RepeatIndicator: parser.RepeatIndicator, 283 | GnssPositionStatus: parser.NotGnssPosition, 284 | NavigationStatus: parser.NavigationStatus); 285 | 286 | this.messages.OnNext(message); 287 | } 288 | } -------------------------------------------------------------------------------- /Solutions/Ais.Net.Receiver/Ais/Net/Receiver/Receiver/FileStreamNmeaReceiver.cs: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Endjin Limited. All rights reserved. 3 | // 4 | 5 | using System; 6 | using System.Collections.Generic; 7 | using System.IO; 8 | using System.Runtime.CompilerServices; 9 | using System.Threading; 10 | using System.Threading.Tasks; 11 | 12 | namespace Ais.Net.Receiver.Receiver; 13 | 14 | public class FileStreamNmeaReceiver : INmeaReceiver 15 | { 16 | private readonly string path; 17 | private readonly TimeSpan delay = TimeSpan.Zero; 18 | 19 | public FileStreamNmeaReceiver(string path) 20 | { 21 | this.path = path; 22 | } 23 | 24 | public FileStreamNmeaReceiver(string path, TimeSpan delay) 25 | { 26 | this.path = path; 27 | this.delay = delay; 28 | } 29 | 30 | public async IAsyncEnumerable GetAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) 31 | { 32 | using StreamReader sr = new(this.path); 33 | 34 | while (sr.Peek() >= 0) 35 | { 36 | if (cancellationToken.IsCancellationRequested) 37 | { 38 | break; 39 | } 40 | 41 | if (this.delay > TimeSpan.Zero) 42 | { 43 | await Task.Delay(this.delay, cancellationToken).ConfigureAwait(false); 44 | } 45 | 46 | string? line = await sr.ReadLineAsync(cancellationToken).ConfigureAwait(false); 47 | 48 | if (line is not null) { yield return line; } 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /Solutions/Ais.Net.Receiver/Ais/Net/Receiver/Receiver/INmeaReceiver.cs: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Endjin Limited. All rights reserved. 3 | // 4 | 5 | using System.Collections.Generic; 6 | using System.Threading; 7 | 8 | namespace Ais.Net.Receiver.Receiver; 9 | 10 | public interface INmeaReceiver 11 | { 12 | IAsyncEnumerable GetAsync(CancellationToken cancellationToken = default); 13 | } -------------------------------------------------------------------------------- /Solutions/Ais.Net.Receiver/Ais/Net/Receiver/Receiver/INmeaStreamReader.cs: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Endjin Limited. All rights reserved. 3 | // 4 | 5 | namespace Ais.Net.Receiver.Receiver; 6 | 7 | using System; 8 | using System.Threading; 9 | using System.Threading.Tasks; 10 | 11 | /// 12 | /// Abstracts network stream reading operations for NMEA messages 13 | /// 14 | public interface INmeaStreamReader : IAsyncDisposable 15 | { 16 | /// 17 | /// Establishes a connection to the specified host and port 18 | /// 19 | Task ConnectAsync(string host, int port, CancellationToken cancellationToken); 20 | 21 | /// 22 | /// Reads a line of text asynchronously 23 | /// 24 | Task ReadLineAsync(CancellationToken cancellationToken); 25 | 26 | /// 27 | /// Gets whether data is available to be read 28 | /// 29 | bool DataAvailable { get; } 30 | 31 | /// 32 | /// Gets whether the connection is established 33 | /// 34 | bool Connected { get; } 35 | } -------------------------------------------------------------------------------- /Solutions/Ais.Net.Receiver/Ais/Net/Receiver/Receiver/NetworkStreamNmeaReceiver.cs: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Endjin Limited. All rights reserved. 3 | // 4 | 5 | using System; 6 | using System.Collections.Generic; 7 | using System.Linq; 8 | using System.Reactive.Linq; 9 | using System.Threading; 10 | using System.Threading.Tasks; 11 | 12 | namespace Ais.Net.Receiver.Receiver; 13 | 14 | public class NetworkStreamNmeaReceiver : INmeaReceiver 15 | { 16 | private readonly INmeaStreamReader nmeaStreamReader; 17 | 18 | public NetworkStreamNmeaReceiver(string host, int port, TimeSpan? retryPeriodicity, int retryAttemptLimit = 100) 19 | : this(new TcpClientNmeaStreamReader(), host, port, retryPeriodicity, retryAttemptLimit) 20 | { 21 | } 22 | 23 | public NetworkStreamNmeaReceiver(INmeaStreamReader reader, string host, int port, TimeSpan? retryPeriodicity, int retryAttemptLimit = 100) 24 | { 25 | this.Host = host; 26 | this.Port = port; 27 | this.RetryPeriodicity = retryPeriodicity ?? TimeSpan.FromSeconds(1); 28 | this.RetryAttemptLimit = retryAttemptLimit; 29 | this.nmeaStreamReader = reader ?? throw new ArgumentNullException(nameof(reader)); 30 | } 31 | 32 | public string Host { get; } 33 | 34 | public int Port { get; } 35 | 36 | public int RetryAttemptLimit { get; } 37 | 38 | public TimeSpan RetryPeriodicity { get; } 39 | 40 | // We still provide the IAsyncEnumerable API for backwards compatibility. 41 | public IAsyncEnumerable GetAsync(CancellationToken cancellationToken = default) 42 | { 43 | return this.GetObservable(cancellationToken).ToAsyncEnumerable(); 44 | } 45 | 46 | public IObservable GetObservable(CancellationToken cancellationToken = default) 47 | { 48 | IObservable withoutRetry = Observable.Create(async (obs, innerCancel) => 49 | { 50 | using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, innerCancel); 51 | CancellationToken mergedToken = cts.Token; 52 | 53 | while (!mergedToken.IsCancellationRequested) 54 | { 55 | await this.nmeaStreamReader.ConnectAsync(this.Host, this.Port, mergedToken); 56 | 57 | int retryAttempt = 0; 58 | 59 | try 60 | { 61 | while (this.nmeaStreamReader.Connected) 62 | { 63 | while (this.nmeaStreamReader.DataAvailable && !mergedToken.IsCancellationRequested) 64 | { 65 | string? line = await this.nmeaStreamReader.ReadLineAsync(mergedToken).ConfigureAwait(false); 66 | if (line is not null) 67 | { 68 | obs.OnNext(line); 69 | } 70 | retryAttempt = 0; 71 | } 72 | 73 | if (mergedToken.IsCancellationRequested || retryAttempt == this.RetryAttemptLimit) 74 | { 75 | break; 76 | } 77 | 78 | await Task.Delay(this.RetryPeriodicity, mergedToken).ConfigureAwait(false); 79 | retryAttempt++; 80 | } 81 | } 82 | finally 83 | { 84 | await this.nmeaStreamReader.DisposeAsync(); 85 | } 86 | } 87 | }); 88 | 89 | return withoutRetry.Retry(); 90 | } 91 | } -------------------------------------------------------------------------------- /Solutions/Ais.Net.Receiver/Ais/Net/Receiver/Receiver/ReceiverHost.cs: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Endjin Limited. All rights reserved. 3 | // 4 | 5 | using System; 6 | using System.Collections.Generic; 7 | using System.Reactive.Subjects; 8 | using System.Runtime.CompilerServices; 9 | using System.Text; 10 | using System.Threading; 11 | using System.Threading.Tasks; 12 | 13 | using Ais.Net.Models.Abstractions; 14 | using Ais.Net.Receiver.Parser; 15 | 16 | using Corvus.Retry; 17 | using Corvus.Retry.Policies; 18 | using Corvus.Retry.Strategies; 19 | 20 | namespace Ais.Net.Receiver.Receiver; 21 | 22 | public class ReceiverHost 23 | { 24 | private readonly INmeaReceiver receiver; 25 | private readonly Subject sentences = new(); 26 | private readonly Subject messages = new(); 27 | private readonly Subject<(Exception Exception, string Line)> errors = new(); 28 | 29 | public ReceiverHost(INmeaReceiver receiver) 30 | { 31 | this.receiver = receiver; 32 | } 33 | 34 | public IObservable Sentences => this.sentences; 35 | 36 | public IObservable Messages => this.messages; 37 | 38 | public IObservable<(Exception Exception, string Line)> Errors => this.errors; 39 | 40 | public Task StartAsync(CancellationToken cancellationToken = default) 41 | { 42 | return Retriable.RetryAsync(() => 43 | this.StartAsyncInternal(cancellationToken), 44 | cancellationToken, 45 | new Backoff(maxTries: 100, deltaBackoff: TimeSpan.FromSeconds(5)), 46 | new AnyExceptionPolicy(), 47 | continueOnCapturedContext: false); 48 | } 49 | 50 | private async Task StartAsyncInternal(CancellationToken cancellationToken = default) 51 | { 52 | NmeaToAisMessageTypeProcessor processor = new(); 53 | NmeaLineToAisStreamAdapter adapter = new(processor); 54 | 55 | processor.Messages.Subscribe(this.messages); 56 | 57 | await foreach (string? message in this.GetAsync(cancellationToken)) 58 | { 59 | static void ProcessLineNonAsync(string line, INmeaLineStreamProcessor lineStreamProcessor, Subject<(Exception Exception, string Line)> errorSubject) 60 | { 61 | byte[] lineAsAscii = Encoding.ASCII.GetBytes(line); 62 | 63 | try 64 | { 65 | lineStreamProcessor.OnNext(new NmeaLineParser(lineAsAscii), lineNumber: 0); 66 | } 67 | catch (ArgumentException ex) 68 | { 69 | if (errorSubject.HasObservers) 70 | { 71 | errorSubject.OnNext((Exception: ex, line)); 72 | } 73 | } 74 | catch (NotImplementedException ex) 75 | { 76 | if (errorSubject.HasObservers) 77 | { 78 | errorSubject.OnNext((Exception: ex, line)); 79 | } 80 | } 81 | } 82 | 83 | this.sentences.OnNext(message); 84 | 85 | if (this.messages.HasObservers) 86 | { 87 | ProcessLineNonAsync(message, adapter, this.errors); 88 | } 89 | } 90 | } 91 | 92 | private async IAsyncEnumerable GetAsync([EnumeratorCancellation]CancellationToken cancellationToken = default) 93 | { 94 | await foreach (string message in this.receiver.GetAsync(cancellationToken)) 95 | { 96 | if (message.IsMissingNmeaBlockTags()) 97 | { 98 | yield return message.PrependNmeaBlockTags(); 99 | } 100 | else 101 | { 102 | yield return message; 103 | } 104 | } 105 | } 106 | } -------------------------------------------------------------------------------- /Solutions/Ais.Net.Receiver/Ais/Net/Receiver/Receiver/TcpClientNmeaStreamReader.cs: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Endjin Limited. All rights reserved. 3 | // 4 | 5 | namespace Ais.Net.Receiver.Receiver; 6 | 7 | using System; 8 | using System.IO; 9 | using System.Net.Sockets; 10 | using System.Threading; 11 | using System.Threading.Tasks; 12 | 13 | public class TcpClientNmeaStreamReader : INmeaStreamReader 14 | { 15 | private TcpClient? tcpClient; 16 | private NetworkStream? stream; 17 | private StreamReader? reader; 18 | 19 | public bool DataAvailable => this.stream?.DataAvailable ?? false; 20 | 21 | public bool Connected => (this.tcpClient?.Connected ?? false) && (this.stream?.Socket.Connected ?? false); 22 | 23 | public async Task ConnectAsync(string host, int port, CancellationToken cancellationToken) 24 | { 25 | this.tcpClient = new TcpClient(); 26 | 27 | try 28 | { 29 | await this.tcpClient.ConnectAsync(host, port, cancellationToken); 30 | this.stream = this.tcpClient.GetStream(); 31 | this.reader = new StreamReader(this.stream); 32 | } 33 | catch (Exception) 34 | { 35 | // If connection fails, clean up resources 36 | await this.DisposeAsync(); 37 | throw; 38 | } 39 | } 40 | 41 | public async Task ReadLineAsync(CancellationToken cancellationToken) 42 | { 43 | return this.reader is not null 44 | ? await this.reader.ReadLineAsync(cancellationToken).ConfigureAwait(false) 45 | : null; 46 | } 47 | 48 | public async ValueTask DisposeAsync() 49 | { 50 | if (this.reader is not null) 51 | { 52 | try { this.reader.Dispose(); } catch { /* Ignore any errors during cleanup */ } 53 | this.reader = null; 54 | } 55 | 56 | if (this.stream is not null) 57 | { 58 | try { await this.stream.DisposeAsync(); } catch { /* Ignore any errors during cleanup */ } 59 | this.stream = null; 60 | } 61 | 62 | if (this.tcpClient is not null) 63 | { 64 | try { this.tcpClient.Dispose(); } catch { /* Ignore any errors during cleanup */ } 65 | this.tcpClient = null; 66 | } 67 | } 68 | } -------------------------------------------------------------------------------- /Solutions/Ais.Net.Receiver/Ais/Net/Receiver/Storage/IStorageClient.cs: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Endjin Limited. All rights reserved. 3 | // 4 | 5 | using System.Collections.Generic; 6 | using System.Threading.Tasks; 7 | 8 | namespace Ais.Net.Receiver.Storage; 9 | 10 | public interface IStorageClient 11 | { 12 | Task PersistAsync(IEnumerable messages); 13 | } -------------------------------------------------------------------------------- /Solutions/PackageIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ais-dotnet/Ais.Net.Receiver/bd5684713b69546e85f8b8139a6c04a75a32739d/Solutions/PackageIcon.png -------------------------------------------------------------------------------- /Solutions/docker-compose.dcproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 2.1 5 | Linux 6 | False 7 | 81dded9d-158b-e303-5f62-77a2896d2a5a 8 | 9 | 10 | 11 | docker-compose.yml 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /Solutions/docker-compose.override.yml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ais-dotnet/Ais.Net.Receiver/bd5684713b69546e85f8b8139a6c04a75a32739d/Solutions/docker-compose.override.yml -------------------------------------------------------------------------------- /Solutions/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | ais.net.receiver.host.console: 3 | image: ${DOCKER_REGISTRY-}aisnetreceiverhostconsole 4 | build: 5 | context: . 6 | dockerfile: Ais.Net.Receiver.Host.Console/Dockerfile 7 | environment: 8 | - Ais__host=153.44.253.27 9 | - Ais__port=5631 10 | - Ais__loggerVerbosity=Minimal 11 | - Ais__statisticsPeriodicity=00:00:01:00 12 | - Ais__retryAttempts=5 13 | - Ais__retryPeriodicity=00:00:00:01 14 | - Storage__enableCapture=true 15 | - Storage__connectionString=${AIS_NET_RECEIVER_AZURE_CONNECTION_STRING} 16 | - Storage__containerName=nmea-ais-dev 17 | - Storage__writeBatchSize=500 18 | -------------------------------------------------------------------------------- /Solutions/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "Docker Compose": { 4 | "commandName": "DockerCompose", 5 | "commandVersion": "1.0", 6 | "serviceActions": { 7 | "ais.net.receiver.host.console": "StartDebugging" 8 | } 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /Solutions/stylecop.json: -------------------------------------------------------------------------------- 1 | { 2 | // Note: this file was added to your solution as a result of one or more projects using the Endjin.RecommendedPractices.Build.Common NuGet package. 3 | // You can edit this file (e.g., to remove these comments), and it will not be updated - the package just checks for its presence, and copies 4 | // this file. If you don't want this file (but you want to use the NuGet package that puts it here), add this setting to all projects 5 | // using Endjin.RecommendedPractices.Build.Common: 6 | // true 7 | // and then delete this file. That setting will prevent the package from recreating this file. 8 | "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json", 9 | "settings": { 10 | "documentationRules": { 11 | "companyName": "Endjin Limited" 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /build.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .SYNOPSIS 3 | Runs a .NET flavoured build process. 4 | .DESCRIPTION 5 | This script was scaffolded using a template from the Endjin.RecommendedPractices.Build PowerShell module. 6 | It uses the InvokeBuild module to orchestrate an opinonated software build process for .NET solutions. 7 | .EXAMPLE 8 | PS C:\> ./build.ps1 9 | Downloads any missing module dependencies (Endjin.RecommendedPractices.Build & InvokeBuild) and executes 10 | the build process. 11 | .PARAMETER Tasks 12 | Optionally override the default task executed as the entry-point of the build. 13 | .PARAMETER Configuration 14 | The build configuration, defaults to 'Release'. 15 | .PARAMETER BuildRepositoryUri 16 | Optional URI that supports pulling MSBuild logic from a web endpoint (e.g. a GitHub blob). 17 | .PARAMETER SourcesDir 18 | The path where the source code to be built is located, defaults to the current working directory. 19 | .PARAMETER CoverageDir 20 | The output path for the test coverage data, if run. 21 | .PARAMETER TestReportTypes 22 | The test report format that should be generated by the test report generator, if run. 23 | .PARAMETER PackagesDir 24 | The output path for any packages produced as part of the build. 25 | .PARAMETER LogLevel 26 | The logging verbosity. 27 | .PARAMETER Clean 28 | When true, the .NET solution will be cleaned and all output/intermediate folders deleted. 29 | .PARAMETER BuildModulePath 30 | The path to import the Endjin.RecommendedPractices.Build module from. This is useful when 31 | testing pre-release versions of the Endjin.RecommendedPractices.Build that are not yet 32 | available in the PowerShell Gallery. 33 | .PARAMETER BuildModuleVersion 34 | The version of the Endjin.RecommendedPractices.Build module to import. This is useful when 35 | testing pre-release versions of the Endjin.RecommendedPractices.Build that are not yet 36 | available in the PowerShell Gallery. 37 | .PARAMETER InvokeBuildModuleVersion 38 | The version of the InvokeBuild module to be used. 39 | #> 40 | [CmdletBinding()] 41 | param ( 42 | [Parameter(Position=0)] 43 | [string[]] $Tasks = @("."), 44 | 45 | [Parameter()] 46 | [string] $Configuration = "Release", 47 | 48 | [Parameter()] 49 | [string] $BuildRepositoryUri = "", 50 | 51 | [Parameter()] 52 | [string] $SourcesDir = $PWD, 53 | 54 | [Parameter()] 55 | [string] $CoverageDir = "_codeCoverage", 56 | 57 | [Parameter()] 58 | [string] $TestReportTypes = "Cobertura", 59 | 60 | [Parameter()] 61 | [string] $PackagesDir = "_packages", 62 | 63 | [Parameter()] 64 | [ValidateSet("quiet","minimal","normal","detailed")] 65 | [string] $LogLevel = "minimal", 66 | 67 | [Parameter()] 68 | [switch] $Clean, 69 | 70 | [Parameter()] 71 | [string] $BuildModulePath, 72 | 73 | [Parameter()] 74 | [version] $BuildModuleVersion = "1.5.12", 75 | 76 | [Parameter()] 77 | [version] $InvokeBuildModuleVersion = "5.11.3" 78 | ) 79 | $ErrorActionPreference = $ErrorActionPreference ? $ErrorActionPreference : 'Stop' 80 | $InformationPreference = 'Continue' 81 | $here = Split-Path -Parent $PSCommandPath 82 | 83 | #region InvokeBuild setup 84 | if (!(Get-Module -ListAvailable InvokeBuild)) { 85 | Install-Module InvokeBuild -RequiredVersion $InvokeBuildModuleVersion -Scope CurrentUser -Force -Repository PSGallery 86 | } 87 | Import-Module InvokeBuild 88 | # This handles calling the build engine when this file is run like a normal PowerShell script 89 | # (i.e. avoids the need to have another script to setup the InvokeBuild environment and issue the 'Invoke-Build' command ) 90 | if ($MyInvocation.ScriptName -notlike '*Invoke-Build.ps1') { 91 | try { 92 | Invoke-Build $Tasks $MyInvocation.MyCommand.Path @PSBoundParameters 93 | } 94 | catch { 95 | $_.ScriptStackTrace 96 | throw 97 | } 98 | return 99 | } 100 | #endregion 101 | 102 | #region Import shared tasks and initialise build framework 103 | if (!($BuildModulePath)) { 104 | if (!(Get-Module -ListAvailable Endjin.RecommendedPractices.Build | ? { $_.Version -eq $BuildModuleVersion })) { 105 | Write-Information "Installing 'Endjin.RecommendedPractices.Build' module..." 106 | Install-Module Endjin.RecommendedPractices.Build -RequiredVersion $BuildModuleVersion -Scope CurrentUser -Force -Repository PSGallery 107 | } 108 | $BuildModulePath = "Endjin.RecommendedPractices.Build" 109 | } 110 | else { 111 | Write-Information "BuildModulePath: $BuildModulePath" 112 | } 113 | Import-Module $BuildModulePath -RequiredVersion $BuildModuleVersion -Force 114 | # Load the build process & tasks 115 | . Endjin.RecommendedPractices.Build.tasks 116 | #endregion 117 | 118 | # 119 | # Build process control options 120 | # 121 | $SkipInit = $false 122 | $SkipVersion = $false 123 | $SkipBuild = $false 124 | $CleanBuild = $Clean 125 | $SkipTest = $false 126 | $SkipTestReport = $false 127 | $SkipPackage = $false 128 | $SkipAnalysis = $false 129 | # 130 | # Build process configuration 131 | # 132 | $SolutionToBuild = (Resolve-Path (Join-Path $here ".\Solutions\Ais.Net.Receiver.sln")).Path 133 | $ProjectsToPublish = @( 134 | # "Solutions/Ais.Net.Receiver.Host.Console/Ais.Net.Receiver.Host.Console.csproj" 135 | ) 136 | $NuSpecFilesToPackage = @( 137 | # "Solutions/MySolution/MyProject/MyProject.nuspec" 138 | ) 139 | 140 | $ContainerRegistryType = 'docker' 141 | $ContainerRegistryPublishPrefix = 'endjin' # publish the container images to the 'endjin' DockerHub namespace 142 | $ContainerImageVersionOverride = 'local' # override the GitVersion-generated SemVer used for tagging container images 143 | $ContainersToBuild = @( 144 | @{ 145 | Dockerfile = 'Solutions/Ais.Net.Receiver.Host.Console/Dockerfile' 146 | ImageName = 'ais-dotnet-receiver' 147 | ContextDir = "$here/Solutions" 148 | Arguments = @{ BUILD_CONFIGURATION = $Configuration; } 149 | } 150 | ) 151 | 152 | $CreateGitHubRelease = $false # temporarily disable until we figure out why the git tag is being added to 'main' 153 | $PublishNuGetPackagesAsGitHubReleaseArtefacts = $true 154 | 155 | # Synopsis: Build, Test and Package 156 | task . FullBuild 157 | 158 | # build extensibility tasks 159 | task RunFirst {} 160 | task PreInit {} 161 | task PostInit {} 162 | task PreVersion {} 163 | task PostVersion {} 164 | task PreBuild {} 165 | task PostBuild {} 166 | task PreTest {} 167 | task PostTest {} 168 | task PreTestReport {} 169 | task PostTestReport {} 170 | task PreAnalysis {} 171 | task PostAnalysis {} 172 | task PrePackage {} 173 | task PostPackage {} 174 | task PrePublish { 175 | # Ensure the build agent is logged-in to DockerHub before trying to publish any images 176 | if ($env:DOCKERHUB_ACCESSTOKEN) { 177 | if (!$DockerRegistryUsername) { 178 | Write-Warning "The 'DockerRegistryUsername' variable is not set - skipping DockerHub login, publishing container images may fail." 179 | } 180 | else { 181 | Write-Build White "Attempting to login to DockerHub as user '$DockerRegistryUsername'..." 182 | $env:DOCKERHUB_ACCESSTOKEN | docker login -u $DockerRegistryUsername --password-stdin 183 | } 184 | } 185 | else { 186 | Write-Warning "The 'DOCKERHUB_ACCESSTOKEN' environment variable is not set - skipping DockerHub login, publishing container images may fail." 187 | } 188 | } 189 | task PostPublish {} 190 | task RunLast {} 191 | -------------------------------------------------------------------------------- /docs/ReleaseNotes.md: -------------------------------------------------------------------------------- 1 | # Ais.Net.Receiver release notes 2 | 3 | ## v0.2.0 4 | 5 | * Add Errors observable to `Ais.Net.Receiver.Receiver.ReceiverHost` https://github.com/ais-dotnet/Ais.Net.Receiver/pull/142 -------------------------------------------------------------------------------- /imm.yaml: -------------------------------------------------------------------------------- 1 | - Name: Shared Engineering Standards 2 | Id: 74e29f9b-6dca-4161-8fdd-b468a1eb185d 3 | Measures: 4 | - Score: 0 5 | Description: None 6 | - Name: Coding Standards 7 | Id: f6f6490f-9493-4dc3-a674-15584fa951d8 8 | Measures: 9 | - Score: 0 10 | Description: None 11 | - Name: Executable Specifications 12 | Id: bb49fb94-6ab5-40c3-a6da-dfd2e9bc4b00 13 | Measures: 14 | - Score: 0 15 | Description: None 16 | - Name: Code Coverage 17 | Id: 0449cadc-0078-4094-b019-520d75cc6cbb 18 | Measures: 19 | - Score: 0 20 | Description: 0-25 21 | - Name: Benchmarks 22 | Id: 64ed80dc-d354-45a9-9a56-c32437306afa 23 | Measures: 24 | - Score: 0 25 | Description: None 26 | - Name: Reference Documentation 27 | Id: 2a7fc206-d578-41b0-85f6-a28b6b0fec5f 28 | Measures: 29 | - Score: 0 30 | Description: None 31 | - Name: Design & Implementation Documentation 32 | Id: f026d5a2-ce1a-4e04-af15-5a35792b164b 33 | Measures: 34 | - Score: 0 35 | Description: None 36 | - Name: How-to Documentation 37 | Id: 145f2e3d-bb05-4ced-989b-7fb218fc6705 38 | Measures: 39 | - Score: 0 40 | Description: None 41 | - Name: Date of Last IP Review 42 | Id: da4ed776-0365-4d8a-a297-c4e91a14d646 43 | Measures: 44 | - Score: 0 45 | Description: None 46 | - 47 | - Name: Framework Version 48 | Id: 6c0402b3-f0e3-4bd7-83fe-04bb6dca7924 49 | Measures: 50 | - Score: 3 51 | Description: Using the most current LTS version 52 | - Name: Associated Work Items 53 | Id: 79b8ff50-7378-4f29-b07c-bcd80746bfd4 54 | Measures: 55 | - Score: 0 56 | Description: None 57 | - Name: Source Code Availability 58 | Id: 30e1b40b-b27d-4631-b38d-3172426593ca 59 | Measures: 60 | - Score: 0 61 | Description: None 62 | - Name: License 63 | Id: d96b5bdc-62c7-47b6-bcc4-de31127c08b7 64 | Measures: 65 | - Score: 1 66 | Description: Copyright headers in each source file 67 | - Score: 1 68 | Description: License in Source & Packages 69 | - Score: 1 70 | Description: Contributor License Agreement Configured in Repo 71 | - Name: Production Use 72 | Id: 87ee2c3e-b17a-4939-b969-2c9c034d05d7 73 | Measures: 74 | - Score: 0 75 | Description: None 76 | - Name: Insights 77 | Id: 71a02488-2dc9-4d25-94fa-8c2346169f8b 78 | Measures: 79 | - Score: 0 80 | Description: None 81 | - Name: Packaging 82 | Id: 547fd9f5-9caf-449f-82d9-4fba9e7ce13a 83 | Measures: 84 | - Score: 0 85 | Description: None 86 | - Name: Deployment 87 | Id: edea4593-d2dd-485b-bc1b-aaaf18f098f9 88 | Measures: 89 | - Score: 0 90 | Description: None 91 | - Name: OpenChain 92 | Id: 66efac1a-662c-40cf-b4ec-8b34c29e9fd7 93 | Measures: 94 | - Score: 1 95 | Description: SBOM Available 96 | 97 | --------------------------------------------------------------------------------