├── .gitignore ├── dashboard.png ├── .github ├── workflows │ ├── label.yml │ ├── build.yml │ └── docker.yml ├── labeler.yml └── dependabot.yml ├── .air.toml ├── track_brightness.sh ├── go.mod ├── LICENSE ├── message_test.go ├── Dockerfile ├── Makefile ├── .golangci.yml ├── README.md ├── jarvis_test.go ├── go.sum ├── message.go ├── cmd └── jarvis_exporter │ └── main.go ├── jarvis.go └── dashboard └── desk_metrics.json /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | c.out 3 | .air.tmp/ 4 | -------------------------------------------------------------------------------- /dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hairyhenderson/jarvis_exporter/HEAD/dashboard.png -------------------------------------------------------------------------------- /.github/workflows/label.yml: -------------------------------------------------------------------------------- 1 | name: Labeler 2 | on: 3 | pull_request_target: 4 | 5 | jobs: 6 | label: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/labeler@v6 10 | with: 11 | repo-token: "${{ secrets.GITHUB_TOKEN }}" 12 | -------------------------------------------------------------------------------- /.github/labeler.yml: -------------------------------------------------------------------------------- 1 | documentation: 2 | - changed-files: 3 | - any-glob-to-any-file: 4 | - docs/**/* 5 | - '*.md' 6 | 7 | dependencies: 8 | - changed-files: 9 | - any-glob-to-any-file: 10 | - go.mod 11 | - go.sum 12 | 13 | build: 14 | - changed-files: 15 | - any-glob-to-any-file: 16 | - .github/workflows/* 17 | - Makefile 18 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: [push] 3 | 4 | jobs: 5 | test: 6 | runs-on: ubuntu-latest 7 | container: 8 | image: ghcr.io/hairyhenderson/gomplate-ci-build:latest 9 | steps: 10 | - uses: actions/checkout@v6 11 | - run: make test 12 | lint: 13 | runs-on: ubuntu-latest 14 | container: 15 | image: ghcr.io/hairyhenderson/gomplate-ci-build:latest 16 | steps: 17 | - uses: actions/checkout@v6 18 | - run: make lint 19 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "02:00" 8 | timezone: Canada/Eastern 9 | open-pull-requests-limit: 10 10 | - package-ecosystem: gomod 11 | directory: "/" 12 | schedule: 13 | interval: daily 14 | time: "02:00" 15 | timezone: Canada/Eastern 16 | open-pull-requests-limit: 10 17 | - package-ecosystem: docker 18 | directory: "/" 19 | schedule: 20 | interval: daily 21 | time: "02:00" 22 | timezone: Canada/Eastern 23 | open-pull-requests-limit: 10 24 | -------------------------------------------------------------------------------- /.air.toml: -------------------------------------------------------------------------------- 1 | # Config file for [Air](https://github.com/cosmtrek/air) 2 | # Air rebuilds & reloads Go apps for a faster REPL loop - just run 'air' 3 | 4 | # Working directory 5 | # . or absolute path, please note that the directories following must be under root. 6 | root = "." 7 | tmp_dir = ".air.tmp" 8 | 9 | [build] 10 | cmd = "make bin/jarvis_exporter" 11 | bin = "jarvis_exporter" 12 | full_bin = "./bin/jarvis_exporter" 13 | include_ext = ["go", "mod"] 14 | exclude_dir = [".air.tmp"] 15 | 16 | # SIGINT before SIGKILL (mac/linux only) 17 | send_interrupt = true 18 | delay = 1000 # ms 19 | kill_delay = 1000 # ms 20 | 21 | [misc] 22 | # Delete .air.tmp directory on exit 23 | clean_on_exit = true 24 | -------------------------------------------------------------------------------- /track_brightness.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # This script is used to track the brightness of the display on a Mac. 4 | # It is intended to be run by cron every minute. 5 | # 6 | # The script uses the brightness command from https://github.com/nriley/brightness 7 | # to get the current brightness of the display. It then writes the value to a 8 | # Prometheus-format file which is scraped by the node_exporter's textfile 9 | # collector. 10 | # 11 | # See https://github.com/prometheus/node_exporter#textfile-collector 12 | 13 | awake="$(/usr/local/bin/brightness -l | head -n1 | grep awake)" 14 | brightness="$(/usr/local/bin/brightness -l | awk '/display 0: brightness/{print $4}')" 15 | state="awake" 16 | if [ -z "${awake}" ]; then 17 | state="asleep" 18 | fi 19 | echo "macos_display_brightness_percent{state=\"$state\"} $brightness" > /usr/local/etc/textcollector/brightness.prom 20 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/hairyhenderson/jarvis_exporter 2 | 3 | go 1.23.1 4 | 5 | require ( 6 | github.com/prometheus/client_golang v1.22.0 7 | github.com/stretchr/testify v1.11.1 8 | github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07 9 | ) 10 | 11 | require ( 12 | github.com/beorn7/perks v1.0.1 // indirect 13 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 14 | github.com/davecgh/go-spew v1.1.1 // indirect 15 | github.com/kr/text v0.2.0 // indirect 16 | github.com/kylelemons/godebug v1.1.0 // indirect 17 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 18 | github.com/pmezard/go-difflib v1.0.0 // indirect 19 | github.com/prometheus/client_model v0.6.1 // indirect 20 | github.com/prometheus/common v0.62.0 // indirect 21 | github.com/prometheus/procfs v0.15.1 // indirect 22 | golang.org/x/sys v0.30.0 // indirect 23 | google.golang.org/protobuf v1.36.5 // indirect 24 | gopkg.in/yaml.v3 v3.0.1 // indirect 25 | ) 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-2023 Dave Henderson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /message_test.go: -------------------------------------------------------------------------------- 1 | package jarvis 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestMessagePreset(t *testing.T) { 10 | m := Message{} 11 | assert.Equal(t, uint8(0), m.Preset()) 12 | 13 | m = Message{Type: Height} 14 | assert.Equal(t, uint8(0), m.Preset()) 15 | 16 | testdata := []struct { 17 | params uint32 18 | expected uint8 19 | }{ 20 | {0x04, 1}, 21 | {0x08, 2}, 22 | {0x10, 3}, 23 | {0x20, 4}, 24 | } 25 | 26 | for _, d := range testdata { 27 | m := Message{ 28 | Type: Preset, 29 | params: d.params, 30 | } 31 | assert.Equal(t, d.expected, m.Preset()) 32 | } 33 | } 34 | 35 | func TestMessageHeight(t *testing.T) { 36 | m := Message{} 37 | assert.Equal(t, uint64(0), m.Height()) 38 | 39 | m = Message{Type: Preset} 40 | assert.Equal(t, uint64(0), m.Height()) 41 | 42 | testdata := []struct { 43 | params uint32 44 | expected uint64 45 | }{ 46 | {0x000000, 0}, 47 | {0x04ab0f, 1195}, 48 | {0x02780f, 632}, 49 | // if it's too low we assume it's 1/10ths of inches and convert to mm 50 | {0x01230f, 739}, 51 | // unexpected last byte but... 52 | {0x02787e, 632}, 53 | } 54 | 55 | for _, d := range testdata { 56 | m := Message{ 57 | Type: Height, 58 | params: d.params, 59 | } 60 | assert.Equal(t, d.expected, m.Height()) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1.2.1-labs 2 | FROM --platform=linux/amd64 golang:1.25-alpine AS build 3 | 4 | ARG PKG_NAME 5 | ARG TARGETOS 6 | ARG TARGETARCH 7 | ARG TARGETVARIANT 8 | ENV GOOS=$TARGETOS GOARCH=$TARGETARCH 9 | 10 | RUN apk add --no-cache make git 11 | 12 | WORKDIR /src 13 | COPY go.mod ./ 14 | COPY go.sum ./ 15 | 16 | RUN --mount=type=cache,id=go-build-${TARGETOS}-${TARGETARCH}${TARGETVARIANT},target=/root/.cache/go-build \ 17 | --mount=type=cache,id=go-pkg-${TARGETOS}-${TARGETARCH}${TARGETVARIANT},target=/go/pkg \ 18 | go mod download -x 19 | 20 | COPY . ./ 21 | 22 | RUN --mount=type=cache,id=go-build-${TARGETOS}-${TARGETARCH}${TARGETVARIANT},target=/root/.cache/go-build \ 23 | --mount=type=cache,id=go-pkg-${TARGETOS}-${TARGETARCH}${TARGETVARIANT},target=/go/pkg \ 24 | go build -ldflags "-w -s" \ 25 | -o bin/${PKG_NAME} \ 26 | ./cmd/${PKG_NAME} 27 | RUN mv bin/${PKG_NAME}* /bin/ 28 | 29 | FROM alpine:3.22 AS runtime 30 | 31 | ARG PKG_NAME 32 | ARG VCS_REF 33 | ARG TARGETOS 34 | ARG TARGETARCH 35 | ARG TARGETVARIANT 36 | 37 | LABEL org.opencontainers.image.revision=$VCS_REF \ 38 | org.opencontainers.image.source="https://github.com/hairyhenderson/${PKG_NAME}" 39 | 40 | COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt 41 | COPY --from=build /bin/${PKG_NAME} /${PKG_NAME} 42 | 43 | ENTRYPOINT [ "/jarvis_exporter" ] 44 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL = build 2 | BIN_DIR := bin 3 | PKG_NAME := jarvis_exporter 4 | DOCKER_REPO ?= hairyhenderson/$(PKG_NAME) 5 | DOCKER_PLATFORMS ?= linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7 6 | # we just build by default, as a "dry run" 7 | BUILDX_ACTION ?= --output=type=image,push=false 8 | # BUILDX_ACTION ?= --push 9 | TAG_LATEST ?= latest 10 | 11 | GOOS ?= $(shell go version | sed 's/^.*\ \([a-z0-9]*\)\/\([a-z0-9]*\)/\1/') 12 | GOARCH ?= $(shell go version | sed 's/^.*\ \([a-z0-9]*\)\/\([a-z0-9]*\)/\2/') 13 | 14 | ifeq ("$(TARGETVARIANT)","") 15 | ifneq ("$(GOARM)","") 16 | TARGETVARIANT := v$(GOARM) 17 | endif 18 | else 19 | ifeq ("$(GOARM)","") 20 | GOARM ?= $(subst v,,$(TARGETVARIANT)) 21 | endif 22 | endif 23 | 24 | build: bin/jarvis_exporter 25 | 26 | bin/%: $(shell find . -type f -name "*.go") go.mod go.sum 27 | go build \ 28 | -ldflags "-w -s" \ 29 | -o $@ \ 30 | ./cmd/$(patsubst bin/%,%,$@) 31 | 32 | docker-multi: Dockerfile 33 | docker buildx build \ 34 | --build-arg VCS_REF=$(COMMIT) \ 35 | --build-arg PKG_NAME=$(PKG_NAME) \ 36 | --platform $(DOCKER_PLATFORMS) \ 37 | --tag $(DOCKER_REPO):$(TAG_LATEST) \ 38 | --target runtime \ 39 | $(BUILDX_ACTION) . 40 | 41 | test: 42 | @go test -race -coverprofile=c.out ./... 43 | 44 | lint: 45 | @golangci-lint run -v --max-same-issues=0 --max-issues-per-linter=0 46 | 47 | .PHONY: test lint 48 | .DELETE_ON_ERROR: 49 | .SECONDARY: 50 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | default: none 4 | enable: 5 | - asciicheck 6 | - bodyclose 7 | - copyloopvar 8 | - dogsled 9 | - dupl 10 | # - errcheck 11 | - exhaustive 12 | - funlen 13 | # - gochecknoglobals 14 | - gochecknoinits 15 | - gocognit 16 | - goconst 17 | - gocritic 18 | - gocyclo 19 | - godox 20 | - goheader 21 | # - gomnd 22 | - gomodguard 23 | - goprintffuncname 24 | - gosec 25 | - govet 26 | - ineffassign 27 | - lll 28 | - misspell 29 | - nakedret 30 | - nestif 31 | - nlreturn 32 | - noctx 33 | - nolintlint 34 | - prealloc 35 | - revive 36 | - rowserrcheck 37 | - sqlclosecheck 38 | - staticcheck 39 | - unconvert 40 | - unparam 41 | - unused 42 | - whitespace 43 | - wsl_v5 44 | settings: 45 | goconst: 46 | min-len: 2 47 | min-occurrences: 4 48 | gocyclo: 49 | min-complexity: 10 50 | govet: 51 | enable-all: true 52 | lll: 53 | line-length: 140 54 | nolintlint: 55 | require-explanation: false 56 | require-specific: false 57 | allow-unused: false 58 | exclusions: 59 | generated: lax 60 | presets: 61 | - comments 62 | - common-false-positives 63 | - legacy 64 | - std-error-handling 65 | paths: 66 | - third_party$ 67 | - builtin$ 68 | - examples$ 69 | formatters: 70 | enable: 71 | - gci 72 | - gofmt 73 | - gofumpt 74 | - goimports 75 | exclusions: 76 | generated: lax 77 | paths: 78 | - third_party$ 79 | - builtin$ 80 | - examples$ 81 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Build](https://github.com/hairyhenderson/jarvis_exporter/workflows/Build/badge.svg) 2 | ![Docker Build](https://github.com/hairyhenderson/jarvis_exporter/workflows/Docker%20Build/badge.svg) 3 | [![hairyhenderson/jarvis_exporter on DockerHub](https://img.shields.io/badge/docker-ready-blue.svg)](https://hub.docker.com/r/hairyhenderson/jarvis_exporter) 4 | 5 | # jarvis_exporter 6 | 7 | A Prometheus exporter for the Fully Jarvis standing desk. With a bit of simple 8 | custom hardware, it can export the desk's current height, as a Prometheus 9 | metric! 10 | 11 | Based on the excellent reverse-engineering done by @phord at https://github.com/phord/Jarvis. 12 | 13 | [Docker Images available](https://hub.docker.com/r/hairyhenderson/jarvis_exporter) 14 | for Linux on x86-64, arm64, armv7 (Raspberry Pi), and armv6 (Pi Zero) platforms. 15 | 16 | ## Dashboard configuration 17 | 18 | With `jarvis_exporter`, you can create a Grafana dashboard that looks like this: 19 | 20 | ![Grafana dashboard displaying stand/sit ratio and other metrics](dashboard.png) 21 | 22 | This dashboard has been exported to [`dashboard/desk_metrics.json](./dashboard/desk_metrics.json). 23 | 24 | Note that this dashboard in particular also uses some metrics exported by a 25 | [simple script](./track_brightness.sh) that I wrote to export the display 26 | brightness (and whether or not the display is asleep). It's scheduled to run 27 | every minute via `cron`, and uses the [`brightness`](https://github.com/nriley/brightness) 28 | tool to read the current brightness. The output is then scraped by 29 | `node_exporter`'s [textfile collector](https://github.com/prometheus/node_exporter#textfile-collector). 30 | 31 | ## Hardware 32 | 33 | _A description of the hardware I've put together is coming soon, but for now see 34 | [this discussion](https://github.com/phord/Jarvis/discussions/8) for some hints!_ 35 | 36 | ## TODO 37 | 38 | - [ ] Support multiple UARTs for reading from both handset & desk (probably on 39 | a Raspberry Pi) 40 | 41 | ## License 42 | 43 | [The MIT License](http://opensource.org/licenses/MIT) 44 | 45 | Copyright (c) 2021-2023 Dave Henderson 46 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker Build 2 | on: 3 | push: 4 | 5 | jobs: 6 | docker-build: 7 | runs-on: ubuntu-22.04 8 | services: 9 | registry: 10 | image: registry:2 11 | ports: 12 | - '5000:5000' 13 | env: 14 | DOCKER_BUILDKIT: 1 15 | DOCKER_CLI_EXPERIMENTAL: enabled 16 | IMG_NAME: jarvis_exporter 17 | steps: 18 | - name: enable experimental mode 19 | run: | 20 | mkdir -p ~/.docker 21 | echo '{"experimental": "enabled"}' > ~/.docker/config.json 22 | - uses: actions/checkout@v6 23 | - name: Set up QEMU 24 | uses: docker/setup-qemu-action@v3.7.0 25 | - name: Set up Docker Buildx 26 | id: buildx 27 | uses: docker/setup-buildx-action@v3.11.1 28 | with: 29 | version: v0.5.1 30 | driver-opts: | 31 | image=moby/buildkit:buildx-stable-1 32 | network=host 33 | - name: Available platforms 34 | run: echo ${{ steps.buildx.outputs.platforms }} 35 | - name: determine if this is a tag 36 | run: | 37 | if (git describe --abbrev=0 --exact-match &>/dev/null); then 38 | tag=$(git describe --abbrev=0 --exact-match) 39 | echo "is_tag=true" >> $GITHUB_ENV 40 | echo "git_tag=$tag" >> $GITHUB_ENV 41 | # splits the major version from $tag - assumes it's a 3-part semver 42 | echo "major_version=${tag%%\.*}" >> $GITHUB_ENV 43 | fi 44 | if: github.repository == 'hairyhenderson/jarvis_exporter' 45 | - name: Login to DockerHub 46 | uses: docker/login-action@v3.6.0 47 | with: 48 | username: hairyhenderson 49 | password: ${{ secrets.DOCKERHUB_TOKEN }} 50 | if: github.repository == 'hairyhenderson/jarvis_exporter' && (github.ref == 'refs/heads/main' || env.is_tag == 'true') 51 | - name: Build & Push (non-main branch) 52 | run: | 53 | make docker-multi COMMIT=${{ github.sha }} DOCKER_REPO=localhost:5000/${IMG_NAME} BUILDX_ACTION=--push 54 | 55 | docker buildx imagetools create --dry-run -t localhost:5000/${IMG_NAME}:dev localhost:5000/${IMG_NAME}:latest 56 | if: github.repository != 'hairyhenderson/jarvis_exporter' || github.ref != 'refs/heads/main' 57 | - name: Build & Push (main branch) 58 | run: | 59 | make docker-multi COMMIT=${{ github.sha }} DOCKER_REPO=hairyhenderson/${IMG_NAME} BUILDX_ACTION=--push 60 | if: github.repository == 'hairyhenderson/jarvis_exporter' && github.ref == 'refs/heads/main' 61 | - name: Build & Push (tagged release) 62 | run: | 63 | make docker-multi COMMIT=${{ github.sha }} DOCKER_REPO=hairyhenderson/${IMG_NAME} BUILDX_ACTION=--push 64 | 65 | docker buildx imagetools create -t hairyhenderson/${IMG_NAME}:${git_tag} hairyhenderson/${IMG_NAME}:latest 66 | docker buildx imagetools create -t hairyhenderson/${IMG_NAME}:${major_version} hairyhenderson/${IMG_NAME}:latest 67 | if: github.repository == 'hairyhenderson/jarvis_exporter' && env.is_tag == 'true' 68 | -------------------------------------------------------------------------------- /jarvis_test.go: -------------------------------------------------------------------------------- 1 | package jarvis 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "testing" 7 | 8 | "github.com/prometheus/client_golang/prometheus/testutil" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestVerifyChecksum(t *testing.T) { 13 | m := Message{ 14 | Type: Height, 15 | length: 3, 16 | params: 0x01020f, 17 | cksum: 0x16, 18 | } 19 | 20 | assert.True(t, verifyChecksum(m)) 21 | 22 | m.cksum = 0x07 23 | assert.False(t, verifyChecksum(m)) 24 | } 25 | 26 | //nolint:funlen 27 | func TestNextMessage(t *testing.T) { 28 | ctx := context.Background() 29 | 30 | j := New(bytes.NewReader(nil), InitMetrics("jarvis")) 31 | 32 | m, err := j.NextMessage(ctx) 33 | assert.Error(t, err) 34 | assert.Nil(t, m) 35 | 36 | j = New(bytes.NewReader([]byte{0x00}), InitMetrics("jarvis")) 37 | m, err = j.NextMessage(ctx) 38 | assert.Error(t, err) 39 | assert.Nil(t, m) 40 | 41 | // should error when the context is cancelled! 42 | ctx, cancel := context.WithCancel(context.Background()) 43 | cancel() 44 | 45 | j = New(bytes.NewReader([]byte{0x00}), InitMetrics("jarvis")) 46 | m, err = j.NextMessage(ctx) 47 | assert.Error(t, err) 48 | assert.Nil(t, m) 49 | 50 | ctx = context.Background() 51 | 52 | // early close after incomplete message 53 | j = New(bytes.NewReader([]byte{0x0f2, 0x0f2}), InitMetrics("jarvis")) 54 | m, err = j.NextMessage(ctx) 55 | assert.Error(t, err) 56 | assert.Nil(t, m) 57 | 58 | // invalid length messages 59 | j = New(bytes.NewReader([]byte{ 60 | 0x0f2, 0x0f2, 0x01, 0x04, 61 | 0x0f2, 0x0f2, 0x01, 0x04, 62 | 0x0f2, 0x0f2, 0x01, 0x04, 63 | }), InitMetrics("jarvis")) 64 | m, err = j.NextMessage(ctx) 65 | assert.Error(t, err) 66 | assert.Nil(t, m) 67 | 68 | assert.Equal(t, 3.0, testutil.ToFloat64(j.metrics.invalidLenCount)) 69 | assert.Equal(t, 0.0, testutil.ToFloat64(j.metrics.checksumErrorsCount)) 70 | 71 | // bad checksum 72 | msg := []byte{ 73 | 0x0f2, 0x0f2, 0x01, 0x03, 0x01, 0x0a, 0x0f, 0x05, 0x7e, 74 | } 75 | j = New(bytes.NewReader(msg), InitMetrics("jarvis")) 76 | m, err = j.NextMessage(ctx) 77 | assert.NoError(t, err) 78 | assert.Equal(t, &Message{ 79 | raw: msg, 80 | addr: 0xf2, 81 | Type: 0x01, 82 | length: 0x03, 83 | params: 0x010a0f, 84 | cksum: 0x05, 85 | }, m) 86 | 87 | assert.Equal(t, 1.0, testutil.ToFloat64(j.metrics.checksumErrorsCount)) 88 | assert.Equal(t, 0.0, testutil.ToFloat64(j.metrics.invalidLenCount)) 89 | 90 | // legit message 91 | msg = []byte{ 92 | 0x0f2, 0x0f2, 0x01, 0x03, 0x01, 0x0a, 0x0f, 0x1e, 0x7e, 93 | } 94 | j = New(bytes.NewReader(msg), InitMetrics("jarvis")) 95 | m, err = j.NextMessage(ctx) 96 | assert.NoError(t, err) 97 | assert.Equal(t, 0.0, testutil.ToFloat64(j.metrics.checksumErrorsCount)) 98 | assert.Equal(t, &Message{ 99 | raw: msg, 100 | addr: 0xf2, 101 | Type: 0x01, 102 | length: 0x03, 103 | params: 0x010a0f, 104 | cksum: 0x1e, 105 | }, m) 106 | 107 | assert.Equal(t, 0.0, testutil.ToFloat64(j.metrics.invalidLenCount)) 108 | } 109 | 110 | func TestMetricsCollector(t *testing.T) { 111 | m := InitMetrics("foo") 112 | 113 | probs, err := testutil.CollectAndLint(m) 114 | assert.NoError(t, err) 115 | assert.Empty(t, probs) 116 | } 117 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 2 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 3 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 4 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 5 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 9 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 10 | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 11 | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 12 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 13 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 14 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 15 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 16 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 17 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 18 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 19 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 20 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 21 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 22 | github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= 23 | github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= 24 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 25 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 26 | github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= 27 | github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= 28 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 29 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 30 | github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 31 | github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 32 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 33 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 34 | github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07 h1:UyzmZLoiDWMRywV4DUYb9Fbt8uiOSooupjTq10vpvnU= 35 | github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA= 36 | golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 37 | golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 38 | google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= 39 | google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 40 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 41 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 42 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 43 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 44 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 45 | -------------------------------------------------------------------------------- /message.go: -------------------------------------------------------------------------------- 1 | package jarvis 2 | 3 | import ( 4 | "math/bits" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | // Commands sent by desk 10 | const ( 11 | Height uint8 = 0x01 // Height report (in mm, P0/P1, P2 unused - always 0xf) 12 | LimitResp uint8 = 0x20 // Max-height set/cleared; response to SetMax (0x21) 13 | GetMax uint8 = 0x21 // Report max-height; [P0,P1] = max-height (c.f. SetMax) 14 | GetMin uint8 = 0x22 // Report min-height; [P0,P1] = min-height 15 | LimitStop uint8 = 0x23 // Min/Max reached (0x01 "Max-height reached", 0x02 "Min-height reached") 16 | Reset uint8 = 0x40 // Indicates desk in RESET mode; Displays "RESET" 17 | Preset uint8 = 0x92 // Moving to Preset location ([0x4,0x8,0x10,0x20] mapping to presets [1,2,3,4]) 18 | ) 19 | 20 | // Commands sent by handset 21 | const ( 22 | ProgMem1 uint8 = 0x03 // Set memory position 1 to current height 23 | ProgMem2 uint8 = 0x04 // Set memory position 2 to current height 24 | ProgMem3 uint8 = 0x25 // Set memory position 3 to current height 25 | ProgMem4 uint8 = 0x26 // Set memory position 4 to current height 26 | Units uint8 = 0x0e // Set units to cm/inches (param 0x00 == cm, 0x01 == in) 27 | MemMode uint8 = 0x19 // Set memory mode (0x00 One-touch mode, 0x01 Constant touch mode) 28 | CollSens uint8 = 0x1d // Set anti-collision sensitivity (Sent 1x; no repeats, 1/2/3 high/medium/low) 29 | SetMax uint8 = 0x21 // Set max height; Sets max-height to current height 30 | SetMin uint8 = 0x22 // Set min height; Sets min-height to current height 31 | LimitClear uint8 = 0x23 // Clear min/max height (0x01 Max-height cleared, 0x02 Min-height cleared) 32 | Wake uint8 = 0x29 // Poll message sent when desk doesn't respond to BREAK messages 33 | Calibrate uint8 = 0x91 // Height calibration (Repeats 2x) (Desk must be at lowest position, enters RESET mode after this) 34 | ) 35 | 36 | type Message struct { 37 | raw []byte 38 | params uint32 39 | addr uint8 40 | Type uint8 41 | length uint8 42 | cksum uint8 43 | } 44 | 45 | //nolint:gocyclo 46 | func (m *Message) String() string { 47 | s := strings.Builder{} 48 | if m.addr == 0xf2 { 49 | s.WriteString("from desk: ") 50 | } 51 | 52 | switch m.Type { 53 | case Height: 54 | height := uint64(m.params >> 8) 55 | 56 | s.WriteString("height: ") 57 | s.WriteString(strconv.FormatUint(height, 10)) 58 | 59 | if height <= 550 { 60 | s.WriteString("in") 61 | } else { 62 | s.WriteString("mm") 63 | } 64 | case Preset: 65 | s.WriteString("preset: ") 66 | s.WriteString(strconv.FormatUint(uint64(m.Preset()), 10)) 67 | case LimitResp: 68 | case GetMax: 69 | case GetMin: 70 | case LimitStop: 71 | switch m.params { 72 | case 0x01: 73 | s.WriteString("Max-height reached") 74 | case 0x02: 75 | s.WriteString("Min-height reached") 76 | default: 77 | s.WriteString("invalid params for LIMIT_STOP") 78 | } 79 | case Reset: 80 | s.WriteString("reset!") 81 | } 82 | 83 | return s.String() 84 | } 85 | 86 | func (m Message) Preset() uint8 { 87 | if m.Type != Preset { 88 | return 0 89 | } 90 | 91 | // The preset response returned by the desk is 4/8/16/32 or the 3rd, 4th, 92 | // 6th, and 7th bit set (from the right) in binary. To convert these to 93 | // their 1-indexed positions, we shift right by 1 and count the trailing 94 | // zeroes. Another approach could be to use math.Log2 with lots of float64 95 | // conversions, but this is far more efficient! 96 | //nolint:gosec // disable G115 97 | return uint8(bits.TrailingZeros8(uint8(m.params) >> 1)) 98 | } 99 | 100 | // Height returns the height (in mm) 101 | func (m Message) Height() uint64 { 102 | if m.Type != Height { 103 | return 0 104 | } 105 | 106 | // strip off the last 0xf - only the first two bytes are part of the height 107 | h := uint64(m.params >> 8) 108 | 109 | // if the value is <= 550 we assume the unit is 1/10 inches, so multiply by 110 | // 2.54 to get mm! 111 | if h <= 550 { 112 | h = uint64(float64(h) * 2.54) 113 | } 114 | 115 | return h 116 | } 117 | -------------------------------------------------------------------------------- /cmd/jarvis_exporter/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "log" 8 | "net" 9 | "net/http" 10 | "os" 11 | "os/signal" 12 | "time" 13 | 14 | jarvis "github.com/hairyhenderson/jarvis_exporter" 15 | "github.com/prometheus/client_golang/prometheus" 16 | "github.com/prometheus/client_golang/prometheus/promhttp" 17 | "github.com/tarm/serial" 18 | ) 19 | 20 | func main() { 21 | exitCode := 0 22 | 23 | defer func() { os.Exit(exitCode) }() 24 | 25 | ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill) 26 | defer cancel() 27 | 28 | addr := flag.String("addr", ":9834", "address to listen to") 29 | serialport := flag.String("serialport", "/dev/cu.usbserial-AH02F0IR", "serial port path") 30 | 31 | flag.Parse() 32 | 33 | jmetrics := jarvis.InitMetrics(ns) 34 | 35 | prometheus.MustRegister(deskHeightGauge, readErrorsCount, jmetrics) 36 | 37 | log.Printf("starting server at %s", *addr) 38 | 39 | srv, err := startServer(ctx, *addr, &exitCode) 40 | if err != nil { 41 | log.Printf("start: %v", err) 42 | 43 | exitCode = 1 44 | 45 | return 46 | } 47 | 48 | defer srv.Shutdown(ctx) 49 | 50 | if err := readLoop(ctx, *serialport, jmetrics); err != nil { 51 | fmt.Println(err) 52 | } 53 | } 54 | 55 | func startServer(ctx context.Context, addr string, exitCode *int) (*http.Server, error) { 56 | mux := http.NewServeMux() 57 | mux.Handle("/metrics", promhttp.Handler()) 58 | 59 | srv := &http.Server{ 60 | Addr: addr, 61 | Handler: mux, 62 | BaseContext: func(net.Listener) context.Context { return ctx }, 63 | ReadHeaderTimeout: 2 * time.Second, 64 | } 65 | 66 | lc := net.ListenConfig{} 67 | 68 | l, err := lc.Listen(ctx, "tcp", addr) 69 | if err != nil { 70 | return nil, fmt.Errorf("listen: %w", err) 71 | } 72 | 73 | go func() { 74 | err := srv.Serve(l) 75 | if err != nil && err != http.ErrServerClosed { 76 | log.Printf("server terminated with error: %v", err) 77 | 78 | *exitCode = 1 79 | } 80 | 81 | srv.Shutdown(ctx) 82 | }() 83 | 84 | return srv, nil 85 | } 86 | 87 | var ( 88 | ns = "jarvis" 89 | deskHeightGauge = prometheus.NewGauge(prometheus.GaugeOpts{ 90 | Namespace: ns, 91 | Name: "desk_height_meters", 92 | Help: "The current height of the desk, in meters.", 93 | }) 94 | presetSelectedGauge = prometheus.NewGauge(prometheus.GaugeOpts{ 95 | Namespace: ns, 96 | Name: "desk_preset_selected", 97 | Help: "The last preset selected", 98 | }) 99 | readErrorsCount = prometheus.NewCounter(prometheus.CounterOpts{ 100 | Namespace: ns, 101 | Name: "read_errors_total", 102 | Help: "A count of read errors encountered. See logs for details.", 103 | }) 104 | ) 105 | 106 | func readLoop(ctx context.Context, portname string, jmetrics *jarvis.Metrics) error { 107 | options := serial.Config{Name: portname, Baud: 9600} 108 | 109 | // loop to reconnect to the serial port if it closes or we get a read 110 | // failure... 111 | for { 112 | if err := streamMessages(ctx, &options, jmetrics); err != nil { 113 | return err 114 | } 115 | } 116 | } 117 | 118 | func streamMessages(ctx context.Context, options *serial.Config, jmetrics *jarvis.Metrics) error { 119 | log.Println("opening serial port for streaming...") 120 | 121 | port, err := serial.OpenPort(options) 122 | if err != nil { 123 | return fmt.Errorf("port open: %w", err) 124 | } 125 | 126 | defer port.Close() 127 | 128 | desk := jarvis.New(port, jmetrics) 129 | 130 | errch := make(chan error) 131 | ch := make(chan *jarvis.Message) 132 | 133 | // start streaming messages into the given channel 134 | go desk.ReadMessages(ctx, errch, ch) 135 | 136 | for { 137 | select { 138 | case msg := <-ch: 139 | if msg == nil { 140 | break 141 | } 142 | 143 | switch msg.Type { 144 | case jarvis.Height: 145 | deskHeightGauge.Set(float64(msg.Height()) / 1000) 146 | case jarvis.Preset: 147 | log.Printf("moving to preset %#02x", msg.Preset()) 148 | presetSelectedGauge.Set(float64(msg.Preset())) 149 | default: 150 | log.Printf("got message (type %#x): %s", msg.Type, msg) 151 | } 152 | case <-ctx.Done(): 153 | return ctx.Err() 154 | case err = <-errch: 155 | readErrorsCount.Inc() 156 | 157 | log.Printf("read error: %v", err) 158 | 159 | // we count this as a non-fatal error - perhaps retrying will help... 160 | return nil 161 | } 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /jarvis.go: -------------------------------------------------------------------------------- 1 | package jarvis 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | 8 | "github.com/prometheus/client_golang/prometheus" 9 | ) 10 | 11 | type Jarvis struct { 12 | rdr io.Reader 13 | 14 | metrics *Metrics 15 | } 16 | 17 | type Metrics struct { 18 | checksumErrorsCount prometheus.Counter 19 | invalidLenCount prometheus.Counter 20 | } 21 | 22 | func InitMetrics(ns string) *Metrics { 23 | return &Metrics{ 24 | checksumErrorsCount: prometheus.NewCounter(prometheus.CounterOpts{ 25 | Namespace: ns, 26 | Name: "checksum_errors_total", 27 | Help: "A count of checksum errors encountered.", 28 | }), 29 | invalidLenCount: prometheus.NewCounter(prometheus.CounterOpts{ 30 | Namespace: ns, 31 | Name: "invalid_length_count_total", 32 | Help: "Number of times an invalid length field was received.", 33 | }), 34 | } 35 | } 36 | 37 | // Collect implements Prometheus.Collector. 38 | func (m *Metrics) Collect(ch chan<- prometheus.Metric) { 39 | m.checksumErrorsCount.Collect(ch) 40 | m.invalidLenCount.Collect(ch) 41 | } 42 | 43 | // Describe implements Prometheus.Collector. 44 | func (m *Metrics) Describe(ch chan<- *prometheus.Desc) { 45 | m.checksumErrorsCount.Describe(ch) 46 | m.invalidLenCount.Describe(ch) 47 | } 48 | 49 | func New(r io.Reader, metrics *Metrics) *Jarvis { 50 | return &Jarvis{ 51 | rdr: r, 52 | metrics: metrics, 53 | } 54 | } 55 | 56 | // ReadMessages - 57 | func (j *Jarvis) ReadMessages(ctx context.Context, errch chan error, ch chan *Message) { 58 | for { 59 | msg, err := j.NextMessage(ctx) 60 | 61 | // send non-nil messages back first - sometimes we want both! 62 | if msg != nil { 63 | ch <- msg 64 | } 65 | 66 | if err != nil { 67 | if errch != nil { 68 | errch <- err 69 | } 70 | 71 | close(ch) 72 | 73 | return 74 | } 75 | } 76 | } 77 | 78 | // NextMessage reads the next *Message from the given reader. 79 | // 80 | // the format is: 81 | // [addr (2b)][command (1b)][param len (1b)][params (0-3b)][cksum (1b)][EOM (1b)] 82 | // 83 | // states (at beginning of loop) 84 | // 0 = unsynchronized 85 | // 1 = half-synchronized 86 | // 2 = synchronized 87 | // 3 = received command 88 | // 4 = received param length (0-3), reading params 89 | // 5 = received all params 90 | // 6 = verified checksum 91 | // 7 = eom 92 | // 93 | //nolint:gocyclo,funlen 94 | func (j *Jarvis) NextMessage(ctx context.Context) (*Message, error) { 95 | state := uint8(0) 96 | curParam := uint8(0) 97 | p := Message{} 98 | 99 | // tracks resets local to this function, so that we can error after too many 100 | invalidLenCount := 0 101 | 102 | reset := func() { 103 | // reset state to 255 (aka -1), will be incremented on next loop to 0 104 | state = 255 105 | p = Message{} 106 | } 107 | 108 | for ; state < 7; state++ { 109 | if ctx.Err() != nil { 110 | return nil, ctx.Err() 111 | } 112 | 113 | if invalidLenCount >= 3 { 114 | return nil, fmt.Errorf("too many invalid lengths") 115 | } 116 | 117 | c := make([]byte, 1) 118 | 119 | n, err := j.rdr.Read(c) 120 | if err != nil { 121 | return nil, fmt.Errorf("read: %w", err) 122 | } 123 | 124 | if n == 0 { 125 | return nil, fmt.Errorf("short read") 126 | } 127 | 128 | x := c[0] 129 | p.raw = append(p.raw, x) 130 | 131 | switch { 132 | case state == 0 && (x == 0xf1 || x == 0xf2): 133 | p.addr = x 134 | case state == 1 && x == p.addr: 135 | // synchronized 136 | continue 137 | case state == 2: 138 | p.Type = x 139 | case state == 3: 140 | p.length = x 141 | 142 | // 0-indexed position of current param byte to parse 143 | curParam = x - 1 144 | 145 | // max number of param bytes is 3 146 | if p.length > 3 { 147 | fmt.Printf("invalid length %0#x is greater than 3 (raw: %#v)\n", p.length, p.raw) 148 | j.metrics.invalidLenCount.Inc() 149 | 150 | invalidLenCount++ 151 | 152 | reset() 153 | } 154 | case state == 4 && p.length > 0: 155 | p.params += uint32(x) << (8 * curParam) 156 | 157 | if curParam > 0 { 158 | state = 3 159 | curParam-- 160 | } 161 | case state == 5: 162 | p.cksum = x 163 | case state == 6 && x == 0x7e: 164 | if !verifyChecksum(p) { 165 | j.metrics.checksumErrorsCount.Inc() 166 | } 167 | 168 | return &p, nil 169 | default: 170 | reset() 171 | } 172 | } 173 | 174 | return nil, fmt.Errorf("invalid state %d", state) 175 | } 176 | 177 | // verifyChecksum, more for information than anything else - it's 178 | // unreliable at certain heights (at least on my desk!) 179 | func verifyChecksum(p Message) bool { 180 | rem := (uint32(p.Type) + uint32(p.length) + p.params) % 0xFF 181 | 182 | return rem == uint32(p.cksum) 183 | } 184 | -------------------------------------------------------------------------------- /dashboard/desk_metrics.json: -------------------------------------------------------------------------------- 1 | { 2 | "__inputs": [ 3 | { 4 | "name": "DS_PROMETHEUS", 5 | "label": "default", 6 | "description": "", 7 | "type": "datasource", 8 | "pluginId": "prometheus", 9 | "pluginName": "Prometheus" 10 | } 11 | ], 12 | "__elements": {}, 13 | "__requires": [ 14 | { 15 | "type": "panel", 16 | "id": "barchart", 17 | "name": "Bar chart", 18 | "version": "" 19 | }, 20 | { 21 | "type": "grafana", 22 | "id": "grafana", 23 | "name": "Grafana", 24 | "version": "9.3.6" 25 | }, 26 | { 27 | "type": "datasource", 28 | "id": "prometheus", 29 | "name": "Prometheus", 30 | "version": "1.0.0" 31 | }, 32 | { 33 | "type": "panel", 34 | "id": "stat", 35 | "name": "Stat", 36 | "version": "" 37 | }, 38 | { 39 | "type": "panel", 40 | "id": "timeseries", 41 | "name": "Time series", 42 | "version": "" 43 | } 44 | ], 45 | "annotations": { 46 | "list": [ 47 | { 48 | "builtIn": 1, 49 | "datasource": { 50 | "type": "datasource", 51 | "uid": "grafana" 52 | }, 53 | "enable": true, 54 | "hide": true, 55 | "iconColor": "rgba(0, 211, 255, 1)", 56 | "name": "Annotations & Alerts", 57 | "target": { 58 | "limit": 100, 59 | "matchAny": false, 60 | "tags": [], 61 | "type": "dashboard" 62 | }, 63 | "type": "dashboard" 64 | } 65 | ] 66 | }, 67 | "editable": true, 68 | "fiscalYearStartMonth": 0, 69 | "graphTooltip": 1, 70 | "id": null, 71 | "links": [], 72 | "liveNow": false, 73 | "panels": [ 74 | { 75 | "datasource": { 76 | "type": "prometheus", 77 | "uid": "${DS_PROMETHEUS}" 78 | }, 79 | "description": "", 80 | "fieldConfig": { 81 | "defaults": { 82 | "color": { 83 | "mode": "thresholds" 84 | }, 85 | "mappings": [], 86 | "min": 0, 87 | "thresholds": { 88 | "mode": "absolute", 89 | "steps": [ 90 | { 91 | "color": "red", 92 | "value": null 93 | }, 94 | { 95 | "color": "#EAB839", 96 | "value": 0.4 97 | }, 98 | { 99 | "color": "green", 100 | "value": 0.5 101 | } 102 | ] 103 | }, 104 | "unit": "percentunit" 105 | }, 106 | "overrides": [] 107 | }, 108 | "gridPos": { 109 | "h": 5, 110 | "w": 4, 111 | "x": 0, 112 | "y": 0 113 | }, 114 | "id": 13, 115 | "options": { 116 | "colorMode": "value", 117 | "graphMode": "none", 118 | "justifyMode": "center", 119 | "orientation": "horizontal", 120 | "reduceOptions": { 121 | "calcs": [ 122 | "lastNotNull" 123 | ], 124 | "fields": "", 125 | "values": false 126 | }, 127 | "text": {}, 128 | "textMode": "auto" 129 | }, 130 | "pluginVersion": "9.3.6", 131 | "targets": [ 132 | { 133 | "datasource": { 134 | "type": "prometheus", 135 | "uid": "${DS_PROMETHEUS}" 136 | }, 137 | "exemplar": false, 138 | "expr": "jarvis_desk_height:awake:7d", 139 | "hide": false, 140 | "instant": true, 141 | "interval": "", 142 | "legendFormat": "Week ", 143 | "refId": "B" 144 | }, 145 | { 146 | "datasource": { 147 | "type": "prometheus", 148 | "uid": "${DS_PROMETHEUS}" 149 | }, 150 | "exemplar": false, 151 | "expr": "jarvis_desk_height:awake:1d", 152 | "hide": false, 153 | "instant": true, 154 | "interval": "", 155 | "legendFormat": "Day", 156 | "refId": "A" 157 | } 158 | ], 159 | "title": "Stand/Sit Ratio", 160 | "transformations": [], 161 | "type": "stat" 162 | }, 163 | { 164 | "datasource": { 165 | "type": "prometheus", 166 | "uid": "${DS_PROMETHEUS}" 167 | }, 168 | "description": "", 169 | "fieldConfig": { 170 | "defaults": { 171 | "color": { 172 | "mode": "palette-classic" 173 | }, 174 | "custom": { 175 | "axisCenteredZero": false, 176 | "axisColorMode": "text", 177 | "axisLabel": "", 178 | "axisPlacement": "auto", 179 | "barAlignment": 0, 180 | "drawStyle": "line", 181 | "fillOpacity": 0, 182 | "gradientMode": "none", 183 | "hideFrom": { 184 | "graph": false, 185 | "legend": false, 186 | "tooltip": false, 187 | "viz": false 188 | }, 189 | "lineInterpolation": "smooth", 190 | "lineWidth": 2, 191 | "pointSize": 5, 192 | "scaleDistribution": { 193 | "type": "linear" 194 | }, 195 | "showPoints": "never", 196 | "spanNulls": false, 197 | "stacking": { 198 | "group": "A", 199 | "mode": "none" 200 | }, 201 | "thresholdsStyle": { 202 | "mode": "off" 203 | } 204 | }, 205 | "mappings": [], 206 | "min": 0, 207 | "thresholds": { 208 | "mode": "absolute", 209 | "steps": [ 210 | { 211 | "color": "green", 212 | "value": null 213 | }, 214 | { 215 | "color": "red", 216 | "value": 80 217 | } 218 | ] 219 | }, 220 | "unit": "percentunit" 221 | }, 222 | "overrides": [ 223 | { 224 | "matcher": { 225 | "id": "byName", 226 | "options": "Avg. Height (day)" 227 | }, 228 | "properties": [ 229 | { 230 | "id": "unit", 231 | "value": "lengthm" 232 | }, 233 | { 234 | "id": "custom.axisLabel", 235 | "value": "Average Desk Height" 236 | } 237 | ] 238 | } 239 | ] 240 | }, 241 | "gridPos": { 242 | "h": 9, 243 | "w": 10, 244 | "x": 4, 245 | "y": 0 246 | }, 247 | "id": 7, 248 | "options": { 249 | "graph": {}, 250 | "legend": { 251 | "calcs": [], 252 | "displayMode": "list", 253 | "placement": "bottom", 254 | "showLegend": true 255 | }, 256 | "tooltip": { 257 | "mode": "multi", 258 | "sort": "none" 259 | } 260 | }, 261 | "pluginVersion": "7.5.7", 262 | "targets": [ 263 | { 264 | "datasource": { 265 | "type": "prometheus", 266 | "uid": "${DS_PROMETHEUS}" 267 | }, 268 | "editorMode": "code", 269 | "exemplar": true, 270 | "expr": "avg_over_time(\n (\n sum(jarvis_desk_height_meters > 0.2)\n *\n count(absent(macos_display_brightness_percent{state=\"asleep\"}))\n )[24h:1m]\n)\n*\ncount(absent(macos_display_brightness_percent{state=\"asleep\"}))", 271 | "hide": false, 272 | "interval": "", 273 | "legendFormat": "Avg. Height (day)", 274 | "range": true, 275 | "refId": "A" 276 | }, 277 | { 278 | "datasource": { 279 | "type": "prometheus", 280 | "uid": "${DS_PROMETHEUS}" 281 | }, 282 | "exemplar": true, 283 | "expr": "jarvis_desk_height:awake:1d", 284 | "hide": false, 285 | "interval": "", 286 | "legendFormat": "Stand/Sit Ratio (day)", 287 | "refId": "B" 288 | }, 289 | { 290 | "datasource": { 291 | "type": "prometheus", 292 | "uid": "${DS_PROMETHEUS}" 293 | }, 294 | "exemplar": true, 295 | "expr": "jarvis_desk_height:awake:7d", 296 | "hide": false, 297 | "interval": "", 298 | "legendFormat": "Stand/Sit Ratio (week)", 299 | "refId": "C" 300 | } 301 | ], 302 | "title": "Stand/Sit Ratio over time, Avg. desk height", 303 | "type": "timeseries" 304 | }, 305 | { 306 | "datasource": { 307 | "type": "prometheus", 308 | "uid": "${DS_PROMETHEUS}" 309 | }, 310 | "description": "Height of desk (when awake)", 311 | "fieldConfig": { 312 | "defaults": { 313 | "color": { 314 | "mode": "palette-classic" 315 | }, 316 | "custom": { 317 | "axisCenteredZero": false, 318 | "axisColorMode": "text", 319 | "axisLabel": "", 320 | "axisPlacement": "auto", 321 | "barAlignment": 0, 322 | "drawStyle": "line", 323 | "fillOpacity": 69, 324 | "gradientMode": "opacity", 325 | "hideFrom": { 326 | "graph": false, 327 | "legend": false, 328 | "tooltip": false, 329 | "viz": false 330 | }, 331 | "lineInterpolation": "smooth", 332 | "lineWidth": 1, 333 | "pointSize": 5, 334 | "scaleDistribution": { 335 | "type": "linear" 336 | }, 337 | "showPoints": "never", 338 | "spanNulls": false, 339 | "stacking": { 340 | "group": "A", 341 | "mode": "none" 342 | }, 343 | "thresholdsStyle": { 344 | "mode": "off" 345 | } 346 | }, 347 | "decimals": 3, 348 | "mappings": [], 349 | "max": 2, 350 | "min": 0, 351 | "thresholds": { 352 | "mode": "absolute", 353 | "steps": [ 354 | { 355 | "color": "green", 356 | "value": null 357 | }, 358 | { 359 | "color": "red", 360 | "value": 80 361 | } 362 | ] 363 | }, 364 | "unit": "lengthm" 365 | }, 366 | "overrides": [] 367 | }, 368 | "gridPos": { 369 | "h": 9, 370 | "w": 10, 371 | "x": 14, 372 | "y": 0 373 | }, 374 | "id": 5, 375 | "options": { 376 | "graph": {}, 377 | "legend": { 378 | "calcs": [], 379 | "displayMode": "list", 380 | "placement": "bottom", 381 | "showLegend": true 382 | }, 383 | "tooltip": { 384 | "mode": "multi", 385 | "sort": "none" 386 | } 387 | }, 388 | "pluginVersion": "7.5.7", 389 | "targets": [ 390 | { 391 | "datasource": { 392 | "type": "prometheus", 393 | "uid": "${DS_PROMETHEUS}" 394 | }, 395 | "exemplar": true, 396 | "expr": "sum(jarvis_desk_height_meters > 0.2) * (count(absent(macos_display_brightness_percent{state=\"asleep\"})))", 397 | "interval": "", 398 | "legendFormat": "Desk Height", 399 | "refId": "A" 400 | } 401 | ], 402 | "title": "Desk Height", 403 | "type": "timeseries" 404 | }, 405 | { 406 | "datasource": { 407 | "type": "prometheus", 408 | "uid": "${DS_PROMETHEUS}" 409 | }, 410 | "description": "", 411 | "fieldConfig": { 412 | "defaults": { 413 | "color": { 414 | "mode": "thresholds" 415 | }, 416 | "mappings": [ 417 | { 418 | "options": { 419 | "from": 0, 420 | "result": { 421 | "index": 0, 422 | "text": "🪑" 423 | }, 424 | "to": 0.8 425 | }, 426 | "type": "range" 427 | }, 428 | { 429 | "options": { 430 | "from": 0.8, 431 | "result": { 432 | "index": 1, 433 | "text": "🧍" 434 | }, 435 | "to": 99 436 | }, 437 | "type": "range" 438 | }, 439 | { 440 | "options": { 441 | "match": "null+nan", 442 | "result": { 443 | "index": 2, 444 | "text": "🛌" 445 | } 446 | }, 447 | "type": "special" 448 | } 449 | ], 450 | "min": 0, 451 | "thresholds": { 452 | "mode": "absolute", 453 | "steps": [ 454 | { 455 | "color": "red", 456 | "value": null 457 | } 458 | ] 459 | }, 460 | "unit": "none" 461 | }, 462 | "overrides": [] 463 | }, 464 | "gridPos": { 465 | "h": 4, 466 | "w": 2, 467 | "x": 0, 468 | "y": 5 469 | }, 470 | "id": 17, 471 | "maxDataPoints": 7200, 472 | "options": { 473 | "colorMode": "none", 474 | "graphMode": "none", 475 | "justifyMode": "center", 476 | "orientation": "horizontal", 477 | "reduceOptions": { 478 | "calcs": [ 479 | "lastNotNull" 480 | ], 481 | "fields": "", 482 | "values": false 483 | }, 484 | "text": {}, 485 | "textMode": "auto" 486 | }, 487 | "pluginVersion": "9.3.6", 488 | "targets": [ 489 | { 490 | "datasource": { 491 | "type": "prometheus", 492 | "uid": "${DS_PROMETHEUS}" 493 | }, 494 | "editorMode": "code", 495 | "exemplar": false, 496 | "expr": "sum(jarvis_desk_height_meters > 0.2) * (count(absent(macos_display_brightness_percent{state=\"asleep\"})))", 497 | "hide": false, 498 | "instant": true, 499 | "interval": "1m", 500 | "legendFormat": "", 501 | "refId": "B" 502 | } 503 | ], 504 | "title": "Current state", 505 | "transformations": [], 506 | "type": "stat" 507 | }, 508 | { 509 | "datasource": { 510 | "type": "prometheus", 511 | "uid": "${DS_PROMETHEUS}" 512 | }, 513 | "description": "Displays whether the daily ratio is greater than, equal, or less than the weekly ratio.", 514 | "fieldConfig": { 515 | "defaults": { 516 | "color": { 517 | "mode": "thresholds" 518 | }, 519 | "mappings": [ 520 | { 521 | "options": { 522 | "from": 0, 523 | "result": { 524 | "index": 0, 525 | "text": "⬇︎" 526 | }, 527 | "to": 0.95 528 | }, 529 | "type": "range" 530 | }, 531 | { 532 | "options": { 533 | "from": 0.95, 534 | "result": { 535 | "index": 1, 536 | "text": "―" 537 | }, 538 | "to": 1.05 539 | }, 540 | "type": "range" 541 | }, 542 | { 543 | "options": { 544 | "from": 1.05, 545 | "result": { 546 | "index": 2, 547 | "text": "⬆︎" 548 | }, 549 | "to": 999 550 | }, 551 | "type": "range" 552 | } 553 | ], 554 | "thresholds": { 555 | "mode": "absolute", 556 | "steps": [ 557 | { 558 | "color": "red", 559 | "value": null 560 | }, 561 | { 562 | "color": "text", 563 | "value": 0.95 564 | }, 565 | { 566 | "color": "green", 567 | "value": 1.05 568 | } 569 | ] 570 | }, 571 | "unit": "short" 572 | }, 573 | "overrides": [] 574 | }, 575 | "gridPos": { 576 | "h": 4, 577 | "w": 2, 578 | "x": 2, 579 | "y": 5 580 | }, 581 | "id": 15, 582 | "options": { 583 | "colorMode": "value", 584 | "graphMode": "none", 585 | "justifyMode": "center", 586 | "orientation": "horizontal", 587 | "reduceOptions": { 588 | "calcs": [ 589 | "lastNotNull" 590 | ], 591 | "fields": "", 592 | "values": true 593 | }, 594 | "text": {}, 595 | "textMode": "auto" 596 | }, 597 | "pluginVersion": "9.3.6", 598 | "targets": [ 599 | { 600 | "datasource": { 601 | "type": "prometheus", 602 | "uid": "${DS_PROMETHEUS}" 603 | }, 604 | "exemplar": false, 605 | "expr": "max(jarvis_desk_height:awake:1d) / max(jarvis_desk_height:awake:7d)", 606 | "hide": false, 607 | "instant": true, 608 | "interval": "", 609 | "legendFormat": "ratio", 610 | "refId": "B" 611 | } 612 | ], 613 | "title": "Daily Trend", 614 | "transformations": [], 615 | "type": "stat" 616 | }, 617 | { 618 | "datasource": { 619 | "type": "prometheus", 620 | "uid": "${DS_PROMETHEUS}" 621 | }, 622 | "description": "based on whether or not the display is awake", 623 | "fieldConfig": { 624 | "defaults": { 625 | "color": { 626 | "mode": "thresholds" 627 | }, 628 | "mappings": [], 629 | "thresholds": { 630 | "mode": "absolute", 631 | "steps": [ 632 | { 633 | "color": "red", 634 | "value": null 635 | } 636 | ] 637 | }, 638 | "unit": "dtdurations" 639 | }, 640 | "overrides": [] 641 | }, 642 | "gridPos": { 643 | "h": 4, 644 | "w": 2, 645 | "x": 0, 646 | "y": 9 647 | }, 648 | "id": 19, 649 | "maxDataPoints": 7200, 650 | "options": { 651 | "colorMode": "none", 652 | "graphMode": "none", 653 | "justifyMode": "center", 654 | "orientation": "horizontal", 655 | "reduceOptions": { 656 | "calcs": [ 657 | "lastNotNull" 658 | ], 659 | "fields": "", 660 | "values": false 661 | }, 662 | "text": {}, 663 | "textMode": "auto" 664 | }, 665 | "pluginVersion": "9.3.6", 666 | "targets": [ 667 | { 668 | "datasource": { 669 | "type": "prometheus", 670 | "uid": "${DS_PROMETHEUS}" 671 | }, 672 | "editorMode": "code", 673 | "exemplar": false, 674 | "expr": "sum_over_time(\n count(macos_display_brightness_percent{state=\"awake\"})\n[24h:1m]) * 60", 675 | "hide": false, 676 | "instant": true, 677 | "interval": "1m", 678 | "legendFormat": "", 679 | "refId": "B" 680 | } 681 | ], 682 | "title": "Computer use over past 24hrs", 683 | "transformations": [], 684 | "type": "stat" 685 | }, 686 | { 687 | "datasource": { 688 | "type": "prometheus", 689 | "uid": "${DS_PROMETHEUS}" 690 | }, 691 | "description": "", 692 | "fieldConfig": { 693 | "defaults": { 694 | "color": { 695 | "mode": "thresholds" 696 | }, 697 | "mappings": [], 698 | "thresholds": { 699 | "mode": "absolute", 700 | "steps": [ 701 | { 702 | "color": "red", 703 | "value": null 704 | } 705 | ] 706 | }, 707 | "unit": "dtdurations" 708 | }, 709 | "overrides": [] 710 | }, 711 | "gridPos": { 712 | "h": 4, 713 | "w": 2, 714 | "x": 2, 715 | "y": 9 716 | }, 717 | "id": 20, 718 | "maxDataPoints": 7200, 719 | "options": { 720 | "colorMode": "none", 721 | "graphMode": "none", 722 | "justifyMode": "center", 723 | "orientation": "horizontal", 724 | "reduceOptions": { 725 | "calcs": [ 726 | "lastNotNull" 727 | ], 728 | "fields": "", 729 | "values": false 730 | }, 731 | "text": {}, 732 | "textMode": "auto" 733 | }, 734 | "pluginVersion": "9.3.6", 735 | "targets": [ 736 | { 737 | "datasource": { 738 | "type": "prometheus", 739 | "uid": "${DS_PROMETHEUS}" 740 | }, 741 | "editorMode": "code", 742 | "exemplar": false, 743 | "expr": "count(sum(jarvis_desk_height_meters > 0.2))\n*\nsum_over_time(\n count(macos_display_brightness_percent{state=\"awake\"})\n[24h:1m])\n* 60", 744 | "hide": false, 745 | "instant": true, 746 | "interval": "1m", 747 | "legendFormat": "", 748 | "refId": "B" 749 | } 750 | ], 751 | "title": "Standing over past 24hrs", 752 | "transformations": [], 753 | "type": "stat" 754 | }, 755 | { 756 | "datasource": { 757 | "type": "prometheus", 758 | "uid": "${DS_PROMETHEUS}" 759 | }, 760 | "description": "", 761 | "fieldConfig": { 762 | "defaults": { 763 | "color": { 764 | "mode": "palette-classic" 765 | }, 766 | "custom": { 767 | "axisCenteredZero": false, 768 | "axisColorMode": "text", 769 | "axisLabel": "", 770 | "axisPlacement": "auto", 771 | "barAlignment": 0, 772 | "drawStyle": "line", 773 | "fillOpacity": 23, 774 | "gradientMode": "opacity", 775 | "hideFrom": { 776 | "graph": false, 777 | "legend": false, 778 | "tooltip": false, 779 | "viz": false 780 | }, 781 | "lineInterpolation": "smooth", 782 | "lineWidth": 1, 783 | "pointSize": 5, 784 | "scaleDistribution": { 785 | "type": "linear" 786 | }, 787 | "showPoints": "never", 788 | "spanNulls": false, 789 | "stacking": { 790 | "group": "A", 791 | "mode": "none" 792 | }, 793 | "thresholdsStyle": { 794 | "mode": "off" 795 | } 796 | }, 797 | "mappings": [], 798 | "max": 0.0001, 799 | "min": -0.0001, 800 | "thresholds": { 801 | "mode": "absolute", 802 | "steps": [ 803 | { 804 | "color": "green", 805 | "value": null 806 | }, 807 | { 808 | "color": "red", 809 | "value": 80 810 | } 811 | ] 812 | }, 813 | "unit": "short" 814 | }, 815 | "overrides": [] 816 | }, 817 | "gridPos": { 818 | "h": 9, 819 | "w": 10, 820 | "x": 4, 821 | "y": 9 822 | }, 823 | "id": 14, 824 | "options": { 825 | "graph": {}, 826 | "legend": { 827 | "calcs": [], 828 | "displayMode": "list", 829 | "placement": "bottom", 830 | "showLegend": true 831 | }, 832 | "tooltip": { 833 | "mode": "multi", 834 | "sort": "none" 835 | } 836 | }, 837 | "pluginVersion": "7.5.7", 838 | "targets": [ 839 | { 840 | "datasource": { 841 | "type": "prometheus", 842 | "uid": "${DS_PROMETHEUS}" 843 | }, 844 | "exemplar": true, 845 | "expr": "deriv(jarvis_desk_height:awake:1d[1h])", 846 | "interval": "", 847 | "legendFormat": "deriv(day)", 848 | "refId": "A" 849 | }, 850 | { 851 | "datasource": { 852 | "type": "prometheus", 853 | "uid": "${DS_PROMETHEUS}" 854 | }, 855 | "exemplar": true, 856 | "expr": "deriv(jarvis_desk_height:awake:7d[1h])", 857 | "hide": false, 858 | "interval": "", 859 | "legendFormat": "deriv(week)", 860 | "refId": "B" 861 | } 862 | ], 863 | "title": "Trends (deriv/1h)", 864 | "type": "timeseries" 865 | }, 866 | { 867 | "datasource": { 868 | "type": "prometheus", 869 | "uid": "${DS_PROMETHEUS}" 870 | }, 871 | "description": "", 872 | "fieldConfig": { 873 | "defaults": { 874 | "color": { 875 | "mode": "thresholds" 876 | }, 877 | "custom": { 878 | "axisCenteredZero": false, 879 | "axisColorMode": "text", 880 | "axisLabel": "", 881 | "axisPlacement": "auto", 882 | "axisSoftMin": 0, 883 | "fillOpacity": 75, 884 | "gradientMode": "none", 885 | "hideFrom": { 886 | "legend": false, 887 | "tooltip": false, 888 | "viz": false 889 | }, 890 | "lineWidth": 0, 891 | "scaleDistribution": { 892 | "type": "linear" 893 | }, 894 | "thresholdsStyle": { 895 | "mode": "off" 896 | } 897 | }, 898 | "decimals": 2, 899 | "mappings": [], 900 | "min": 0, 901 | "thresholds": { 902 | "mode": "absolute", 903 | "steps": [ 904 | { 905 | "color": "red", 906 | "value": null 907 | }, 908 | { 909 | "color": "#EAB839", 910 | "value": 0.4 911 | }, 912 | { 913 | "color": "green", 914 | "value": 0.5 915 | } 916 | ] 917 | }, 918 | "unit": "percentunit" 919 | }, 920 | "overrides": [] 921 | }, 922 | "gridPos": { 923 | "h": 9, 924 | "w": 10, 925 | "x": 14, 926 | "y": 9 927 | }, 928 | "id": 16, 929 | "options": { 930 | "barRadius": 0, 931 | "barWidth": 0.8, 932 | "colorByField": "Stand/Sit (day)", 933 | "groupWidth": 0.7, 934 | "legend": { 935 | "calcs": [], 936 | "displayMode": "list", 937 | "placement": "bottom", 938 | "showLegend": false 939 | }, 940 | "orientation": "auto", 941 | "showValue": "auto", 942 | "stacking": "none", 943 | "tooltip": { 944 | "mode": "single", 945 | "sort": "none" 946 | }, 947 | "xField": "Time", 948 | "xTickLabelRotation": 0, 949 | "xTickLabelSpacing": 0 950 | }, 951 | "pluginVersion": "7.5.7", 952 | "targets": [ 953 | { 954 | "datasource": { 955 | "type": "prometheus", 956 | "uid": "${DS_PROMETHEUS}" 957 | }, 958 | "exemplar": true, 959 | "expr": "avg_over_time(jarvis_desk_height:awake:1d[1d])", 960 | "format": "time_series", 961 | "hide": false, 962 | "interval": "1d", 963 | "intervalFactor": 1, 964 | "legendFormat": "Stand/Sit (day)", 965 | "refId": "B" 966 | } 967 | ], 968 | "title": "Stand/Sit Ratio over time, Avg. desk height", 969 | "type": "barchart" 970 | }, 971 | { 972 | "collapsed": true, 973 | "datasource": { 974 | "type": "prometheus", 975 | "uid": "grafanacloud-prom" 976 | }, 977 | "gridPos": { 978 | "h": 1, 979 | "w": 24, 980 | "x": 0, 981 | "y": 18 982 | }, 983 | "id": 11, 984 | "panels": [ 985 | { 986 | "datasource": { 987 | "type": "prometheus", 988 | "uid": "${DS_PROMETHEUS}" 989 | }, 990 | "description": "", 991 | "fieldConfig": { 992 | "defaults": { 993 | "color": { 994 | "mode": "palette-classic" 995 | }, 996 | "custom": { 997 | "axisCenteredZero": false, 998 | "axisColorMode": "text", 999 | "axisLabel": "", 1000 | "axisPlacement": "auto", 1001 | "barAlignment": 0, 1002 | "drawStyle": "line", 1003 | "fillOpacity": 69, 1004 | "gradientMode": "opacity", 1005 | "hideFrom": { 1006 | "graph": false, 1007 | "legend": false, 1008 | "tooltip": false, 1009 | "viz": false 1010 | }, 1011 | "lineInterpolation": "smooth", 1012 | "lineWidth": 1, 1013 | "pointSize": 5, 1014 | "scaleDistribution": { 1015 | "type": "linear" 1016 | }, 1017 | "showPoints": "never", 1018 | "spanNulls": false, 1019 | "stacking": { 1020 | "group": "A", 1021 | "mode": "none" 1022 | }, 1023 | "thresholdsStyle": { 1024 | "mode": "off" 1025 | } 1026 | }, 1027 | "decimals": 3, 1028 | "mappings": [], 1029 | "max": 2, 1030 | "min": 0, 1031 | "thresholds": { 1032 | "mode": "absolute", 1033 | "steps": [ 1034 | { 1035 | "color": "green", 1036 | "value": null 1037 | }, 1038 | { 1039 | "color": "red", 1040 | "value": 80 1041 | } 1042 | ] 1043 | }, 1044 | "unit": "lengthm" 1045 | }, 1046 | "overrides": [] 1047 | }, 1048 | "gridPos": { 1049 | "h": 9, 1050 | "w": 10, 1051 | "x": 0, 1052 | "y": 19 1053 | }, 1054 | "id": 2, 1055 | "options": { 1056 | "graph": {}, 1057 | "legend": { 1058 | "calcs": [], 1059 | "displayMode": "list", 1060 | "placement": "bottom", 1061 | "showLegend": true 1062 | }, 1063 | "tooltip": { 1064 | "mode": "multi", 1065 | "sort": "none" 1066 | } 1067 | }, 1068 | "pluginVersion": "7.5.7", 1069 | "targets": [ 1070 | { 1071 | "datasource": { 1072 | "type": "prometheus", 1073 | "uid": "${DS_PROMETHEUS}" 1074 | }, 1075 | "exemplar": true, 1076 | "expr": "jarvis_desk_height_meters", 1077 | "interval": "", 1078 | "legendFormat": "Desk Height", 1079 | "refId": "A" 1080 | } 1081 | ], 1082 | "title": "Desk Height", 1083 | "type": "timeseries" 1084 | }, 1085 | { 1086 | "datasource": { 1087 | "type": "prometheus", 1088 | "uid": "${DS_PROMETHEUS}" 1089 | }, 1090 | "description": "", 1091 | "fieldConfig": { 1092 | "defaults": { 1093 | "color": { 1094 | "mode": "palette-classic" 1095 | }, 1096 | "custom": { 1097 | "axisCenteredZero": false, 1098 | "axisColorMode": "text", 1099 | "axisLabel": "", 1100 | "axisPlacement": "auto", 1101 | "barAlignment": 0, 1102 | "drawStyle": "line", 1103 | "fillOpacity": 1, 1104 | "gradientMode": "none", 1105 | "hideFrom": { 1106 | "graph": false, 1107 | "legend": false, 1108 | "tooltip": false, 1109 | "viz": false 1110 | }, 1111 | "lineInterpolation": "smooth", 1112 | "lineWidth": 1, 1113 | "pointSize": 5, 1114 | "scaleDistribution": { 1115 | "type": "linear" 1116 | }, 1117 | "showPoints": "never", 1118 | "spanNulls": false, 1119 | "stacking": { 1120 | "group": "A", 1121 | "mode": "none" 1122 | }, 1123 | "thresholdsStyle": { 1124 | "mode": "off" 1125 | } 1126 | }, 1127 | "decimals": 3, 1128 | "mappings": [], 1129 | "thresholds": { 1130 | "mode": "absolute", 1131 | "steps": [ 1132 | { 1133 | "color": "green", 1134 | "value": null 1135 | }, 1136 | { 1137 | "color": "red", 1138 | "value": 80 1139 | } 1140 | ] 1141 | }, 1142 | "unit": "lengthm" 1143 | }, 1144 | "overrides": [] 1145 | }, 1146 | "gridPos": { 1147 | "h": 9, 1148 | "w": 10, 1149 | "x": 10, 1150 | "y": 19 1151 | }, 1152 | "id": 6, 1153 | "options": { 1154 | "graph": {}, 1155 | "legend": { 1156 | "calcs": [], 1157 | "displayMode": "list", 1158 | "placement": "bottom", 1159 | "showLegend": true 1160 | }, 1161 | "tooltip": { 1162 | "mode": "multi", 1163 | "sort": "none" 1164 | } 1165 | }, 1166 | "pluginVersion": "7.5.7", 1167 | "targets": [ 1168 | { 1169 | "datasource": { 1170 | "type": "prometheus", 1171 | "uid": "${DS_PROMETHEUS}" 1172 | }, 1173 | "exemplar": true, 1174 | "expr": "avg_over_time(\n (\n sum(jarvis_desk_height_meters)\n *\n count(absent(macos_display_brightness_percent{state=\"asleep\"}))\n )[24h:1m]\n)\n*\ncount(absent(macos_display_brightness_percent{state=\"asleep\"}))", 1175 | "hide": false, 1176 | "interval": "", 1177 | "legendFormat": "24hr Avg. Desk Height", 1178 | "refId": "A" 1179 | }, 1180 | { 1181 | "datasource": { 1182 | "type": "prometheus", 1183 | "uid": "${DS_PROMETHEUS}" 1184 | }, 1185 | "exemplar": true, 1186 | "expr": "max_over_time(\n (\n max(jarvis_desk_height_meters)\n *\n (\n count(absent(macos_display_brightness_percent{state=\"asleep\"}))\n )\n )[3d:]\n)\n-\nmin_over_time(\n (\n max(jarvis_desk_height_meters > 0)\n *\n (\n count(absent(macos_display_brightness_percent{state=\"asleep\"}))\n )\n )[3d:]\n)", 1187 | "hide": true, 1188 | "interval": "", 1189 | "legendFormat": "Diff", 1190 | "refId": "B" 1191 | }, 1192 | { 1193 | "datasource": { 1194 | "type": "prometheus", 1195 | "uid": "${DS_PROMETHEUS}" 1196 | }, 1197 | "exemplar": true, 1198 | "expr": "max_over_time(max(jarvis_desk_height_meters)[3d:])", 1199 | "hide": true, 1200 | "interval": "", 1201 | "legendFormat": "Max", 1202 | "refId": "C" 1203 | }, 1204 | { 1205 | "datasource": { 1206 | "type": "prometheus", 1207 | "uid": "${DS_PROMETHEUS}" 1208 | }, 1209 | "exemplar": true, 1210 | "expr": "min_over_time(max(jarvis_desk_height_meters > 0.2)[3d:])", 1211 | "hide": true, 1212 | "interval": "", 1213 | "legendFormat": "Min", 1214 | "refId": "D" 1215 | }, 1216 | { 1217 | "datasource": { 1218 | "type": "prometheus", 1219 | "uid": "${DS_PROMETHEUS}" 1220 | }, 1221 | "exemplar": true, 1222 | "expr": "sum(jarvis_desk_height_meters) * (count(absent(macos_display_brightness_percent{state=\"asleep\"})))", 1223 | "hide": true, 1224 | "interval": "", 1225 | "legendFormat": "Desk Height", 1226 | "refId": "E" 1227 | } 1228 | ], 1229 | "title": "Avg Desk Height (24hr while awake)", 1230 | "type": "timeseries" 1231 | }, 1232 | { 1233 | "datasource": { 1234 | "type": "prometheus", 1235 | "uid": "${DS_PROMETHEUS}" 1236 | }, 1237 | "description": "", 1238 | "fieldConfig": { 1239 | "defaults": { 1240 | "color": { 1241 | "mode": "palette-classic" 1242 | }, 1243 | "custom": { 1244 | "axisCenteredZero": false, 1245 | "axisColorMode": "text", 1246 | "axisLabel": "", 1247 | "axisPlacement": "auto", 1248 | "barAlignment": 0, 1249 | "drawStyle": "line", 1250 | "fillOpacity": 68, 1251 | "gradientMode": "opacity", 1252 | "hideFrom": { 1253 | "graph": false, 1254 | "legend": false, 1255 | "tooltip": false, 1256 | "viz": false 1257 | }, 1258 | "lineInterpolation": "linear", 1259 | "lineWidth": 0, 1260 | "pointSize": 5, 1261 | "scaleDistribution": { 1262 | "type": "linear" 1263 | }, 1264 | "showPoints": "never", 1265 | "spanNulls": false, 1266 | "stacking": { 1267 | "group": "A", 1268 | "mode": "none" 1269 | }, 1270 | "thresholdsStyle": { 1271 | "mode": "off" 1272 | } 1273 | }, 1274 | "mappings": [], 1275 | "max": 1, 1276 | "min": 0, 1277 | "thresholds": { 1278 | "mode": "absolute", 1279 | "steps": [ 1280 | { 1281 | "color": "green", 1282 | "value": null 1283 | }, 1284 | { 1285 | "color": "red", 1286 | "value": 80 1287 | } 1288 | ] 1289 | }, 1290 | "unit": "percentunit" 1291 | }, 1292 | "overrides": [ 1293 | { 1294 | "matcher": { 1295 | "id": "byRegexp", 1296 | "options": "/asleep/" 1297 | }, 1298 | "properties": [ 1299 | { 1300 | "id": "color", 1301 | "value": { 1302 | "fixedColor": "dark-blue", 1303 | "mode": "fixed" 1304 | } 1305 | } 1306 | ] 1307 | }, 1308 | { 1309 | "matcher": { 1310 | "id": "byRegexp", 1311 | "options": "/awake/" 1312 | }, 1313 | "properties": [ 1314 | { 1315 | "id": "color", 1316 | "value": { 1317 | "fixedColor": "yellow", 1318 | "mode": "fixed" 1319 | } 1320 | } 1321 | ] 1322 | } 1323 | ] 1324 | }, 1325 | "gridPos": { 1326 | "h": 9, 1327 | "w": 10, 1328 | "x": 0, 1329 | "y": 28 1330 | }, 1331 | "id": 4, 1332 | "links": [], 1333 | "options": { 1334 | "graph": {}, 1335 | "legend": { 1336 | "calcs": [], 1337 | "displayMode": "list", 1338 | "placement": "bottom", 1339 | "showLegend": true 1340 | }, 1341 | "tooltip": { 1342 | "mode": "multi", 1343 | "sort": "none" 1344 | } 1345 | }, 1346 | "pluginVersion": "7.4.0", 1347 | "targets": [ 1348 | { 1349 | "datasource": { 1350 | "type": "prometheus", 1351 | "uid": "${DS_PROMETHEUS}" 1352 | }, 1353 | "exemplar": true, 1354 | "expr": "max(macos_display_brightness_percent) by (state)", 1355 | "format": "time_series", 1356 | "hide": false, 1357 | "interval": "", 1358 | "intervalFactor": 1, 1359 | "legendFormat": "{{state}}", 1360 | "refId": "A" 1361 | } 1362 | ], 1363 | "title": "Display Brightness over time", 1364 | "type": "timeseries" 1365 | }, 1366 | { 1367 | "datasource": { 1368 | "type": "prometheus", 1369 | "uid": "${DS_PROMETHEUS}" 1370 | }, 1371 | "description": "", 1372 | "fieldConfig": { 1373 | "defaults": { 1374 | "color": { 1375 | "mode": "palette-classic" 1376 | }, 1377 | "custom": { 1378 | "axisCenteredZero": false, 1379 | "axisColorMode": "text", 1380 | "axisLabel": "", 1381 | "axisPlacement": "auto", 1382 | "barAlignment": 0, 1383 | "drawStyle": "line", 1384 | "fillOpacity": 1, 1385 | "gradientMode": "none", 1386 | "hideFrom": { 1387 | "graph": false, 1388 | "legend": false, 1389 | "tooltip": false, 1390 | "viz": false 1391 | }, 1392 | "lineInterpolation": "smooth", 1393 | "lineWidth": 1, 1394 | "pointSize": 5, 1395 | "scaleDistribution": { 1396 | "type": "linear" 1397 | }, 1398 | "showPoints": "never", 1399 | "spanNulls": false, 1400 | "stacking": { 1401 | "group": "A", 1402 | "mode": "none" 1403 | }, 1404 | "thresholdsStyle": { 1405 | "mode": "off" 1406 | } 1407 | }, 1408 | "decimals": 3, 1409 | "mappings": [], 1410 | "thresholds": { 1411 | "mode": "absolute", 1412 | "steps": [ 1413 | { 1414 | "color": "green", 1415 | "value": null 1416 | }, 1417 | { 1418 | "color": "red", 1419 | "value": 80 1420 | } 1421 | ] 1422 | }, 1423 | "unit": "m" 1424 | }, 1425 | "overrides": [] 1426 | }, 1427 | "gridPos": { 1428 | "h": 9, 1429 | "w": 10, 1430 | "x": 10, 1431 | "y": 28 1432 | }, 1433 | "id": 18, 1434 | "options": { 1435 | "graph": {}, 1436 | "legend": { 1437 | "calcs": [], 1438 | "displayMode": "list", 1439 | "placement": "bottom", 1440 | "showLegend": true 1441 | }, 1442 | "tooltip": { 1443 | "mode": "multi", 1444 | "sort": "none" 1445 | } 1446 | }, 1447 | "pluginVersion": "7.5.7", 1448 | "targets": [ 1449 | { 1450 | "datasource": { 1451 | "type": "prometheus", 1452 | "uid": "${DS_PROMETHEUS}" 1453 | }, 1454 | "editorMode": "code", 1455 | "exemplar": true, 1456 | "expr": "sum_over_time(\n count(macos_display_brightness_percent{state=\"awake\"})\n[24h:1m])", 1457 | "hide": false, 1458 | "interval": "", 1459 | "legendFormat": "Awake (past 24h)", 1460 | "range": true, 1461 | "refId": "A" 1462 | } 1463 | ], 1464 | "title": "awake time (past 24hrs)", 1465 | "type": "timeseries" 1466 | } 1467 | ], 1468 | "targets": [ 1469 | { 1470 | "datasource": { 1471 | "type": "prometheus", 1472 | "uid": "grafanacloud-prom" 1473 | }, 1474 | "refId": "A" 1475 | } 1476 | ], 1477 | "title": "other stuff...", 1478 | "type": "row" 1479 | } 1480 | ], 1481 | "refresh": "30s", 1482 | "schemaVersion": 37, 1483 | "style": "dark", 1484 | "tags": [], 1485 | "templating": { 1486 | "list": [ 1487 | { 1488 | "current": { 1489 | "selected": false, 1490 | "text": "default", 1491 | "value": "default" 1492 | }, 1493 | "hide": 0, 1494 | "includeAll": false, 1495 | "multi": false, 1496 | "name": "datasource", 1497 | "options": [], 1498 | "query": "prometheus", 1499 | "queryValue": "", 1500 | "refresh": 1, 1501 | "regex": "", 1502 | "skipUrlSync": false, 1503 | "type": "datasource" 1504 | } 1505 | ] 1506 | }, 1507 | "time": { 1508 | "from": "now-7d", 1509 | "to": "now" 1510 | }, 1511 | "timepicker": {}, 1512 | "timezone": "", 1513 | "title": "Desk Metrics", 1514 | "uid": "Y-IWGIk7k", 1515 | "version": 27, 1516 | "weekStart": "" 1517 | } 1518 | --------------------------------------------------------------------------------