├── .gitignore ├── img └── grafana.png ├── .github └── workflows │ └── test.yml ├── renovate.json ├── go.mod ├── Dockerfile ├── README.md ├── go.sum └── breathe.go /.gitignore: -------------------------------------------------------------------------------- 1 | breathe 2 | -------------------------------------------------------------------------------- /img/grafana.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mhansen/breathe/HEAD/img/grafana.png -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: [push] 3 | jobs: 4 | test: 5 | runs-on: ubuntu-latest 6 | 7 | steps: 8 | - uses: actions/checkout@v4 9 | - uses: actions/setup-go@v5 10 | with: 11 | go-version: '1.24.2' 12 | 13 | - run: go test 14 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ], 6 | "packageRules": [ 7 | { 8 | "matchUpdateTypes": ["minor", "patch", "pin", "digest"], 9 | "automerge": true 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mhansen/breathe 2 | 3 | go 1.21 4 | 5 | toolchain go1.24.2 6 | 7 | require ( 8 | github.com/jacobsa/go-serial v0.0.0-20180131005756-15cf729a72d4 9 | github.com/prometheus/client_golang v1.21.1 10 | ) 11 | 12 | require ( 13 | github.com/beorn7/perks v1.0.1 // indirect 14 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 15 | github.com/klauspost/compress v1.17.11 // indirect 16 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 17 | github.com/prometheus/client_model v0.6.1 // indirect 18 | github.com/prometheus/common v0.62.0 // indirect 19 | github.com/prometheus/procfs v0.15.1 // indirect 20 | golang.org/x/sys v0.28.0 // indirect 21 | google.golang.org/protobuf v1.36.1 // indirect 22 | ) 23 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Can compile on any machine/architecture - thanks Go! 2 | 3 | # Use the official Golang image to create a build artifact. 4 | # This is based on Debian and sets the GOPATH to /go. 5 | # https://hub.docker.com/_/golang 6 | FROM golang:1.24 as builder 7 | 8 | # Create and change to the app directory. 9 | WORKDIR /app 10 | 11 | # Retrieve application dependencies. 12 | # This allows the container build to reuse cached dependencies. 13 | COPY go.* ./ 14 | RUN go mod download 15 | 16 | # Copy local code to the container image. 17 | COPY . ./ 18 | 19 | # Build the binary. 20 | RUN CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -mod=readonly -v -o server 21 | 22 | # Use the official Alpine image for a lean production container. 23 | # https://hub.docker.com/_/alpine 24 | # https://docs.docker.com/develop/develop-images/multistage-build/#use-multi-stage-builds 25 | FROM alpine:3 26 | RUN apk add --no-cache ca-certificates 27 | 28 | # Copy the binary to the production image from the builder stage. 29 | COPY --from=builder /app/server /server 30 | 31 | # Run the web service on container startup. 32 | ENTRYPOINT ["/server"] 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Breathe 2 | 3 | A [Prometheus](https://prometheus.io/) exporter for [PMS5003](https://www.aqmd.gov/docs/default-source/aq-spec/resources-page/plantower-pms5003-manual_v2-3.pdf) Particulate Matter/Air Quality sensors. 4 | 5 | Pushed to [Docker Hub](https://hub.docker.com/repository/docker/markhnsn/breathe) 6 | 7 | Pair with Grafana for beautiful dashboarding: 8 | 9 |  10 | 11 | Example usage: 12 | 13 | ```shell 14 | $ go build 15 | $ ./breathe --port=:9662 --portname=/dev/serial0 16 | 17 | $ curl http://localhost:9662/metrics 18 | 19 | ... 20 | # HELP pms_packet_checksum_errors 21 | # TYPE pms_packet_checksum_errors counter 22 | pms_packet_checksum_errors 0 23 | # HELP pms_particle_counts Number of particles with diameter beyond given number of microns in 0.1L of air 24 | # TYPE pms_particle_counts gauge 25 | pms_particle_counts{microns_lower_bound="10"} 34 26 | pms_particle_counts{microns_lower_bound="100"} 0 27 | pms_particle_counts{microns_lower_bound="25"} 0 28 | pms_particle_counts{microns_lower_bound="3"} 954 29 | pms_particle_counts{microns_lower_bound="5"} 254 30 | pms_particle_counts{microns_lower_bound="50"} 0 31 | # HELP pms_particulate_matter_environmental micrograms per cubic meter, adjusted for atmospheric environment 32 | # TYPE pms_particulate_matter_environmental gauge 33 | pms_particulate_matter_environmental{microns="1"} 4 34 | pms_particulate_matter_environmental{microns="10"} 6 35 | pms_particulate_matter_environmental{microns="2.5"} 6 36 | # HELP pms_particulate_matter_standard Micrograms per cubic meter, standard particle 37 | # TYPE pms_particulate_matter_standard gauge 38 | pms_particulate_matter_standard{microns="1"} 4 39 | pms_particulate_matter_standard{microns="10"} 6 40 | pms_particulate_matter_standard{microns="2.5"} 6 41 | # HELP pms_received_packets 42 | # TYPE pms_received_packets counter 43 | pms_received_packets 27655 44 | # HELP pms_skipped_bytes 45 | # TYPE pms_skipped_bytes counter 46 | pms_skipped_bytes 0 47 | ``` 48 | 49 | Example docker-compose.yml: 50 | 51 | ```yml 52 | version: '3.4' 53 | services: 54 | breathe: 55 | image: markhnsn/breathe 56 | restart: always 57 | ports: 58 | - "9662:9662" 59 | command: [ 60 | "--port", ":9662", 61 | "--portname", "/dev/serial0" 62 | ] 63 | devices: 64 | - "/dev/serial0" 65 | ``` 66 | 67 | Example prometheus.yml: 68 | 69 | ```yml 70 | scrape_configs: 71 | - job_name: 'breathe' 72 | static_configs: 73 | - targets: ['breathe:9662'] 74 | labels: 75 | location: 'Lounge' 76 | ``` 77 | -------------------------------------------------------------------------------- /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.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= 4 | github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 5 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 6 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 7 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 8 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 9 | github.com/jacobsa/go-serial v0.0.0-20180131005756-15cf729a72d4 h1:G2ztCwXov8mRvP0ZfjE6nAlaCX2XbykaeHdbT6KwDz0= 10 | github.com/jacobsa/go-serial v0.0.0-20180131005756-15cf729a72d4/go.mod h1:2RvX5ZjVtsznNZPEt4xwJXNJrM3VTZoQf7V6gk0ysvs= 11 | github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= 12 | github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= 13 | github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= 14 | github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= 15 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 16 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 17 | github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU= 18 | github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= 19 | github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= 20 | github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= 21 | github.com/prometheus/client_golang v1.20.0 h1:jBzTZ7B099Rg24tny+qngoynol8LtVYlA2bqx3vEloI= 22 | github.com/prometheus/client_golang v1.20.0/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= 23 | github.com/prometheus/client_golang v1.20.1 h1:IMJXHOD6eARkQpxo8KkhgEVFlBNm+nkrFUyGlIu7Na8= 24 | github.com/prometheus/client_golang v1.20.1/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= 25 | github.com/prometheus/client_golang v1.20.2 h1:5ctymQzZlyOON1666svgwn3s6IKWgfbjsejTMiXIyjg= 26 | github.com/prometheus/client_golang v1.20.2/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= 27 | github.com/prometheus/client_golang v1.20.3 h1:oPksm4K8B+Vt35tUhw6GbSNSgVlVSBH0qELP/7u83l4= 28 | github.com/prometheus/client_golang v1.20.3/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= 29 | github.com/prometheus/client_golang v1.20.4 h1:Tgh3Yr67PaOv/uTqloMsCEdeuFTatm5zIq5+qNN23vI= 30 | github.com/prometheus/client_golang v1.20.4/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= 31 | github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= 32 | github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= 33 | github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk= 34 | github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= 35 | github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= 36 | github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= 37 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 38 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 39 | github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE= 40 | github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= 41 | github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= 42 | github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= 43 | github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= 44 | github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= 45 | github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= 46 | github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= 47 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 48 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 49 | golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= 50 | golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 51 | golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= 52 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 53 | golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= 54 | golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 55 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 56 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 57 | google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= 58 | google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 59 | google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= 60 | google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= 61 | google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= 62 | google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 63 | -------------------------------------------------------------------------------- /breathe.go: -------------------------------------------------------------------------------- 1 | // Binary breathe reads air quality data from a PMS5003 chip, exporting the data over prometheus HTTP. 2 | // 3 | // PMS5003 datasheet: http://www.aqmd.gov/docs/default-source/aq-spec/resources-page/plantower-pms5003-manual_v2-3.pdf 4 | // 5 | // TODO: 6 | // * Reset the chip when it borks? Reopen the serial port for every read? 7 | // * Pull only when prometheus does an HTTP request? 8 | package main 9 | 10 | import ( 11 | "bytes" 12 | "encoding/binary" 13 | "flag" 14 | "fmt" 15 | "io" 16 | "net/http" 17 | "text/template" 18 | 19 | "log" 20 | 21 | "github.com/prometheus/client_golang/prometheus" 22 | "github.com/prometheus/client_golang/prometheus/promauto" 23 | "github.com/prometheus/client_golang/prometheus/promhttp" 24 | 25 | "github.com/jacobsa/go-serial/serial" 26 | ) 27 | 28 | const ( 29 | magic1 = 0x42 // :) 30 | magic2 = 0x4d 31 | ) 32 | 33 | var ( 34 | portname = flag.String("portname", "", "filename of serial port") 35 | // Port reserved at https://github.com/prometheus/prometheus/wiki/Default-port-allocations 36 | port = flag.String("port", ":9662", "http port to listen on") 37 | 38 | pms_received_packets = promauto.NewCounter( 39 | prometheus.CounterOpts{ 40 | Name: "pms_received_packets", 41 | }, 42 | ) 43 | 44 | pms_packet_checksum_errors = promauto.NewCounter( 45 | prometheus.CounterOpts{ 46 | Name: "pms_packet_checksum_errors", 47 | }, 48 | ) 49 | 50 | pms_skipped_bytes = promauto.NewCounter( 51 | prometheus.CounterOpts{ 52 | Name: "pms_skipped_bytes", 53 | }, 54 | ) 55 | 56 | // https://cdn-shop.adafruit.com/product-files/3686/plantower-pms5003-manual_v2-3.pdf 57 | pms_particulate_matter_standard = promauto.NewGaugeVec( 58 | prometheus.GaugeOpts{ 59 | Name: "pms_particulate_matter_standard", 60 | Help: "Micrograms per cubic meter, standard particle", 61 | }, 62 | []string{"microns"}, 63 | ) 64 | 65 | // https://cdn-shop.adafruit.com/product-files/3686/plantower-pms5003-manual_v2-3.pdf 66 | pms_particulate_matter_environmental = promauto.NewGaugeVec( 67 | prometheus.GaugeOpts{ 68 | Name: "pms_particulate_matter_environmental", 69 | Help: "micrograms per cubic meter, adjusted for atmospheric environment", 70 | }, 71 | []string{"microns"}, 72 | ) 73 | 74 | // https://cdn-shop.adafruit.com/product-files/3686/plantower-pms5003-manual_v2-3.pdf 75 | pms_particle_counts = promauto.NewGaugeVec( 76 | prometheus.GaugeOpts{ 77 | Name: "pms_particle_counts", 78 | Help: "Number of particles with diameter beyond given number of microns in 0.1L of air", 79 | }, 80 | []string{"microns_lower_bound"}, 81 | ) 82 | 83 | index = template.Must(template.New("index").Parse( 84 | ` 85 |
89 |
portname={{.}}
90 | `))
91 | )
92 |
93 | func main() {
94 | flag.Parse()
95 | log.Printf("PMS Prometheus Exporter starting on port %v and file %v\n", *port, *portname)
96 | go readPortForever()
97 | http.Handle("/metrics", promhttp.Handler())
98 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
99 | w.Header().Set("Content-Type", "text/html; charset=utf-8")
100 | index.Execute(w, *portname)
101 | })
102 | http.ListenAndServe(*port, nil)
103 | }
104 |
105 | func readPortForever() {
106 | options := serial.OpenOptions{
107 | PortName: *portname,
108 | BaudRate: 9600,
109 | DataBits: 8,
110 | StopBits: 1,
111 | MinimumReadSize: 1,
112 | }
113 |
114 | port, err := serial.Open(options)
115 | if err != nil {
116 | log.Fatalf("serial.Open: %v", err)
117 | }
118 |
119 | defer port.Close()
120 |
121 | for {
122 | log.Println("Attempting to read.")
123 | pms, err := readPMS(port)
124 | if err != nil {
125 | log.Printf("readPMS: %v\n", err)
126 | continue
127 | }
128 | log.Printf("pms = %+v\n", pms)
129 | if !pms.valid() {
130 | log.Println("pms is not valid. Ignoring...")
131 | continue
132 | }
133 | pms_received_packets.Inc()
134 | pms_particulate_matter_standard.WithLabelValues("1").Set(float64(pms.Pm10Std))
135 | pms_particulate_matter_standard.WithLabelValues("2.5").Set(float64(pms.Pm25Std))
136 | pms_particulate_matter_standard.WithLabelValues("10").Set(float64(pms.Pm100Std))
137 | pms_particulate_matter_environmental.WithLabelValues("1").Set(float64(pms.Pm10Env))
138 | pms_particulate_matter_environmental.WithLabelValues("2.5").Set(float64(pms.Pm25Env))
139 | pms_particulate_matter_environmental.WithLabelValues("10").Set(float64(pms.Pm100Env))
140 | pms_particle_counts.WithLabelValues("3").Set(float64(pms.Particles3um))
141 | pms_particle_counts.WithLabelValues("5").Set(float64(pms.Particles5um))
142 | pms_particle_counts.WithLabelValues("10").Set(float64(pms.Particles10um))
143 | pms_particle_counts.WithLabelValues("25").Set(float64(pms.Particles25um))
144 | pms_particle_counts.WithLabelValues("50").Set(float64(pms.Particles50um))
145 | pms_particle_counts.WithLabelValues("100").Set(float64(pms.Particles100um))
146 | }
147 | }
148 |
149 | // PMS5003 wraps an air quality packet, as documented in https://cdn-shop.adafruit.com/product-files/3686/plantower-pms5003-manual_v2-3.pdf
150 | type PMS5003 struct {
151 | Length uint16
152 | Pm10Std uint16
153 | Pm25Std uint16
154 | Pm100Std uint16
155 | Pm10Env uint16
156 | Pm25Env uint16
157 | Pm100Env uint16
158 | Particles3um uint16
159 | Particles5um uint16
160 | Particles10um uint16
161 | Particles25um uint16
162 | Particles50um uint16
163 | Particles100um uint16
164 | Unused uint16
165 | Checksum uint16
166 | }
167 |
168 | func (p *PMS5003) valid() bool {
169 | if p.Length != 28 {
170 | return false
171 | }
172 | return true
173 | }
174 |
175 | func readPMS(r io.Reader) (*PMS5003, error) {
176 | if err := awaitMagic(r); err != nil {
177 | // Read errors are likely unrecoverable - just quit and restart.
178 | log.Fatalf("awaitMagic: %v", err)
179 | }
180 | buf := make([]byte, 30)
181 | n, err := io.ReadFull(r, buf)
182 | if err != nil {
183 | // Read errors are likely unrecoverable - just quit and restart.
184 | log.Fatalf("ReadFull: %v", err)
185 | }
186 | if n != 30 {
187 | return nil, fmt.Errorf("too few bytes read: want %d got %d", 30, n)
188 | }
189 |
190 | var sum uint16 = uint16(magic1) + uint16(magic2)
191 | for i := 0; i < 28; i++ {
192 | sum += uint16(buf[i])
193 | }
194 |
195 | var p PMS5003
196 | bufR := bytes.NewReader(buf)
197 | binary.Read(bufR, binary.BigEndian, &p)
198 |
199 | if sum != p.Checksum {
200 | // This error is recoverable
201 | pms_packet_checksum_errors.Inc()
202 | return nil, fmt.Errorf("checksum: got %v want %v", sum, p)
203 | }
204 | return &p, nil
205 | }
206 |
207 | func awaitMagic(r io.Reader) error {
208 | log.Println("Awaiting magic... ")
209 | var b1 byte
210 | b2, err := pop(r)
211 | if err != nil {
212 | return err
213 | }
214 | for {
215 | b1 = b2
216 | b2, err = pop(r)
217 | if err != nil {
218 | return err
219 | }
220 | if b1 == magic1 && b2 == magic2 {
221 | // found magic
222 | return nil
223 | }
224 | pms_skipped_bytes.Inc()
225 | }
226 | }
227 |
228 | func pop(r io.Reader) (byte, error) {
229 | b := make([]byte, 1)
230 | _, err := r.Read(b)
231 | if err != nil {
232 | return 0, err
233 | }
234 | return b[0], nil
235 | }
236 |
--------------------------------------------------------------------------------