Compare commits

...

114 Commits
v0.2.6 ... main

Author SHA1 Message Date
e1ff48a262 bump chart
All checks were successful
Build and Publish / check-chart (push) Successful in 14s
Build and Publish / helm-release (push) Successful in 33s
Build and Publish / release (push) Successful in 5m10s
2025-04-19 12:29:46 -04:00
be629e3d00 correct lightning distance unit
All checks were successful
Build and Publish / release (push) Has been skipped
Build and Publish / check-chart (push) Successful in 10s
Build and Publish / helm-release (push) Has been skipped
2025-04-19 12:29:14 -04:00
f2fd297218 bump helm chart
All checks were successful
Build and Publish / release (push) Has been skipped
Build and Publish / check-chart (push) Successful in 10s
Build and Publish / helm-release (push) Successful in 24s
2025-04-03 16:17:04 -04:00
ac000112c3 implement redis key ttl
All checks were successful
Build and Publish / check-chart (push) Successful in 12s
Build and Publish / helm-release (push) Has been skipped
Build and Publish / release (push) Successful in 3m33s
2025-04-03 16:00:57 -04:00
21a4165489 implement redis key ttl
All checks were successful
Build and Publish / check-chart (push) Successful in 12s
Build and Publish / helm-release (push) Has been skipped
Build and Publish / release (push) Successful in 3m52s
2025-04-03 15:51:52 -04:00
15dd74d6ab implement lightning_time in grpc
All checks were successful
Build and Publish / check-chart (push) Successful in 12s
Build and Publish / helm-release (push) Has been skipped
Build and Publish / release (push) Successful in 4m6s
2025-04-03 15:31:43 -04:00
82ae3e4ba4 add counter support, add lightning time
All checks were successful
Build and Publish / check-chart (push) Successful in 11s
Build and Publish / helm-release (push) Has been skipped
Build and Publish / release (push) Successful in 3m49s
2025-04-03 15:16:42 -04:00
f0bda0a3bd bump helm chart
All checks were successful
Build and Publish / check-chart (push) Successful in 14s
Build and Publish / helm-release (push) Successful in 34s
Build and Publish / release (push) Successful in 3m48s
2025-04-03 13:49:11 -04:00
1ee231fe30 Fix embedded lightning data
All checks were successful
Build and Publish / release (push) Has been skipped
Build and Publish / check-chart (push) Successful in 10s
Build and Publish / helm-release (push) Has been skipped
2025-04-03 13:48:41 -04:00
3279c6fd38 add json tags
All checks were successful
Build and Publish / check-chart (push) Successful in 14s
Build and Publish / helm-release (push) Has been skipped
Build and Publish / release (push) Successful in 3m55s
2025-04-03 13:35:32 -04:00
268bc7d8a2 code tweaks
All checks were successful
Build and Publish / release (push) Has been skipped
Build and Publish / check-chart (push) Successful in 10s
Build and Publish / helm-release (push) Has been skipped
2025-04-03 10:44:43 -04:00
2f83edc426 bump chart
Some checks are pending
Build and Publish / release (push) Waiting to run
Build and Publish / check-chart (push) Successful in 15s
Build and Publish / helm-release (push) Successful in 47s
2025-04-02 20:45:36 -04:00
80f9c00615 add lightning sensor
All checks were successful
Build and Publish / check-chart (push) Successful in 17s
Build and Publish / helm-release (push) Has been skipped
Build and Publish / release (push) Successful in 7m58s
2025-04-02 20:44:58 -04:00
53c22ab8c7 add lightning sensor
Some checks failed
Build and Publish / check-chart (push) Successful in 15s
Build and Publish / helm-release (push) Has been skipped
Build and Publish / release (push) Failing after 1m14s
2025-04-02 20:09:44 -04:00
35374ab6e0 add lightning sensor
All checks were successful
Build and Publish / check-chart (push) Successful in 12s
Build and Publish / helm-release (push) Has been skipped
Build and Publish / release (push) Successful in 4m16s
2025-04-02 20:01:21 -04:00
dcb60e6898 add lightning sensor
All checks were successful
Build and Publish / release (push) Has been skipped
Build and Publish / check-chart (push) Successful in 10s
Build and Publish / helm-release (push) Has been skipped
2025-04-02 19:49:32 -04:00
e2c464b34e permit unknown keys
All checks were successful
Build and Publish / check-chart (push) Successful in 17s
Build and Publish / helm-release (push) Has been skipped
Build and Publish / release (push) Successful in 5m2s
2025-04-02 19:30:32 -04:00
8a4f360a45 add lightning sensor
Some checks failed
Build and Publish / release (push) Successful in 6m13s
Build and Publish / check-chart (push) Failing after 12m32s
Build and Publish / helm-release (push) Has been cancelled
2025-04-02 19:28:05 -04:00
7a6ad00f7f bump helm chart
All checks were successful
Build and Publish / check-chart (push) Successful in 13s
Build and Publish / helm-release (push) Successful in 43s
Build and Publish / release (push) Successful in 3m48s
2025-03-27 15:36:23 -04:00
d847c36715 implement count rpc
All checks were successful
Build and Publish / release (push) Has been skipped
Build and Publish / check-chart (push) Successful in 11s
Build and Publish / helm-release (push) Has been skipped
2025-03-27 15:32:10 -04:00
eeea3c7cb7 bump chart
All checks were successful
Build and Publish / check-chart (push) Successful in 10s
Build and Publish / helm-release (push) Has been skipped
Build and Publish / release (push) Successful in 4m57s
2025-03-26 08:39:45 -04:00
82c08a7bc3 bump go-app
All checks were successful
Build and Publish / release (push) Has been skipped
Build and Publish / check-chart (push) Successful in 10s
Build and Publish / helm-release (push) Has been skipped
2025-03-26 08:38:57 -04:00
a8f62858e0 clean-up grpc-gateway middleware 2025-03-26 08:31:37 -04:00
f201ac1fca support runtime.ServeMux opts for grpc-gateway
All checks were successful
Build and Publish / release (push) Has been skipped
Build and Publish / check-chart (push) Successful in 10s
Build and Publish / helm-release (push) Has been skipped
2025-03-25 17:05:59 -04:00
1e481627e3 add grpc dial opts for grpc-gateway
All checks were successful
Build and Publish / release (push) Has been skipped
Build and Publish / check-chart (push) Successful in 11s
Build and Publish / helm-release (push) Has been skipped
2025-03-25 10:41:49 -04:00
9077ae354a implement grpc gateway for http api
All checks were successful
Build and Publish / release (push) Has been skipped
Build and Publish / check-chart (push) Successful in 10s
Build and Publish / helm-release (push) Has been skipped
2025-03-24 16:47:54 -04:00
2c78dcc133 update to go 1.24, prepare for grpc-gateway 2025-03-24 09:48:38 -04:00
787d065e69 update TODO
All checks were successful
Build and Publish / release (push) Has been skipped
Build and Publish / check-chart (push) Successful in 10s
Build and Publish / helm-release (push) Has been skipped
2025-03-23 12:45:18 -04:00
6d5075817e bump chart
All checks were successful
Build and Publish / check-chart (push) Successful in 40s
Build and Publish / helm-release (push) Has been skipped
Build and Publish / release (push) Successful in 2m37s
2025-03-23 12:34:21 -04:00
a02e8c5061 update concurrently 2025-03-23 12:33:58 -04:00
4a1a8fe8a0 update TODO
All checks were successful
Build and Publish / release (push) Has been skipped
Build and Publish / check-chart (push) Successful in 10s
Build and Publish / helm-release (push) Has been skipped
2025-03-23 12:23:00 -04:00
2087f6829f bump chart
All checks were successful
Build and Publish / release (push) Has been skipped
Build and Publish / check-chart (push) Successful in 19s
Build and Publish / helm-release (push) Successful in 19s
2025-03-23 12:18:45 -04:00
f079d106a1 add timestamp to proto
All checks were successful
Build and Publish / check-chart (push) Successful in 25s
Build and Publish / helm-release (push) Has been skipped
Build and Publish / release (push) Successful in 3m30s
2025-03-23 12:15:18 -04:00
fc2d05e957 update TODO
All checks were successful
Build and Publish / release (push) Has been skipped
Build and Publish / check-chart (push) Successful in 11s
Build and Publish / helm-release (push) Has been skipped
2025-03-23 12:06:27 -04:00
59f5bfbba1 bump chart for redis support;
All checks were successful
Build and Publish / check-chart (push) Successful in 41s
Build and Publish / helm-release (push) Has been skipped
Build and Publish / release (push) Successful in 2m56s
2025-03-23 11:44:13 -04:00
0adab13221 skip nil sensors data in grpc 2025-03-23 11:43:31 -04:00
3b0748da25 improve redis recorder
All checks were successful
Build and Publish / check-chart (push) Successful in 44s
Build and Publish / helm-release (push) Has been skipped
Build and Publish / release (push) Successful in 3m17s
2025-03-23 11:16:54 -04:00
2d683e61b0 update TODO
All checks were successful
Build and Publish / release (push) Has been skipped
Build and Publish / check-chart (push) Successful in 9s
Build and Publish / helm-release (push) Has been skipped
2025-03-22 14:39:26 -04:00
82bc3acfc3 bump helm chart
All checks were successful
Build and Publish / release (push) Has been skipped
Build and Publish / check-chart (push) Successful in 23s
Build and Publish / helm-release (push) Successful in 33s
2025-03-22 14:30:17 -04:00
6e1ec0b5c1 implement redis and noop recorders
All checks were successful
Build and Publish / check-chart (push) Successful in 20s
Build and Publish / helm-release (push) Has been skipped
Build and Publish / release (push) Successful in 4m12s
2025-03-22 14:29:24 -04:00
46a213f314 support json schema
All checks were successful
Build and Publish / release (push) Has been skipped
Build and Publish / check-chart (push) Successful in 10s
Build and Publish / helm-release (push) Has been skipped
2025-03-22 14:04:20 -04:00
b483fc22a3 recorder implementations
All checks were successful
Build and Publish / release (push) Has been skipped
Build and Publish / check-chart (push) Successful in 11s
Build and Publish / helm-release (push) Has been skipped
2025-03-22 12:05:25 -04:00
fb6941e6bd fix recorder npe, implement filters
All checks were successful
Build and Publish / check-chart (push) Successful in 14s
Build and Publish / helm-release (push) Has been skipped
Build and Publish / release (push) Successful in 3m36s
2025-03-21 16:26:23 -04:00
a5abbbec1f move recorder to interface
All checks were successful
Build and Publish / check-chart (push) Successful in 36s
Build and Publish / helm-release (push) Has been skipped
Build and Publish / release (push) Successful in 3m8s
2025-03-21 15:54:28 -04:00
e9b70fe6e0 update gitea ci
All checks were successful
Build and Publish / release (push) Has been skipped
Build and Publish / check-chart (push) Successful in 20s
Build and Publish / helm-release (push) Has been skipped
2025-03-21 13:17:03 -04:00
ca18eba167 update gitea ci
All checks were successful
Build and Publish / chart-updated (push) Successful in 40s
Build and Publish / helm-release (push) Successful in 18s
Build and Publish / release (push) Successful in 3m4s
2025-03-21 13:14:45 -04:00
c020c4eec8 update gitea ci
All checks were successful
Build and Publish / release (push) Has been skipped
Build and Publish / chart-updated (push) Successful in 10s
Build and Publish / helm-release (push) Successful in 18s
2025-03-21 13:12:39 -04:00
df7c2dff40 update gitea ci
All checks were successful
Build and Publish / release (push) Has been skipped
Build and Publish / chart-updated (push) Successful in 10s
Build and Publish / helm-release (push) Has been skipped
2025-03-21 13:09:47 -04:00
0b27285b86 update gitea ci
Some checks failed
Build and Publish / release (push) Has been skipped
Build and Publish / chart-updated (push) Failing after 18s
Build and Publish / helm-release (push) Has been skipped
2025-03-21 13:08:08 -04:00
1f097b1fd7 fix recorder 2025-03-21 12:52:39 -04:00
42eea2346b refactor state to recorder, finish implementing 2025-03-21 09:22:32 -04:00
a5948cf334 bump helm chart
All checks were successful
Build and Publish / release (push) Has been skipped
Build and Publish / helm-release (push) Successful in 17s
2025-03-20 17:04:35 -04:00
deb831a2c5 bump helm chart
All checks were successful
Build and Publish / release (push) Has been skipped
Build and Publish / helm-release (push) Has been skipped
2025-03-20 16:50:06 -04:00
d8291150d4 fix tag
All checks were successful
Build and Publish / release (push) Successful in 2m45s
Build and Publish / helm-release (push) Successful in 21s
2025-03-20 16:41:51 -04:00
4a35f11553 bump helm version
All checks were successful
Build and Publish / release (push) Successful in 2m55s
Build and Publish / helm-release (push) Successful in 20s
2025-03-20 16:31:04 -04:00
399c694c3e add map update test 2025-03-20 16:26:11 -04:00
133c6f7315 map weather update
All checks were successful
Build and Publish / release (push) Successful in 4m12s
Build and Publish / helm-release (push) Successful in 22s
2025-03-20 16:04:28 -04:00
220cc818e7 continue working on weather mapping 2025-03-15 09:53:13 -04:00
69fd27916b implement grpc weather 2025-03-14 23:10:01 -04:00
421c246cc6 remove flux from ale 2025-03-10 16:11:12 -04:00
6f297829fc add flux to ale 2025-03-10 16:08:30 -04:00
797b0a2f3a update TODO 2025-03-08 17:40:03 -05:00
be66762834 add helm ci 2025-03-08 17:30:56 -05:00
7e53afe035 add helm ci
All checks were successful
Build and Publish / helm-release (push) Successful in 37s
Build and Publish / release (push) Successful in 3m7s
2025-03-08 17:29:40 -05:00
be2895695f add helm ci
All checks were successful
Build and Publish / helm-release (push) Successful in 41s
Build and Publish / release (push) Successful in 2m43s
2025-03-08 17:22:19 -05:00
27af90322a add helm ci
Some checks failed
Build and Publish / helm-release (push) Failing after 1m5s
Build and Publish / release (push) Successful in 3m16s
2025-03-08 17:16:35 -05:00
a8e7b24656 add health checks 2025-03-08 16:53:15 -05:00
2653825ac8 implement hull helm chart 2025-03-08 16:35:08 -05:00
a99e52ae4c implement hull helm chart 2025-03-08 16:08:41 -05:00
48828bf8d0 implement hull helm chart 2025-03-08 12:33:45 -05:00
bcf4c0b5ce start helm chart 2025-03-07 20:35:23 -05:00
8674bb4e01 implement grpc weather
All checks were successful
Build and Publish / release (push) Successful in 4m42s
2025-03-07 17:24:05 -05:00
74120183ab add grpc support 2025-03-07 17:05:48 -05:00
f05496632a add grpc support 2025-03-06 17:20:27 -05:00
ffac524cfc add grpc support 2025-03-06 17:20:02 -05:00
64ca321c3a tweak spans
All checks were successful
Build and Publish / release (push) Successful in 2m58s
2025-03-05 13:32:39 -05:00
3329c980a9 tweak spans
All checks were successful
Build and Publish / release (push) Successful in 3m23s
2025-03-05 13:23:04 -05:00
b4412a461f tweak spans
All checks were successful
Build and Publish / release (push) Successful in 3m22s
2025-03-05 13:18:37 -05:00
042fd221c4 move enrich span end to defer 2025-03-05 13:04:11 -05:00
29433cddd7 support sensor name mapping
All checks were successful
Build and Publish / release (push) Successful in 4m6s
2025-03-05 12:53:57 -05:00
f98a4cf348 Add support for temp+humidity sensors
All checks were successful
Build and Publish / release (push) Successful in 6m32s
2025-03-04 20:11:08 -05:00
8b46238e49 allow skip go list
All checks were successful
Build and Publish / release (push) Successful in 4m5s
2025-02-15 15:06:30 -05:00
4ed1e465d2 Upgrade framework
Some checks failed
Build and Publish / release (push) Failing after 1m45s
2025-02-15 14:44:29 -05:00
ea93beb6b2 Reclassify malformed header log
All checks were successful
Build and Publish / release (push) Successful in 3m7s
2025-01-29 19:46:00 -05:00
ae53a1d5fd Invalid AWN Request Fixer
All checks were successful
Build and Publish / release (push) Successful in 4m31s
2025-01-29 19:37:23 -05:00
cd04beeec6 Invalid AWN Request Fixer 2025-01-29 18:06:50 -05:00
3d3492a283 Ignore invalid temp 2025-01-28 19:41:57 -05:00
ce0ef7d291 Clear invalid measurements
All checks were successful
Build and Publish / release (push) Successful in 3m34s
2025-01-28 19:26:40 -05:00
f2f160b112 Fix wunderground wind speed mapping
All checks were successful
Build and Publish / release (push) Successful in 2m40s
2025-01-28 17:05:25 -05:00
2c29493229 Upgrade, fix attrs
All checks were successful
Build and Publish / release (push) Successful in 3m46s
2025-01-28 15:47:58 -05:00
59433cc77f Upgrades
All checks were successful
Build and Publish / release (push) Successful in 4m27s
2025-01-28 15:08:31 -05:00
16d3f9cb17 Add grafana dashboard 2025-01-12 21:44:34 -05:00
e93c7ec5c0 Update go-app
All checks were successful
Build and Publish / release (push) Successful in 6m10s
2025-01-12 20:22:56 -05:00
c2bde1e1dd Update TODO 2025-01-12 19:27:10 -05:00
44bf293eab Proxy observability updates
All checks were successful
Build and Publish / release (push) Successful in 2m51s
2025-01-12 19:14:24 -05:00
f87b35b306 Perform proxy updates in goroutines 2025-01-12 19:10:30 -05:00
eaf2cf211d Update wunderground url
All checks were successful
Build and Publish / release (push) Successful in 2m36s
2025-01-12 19:06:45 -05:00
e8fbacfe6d Proxy updates for AWN
All checks were successful
Build and Publish / release (push) Successful in 2m53s
2025-01-12 17:49:54 -05:00
19823ea08f Weather service proxy support
All checks were successful
Build and Publish / release (push) Successful in 3m51s
2025-01-12 17:23:32 -05:00
7fc1fc9b56 Proxy support for AWN/Wunderground 2025-01-12 15:30:37 -05:00
849dbfb6ff Update go-app
All checks were successful
Build and Publish / release (push) Successful in 4m5s
2025-01-08 21:31:50 -05:00
dc2470da71 Improve metrics recording
Some checks failed
Build and Publish / release (push) Failing after 40s
2025-01-08 21:28:38 -05:00
c23177b62d Support metric prefix change
Some checks failed
Build and Publish / release (push) Failing after 40s
2025-01-08 16:59:27 -05:00
86653cf589 Use custom config type 2025-01-08 16:49:31 -05:00
fa0a9f4ddc Fix wind speed mapping for awn
All checks were successful
Build and Publish / release (push) Successful in 2m46s
2025-01-08 14:24:56 -05:00
689790fe86 Update TODO 2025-01-08 14:24:46 -05:00
3af1cc40a5 Fix metric prefix
All checks were successful
Build and Publish / release (push) Successful in 3m29s
2025-01-07 15:08:14 -05:00
4ff684abe3 Update TODO 2025-01-07 11:35:14 -05:00
87f0cbac01 Fix typos, update TODO
All checks were successful
Build and Publish / release (push) Successful in 3m44s
2025-01-07 11:28:34 -05:00
4c93303f27 Consolidate battery metrics 2025-01-07 10:27:02 -05:00
e8654e76bc Update TODO 2025-01-07 08:53:06 -05:00
54f725c822 Update go-app 2025-01-05 20:33:28 -05:00
c002331fd4 Fix NaN with zero values
All checks were successful
Build and Publish / release (push) Successful in 3m7s
2025-01-05 19:05:24 -05:00
2ed4eca56c Add support for co2 battery
All checks were successful
Build and Publish / release (push) Successful in 2m57s
2025-01-05 18:39:58 -05:00
70 changed files with 11155 additions and 401 deletions

4
.dockerignore Normal file
View File

@ -0,0 +1,4 @@
.git
config.y*ml
go.work*
docker-compose-sample*

View File

@ -2,6 +2,7 @@ name: Build and Publish
on:
push:
tags: ["v*"]
branches: ["main"]
env:
PACKAGE_NAME: ambient-local-exporter
@ -16,14 +17,15 @@ env:
DOCKER_USER: rmcguire
DOCKER_REPO: rmcguire/ambient-local-exporter
DOCKER_IMG: ${{ env.DOCKER_REGISTRY }}/${{ env.DOCKER_REPO }}
CHART_DIR: helm/ambient-local-exporter
jobs:
release:
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v') # Only run on tag push
steps:
- name: Checkout Code
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Set up Go Environment
uses: actions/setup-go@v4
@ -54,6 +56,7 @@ jobs:
done
- name: Run Go List
continue-on-error: true
env:
TAG_NAME: ${{ github.ref_name }} # Use the pushed tag name
run: |
@ -85,3 +88,46 @@ jobs:
build-args: |
VER_PKG=${{ env.VER_PKG }}
VERSION=${{ github.ref_name }}
# Detect if the helm chart was updated
check-chart:
runs-on: ubuntu-latest
outputs:
chart-updated: ${{ steps.filter.outputs.chart }}
steps:
- uses: actions/checkout@v4
- name: Check Chart Changed
uses: dorny/paths-filter@v3
id: filter
with:
base: ${{ github.ref }}
filters: |
chart:
- helm/ambient-local-exporter/Chart.yaml
helm-release:
runs-on: ubuntu-latest
needs: check-chart
if: ${{ needs.check-chart.outputs.chart-updated == 'true' }}
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Install Helm
env:
BINARY_NAME: helm
run: |
curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
- name: Package Chart
run: |
helm package --app-version ${VERSION} ${CHART_DIR}
- name: Publish Chart
env:
API_TOKEN: ${{ secrets.API_TOKEN }}
run: |
curl -X POST \
-H "Authorization: token ${API_TOKEN}" \
--upload-file ./${PACKAGE_NAME}-*.tgz \
https://gitea.libretechconsulting.com/api/packages/${GITHUB_REPOSITORY_OWNER}/helm/api/charts

6
.gitignore vendored
View File

@ -26,5 +26,9 @@ go.work.sum
bin/*
config.yaml
config.y*ml
docker-compose.yml
.vscode
helm/values*.y*ml
helm_upgrade.sh

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "proto/googleapis"]
path = proto/googleapis
url = https://github.com/googleapis/googleapis.git

View File

@ -1,14 +1,25 @@
CMD_NAME := ambient-local-exporter
.PHONY: all test build docker install clean
.PHONY: all test build docker install clean proto check_protoc
VERSION ?= development # Default to "development" if VERSION is not set
APIVERSION := v1alpha1
API_DIR := api/$(APIVERSION)
PROTO_DIRS := $(wildcard proto/*)
PLATFORMS := linux/amd64 linux/arm64 darwin/amd64 darwin/arm64
OUTPUT_DIR := bin
VER_PKG := gitea.libretechconsulting.com/rmcguire/go-app/pkg/config.Version
DOCKER_IMG := gitea.libretechconsulting.com/rmcguire/ambient-local-exporter
all: test build docker
all: proto test build docker
proto: check_protoc $(API_DIR)
protoc --proto_path=proto --proto_path=proto/googleapis \
--go_out=$(API_DIR) --go_opt=paths=source_relative \
--go-grpc_out=$(API_DIR) --go-grpc_opt=paths=source_relative \
--grpc-gateway_out=$(API_DIR) --grpc-gateway_opt=paths=source_relative \
--openapiv2_out=$(API_DIR) \
$(foreach dir, $(PROTO_DIRS), $(wildcard $(dir)/*.proto))
test:
go test -v ./...
@ -24,6 +35,9 @@ build: test
done
go build -ldflags "-X $(VER_PKG)=$(VERSION)" -o bin/${CMD_NAME}
schema:
go run . -schema > contrib/schema.json
docker:
@echo "Building Docker image $(DOCKER_IMG):$(VERSION)"
docker build \
@ -37,3 +51,13 @@ install:
clean:
rm -rf bin/${CMD_NAME}
check_protoc:
@if ! command -v protoc-gen-go > /dev/null; then \
echo "Error: protoc-gen-go not found in PATH"; \
exit 1; \
fi
@if ! command -v protoc-gen-go-grpc > /dev/null; then \
echo "Error: protoc-gen-go-grpc not found in PATH"; \
exit 1; \
fi

View File

@ -1,3 +1,3 @@
# ambient-weather-local-exporter
# ambient-local-exporter
A simple exporter that takes data from an Ambient Weather weather station or Weather Hub. Requires no internet connection, and does not use any cloud APIs.

28
TODO.md
View File

@ -1,11 +1,31 @@
# Issues
- [x] Redis recorder panic
# TODO
- [ ] Fix shutdown
- [ ] Configuration for app
- [ ] Helm Chart
- [ ] Make lightning distance unit configurable (kph|mph)
- [ ] Set TTL on redis key
- [ ] Add json schema to CI and README
- [ ] Update README
- [ ] Add new fields from WS-2192
- [ ] Add Grafana dashboard
## Done
- [x] Make recorder.Set concurrent
- [x] Stop Marshaling sensors / batteries with no data
- [x] Finish implementing weather GRPC
- [x] Add json schema for config
- [x] Add new spans
- [x] Helm Chart
- [x] Add proxy to upstream support
- [x] Fix wunderground 401
- [x] Perform proxy calls in goroutines
- [x] Configuration for app
- [x] Configurable metric prefix
- [x] Add device name field with ID/Key mappings
- [x] Add device type field with ID/Key mappings
- [x] Move EVERYTHING to pointers to support nil
- [x] Consolidate battery status into one metric with device label
- [x] Fix shutdown
- [x] Add new fields from WS-2192
- [x] Gitea CI
- [x] Version flag
- [x] Makefile

View File

@ -0,0 +1,827 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
// protoc v5.29.3
// source: weather/weather.proto
package weather
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type CountWeatherUpdatesRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Opts *GetWeatherOpts `protobuf:"bytes,1,opt,name=opts,proto3" json:"opts,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CountWeatherUpdatesRequest) Reset() {
*x = CountWeatherUpdatesRequest{}
mi := &file_weather_weather_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CountWeatherUpdatesRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CountWeatherUpdatesRequest) ProtoMessage() {}
func (x *CountWeatherUpdatesRequest) ProtoReflect() protoreflect.Message {
mi := &file_weather_weather_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CountWeatherUpdatesRequest.ProtoReflect.Descriptor instead.
func (*CountWeatherUpdatesRequest) Descriptor() ([]byte, []int) {
return file_weather_weather_proto_rawDescGZIP(), []int{0}
}
func (x *CountWeatherUpdatesRequest) GetOpts() *GetWeatherOpts {
if x != nil {
return x.Opts
}
return nil
}
type CountWeatherUpdatesResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Count int32 `protobuf:"varint,1,opt,name=count,proto3" json:"count,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CountWeatherUpdatesResponse) Reset() {
*x = CountWeatherUpdatesResponse{}
mi := &file_weather_weather_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CountWeatherUpdatesResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CountWeatherUpdatesResponse) ProtoMessage() {}
func (x *CountWeatherUpdatesResponse) ProtoReflect() protoreflect.Message {
mi := &file_weather_weather_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CountWeatherUpdatesResponse.ProtoReflect.Descriptor instead.
func (*CountWeatherUpdatesResponse) Descriptor() ([]byte, []int) {
return file_weather_weather_proto_rawDescGZIP(), []int{1}
}
func (x *CountWeatherUpdatesResponse) GetCount() int32 {
if x != nil {
return x.Count
}
return 0
}
type GetWeatherRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Opts *GetWeatherOpts `protobuf:"bytes,1,opt,name=opts,proto3" json:"opts,omitempty"`
Limit *int32 `protobuf:"varint,2,opt,name=limit,proto3,oneof" json:"limit,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetWeatherRequest) Reset() {
*x = GetWeatherRequest{}
mi := &file_weather_weather_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetWeatherRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetWeatherRequest) ProtoMessage() {}
func (x *GetWeatherRequest) ProtoReflect() protoreflect.Message {
mi := &file_weather_weather_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetWeatherRequest.ProtoReflect.Descriptor instead.
func (*GetWeatherRequest) Descriptor() ([]byte, []int) {
return file_weather_weather_proto_rawDescGZIP(), []int{2}
}
func (x *GetWeatherRequest) GetOpts() *GetWeatherOpts {
if x != nil {
return x.Opts
}
return nil
}
func (x *GetWeatherRequest) GetLimit() int32 {
if x != nil && x.Limit != nil {
return *x.Limit
}
return 0
}
type GetWeatherResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
LastUpdated *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=last_updated,json=lastUpdated,proto3" json:"last_updated,omitempty"`
WeatherUpdates []*WeatherUpdate `protobuf:"bytes,2,rep,name=weather_updates,json=weatherUpdates,proto3" json:"weather_updates,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetWeatherResponse) Reset() {
*x = GetWeatherResponse{}
mi := &file_weather_weather_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetWeatherResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetWeatherResponse) ProtoMessage() {}
func (x *GetWeatherResponse) ProtoReflect() protoreflect.Message {
mi := &file_weather_weather_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetWeatherResponse.ProtoReflect.Descriptor instead.
func (*GetWeatherResponse) Descriptor() ([]byte, []int) {
return file_weather_weather_proto_rawDescGZIP(), []int{3}
}
func (x *GetWeatherResponse) GetLastUpdated() *timestamppb.Timestamp {
if x != nil {
return x.LastUpdated
}
return nil
}
func (x *GetWeatherResponse) GetWeatherUpdates() []*WeatherUpdate {
if x != nil {
return x.WeatherUpdates
}
return nil
}
type GetWeatherOpts struct {
state protoimpl.MessageState `protogen:"open.v1"`
StationName *string `protobuf:"bytes,1,opt,name=station_name,json=stationName,proto3,oneof" json:"station_name,omitempty"`
StationType *string `protobuf:"bytes,2,opt,name=station_type,json=stationType,proto3,oneof" json:"station_type,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetWeatherOpts) Reset() {
*x = GetWeatherOpts{}
mi := &file_weather_weather_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetWeatherOpts) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetWeatherOpts) ProtoMessage() {}
func (x *GetWeatherOpts) ProtoReflect() protoreflect.Message {
mi := &file_weather_weather_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetWeatherOpts.ProtoReflect.Descriptor instead.
func (*GetWeatherOpts) Descriptor() ([]byte, []int) {
return file_weather_weather_proto_rawDescGZIP(), []int{4}
}
func (x *GetWeatherOpts) GetStationName() string {
if x != nil && x.StationName != nil {
return *x.StationName
}
return ""
}
func (x *GetWeatherOpts) GetStationType() string {
if x != nil && x.StationType != nil {
return *x.StationType
}
return ""
}
type WeatherUpdate struct {
state protoimpl.MessageState `protogen:"open.v1"`
StationName string `protobuf:"bytes,1,opt,name=station_name,json=stationName,proto3" json:"station_name,omitempty"`
StationType string `protobuf:"bytes,2,opt,name=station_type,json=stationType,proto3" json:"station_type,omitempty"`
StationId string `protobuf:"bytes,3,opt,name=station_id,json=stationId,proto3" json:"station_id,omitempty"`
TempOutdoorF *float64 `protobuf:"fixed64,4,opt,name=temp_outdoor_f,json=tempOutdoorF,proto3,oneof" json:"temp_outdoor_f,omitempty"`
TempIndoorF *float64 `protobuf:"fixed64,5,opt,name=temp_indoor_f,json=tempIndoorF,proto3,oneof" json:"temp_indoor_f,omitempty"`
HumidityOutdoor *int32 `protobuf:"varint,6,opt,name=humidity_outdoor,json=humidityOutdoor,proto3,oneof" json:"humidity_outdoor,omitempty"`
HumidityIndoor *int32 `protobuf:"varint,7,opt,name=humidity_indoor,json=humidityIndoor,proto3,oneof" json:"humidity_indoor,omitempty"`
WindSpeedMph *float64 `protobuf:"fixed64,8,opt,name=wind_speed_mph,json=windSpeedMph,proto3,oneof" json:"wind_speed_mph,omitempty"`
WindGustMph *float64 `protobuf:"fixed64,9,opt,name=wind_gust_mph,json=windGustMph,proto3,oneof" json:"wind_gust_mph,omitempty"`
MaxDailyGust *float64 `protobuf:"fixed64,10,opt,name=max_daily_gust,json=maxDailyGust,proto3,oneof" json:"max_daily_gust,omitempty"`
WindDir *int32 `protobuf:"varint,11,opt,name=wind_dir,json=windDir,proto3,oneof" json:"wind_dir,omitempty"`
WindDirAvg_10M *int32 `protobuf:"varint,12,opt,name=wind_dir_avg_10m,json=windDirAvg10m,proto3,oneof" json:"wind_dir_avg_10m,omitempty"`
Uv *int32 `protobuf:"varint,13,opt,name=uv,proto3,oneof" json:"uv,omitempty"`
SolarRadiation *float64 `protobuf:"fixed64,14,opt,name=solar_radiation,json=solarRadiation,proto3,oneof" json:"solar_radiation,omitempty"`
HourlyRainIn *float64 `protobuf:"fixed64,15,opt,name=hourly_rain_in,json=hourlyRainIn,proto3,oneof" json:"hourly_rain_in,omitempty"`
EventRainIn *float64 `protobuf:"fixed64,16,opt,name=event_rain_in,json=eventRainIn,proto3,oneof" json:"event_rain_in,omitempty"`
DailyRainIn *float64 `protobuf:"fixed64,17,opt,name=daily_rain_in,json=dailyRainIn,proto3,oneof" json:"daily_rain_in,omitempty"`
WeeklyRainIn *float64 `protobuf:"fixed64,18,opt,name=weekly_rain_in,json=weeklyRainIn,proto3,oneof" json:"weekly_rain_in,omitempty"`
MonthlyRainIn *float64 `protobuf:"fixed64,19,opt,name=monthly_rain_in,json=monthlyRainIn,proto3,oneof" json:"monthly_rain_in,omitempty"`
YearlyRainIn *float64 `protobuf:"fixed64,20,opt,name=yearly_rain_in,json=yearlyRainIn,proto3,oneof" json:"yearly_rain_in,omitempty"`
TotalRainIn *float64 `protobuf:"fixed64,21,opt,name=total_rain_in,json=totalRainIn,proto3,oneof" json:"total_rain_in,omitempty"`
Batteries []*BatteryStatus `protobuf:"bytes,22,rep,name=batteries,proto3" json:"batteries,omitempty"`
BaromRelativeIn *float64 `protobuf:"fixed64,23,opt,name=barom_relative_in,json=baromRelativeIn,proto3,oneof" json:"barom_relative_in,omitempty"`
BaromAbsoluteIn *float64 `protobuf:"fixed64,24,opt,name=barom_absolute_in,json=baromAbsoluteIn,proto3,oneof" json:"barom_absolute_in,omitempty"`
DewPointF *float64 `protobuf:"fixed64,25,opt,name=dew_point_f,json=dewPointF,proto3,oneof" json:"dew_point_f,omitempty"`
WindChillF *float64 `protobuf:"fixed64,26,opt,name=wind_chill_f,json=windChillF,proto3,oneof" json:"wind_chill_f,omitempty"`
TempHumiditySensors []*TempHumiditySensor `protobuf:"bytes,27,rep,name=temp_humidity_sensors,json=tempHumiditySensors,proto3" json:"temp_humidity_sensors,omitempty"`
UpdateTimestamp *timestamppb.Timestamp `protobuf:"bytes,28,opt,name=update_timestamp,json=updateTimestamp,proto3" json:"update_timestamp,omitempty"`
LightningDay *int32 `protobuf:"varint,29,opt,name=lightning_day,json=lightningDay,proto3,oneof" json:"lightning_day,omitempty"`
LightningDistance *int32 `protobuf:"varint,30,opt,name=lightning_distance,json=lightningDistance,proto3,oneof" json:"lightning_distance,omitempty"`
LightningLastTime *timestamppb.Timestamp `protobuf:"bytes,31,opt,name=lightning_last_time,json=lightningLastTime,proto3,oneof" json:"lightning_last_time,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *WeatherUpdate) Reset() {
*x = WeatherUpdate{}
mi := &file_weather_weather_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *WeatherUpdate) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*WeatherUpdate) ProtoMessage() {}
func (x *WeatherUpdate) ProtoReflect() protoreflect.Message {
mi := &file_weather_weather_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use WeatherUpdate.ProtoReflect.Descriptor instead.
func (*WeatherUpdate) Descriptor() ([]byte, []int) {
return file_weather_weather_proto_rawDescGZIP(), []int{5}
}
func (x *WeatherUpdate) GetStationName() string {
if x != nil {
return x.StationName
}
return ""
}
func (x *WeatherUpdate) GetStationType() string {
if x != nil {
return x.StationType
}
return ""
}
func (x *WeatherUpdate) GetStationId() string {
if x != nil {
return x.StationId
}
return ""
}
func (x *WeatherUpdate) GetTempOutdoorF() float64 {
if x != nil && x.TempOutdoorF != nil {
return *x.TempOutdoorF
}
return 0
}
func (x *WeatherUpdate) GetTempIndoorF() float64 {
if x != nil && x.TempIndoorF != nil {
return *x.TempIndoorF
}
return 0
}
func (x *WeatherUpdate) GetHumidityOutdoor() int32 {
if x != nil && x.HumidityOutdoor != nil {
return *x.HumidityOutdoor
}
return 0
}
func (x *WeatherUpdate) GetHumidityIndoor() int32 {
if x != nil && x.HumidityIndoor != nil {
return *x.HumidityIndoor
}
return 0
}
func (x *WeatherUpdate) GetWindSpeedMph() float64 {
if x != nil && x.WindSpeedMph != nil {
return *x.WindSpeedMph
}
return 0
}
func (x *WeatherUpdate) GetWindGustMph() float64 {
if x != nil && x.WindGustMph != nil {
return *x.WindGustMph
}
return 0
}
func (x *WeatherUpdate) GetMaxDailyGust() float64 {
if x != nil && x.MaxDailyGust != nil {
return *x.MaxDailyGust
}
return 0
}
func (x *WeatherUpdate) GetWindDir() int32 {
if x != nil && x.WindDir != nil {
return *x.WindDir
}
return 0
}
func (x *WeatherUpdate) GetWindDirAvg_10M() int32 {
if x != nil && x.WindDirAvg_10M != nil {
return *x.WindDirAvg_10M
}
return 0
}
func (x *WeatherUpdate) GetUv() int32 {
if x != nil && x.Uv != nil {
return *x.Uv
}
return 0
}
func (x *WeatherUpdate) GetSolarRadiation() float64 {
if x != nil && x.SolarRadiation != nil {
return *x.SolarRadiation
}
return 0
}
func (x *WeatherUpdate) GetHourlyRainIn() float64 {
if x != nil && x.HourlyRainIn != nil {
return *x.HourlyRainIn
}
return 0
}
func (x *WeatherUpdate) GetEventRainIn() float64 {
if x != nil && x.EventRainIn != nil {
return *x.EventRainIn
}
return 0
}
func (x *WeatherUpdate) GetDailyRainIn() float64 {
if x != nil && x.DailyRainIn != nil {
return *x.DailyRainIn
}
return 0
}
func (x *WeatherUpdate) GetWeeklyRainIn() float64 {
if x != nil && x.WeeklyRainIn != nil {
return *x.WeeklyRainIn
}
return 0
}
func (x *WeatherUpdate) GetMonthlyRainIn() float64 {
if x != nil && x.MonthlyRainIn != nil {
return *x.MonthlyRainIn
}
return 0
}
func (x *WeatherUpdate) GetYearlyRainIn() float64 {
if x != nil && x.YearlyRainIn != nil {
return *x.YearlyRainIn
}
return 0
}
func (x *WeatherUpdate) GetTotalRainIn() float64 {
if x != nil && x.TotalRainIn != nil {
return *x.TotalRainIn
}
return 0
}
func (x *WeatherUpdate) GetBatteries() []*BatteryStatus {
if x != nil {
return x.Batteries
}
return nil
}
func (x *WeatherUpdate) GetBaromRelativeIn() float64 {
if x != nil && x.BaromRelativeIn != nil {
return *x.BaromRelativeIn
}
return 0
}
func (x *WeatherUpdate) GetBaromAbsoluteIn() float64 {
if x != nil && x.BaromAbsoluteIn != nil {
return *x.BaromAbsoluteIn
}
return 0
}
func (x *WeatherUpdate) GetDewPointF() float64 {
if x != nil && x.DewPointF != nil {
return *x.DewPointF
}
return 0
}
func (x *WeatherUpdate) GetWindChillF() float64 {
if x != nil && x.WindChillF != nil {
return *x.WindChillF
}
return 0
}
func (x *WeatherUpdate) GetTempHumiditySensors() []*TempHumiditySensor {
if x != nil {
return x.TempHumiditySensors
}
return nil
}
func (x *WeatherUpdate) GetUpdateTimestamp() *timestamppb.Timestamp {
if x != nil {
return x.UpdateTimestamp
}
return nil
}
func (x *WeatherUpdate) GetLightningDay() int32 {
if x != nil && x.LightningDay != nil {
return *x.LightningDay
}
return 0
}
func (x *WeatherUpdate) GetLightningDistance() int32 {
if x != nil && x.LightningDistance != nil {
return *x.LightningDistance
}
return 0
}
func (x *WeatherUpdate) GetLightningLastTime() *timestamppb.Timestamp {
if x != nil {
return x.LightningLastTime
}
return nil
}
// Represents a temperature and humidity sensor
type TempHumiditySensor struct {
state protoimpl.MessageState `protogen:"open.v1"`
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
TempF *float64 `protobuf:"fixed64,2,opt,name=temp_f,json=tempF,proto3,oneof" json:"temp_f,omitempty"`
Humidity *int32 `protobuf:"varint,3,opt,name=humidity,proto3,oneof" json:"humidity,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *TempHumiditySensor) Reset() {
*x = TempHumiditySensor{}
mi := &file_weather_weather_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *TempHumiditySensor) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*TempHumiditySensor) ProtoMessage() {}
func (x *TempHumiditySensor) ProtoReflect() protoreflect.Message {
mi := &file_weather_weather_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use TempHumiditySensor.ProtoReflect.Descriptor instead.
func (*TempHumiditySensor) Descriptor() ([]byte, []int) {
return file_weather_weather_proto_rawDescGZIP(), []int{6}
}
func (x *TempHumiditySensor) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *TempHumiditySensor) GetTempF() float64 {
if x != nil && x.TempF != nil {
return *x.TempF
}
return 0
}
func (x *TempHumiditySensor) GetHumidity() int32 {
if x != nil && x.Humidity != nil {
return *x.Humidity
}
return 0
}
// Represents battery status for different components
type BatteryStatus struct {
state protoimpl.MessageState `protogen:"open.v1"`
Component string `protobuf:"bytes,1,opt,name=component,proto3" json:"component,omitempty"`
Status *int32 `protobuf:"varint,2,opt,name=status,proto3,oneof" json:"status,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *BatteryStatus) Reset() {
*x = BatteryStatus{}
mi := &file_weather_weather_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *BatteryStatus) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*BatteryStatus) ProtoMessage() {}
func (x *BatteryStatus) ProtoReflect() protoreflect.Message {
mi := &file_weather_weather_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use BatteryStatus.ProtoReflect.Descriptor instead.
func (*BatteryStatus) Descriptor() ([]byte, []int) {
return file_weather_weather_proto_rawDescGZIP(), []int{7}
}
func (x *BatteryStatus) GetComponent() string {
if x != nil {
return x.Component
}
return ""
}
func (x *BatteryStatus) GetStatus() int32 {
if x != nil && x.Status != nil {
return *x.Status
}
return 0
}
var File_weather_weather_proto protoreflect.FileDescriptor
const file_weather_weather_proto_rawDesc = "" +
"\n" +
"\x15weather/weather.proto\x12\x0fambient.weather\x1a\x1fgoogle/protobuf/timestamp.proto\"Q\n" +
"\x1aCountWeatherUpdatesRequest\x123\n" +
"\x04opts\x18\x01 \x01(\v2\x1f.ambient.weather.GetWeatherOptsR\x04opts\"3\n" +
"\x1bCountWeatherUpdatesResponse\x12\x14\n" +
"\x05count\x18\x01 \x01(\x05R\x05count\"m\n" +
"\x11GetWeatherRequest\x123\n" +
"\x04opts\x18\x01 \x01(\v2\x1f.ambient.weather.GetWeatherOptsR\x04opts\x12\x19\n" +
"\x05limit\x18\x02 \x01(\x05H\x00R\x05limit\x88\x01\x01B\b\n" +
"\x06_limit\"\x9c\x01\n" +
"\x12GetWeatherResponse\x12=\n" +
"\flast_updated\x18\x01 \x01(\v2\x1a.google.protobuf.TimestampR\vlastUpdated\x12G\n" +
"\x0fweather_updates\x18\x02 \x03(\v2\x1e.ambient.weather.WeatherUpdateR\x0eweatherUpdates\"\x82\x01\n" +
"\x0eGetWeatherOpts\x12&\n" +
"\fstation_name\x18\x01 \x01(\tH\x00R\vstationName\x88\x01\x01\x12&\n" +
"\fstation_type\x18\x02 \x01(\tH\x01R\vstationType\x88\x01\x01B\x0f\n" +
"\r_station_nameB\x0f\n" +
"\r_station_type\"\xee\x0e\n" +
"\rWeatherUpdate\x12!\n" +
"\fstation_name\x18\x01 \x01(\tR\vstationName\x12!\n" +
"\fstation_type\x18\x02 \x01(\tR\vstationType\x12\x1d\n" +
"\n" +
"station_id\x18\x03 \x01(\tR\tstationId\x12)\n" +
"\x0etemp_outdoor_f\x18\x04 \x01(\x01H\x00R\ftempOutdoorF\x88\x01\x01\x12'\n" +
"\rtemp_indoor_f\x18\x05 \x01(\x01H\x01R\vtempIndoorF\x88\x01\x01\x12.\n" +
"\x10humidity_outdoor\x18\x06 \x01(\x05H\x02R\x0fhumidityOutdoor\x88\x01\x01\x12,\n" +
"\x0fhumidity_indoor\x18\a \x01(\x05H\x03R\x0ehumidityIndoor\x88\x01\x01\x12)\n" +
"\x0ewind_speed_mph\x18\b \x01(\x01H\x04R\fwindSpeedMph\x88\x01\x01\x12'\n" +
"\rwind_gust_mph\x18\t \x01(\x01H\x05R\vwindGustMph\x88\x01\x01\x12)\n" +
"\x0emax_daily_gust\x18\n" +
" \x01(\x01H\x06R\fmaxDailyGust\x88\x01\x01\x12\x1e\n" +
"\bwind_dir\x18\v \x01(\x05H\aR\awindDir\x88\x01\x01\x12,\n" +
"\x10wind_dir_avg_10m\x18\f \x01(\x05H\bR\rwindDirAvg10m\x88\x01\x01\x12\x13\n" +
"\x02uv\x18\r \x01(\x05H\tR\x02uv\x88\x01\x01\x12,\n" +
"\x0fsolar_radiation\x18\x0e \x01(\x01H\n" +
"R\x0esolarRadiation\x88\x01\x01\x12)\n" +
"\x0ehourly_rain_in\x18\x0f \x01(\x01H\vR\fhourlyRainIn\x88\x01\x01\x12'\n" +
"\revent_rain_in\x18\x10 \x01(\x01H\fR\veventRainIn\x88\x01\x01\x12'\n" +
"\rdaily_rain_in\x18\x11 \x01(\x01H\rR\vdailyRainIn\x88\x01\x01\x12)\n" +
"\x0eweekly_rain_in\x18\x12 \x01(\x01H\x0eR\fweeklyRainIn\x88\x01\x01\x12+\n" +
"\x0fmonthly_rain_in\x18\x13 \x01(\x01H\x0fR\rmonthlyRainIn\x88\x01\x01\x12)\n" +
"\x0eyearly_rain_in\x18\x14 \x01(\x01H\x10R\fyearlyRainIn\x88\x01\x01\x12'\n" +
"\rtotal_rain_in\x18\x15 \x01(\x01H\x11R\vtotalRainIn\x88\x01\x01\x12<\n" +
"\tbatteries\x18\x16 \x03(\v2\x1e.ambient.weather.BatteryStatusR\tbatteries\x12/\n" +
"\x11barom_relative_in\x18\x17 \x01(\x01H\x12R\x0fbaromRelativeIn\x88\x01\x01\x12/\n" +
"\x11barom_absolute_in\x18\x18 \x01(\x01H\x13R\x0fbaromAbsoluteIn\x88\x01\x01\x12#\n" +
"\vdew_point_f\x18\x19 \x01(\x01H\x14R\tdewPointF\x88\x01\x01\x12%\n" +
"\fwind_chill_f\x18\x1a \x01(\x01H\x15R\n" +
"windChillF\x88\x01\x01\x12W\n" +
"\x15temp_humidity_sensors\x18\x1b \x03(\v2#.ambient.weather.TempHumiditySensorR\x13tempHumiditySensors\x12E\n" +
"\x10update_timestamp\x18\x1c \x01(\v2\x1a.google.protobuf.TimestampR\x0fupdateTimestamp\x12(\n" +
"\rlightning_day\x18\x1d \x01(\x05H\x16R\flightningDay\x88\x01\x01\x122\n" +
"\x12lightning_distance\x18\x1e \x01(\x05H\x17R\x11lightningDistance\x88\x01\x01\x12O\n" +
"\x13lightning_last_time\x18\x1f \x01(\v2\x1a.google.protobuf.TimestampH\x18R\x11lightningLastTime\x88\x01\x01B\x11\n" +
"\x0f_temp_outdoor_fB\x10\n" +
"\x0e_temp_indoor_fB\x13\n" +
"\x11_humidity_outdoorB\x12\n" +
"\x10_humidity_indoorB\x11\n" +
"\x0f_wind_speed_mphB\x10\n" +
"\x0e_wind_gust_mphB\x11\n" +
"\x0f_max_daily_gustB\v\n" +
"\t_wind_dirB\x13\n" +
"\x11_wind_dir_avg_10mB\x05\n" +
"\x03_uvB\x12\n" +
"\x10_solar_radiationB\x11\n" +
"\x0f_hourly_rain_inB\x10\n" +
"\x0e_event_rain_inB\x10\n" +
"\x0e_daily_rain_inB\x11\n" +
"\x0f_weekly_rain_inB\x12\n" +
"\x10_monthly_rain_inB\x11\n" +
"\x0f_yearly_rain_inB\x10\n" +
"\x0e_total_rain_inB\x14\n" +
"\x12_barom_relative_inB\x14\n" +
"\x12_barom_absolute_inB\x0e\n" +
"\f_dew_point_fB\x0f\n" +
"\r_wind_chill_fB\x10\n" +
"\x0e_lightning_dayB\x15\n" +
"\x13_lightning_distanceB\x16\n" +
"\x14_lightning_last_time\"}\n" +
"\x12TempHumiditySensor\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\x12\x1a\n" +
"\x06temp_f\x18\x02 \x01(\x01H\x00R\x05tempF\x88\x01\x01\x12\x1f\n" +
"\bhumidity\x18\x03 \x01(\x05H\x01R\bhumidity\x88\x01\x01B\t\n" +
"\a_temp_fB\v\n" +
"\t_humidity\"U\n" +
"\rBatteryStatus\x12\x1c\n" +
"\tcomponent\x18\x01 \x01(\tR\tcomponent\x12\x1b\n" +
"\x06status\x18\x02 \x01(\x05H\x00R\x06status\x88\x01\x01B\t\n" +
"\a_statusBTZRgitea.libretechconsulting.com/rmcguire/ambient-local-exporter/api/v1alpha1/weatherb\x06proto3"
var (
file_weather_weather_proto_rawDescOnce sync.Once
file_weather_weather_proto_rawDescData []byte
)
func file_weather_weather_proto_rawDescGZIP() []byte {
file_weather_weather_proto_rawDescOnce.Do(func() {
file_weather_weather_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_weather_weather_proto_rawDesc), len(file_weather_weather_proto_rawDesc)))
})
return file_weather_weather_proto_rawDescData
}
var file_weather_weather_proto_msgTypes = make([]protoimpl.MessageInfo, 8)
var file_weather_weather_proto_goTypes = []any{
(*CountWeatherUpdatesRequest)(nil), // 0: ambient.weather.CountWeatherUpdatesRequest
(*CountWeatherUpdatesResponse)(nil), // 1: ambient.weather.CountWeatherUpdatesResponse
(*GetWeatherRequest)(nil), // 2: ambient.weather.GetWeatherRequest
(*GetWeatherResponse)(nil), // 3: ambient.weather.GetWeatherResponse
(*GetWeatherOpts)(nil), // 4: ambient.weather.GetWeatherOpts
(*WeatherUpdate)(nil), // 5: ambient.weather.WeatherUpdate
(*TempHumiditySensor)(nil), // 6: ambient.weather.TempHumiditySensor
(*BatteryStatus)(nil), // 7: ambient.weather.BatteryStatus
(*timestamppb.Timestamp)(nil), // 8: google.protobuf.Timestamp
}
var file_weather_weather_proto_depIdxs = []int32{
4, // 0: ambient.weather.CountWeatherUpdatesRequest.opts:type_name -> ambient.weather.GetWeatherOpts
4, // 1: ambient.weather.GetWeatherRequest.opts:type_name -> ambient.weather.GetWeatherOpts
8, // 2: ambient.weather.GetWeatherResponse.last_updated:type_name -> google.protobuf.Timestamp
5, // 3: ambient.weather.GetWeatherResponse.weather_updates:type_name -> ambient.weather.WeatherUpdate
7, // 4: ambient.weather.WeatherUpdate.batteries:type_name -> ambient.weather.BatteryStatus
6, // 5: ambient.weather.WeatherUpdate.temp_humidity_sensors:type_name -> ambient.weather.TempHumiditySensor
8, // 6: ambient.weather.WeatherUpdate.update_timestamp:type_name -> google.protobuf.Timestamp
8, // 7: ambient.weather.WeatherUpdate.lightning_last_time:type_name -> google.protobuf.Timestamp
8, // [8:8] is the sub-list for method output_type
8, // [8:8] is the sub-list for method input_type
8, // [8:8] is the sub-list for extension type_name
8, // [8:8] is the sub-list for extension extendee
0, // [0:8] is the sub-list for field type_name
}
func init() { file_weather_weather_proto_init() }
func file_weather_weather_proto_init() {
if File_weather_weather_proto != nil {
return
}
file_weather_weather_proto_msgTypes[2].OneofWrappers = []any{}
file_weather_weather_proto_msgTypes[4].OneofWrappers = []any{}
file_weather_weather_proto_msgTypes[5].OneofWrappers = []any{}
file_weather_weather_proto_msgTypes[6].OneofWrappers = []any{}
file_weather_weather_proto_msgTypes[7].OneofWrappers = []any{}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_weather_weather_proto_rawDesc), len(file_weather_weather_proto_rawDesc)),
NumEnums: 0,
NumMessages: 8,
NumExtensions: 0,
NumServices: 0,
},
GoTypes: file_weather_weather_proto_goTypes,
DependencyIndexes: file_weather_weather_proto_depIdxs,
MessageInfos: file_weather_weather_proto_msgTypes,
}.Build()
File_weather_weather_proto = out.File
file_weather_weather_proto_goTypes = nil
file_weather_weather_proto_depIdxs = nil
}

View File

@ -0,0 +1,44 @@
{
"swagger": "2.0",
"info": {
"title": "weather/weather.proto",
"version": "version not set"
},
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"paths": {},
"definitions": {
"protobufAny": {
"type": "object",
"properties": {
"@type": {
"type": "string"
}
},
"additionalProperties": {}
},
"rpcStatus": {
"type": "object",
"properties": {
"code": {
"type": "integer",
"format": "int32"
},
"message": {
"type": "string"
},
"details": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/protobufAny"
}
}
}
}
}
}

View File

@ -0,0 +1,74 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
// protoc v5.29.3
// source: weather/weather_service.proto
package weather
import (
_ "google.golang.org/genproto/googleapis/api/annotations"
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
var File_weather_weather_service_proto protoreflect.FileDescriptor
const file_weather_weather_service_proto_rawDesc = "" +
"\n" +
"\x1dweather/weather_service.proto\x12\x0fambient.weather\x1a\x1cgoogle/api/annotations.proto\x1a\x15weather/weather.proto2\x8e\x02\n" +
"\x1aAmbientLocalWeatherService\x12j\n" +
"\n" +
"GetWeather\x12\".ambient.weather.GetWeatherRequest\x1a#.ambient.weather.GetWeatherResponse\"\x13\x82\xd3\xe4\x93\x02\r\x12\v/v1/weather\x12\x83\x01\n" +
"\x13CountWeatherUpdates\x12+.ambient.weather.CountWeatherUpdatesRequest\x1a,.ambient.weather.CountWeatherUpdatesResponse\"\x11\x82\xd3\xe4\x93\x02\v\x12\t/v1/countBTZRgitea.libretechconsulting.com/rmcguire/ambient-local-exporter/api/v1alpha1/weatherb\x06proto3"
var file_weather_weather_service_proto_goTypes = []any{
(*GetWeatherRequest)(nil), // 0: ambient.weather.GetWeatherRequest
(*CountWeatherUpdatesRequest)(nil), // 1: ambient.weather.CountWeatherUpdatesRequest
(*GetWeatherResponse)(nil), // 2: ambient.weather.GetWeatherResponse
(*CountWeatherUpdatesResponse)(nil), // 3: ambient.weather.CountWeatherUpdatesResponse
}
var file_weather_weather_service_proto_depIdxs = []int32{
0, // 0: ambient.weather.AmbientLocalWeatherService.GetWeather:input_type -> ambient.weather.GetWeatherRequest
1, // 1: ambient.weather.AmbientLocalWeatherService.CountWeatherUpdates:input_type -> ambient.weather.CountWeatherUpdatesRequest
2, // 2: ambient.weather.AmbientLocalWeatherService.GetWeather:output_type -> ambient.weather.GetWeatherResponse
3, // 3: ambient.weather.AmbientLocalWeatherService.CountWeatherUpdates:output_type -> ambient.weather.CountWeatherUpdatesResponse
2, // [2:4] is the sub-list for method output_type
0, // [0:2] is the sub-list for method input_type
0, // [0:0] is the sub-list for extension type_name
0, // [0:0] is the sub-list for extension extendee
0, // [0:0] is the sub-list for field type_name
}
func init() { file_weather_weather_service_proto_init() }
func file_weather_weather_service_proto_init() {
if File_weather_weather_service_proto != nil {
return
}
file_weather_weather_proto_init()
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_weather_weather_service_proto_rawDesc), len(file_weather_weather_service_proto_rawDesc)),
NumEnums: 0,
NumMessages: 0,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_weather_weather_service_proto_goTypes,
DependencyIndexes: file_weather_weather_service_proto_depIdxs,
}.Build()
File_weather_weather_service_proto = out.File
file_weather_weather_service_proto_goTypes = nil
file_weather_weather_service_proto_depIdxs = nil
}

View File

@ -0,0 +1,235 @@
// Code generated by protoc-gen-grpc-gateway. DO NOT EDIT.
// source: weather/weather_service.proto
/*
Package weather is a reverse proxy.
It translates gRPC into RESTful JSON APIs.
*/
package weather
import (
"context"
"errors"
"io"
"net/http"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"github.com/grpc-ecosystem/grpc-gateway/v2/utilities"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/grpclog"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
)
// Suppress "imported and not used" errors
var (
_ codes.Code
_ io.Reader
_ status.Status
_ = errors.New
_ = runtime.String
_ = utilities.NewDoubleArray
_ = metadata.Join
)
var filter_AmbientLocalWeatherService_GetWeather_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)}
func request_AmbientLocalWeatherService_GetWeather_0(ctx context.Context, marshaler runtime.Marshaler, client AmbientLocalWeatherServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq GetWeatherRequest
metadata runtime.ServerMetadata
)
io.Copy(io.Discard, req.Body)
if err := req.ParseForm(); err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_AmbientLocalWeatherService_GetWeather_0); err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := client.GetWeather(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
return msg, metadata, err
}
func local_request_AmbientLocalWeatherService_GetWeather_0(ctx context.Context, marshaler runtime.Marshaler, server AmbientLocalWeatherServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq GetWeatherRequest
metadata runtime.ServerMetadata
)
if err := req.ParseForm(); err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_AmbientLocalWeatherService_GetWeather_0); err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := server.GetWeather(ctx, &protoReq)
return msg, metadata, err
}
var filter_AmbientLocalWeatherService_CountWeatherUpdates_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)}
func request_AmbientLocalWeatherService_CountWeatherUpdates_0(ctx context.Context, marshaler runtime.Marshaler, client AmbientLocalWeatherServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq CountWeatherUpdatesRequest
metadata runtime.ServerMetadata
)
io.Copy(io.Discard, req.Body)
if err := req.ParseForm(); err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_AmbientLocalWeatherService_CountWeatherUpdates_0); err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := client.CountWeatherUpdates(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
return msg, metadata, err
}
func local_request_AmbientLocalWeatherService_CountWeatherUpdates_0(ctx context.Context, marshaler runtime.Marshaler, server AmbientLocalWeatherServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq CountWeatherUpdatesRequest
metadata runtime.ServerMetadata
)
if err := req.ParseForm(); err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_AmbientLocalWeatherService_CountWeatherUpdates_0); err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := server.CountWeatherUpdates(ctx, &protoReq)
return msg, metadata, err
}
// RegisterAmbientLocalWeatherServiceHandlerServer registers the http handlers for service AmbientLocalWeatherService to "mux".
// UnaryRPC :call AmbientLocalWeatherServiceServer directly.
// StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906.
// Note that using this registration option will cause many gRPC library features to stop working. Consider using RegisterAmbientLocalWeatherServiceHandlerFromEndpoint instead.
// GRPC interceptors will not work for this type of registration. To use interceptors, you must use the "runtime.WithMiddlewares" option in the "runtime.NewServeMux" call.
func RegisterAmbientLocalWeatherServiceHandlerServer(ctx context.Context, mux *runtime.ServeMux, server AmbientLocalWeatherServiceServer) error {
mux.Handle(http.MethodGet, pattern_AmbientLocalWeatherService_GetWeather_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
var stream runtime.ServerTransportStream
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/ambient.weather.AmbientLocalWeatherService/GetWeather", runtime.WithHTTPPathPattern("/v1/weather"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := local_request_AmbientLocalWeatherService_GetWeather_0(annotatedContext, inboundMarshaler, server, req, pathParams)
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_AmbientLocalWeatherService_GetWeather_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodGet, pattern_AmbientLocalWeatherService_CountWeatherUpdates_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
var stream runtime.ServerTransportStream
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/ambient.weather.AmbientLocalWeatherService/CountWeatherUpdates", runtime.WithHTTPPathPattern("/v1/count"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := local_request_AmbientLocalWeatherService_CountWeatherUpdates_0(annotatedContext, inboundMarshaler, server, req, pathParams)
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_AmbientLocalWeatherService_CountWeatherUpdates_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
return nil
}
// RegisterAmbientLocalWeatherServiceHandlerFromEndpoint is same as RegisterAmbientLocalWeatherServiceHandler but
// automatically dials to "endpoint" and closes the connection when "ctx" gets done.
func RegisterAmbientLocalWeatherServiceHandlerFromEndpoint(ctx context.Context, mux *runtime.ServeMux, endpoint string, opts []grpc.DialOption) (err error) {
conn, err := grpc.NewClient(endpoint, opts...)
if err != nil {
return err
}
defer func() {
if err != nil {
if cerr := conn.Close(); cerr != nil {
grpclog.Errorf("Failed to close conn to %s: %v", endpoint, cerr)
}
return
}
go func() {
<-ctx.Done()
if cerr := conn.Close(); cerr != nil {
grpclog.Errorf("Failed to close conn to %s: %v", endpoint, cerr)
}
}()
}()
return RegisterAmbientLocalWeatherServiceHandler(ctx, mux, conn)
}
// RegisterAmbientLocalWeatherServiceHandler registers the http handlers for service AmbientLocalWeatherService to "mux".
// The handlers forward requests to the grpc endpoint over "conn".
func RegisterAmbientLocalWeatherServiceHandler(ctx context.Context, mux *runtime.ServeMux, conn *grpc.ClientConn) error {
return RegisterAmbientLocalWeatherServiceHandlerClient(ctx, mux, NewAmbientLocalWeatherServiceClient(conn))
}
// RegisterAmbientLocalWeatherServiceHandlerClient registers the http handlers for service AmbientLocalWeatherService
// to "mux". The handlers forward requests to the grpc endpoint over the given implementation of "AmbientLocalWeatherServiceClient".
// Note: the gRPC framework executes interceptors within the gRPC handler. If the passed in "AmbientLocalWeatherServiceClient"
// doesn't go through the normal gRPC flow (creating a gRPC client etc.) then it will be up to the passed in
// "AmbientLocalWeatherServiceClient" to call the correct interceptors. This client ignores the HTTP middlewares.
func RegisterAmbientLocalWeatherServiceHandlerClient(ctx context.Context, mux *runtime.ServeMux, client AmbientLocalWeatherServiceClient) error {
mux.Handle(http.MethodGet, pattern_AmbientLocalWeatherService_GetWeather_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/ambient.weather.AmbientLocalWeatherService/GetWeather", runtime.WithHTTPPathPattern("/v1/weather"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := request_AmbientLocalWeatherService_GetWeather_0(annotatedContext, inboundMarshaler, client, req, pathParams)
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_AmbientLocalWeatherService_GetWeather_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodGet, pattern_AmbientLocalWeatherService_CountWeatherUpdates_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/ambient.weather.AmbientLocalWeatherService/CountWeatherUpdates", runtime.WithHTTPPathPattern("/v1/count"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := request_AmbientLocalWeatherService_CountWeatherUpdates_0(annotatedContext, inboundMarshaler, client, req, pathParams)
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_AmbientLocalWeatherService_CountWeatherUpdates_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
return nil
}
var (
pattern_AmbientLocalWeatherService_GetWeather_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"v1", "weather"}, ""))
pattern_AmbientLocalWeatherService_CountWeatherUpdates_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"v1", "count"}, ""))
)
var (
forward_AmbientLocalWeatherService_GetWeather_0 = runtime.ForwardResponseMessage
forward_AmbientLocalWeatherService_CountWeatherUpdates_0 = runtime.ForwardResponseMessage
)

View File

@ -0,0 +1,327 @@
{
"swagger": "2.0",
"info": {
"title": "weather/weather_service.proto",
"version": "version not set"
},
"tags": [
{
"name": "AmbientLocalWeatherService"
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"paths": {
"/v1/count": {
"get": {
"operationId": "AmbientLocalWeatherService_CountWeatherUpdates",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/weatherCountWeatherUpdatesResponse"
}
},
"default": {
"description": "An unexpected error response.",
"schema": {
"$ref": "#/definitions/rpcStatus"
}
}
},
"parameters": [
{
"name": "opts.stationName",
"in": "query",
"required": false,
"type": "string"
},
{
"name": "opts.stationType",
"in": "query",
"required": false,
"type": "string"
}
],
"tags": [
"AmbientLocalWeatherService"
]
}
},
"/v1/weather": {
"get": {
"operationId": "AmbientLocalWeatherService_GetWeather",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/weatherGetWeatherResponse"
}
},
"default": {
"description": "An unexpected error response.",
"schema": {
"$ref": "#/definitions/rpcStatus"
}
}
},
"parameters": [
{
"name": "opts.stationName",
"in": "query",
"required": false,
"type": "string"
},
{
"name": "opts.stationType",
"in": "query",
"required": false,
"type": "string"
},
{
"name": "limit",
"in": "query",
"required": false,
"type": "integer",
"format": "int32"
}
],
"tags": [
"AmbientLocalWeatherService"
]
}
}
},
"definitions": {
"protobufAny": {
"type": "object",
"properties": {
"@type": {
"type": "string"
}
},
"additionalProperties": {}
},
"rpcStatus": {
"type": "object",
"properties": {
"code": {
"type": "integer",
"format": "int32"
},
"message": {
"type": "string"
},
"details": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/protobufAny"
}
}
}
},
"weatherBatteryStatus": {
"type": "object",
"properties": {
"component": {
"type": "string"
},
"status": {
"type": "integer",
"format": "int32"
}
},
"title": "Represents battery status for different components"
},
"weatherCountWeatherUpdatesResponse": {
"type": "object",
"properties": {
"count": {
"type": "integer",
"format": "int32"
}
}
},
"weatherGetWeatherOpts": {
"type": "object",
"properties": {
"stationName": {
"type": "string"
},
"stationType": {
"type": "string"
}
}
},
"weatherGetWeatherResponse": {
"type": "object",
"properties": {
"lastUpdated": {
"type": "string",
"format": "date-time"
},
"weatherUpdates": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/weatherWeatherUpdate"
}
}
}
},
"weatherTempHumiditySensor": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"tempF": {
"type": "number",
"format": "double"
},
"humidity": {
"type": "integer",
"format": "int32"
}
},
"title": "Represents a temperature and humidity sensor"
},
"weatherWeatherUpdate": {
"type": "object",
"properties": {
"stationName": {
"type": "string"
},
"stationType": {
"type": "string"
},
"stationId": {
"type": "string"
},
"tempOutdoorF": {
"type": "number",
"format": "double"
},
"tempIndoorF": {
"type": "number",
"format": "double"
},
"humidityOutdoor": {
"type": "integer",
"format": "int32"
},
"humidityIndoor": {
"type": "integer",
"format": "int32"
},
"windSpeedMph": {
"type": "number",
"format": "double"
},
"windGustMph": {
"type": "number",
"format": "double"
},
"maxDailyGust": {
"type": "number",
"format": "double"
},
"windDir": {
"type": "integer",
"format": "int32"
},
"windDirAvg10m": {
"type": "integer",
"format": "int32"
},
"uv": {
"type": "integer",
"format": "int32"
},
"solarRadiation": {
"type": "number",
"format": "double"
},
"hourlyRainIn": {
"type": "number",
"format": "double"
},
"eventRainIn": {
"type": "number",
"format": "double"
},
"dailyRainIn": {
"type": "number",
"format": "double"
},
"weeklyRainIn": {
"type": "number",
"format": "double"
},
"monthlyRainIn": {
"type": "number",
"format": "double"
},
"yearlyRainIn": {
"type": "number",
"format": "double"
},
"totalRainIn": {
"type": "number",
"format": "double"
},
"batteries": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/weatherBatteryStatus"
}
},
"baromRelativeIn": {
"type": "number",
"format": "double"
},
"baromAbsoluteIn": {
"type": "number",
"format": "double"
},
"dewPointF": {
"type": "number",
"format": "double"
},
"windChillF": {
"type": "number",
"format": "double"
},
"tempHumiditySensors": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/weatherTempHumiditySensor"
}
},
"updateTimestamp": {
"type": "string",
"format": "date-time"
},
"lightningDay": {
"type": "integer",
"format": "int32"
},
"lightningDistance": {
"type": "integer",
"format": "int32"
},
"lightningLastTime": {
"type": "string",
"format": "date-time"
}
}
}
}
}

View File

@ -0,0 +1,160 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v5.29.3
// source: weather/weather_service.proto
package weather
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
AmbientLocalWeatherService_GetWeather_FullMethodName = "/ambient.weather.AmbientLocalWeatherService/GetWeather"
AmbientLocalWeatherService_CountWeatherUpdates_FullMethodName = "/ambient.weather.AmbientLocalWeatherService/CountWeatherUpdates"
)
// AmbientLocalWeatherServiceClient is the client API for AmbientLocalWeatherService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type AmbientLocalWeatherServiceClient interface {
GetWeather(ctx context.Context, in *GetWeatherRequest, opts ...grpc.CallOption) (*GetWeatherResponse, error)
CountWeatherUpdates(ctx context.Context, in *CountWeatherUpdatesRequest, opts ...grpc.CallOption) (*CountWeatherUpdatesResponse, error)
}
type ambientLocalWeatherServiceClient struct {
cc grpc.ClientConnInterface
}
func NewAmbientLocalWeatherServiceClient(cc grpc.ClientConnInterface) AmbientLocalWeatherServiceClient {
return &ambientLocalWeatherServiceClient{cc}
}
func (c *ambientLocalWeatherServiceClient) GetWeather(ctx context.Context, in *GetWeatherRequest, opts ...grpc.CallOption) (*GetWeatherResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetWeatherResponse)
err := c.cc.Invoke(ctx, AmbientLocalWeatherService_GetWeather_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *ambientLocalWeatherServiceClient) CountWeatherUpdates(ctx context.Context, in *CountWeatherUpdatesRequest, opts ...grpc.CallOption) (*CountWeatherUpdatesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CountWeatherUpdatesResponse)
err := c.cc.Invoke(ctx, AmbientLocalWeatherService_CountWeatherUpdates_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// AmbientLocalWeatherServiceServer is the server API for AmbientLocalWeatherService service.
// All implementations must embed UnimplementedAmbientLocalWeatherServiceServer
// for forward compatibility.
type AmbientLocalWeatherServiceServer interface {
GetWeather(context.Context, *GetWeatherRequest) (*GetWeatherResponse, error)
CountWeatherUpdates(context.Context, *CountWeatherUpdatesRequest) (*CountWeatherUpdatesResponse, error)
mustEmbedUnimplementedAmbientLocalWeatherServiceServer()
}
// UnimplementedAmbientLocalWeatherServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedAmbientLocalWeatherServiceServer struct{}
func (UnimplementedAmbientLocalWeatherServiceServer) GetWeather(context.Context, *GetWeatherRequest) (*GetWeatherResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetWeather not implemented")
}
func (UnimplementedAmbientLocalWeatherServiceServer) CountWeatherUpdates(context.Context, *CountWeatherUpdatesRequest) (*CountWeatherUpdatesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CountWeatherUpdates not implemented")
}
func (UnimplementedAmbientLocalWeatherServiceServer) mustEmbedUnimplementedAmbientLocalWeatherServiceServer() {
}
func (UnimplementedAmbientLocalWeatherServiceServer) testEmbeddedByValue() {}
// UnsafeAmbientLocalWeatherServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to AmbientLocalWeatherServiceServer will
// result in compilation errors.
type UnsafeAmbientLocalWeatherServiceServer interface {
mustEmbedUnimplementedAmbientLocalWeatherServiceServer()
}
func RegisterAmbientLocalWeatherServiceServer(s grpc.ServiceRegistrar, srv AmbientLocalWeatherServiceServer) {
// If the following call pancis, it indicates UnimplementedAmbientLocalWeatherServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&AmbientLocalWeatherService_ServiceDesc, srv)
}
func _AmbientLocalWeatherService_GetWeather_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetWeatherRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AmbientLocalWeatherServiceServer).GetWeather(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AmbientLocalWeatherService_GetWeather_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AmbientLocalWeatherServiceServer).GetWeather(ctx, req.(*GetWeatherRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AmbientLocalWeatherService_CountWeatherUpdates_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CountWeatherUpdatesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AmbientLocalWeatherServiceServer).CountWeatherUpdates(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AmbientLocalWeatherService_CountWeatherUpdates_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AmbientLocalWeatherServiceServer).CountWeatherUpdates(ctx, req.(*CountWeatherUpdatesRequest))
}
return interceptor(ctx, in, info, handler)
}
// AmbientLocalWeatherService_ServiceDesc is the grpc.ServiceDesc for AmbientLocalWeatherService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var AmbientLocalWeatherService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "ambient.weather.AmbientLocalWeatherService",
HandlerType: (*AmbientLocalWeatherServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "GetWeather",
Handler: _AmbientLocalWeatherService_GetWeather_Handler,
},
{
MethodName: "CountWeatherUpdates",
Handler: _AmbientLocalWeatherService_CountWeatherUpdates_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "weather/weather_service.proto",
}

75
app.go Normal file
View File

@ -0,0 +1,75 @@
package main
import (
"context"
"errors"
"gitea.libretechconsulting.com/rmcguire/go-app/pkg/app"
grpcopts "gitea.libretechconsulting.com/rmcguire/go-app/pkg/srv/grpc/opts"
httpopts "gitea.libretechconsulting.com/rmcguire/go-app/pkg/srv/http/opts"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
weatherpb "gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/api/v1alpha1/weather"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/ambient"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/ambient/ambientgrpc"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/ambient/ambienthttp"
)
func prepareApp(ctx context.Context, aw *ambient.AmbientWeather) *app.App {
// Load ambient routes into app
awApp := &app.App{
AppContext: ctx,
// HTTP Endpoints for Ambient Weather Stations
HTTP: &httpopts.AppHTTP{
Funcs: []httpopts.HTTPFunc{
{
Path: "/weatherstation/updateweatherstation.php",
HandlerFunc: aw.GetWundergroundHandlerFunc(ctx),
},
{
Path: "/data/report",
HandlerFunc: aw.GetAWNHandlerFunc(ctx),
},
},
// HTTP Listener that fixes broken requests generated by
// some versions of awn firmware
CustomListener: ambienthttp.NewAWNMutatingListener(ctx,
aw.Config.HTTP.Listen), // Necessary to fix certain bad AWN firmware
HealthChecks: []httpopts.HealthCheckFunc{
// Check recorder health
// Only redis implements this
func(ctx context.Context) error {
var errs error
errs = errors.Join(errs, aw.GetRecorder().Ping(ctx))
return errs
},
},
},
// GRPC Service for retrieving weather
GRPC: &grpcopts.AppGRPC{
GRPCDialOpts: []grpc.DialOption{
grpc.WithTransportCredentials(insecure.NewCredentials()),
},
GRPCGatewayOpts: aw.GetGatewayOpts(),
Services: []*grpcopts.GRPCService{
{
Name: "Weather Service",
Type: &weatherpb.AmbientLocalWeatherService_ServiceDesc,
Service: ambientgrpc.NewGRPCWeather(ctx, aw.GetRecorder()),
GwRegistrationFuncs: []grpcopts.GwRegistrationFunc{
// HTTP API via grpc-gateway. Update path in app grpc config
// to change default (/grpcapi)
weatherpb.RegisterAmbientLocalWeatherServiceHandler,
},
},
},
},
}
return awApp
}

View File

@ -0,0 +1,21 @@
name: Ambient Local Exporter
services:
ambient-local-exporter:
image: image.libretechconsulting.com/dev/ambient-local-exporter:latest
ports:
- 8080:8080
environment:
APP_NAME: ambient-local-exporter
APP_LOG_LEVEL: debug ## For testing only
APP_LOG_FORMAT: json ## console, json
APP_LOG_TIME_FORMAT: rfc3339 ## long, short, unix, rfc3339, off
APP_HTTP_LISTEN: 0.0.0.0:8080
APP_HTTP_READ_TIMEOUT: 10s
APP_HTTP_WRITE_TIMEOUT: 10s
APP_HTTP_IDLE_TIMEOUT: 30s
APP_HTTP_LOG_REQUESTS: true
APP_OTEL_STDOUT_ENABLED: false
APP_OTEL_METRIC_INTERVAL_SECS: 30
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel.libretechconsulting.com:4317 # Set to your otel collector
OTEL_SERVICE_NAME: ambient-local-exporter
OTEL_RESOURCE_ATTRIBUTES: "env=development,service.version=(devel)"

File diff suppressed because it is too large Load Diff

238
contrib/schema.json Normal file
View File

@ -0,0 +1,238 @@
{
"definitions": {
"ConfigGRPCConfig": {
"properties": {
"enableGRPCGateway": {
"default": true,
"type": "boolean"
},
"enableInstrumentation": {
"type": "boolean"
},
"enableReflection": {
"type": "boolean"
},
"enabled": {
"type": "boolean"
},
"grpcGatewayPath": {
"default": "/grpc-api",
"type": "string"
},
"listen": {
"type": "string"
},
"logRequests": {
"type": "boolean"
}
},
"type": "object"
},
"ConfigHTTPConfig": {
"properties": {
"enabled": {
"type": "boolean"
},
"idleTimeout": {
"type": "string"
},
"listen": {
"type": "string"
},
"logRequests": {
"type": "boolean"
},
"readTimeout": {
"type": "string"
},
"writeTimeout": {
"type": "string"
}
},
"type": "object"
},
"ConfigLogConfig": {
"properties": {
"enabled": {
"type": "boolean"
},
"format": {
"type": "string"
},
"level": {
"type": "string"
},
"output": {
"type": "string"
},
"timeFormat": {
"type": "string"
}
},
"type": "object"
},
"ConfigOTELConfig": {
"properties": {
"enabled": {
"type": "boolean"
},
"metricIntervalSecs": {
"type": "integer"
},
"prometheusEnabled": {
"type": "boolean"
},
"prometheusPath": {
"type": "string"
},
"stdoutEnabled": {
"type": "boolean"
}
},
"type": "object"
},
"ConfigRecorderConfig": {
"properties": {
"keepLast": {
"default": 120,
"type": "integer"
},
"redisConfig": {
"$ref": "#/definitions/ConfigRedisConfig"
},
"type": {
"enum": [
"memory",
"redis",
"noop"
],
"type": "string"
}
},
"type": "object"
},
"ConfigRedisConfig": {
"properties": {
"redisDB": {
"default": 0,
"type": "integer"
},
"redisHost": {
"default": "127.0.0.1",
"type": "string"
},
"redisKeyTTL": {
"default": "24h",
"type": "string"
},
"redisPassword": {
"type": "string"
},
"redisPort": {
"default": 6379,
"type": "integer"
},
"redisTLS": {
"default": false,
"type": "boolean"
},
"redisTLSInsecure": {
"default": false,
"type": "boolean"
},
"redisUser": {
"type": "string"
}
},
"type": "object"
},
"ConfigWeatherStation": {
"properties": {
"awnPassKey": {
"type": "string"
},
"dropMetrics": {
"items": {
"type": "string"
},
"type": "array"
},
"equipment": {
"type": "string"
},
"keepMetrics": {
"items": {
"type": "string"
},
"type": "array"
},
"name": {
"type": "string"
},
"proxyToAWN": {
"type": "boolean"
},
"proxyToWunderground": {
"type": "boolean"
},
"sensorMappings": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"wundergroundID": {
"type": "string"
},
"wundergroundPassword": {
"type": "string"
}
},
"type": "object"
}
},
"properties": {
"environment": {
"type": "string"
},
"grpc": {
"$ref": "#/definitions/ConfigGRPCConfig"
},
"http": {
"$ref": "#/definitions/ConfigHTTPConfig"
},
"logging": {
"$ref": "#/definitions/ConfigLogConfig"
},
"metricPrefix": {
"default": "weather",
"type": "string"
},
"name": {
"type": "string"
},
"otel": {
"$ref": "#/definitions/ConfigOTELConfig"
},
"recorderConfig": {
"$ref": "#/definitions/ConfigRecorderConfig"
},
"updatesToKeep": {
"default": 1,
"type": [
"null",
"integer"
]
},
"version": {
"type": "string"
},
"weatherStations": {
"items": {
"$ref": "#/definitions/ConfigWeatherStation"
},
"type": "array"
}
},
"type": "object"
}

89
contrib/wu_sample.json Normal file
View File

@ -0,0 +1,89 @@
{
"PASSKEY": [
"DE:AD:BE:EF:BE:EF"
],
"baromabsin": [
"29.025"
],
"baromrelin": [
"30.005"
],
"batt1": [
"1"
],
"battin": [
"1"
],
"battout": [
"1"
],
"battrain": [
"1"
],
"dailyrainin": [
"0.000"
],
"dateutc": [
"2025-03-23 14:07:59"
],
"eventrainin": [
"0.000"
],
"hourlyrainin": [
"0.000"
],
"humidity": [
"55"
],
"humidity1": [
"53"
],
"humidityin": [
"36"
],
"maxdailygust": [
"20.36"
],
"monthlyrainin": [
"1.969"
],
"solarradiation": [
"142.15"
],
"stationtype": [
"WeatherHub_V1.0.2"
],
"temp1f": [
"-2.74"
],
"tempf": [
"33.98"
],
"tempinf": [
"70.52"
],
"totalrainin": [
"2.421"
],
"uv": [
"1"
],
"weeklyrainin": [
"0.000"
],
"winddir": [
"117"
],
"winddir_avg10m": [
"127"
],
"windgustmph": [
"17.22"
],
"windspeedmph": [
"10.29"
],
"yearlyrainin": [
"2.421"
]
}

9
contrib/wu_test_sample.sh Executable file
View File

@ -0,0 +1,9 @@
#!env sh
SCRIPT_DIR=$( dirname $0 )
JSON_FILE="${SCRIPT_DIR}/wu_sample.json"
BASE_URL="http://127.0.0.1:8080/data/report"
# Convert JSON to query parameters
QUERY_STRING=$(jq -r 'to_entries | map("\(.key)=\(.value[0] | @uri)") | join("&")' "$JSON_FILE")
curl -G --data "$QUERY_STRING" "$BASE_URL"

View File

@ -1,9 +1,15 @@
name: Ambient Local Exporter
services:
ambient-local-exporter:
image: image.libretechconsulting.com/dev/ambient-local-exporter:latest
build: .
ports:
- 8080:8080
- 8080:8080 # HTTP
- 8081:8081 # GRPC
volumes:
- ./config.yaml:/app/config.yaml
command:
- -config
- /app/config.yaml
environment:
APP_NAME: ambient-local-exporter
APP_LOG_LEVEL: debug ## For testing only
@ -16,6 +22,6 @@ services:
APP_HTTP_LOG_REQUESTS: true
APP_OTEL_STDOUT_ENABLED: false
APP_OTEL_METRIC_INTERVAL_SECS: 30
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel.libretechconsulting.com:4317 # Set to your otel collector
OTEL_EXPORTER_OTLP_ENDPOINT: https://otel.libretechconsulting.com:4317 # Set to your otel collector
OTEL_SERVICE_NAME: ambient-local-exporter
OTEL_RESOURCE_ATTRIBUTES: "env=development,service.version=(devel)"

79
go.mod
View File

@ -1,14 +1,22 @@
module gitea.libretechconsulting.com/rmcguire/ambient-weather-local-exporter
module gitea.libretechconsulting.com/rmcguire/ambient-local-exporter
go 1.23.4
go 1.24.1
require (
gitea.libretechconsulting.com/rmcguire/go-app v0.2.0
gitea.libretechconsulting.com/rmcguire/go-app v0.9.1
github.com/go-resty/resty/v2 v2.16.5
github.com/gorilla/schema v1.4.1
github.com/rs/zerolog v1.33.0
go.opentelemetry.io/otel v1.33.0
go.opentelemetry.io/otel/metric v1.33.0
golang.org/x/sys v0.29.0
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3
github.com/redis/go-redis/v9 v9.7.3
github.com/rs/zerolog v1.34.0
go.opentelemetry.io/otel v1.35.0
go.opentelemetry.io/otel/metric v1.35.0
go.opentelemetry.io/otel/trace v1.35.0
golang.org/x/sys v0.31.0
google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463
google.golang.org/grpc v1.71.0
google.golang.org/protobuf v1.36.6
k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e
)
require (
@ -16,36 +24,47 @@ require (
github.com/caarlos0/env/v11 v11.3.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.1 // indirect
github.com/grpc-ecosystem/grpc-gateway v1.16.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_golang v1.20.5 // indirect
github.com/prometheus/client_golang v1.21.1 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.61.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/prometheus/common v0.63.0 // indirect
github.com/prometheus/procfs v0.16.0 // indirect
github.com/swaggest/jsonschema-go v0.3.73 // indirect
github.com/swaggest/refl v1.3.1 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.33.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.55.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.33.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.33.0 // indirect
go.opentelemetry.io/otel/sdk v1.33.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.33.0 // indirect
go.opentelemetry.io/otel/trace v1.33.0 // indirect
go.opentelemetry.io/proto/otlp v1.4.0 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/text v0.21.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250102185135-69823020774d // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d // indirect
google.golang.org/grpc v1.69.2 // indirect
google.golang.org/protobuf v1.36.1 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.57.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0 // indirect
go.opentelemetry.io/otel/sdk v1.35.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.35.0 // indirect
go.opentelemetry.io/proto/otlp v1.5.0 // indirect
golang.org/x/net v0.37.0 // indirect
golang.org/x/text v0.23.0 // indirect
google.golang.org/genproto v0.0.0-20250313205543-e70fdf4c4cb4 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
tool (
github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway
github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2
google.golang.org/grpc/cmd/protoc-gen-go-grpc
google.golang.org/protobuf/cmd/protoc-gen-go
)

238
go.sum
View File

@ -1,46 +1,91 @@
gitea.libretechconsulting.com/rmcguire/go-app v0.1.3 h1:EwmEJLpN+rQjJ5stGEkZsqEDa5F/YnDAEeqJB9XlFn4=
gitea.libretechconsulting.com/rmcguire/go-app v0.1.3/go.mod h1:wHOWh4O4AMDATQ3WEUYjq5a5bnICPBpu5G6BsNxqN38=
gitea.libretechconsulting.com/rmcguire/go-app v0.2.0 h1:pOm/PysC0IWPuEbmEjNSHHa8Qc5OhuoksYExcuJMFE4=
gitea.libretechconsulting.com/rmcguire/go-app v0.2.0/go.mod h1:wHOWh4O4AMDATQ3WEUYjq5a5bnICPBpu5G6BsNxqN38=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
gitea.libretechconsulting.com/rmcguire/go-app v0.7.0 h1:yhRRwV/dxN4Bey1Qv8/rHHV5QvALrZuDJFI+zFPc7sU=
gitea.libretechconsulting.com/rmcguire/go-app v0.7.0/go.mod h1:EM3Z9QcRD+b7UlKGA9y37ppfUBC0Reyf5sYEC6vFZcY=
gitea.libretechconsulting.com/rmcguire/go-app v0.8.0 h1:PC8vlpr8tLoRwIHsq5nvOYzz9VqGhxrecAzWuwDXZVo=
gitea.libretechconsulting.com/rmcguire/go-app v0.8.0/go.mod h1:W6YHFSGf4nJrgs9DqEaw+3J6ufIARsr1zpOs/V6gRTQ=
gitea.libretechconsulting.com/rmcguire/go-app v0.8.1 h1:pnmFJnY77dsh84zvVcHqFuQgkxy3hjCQ8tFRGv/aJOw=
gitea.libretechconsulting.com/rmcguire/go-app v0.8.1/go.mod h1:W6YHFSGf4nJrgs9DqEaw+3J6ufIARsr1zpOs/V6gRTQ=
gitea.libretechconsulting.com/rmcguire/go-app v0.9.0 h1:ZOBdVk2EehGCX6K3pdT5Dy07bMiUR7VTifE+n0ODfak=
gitea.libretechconsulting.com/rmcguire/go-app v0.9.0/go.mod h1:W6YHFSGf4nJrgs9DqEaw+3J6ufIARsr1zpOs/V6gRTQ=
gitea.libretechconsulting.com/rmcguire/go-app v0.9.1 h1:QihWX1YeCqttpAjXq6nHWvNk1MpIhuW8bSO3G+fbsRg=
gitea.libretechconsulting.com/rmcguire/go-app v0.9.1/go.mod h1:W6YHFSGf4nJrgs9DqEaw+3J6ufIARsr1zpOs/V6gRTQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bool64/dev v0.2.39 h1:kP8DnMGlWXhGYJEZE/J0l/gVBdbuhoPGL+MJG4QbofE=
github.com/bool64/dev v0.2.39/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg=
github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E=
github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM=
github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.1 h1:KcFzXwzM/kGhIRHvc8jdixfIJjVzuUJdnv+5xsPutog=
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.1/go.mod h1:qOchhhIlmRcqk/O9uCo/puJlyo07YINaIqdZfZG3Jkc=
github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo=
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc=
github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@ -50,70 +95,143 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8m
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.61.0 h1:3gv/GThfX0cV2lpO7gkTUwZru38mxevy90Bj8YFSRQQ=
github.com/prometheus/common v0.61.0/go.mod h1:zr29OCN/2BsJRaFwG8QOBr41D6kkchKbpeNH7pAjb/s=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k=
github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18=
github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM=
github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg=
github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ=
github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU=
github.com/swaggest/jsonschema-go v0.3.73 h1:gU1pBzF3pkZ1GDD3dRMdQoCjrA0sldJ+QcM7aSSPgvc=
github.com/swaggest/jsonschema-go v0.3.73/go.mod h1:qp+Ym2DIXHlHzch3HKz50gPf2wJhKOrAB/VYqLS2oJU=
github.com/swaggest/refl v1.3.1 h1:XGplEkYftR7p9cz1lsiwXMM2yzmOymTE9vneVVpaOh4=
github.com/swaggest/refl v1.3.1/go.mod h1:4uUVFVfPJ0NSX9FPwMPspeHos9wPFlCMGoPRllUbpvA=
github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA=
github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M=
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q=
go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw=
go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.33.0 h1:7F29RDmnlqk6B5d+sUqemt8TBfDqxryYW5gX6L74RFA=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.33.0/go.mod h1:ZiGDq7xwDMKmWDrN1XsXAj0iC7hns+2DhxBFSncNHSE=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 h1:5pojmb1U1AogINhN3SurB+zm/nIcusopeBNp42f45QM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0/go.mod h1:57gTHJSE5S1tqg+EKsLPlTWhpHMsWlVmer+LA926XiA=
go.opentelemetry.io/otel/exporters/prometheus v0.55.0 h1:sSPw658Lk2NWAv74lkD3B/RSDb+xRFx46GjkrL3VUZo=
go.opentelemetry.io/otel/exporters/prometheus v0.55.0/go.mod h1:nC00vyCmQixoeaxF6KNyP42II/RHa9UdruK02qBmHvI=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.33.0 h1:FiOTYABOX4tdzi8A0+mtzcsTmi6WBOxk66u0f1Mj9Gs=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.33.0/go.mod h1:xyo5rS8DgzV0Jtsht+LCEMwyiDbjpsxBpWETwFRF0/4=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.33.0 h1:W5AWUn/IVe8RFb5pZx1Uh9Laf/4+Qmm4kJL5zPuvR+0=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.33.0/go.mod h1:mzKxJywMNBdEX8TSJais3NnsVZUaJ+bAy6UxPTng2vk=
go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ=
go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M=
go.opentelemetry.io/otel/sdk v1.33.0 h1:iax7M131HuAm9QkZotNHEfstof92xM+N8sr3uHXc2IM=
go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM=
go.opentelemetry.io/otel/sdk/metric v1.33.0 h1:Gs5VK9/WUJhNXZgn8MR6ITatvAmKeIuCtNbsP3JkNqU=
go.opentelemetry.io/otel/sdk/metric v1.33.0/go.mod h1:dL5ykHZmm1B1nVRk9dDjChwDmt81MjVp3gLkQRwKf/Q=
go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s=
go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck=
go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg=
go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0 h1:QcFwRrZLc82r8wODjvyCbP7Ifp3UANaBSmhDSFjnqSc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0/go.mod h1:CXIWhUomyWBG/oY2/r/kLp6K/cmx9e/7DLpBuuGdLCA=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 h1:m639+BofXTvcY1q8CGs4ItwQarYtJPOWmVobfM1HpVI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0/go.mod h1:LjReUci/F4BUyv+y4dwnq3h/26iNOeC3wAIqgvTIZVo=
go.opentelemetry.io/otel/exporters/prometheus v0.57.0 h1:AHh/lAP1BHrY5gBwk8ncc25FXWm/gmmY3BX258z5nuk=
go.opentelemetry.io/otel/exporters/prometheus v0.57.0/go.mod h1:QpFWz1QxqevfjwzYdbMb4Y1NnlJvqSGwyuU0B4iuc9c=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0 h1:PB3Zrjs1sG1GBX51SXyTSoOTqcDglmsk7nT6tkKPb/k=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0/go.mod h1:U2R3XyVPzn0WX7wOIypPuptulsMcPDPs/oiSVOMVnHY=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0 h1:T0Ec2E+3YZf5bgTNQVet8iTDW7oIk03tXHq+wkwIDnE=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0/go.mod h1:30v2gqH+vYGJsesLWFov8u47EpYTcIQcBjKpI6pJThg=
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
google.golang.org/genproto/googleapis/api v0.0.0-20250102185135-69823020774d h1:H8tOf8XM88HvKqLTxe755haY6r1fqqzLbEnfrmLXlSA=
google.golang.org/genproto/googleapis/api v0.0.0-20250102185135-69823020774d/go.mod h1:2v7Z7gP2ZUOGsaFyxATQSRoBnKygqVq2Cwnvom7QiqY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d h1:xJJRGY7TJcvIlpSrN3K6LAWgNFUILlO+OMAqtg9aqnw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d/go.mod h1:3ENsm/5D1mzDyhpzeRi1NR784I0BcofWBoSc5QqqMK4=
google.golang.org/grpc v1.69.2 h1:U3S9QEtbXC0bYNvRtcoklF3xGtLViumSYxWykJS+7AU=
google.golang.org/grpc v1.69.2/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4=
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20250313205543-e70fdf4c4cb4 h1:kCjWYliqPA8g5z87mbjnf/cdgQqMzBfp9xYre5qKu2A=
google.golang.org/genproto v0.0.0-20250313205543-e70fdf4c4cb4/go.mod h1:SqIx1NV9hcvqdLHo7uNZDS5lrUJybQ3evo3+z/WBfA0=
google.golang.org/genproto/googleapis/api v0.0.0-20250313205543-e70fdf4c4cb4 h1:IFnXJq3UPB3oBREOodn1v1aGQeZYQclEmvWRMN0PSsY=
google.golang.org/genproto/googleapis/api v0.0.0-20250313205543-e70fdf4c4cb4/go.mod h1:c8q6Z6OCqnfVIqUFJkCzKcrj8eCvUrz+K4KRzSTuANg=
google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463 h1:hE3bRWtU6uceqlh4fhrSnUyjKHMKB9KrTLLG+bc0ddM=
google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463/go.mod h1:U90ffi8eUL9MwPcrJylN5+Mk2v3vuPDptd5yyNUiRR8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 h1:iK2jbkWL86DXjEx0qiHcRE9dE4/Ahua5k6V8OWFb//c=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=
google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1 h1:F29+wU6Ee6qgu9TddPgooOdaqsxTMunOoj8KA5yuS5A=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1/go.mod h1:5KF+wpkbTSbGcR9zteSqZV6fqFOWBl4Yde8En8MryZA=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e h1:KqK5c/ghOm8xkHYhlodbp6i6+r+ChV2vuAuVRdFbLro=
k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=

View File

@ -0,0 +1,23 @@
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*.orig
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/

View File

@ -0,0 +1,6 @@
dependencies:
- name: hull
repository: https://vidispine.github.io/hull
version: 1.32.2
digest: sha256:7b73a7f152916fed9842efe4f65081b1cda0fcebd8f36d27e48136b608ce305f
generated: "2025-03-08T12:22:41.343082-05:00"

View File

@ -0,0 +1,30 @@
apiVersion: v2
name: ambient-local-exporter
description: Cloud-free metrics exporter for ambient weather stations
# A chart can be either an 'application' or a 'library' chart.
#
# Application charts are a collection of templates that can be packaged into versioned archives
# to be deployed.
#
# Library charts provide useful utilities or functions for the chart developer. They're included as
# a dependency of application charts to inject those utilities and functions into the rendering
# pipeline. Library charts do not define any templates and therefore cannot be deployed.
type: application
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.1.12
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
appVersion: "v0.18.1"
dependencies:
- name: hull
repository: https://vidispine.github.io/hull
version: 1.32.2

Binary file not shown.

View File

@ -0,0 +1,643 @@
################################
### values.yaml for HULL
### The basic pre-configuration takes place here.
###
### Do not change this file, use additional values.hull.yaml
### to overwrite the selected fields!
################################
###################################################
### CONFIG
config:
general:
rbac: true
fullnameOverride: ""
nameOverride: ""
namespaceOverride: ""
noObjectNamePrefixes: false
createImagePullSecretsFromRegistries: true
globalImageRegistryServer: ""
globalImageRegistryToFirstRegistrySecretServer: false
serialization:
configmap:
enabled: true
fileExtensions:
json: toPrettyJson
yml: toYaml
yaml: toYaml
secret:
enabled: true
fileExtensions:
json: toPrettyJson
yml: toYaml
yaml: toYaml
render:
passes: 3
emptyLabels: false
emptyAnnotations: false
emptyTemplateLabels: false
emptyTemplateAnnotations: false
emptyHullObjects: false
postRender:
globalStringReplacements:
instanceKey:
enabled: false
string: _HULL_OBJECT_TYPE_DEFAULT_
replacement: OBJECT_INSTANCE_KEY
instanceKeyResolved:
enabled: false
string: _HULL_OBJECT_TYPE_DEFAULT_
replacement: OBJECT_INSTANCE_KEY_RESOLVED
instanceName:
enabled: false
string: _HULL_OBJECT_TYPE_DEFAULT_
replacement: OBJECT_INSTANCE_NAME
errorChecks:
objectYamlValid: true
hullGetTransformationReferenceValid: true
containerImageValid: true
virtualFolderDataPathExists: true
virtualFolderDataInlineValid: false
debug:
renderBrokenHullGetTransformationReferences: false
renderNilWhenInlineIsNil: false
renderPathMissingWhenPathIsNonExistent: false
metadata:
labels:
common:
'app.kubernetes.io/managed-by':
'app.kubernetes.io/version':
'app.kubernetes.io/part-of':
'app.kubernetes.io/name':
'app.kubernetes.io/instance':
'app.kubernetes.io/component':
'helm.sh/chart':
'vidispine.hull/version':
custom: {}
annotations:
hashes: false
custom: {}
data: {}
specific: {}
templates:
pod:
global: {}
container:
global: {}
###################################################
###################################################
### OBJECTS
objects:
# NAMESPACE
namespace:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
staticName: true
annotations: {}
labels: {}
###################################################
# CONFIGMAPS
configmap:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
annotations: {}
labels: {}
###################################################
# SECRETS
secret:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
annotations: {}
labels: {}
###################################################
# REGISTRIES
registry:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
annotations: {}
labels: {}
###################################################
# SERVICEACCOUNTS
serviceaccount:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
annotations: {}
labels: {}
default:
enabled: _HT?eq (dig "serviceAccountName" "" _HT*hull.config.templates.pod.global) ""
annotations: {}
labels: {}
###################################################
# ROLES
role:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
annotations: {}
labels: {}
rules:
_HULL_OBJECT_TYPE_DEFAULT_: {}
default:
enabled: _HT?eq (dig "serviceAccountName" "" _HT*hull.config.templates.pod.global) ""
rules: {}
###################################################
# ROLEBINDINGS
rolebinding:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
annotations: {}
labels: {}
default:
enabled: _HT?eq (dig "serviceAccountName" "" _HT*hull.config.templates.pod.global) ""
roleRef:
apiGroup: "rbac.authorization.k8s.io"
kind: "Role"
name: _HT^default
subjects:
- kind: ServiceAccount
name: _HT^default
namespace: _HT**Release.Namespace
###################################################
# CLUSTERROLES
clusterrole:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
annotations: {}
labels: {}
rules:
_HULL_OBJECT_TYPE_DEFAULT_: {}
###################################################
# CLUSTERROLEBINDINGS
clusterrolebinding:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
annotations: {}
labels: {}
###################################################
# CUSTOMRESOURCEDEFINITIONS (deprecated with Helm3)
# customresourcedefinitions:
# _HULL_OBJECT_TYPE_DEFAULT_:
# enabled: true
# annotations: {}
# labels: {}
###################################################
# CUSTOMRESOURCES
customresource:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
annotations: {}
labels: {}
###################################################
# PERSISTENTVOLUMECLAIMS
persistentvolumeclaim:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
annotations: {}
labels: {}
###################################################
# PERSISTENTVOLUMES
persistentvolume:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
annotations: {}
labels: {}
###################################################
# STORAGECLASSES
storageclass:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
annotations: {}
labels: {}
###################################################
# SERVICES
service:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
annotations: {}
labels: {}
ports:
_HULL_OBJECT_TYPE_DEFAULT_: {}
###################################################
# INGRESSES
ingress:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
annotations: {}
labels: {}
tls:
_HULL_OBJECT_TYPE_DEFAULT_: {}
rules:
_HULL_OBJECT_TYPE_DEFAULT_:
http:
paths:
_HULL_OBJECT_TYPE_DEFAULT_: {}
###################################################
# INGRESSCLASSES
ingressclass:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
annotations: {}
labels: {}
###################################################
# DEPLOYMENTS
deployment:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
annotations: {}
labels: {}
templateAnnotations: {}
templateLabels: {}
pod:
initContainers:
_HULL_OBJECT_TYPE_DEFAULT_:
env:
_HULL_OBJECT_TYPE_DEFAULT_: {}
envFrom:
_HULL_OBJECT_TYPE_DEFAULT_: {}
volumeMounts:
_HULL_OBJECT_TYPE_DEFAULT_: {}
containers:
_HULL_OBJECT_TYPE_DEFAULT_:
env:
_HULL_OBJECT_TYPE_DEFAULT_: {}
envFrom:
_HULL_OBJECT_TYPE_DEFAULT_: {}
volumeMounts:
_HULL_OBJECT_TYPE_DEFAULT_: {}
volumes:
_HULL_OBJECT_TYPE_DEFAULT_: {}
###################################################
# JOBS
job:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
annotations: {}
labels: {}
templateAnnotations: {}
templateLabels: {}
pod:
initContainers:
_HULL_OBJECT_TYPE_DEFAULT_:
env:
_HULL_OBJECT_TYPE_DEFAULT_: {}
envFrom:
_HULL_OBJECT_TYPE_DEFAULT_: {}
volumeMounts:
_HULL_OBJECT_TYPE_DEFAULT_: {}
containers:
_HULL_OBJECT_TYPE_DEFAULT_:
env:
_HULL_OBJECT_TYPE_DEFAULT_: {}
envFrom:
_HULL_OBJECT_TYPE_DEFAULT_: {}
volumeMounts:
_HULL_OBJECT_TYPE_DEFAULT_: {}
volumes:
_HULL_OBJECT_TYPE_DEFAULT_: {}
###################################################
# CRONJOBS
cronjob:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
annotations: {}
labels: {}
job:
templateAnnotations: {}
templateLabels: {}
pod:
initContainers:
_HULL_OBJECT_TYPE_DEFAULT_:
env:
_HULL_OBJECT_TYPE_DEFAULT_: {}
envFrom:
_HULL_OBJECT_TYPE_DEFAULT_: {}
volumeMounts:
_HULL_OBJECT_TYPE_DEFAULT_: {}
containers:
_HULL_OBJECT_TYPE_DEFAULT_:
env:
_HULL_OBJECT_TYPE_DEFAULT_: {}
envFrom:
_HULL_OBJECT_TYPE_DEFAULT_: {}
volumeMounts:
_HULL_OBJECT_TYPE_DEFAULT_: {}
volumes:
_HULL_OBJECT_TYPE_DEFAULT_: {}
###################################################
# DAEMONSETS
daemonset:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
annotations: {}
labels: {}
templateAnnotations: {}
templateLabels: {}
pod:
initContainers:
_HULL_OBJECT_TYPE_DEFAULT_:
env:
_HULL_OBJECT_TYPE_DEFAULT_: {}
envFrom:
_HULL_OBJECT_TYPE_DEFAULT_: {}
volumeMounts:
_HULL_OBJECT_TYPE_DEFAULT_: {}
containers:
_HULL_OBJECT_TYPE_DEFAULT_:
env:
_HULL_OBJECT_TYPE_DEFAULT_: {}
envFrom:
_HULL_OBJECT_TYPE_DEFAULT_: {}
volumeMounts:
_HULL_OBJECT_TYPE_DEFAULT_: {}
volumes:
_HULL_OBJECT_TYPE_DEFAULT_: {}
###################################################
# STATEFULSETS
statefulset:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
annotations: {}
labels: {}
templateAnnotations: {}
templateLabels: {}
pod:
initContainers:
_HULL_OBJECT_TYPE_DEFAULT_:
env:
_HULL_OBJECT_TYPE_DEFAULT_: {}
envFrom:
_HULL_OBJECT_TYPE_DEFAULT_: {}
volumeMounts:
_HULL_OBJECT_TYPE_DEFAULT_: {}
containers:
_HULL_OBJECT_TYPE_DEFAULT_:
env:
_HULL_OBJECT_TYPE_DEFAULT_: {}
envFrom:
_HULL_OBJECT_TYPE_DEFAULT_: {}
volumeMounts:
_HULL_OBJECT_TYPE_DEFAULT_: {}
volumes:
_HULL_OBJECT_TYPE_DEFAULT_: {}
###################################################
# SERVICEMONITORS
servicemonitor:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
annotations: {}
labels: {}
###################################################
# HORIZONTALPODAUTOSCALER
horizontalpodautoscaler:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
annotations: {}
labels: {}
###################################################
# PODDISRUPTIONBUDGET
poddisruptionbudget:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
annotations: {}
labels: {}
###################################################
# PRIORITYCLASS
priorityclass:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
annotations: {}
labels: {}
###################################################
# ENDPOINTS
endpoints:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
annotations: {}
labels: {}
###################################################
# ENDPOINTSLICE
endpointslice:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
annotations: {}
labels: {}
###################################################
# LIMITRANGE
limitrange:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
annotations: {}
labels: {}
###################################################
# MUTATINGWEBHOOKCONFIGURATION
mutatingwebhookconfiguration:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
annotations: {}
labels: {}
webhooks:
_HULL_OBJECT_TYPE_DEFAULT_: {}
###################################################
# VALIDATINGWEBHOOKCONFIGURATION
validatingwebhookconfiguration:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
annotations: {}
labels: {}
webhooks:
_HULL_OBJECT_TYPE_DEFAULT_: {}
###################################################
# RESOURCEQUOTA
resourcequota:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
annotations: {}
labels: {}
##################################################
# NETWORKPOLICY
networkpolicy:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
annotations: {}
labels: {}
##################################################
# GATEWAY API - BACKENDLBPOLICY
backendlbpolicy:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
annotations: {}
labels: {}
targetRefs:
_HULL_OBJECT_TYPE_DEFAULT_: {}
##################################################
# GATEWAY API - BACKENDTLSPOLICY
backendtlspolicy:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
annotations: {}
labels: {}
targetRefs:
_HULL_OBJECT_TYPE_DEFAULT_: {}
##################################################
# GATEWAY API - GATEWAYCLASS
gatewayclass:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
annotations: {}
labels: {}
##################################################
# GATEWAY API - GATEWAY
gateway:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
annotations: {}
labels: {}
addresses:
_HULL_OBJECT_TYPE_DEFAULT_: {}
listeners:
_HULL_OBJECT_TYPE_DEFAULT_:
tls:
certificateRefs:
_HULL_OBJECT_TYPE_DEFAULT_: {}
frontendValidation:
caCertificateRefs:
_HULL_OBJECT_TYPE_DEFAULT_: {}
allowedRoutes:
kinds:
_HULL_OBJECT_TYPE_DEFAULT_: {}
##################################################
# GATEWAY API - GRPCROUTE
grpcroute:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
annotations: {}
labels: {}
parentRefs:
_HULL_OBJECT_TYPE_DEFAULT_: {}
rules:
_HULL_OBJECT_TYPE_DEFAULT_:
matches:
_HULL_OBJECT_TYPE_DEFAULT_: {}
filters:
_HULL_OBJECT_TYPE_DEFAULT_: {}
backendRefs:
_HULL_OBJECT_TYPE_DEFAULT_:
filters:
_HULL_OBJECT_TYPE_DEFAULT_: {}
##################################################
# GATEWAY API - REFERENCEGRANT
referencegrant:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
annotations: {}
labels: {}
from:
_HULL_OBJECT_TYPE_DEFAULT_: {}
to:
_HULL_OBJECT_TYPE_DEFAULT_: {}
##################################################
# GATEWAY API - TCPROUTE
tcproute:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
annotations: {}
labels: {}
parentRefs:
_HULL_OBJECT_TYPE_DEFAULT_: {}
rules:
_HULL_OBJECT_TYPE_DEFAULT_:
backendRefs:
_HULL_OBJECT_TYPE_DEFAULT_: {}
##################################################
# GATEWAY API - TLSROUTE
tlsroute:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
annotations: {}
labels: {}
parentRefs:
_HULL_OBJECT_TYPE_DEFAULT_: {}
rules:
_HULL_OBJECT_TYPE_DEFAULT_:
backendRefs:
_HULL_OBJECT_TYPE_DEFAULT_: {}
##################################################
# GATEWAY API - UDPROUTE
udproute:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
annotations: {}
labels: {}
parentRefs:
_HULL_OBJECT_TYPE_DEFAULT_: {}
rules:
_HULL_OBJECT_TYPE_DEFAULT_:
backendRefs:
_HULL_OBJECT_TYPE_DEFAULT_: {}
##################################################
# GATEWAY API - HTTPROUTE
httproute:
_HULL_OBJECT_TYPE_DEFAULT_:
enabled: true
annotations: {}
labels: {}
parentRefs:
_HULL_OBJECT_TYPE_DEFAULT_: {}
rules:
_HULL_OBJECT_TYPE_DEFAULT_:
matches:
_HULL_OBJECT_TYPE_DEFAULT_: {}
filters:
_HULL_OBJECT_TYPE_DEFAULT_: {}
backendRefs:
_HULL_OBJECT_TYPE_DEFAULT_:
filters:
_HULL_OBJECT_TYPE_DEFAULT_: {}
##################################################

View File

@ -0,0 +1 @@
{{- include "hull.objects.prepare.all" (dict "HULL_ROOT_KEY" "hull" "ROOT_CONTEXT" $) }}

View File

@ -0,0 +1,217 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/vidispine/hull/refs/heads/main/hull/values.schema.json
hull:
config:
## Ambient-Local-Exporter settings (config.yaml)
appConfig:
## App Config
environment: production
name: ambient-local-exporter
logging:
level: info
format: json
output: stdout
timeFormat: rfc3339
http:
listen: :8080
logRequests: false
grpc:
enabled: true
listen: :8081
logRequests: true
enableReflection: true
enableInstrumentation: true
otel:
enabled: true
metricIntervalSecs: 30
stdoutEnabled: false
## Ambient Config
metricPrefix: weather
weatherStations:
[]
# - name: Home Weather Station
# equipment: Ambient WS-2909
# awnPassKey: D3:AD:B3:3F:00:00
# proxyToAWN: true
# proxyToWunderground: false
# keepMetrics:
# - BaromAbsoluteIn
# - BaromRelativeIn
# - HumidityIndoor
# - StationType
# - TempIndoorF
# discardMetrics: []
# - name: Shop Weather Station
# equipment: Ambient WS-5000
# awnPassKey: D3:AD:B3:3F:00:00
# wundergroundID: KINCOLUMXXX
# wundergroundPassword: somekey
# proxyToAWN: true
# proxyToWunderground: true
# keepMetrics: []
# discardMetrics: []
# sensorMappings:
# TempHumiditySensor1: Deep Freezer
## Chart settings
settings:
resources: {} # Applies to the exporter container
repo: gitea.libretechconsulting.com/rmcguire/ambient-local-exporter
tag: _HT**Chart.AppVersion
httpPort: 8080 # Should match appConfig http.listen
grpcPort: 8081 # Should match appConfig grpc.listen
# Use this as a shortcut, or create your own hull.objects.httproute
httproute:
enabled: true
hostnames:
- ambient-local-exporter.mydomain.com
gatewayName: istio-ingressgateway
gatewayNamespace: istio-system
# Use this as a shortcut, or create your own hull.objects.grpcroute
grpcroute:
enabled: true
hostnames:
- ambient-local-exporter.mydomain.com
gatewayName: istio-ingressgateway
gatewayNamespace: istio-system
otelServiceName: ambient-local-exporter
otelResourceAttributes: app=ambient-local-exporter
otlpEndpoint: http://otel.otel.svc.cluster.local:4317 # Replace me
serviceType: ClusterIP
serviceLbIP: "" # Used if serviceTyps=LoadBalancer
general:
rbac: false
render:
passes: 2
# Applies to all objects
metadata:
labels:
custom:
app: _HT**Release.Name
version: _HT**Chart.AppVersion
objects:
configmap:
config:
data:
config.yaml:
serialization: toYaml
inline: _HT*hull.config.appConfig
environment:
data:
OTEL_EXPORTER_OTLP_ENDPOINT:
serialization: none
inline: _HT*hull.config.settings.otlpEndpoint
OTEL_SERVICE_NAME:
serialization: none
inline: _HT*hull.config.settings.otelServiceName
OTEL_RESOURCE_ATTRIBUTES:
serialization: none
inline: _HT!
{{ printf "deployment.name=%s,%s" _HT**Release.Name _HT*hull.config.settings.otelResourceAttributes }}
serviceaccount:
default:
enabled: false
role:
default:
enabled: false
rolebinding:
default:
enabled: false
deployment:
main:
pod:
containers:
main:
image:
repository: _HT*hull.config.settings.repo
tag: _HT*hull.config.settings.tag
imagePullPolicy: Always
args:
- -config
- /app/config.yaml
ports:
http:
containerPort: _HT*hull.config.settings.httpPort
grpc:
containerPort: _HT*hull.config.settings.grpcPort
envFrom:
main:
configMapRef:
name: environment
resources: _HT*hull.config.settings.resources
readinessProbe:
httpGet:
path: /health
port: 8080
scheme: HTTP
periodSeconds: 10
failureThreshold: 2
livenessProbe:
httpGet:
path: /health
port: 8080
scheme: HTTP
periodSeconds: 10
failureThreshold: 2
volumeMounts:
config:
name: config
mountPath: /app/config.yaml
subPath: config.yaml
volumes:
environment:
configMap:
name: environment
config:
configMap:
name: config
service:
main:
type: _HT*hull.config.settings.serviceType
loadBalancerIP: _HT*hull.config.settings.serviceLbIP
ports:
http:
port: _HT*hull.config.settings.httpPort
targetPort: http
grpc:
port: _HT*hull.config.settings.grpcPort
targetPort: grpc
httproute:
main:
enabled: _HT*hull.config.settings.httproute.enabled
hostnames: _HT*hull.config.settings.httproute.hostnames
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: _HT*hull.config.settings.httproute.gatewayName
namespace: _HT*hull.config.settings.httproute.gatewayNamespace
rules:
- backendRefs:
- group: ""
kind: Service
name: _HT^main
port: _HT*hull.config.settings.httpPort
grpcroute:
main:
enabled: _HT*hull.config.settings.grpcroute.enabled
hostnames: _HT*hull.config.settings.grpcroute.hostnames
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: _HT*hull.config.settings.grpcroute.gatewayName
namespace: _HT*hull.config.settings.grpcroute.gatewayNamespace
rules:
- backendRefs:
- group: ""
kind: Service
name: _HT^main
port: _HT*hull.config.settings.grpcPort

69
main.go
View File

@ -2,48 +2,63 @@ package main
import (
"context"
"flag"
"fmt"
"os"
"os/signal"
"gitea.libretechconsulting.com/rmcguire/go-app/pkg/app"
"gitea.libretechconsulting.com/rmcguire/go-app/pkg/srv"
"golang.org/x/sys/unix"
"gitea.libretechconsulting.com/rmcguire/ambient-weather-local-exporter/pkg/ambient"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/ambient"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/ambient/config"
)
const (
defaultMetricPrefix = "weather"
)
var schema bool
func main() {
ctx, cncl := signal.NotifyContext(context.Background(), os.Kill, unix.SIGTERM)
ctx, cncl := signal.NotifyContext(context.Background(), os.Kill, os.Interrupt, unix.SIGTERM)
defer cncl()
ctx = app.MustSetupConfigAndLogging(ctx)
// Read config and environment, prepare an appCtx, and merge
// go-app config into ambient weather exporter app config
ctx, awConfig := app.MustLoadConfigInto(ctx, &config.AmbientLocalExporterConfig{
MetricPrefix: defaultMetricPrefix,
WeatherStations: make([]config.WeatherStation, 0),
})
aw := ambient.New(ctx)
aw.MustInit()
// Prepare the ambient exporter with our prepared config
// and set up logging, tracing, etc..
aw := ambient.New(ctx, awConfig).Init()
awApp := app.App{
AppContext: ctx,
HTTP: &app.AppHTTP{
Funcs: []srv.HTTPFunc{
{
Path: "/weatherstation/updateweatherstation.php",
HandlerFunc: aw.GetWundergroundHandlerFunc(ctx),
},
{
Path: "/data/report",
HandlerFunc: aw.GetAWNHandlerFunc(ctx),
},
},
HealthChecks: []srv.HealthCheckFunc{
// TODO: Implement
func(ctx context.Context) error {
return nil
},
},
},
// Just show schema if that's all we were asked to do
if schema {
printSchema(awConfig)
os.Exit(0)
}
awApp.MustRun()
// Load http and grpc routes, prepare the app
awApp := prepareApp(ctx, aw)
// Run app and wait
awApp.MustRun()
<-awApp.Done()
}
// flag.Parse will be called by go-app
func init() {
flag.BoolVar(&schema, "schema", false, "generate json schema and exit")
}
func printSchema(config *config.AmbientLocalExporterConfig) {
bytes, err := app.CustomSchema(config)
if err != nil {
panic(err)
}
fmt.Println(string(bytes))
}

View File

@ -7,40 +7,99 @@ import (
"context"
"fmt"
"net/http"
"sync"
"gitea.libretechconsulting.com/rmcguire/go-app/pkg/otel"
"github.com/rs/zerolog"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"gitea.libretechconsulting.com/rmcguire/ambient-weather-local-exporter/pkg/provider"
"gitea.libretechconsulting.com/rmcguire/ambient-weather-local-exporter/pkg/provider/awn"
"gitea.libretechconsulting.com/rmcguire/ambient-weather-local-exporter/pkg/provider/wunderground"
"gitea.libretechconsulting.com/rmcguire/ambient-weather-local-exporter/pkg/weather"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/ambient/config"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/provider"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/provider/awn"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/provider/wunderground"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/weather"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/weather/recorder"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/weather/recorder/recorders"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/weather/recorder/recorders/memory"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/weather/recorder/recorders/noop"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/weather/recorder/recorders/redis"
)
const defUpdatesToKeep = 120
type AmbientWeather struct {
// These providers implement support for the update sent
// when either "AmbientWeather" or "Wunderground" are selected
// in the "Custom" section of the AWNet app, or the web UI
// of an Ambient WeatherHub
awnProvider provider.AmbientProvider
wuProvider provider.AmbientProvider
appCtx context.Context
metrics *weather.WeatherMetrics
l *zerolog.Logger
Config *config.AmbientLocalExporterConfig
awnProvider provider.AmbientProvider
wuProvider provider.AmbientProvider
weatherRecorder *recorder.WeatherRecorder
appCtx context.Context
metrics *weather.WeatherMetrics
l *zerolog.Logger
*sync.RWMutex
}
func New(appCtx context.Context) *AmbientWeather {
func New(appCtx context.Context, awConfig *config.AmbientLocalExporterConfig) *AmbientWeather {
return &AmbientWeather{
appCtx: appCtx,
Config: awConfig,
appCtx: appCtx,
RWMutex: &sync.RWMutex{},
}
}
func (aw *AmbientWeather) MustInit() {
// Initialize with defaults, set logger from context
func (aw *AmbientWeather) Init() *AmbientWeather {
tracer := otel.GetTracer(aw.appCtx, "ambientWeather")
_, span := tracer.Start(aw.appCtx, "ambientWeather.init",
trace.WithAttributes(
attribute.String("name", aw.Config.Name),
attribute.Bool("grpcEnabled", aw.Config.GRPCEnabled()),
attribute.Bool("httpEnabled", aw.Config.HTTPEnabled()),
attribute.Int("weatherStations", len(aw.Config.WeatherStations)),
))
defer span.End()
aw.awnProvider = &awn.AWNProvider{}
aw.wuProvider = &wunderground.WUProvider{}
aw.l = zerolog.Ctx(aw.appCtx)
updatesToKeep := defUpdatesToKeep
if aw.Config.UpdatesToKeep != nil && *aw.Config.UpdatesToKeep > 0 {
updatesToKeep = *aw.Config.UpdatesToKeep
}
span.SetAttributes(attribute.Int("updatesToKeep", updatesToKeep))
// Choose weather recorder for grpc / api requests,
// default is memory recorder
var r recorders.Recorder
if aw.Config == nil || aw.Config.RecorderConfig == nil {
r = &memory.MemoryRecorder{}
} else {
switch aw.Config.RecorderConfig.Type {
case config.TypeMemory:
r = &memory.MemoryRecorder{}
case config.TypeRedis:
r = &redis.RedisRecorder{}
case config.TypeNoop:
r = &noop.NoopRecorder{}
}
}
aw.weatherRecorder = recorder.MustNewWeatherRecorder(&recorder.Opts{
AppConfig: aw.Config,
Ctx: aw.appCtx,
KeepLast: updatesToKeep,
Recorder: r,
})
aw.l.Trace().Any("awConfig", aw.Config).Send()
span.SetStatus(codes.Ok, "")
return aw
}
func (aw *AmbientWeather) GetAWNHandlerFunc(appCtx context.Context) func(http.ResponseWriter, *http.Request) {
@ -55,21 +114,29 @@ func (aw *AmbientWeather) GetWundergroundHandlerFunc(appCtx context.Context) fun
}
}
// Takes an HTTP requests and convers it to a
// Takes an HTTP requests and converts it to a
// stable type. Enrich is called on the type to complete
// any missing fields as the two providers supported by Ambient
// devices (awn/wunderground) produce different fields
//
// This will call Update on metrics, and will also proxy
// requests to AWN/Wunderground if enabled
// This is the main work performed when a weather station or
// weather hub sends an update
func (aw *AmbientWeather) handleProviderRequest(
p provider.AmbientProvider,
w http.ResponseWriter,
r *http.Request,
) {
aw.Lock()
defer aw.Unlock()
l := zerolog.Ctx(aw.appCtx)
tracer := otel.GetTracer(aw.appCtx, p.Name()+".http.handler")
ctx, span := tracer.Start(r.Context(), p.Name()+".update")
span.SetAttributes(attribute.String("provider", p.Name()))
defer span.End()
ctx, updateSpan := tracer.Start(r.Context(), p.Name()+".update")
updateSpan.SetAttributes(attribute.String("provider", p.Name()))
defer updateSpan.End()
l.Trace().Str("p", p.Name()).
Any("query", r.URL.Query()).Send()
@ -78,8 +145,8 @@ func (aw *AmbientWeather) handleProviderRequest(
update, err := p.ReqToWeather(ctx, r)
if err != nil {
l.Err(err).Send()
span.RecordError(err)
span.SetStatus(codes.Error,
updateSpan.RecordError(err)
updateSpan.SetStatus(codes.Error,
fmt.Sprintf("failed to handle %s update: %s",
p.Name(), err.Error()))
@ -88,15 +155,45 @@ func (aw *AmbientWeather) handleProviderRequest(
return
}
// Calculate any fields that may be missing
// such as dew point and wind chill
update.Enrich()
// Perform enrichment
aw.enrichUpdate(ctx, p, update)
// We may know which station this was for now
if update.StationConfig != nil {
updateSpan.SetAttributes(attribute.String("stationName", update.StationConfig.Name))
}
// Concurrently record the now enriched update
// to the configured recorder and to otel, and proxy
// to enabled downstream providers
var updateWg sync.WaitGroup
// Record state
updateWg.Add(1)
go func() {
defer updateWg.Done()
aw.weatherRecorder.Set(ctx, update)
}()
// Update metrics
if aw.metrics == nil {
aw.metrics = weather.MustInitMetrics(aw.appCtx)
updateWg.Add(1)
go func() {
defer updateWg.Done()
aw.metricsUpdate(ctx, p, update)
}()
// Proxy update to one or both services if configured to do so
// Uses a weather update to allow awn to publish to wunderground and
// visa versa.
if update.StationConfig != nil {
updateWg.Add(1)
go func() {
defer updateWg.Done()
aw.proxyUpdate(ctx, p, update)
}()
}
aw.metrics.Update(update)
updateWg.Wait()
l.Debug().
Str("provider", p.Name()).
@ -104,3 +201,138 @@ func (aw *AmbientWeather) handleProviderRequest(
Msg("successfully handled update")
w.Write([]byte("ok"))
}
func (aw *AmbientWeather) enrichUpdate(
ctx context.Context,
p provider.AmbientProvider,
update *weather.WeatherUpdate,
) {
tracer := otel.GetTracer(aw.appCtx, p.Name()+".http.handler")
// Calculate any fields that may be missing
// such as dew point and wind chill
_, enrichSpan := tracer.Start(ctx, p.Name()+".update.enrich")
defer enrichSpan.End()
// Metric enrichment
update.Enrich()
// Enrich station if configured
aw.enrichStation(update)
// Map sensor names
update.MapSensors()
enrichSpan.SetStatus(codes.Ok, "")
}
func (aw *AmbientWeather) metricsUpdate(
ctx context.Context,
p provider.AmbientProvider,
update *weather.WeatherUpdate,
) {
tracer := otel.GetTracer(aw.appCtx, p.Name()+".http.handler")
_, metricsSpan := tracer.Start(ctx, p.Name()+".update.metrics")
if aw.metrics == nil {
aw.InitMetrics()
}
aw.metrics.Update(update)
metricsSpan.SetStatus(codes.Ok, "")
metricsSpan.End()
}
func (aw *AmbientWeather) proxyUpdate(
ctx context.Context,
p provider.AmbientProvider,
update *weather.WeatherUpdate,
) {
var proxyWg sync.WaitGroup
tracer := otel.GetTracer(aw.appCtx, p.Name()+".http.handler")
station := update.StationConfig
ctx, proxySpan := tracer.Start(ctx, p.Name()+".update.proxy", trace.WithAttributes(
attribute.Bool("proxyToWunderground", station.ProxyToWunderground),
attribute.Bool("proxyToAWN", station.ProxyToAWN),
))
defer proxySpan.End()
// Perform proxy updates in parallel if enabled
if station.ProxyToAWN {
proxyWg.Add(1)
go func() {
defer proxyWg.Done()
defer proxySpan.AddEvent("proxied to ambient weather network")
err := aw.awnProvider.ProxyReq(ctx, update)
if err != nil {
zerolog.Ctx(aw.appCtx).Err(err).Msg("failed to proxy to ambient weather")
proxySpan.RecordError(err)
proxySpan.SetStatus(codes.Error, err.Error())
return
}
zerolog.Ctx(aw.appCtx).Debug().
Str("station", station.Name).
Msg("proxied weather update to awn")
}()
}
if station.ProxyToWunderground {
proxyWg.Add(1)
go func() {
defer proxyWg.Done()
defer proxySpan.AddEvent("proxied to wunderground")
err := aw.wuProvider.ProxyReq(ctx, update)
if err != nil {
zerolog.Ctx(aw.appCtx).Err(err).Msg("failed to proxy to ambient weather")
proxySpan.RecordError(err)
proxySpan.SetStatus(codes.Error, err.Error())
return
}
zerolog.Ctx(aw.appCtx).Debug().
Str("station", station.Name).
Msg("proxied weather update to wunderground")
}()
}
proxyWg.Wait()
}
func (aw *AmbientWeather) InitMetrics() {
if aw.Config.MetricPrefix != "" {
weather.MetricPrefix = aw.Config.MetricPrefix
}
aw.metrics = weather.MustInitMetrics(aw.appCtx)
}
func (aw *AmbientWeather) enrichStation(update *weather.WeatherUpdate) {
if update != nil && update.StationID != nil && *update.StationID != "" {
for _, station := range aw.Config.WeatherStations {
if *update.StationID == station.AWNPassKey || *update.StationID == station.WundergroundID {
update.StationConfig = &station
}
}
}
}
func (aw *AmbientWeather) GetLogger() *zerolog.Logger {
aw.RLock()
defer aw.RUnlock()
return aw.l
}
func (aw *AmbientWeather) GetCtx() context.Context {
aw.RLock()
defer aw.RUnlock()
return aw.appCtx
}
func (aw *AmbientWeather) GetRecorder() *recorder.WeatherRecorder {
aw.RLock()
defer aw.RUnlock()
return aw.weatherRecorder
}

View File

@ -0,0 +1,88 @@
package ambientgrpc
import (
"context"
"go.opentelemetry.io/otel/attribute"
otelcodes "go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
"k8s.io/utils/ptr"
"gitea.libretechconsulting.com/rmcguire/go-app/pkg/otel"
pb "gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/api/v1alpha1/weather"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/weather/recorder"
)
type GRPCWeather struct {
ctx context.Context
recorder *recorder.WeatherRecorder
tracer trace.Tracer
*pb.UnimplementedAmbientLocalWeatherServiceServer
}
func NewGRPCWeather(ctx context.Context, recorder *recorder.WeatherRecorder) *GRPCWeather {
return &GRPCWeather{
ctx: ctx,
recorder: recorder,
tracer: otel.GetTracer(ctx, "grpcWeather"),
}
}
func (w *GRPCWeather) CountWeatherUpdates(ctx context.Context, req *pb.CountWeatherUpdatesRequest) (
*pb.CountWeatherUpdatesResponse, error,
) {
if req.Opts == nil {
count := w.recorder.Count(ctx)
return &pb.CountWeatherUpdatesResponse{Count: int32(count)}, nil
}
resp, err := w.GetWeather(ctx, &pb.GetWeatherRequest{
Opts: req.Opts,
Limit: ptr.To(int32(-1)),
})
if err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
return &pb.CountWeatherUpdatesResponse{
Count: int32(len(resp.WeatherUpdates)),
}, nil
}
func (w *GRPCWeather) GetWeather(ctx context.Context, req *pb.GetWeatherRequest) (
*pb.GetWeatherResponse, error,
) {
ctx, span := w.tracer.Start(ctx, "getWeather")
defer span.End()
limit := 1
if req.Limit != nil {
if *req.Limit > 1 {
limit = int(*req.Limit)
}
}
span.SetAttributes(attribute.Int("limit", limit))
updates, err := w.recorder.Get(ctx, req)
if err != nil {
span.RecordError(err)
span.SetStatus(otelcodes.Error, err.Error())
return nil, status.Error(codes.Internal, err.Error())
} else if len(updates) < 1 {
return nil, status.Error(codes.OutOfRange, "no weather available")
}
span.SetStatus(otelcodes.Ok, "")
return &pb.GetWeatherResponse{
LastUpdated: &timestamppb.Timestamp{
Seconds: updates[0].DateUTC.Unix(),
},
WeatherUpdates: UpdatesToPbUpdates(updates),
}, nil
}

View File

@ -0,0 +1,93 @@
package ambientgrpc
import (
"slices"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
pb "gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/api/v1alpha1/weather"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/util"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/weather"
)
// Converts a slice of weather.WeatherUpdate to proto structs
func UpdatesToPbUpdates(u []*weather.WeatherUpdate) []*pb.WeatherUpdate {
updates := make([]*pb.WeatherUpdate, len(u))
for i, update := range u {
updates[i] = UpdateToPbUpdate(update)
}
return updates
}
// Maps a weather.WeatherUpdate to the proto version.
//
// This should be auto-generated code, but as the proto should be
// stable, it is probably not worth the effort.
func UpdateToPbUpdate(u *weather.WeatherUpdate) *pb.WeatherUpdate {
if u.DateUTC == nil {
now := time.Now()
u.DateUTC = &now
}
return &pb.WeatherUpdate{
StationName: u.StationConfig.Name,
StationType: util.DerefStr(u.StationType),
StationId: util.DerefStr(u.StationID),
TempOutdoorF: u.TempOutdoorF,
TempIndoorF: u.TempIndoorF,
HumidityOutdoor: util.Int32ptr(u.HumidityOudoor),
HumidityIndoor: util.Int32ptr(u.HumidityIndoor),
WindSpeedMph: u.WindSpeedMPH,
WindGustMph: u.WindGustMPH,
MaxDailyGust: u.MaxDailyGust,
WindDir: util.Int32ptr(u.WindDir),
WindDirAvg_10M: util.Int32ptr(u.WindDirAvg10m),
Uv: util.Int32ptr(u.UV),
SolarRadiation: u.SolarRadiation,
HourlyRainIn: u.HourlyRainIn,
EventRainIn: u.EventRainIn,
DailyRainIn: u.DailyRainIn,
WeeklyRainIn: u.WeeklyRainIn,
MonthlyRainIn: u.MonthlyRainIn,
YearlyRainIn: u.YearlyRainIn,
TotalRainIn: u.TotalRainIn,
Batteries: batteriesToPbBatteries(u.Batteries),
BaromRelativeIn: u.BaromRelativeIn,
BaromAbsoluteIn: u.BaromAbsoluteIn,
DewPointF: u.DewPointF,
WindChillF: u.WindChillF,
TempHumiditySensors: thSensorsToPbSensors(u.TempHumiditySensors),
UpdateTimestamp: timestamppb.New(*u.DateUTC),
LightningDay: util.Int32ptr(u.LightningDay),
LightningDistance: util.Int32ptr(u.LightningDistance),
LightningLastTime: util.TimestampFromIntPtr(u.LightningLastTime),
}
}
func batteriesToPbBatteries(batteries []weather.BatteryStatus) []*pb.BatteryStatus {
pbBatteries := make([]*pb.BatteryStatus, 0, len(batteries))
for _, b := range batteries {
if b.Status != nil {
pbBatteries = append(pbBatteries, &pb.BatteryStatus{
Component: b.Component,
Status: util.Int32ptr(b.Status),
})
}
}
return slices.Clip(pbBatteries)
}
func thSensorsToPbSensors(sensors []*weather.TempHumiditySensor) []*pb.TempHumiditySensor {
pbSensors := make([]*pb.TempHumiditySensor, 0, len(sensors))
for _, s := range sensors {
if s.TempF != nil || s.Humidity != nil {
pbSensors = append(pbSensors, &pb.TempHumiditySensor{
Name: s.Name,
TempF: s.TempF,
Humidity: util.Int32ptr(s.Humidity),
})
}
}
return slices.Clip(pbSensors)
}

View File

@ -0,0 +1,121 @@
package ambientgrpc
import (
"reflect"
"testing"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
"k8s.io/utils/ptr"
pb "gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/api/v1alpha1/weather"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/ambient/config"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/weather"
)
var now = time.Now()
var mockUpdate = &weather.WeatherUpdate{
DateUTC: &now,
StationConfig: &config.WeatherStation{
Name: "50W",
Equipment: "WS-5000",
},
StationID: ptr.To("50W"),
StationType: ptr.To("WS-5000"),
TempOutdoorF: ptr.To(97.6),
TempIndoorF: ptr.To(77.6),
HumidityOudoor: ptr.To(50),
HumidityIndoor: ptr.To(50),
WindSpeedMPH: ptr.To(20.5),
WindGustMPH: ptr.To(30.5),
MaxDailyGust: ptr.To(40.0),
WindDir: ptr.To(180),
WindDirAvg10m: ptr.To(180),
UV: nil,
SolarRadiation: ptr.To(9.999),
HourlyRainIn: ptr.To(9.999),
EventRainIn: ptr.To(9.999),
DailyRainIn: ptr.To(9.999),
WeeklyRainIn: ptr.To(9.999),
MonthlyRainIn: ptr.To(9.999),
YearlyRainIn: ptr.To(9.999),
TotalRainIn: ptr.To(9.999),
Batteries: []weather.BatteryStatus{
{Component: "battery1", Status: ptr.To(1)},
{Component: "battery2", Status: ptr.To(1)},
},
BaromRelativeIn: ptr.To(9.999),
BaromAbsoluteIn: ptr.To(9.999),
DewPointF: ptr.To(9.999),
WindChillF: ptr.To(9.999),
TempHumiditySensors: []*weather.TempHumiditySensor{
{Name: "sensor1", TempF: ptr.To(99.999), Humidity: nil},
},
LightningDay: ptr.To(1),
LightningDistance: ptr.To(20),
}
func TestUpdateToPbUpdate(t *testing.T) {
type args struct {
u *weather.WeatherUpdate
}
tests := []struct {
name string
args args
want *pb.WeatherUpdate
}{
{
name: "Map Update to PB",
args: args{u: mockUpdate},
want: &pb.WeatherUpdate{
StationName: mockUpdate.StationConfig.Name,
StationType: *mockUpdate.StationType,
StationId: *mockUpdate.StationID,
TempOutdoorF: mockUpdate.TempOutdoorF,
TempIndoorF: mockUpdate.TempIndoorF,
HumidityOutdoor: ptr.To(int32(*mockUpdate.HumidityOudoor)),
HumidityIndoor: ptr.To(int32(*mockUpdate.HumidityIndoor)),
WindSpeedMph: mockUpdate.WindSpeedMPH,
WindGustMph: mockUpdate.WindGustMPH,
MaxDailyGust: mockUpdate.MaxDailyGust,
WindDir: ptr.To(int32(*mockUpdate.WindDir)),
WindDirAvg_10M: ptr.To(int32(*mockUpdate.WindDirAvg10m)),
Uv: nil,
SolarRadiation: mockUpdate.SolarRadiation,
HourlyRainIn: mockUpdate.HourlyRainIn,
EventRainIn: mockUpdate.EventRainIn,
DailyRainIn: mockUpdate.DailyRainIn,
WeeklyRainIn: mockUpdate.WeeklyRainIn,
MonthlyRainIn: mockUpdate.MonthlyRainIn,
YearlyRainIn: mockUpdate.YearlyRainIn,
TotalRainIn: mockUpdate.TotalRainIn,
Batteries: []*pb.BatteryStatus{
{Component: mockUpdate.Batteries[0].Component, Status: ptr.To(int32(*mockUpdate.Batteries[0].Status))},
{Component: mockUpdate.Batteries[1].Component, Status: ptr.To(int32(*mockUpdate.Batteries[1].Status))},
},
BaromRelativeIn: mockUpdate.BaromRelativeIn,
BaromAbsoluteIn: mockUpdate.BaromAbsoluteIn,
DewPointF: mockUpdate.DewPointF,
WindChillF: mockUpdate.WindChillF,
TempHumiditySensors: []*pb.TempHumiditySensor{
{
Name: mockUpdate.TempHumiditySensors[0].Name,
TempF: mockUpdate.TempHumiditySensors[0].TempF,
Humidity: nil,
},
},
UpdateTimestamp: timestamppb.New(now),
LightningDay: ptr.To(int32(1)),
LightningDistance: ptr.To(int32(20)),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := UpdateToPbUpdate(tt.args.u); !reflect.DeepEqual(got, tt.want) {
t.Errorf("UpdateToPbUpdate() = %v, want %v", got, tt.want)
}
})
}
}

View File

@ -0,0 +1,112 @@
// This package exists purely to override the net.Listener used
// by the application's http server. This is necessary for certain versions
// of firmware which errantly put an 0x0a (LF) following PASSKEY for
// AmbientWeather type http reporting.
//
// This needs to be fixed upstream by Ambient Weather and is a complete
// hack that should never be necessary. Without this, the http server
// will silently crank back an HTTP:400
package ambienthttp
import (
"bufio"
"bytes"
"context"
"io"
"net"
"regexp"
"github.com/rs/zerolog"
)
// Invalid Request Pattern
var badReqURI = regexp.MustCompile(`PASSKEY=[^\n&]{16,}$`)
// Listener encapsulates LFStrippingConn to perform
// infuriating strip of newline character present after PASSKEY
// sent errantly by specific versions of firmware sending updates
// in AmbientWeather protocol
type LFStrippingListener struct {
ctx context.Context
net.Listener
}
type LFStrippingConn struct {
ctx context.Context
reader io.Reader
net.Conn
}
func (l *LFStrippingListener) WrapConn(conn net.Conn) net.Conn {
buf := new(bytes.Buffer)
reader := io.TeeReader(conn, buf)
scanner := bufio.NewScanner(reader)
var newData []byte
for scanner.Scan() {
line := scanner.Bytes()
newData = append(newData, line...)
// Only restore newline if not a bad request
if !badReqURI.Match(line) {
newData = append(newData, '\n')
} else {
zerolog.Ctx(l.ctx).Warn().Bytes("line", line).
Msg("malformed request found, stripped 0x0a")
}
if len(line) == 0 {
break
}
}
if scanner.Err() != nil {
zerolog.Ctx(l.ctx).Err(scanner.Err()).Send()
}
zerolog.Ctx(l.ctx).Trace().
Int("numBytes", len(newData)).
Bytes("request", newData).
Msg("stripping conn complete")
// Use a multi-reader to prepend the modified request
finalReader := io.MultiReader(bytes.NewReader(newData), conn)
return &LFStrippingConn{
Conn: conn,
ctx: l.ctx,
reader: finalReader,
}
}
func NewAWNMutatingListener(ctx context.Context, listen string) net.Listener {
rawListener, err := net.Listen("tcp", listen)
if err != nil {
panic(err)
}
// Encapsulate the raw listener with ours
return &LFStrippingListener{
Listener: rawListener,
ctx: ctx,
}
}
func (l *LFStrippingListener) Accept() (net.Conn, error) {
conn, err := l.Listener.Accept()
if err != nil {
return nil, err
}
return l.WrapConn(conn), nil
}
func (l *LFStrippingListener) Close() error {
return l.Listener.Close()
}
func (l *LFStrippingListener) Addr() net.Addr {
return l.Listener.Addr()
}
func (c *LFStrippingConn) Read(b []byte) (int, error) {
return c.reader.Read(b)
}

17
pkg/ambient/app.go Normal file
View File

@ -0,0 +1,17 @@
package ambient
import (
"context"
"net/http"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
)
func (aw *AmbientWeather) GetGatewayOpts() []runtime.ServeMuxOption {
return []runtime.ServeMuxOption{
runtime.WithErrorHandler(func(ctx context.Context, sm *runtime.ServeMux, m runtime.Marshaler, w http.ResponseWriter, req *http.Request, err error) {
aw.GetLogger().Err(err).Msg("error handling gateway request")
runtime.DefaultHTTPErrorHandler(ctx, sm, m, w, req, err)
}),
}
}

View File

@ -0,0 +1,48 @@
package config
import (
"gitea.libretechconsulting.com/rmcguire/go-app/pkg/config"
)
// This configuration includes all config from go-app/config.AppConfig
type AmbientLocalExporterConfig struct {
MetricPrefix string `yaml:"metricPrefix" default:"weather" env:"AMBIENT_METRIC_PREFIX" json:"metricPrefix,omitempty"`
UpdatesToKeep *int `yaml:"updatesToKeep" default:"1" env:"AMBIENT_UPDATES_TO_KEEP" json:"updatesToKeep,omitempty"`
WeatherStations []WeatherStation `yaml:"weatherStations" json:"weatherStations,omitempty"` // No env, too complex, not worth the time
RecorderConfig *RecorderConfig `yaml:"recorderConfig" json:"recorderConfig,omitempty"`
*config.AppConfig // Extends app config
}
type WeatherStation struct {
Name string `yaml:"name" json:"name,omitempty"` // Human Friendly Name (e.g. Back Yard Weather)
Equipment string `yaml:"equipment" json:"equipment,omitempty"` // Equipment Type (e.g. WS-5000)
// Required if proxying to awn/wu is enabled
WundergroundID string `yaml:"wundergroundID" json:"wundergroundID,omitempty"`
WundergroundPassword string `yaml:"wundergroundPassword" json:"wundergroundPassword,omitempty"`
AWNPassKey string `yaml:"awnPassKey" json:"awnPassKey,omitempty"`
// Proxy updates to AWN or Wunderground
ProxyToAWN bool `yaml:"proxyToAWN" json:"proxyToAWN,omitempty"`
ProxyToWunderground bool `yaml:"proxyToWunderground" json:"proxyToWunderground,omitempty"`
// Unreliable / unwanted metrics by name of WeatherUpdate Field
// will be excluded if present in discardMetrics
//
// If anything is present in keepMetrics, it is solely applied,
// ignoring discardMetrics.
//
// Check weather.WeatherUpdateField for options
KeepMetrics []string `yaml:"keepMetrics" json:"keepMetrics,omitempty"`
DropMetrics []string `yaml:"dropMetrics" json:"dropMetrics,omitempty"`
// Relabels battery and sensor names
// Temp+Humidity Sensors:
// - TempHumiditySensor[1-8]
// Batteries:
// - IndoorSensor
// - OutdoorSensor
// - RainSensor
// - CO2Sensor
SensorMappings map[string]string `yaml:"sensorMappings" json:"sensorMappings,omitempty"`
}

View File

@ -0,0 +1,26 @@
package config
type RecorderType string
const (
TypeMemory RecorderType = "memory" // Stores weather updates in memory
TypeRedis RecorderType = "redis" // Required for replicas > 1
TypeNoop RecorderType = "noop" // No-op implementation
)
type RecorderConfig struct {
Type RecorderType `yaml:"type" env:"RECORDER_TYPE" json:"type,omitempty" enum:"memory,redis,noop"`
KeepLast int `yaml:"keepLast" env:"RECORDER_KEEP_LAST" json:"keepLast,omitempty" default:"120"`
RedisConfig *RedisConfig `yaml:"redisConfig,omitempty" json:"redisConfig,omitempty"`
}
type RedisConfig struct {
RedisHost string `yaml:"redisHost" env:"REDIS_HOST" default:"127.0.0.1" json:"redisHost,omitempty"`
RedisPort int `yaml:"redisPort" env:"REDIS_PORT" default:"6379" json:"redisPort,omitempty"`
RedisUser string `yaml:"redisUser" env:"REDIS_USER" json:"redisUser,omitempty"`
RedisPassword string `yaml:"redisPassword" env:"REDIS_PASSWORD" json:"redisPassword,omitempty"`
RedisDB int `yaml:"redisDB" env:"REDIS_DB" default:"0" json:"redisDB,omitempty"`
RedisTLS bool `yaml:"redisTLS" env:"REDIS_TLS" default:"false" json:"redisTLS,omitempty"`
RedisTLSInsecure bool `yaml:"redisTLSInsecure" env:"REDIS_TLS_INSECURE" default:"false" json:"redisTLSInsecure,omitempty"`
RedisKeyTTL string `yaml:"redisKeyTTL" env:"REDIS_KEY_TTL" default:"24h" json:"redisKeyTTL,omitempty"`
}

View File

@ -0,0 +1,12 @@
package config
// If the weather-station has a mapping, returns the new
// name for the sensor
func (ws *WeatherStation) MapSensor(sensor string) string {
for name, replacement := range ws.SensorMappings {
if name == sensor && replacement != "" {
return replacement
}
}
return sensor
}

View File

@ -0,0 +1,67 @@
package config
import "testing"
func TestWeatherStation_MapSensor(t *testing.T) {
type fields struct {
Name string
Equipment string
WundergroundID string
WundergroundPassword string
AWNPassKey string
ProxyToAWN bool
ProxyToWunderground bool
KeepMetrics []string
DropMetrics []string
SensorMappings map[string]string
}
type args struct {
sensor string
}
tests := []struct {
name string
fields fields
args args
want string
}{
{
name: "Check sensor mapping",
fields: fields{
SensorMappings: map[string]string{
"TempHumiditySensor1": "TestSensor",
},
},
args: args{sensor: "TempHumiditySensor1"},
want: "TestSensor",
},
{
name: "Check sensor no mapping",
fields: fields{
SensorMappings: map[string]string{
"TempHumiditySensor1": "TestSensor",
},
},
args: args{sensor: "TempHumiditySensor2"},
want: "TempHumiditySensor2",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ws := &WeatherStation{
Name: tt.fields.Name,
Equipment: tt.fields.Equipment,
WundergroundID: tt.fields.WundergroundID,
WundergroundPassword: tt.fields.WundergroundPassword,
AWNPassKey: tt.fields.AWNPassKey,
ProxyToAWN: tt.fields.ProxyToAWN,
ProxyToWunderground: tt.fields.ProxyToWunderground,
KeepMetrics: tt.fields.KeepMetrics,
DropMetrics: tt.fields.DropMetrics,
SensorMappings: tt.fields.SensorMappings,
}
if got := ws.MapSensor(tt.args.sensor); got != tt.want {
t.Errorf("WeatherStation.MapSensor() = %v, want %v", got, tt.want)
}
})
}
}

View File

@ -8,12 +8,25 @@ import (
"github.com/gorilla/schema"
"gitea.libretechconsulting.com/rmcguire/ambient-weather-local-exporter/pkg/weather"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/weather"
)
type AWNProvider struct{}
const providerName = "awn"
const (
providerName = "awn"
awnURL = "http://ambientweather.net/data/report"
)
// Battery Sensors
const (
BattOutdoorSensor = "OutdoorSensor"
BattIndoorSensor = "IndoorSensor"
BattRainSensor = "RainSensor"
BattCO2Sensor = "CO2Sensor"
BattLightningSensor = "LightningSensor"
THSensor = "TempHumiditySensor"
)
func (awn *AWNProvider) Name() string {
return providerName
@ -33,37 +46,106 @@ func (awn *AWNProvider) ReqToWeather(_ context.Context, r *http.Request) (
}
func MapAwnUpdate(awnUpdate *AmbientWeatherUpdate) *weather.WeatherUpdate {
updateTime, err := time.Parse(time.DateTime, awnUpdate.DateUTC)
if err != nil {
updateTime = time.Now()
updateTime := time.Now()
if awnUpdate.DateUTC != nil {
ut, err := time.Parse(time.DateTime, *awnUpdate.DateUTC)
if err == nil {
updateTime = ut
}
}
return &weather.WeatherUpdate{
StationType: awnUpdate.StationType,
DateUTC: &updateTime,
TempOutdoorF: awnUpdate.TempF,
HumidityOudoor: awnUpdate.Humidity,
WindSpeedMPH: awnUpdate.WindGustMPH,
WindGustMPH: awnUpdate.WindGustMPH,
MaxDailyGust: awnUpdate.MaxDailyGust,
WindDir: awnUpdate.WindDir,
WindDirAvg10m: awnUpdate.WindDirAVG10m,
UV: awnUpdate.UV,
SolarRadiation: awnUpdate.SolarRadiation,
HourlyRainIn: awnUpdate.HourlyRainIn,
EventRainIn: awnUpdate.EventRainIn,
DailyRainIn: awnUpdate.DailyRainIn,
WeeklyRainIn: awnUpdate.WeeklyRainIn,
MonthlyRainIn: awnUpdate.MonthlyRainIn,
YearlyRainIn: awnUpdate.YearlyRainIn,
TotalRainIn: awnUpdate.TotalRainIn,
BattOutdoorSensor: awnUpdate.BattOut,
BattIndoorSensor: awnUpdate.BattIn,
BattRainSensor: awnUpdate.BattRain,
TempIndoorF: awnUpdate.TempInF,
HumidityIndoor: awnUpdate.HumidityIn,
BaromRelativeIn: awnUpdate.BaromRelIn,
BaromAbsoluteIn: awnUpdate.BaromAbsIn,
DateUTC: &updateTime,
StationID: awnUpdate.PassKey,
StationType: awnUpdate.StationType,
TempOutdoorF: awnUpdate.TempF,
HumidityOudoor: awnUpdate.Humidity,
WindSpeedMPH: awnUpdate.WindSpeedMPH,
WindGustMPH: awnUpdate.WindGustMPH,
MaxDailyGust: awnUpdate.MaxDailyGust,
WindDir: awnUpdate.WindDir,
WindDirAvg10m: awnUpdate.WindDirAVG10m,
UV: awnUpdate.UV,
SolarRadiation: awnUpdate.SolarRadiation,
HourlyRainIn: awnUpdate.HourlyRainIn,
EventRainIn: awnUpdate.EventRainIn,
DailyRainIn: awnUpdate.DailyRainIn,
WeeklyRainIn: awnUpdate.WeeklyRainIn,
MonthlyRainIn: awnUpdate.MonthlyRainIn,
YearlyRainIn: awnUpdate.YearlyRainIn,
TotalRainIn: awnUpdate.TotalRainIn,
Batteries: []weather.BatteryStatus{
{
Component: BattLightningSensor,
Status: awnUpdate.BattLightning,
},
{
Component: BattOutdoorSensor,
Status: awnUpdate.BattOut,
},
{
Component: BattIndoorSensor,
Status: awnUpdate.BattIn,
},
{
Component: BattRainSensor,
Status: awnUpdate.BattRain,
},
{
Component: BattCO2Sensor,
Status: awnUpdate.BattCO2,
},
// Temp and Humidity Sensors
{
Component: THSensor + "1",
Status: awnUpdate.Batt1,
},
{
Component: THSensor + "2",
Status: awnUpdate.Batt2,
},
{
Component: THSensor + "3",
Status: awnUpdate.Batt3,
},
{
Component: THSensor + "4",
Status: awnUpdate.Batt4,
},
{
Component: THSensor + "5",
Status: awnUpdate.Batt5,
},
{
Component: THSensor + "6",
Status: awnUpdate.Batt6,
},
{
Component: THSensor + "7",
Status: awnUpdate.Batt7,
},
{
Component: THSensor + "8",
Status: awnUpdate.Batt8,
},
},
TempIndoorF: awnUpdate.TempInF,
HumidityIndoor: awnUpdate.HumidityIn,
BaromRelativeIn: awnUpdate.BaromRelIn,
BaromAbsoluteIn: awnUpdate.BaromAbsIn,
TempHumiditySensors: []*weather.TempHumiditySensor{
{Name: THSensor + "1", TempF: awnUpdate.Temp1F, Humidity: awnUpdate.Humidity1},
{Name: THSensor + "2", TempF: awnUpdate.Temp2F, Humidity: awnUpdate.Humidity2},
{Name: THSensor + "3", TempF: awnUpdate.Temp3F, Humidity: awnUpdate.Humidity3},
{Name: THSensor + "4", TempF: awnUpdate.Temp4F, Humidity: awnUpdate.Humidity4},
{Name: THSensor + "5", TempF: awnUpdate.Temp5F, Humidity: awnUpdate.Humidity5},
{Name: THSensor + "6", TempF: awnUpdate.Temp6F, Humidity: awnUpdate.Humidity6},
{Name: THSensor + "7", TempF: awnUpdate.Temp7F, Humidity: awnUpdate.Humidity7},
{Name: THSensor + "8", TempF: awnUpdate.Temp8F, Humidity: awnUpdate.Humidity8},
},
LightningDay: awnUpdate.LightningDay,
LightningDistance: awnUpdate.LightningDistance,
LightningLastTime: awnUpdate.LightningLastTime,
}
}
@ -71,6 +153,8 @@ func UnmarshalQueryParams(query url.Values) (*AmbientWeatherUpdate, error) {
update := new(AmbientWeatherUpdate)
decoder := schema.NewDecoder()
decoder.IgnoreUnknownKeys(true)
if err := decoder.Decode(update, query); err != nil {
return nil, err
}

105
pkg/provider/awn/proxy.go Normal file
View File

@ -0,0 +1,105 @@
package awn
import (
"context"
"errors"
"net/url"
"time"
"gitea.libretechconsulting.com/rmcguire/go-app/pkg/otel"
"github.com/go-resty/resty/v2"
"github.com/rs/zerolog/log"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/weather"
)
// Attempts to proxy the weather station update to awn
// SAMPLE:
// {"PASSKEY":["ABF7E052BC7325A32300ACC89112AA91"],"baromabsin":["28.895"],
// "baromrelin":["29.876"],"battin":["1"],"battout":["1"],"battrain":["1"],
// "dailyrainin":["0.000"],"dateutc":["2025-01-11 22:07:57"],"eventrainin":["0.000"],
// "hourlyrainin":["0.000"],"humidity":["76"],"humidityin":["31"],"maxdailygust":["7.83"],
// "monthlyrainin":["0.000"],"solarradiation":["14.21"],"stationtype":["WeatherHub_V1.0.1"],
// "tempf":["29.48"],"tempinf":["66.20"],"totalrainin":["0.000"],"uv":["0"],
// "weeklyrainin":["0.000"],"winddir":["66"],"winddir_avg10m":["268"],"windgustmph":["2.68"],
// "windspeedmph":["0.00"],"yearlyrainin":["0.000"]}
func (awn *AWNProvider) ProxyReq(ctx context.Context, update *weather.WeatherUpdate) error {
tracer := otel.GetTracer(ctx, "awnProvider", "proxyReq")
ctx, span := tracer.Start(ctx, "proxyToAWN")
defer span.End()
if update.StationConfig.AWNPassKey == "" {
err := errors.New("no PASSKEY set in update")
span.RecordError(err)
return err
}
params := updateToAWNParams(update)
resp, err := resty.New().R().
SetContext(ctx).
SetQueryParamsFromValues(*params).
Get(awnURL)
if err != nil {
span.RecordError(err)
span.SetAttributes(
attribute.String("query", resp.Request.QueryParam.Encode()),
attribute.String("body", string(resp.Body())),
)
span.SetStatus(codes.Error, err.Error())
log.Err(err).Any("query", resp.Request.PathParams).
Int("statusCode", resp.StatusCode()).
Msg("awn proxy failed")
}
span.SetAttributes(
attribute.Int("statusCode", resp.StatusCode()),
)
return err
}
func updateToAWNParams(update *weather.WeatherUpdate) *url.Values {
params := &url.Values{}
params.Set("dateutc", time.Now().Format(time.DateTime))
weather.SetURLVal(params, "PASSKEY", &update.StationConfig.AWNPassKey)
weather.SetURLVal(params, "baromabsin", update.BaromAbsoluteIn)
weather.SetURLVal(params, "baromrelin", update.BaromRelativeIn)
weather.SetURLVal(params, "dailyrainin", update.DailyRainIn)
weather.SetURLVal(params, "weeklyrainin", update.WeeklyRainIn)
weather.SetURLVal(params, "eventrainin", update.EventRainIn)
weather.SetURLVal(params, "hourlyrainin", update.HourlyRainIn)
weather.SetURLVal(params, "monthlyrainin", update.MonthlyRainIn)
weather.SetURLVal(params, "yearlyrainin", update.YearlyRainIn)
weather.SetURLVal(params, "totalrainin", update.TotalRainIn)
weather.SetURLVal(params, "humidity", update.HumidityOudoor)
weather.SetURLVal(params, "humidityin", update.HumidityIndoor)
weather.SetURLVal(params, "solarradiation", update.SolarRadiation)
weather.SetURLVal(params, "uv", update.UV)
weather.SetURLVal(params, "stationtype", update.StationType)
weather.SetURLVal(params, "tempf", update.TempOutdoorF)
weather.SetURLVal(params, "tempinf", update.TempIndoorF)
weather.SetURLVal(params, "winddir", update.WindDir)
weather.SetURLVal(params, "winddir_avg10m", update.WindDirAvg10m)
weather.SetURLVal(params, "windgustmph", update.WindGustMPH)
weather.SetURLVal(params, "windspeedmph", update.WindSpeedMPH)
weather.SetURLVal(params, "maxdailygust", update.MaxDailyGust)
// Batteries
for _, status := range update.Batteries {
switch status.Component {
case BattOutdoorSensor:
weather.SetURLVal(params, "battin", status.Status)
case BattIndoorSensor:
weather.SetURLVal(params, "battout", status.Status)
case BattRainSensor:
weather.SetURLVal(params, "battrain", status.Status)
case BattCO2Sensor:
weather.SetURLVal(params, "batt_co2", status.Status)
}
}
return params
}

View File

@ -1,30 +1,67 @@
package awn
type AmbientWeatherUpdate struct {
PassKey string `json:"PASSKEY,omitempty" schema:"PASSKEY"`
StationType string `json:"stationtype,omitempty" schema:"stationtype"`
DateUTC string `json:"dateutc,omitempty" schema:"dateutc"`
TempF float64 `json:"tempf,omitempty" schema:"tempf"`
Humidity int `json:"humidity,omitempty" schema:"humidity"`
WindSpeedMPH float64 `json:"windspeedmph,omitempty" schema:"windspeedmph"`
WindGustMPH float64 `json:"windgustmph,omitempty" schema:"windgustmph"`
MaxDailyGust float64 `json:"maxdailygust,omitempty" schema:"maxdailygust"`
WindDir int `json:"winddir,omitempty" schema:"winddir"`
WindDirAVG10m int `json:"winddir_avg10m,omitempty" schema:"winddir_avg10m"`
UV int `json:"uv,omitempty" schema:"uv"`
SolarRadiation float64 `json:"solarradiation,omitempty" schema:"solarradiation"`
HourlyRainIn float64 `json:"hourlyrainin,omitempty" schema:"hourlyrainin"`
EventRainIn float64 `json:"eventrainin,omitempty" schema:"eventrainin"`
DailyRainIn float64 `json:"dailyrainin,omitempty" schema:"dailyrainin"`
WeeklyRainIn float64 `json:"weeklyrainin,omitempty" schema:"weeklyrainin"`
MonthlyRainIn float64 `json:"monthlyrainin,omitempty" schema:"monthlyrainin"`
YearlyRainIn float64 `json:"yearlyrainin,omitempty" schema:"yearlyrainin"`
TotalRainIn float64 `json:"totalrainin,omitempty" schema:"totalrainin"`
BattOut int `json:"battout,omitempty" schema:"battout"`
BattRain int `json:"battrain,omitempty" schema:"battrain"`
TempInF float64 `json:"tempinf,omitempty" schema:"tempinf"`
HumidityIn int `json:"humidityin,omitempty" schema:"humidityin"`
BaromRelIn float64 `json:"baromrelin,omitempty" schema:"baromrelin"`
BaromAbsIn float64 `json:"baromabsin,omitempty" schema:"baromabsin"`
BattIn int `json:"battin,omitempty" schema:"battin"`
PassKey *string `json:"PASSKEY,omitempty" schema:"PASSKEY"`
StationType *string `json:"stationtype,omitempty" schema:"stationtype"`
DateUTC *string `json:"dateutc,omitempty" schema:"dateutc"`
TempF *float64 `json:"tempf,omitempty" schema:"tempf"`
Humidity *int `json:"humidity,omitempty" schema:"humidity"`
WindSpeedMPH *float64 `json:"windspeedmph,omitempty" schema:"windspeedmph"`
WindGustMPH *float64 `json:"windgustmph,omitempty" schema:"windgustmph"`
MaxDailyGust *float64 `json:"maxdailygust,omitempty" schema:"maxdailygust"`
WindDir *int `json:"winddir,omitempty" schema:"winddir"`
WindDirAVG10m *int `json:"winddir_avg10m,omitempty" schema:"winddir_avg10m"`
UV *int `json:"uv,omitempty" schema:"uv"`
SolarRadiation *float64 `json:"solarradiation,omitempty" schema:"solarradiation"`
HourlyRainIn *float64 `json:"hourlyrainin,omitempty" schema:"hourlyrainin"`
EventRainIn *float64 `json:"eventrainin,omitempty" schema:"eventrainin"`
DailyRainIn *float64 `json:"dailyrainin,omitempty" schema:"dailyrainin"`
WeeklyRainIn *float64 `json:"weeklyrainin,omitempty" schema:"weeklyrainin"`
MonthlyRainIn *float64 `json:"monthlyrainin,omitempty" schema:"monthlyrainin"`
YearlyRainIn *float64 `json:"yearlyrainin,omitempty" schema:"yearlyrainin"`
TotalRainIn *float64 `json:"totalrainin,omitempty" schema:"totalrainin"`
BattOut *int `json:"battout,omitempty" schema:"battout"`
BattRain *int `json:"battrain,omitempty" schema:"battrain"`
TempInF *float64 `json:"tempinf,omitempty" schema:"tempinf"`
HumidityIn *int `json:"humidityin,omitempty" schema:"humidityin"`
BaromRelIn *float64 `json:"baromrelin,omitempty" schema:"baromrelin"`
BaromAbsIn *float64 `json:"baromabsin,omitempty" schema:"baromabsin"`
BattIn *int `json:"battin,omitempty" schema:"battin"`
BattCO2 *int `json:"batt_co2,omitempty" schema:"batt_co2"`
*AmbientTempHumiditySensors
*AmbientLightningData
}
type AmbientLightningData struct {
BattLightning *int `json:"batt_lightning,omitempty" schema:"batt_lightning"`
LightningDay *int `json:"lightning_day,omitempty" schema:"lightning_day" description:"count of lightning detections"`
LightningDistance *int `json:"lightning_distance,omitempty" schema:"lightning_distance" description:"distance in kilometers"`
LightningLastTime *int `json:"lightning_time,omitempty" schema:"lightning_time" description:"last lightning detection time"`
}
type AmbientTempHumiditySensors struct {
Temp1F *float64 `json:"temp1f,omitempty" schema:"temp1f"`
Temp2F *float64 `json:"temp2f,omitempty" schema:"temp2f"`
Temp3F *float64 `json:"temp3f,omitempty" schema:"temp3f"`
Temp4F *float64 `json:"temp4f,omitempty" schema:"temp4f"`
Temp5F *float64 `json:"temp5f,omitempty" schema:"temp5f"`
Temp6F *float64 `json:"temp6f,omitempty" schema:"temp6f"`
Temp7F *float64 `json:"temp7f,omitempty" schema:"temp7f"`
Temp8F *float64 `json:"temp8f,omitempty" schema:"temp8f"`
Humidity1 *int `json:"humidity1,omitempty" schema:"humidity1"`
Humidity2 *int `json:"humidity2,omitempty" schema:"humidity2"`
Humidity3 *int `json:"humidity3,omitempty" schema:"humidity3"`
Humidity4 *int `json:"humidity4,omitempty" schema:"humidity4"`
Humidity5 *int `json:"humidity5,omitempty" schema:"humidity5"`
Humidity6 *int `json:"humidity6,omitempty" schema:"humidity6"`
Humidity7 *int `json:"humidity7,omitempty" schema:"humidity7"`
Humidity8 *int `json:"humidity8,omitempty" schema:"humidity8"`
Batt1 *int `json:"batt1,omitempty" schema:"batt1"`
Batt2 *int `json:"batt2,omitempty" schema:"batt2"`
Batt3 *int `json:"batt3,omitempty" schema:"batt3"`
Batt4 *int `json:"batt4,omitempty" schema:"batt4"`
Batt5 *int `json:"batt5,omitempty" schema:"batt5"`
Batt6 *int `json:"batt6,omitempty" schema:"batt6"`
Batt7 *int `json:"batt7,omitempty" schema:"batt7"`
Batt8 *int `json:"batt8,omitempty" schema:"batt8"`
}

View File

@ -4,12 +4,13 @@ import (
"context"
"net/http"
"gitea.libretechconsulting.com/rmcguire/ambient-weather-local-exporter/pkg/weather"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/weather"
)
// Simple interface used for converting Wunderground and
// Ambient Weather Network HTTP requests to a stable struct
type AmbientProvider interface {
ReqToWeather(context.Context, *http.Request) (*weather.WeatherUpdate, error)
ProxyReq(context.Context, *weather.WeatherUpdate) error
Name() string
}

View File

@ -8,12 +8,15 @@ import (
"github.com/gorilla/schema"
"gitea.libretechconsulting.com/rmcguire/ambient-weather-local-exporter/pkg/weather"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/weather"
)
type WUProvider struct{}
const providerName = "weatherunderground"
const (
providerName = "weatherunderground"
wuURL = "https://rtupdate.wunderground.com/weatherstation/updateweatherstation.php"
)
func (wu *WUProvider) Name() string {
return providerName
@ -33,17 +36,22 @@ func (wu *WUProvider) ReqToWeather(_ context.Context, r *http.Request) (
}
func MapWUUpdate(wuUpdate *WundergroundUpdate) *weather.WeatherUpdate {
updateTime, err := time.Parse(time.DateTime, wuUpdate.DateUTC)
if err != nil {
updateTime = time.Now()
updateTime := time.Now()
if wuUpdate.DateUTC != nil {
ut, err := time.Parse(time.DateTime, *wuUpdate.DateUTC)
if err == nil {
updateTime = ut
}
}
return &weather.WeatherUpdate{
StationType: wuUpdate.SoftwareType,
DateUTC: &updateTime,
TempOutdoorF: wuUpdate.Tempf,
HumidityOudoor: wuUpdate.Humidity,
WindSpeedMPH: wuUpdate.WindGustMPH,
StationID: wuUpdate.ID,
StationType: wuUpdate.SoftwareType,
TempOutdoorF: wuUpdate.Tempf,
HumidityOudoor: wuUpdate.Humidity,
WindSpeedMPH: wuUpdate.WindSpeedMPH,
WindGustMPH: wuUpdate.WindGustMPH,
WindDir: wuUpdate.WindDir,
UV: wuUpdate.UV,

View File

@ -0,0 +1,88 @@
package wunderground
import (
"context"
"errors"
"net/url"
"github.com/go-resty/resty/v2"
"github.com/rs/zerolog/log"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"k8s.io/utils/ptr"
"gitea.libretechconsulting.com/rmcguire/go-app/pkg/otel"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/weather"
)
func (wu *WUProvider) ProxyReq(ctx context.Context, update *weather.WeatherUpdate) error {
tracer := otel.GetTracer(ctx, "wuProvider", "proxyReq")
ctx, span := tracer.Start(ctx, "proxyToWunderground")
defer span.End()
if update.StationConfig.WundergroundID == "" {
err := errors.New("no wunderground id set in update")
span.RecordError(err)
return err
} else if update.StationConfig.WundergroundPassword == "" {
err := errors.New("no wunderground id set in update")
span.RecordError(err)
return err
}
params := updateToWuParams(update)
resp, err := resty.New().R().
SetContext(ctx).
SetQueryParamsFromValues(*params).
Get(wuURL)
if err != nil {
span.SetStatus(codes.Error, err.Error())
span.SetAttributes(
attribute.String("query", resp.Request.QueryParam.Encode()),
attribute.String("body", string(resp.Body())),
)
span.RecordError(err)
log.Err(err).
Int("statusCode", resp.StatusCode()).
Any("query", resp.Request.PathParams).
Msg("wunderground proxy failed")
}
span.SetAttributes(
attribute.Int("statusCode", resp.StatusCode()),
)
return err
}
func updateToWuParams(u *weather.WeatherUpdate) *url.Values {
params := &url.Values{}
weather.SetURLVal(params, "ID", &u.StationConfig.WundergroundID)
weather.SetURLVal(params, "PASSWORD", &u.StationConfig.WundergroundPassword)
params.Set("action", "updateraw")
params.Set("dateutc", "now")
weather.SetURLVal(params, "UV", u.UV)
weather.SetURLVal(params, "baromin", u.BaromRelativeIn)
weather.SetURLVal(params, "dailyrainin", u.DailyRainIn)
weather.SetURLVal(params, "dewptf", u.DewPointF)
weather.SetURLVal(params, "humidity", u.HumidityOudoor)
weather.SetURLVal(params, "indoorhumidity", u.HumidityIndoor)
weather.SetURLVal(params, "indoortempf", u.TempIndoorF)
weather.SetURLVal(params, "lowbatt", ptr.To(0))
weather.SetURLVal(params, "monthlyrainin", u.MonthlyRainIn)
weather.SetURLVal(params, "rainin", u.HourlyRainIn)
weather.SetURLVal(params, "realtime", ptr.To(1))
weather.SetURLVal(params, "rtfreq", ptr.To(60))
weather.SetURLVal(params, "softwaretype", u.StationType)
weather.SetURLVal(params, "solarradiation", u.SolarRadiation)
weather.SetURLVal(params, "tempf", u.TempOutdoorF)
weather.SetURLVal(params, "weeklyrainin", u.WeeklyRainIn)
weather.SetURLVal(params, "windchillf", u.WindChillF)
weather.SetURLVal(params, "winddir", u.WindDir)
weather.SetURLVal(params, "windgustmph", u.WindGustMPH)
weather.SetURLVal(params, "windspeedmph", u.WindSpeedMPH)
weather.SetURLVal(params, "yearlyrainin", u.YearlyRainIn)
return params
}

View File

@ -1,29 +1,29 @@
package wunderground
type WundergroundUpdate struct {
ID string `json:"ID,omitempty" schema:"ID"`
Password string `json:"PASSWORD,omitempty" schema:"PASSWORD"`
UV int `json:"UV,omitempty" schema:"UV"`
Action string `json:"action,omitempty" schema:"action"`
BaromIn float64 `json:"baromin,omitempty" schema:"baromin"`
DailyRainIn float64 `json:"dailyrainin,omitempty" schema:"dailyrainin"`
DateUTC string `json:"dateutc,omitempty" schema:"dateutc"`
DewPtF float64 `json:"dewptf,omitempty" schema:"dewptf"`
Humidity int `json:"humidity,omitempty" schema:"humidity"`
IndoorHumidity int `json:"indoorhumidity,omitempty" schema:"indoorhumidity"`
IndoorTempF float64 `json:"indoortempf,omitempty" schema:"indoortempf"`
LowBatt bool `json:"lowbatt,omitempty" schema:"lowbatt"`
MonthlyRainIn float64 `json:"monthlyrainin,omitempty" schema:"monthlyrainin"`
RainIn float64 `json:"rainin,omitempty" schema:"rainin"`
Realtime bool `json:"realtime,omitempty" schema:"realtime"`
Rtfreq int `json:"rtfreq,omitempty" schema:"rtfreq"`
SoftwareType string `json:"softwaretype,omitempty" schema:"softwaretype"`
SolarRadiation float64 `json:"solarradiation,omitempty" schema:"solarradiation"`
Tempf float64 `json:"tempf,omitempty" schema:"tempf"`
WeeklyRainIn float64 `json:"weeklyrainin,omitempty" schema:"weeklyrainin"`
WindChillF float64 `json:"windchillf,omitempty" schema:"windchillf"`
WindDir int `json:"winddir,omitempty" schema:"winddir"`
WindGustMPH float64 `json:"windgustmph,omitempty" schema:"windgustmph"`
WindSpeedMPH float64 `json:"windspeedmph,omitempty" schema:"windspeedmph"`
YearlyRainIn float64 `json:"yearlyrainin,omitempty" schema:"yearlyrainin"`
ID *string `json:"ID,omitempty" schema:"ID"`
Password *string `json:"PASSWORD,omitempty" schema:"PASSWORD"`
UV *int `json:"UV,omitempty" schema:"UV"`
Action *string `json:"action,omitempty" schema:"action"`
BaromIn *float64 `json:"baromin,omitempty" schema:"baromin"`
DailyRainIn *float64 `json:"dailyrainin,omitempty" schema:"dailyrainin"`
DateUTC *string `json:"dateutc,omitempty" schema:"dateutc"`
DewPtF *float64 `json:"dewptf,omitempty" schema:"dewptf"`
Humidity *int `json:"humidity,omitempty" schema:"humidity"`
IndoorHumidity *int `json:"indoorhumidity,omitempty" schema:"indoorhumidity"`
IndoorTempF *float64 `json:"indoortempf,omitempty" schema:"indoortempf"`
LowBatt *bool `json:"lowbatt,omitempty" schema:"lowbatt"`
MonthlyRainIn *float64 `json:"monthlyrainin,omitempty" schema:"monthlyrainin"`
RainIn *float64 `json:"rainin,omitempty" schema:"rainin"`
Realtime *bool `json:"realtime,omitempty" schema:"realtime"`
Rtfreq *int `json:"rtfreq,omitempty" schema:"rtfreq"`
SoftwareType *string `json:"softwaretype,omitempty" schema:"softwaretype"`
SolarRadiation *float64 `json:"solarradiation,omitempty" schema:"solarradiation"`
Tempf *float64 `json:"tempf,omitempty" schema:"tempf"`
WeeklyRainIn *float64 `json:"weeklyrainin,omitempty" schema:"weeklyrainin"`
WindChillF *float64 `json:"windchillf,omitempty" schema:"windchillf"`
WindDir *int `json:"winddir,omitempty" schema:"winddir"`
WindGustMPH *float64 `json:"windgustmph,omitempty" schema:"windgustmph"`
WindSpeedMPH *float64 `json:"windspeedmph,omitempty" schema:"windspeedmph"`
YearlyRainIn *float64 `json:"yearlyrainin,omitempty" schema:"yearlyrainin"`
}

118
pkg/util/util.go Normal file
View File

@ -0,0 +1,118 @@
package util
import (
"crypto/md5"
"encoding/hex"
"fmt"
"slices"
"time"
"gitea.libretechconsulting.com/rmcguire/go-app/pkg/config"
"google.golang.org/protobuf/types/known/timestamppb"
"k8s.io/utils/ptr"
pb "gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/api/v1alpha1/weather"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/weather"
)
const defaultLimit = -1
func DerefStr(s *string) string {
if s == nil {
return ""
}
return *s
}
func Int32ptr(i *int) *int32 {
if i == nil {
return nil
}
return ptr.To(int32(*i))
}
func TimestampFromIntPtr(i *int) *timestamppb.Timestamp {
if i == nil {
return nil
}
return timestamppb.New(time.Unix(int64(*i), 0))
}
// Generates a hash that will be consistent
// across all running replicas
func GetAppHash(conf *config.AppConfig) string {
hashName := fmt.Sprintf("%s-%s-%s",
conf.Name,
conf.Environment,
conf.Version,
)
hash := md5.Sum([]byte(hashName))
return hex.EncodeToString(hash[:])
}
// Get a limit from req, applying a default if unset or not sane
func GetLimitFromReq(req *pb.GetWeatherRequest) int {
if req == nil || req.Limit == nil {
return defaultLimit
}
if req.GetLimit() == 0 {
return defaultLimit
}
return int(req.GetLimit())
}
// Simple helper to trim a list of updates
func LimitUpdates(updates []*weather.WeatherUpdate, limit int) []*weather.WeatherUpdate {
if limit < 0 {
return updates // No limit
}
if len(updates) > limit {
return updates[len(updates)-limit:] // Trim to limit
}
return updates // Within limit
}
func ApplyOptsToUpdates(updates []*weather.WeatherUpdate, limit int, opts *pb.GetWeatherOpts) []*weather.WeatherUpdate {
if opts == nil {
return updates
} else if opts.StationName == nil && opts.StationType == nil {
return updates
}
capacity := len(updates)
if limit > 0 {
capacity = limit
}
filtered := make([]*weather.WeatherUpdate, 0, capacity)
for i := len(updates) - 1; i >= 0; i-- {
update := updates[i]
match := true
if opts.GetStationName() != "" {
if update.GetStationName() != opts.GetStationName() {
match = false
}
}
if opts.GetStationType() != "" {
if DerefStr(update.StationType) != opts.GetStationType() {
match = false
}
}
if match {
filtered = append(filtered, update)
if limit > 0 && len(filtered) >= limit {
return slices.Clip(filtered)
}
}
}
return slices.Clip(filtered)
}

View File

@ -1,27 +1,73 @@
package weather
import "math"
import (
"math"
"net/url"
"strconv"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/ambient/config"
)
// Attempts to complete missing fields that may not
// be set by a specific provider, such as DewPoint and WindChill
func (u *WeatherUpdate) Enrich() {
// TODO: Add span
func (u *WeatherUpdate) Enrich(weatherStations ...*config.WeatherStation) {
if u == nil {
return
}
if u.WindChillF == 0 {
u.WindChillF = CalculateWindChill(u.TempOutdoorF, u.WindSpeedMPH)
// Clear invalid measurements, would be better if these weren't
// sent when sensor was out of range.
// TODO: This should probably just be done for all fields where
// the value is -9999
if u.BaromAbsoluteIn != nil && *u.BaromAbsoluteIn < 20 {
u.BaromAbsoluteIn = nil
}
if u.BaromRelativeIn != nil && *u.BaromRelativeIn < 20 {
u.BaromRelativeIn = nil
}
if u.TempIndoorF != nil && *u.TempIndoorF < -1000 {
u.TempIndoorF = nil
}
if u.DewPointF == 0 {
u.DewPointF = CalculateDewPoint(u.TempOutdoorF, float64(u.HumidityOudoor))
// Calculate Wind Chill
if u.WindChillF == nil && u.TempOutdoorF != nil && u.WindSpeedMPH != nil {
wc := CalculateWindChill(*u.TempOutdoorF, *u.WindSpeedMPH)
u.WindChillF = &wc
}
if u.BaromAbsoluteIn == 0 {
// Calculate Dew Point
if u.DewPointF == nil && (u.TempOutdoorF != nil && u.HumidityOudoor != nil) {
if *u.TempOutdoorF != 0 || *u.HumidityOudoor != 0 {
dp := CalculateDewPoint(*u.TempOutdoorF, float64(*u.HumidityOudoor))
u.DewPointF = &dp
}
}
// Use relative pressure if absolute isn't provided
if u.BaromAbsoluteIn == nil && u.BaromRelativeIn != nil {
u.BaromAbsoluteIn = u.BaromRelativeIn
}
}
// Swaps sensor and component names based on
// user provided configuration
func (u *WeatherUpdate) MapSensors() {
if u.StationConfig == nil {
return
}
// Map sensor battery components
for i, batt := range u.Batteries {
u.Batteries[i].Component = u.StationConfig.MapSensor(batt.Component)
}
// Map other sensors
for _, th := range u.TempHumiditySensors {
th.Name = u.StationConfig.MapSensor(th.Name)
}
}
func CalculateDewPoint(tempF, humidity float64) float64 {
// Convert temperature from Fahrenheit to Celsius
tempC := (tempF - 32) * 5 / 9
@ -49,3 +95,31 @@ func CalculateWindChill(tempF float64, windSpeedMPH float64) float64 {
35.75*math.Pow(windSpeedMPH, 0.16) +
0.4275*tempF*math.Pow(windSpeedMPH, 0.16)
}
// Helper function to set values from fields
// typically from a WeatherUpdate
func SetURLVal(vals *url.Values, key string, value any) {
if value == nil {
return
}
switch v := value.(type) {
case *float64:
if v == nil {
return
}
str := strconv.FormatFloat(*v, 'f', 4, 64)
vals.Set(key, str)
case *int:
if v == nil {
return
}
str := strconv.FormatInt(int64(*v), 10)
vals.Set(key, str)
case *string:
if v == nil {
return
}
vals.Set(key, *v)
}
}

View File

@ -4,145 +4,153 @@ import (
"context"
"gitea.libretechconsulting.com/rmcguire/go-app/pkg/config"
"gitea.libretechconsulting.com/rmcguire/go-app/pkg/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
)
var MetricPrefix = "weather"
type WeatherMetrics struct {
// Weather Metrics
TempOutdoorF metric.Float64Gauge
TempIndoorF metric.Float64Gauge
HumidityOudoor metric.Int64Gauge
HumidityIndoor metric.Int64Gauge
WindSpeedMPH metric.Float64Gauge
WindGustMPH metric.Float64Gauge
MaxDailyGust metric.Float64Gauge
WindDir metric.Int64Gauge
WindDirAvg10m metric.Int64Gauge
UV metric.Int64Gauge
SolarRadiation metric.Float64Gauge
HourlyRainIn metric.Float64Gauge
EventRainIn metric.Float64Gauge
DailyRainIn metric.Float64Gauge
WeeklyRainIn metric.Float64Gauge
MonthlyRainIn metric.Float64Gauge
YearlyRainIn metric.Float64Gauge
TotalRainIn metric.Float64Gauge
BattOutdoorSensor metric.Int64Gauge
BattIndoorSensor metric.Int64Gauge
BattRainSensor metric.Int64Gauge
BaromRelativeIn metric.Float64Gauge
BaromAbsoluteIn metric.Float64Gauge
DewPointF metric.Float64Gauge
WindChillF metric.Float64Gauge
TempOutdoorF metric.Float64Gauge
TempIndoorF metric.Float64Gauge
HumidityOudoor metric.Int64Gauge
HumidityIndoor metric.Int64Gauge
WindSpeedMPH metric.Float64Gauge
WindGustMPH metric.Float64Gauge
MaxDailyGust metric.Float64Gauge
WindDir metric.Int64Gauge
WindDirAvg10m metric.Int64Gauge
UV metric.Int64Gauge
SolarRadiation metric.Float64Gauge
HourlyRainIn metric.Float64Gauge
EventRainIn metric.Float64Gauge
DailyRainIn metric.Float64Gauge
WeeklyRainIn metric.Float64Gauge
MonthlyRainIn metric.Float64Gauge
YearlyRainIn metric.Float64Gauge
TotalRainIn metric.Float64Gauge
BatteryStatus metric.Int64Gauge
BaromRelativeIn metric.Float64Gauge
BaromAbsoluteIn metric.Float64Gauge
DewPointF metric.Float64Gauge
WindChillF metric.Float64Gauge
// Lightning Sensor
LightningCountDay metric.Int64Gauge
LightningLastDistance metric.Int64Gauge
LightningLastTime metric.Int64Gauge
// Temp and Humidity Sensors
SensorTempF metric.Float64Gauge
SensorHumidity metric.Int64Gauge
// Internal Telemetry
UpdatesReceived metric.Int64Counter
appCtx context.Context
cfg *config.AppConfig
meter metric.Meter
}
func MustInitMetrics(appCtx context.Context) *WeatherMetrics {
wm := &WeatherMetrics{
appCtx: appCtx,
cfg: config.MustFromCtx(appCtx),
}
wm.meter = otel.GetMeter(appCtx, "weather", "metrics")
// Weather Metrics
wm.TempOutdoorF, _ = wm.meter.Float64Gauge("weather_temp_outdoor_f",
metric.WithDescription("Outdoor Temperature in Faherenheit"))
wm.TempIndoorF, _ = wm.meter.Float64Gauge("weather_temp_indoor_f",
metric.WithDescription("Indoor Temperature in Faherenheit"))
wm.HumidityOudoor, _ = wm.meter.Int64Gauge("weather_humidity_oudoor",
metric.WithDescription("Outdoor Humidity %"))
wm.HumidityIndoor, _ = wm.meter.Int64Gauge("weather_humidity_indoor",
metric.WithDescription("Indoor Humidity %"))
wm.WindSpeedMPH, _ = wm.meter.Float64Gauge("weather_wind_speed_mph",
metric.WithDescription("Wind Speed in MPH"))
wm.WindGustMPH, _ = wm.meter.Float64Gauge("weather_wind_gust_mph",
metric.WithDescription("Wind Gust in MPH"))
wm.MaxDailyGust, _ = wm.meter.Float64Gauge("weather_max_daily_gust",
metric.WithDescription("Max Daily Wind Gust"))
wm.WindDir, _ = wm.meter.Int64Gauge("weather_wind_dir",
metric.WithDescription("Wind Direction in Degrees"))
wm.WindDirAvg10m, _ = wm.meter.Int64Gauge("weather_wind_dir_avg_10m",
metric.WithDescription("Wind Direction 10m Average"))
wm.UV, _ = wm.meter.Int64Gauge("weather_uv",
metric.WithDescription("UV Index"))
wm.SolarRadiation, _ = wm.meter.Float64Gauge("weather_solar_radiation",
metric.WithDescription("Solar Radiation in W/㎡"))
wm.HourlyRainIn, _ = wm.meter.Float64Gauge("weather_hourly_rain_in",
metric.WithDescription("Hourly Rain in Inches"))
wm.EventRainIn, _ = wm.meter.Float64Gauge("weather_event_rain_in",
metric.WithDescription("Event Rain in Inches"))
wm.DailyRainIn, _ = wm.meter.Float64Gauge("weather_daily_rain_in",
metric.WithDescription("Daily Rain in Inches"))
wm.WeeklyRainIn, _ = wm.meter.Float64Gauge("weather_weekly_rain_in",
metric.WithDescription("Weekly Rain in Inches"))
wm.MonthlyRainIn, _ = wm.meter.Float64Gauge("weather_monthly_rain_in",
metric.WithDescription("Monthly Rain in Inches"))
wm.YearlyRainIn, _ = wm.meter.Float64Gauge("weather_yearly_rain_in",
metric.WithDescription("Yearly Rain in Inches"))
wm.TotalRainIn, _ = wm.meter.Float64Gauge("weather_total_rain_in",
metric.WithDescription("Total Rain in Inches"))
wm.BattOutdoorSensor, _ = wm.meter.Int64Gauge("weather_batt_outdoor_sensor",
metric.WithDescription("Outdoor Equipment Battery"))
wm.BattIndoorSensor, _ = wm.meter.Int64Gauge("weather_batt_indoor_sensor",
metric.WithDescription("Indoor Equipmenet Battery"))
wm.BattRainSensor, _ = wm.meter.Int64Gauge("weather_batt_rain_sensor",
metric.WithDescription("Rain Sensor Battery"))
wm.BaromRelativeIn, _ = wm.meter.Float64Gauge("weather_barometric_pressure_relative_in",
metric.WithDescription("Relative Pressure in Inches of Mercury"))
wm.BaromAbsoluteIn, _ = wm.meter.Float64Gauge("weather_barometric_pressure_absolute_in",
metric.WithDescription("Absolute Pressure in Inches of Mercury"))
wm.DewPointF, _ = wm.meter.Float64Gauge("weather_dew_point_f",
metric.WithDescription("Dew Point in Faherenheit"))
wm.WindChillF, _ = wm.meter.Float64Gauge("weather_wind_chill_f",
metric.WithDescription("Wind Chill in Faherenheit"))
// Internal Telemetry
wm.UpdatesReceived, _ = wm.meter.Int64Counter("weather_updates_received",
metric.WithDescription("Metric Updates Processed by Exporter"))
return wm
recorder *MetricRecorder
}
func (wm *WeatherMetrics) Update(u *WeatherUpdate) {
attributes := attribute.NewSet(
semconv.ServiceVersion(wm.cfg.Version),
attribute.String("station_type", u.StationType),
)
attributes := wm.GetAttributes(u)
wm.TempOutdoorF.Record(wm.appCtx, u.TempOutdoorF, metric.WithAttributeSet(attributes))
wm.TempIndoorF.Record(wm.appCtx, u.TempIndoorF, metric.WithAttributeSet(attributes))
wm.HumidityOudoor.Record(wm.appCtx, int64(u.HumidityOudoor), metric.WithAttributeSet(attributes))
wm.HumidityIndoor.Record(wm.appCtx, int64(u.HumidityIndoor), metric.WithAttributeSet(attributes))
wm.WindSpeedMPH.Record(wm.appCtx, u.WindSpeedMPH, metric.WithAttributeSet(attributes))
wm.WindGustMPH.Record(wm.appCtx, u.WindGustMPH, metric.WithAttributeSet(attributes))
wm.MaxDailyGust.Record(wm.appCtx, u.MaxDailyGust, metric.WithAttributeSet(attributes))
wm.WindDir.Record(wm.appCtx, int64(u.WindDir), metric.WithAttributeSet(attributes))
wm.WindDirAvg10m.Record(wm.appCtx, int64(u.WindDirAvg10m), metric.WithAttributeSet(attributes))
wm.UV.Record(wm.appCtx, int64(u.UV), metric.WithAttributeSet(attributes))
wm.SolarRadiation.Record(wm.appCtx, u.SolarRadiation, metric.WithAttributeSet(attributes))
wm.HourlyRainIn.Record(wm.appCtx, u.HourlyRainIn, metric.WithAttributeSet(attributes))
wm.EventRainIn.Record(wm.appCtx, u.EventRainIn, metric.WithAttributeSet(attributes))
wm.DailyRainIn.Record(wm.appCtx, u.DailyRainIn, metric.WithAttributeSet(attributes))
wm.WeeklyRainIn.Record(wm.appCtx, u.WeeklyRainIn, metric.WithAttributeSet(attributes))
wm.MonthlyRainIn.Record(wm.appCtx, u.MonthlyRainIn, metric.WithAttributeSet(attributes))
wm.YearlyRainIn.Record(wm.appCtx, u.YearlyRainIn, metric.WithAttributeSet(attributes))
wm.TotalRainIn.Record(wm.appCtx, u.TotalRainIn, metric.WithAttributeSet(attributes))
wm.BattOutdoorSensor.Record(wm.appCtx, int64(u.BattOutdoorSensor), metric.WithAttributeSet(attributes))
wm.BattIndoorSensor.Record(wm.appCtx, int64(u.BattIndoorSensor), metric.WithAttributeSet(attributes))
wm.BattRainSensor.Record(wm.appCtx, int64(u.BattRainSensor), metric.WithAttributeSet(attributes))
wm.BaromRelativeIn.Record(wm.appCtx, u.BaromRelativeIn, metric.WithAttributeSet(attributes))
wm.BaromAbsoluteIn.Record(wm.appCtx, u.BaromAbsoluteIn, metric.WithAttributeSet(attributes))
wm.DewPointF.Record(wm.appCtx, u.DewPointF, metric.WithAttributeSet(attributes))
wm.WindChillF.Record(wm.appCtx, u.WindChillF, metric.WithAttributeSet(attributes))
wm.recorder.Record(&RecordOpts{Float64Gauge: wm.TempOutdoorF, FloatVal: u.TempOutdoorF, Field: FieldTempOutdoorF, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Float64Gauge: wm.TempIndoorF, FloatVal: u.TempIndoorF, Field: FieldTempIndoorF, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Int64Gauge: wm.HumidityOudoor, IntVal: u.HumidityOudoor, Field: FieldHumidityOudoor, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Int64Gauge: wm.HumidityIndoor, IntVal: u.HumidityIndoor, Field: FieldHumidityIndoor, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Float64Gauge: wm.WindSpeedMPH, FloatVal: u.WindSpeedMPH, Field: FieldWindSpeedMPH, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Float64Gauge: wm.WindGustMPH, FloatVal: u.WindGustMPH, Field: FieldWindGustMPH, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Float64Gauge: wm.MaxDailyGust, FloatVal: u.MaxDailyGust, Field: FieldMaxDailyGust, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Int64Gauge: wm.WindDir, IntVal: u.WindDir, Field: FieldWindDir, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Int64Gauge: wm.WindDirAvg10m, IntVal: u.WindDirAvg10m, Field: FieldWindDirAvg10m, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Int64Gauge: wm.UV, IntVal: u.UV, Field: FieldUV, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Float64Gauge: wm.SolarRadiation, FloatVal: u.SolarRadiation, Field: FieldSolarRadiation, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Float64Gauge: wm.HourlyRainIn, FloatVal: u.HourlyRainIn, Field: FieldHourlyRainIn, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Float64Gauge: wm.EventRainIn, FloatVal: u.EventRainIn, Field: FieldEventRainIn, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Float64Gauge: wm.DailyRainIn, FloatVal: u.DailyRainIn, Field: FieldDailyRainIn, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Float64Gauge: wm.WeeklyRainIn, FloatVal: u.WeeklyRainIn, Field: FieldWeeklyRainIn, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Float64Gauge: wm.MonthlyRainIn, FloatVal: u.MonthlyRainIn, Field: FieldMonthlyRainIn, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Float64Gauge: wm.YearlyRainIn, FloatVal: u.YearlyRainIn, Field: FieldYearlyRainIn, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Float64Gauge: wm.TotalRainIn, FloatVal: u.TotalRainIn, Field: FieldTotalRainIn, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Float64Gauge: wm.BaromRelativeIn, FloatVal: u.BaromRelativeIn, Field: FieldBaromRelativeIn, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Float64Gauge: wm.BaromAbsoluteIn, FloatVal: u.BaromAbsoluteIn, Field: FieldBaromAbsoluteIn, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Float64Gauge: wm.DewPointF, FloatVal: u.DewPointF, Field: FieldDewPointF, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Float64Gauge: wm.WindChillF, FloatVal: u.WindChillF, Field: FieldWindChillF, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Int64Gauge: wm.LightningLastDistance, IntVal: u.LightningDistance, Field: FieldLightningDistance, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Int64Gauge: wm.LightningCountDay, IntVal: u.LightningDay, Field: FieldLightningDay, Attributes: attributes, Station: u.StationConfig})
wm.recorder.Record(&RecordOpts{Int64Gauge: wm.LightningLastTime, IntVal: u.LightningLastTime, Field: FieldLightningLastTime, Attributes: attributes, Station: u.StationConfig})
wm.RecordBatteries(u, attributes)
wm.RecordTempHumiditySensors(u, attributes)
wm.UpdatesReceived.Add(wm.appCtx, 1)
}
func (wm *WeatherMetrics) RecordBatteries(u *WeatherUpdate, attr []attribute.KeyValue) {
for _, battery := range u.Batteries {
batAttr := attr
batAttr = append(batAttr, attribute.String("component", battery.Component))
wm.recorder.Record(&RecordOpts{
Int64Gauge: wm.BatteryStatus,
IntVal: battery.Status,
Field: FieldBatteries,
Attributes: batAttr,
Station: u.StationConfig,
})
}
}
func (wm *WeatherMetrics) RecordTempHumiditySensors(u *WeatherUpdate, attr []attribute.KeyValue) {
if u == nil || u.TempHumiditySensors == nil {
return
}
for _, sensor := range u.TempHumiditySensors {
sensorAttr := attr
sensorAttr = append(sensorAttr, attribute.String("sensorName", sensor.Name))
wm.recorder.Record(&RecordOpts{
Float64Gauge: wm.SensorTempF,
FloatVal: sensor.TempF,
Field: FieldSensorTempF,
Attributes: sensorAttr,
Station: u.StationConfig,
})
wm.recorder.Record(&RecordOpts{
Int64Gauge: wm.SensorHumidity,
IntVal: sensor.Humidity,
Field: FieldSensorHumidity,
Attributes: sensorAttr,
Station: u.StationConfig,
})
}
}
func (wm *WeatherMetrics) GetAttributes(u *WeatherUpdate) []attribute.KeyValue {
attributes := []attribute.KeyValue{
semconv.ServiceVersion(wm.cfg.Version),
semconv.ServiceName(wm.cfg.Name),
semconv.DeploymentEnvironment(wm.cfg.Environment),
}
if u.StationType != nil {
attributes = append(attributes,
attribute.String("station_type", *u.StationType))
}
if u.StationConfig != nil {
if u.StationConfig.Name != "" {
attributes = append(attributes,
attribute.String("station_name", u.StationConfig.Name))
}
if u.StationConfig.Equipment != "" {
attributes = append(attributes,
attribute.String("station_equipment", u.StationConfig.Equipment))
}
}
return attributes
}

View File

@ -0,0 +1,90 @@
package weather
import (
"context"
"gitea.libretechconsulting.com/rmcguire/go-app/pkg/config"
"gitea.libretechconsulting.com/rmcguire/go-app/pkg/otel"
"github.com/rs/zerolog"
"go.opentelemetry.io/otel/metric"
)
func MustInitMetrics(appCtx context.Context) *WeatherMetrics {
wm := &WeatherMetrics{
appCtx: appCtx,
cfg: config.MustFromCtx(appCtx),
recorder: &MetricRecorder{ctx: appCtx, l: zerolog.Ctx(appCtx)},
}
wm.meter = otel.GetMeter(appCtx, "weather", "metrics")
// Weather Metrics
wm.TempOutdoorF, _ = wm.meter.Float64Gauge(MetricPrefix+"_temp_outdoor_f",
metric.WithDescription("Outdoor Temperature in Faherenheit"))
wm.TempIndoorF, _ = wm.meter.Float64Gauge(MetricPrefix+"_temp_indoor_f",
metric.WithDescription("Indoor Temperature in Faherenheit"))
wm.HumidityOudoor, _ = wm.meter.Int64Gauge(MetricPrefix+"_humidity_outdoor",
metric.WithDescription("Outdoor Humidity %"))
wm.HumidityIndoor, _ = wm.meter.Int64Gauge(MetricPrefix+"_humidity_indoor",
metric.WithDescription("Indoor Humidity %"))
wm.WindSpeedMPH, _ = wm.meter.Float64Gauge(MetricPrefix+"_wind_speed_mph",
metric.WithDescription("Wind Speed in MPH"))
wm.WindGustMPH, _ = wm.meter.Float64Gauge(MetricPrefix+"_wind_gust_mph",
metric.WithDescription("Wind Gust in MPH"))
wm.MaxDailyGust, _ = wm.meter.Float64Gauge(MetricPrefix+"_max_daily_gust",
metric.WithDescription("Max Daily Wind Gust"))
wm.WindDir, _ = wm.meter.Int64Gauge(MetricPrefix+"_wind_dir",
metric.WithDescription("Wind Direction in Degrees"))
wm.WindDirAvg10m, _ = wm.meter.Int64Gauge(MetricPrefix+"_wind_dir_avg_10m",
metric.WithDescription("Wind Direction 10m Average"))
wm.UV, _ = wm.meter.Int64Gauge(MetricPrefix+"_uv",
metric.WithDescription("UV Index"))
wm.SolarRadiation, _ = wm.meter.Float64Gauge(MetricPrefix+"_solar_radiation",
metric.WithDescription("Solar Radiation in W/㎡"))
wm.HourlyRainIn, _ = wm.meter.Float64Gauge(MetricPrefix+"_hourly_rain_in",
metric.WithDescription("Hourly Rain in Inches"))
wm.EventRainIn, _ = wm.meter.Float64Gauge(MetricPrefix+"_event_rain_in",
metric.WithDescription("Event Rain in Inches"))
wm.DailyRainIn, _ = wm.meter.Float64Gauge(MetricPrefix+"_daily_rain_in",
metric.WithDescription("Daily Rain in Inches"))
wm.WeeklyRainIn, _ = wm.meter.Float64Gauge(MetricPrefix+"_weekly_rain_in",
metric.WithDescription("Weekly Rain in Inches"))
wm.MonthlyRainIn, _ = wm.meter.Float64Gauge(MetricPrefix+"_monthly_rain_in",
metric.WithDescription("Monthly Rain in Inches"))
wm.YearlyRainIn, _ = wm.meter.Float64Gauge(MetricPrefix+"_yearly_rain_in",
metric.WithDescription("Yearly Rain in Inches"))
wm.TotalRainIn, _ = wm.meter.Float64Gauge(MetricPrefix+"_total_rain_in",
metric.WithDescription("Total Rain in Inches"))
wm.BatteryStatus, _ = wm.meter.Int64Gauge(MetricPrefix+"_battery_status",
metric.WithDescription("Per-component battery status"))
wm.BaromRelativeIn, _ = wm.meter.Float64Gauge(MetricPrefix+"_barometric_pressure_relative_in",
metric.WithDescription("Relative Pressure in Inches of Mercury"))
wm.BaromAbsoluteIn, _ = wm.meter.Float64Gauge(MetricPrefix+"_barometric_pressure_absolute_in",
metric.WithDescription("Absolute Pressure in Inches of Mercury"))
wm.DewPointF, _ = wm.meter.Float64Gauge(MetricPrefix+"_dew_point_f",
metric.WithDescription("Dew Point in Faherenheit"))
wm.WindChillF, _ = wm.meter.Float64Gauge(MetricPrefix+"_wind_chill_f",
metric.WithDescription("Wind Chill in Faherenheit"))
// Lightning Sensor
wm.LightningCountDay, _ = wm.meter.Int64Gauge(MetricPrefix+"_lightning_day",
metric.WithDescription("Count of lightning strikes for current day"))
wm.LightningLastDistance, _ = wm.meter.Int64Gauge(MetricPrefix+"_lightning_last_distance",
metric.WithDescription("Last measured lightning distance"),
metric.WithUnit("km"))
wm.LightningLastTime, _ = wm.meter.Int64Gauge(MetricPrefix+"_lightning_last_time",
metric.WithDescription("Last lightning time (epoch)"),
metric.WithUnit("s"))
// Temp and Humidity Sensors
wm.SensorTempF, _ = wm.meter.Float64Gauge(MetricPrefix+"_sensor_temp_f",
metric.WithDescription("Temperature Sensor in Faherenheit"))
wm.SensorHumidity, _ = wm.meter.Int64Gauge(MetricPrefix+"_sensor_humidity",
metric.WithDescription("Humidity % Sensor"))
// Internal Telemetry
wm.UpdatesReceived, _ = wm.meter.Int64Counter(MetricPrefix+"_updates_received",
metric.WithDescription("Metric Updates Processed by Exporter"))
return wm
}

View File

@ -0,0 +1,121 @@
package weather
import (
"context"
"errors"
"slices"
"github.com/rs/zerolog"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/ambient/config"
)
type MetricRecorder struct {
ctx context.Context
l *zerolog.Logger
}
type RecordOpts struct {
Float64Gauge metric.Float64Gauge
Int64Gauge metric.Int64Gauge
Int64Counter metric.Int64Counter
IntVal *int
FloatVal *float64
Attributes []attribute.KeyValue
Field string
Station *config.WeatherStation
}
func (r *MetricRecorder) Record(opts *RecordOpts) {
if opts.Station != nil && !opts.keep() {
r.l.Trace().
Str("field", string(opts.Field)).
Str("station", opts.Station.Name).
Msg("Metric dropped by station config")
return
} else if opts.Int64Gauge == nil && opts.Float64Gauge == nil {
r.l.Err(errors.New("neither int nor float gauge provided")).Send()
return
}
if opts.Int64Gauge != nil {
if opts.IntVal == nil {
log := r.l.Trace().Str("field", string(opts.Field))
if opts.Station != nil {
log = log.Str("station", opts.Station.Name)
}
log.Msg("Dropping nil int metric")
return
}
r.recordInt(opts.Int64Gauge, *opts.IntVal, opts.Attributes...)
} else if opts.Float64Gauge != nil {
if opts.FloatVal == nil {
log := r.l.Trace().Str("field", string(opts.Field))
if opts.Station != nil {
log = log.Str("station", opts.Station.Name)
}
log.Msg("Dropping nil float metric")
return
}
r.recordFloat(opts.Float64Gauge, *opts.FloatVal, opts.Attributes...)
} else if opts.Int64Counter != nil {
if opts.IntVal == nil {
log := r.l.Trace().Str("field", string(opts.Field))
if opts.Station != nil {
log = log.Str("station", opts.Station.Name)
}
log.Msg("Dropping nil float metric")
return
}
r.recordCounter(opts.Int64Counter, *opts.IntVal, opts.Attributes...)
}
}
func (o *RecordOpts) keep() bool {
// If keep fields are given, only check keep fields
if len(o.Station.KeepMetrics) > 0 {
return slices.Contains(o.Station.KeepMetrics, o.Field)
}
return !slices.Contains(o.Station.DropMetrics, o.Field)
}
func (r *MetricRecorder) recordCounter(
m metric.Int64Counter, value int, attributes ...attribute.KeyValue,
) {
// Prepare metric attributes
options := make([]metric.AddOption, 0, len(attributes))
if len(attributes) > 0 {
options = append(options, metric.WithAttributes(attributes...))
}
val := int64(value)
m.Add(r.ctx, val, options...)
}
func (r *MetricRecorder) recordInt(
m metric.Int64Gauge, value int, attributes ...attribute.KeyValue,
) {
// Prepare metric attributes
options := make([]metric.RecordOption, 0, len(attributes))
if len(attributes) > 0 {
options = append(options, metric.WithAttributes(attributes...))
}
val := int64(value)
m.Record(r.ctx, val, options...)
}
func (r *MetricRecorder) recordFloat(
m metric.Float64Gauge, value float64, attributes ...attribute.KeyValue,
) {
// Prepare metric attributes
options := make([]metric.RecordOption, 0, len(attributes))
if len(attributes) > 0 {
options = append(options, metric.WithAttributes(attributes...))
}
m.Record(r.ctx, value, options...)
}

View File

@ -0,0 +1,61 @@
package recorder
import (
"context"
"sync"
"github.com/rs/zerolog"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/trace"
"gitea.libretechconsulting.com/rmcguire/go-app/pkg/otel"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/ambient/config"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/weather/recorder/recorders"
)
type WeatherRecorder struct {
recorder recorders.Recorder
ctx context.Context
tracer trace.Tracer
meter metric.Meter
*sync.RWMutex
}
type Opts struct {
AppConfig *config.AmbientLocalExporterConfig
Ctx context.Context
Recorder recorders.Recorder // If nil, will use memory recorder
KeepLast int
}
func (r *WeatherRecorder) Ping(ctx context.Context) error {
return r.recorder.Ping(ctx)
}
func MustNewWeatherRecorder(opts *Opts) *WeatherRecorder {
if opts.KeepLast < 1 {
opts.KeepLast = 1
}
if opts.Recorder == nil {
panic("no recorder provided")
}
opts.Recorder.Init(opts.Ctx, &recorders.RecorderOpts{
AppConfig: opts.AppConfig,
RetainLast: opts.KeepLast,
BaseCtx: opts.Ctx,
})
zerolog.Ctx(opts.Ctx).Info().Str("recorderType", opts.Recorder.Name()).
Msg("weather update recorder ready")
return &WeatherRecorder{
ctx: opts.Ctx,
recorder: opts.Recorder,
tracer: otel.GetTracer(opts.Ctx, "weatherRecorder"),
meter: otel.GetMeter(opts.Ctx, "weatherRecorder"),
RWMutex: &sync.RWMutex{},
}
}

View File

@ -0,0 +1,65 @@
package recorder
import (
"context"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"k8s.io/utils/ptr"
pb "gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/api/v1alpha1/weather"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/weather"
)
// Returns last requested number of weather updates
// If negative number given, will return all weather observations
func (w *WeatherRecorder) Get(ctx context.Context, req *pb.GetWeatherRequest) (
[]*weather.WeatherUpdate, error,
) {
if req == nil {
req = &pb.GetWeatherRequest{Limit: ptr.To(int32(-1))}
}
if req.Limit == nil || *req.Limit < 0 {
req.Limit = ptr.To(int32(-1))
} else if *req.Limit == 0 {
req.Limit = ptr.To(int32(1))
}
var filterSN, filterST string
if req.Opts != nil {
filterSN = req.Opts.GetStationName()
filterST = req.Opts.GetStationType()
}
ctx, span := w.tracer.Start(ctx, "getWeatherRecorder")
span.SetAttributes(
attribute.String("stationNameFilter", filterSN),
attribute.String("stationTypeFilter", filterST),
attribute.Int("last", int(*req.Limit)),
attribute.Int("currentSize", w.Count(ctx)),
)
defer span.End()
updates, err := w.recorder.Get(ctx, req)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
} else {
span.SetStatus(codes.Ok, "")
}
return updates, err
}
// Returns count of retained weather updates
func (w *WeatherRecorder) Count(ctx context.Context) int {
ctx, span := w.tracer.Start(ctx, "countWeatherRecorder")
defer span.End()
count := w.recorder.Count(ctx)
span.SetAttributes(attribute.Int("count", count))
span.SetStatus(codes.Ok, "")
return count
}

View File

@ -0,0 +1,33 @@
package recorder
import (
"context"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/ambient/config"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/util"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/weather"
)
func (w *WeatherRecorder) Set(ctx context.Context, u *weather.WeatherUpdate) error {
if u.StationConfig == nil {
u.StationConfig = &config.WeatherStation{
Name: "unregistered",
Equipment: "unknown",
ProxyToAWN: false,
ProxyToWunderground: false,
KeepMetrics: []string{},
}
}
ctx, span := w.tracer.Start(ctx, "setRecorderUpdate", trace.WithAttributes(
attribute.String("stationName", u.StationConfig.Name),
attribute.String("stationType", util.DerefStr(u.StationType)),
attribute.String("stationEquipment", u.StationConfig.Equipment),
))
defer span.End()
return w.recorder.Set(ctx, u)
}

View File

@ -0,0 +1,24 @@
package memory
import (
"context"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
)
func (r *MemoryRecorder) Count(ctx context.Context) int {
_, span := r.tracer.Start(ctx, "countWeatherRecorder")
defer span.End()
count := r.count()
span.SetAttributes(attribute.Int("count", count))
span.SetStatus(codes.Ok, "")
return count
}
func (r *MemoryRecorder) count() int {
return len(r.updates)
}

View File

@ -0,0 +1,50 @@
package memory
import (
"context"
"errors"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
pb "gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/api/v1alpha1/weather"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/util"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/weather"
)
func (r *MemoryRecorder) Get(ctx context.Context, req *pb.GetWeatherRequest) (
[]*weather.WeatherUpdate, error,
) {
_, span := r.tracer.Start(ctx, "memoryRecorder.Get")
defer span.End()
r.RLock()
defer r.RUnlock()
span.AddEvent("acquired lock on recorder cache")
limit := util.GetLimitFromReq(req)
if r.count() == 0 {
err := errors.New("no recorded updates to get")
span.RecordError(err)
return nil, err
} else if limit > 0 && r.count() <= limit {
span.RecordError(errors.New("requested more updates than recorded"))
}
updates := r.getUpdatesFromReq(req)
span.AddEvent("request limit/opts applied to updates")
span.SetAttributes(attribute.Int("retrieved", len(updates)))
span.SetStatus(codes.Ok, "")
return updates, nil
}
func (r *MemoryRecorder) getUpdatesFromReq(req *pb.GetWeatherRequest) []*weather.WeatherUpdate {
if req.Opts == nil {
return util.LimitUpdates(r.updates, util.GetLimitFromReq(req))
}
return util.ApplyOptsToUpdates(r.updates, util.GetLimitFromReq(req), req.Opts)
}

View File

@ -0,0 +1,45 @@
package memory
import (
"context"
"sync"
"go.opentelemetry.io/otel/trace"
"gitea.libretechconsulting.com/rmcguire/go-app/pkg/otel"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/weather"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/weather/recorder/recorders"
)
const (
DEF_RETAIN_LAST = 120
NAME = "memory recorder"
)
type MemoryRecorder struct {
baseCtx context.Context
updates []*weather.WeatherUpdate
tracer trace.Tracer
keep int
*sync.RWMutex
}
// No meaningful health check to do here
func (r *MemoryRecorder) Ping(ctx context.Context) error {
return nil
}
func (r *MemoryRecorder) Init(ctx context.Context, opts *recorders.RecorderOpts) {
if opts.RetainLast < 1 {
opts.RetainLast = DEF_RETAIN_LAST
}
r.updates = make([]*weather.WeatherUpdate, 0, opts.RetainLast)
r.keep = opts.RetainLast
r.baseCtx = opts.BaseCtx
r.RWMutex = &sync.RWMutex{}
r.tracer = otel.GetTracer(r.baseCtx, "memoryRecorder")
}
func (r *MemoryRecorder) Name() string { return NAME }

View File

@ -0,0 +1,38 @@
package memory
import (
"context"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/weather"
)
func (r *MemoryRecorder) Set(ctx context.Context, u *weather.WeatherUpdate) error {
r.Lock()
defer r.Unlock()
ctx, span := r.tracer.Start(ctx, "memoryRecorder.Set")
span.SetAttributes(
attribute.Int("countWeatherUpdates", r.Count(ctx)),
attribute.Int("keepUpdates", r.keep),
)
defer span.End()
return r.set(ctx, u)
}
func (r *MemoryRecorder) set(ctx context.Context, u *weather.WeatherUpdate) error {
span := trace.SpanFromContext(ctx)
if len(r.updates) > r.keep {
r.updates = r.updates[1:]
span.AddEvent("trimmed recorded updates by 1")
}
r.updates = append(r.updates, u)
span.AddEvent("recorded weather update")
return nil
}

View File

@ -0,0 +1,25 @@
package noop
import (
"context"
pb "gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/api/v1alpha1/weather"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/weather"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/weather/recorder/recorders"
)
type NoopRecorder struct{}
func (n *NoopRecorder) Init(context.Context, *recorders.RecorderOpts) {}
func (n *NoopRecorder) Set(context.Context, *weather.WeatherUpdate) error { return nil }
func (n *NoopRecorder) Get(context.Context, *pb.GetWeatherRequest) ([]*weather.WeatherUpdate, error) {
return nil, nil
}
func (n *NoopRecorder) Count(context.Context) int { return 0 }
func (n *NoopRecorder) Ping(context.Context) error { return nil }
func (r *NoopRecorder) Name() string { return "no-op recorder" }

View File

@ -0,0 +1,24 @@
package recorders
import (
"context"
pb "gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/api/v1alpha1/weather"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/ambient/config"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/weather"
)
type RecorderOpts struct {
RetainLast int
BaseCtx context.Context
AppConfig *config.AmbientLocalExporterConfig
}
type Recorder interface {
Init(context.Context, *RecorderOpts)
Set(context.Context, *weather.WeatherUpdate) error
Get(context.Context, *pb.GetWeatherRequest) ([]*weather.WeatherUpdate, error)
Count(context.Context) int // Best Effort
Ping(context.Context) error
Name() string
}

View File

@ -0,0 +1,38 @@
package redis
import (
"context"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
func (r *RedisRecorder) Count(ctx context.Context) int {
ctx, span := r.tracer.Start(ctx, "redisRecorder.count")
defer span.End()
r.RLock()
defer r.RUnlock()
return r.count(ctx)
}
func (r *RedisRecorder) count(ctx context.Context) int {
ctx, span := r.tracer.Start(ctx, "redisRecorder.count.redis", trace.WithAttributes(
attribute.String("updatesKey", r.Key())))
defer span.End()
count, err := r.redis.LLen(ctx, r.Key()).Result()
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
r.log.Err(err).Send()
return int(count)
}
span.SetAttributes(attribute.Int64("updatesCount", count))
span.SetStatus(codes.Ok, "")
return int(count)
}

View File

@ -0,0 +1,98 @@
package redis
import (
"context"
"encoding/json"
"errors"
"slices"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
pb "gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/api/v1alpha1/weather"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/util"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/weather"
)
func (r *RedisRecorder) Get(ctx context.Context, req *pb.GetWeatherRequest) (
[]*weather.WeatherUpdate, error,
) {
ctx, span := r.tracer.Start(ctx, "redisRecorder.get", trace.WithAttributes(
attribute.Int("limit", util.GetLimitFromReq(req)),
))
defer span.End()
return r.get(ctx, req)
}
func (r *RedisRecorder) get(ctx context.Context, req *pb.GetWeatherRequest) (
[]*weather.WeatherUpdate, error,
) {
ctx, span := r.tracer.Start(ctx, "redisRecorder.get.redis")
defer span.End()
limit := util.GetLimitFromReq(req)
if limit < 1 {
limit = r.keep
}
span.SetAttributes(attribute.Int("limit", limit))
r.log.Debug().Int("limit", limit).Msg("getting updates from redis")
datas, err := r.redis.LRange(ctx, r.Key(), 0, int64(limit)-1).Result()
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
r.log.Err(err).Send()
return nil, err
}
span.AddEvent("redis queried")
r.log.Debug().Int("results", len(datas)).Msg("redis queried")
updates, err := jsonDatasToUpdates(datas)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
r.log.Err(err).Send()
} else {
span.SetStatus(codes.Ok, "")
}
span.AddEvent("results unmarshalled")
span.SetAttributes(attribute.Int("results", len(updates)))
filtered := util.ApplyOptsToUpdates(updates, limit, req.Opts)
span.AddEvent("results filtered")
span.SetAttributes(
attribute.Int("filteredResults", len(filtered)),
attribute.Int("resultsFiltered", len(updates)-len(filtered)),
)
r.log.Debug().
Int("updatesRetrieved", len(updates)).
Int("updatesAfterFiltering", len(filtered)).
Int("updatesFiltered", len(updates)-len(filtered)).
Msg("updates retrieved from redis")
return filtered, err
}
func jsonDatasToUpdates(datas []string) ([]*weather.WeatherUpdate, error) {
var errs error
updates := make([]*weather.WeatherUpdate, 0, len(datas))
for _, data := range datas {
update := new(weather.WeatherUpdate)
err := json.Unmarshal([]byte(data), update)
errs = errors.Join(errs, err)
if err == nil {
updates = append(updates, update)
}
}
return slices.Clip(updates), errs
}

View File

@ -0,0 +1,136 @@
package redis
import (
"context"
"crypto/tls"
"fmt"
"sync"
"time"
"gitea.libretechconsulting.com/rmcguire/go-app/pkg/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
redis "github.com/redis/go-redis/v9"
"github.com/rs/zerolog"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/ambient/config"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/util"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/weather/recorder/recorders"
)
const (
DEF_TTL = "24h"
DEF_RETAIN = 120
UPDATES_KEY = "weatherUpdates"
NAME = "redis recorder"
)
type RedisRecorder struct {
baseCtx context.Context
keyTTL time.Duration
tracer trace.Tracer
redis *redis.Client
config *config.AmbientLocalExporterConfig
log *zerolog.Logger
appKey string // prefix for redis keys, uses app name, environment, and version
keep int
*sync.RWMutex
}
func (r *RedisRecorder) Ping(ctx context.Context) error {
_, err := r.redis.Ping(ctx).Result()
return err
}
func (r *RedisRecorder) Init(ctx context.Context, opts *recorders.RecorderOpts) {
r.baseCtx = opts.BaseCtx
r.log = zerolog.Ctx(r.baseCtx)
if opts.AppConfig.RecorderConfig.RedisConfig == nil {
r.log.Panic().Msg("refusing to init redis recorder with no redisConfig")
}
if opts.RetainLast < 1 {
opts.RetainLast = DEF_RETAIN
}
r.config = opts.AppConfig
r.keep = opts.RetainLast
r.RWMutex = &sync.RWMutex{}
keyTTL := opts.AppConfig.RecorderConfig.RedisConfig.RedisKeyTTL
if keyTTL == "" {
keyTTL = DEF_TTL
}
var err error
r.keyTTL, err = time.ParseDuration(keyTTL)
if err != nil {
r.log.Panic().Str("redisKeyTTL", keyTTL).Err(err).
Msg("invalid redis key ttl provided")
}
r.tracer = otel.GetTracer(r.baseCtx, "redisRecorder")
ctx, span := r.tracer.Start(ctx, "redisRecorder.init", trace.WithAttributes(
attribute.String("redisHost", opts.AppConfig.RecorderConfig.RedisConfig.RedisHost),
attribute.Int("retainLast", opts.RetainLast),
attribute.Int("redisPort", opts.AppConfig.RecorderConfig.RedisConfig.RedisPort),
attribute.Bool("tls", opts.AppConfig.RecorderConfig.RedisConfig.RedisTLS),
attribute.String("keyTTL", r.keyTTL.String()),
))
defer span.End()
// Unique key prefix for this version/env/name of exporter
// will be consistent across replicas, but resets on upgrade
// as it is using version
r.appKey = util.GetAppHash(r.config.AppConfig)
r.MustInitRedis(ctx)
}
func (r *RedisRecorder) MustInitRedis(ctx context.Context) {
ctx, span := r.tracer.Start(ctx, "redisRecorder.init.redis")
defer span.End()
rc := r.config.RecorderConfig.RedisConfig
var tlsConfig *tls.Config
if rc.RedisTLS {
tlsConfig = &tls.Config{
ServerName: rc.RedisHost,
InsecureSkipVerify: rc.RedisTLSInsecure,
}
}
r.redis = redis.NewClient(&redis.Options{
Addr: fmt.Sprintf("%s:%d", rc.RedisHost, rc.RedisPort),
ClientName: fmt.Sprintf("%s-%s", r.config.Name, r.config.Environment),
Username: rc.RedisUser,
Password: rc.RedisPassword,
DB: rc.RedisDB,
TLSConfig: tlsConfig,
})
span.AddEvent("redis client ready")
resp := r.redis.Ping(ctx)
if resp.Err() != nil {
span.RecordError(resp.Err())
span.SetStatus(codes.Error, resp.Err().Error())
r.log.Fatal().Err(resp.Err()).Msg("failed to ping redis")
}
span.AddEvent("redis client ping ok")
span.SetStatus(codes.Ok, "")
r.log.Info().Str("appKey", r.appKey).
Msg("redis ping ok, client ready")
}
func (r *RedisRecorder) Key() string {
return fmt.Sprintf("%s:%s", r.appKey, UPDATES_KEY)
}
func (r *RedisRecorder) Name() string { return NAME }

View File

@ -0,0 +1,75 @@
package redis
import (
"context"
"encoding/json"
"github.com/redis/go-redis/v9"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/util"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/weather"
)
func (r *RedisRecorder) Set(ctx context.Context, u *weather.WeatherUpdate) error {
ctx, span := r.tracer.Start(ctx, "redisRecorder.set", trace.WithAttributes(
attribute.String("stationName", u.GetStationName()),
attribute.String("stationType", util.DerefStr(u.StationType)),
))
defer span.End()
r.Lock()
defer r.Unlock()
// First ensure we can prepare our payload
data, err := json.Marshal(u)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
r.log.Err(err).Send()
return err
}
return r.set(ctx, data)
}
func (r *RedisRecorder) set(ctx context.Context, data []byte) error {
ctx, span := r.tracer.Start(ctx, "redisRecorder.set.push", trace.WithAttributes(
attribute.Int("updateBytes", len(data)),
))
defer span.End()
// Atomic, push and trim
var count *redis.IntCmd
rErr, err := r.redis.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
pipe.LPush(ctx, r.Key(), data)
pipe.LTrim(ctx, r.Key(), 0, int64(r.keep)-1)
count = pipe.LLen(ctx, r.Key())
pipe.Expire(ctx, r.Key(), r.keyTTL)
return nil
})
if err != nil {
for _, cmd := range rErr {
span.RecordError(cmd.Err())
}
span.SetStatus(codes.Error, err.Error())
r.log.Err(err).Send()
return err
}
// Get new update count
r.log.Debug().
Int("updateBytes", len(data)).
Int64("updateCount", count.Val()).
Str("redis", r.redis.String()).
Str("key", r.Key()).
Msg("pushed update to redis")
span.SetAttributes(attribute.Int64("updateCount", count.Val()))
span.SetStatus(codes.Ok, "")
return nil
}

View File

@ -2,38 +2,100 @@ package weather
import (
"time"
"gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/pkg/ambient/config"
)
// Stable intermediate struct containing superset of fields
// between AWN and Wunderground style updates from Ambient devices
type WeatherUpdate struct {
DateUTC *time.Time
StationType string
TempOutdoorF float64
TempIndoorF float64
HumidityOudoor int
HumidityIndoor int
WindSpeedMPH float64
WindGustMPH float64
MaxDailyGust float64
WindDir int
WindDirAvg10m int
UV int
SolarRadiation float64
HourlyRainIn float64
EventRainIn float64
DailyRainIn float64
WeeklyRainIn float64
MonthlyRainIn float64
YearlyRainIn float64
TotalRainIn float64
BattOutdoorSensor int
BattIndoorSensor int
BattRainSensor int
BaromRelativeIn float64
BaromAbsoluteIn float64
DateUTC *time.Time `json:"dateUTC,omitempty"`
StationConfig *config.WeatherStation `json:"stationConfig,omitempty"`
StationID *string `json:"stationID,omitempty"`
StationType *string `json:"stationType,omitempty"`
TempOutdoorF *float64 `json:"tempOutdoorF,omitempty"`
TempIndoorF *float64 `json:"tempIndoorF,omitempty"`
HumidityOudoor *int `json:"humidityOudoor,omitempty"`
HumidityIndoor *int `json:"humidityIndoor,omitempty"`
WindSpeedMPH *float64 `json:"windSpeedMPH,omitempty"`
WindGustMPH *float64 `json:"windGustMPH,omitempty"`
MaxDailyGust *float64 `json:"maxDailyGust,omitempty"`
WindDir *int `json:"windDir,omitempty"`
WindDirAvg10m *int `json:"windDirAvg10M,omitempty"`
UV *int `json:"uv,omitempty"`
SolarRadiation *float64 `json:"solarRadiation,omitempty"`
HourlyRainIn *float64 `json:"hourlyRainIn,omitempty"`
EventRainIn *float64 `json:"eventRainIn,omitempty"`
DailyRainIn *float64 `json:"dailyRainIn,omitempty"`
WeeklyRainIn *float64 `json:"weeklyRainIn,omitempty"`
MonthlyRainIn *float64 `json:"monthlyRainIn,omitempty"`
YearlyRainIn *float64 `json:"yearlyRainIn,omitempty"`
TotalRainIn *float64 `json:"totalRainIn,omitempty"`
Batteries []BatteryStatus `json:"batteries,omitempty"`
BaromRelativeIn *float64 `json:"baromRelativeIn,omitempty"`
BaromAbsoluteIn *float64 `json:"baromAbsoluteIn,omitempty"`
// These fields may be calculated
// if not otherwise set
DewPointF float64
WindChillF float64
DewPointF *float64 `json:"dewPointF,omitempty"`
WindChillF *float64 `json:"windChillF,omitempty"`
// Extra Temp+Humidity Sensors
TempHumiditySensors []*TempHumiditySensor `json:"tempHumiditySensors,omitempty"`
// Lightning Data
LightningDay *int `json:"lightningDay,omitempty"` // Count of detections
LightningDistance *int `json:"lightningDistance,omitempty"` // Last detection distance [kilometers]
LightningLastTime *int `json:"lightningLastTime,omitempty"` // Last detection time
}
type TempHumiditySensor struct {
Name string `json:"name,omitempty"`
TempF *float64 `json:"tempF,omitempty"`
Humidity *int `json:"humidity,omitempty"`
}
type BatteryStatus struct {
Component string `json:"component,omitempty"`
Status *int `json:"status,omitempty"`
}
// CHORE: Maintain this, used to check against
// keep and drop lists
// TODO: Use refelct/ast to generate code
const (
FieldDateUTC = "DateUTC"
FieldStationType = "StationType"
FieldTempOutdoorF = "TempOutdoorF"
FieldTempIndoorF = "TempIndoorF"
FieldHumidityOudoor = "HumidityOudoor"
FieldHumidityIndoor = "HumidityIndoor"
FieldWindSpeedMPH = "WindSpeedMPH"
FieldWindGustMPH = "WindGustMPH"
FieldMaxDailyGust = "MaxDailyGust"
FieldWindDir = "WindDir"
FieldWindDirAvg10m = "WindDirAvg10m"
FieldUV = "UV"
FieldSolarRadiation = "SolarRadiation"
FieldHourlyRainIn = "HourlyRainIn"
FieldEventRainIn = "EventRainIn"
FieldDailyRainIn = "DailyRainIn"
FieldWeeklyRainIn = "WeeklyRainIn"
FieldMonthlyRainIn = "MonthlyRainIn"
FieldYearlyRainIn = "YearlyRainIn"
FieldTotalRainIn = "TotalRainIn"
FieldBatteries = "Batteries"
FieldBaromRelativeIn = "BaromRelativeIn"
FieldBaromAbsoluteIn = "BaromAbsoluteIn"
FieldDewPointF = "DewPointF"
FieldWindChillF = "WindChillF"
FieldSensorTempF = "SensorTempF"
FieldSensorHumidity = "SensorHumidity"
FieldLightningDay = "LightningDay"
FieldLightningDistance = "LightningDistance"
FieldLightningLastTime = "LightningLastTime"
)
func (u *WeatherUpdate) GetStationName() string {
if u.StationConfig != nil {
return u.StationConfig.Name
}
return ""
}

1
proto/googleapis Submodule

@ -0,0 +1 @@
Subproject commit 53ca65d540dd1a7cb4746687daa87208dc9ea437

View File

@ -0,0 +1,76 @@
syntax = "proto3";
package ambient.weather;
import "google/protobuf/timestamp.proto";
option go_package = "gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/api/v1alpha1/weather";
message CountWeatherUpdatesRequest {
GetWeatherOpts opts = 1;
}
message CountWeatherUpdatesResponse {
int32 count = 1;
}
message GetWeatherRequest {
GetWeatherOpts opts = 1;
optional int32 limit = 2;
}
message GetWeatherResponse {
google.protobuf.Timestamp last_updated = 1;
repeated WeatherUpdate weather_updates = 2;
}
message GetWeatherOpts {
optional string station_name = 1;
optional string station_type = 2;
}
message WeatherUpdate {
string station_name = 1;
string station_type = 2;
string station_id = 3;
optional double temp_outdoor_f = 4;
optional double temp_indoor_f = 5;
optional int32 humidity_outdoor = 6;
optional int32 humidity_indoor = 7;
optional double wind_speed_mph = 8;
optional double wind_gust_mph = 9;
optional double max_daily_gust = 10;
optional int32 wind_dir = 11;
optional int32 wind_dir_avg_10m = 12;
optional int32 uv = 13;
optional double solar_radiation = 14;
optional double hourly_rain_in = 15;
optional double event_rain_in = 16;
optional double daily_rain_in = 17;
optional double weekly_rain_in = 18;
optional double monthly_rain_in = 19;
optional double yearly_rain_in = 20;
optional double total_rain_in = 21;
repeated BatteryStatus batteries = 22;
optional double barom_relative_in = 23;
optional double barom_absolute_in = 24;
optional double dew_point_f = 25;
optional double wind_chill_f = 26;
repeated TempHumiditySensor temp_humidity_sensors = 27;
google.protobuf.Timestamp update_timestamp = 28;
optional int32 lightning_day = 29;
optional int32 lightning_distance = 30;
optional google.protobuf.Timestamp lightning_last_time = 31;
}
// Represents a temperature and humidity sensor
message TempHumiditySensor {
string name = 1;
optional double temp_f = 2;
optional int32 humidity = 3;
}
// Represents battery status for different components
message BatteryStatus {
string component = 1;
optional int32 status = 2;
}

View File

@ -0,0 +1,16 @@
syntax = "proto3";
package ambient.weather;
import "google/api/annotations.proto";
import "weather/weather.proto";
option go_package = "gitea.libretechconsulting.com/rmcguire/ambient-local-exporter/api/v1alpha1/weather";
service AmbientLocalWeatherService {
rpc GetWeather(GetWeatherRequest) returns (GetWeatherResponse) {
option (google.api.http) = {get: "/v1/weather"};
}
rpc CountWeatherUpdates(CountWeatherUpdatesRequest) returns (CountWeatherUpdatesResponse) {
option (google.api.http) = {get: "/v1/count"};
}
}