// dominantField looks through the fields, all of which are known to
// have the same name, to find the single field that dominates the
// others using Go's embedding rules, modified by the presence of
// JSON tags. If there are multiple top-level fields, the boolean
// will be false: This condition is an error in Go and we skip all
// the fields.
func dominantField(fields []field) (field, bool) {
	// The fields are sorted in increasing index-length order. The winner
	// must therefore be one with the shortest index length. Drop all
	// longer entries, which is easy: just truncate the slice.
	length := len(fields[0].index)
	tagged := -1 // Index of first tagged field.
	for i, f := range fields {
		if len(f.index) > length {
			fields = fields[:i]
			break
		}
		if f.tag {
			if tagged >= 0 {
				// Multiple tagged fields at the same level: conflict.
				// Return no field.
				return field{}, false
			}
			tagged = i
		}
	}
	if tagged >= 0 {
		return fields[tagged], true
	}
	// All remaining fields have the same length. If there's more than one,
	// we have a conflict (two fields named "X" at the same level) and we
	// return no field.
	if len(fields) > 1 {
		return field{}, false
	}
	return fields[0], true
}

var fieldCache struct {
	sync.RWMutex
	m map[reflect.Type][]field
}

// cachedTypeFields is like typeFields but uses a cache to avoid repeated work.
func cachedTypeFields(t reflect.Type) []field {
	fieldCache.RLock()
	f := fieldCache.m[t]
	fieldCache.RUnlock()
	if f != nil {
		return f
	}

	// Compute fields without lock.
	// Might duplicate effort but won't hold other computations back.
	f = typeFields(t)
	if f == nil {
		f = []field{}
	}

	fieldCache.Lock()
	if fieldCache.m == nil {
		fieldCache.m = map[reflect.Type][]field{}
	}
	fieldCache.m[t] = f
	fieldCache.Unlock()
	return f
}

func isValidTag(s string) bool {
	if s == "" {
		return false
	}
	for _, c := range s {
		switch {
		case strings.ContainsRune("!#$%&()*+-./:<=>?@[]^_{|}~ ", c):
			// Backslash and quote chars are reserved, but
			// otherwise any punctuation chars are allowed
			// in a tag name.
		default:
			if !unicode.IsLetter(c) && !unicode.IsDigit(c) {
				return false
			}
		}
	}
	return true
}

const (
	caseMask     = ^byte(0x20) // Mask to ignore case in ASCII.
	kelvin       = ''
	smallLongEss = 'ſ'
)

// foldFunc returns one of four different case folding equivalence
// functions, from most general (and slow) to fastest:
//
// 1) bytes.EqualFold, if the key s contains any non-ASCII UTF-8
// 2) equalFoldRight, if s contains special folding ASCII ('k', 'K', 's', 'S')
// 3) asciiEqualFold, no special, but includes non-letters (including _)
// 4) simpleLetterEqualFold, no specials, no non-letters.
//
// The letters S and K are special because they map to 3 runes, not just 2:
//   - S maps to s and to U+017F 'ſ' Latin small letter long s
//   - k maps to K and to U+212A 'K' Kelvin sign
//
// See http://play.golang.org/p/tTxjOc0OGo
//
// The returned function is specialized for matching against s and
// should only be given s. It's not curried for performance reasons.
func foldFunc(s []byte) func(s, t []byte) bool {
	nonLetter := false
	special := false // special letter
	for _, b := range s {
		if b >= utf8.RuneSelf {
			return bytes.EqualFold
		}
		upper := b & caseMask
		if upper < 'A' || upper > 'Z' {
			nonLetter = true
		} else if upper == 'K' || upper == 'S' {
			// See above for why these letters are special.
			special = true
		}
	}
	if special {
		return equalFoldRight
	}
	if nonLetter {
		return asciiEqualFold
	}
	return simpleLetterEqualFold
}

// equalFoldRight is a specialization of bytes.EqualFold when s is
// known to be all ASCII (including punctuation), but contains an 's',
// 'S', 'k', or 'K', requiring a Unicode fold on the bytes in t.
// See comments on foldFunc.
func equalFoldRight(s, t []byte) bool {
	for _, sb := range s {
		if len(t) == 0 {
			return false
		}
		tb := t[0]
		if tb < utf8.RuneSelf {
			if sb != tb {
				sbUpper := sb & caseMask
				if 'A' <= sbUpper && sbUpper <= 'Z' {
					if sbUpper != tb&caseMask {
						return false
					}
				} else {
					return false
				}
			}
			t = t[1:]
			continue
		}
		// sb is ASCII and t is not. t must be either kelvin
		// sign or long s; sb must be s, S, k, or K.
		tr, size := utf8.DecodeRune(t)
		switch sb {
		case 's', 'S':
			if tr != smallLongEss {
				return false
			}
		case 'k', 'K':
			if tr != kelvin {
				return false
			}
		default:
			return false
		}
		t = t[size:]

	}

	return len(t) <= 0
}

// asciiEqualFold is a specialization of bytes.EqualFold for use when
// s is all ASCII (but may contain non-letters) and contains no
// special-folding letters.
// See comments on foldFunc.
func asciiEqualFold(s, t []byte) bool {
	if len(s) != len(t) {
		return false
	}
	for i, sb := range s {
		tb := t[i]
		if sb == tb {
			continue
		}
		if ('a' <= sb && sb <= 'z') || ('A' <= sb && sb <= 'Z') {
			if sb&caseMask != tb&caseMask {
				return false
			}
		} else {
			return false
		}
	}
	return true
}

// simpleLetterEqualFold is a specialization of bytes.EqualFold for
// use when s is all ASCII letters (no underscores, etc) and also
// doesn't contain 'k', 'K', 's', or 'S'.
// See comments on foldFunc.
func simpleLetterEqualFold(s, t []byte) bool {
	if len(s) != len(t) {
		return false
	}
	for i, b := range s {
		if b&caseMask != t[i]&caseMask {
			return false
		}
	}
	return true
}

// tagOptions is the string following a comma in a struct field's "json"
// tag, or the empty string. It does not include the leading comma.
type tagOptions string

// parseTag splits a struct field's json tag into its name and
// comma-separated options.
func parseTag(tag string) (string, tagOptions) {
	if idx := strings.Index(tag, ","); idx != -1 {
		return tag[:idx], tagOptions(tag[idx+1:])
	}
	return tag, tagOptions("")
}

// Contains reports whether a comma-separated list of options
// contains a particular substr flag. substr must be surrounded by a
// string boundary or commas.
func (o tagOptions) Contains(optionName string) bool {
	if len(o) == 0 {
		return false
	}
	s := string(o)
	for s != "" {
		var next string
		i := strings.Index(s, ",")
		if i >= 0 {
			s, next = s[:i], s[i+1:]
		}
		if s == optionName {
			return true
		}
		s = next
	}
	return false
}



package kyaml

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"regexp"
	"strconv"
	"strings"
	"time"
	"unicode"
	"unicode/utf8"

	yaml "go.yaml.in/yaml/v3"
)

// Encoder formats objects or YAML data (JSON is valid YAML) into KYAML. KYAML
// is halfway between YAML and JSON, but is a strict subset of YAML, so it
// should should be readable by any YAML parser. It is designed to be explicit
// and unambiguous, and eschews significant whitespace.
type Encoder struct {
	// Compact tells the encoder to use compact formatting. This puts all the
	// data on one line, with no extra newlines, no comments, and no multi-line
	// formatting.
	Compact bool
}

// FromYAML renders a KYAML (multi-)document from YAML bytes (JSON is YAML),
// including the KYAML header. The result always has a trailing newline.
func (ky *Encoder) FromYAML(in io.Reader, out io.Writer) error {
	// We need a YAML decoder to handle multi-document streams.
	dec := yaml.NewDecoder(in)

	// Process each document in the stream.
	for {
		var doc yaml.Node
		err := dec.Decode(&doc)
		if err == io.EOF {
			break
		}
		if err != nil {
			return fmt.Errorf("error decoding: %v", err)
		}
		if doc.Kind != yaml.DocumentNode {
			return fmt.Errorf("kyaml internal error: line %d: expected a document node, got %s", doc.Line, ky.nodeKindString(doc.Kind))
		}

		// Always emit a document separator, which helps disambiguate between YAML
		// and JSON.
		if _, err := fmt.Fprintln(out, "---"); err != nil {
			return err
		}

		if err := ky.renderDocument(&doc, 0, ky.flags(), out); err != nil {
			return err
		}
		fmt.Fprintf(out, "
")
	}

	return nil
}

// FromObject renders a KYAML document from a Go object, including the KYAML
// header. The result always has a trailing newline.
func (ky *Encoder) FromObject(obj any, out io.Writer) error {
	jb, err := json.Marshal(obj)
	if err != nil {
		return fmt.Errorf("error marshaling to JSON: %v", err)
	}
	// JSON is YAML.
	return ky.FromYAML(bytes.NewReader(jb), out)
}

// Marshal renders a single Go object as KYAML, without the header or trailing
// newline.
func (ky *Encoder) Marshal(obj any) ([]byte, error) {
	// Convert the object to JSON bytes to take advantage of all the JSON tag
	// handling and things like that.
	jb, err := json.Marshal(obj)
	if err != nil {
		return nil, fmt.Errorf("error marshaling to JSON: %v", err)
	}

	buf := &bytes.Buffer{}
	// JSON is YAML.
	if err := ky.fromObjectYAML(bytes.NewReader(jb), buf); err != nil {
		return nil, fmt.Errorf("error rendering object: %v", err)
	}
	return buf.Bytes(), nil
}

func (ky *Encoder) fromObjectYAML(in io.Reader, out io.Writer) error {
	yb, err := io.ReadAll(in)
	if err != nil {
		return err
	}

	var doc yaml.Node
	if err := yaml.Unmarshal(yb, &doc); err != nil {
		return fmt.Errorf("error decoding: %v", err)
	}
	if doc.Kind != yaml.DocumentNode {
		return fmt.Errorf("kyaml internal error: line %d: expected document node, got %s", doc.Line, ky.nodeKindString(doc.Kind))
	}

	if err := ky.renderNode(&doc, 0, ky.flags(), out); err != nil {
		return fmt.Errorf("error rendering document: %v", err)
	}

	return nil
}

// From the YAML spec.
const (
	intTag       = "!!int"
	floatTag     = "!!float"
	boolTag      = "!!bool"
	strTag       = "!!str"
	timestampTag = "!!timestamp"
	seqTag       = "!!seq"
	mapTag       = "!!map"
	nullTag      = "!!null"
	binaryTag    = "!!binary"
	mergeTag     = "!!merge"
)

type flagMask uint64

const (
	flagsNone     flagMask = 0
	flagLazyQuote flagMask = 0x01
	flagCompact   flagMask = 0x02
)

// flags returns a flagMask representing the current encoding options. It can
// be used directly or OR'ed with another mask.
func (ky *Encoder) flags() flagMask {
	flags := flagsNone
	if ky.Compact {
		flags |= flagCompact
	}
	return flags
}

// renderNode processes a YAML node, calling the appropriate render function
// for its type.  Each render function should assume that the output "cursor"
// is positioned at the start of the node and should not emit a final newline.
// If a render function needs to linewrap or indent (e.g. a struct), it should
// assume the indent level is currently correct for the node type itself, and
// may need to indent more.
func (ky *Encoder) renderNode(node *yaml.Node, indent int, flags flagMask, out io.Writer) error {
	if node == nil {
		return nil
	}

	switch node.Kind {
	case yaml.DocumentNode:
		return ky.renderDocument(node, indent, flags, out)
	case yaml.ScalarNode:
		return ky.renderScalar(node, indent, flags, out)
	case yaml.SequenceNode:
		return ky.renderSequence(node, indent, flags, out)
	case yaml.MappingNode:
		return ky.renderMapping(node, indent, flags, out)
	case yaml.AliasNode:
		return ky.renderAlias(node, indent, flags, out)
	}
	return fmt.Errorf("kyaml internal error: line %d: unknown node kind %v", node.Line, node.Kind)
}

// renderDocument processes a YAML document node, rendering it to the output.
// This function assumes that the output "cursor" is positioned at the start of
// the document. This does not emit a final newline.
func (ky *Encoder) renderDocument(doc *yaml.Node, indent int, flags flagMask, out io.Writer) error {
	if len(doc.Content) == 0 {
		return fmt.Errorf("kyaml internal error: line %d: document has no content node (%d)", doc.Line, len(doc.Content))
	}
	if len(doc.Content) > 1 {
		return fmt.Errorf("kyaml internal error: line %d: document has more than one content node (%d)", doc.Line, len(doc.Content))
	}
	if indent != 0 {
		return fmt.Errorf("kyaml internal error: line %d: document non-zero indent (%d)", doc.Line, indent)
	}

	compact := flags&flagCompact != 0

	// For document nodes, the cursor is assumed to be ready to render.
	child := doc.Content[0]
	if !compact {
		if len(doc.HeadComment) > 0 {
			ky.renderComments(doc.HeadComment, indent, out)
			fmt.Fprint(out, "
")
		}
		if len(child.HeadComment) > 0 {
			ky.renderComments(child.HeadComment, indent, out)
			fmt.Fprint(out, "
")
		}
	}
	if err := ky.renderNode(child, indent, flags, out); err != nil {
		return err
	}
	if !compact {
		if len(child.LineComment) > 0 {
			ky.renderComments(" "+child.LineComment, 0, out)
		}
		if len(child.FootComment) > 0 {
			fmt.Fprint(out, "
")
			ky.renderComments(child.FootComment, indent, out)
		}
		if len(doc.LineComment) > 0 {
			fmt.Fprint(out, "
")
			ky.renderComments(" "+doc.LineComment, 0, out)
		}
		if len(doc.FootComment) > 0 {
			fmt.Fprint(out, "
")
			ky.renderComments(doc.FootComment, indent, out)
		}
	}
	return nil
}

// renderScalar processes a YAML scalar node, rendering it to the output.  This
// DOES NOT render a trailing newline or head/line/foot comments, as those
// require the parent context.
func (ky *Encoder) renderScalar(node *yaml.Node, indent int, flags flagMask, out io.Writer) error {
	switch node.Tag {
	case intTag, floatTag, boolTag, nullTag:
		fmt.Fprint(out, node.Value)
	case strTag, timestampTag:
		return ky.renderString(node.Value, indent+1, flags, out)
	default:
		return fmt.Errorf("kyaml internal error: line %d: unknown tag %q on scalar node %q", node.Line, node.Tag, node.Value)
	}
	return nil
}

const kyamlFoldStr = "\
"

var regularEscapeMap = map[rune]string{
	'
': "\n" + kyamlFoldStr, // use YAML's line folding to make the output more readable
	'	': "	",                 // literal tab
}
var compactEscapeMap = map[rune]string{
	'
': "\n",
	'	': "\t",
}
Back to Blog

Building PicoWeather Watch: From Embedded C to Cloud Monitoring with TinyGo

Michael Duren
Building PicoWeather Watch: From Embedded C to Cloud Monitoring with TinyGo
#Go #Embedded #IoT #C #Prometheus #Grafana #Docker #TinyGo #Raspberry Pi Pico W

Ever wondered how to bridge the gap between embedded systems and modern cloud monitoring? In this post, I’ll walk you through building PicoWeather Watch—a super simple IoT weather monitoring system that reads temperature and humidity from a Raspberry Pi Pico and DHT sensor and visualizes the data in Grafana.

This project showcases the full stack: embedded C firmware, converting that firmware using Tiny Go, Go services, binary protocol design, and Prometheus metrics. I did not use docker for this because I would have to expose the USB serial device to the container, which can be tricky and is not portable across different host operating systems. Instead, I run the Go reader service directly on the host machine, which allows it to access the USB serial device without any special configuration.

Course repo.

Tiny Go

NOTE: To get tiny go editor support you need to install an appropriate plugin. For neovim I use pcolladosoto/tinygo.nvim.

Lets start with taking a look at the demo. What you’ll notice right away is that the code uses a pkg called machine which provides a hardware abstraction layer for different microcontrollers. This allows you to write Go code that can run on various platforms without modification.

The micro controller target is specified at compile time using the -target flag. For example, to compile for the Raspberry Pi Pico W, you would use:

tinygo build -o firmware.bin -target=pico send_usb.go

To get an idea of the boards supported we can look at their constants here.

I highly recommend going through the Compiler Internals, they’re pretty sumarized but here’s a general overview:

  • Tiny Go uses LLVM as its backend, which allows it to generate efficient machine code for various architectures.

  • It abstracts away interrupts you shouldn’t need to worry about, most of this is implemented on a per board basis.

  • Rather than using volatile variables for hardware registers tiny go gives us Get and Set methods on the Pin type, which handle the necessary memory barriers and ensure safe access to hardware registers.

    Example:

    volatile uint32_t *gpio_out = (volatile uint32_t *)0x4001400;
    *gpio_out = 1;

    becomes

    pin := machine.GPIO.GetPin(25) // Get pin 25 (onboard LED)
    pin.Set(true) // Set pin high
  • If you’re feeling like a nut you can even write assembly:

result := arm.AsmFull(`
    add {}, {value}, #3
`, map[string]interface{}{
    "value": 42,
})
println("result:", int(result))

I’d never do this, but hey it’s there if you need it!

  • Tiny go tries not to use heap allocs, and you should too! Be wary of esacpe analaysis, taking pointers, you can be aware that this is not the time for unecessary abstractions, or any really. Tiny go already gave you some be grateful!

Image of a tiny go running on a microcontroller

  • Go routines are supported, similar to the normal go runtime a stack is allocated per routine and switching happens by saving and restoring the CPU registers. If you are going into the world of concurrent embedded programming though you should be careful about memory usage and avoid creating too many goroutines, and if you need highly performant concurrency maybe use the hardware interrupts instead.

  • One last thing about heap allocations, if you’re using a timing based protocol like the DHT11 you should be careful about any GC pauses that could cause you to miss critical timing windows. In general, it’s best to avoid heap allocations in time-sensitive code. You can use slices as long you’re aware of when they may cause a heap allocation.

The Project

The system architecture is simple:

  1. Raspberry Pi Pico W with DHT sensor reads temperature/humidity
  2. Firmware (C or Go) sends data via USB serial using a custom binary protocol
  3. Go Reader Service parses the binary frames and exposes Prometheus metrics
  4. Prometheus scrapes the metrics every 5 seconds
  5. Grafana visualizes the data in real-time dashboards

Why Two Firmware Implementations?

I implemented the firmware in both C and Go (using TinyGo) to explore different embedded development approaches. Both use the exact same binary protocol, making them interchangeable

The Binary Protocol: Keeping It Simple

When you’re sending data from a microcontroller over USB SERIAL, every byte counts. I mean, this is a super simple example so it’s not that big of a deal right now, but how does it scale? Anyway, I designed a minimal 4-byte frame format:

{0xAA, temperature, humidity, 0x55}
  • Byte 0: Start marker (0xAA)
  • Byte 1: Temperature in degrees Celsius (0-255)
  • Byte 2: Humidity in percent RH (0-100)
  • Byte 3: End marker (0x55)

Bytes 0 and 3

By the way 0xAA and 0x55 are communicationn protocol classics. They are commonly used as start and end markers because they have distinct bit patterns (10101010 and 01010101) that help with synchronization and error detection.

Why this design?

  1. Fixed-length frames are easier to parse than variable-length
  2. Start/end markers provide frame synchronization even if bytes are lost
  3. Single-byte values keep it simple (DHT11 only provides integer readings anyway)
  4. Total of 4 bytes means minimal bandwidth usage
USB Serial Binary Protocol Pico W DHT11 Sensor 23°C / 45% Go Reader Frame Parser Binary Frame: Byte 0 0xAA START Byte 1 0x17 23°C Byte 2 0x2D 45% Byte 3 0x55 END Parsing Steps: 1. Read bytes from USB serial port 2. Wait for START marker (0xAA) 3. Read temperature byte (degrees Celsius) 4. Read humidity byte (percent RH) 5. Verify END marker (0x55) 6. Update weather data struct Prometheus Metrics weather_temperature_celsius 23.00 weather_humidity_percent 45.00

Reading Binary Data: The Frame Parser

The most interesting part of the Go reader service is the frame parser. Here’s how it handles the binary stream:

frame-parser.go
func (wr *WeatherReader) Start(ctx context.Context) error {
  // Buffer to read raw bytes from the serial port, could be smaller since we only expect 4 bytes at a time
  // but using a larger buffer allows us to read multiple frames at once if they arrive together or handle cases where the USB driver delivers data in chunks of any size
	buffer := make([]byte, 256)
  // Temporary buffer to build frames as we read bytes one at a time
	frameBuffer := make([]byte, 0, FrameSize)

    wr.logger.Info("starting USB reader")

    for {
    	select {
      // Just looping until shutdown signal is received, then we log and exit gracefully
    	case <-ctx.Done():
    		wr.logger.Info("stopping USB reader")
    		return ctx.Err()
    	default:
        // read to the buffer, which may contain 1 or more frames or even partial frames depending on how the USB driver delivers data
    		n, err := wr.Read(buffer)
    		if err != nil {
    			if errors.Is(err, io.EOF) {
    				continue
    			}
    			wr.logger.Error("error reading from serial port", "error", err)
    			time.Sleep(time.Second)
    			continue
    		}

        // loop through the bytes we just read, which could be anywhere from 1 to 256 bytes, and use the frameBuffer to build complete frames
    		for i := range n {
    			b := buffer[i]

    			// Start of frame
    			if b == FrameStart && len(frameBuffer) == 0 {
    				frameBuffer = append(frameBuffer, b)
    				continue
    			}

    			// Building frame
    			if len(frameBuffer) > 0 && len(frameBuffer) < FrameSize {
    				frameBuffer = append(frameBuffer, b)

    				// Complete frame received
    				if len(frameBuffer) == FrameSize {
    					if frameBuffer[3] == FrameEnd {
                // update data being read by prometheus
    						wr.processFrame(frameBuffer)
    					} else {
    						wr.logger.Warn("invalid frame end marker", "frame", frameBuffer)
    					}
              // reset frame buffer for next frame
    					frameBuffer = frameBuffer[:0]
    				}
    			}
    		}
    	}
    }

}

How the Parser Works

The parser uses a state machine approach:

  1. Wait for START marker (0xAA): Ignore all bytes until we see the start marker
  2. Collect 4 bytes: Once we have a start marker, collect the next 3 bytes
  3. Verify END marker (0x55): Check if byte 4 is the end marker
  4. Process or discard: If valid, process the frame; otherwise, reset and wait for the next start marker

This approach handles several edge cases:

  • Partial frames: If we only receive 2 bytes before the stream stops, we’ll timeout and reset
  • Noise: Random bytes before the start marker are safely ignored
  • Desync: If we get corrupted data, the end marker check catches it

Binary Parsing Gotcha

Always use a buffer and never assume bytes arrive together! USB serial can deliver data in chunks of any size. The parser must handle receiving bytes one at a time or all four at once.

The C Firmware: Talking to DHT11

The C implementation uses the Pico SDK to read from the DHT11 sensor and send data over USB:

pico_weather_watch.c
int main() {
    init();

    while (1) {
        dht11_reading reading;
        int rc = read_from_dht11(&reading);

        if (rc == SUCCESS) {
            uint8_t packet[4] = {
                0xAA,
                (uint8_t)reading.temperature,
                (uint8_t)reading.humidity,
                0x55
            };
            stdio_put_string((char *)packet, 4, 0, 0);
        }

        sleep_ms(2000);
    }
}

The Go Firmware: TinyGo Magic

The Go version uses TinyGo—a Go compiler for embedded systems. Here’s the equivalent code:

send_usb.go
func send(temperature, humidity int32) error {
    tempByte := byte(temperature / 1000) // millidegrees -> degrees
    humByte := byte(humidity / 1000)     // millipercent -> percent

    packet := [4]byte{0xAA, tempByte, humByte, 0x55}
    _, err := machine.Serial.Write(packet[:])
    return err

}

TinyGo vs C

The build size actually ends up being pretty similar. Although these are both small programs and I may not be the best at cmake files:

  • C: 65K Mar 18 08:11 pico_weather_watch.uf2

  • GO: 66K Mar 18 16:53 output.uf2

Grafana Dashboards: Visualizing the Data

Once Prometheus is scraping the metrics, creating Grafana dashboards is simple. Here are some example queries:

Current Temperature:

weather_temperature_celsius

Temperature Over Last Hour:

weather_temperature_celsius[1h]

Humidity Change Rate:

rate(weather_humidity_percent[5m])

Pro Tip

Add alerts in Grafana to notify you when temperature goes above/below thresholds. Perfect for monitoring server rooms, greenhouses, or your home office!

Lessons Learned

  1. I need to understand the electronics more: After this I have a few books planned on understanding the components like conductors, resistors, capacitors, and how to read circuit diagrams. I also want to learn how to use a multimeter and oscilloscope properly and make a music synthesizer some day.

  2. TinyGo is production-ready: For non-critical embedded projects, TinyGo offers a great developer experience with minimal overhead.

  3. Structured logging kinda sucks: FOR this, I’d have to do different builds for debugging and go service reading data, since logs break the binary protocol.

  4. Observability matters: Even for hobby projects, proper metrics and monitoring make development much smoother, I really should get a debugger for my pico 😝.

What’s Next?

Some ideas for extending this project:

  • Bluetooth support: Use the Pico W’s Bluetooth to send data wirelessly
  • Multiple sensors: Add more DHT11 sensors and implement a sensor ID in the protocol
  • Historical data: Store readings in a time-series database like InfluxDB
  • Remote access: Expose the Grafana dashboard securely over the internet
  • Power optimization: Use deep sleep modes to run on battery power

Conclusion

Building PicoWeather Watch was a great exercise in full-stack IoT development. From bit-level binary protocols to cloud-native monitoring, the project touches on numerous interesting topics.

The source code is available on GitHub, and I encourage you to build your own version. Whether you choose C or Go for the firmware, you’ll learn a ton about embedded systems, binary protocols, and modern observability practices.

Happy hacking!


Have questions or built something similar? Reach out on GitHub!

github.com/michael-duren