Build and maintain a production-quality Go CLI application that fetches monthly trash pickup schedules from BDG's (Barnim Dienstleistungsgesellschaft mbH) public API, caches them locally, and sends reminders to Ulanzi AWTRIX 3 displays via MQTT. The application is designed to run via cron (not as a long-running service) and must be deterministic, reliable, and boring.
Service Provider: BDG serves the Barnim district in Brandenburg, Germany. See Kreiswerke Barnim for more information.
- ❌ Web UI or web server
- ❌ Database or persistent storage beyond file-based cache
- ❌ Real-time monitoring or long-running background services
- ❌ Authentication or user management
- ❌ Multi-tenancy or SaaS features
- ❌ Complex retry logic or fault tolerance beyond fail-fast
- ❌ Metrics, telemetry, or observability beyond basic logging
bdg/
├── cmd/ # CLI layer - Cobra commands, flag parsing
│ ├── root.go # Root command, shared flags, validation
│ ├── status.go # Status command - persistent display
│ └── alarm.go # Alarm command - tomorrow-only notification
│
├── internal/api/ # HTTP client for BDG trash API
│ ├── types.go # API response structs, domain models
│ └── client.go # HTTP client, color normalization, parsing
│
├── internal/cache/ # File-based cache layer
│ └── cache.go # Month-based JSON cache (YYYY-MM.json)
│
├── internal/domain/ # Pure business logic (no I/O)
│ ├── pickup.go # Filtering, finding nearest pickups
│ └── display.go # Date formatting, tomorrow detection
│
├── internal/mqtt/ # MQTT publisher
│ └── publisher.go # Connect, publish, disconnect
│
└── internal/awtrix/ # AWTRIX payload construction
├── types.go # AWTRIX JSON structs
└── builder.go # Status and alarm payload builders
- CLI Command (
statusoralarm) receives flags - Fetch: Check cache, fallback to API if missing
- Cache: Write API response to
~/.cache/bdg/YYYY-MM.json - Parse: Convert API response to domain
Pickupobjects - Filter: Remove past dates and ignored trash types
- Find Nearest: Identify all pickups on the nearest day
- Build Payload: Construct AWTRIX JSON with draw commands
- Publish: Send to MQTT broker, disconnect
- Deterministic: Same inputs always produce same outputs
- Fail Fast: No retries, clear error messages
- Stateless: Each invocation is independent
- Testable: Domain logic is pure, I/O is isolated
- Minimal: Standard library + Cobra + Paho MQTT only
task build
# OR
go build -o bin/bdg .task test
# OR
go test -v -race -coverprofile=coverage.txt -covermode=atomic ./...View coverage:
go tool cover -html=coverage.txttask lint
# OR
golangci-lint run ./...- All flags validated before execution: Use
validateFlags()in root.go - No interactive prompts: CLI must run unattended in cron
- Exit codes: 0 for success, 1 for errors
- Silence usage on error: Set
SilenceUsage: trueon commands - stderr for warnings, stdout for results
- Never refetch: If
YYYY-MM.jsonexists in cache, use it - Atomic writes: Use temp file + rename pattern
- Next month fetch: Only if
now + 7 dayscrosses month boundary - Cache structure:
~/.cache/bdg/YYYY-MM.json - Cache miss is not an error: Return
nil, nilon cache miss
- Pure functions: All domain logic accepts
time.Timeas parameter - Normalize to day: Use
time.Date(year, month, day, 0, 0, 0, 0, loc)for comparisons - UTC in tests: Always use
time.UTCfor deterministic tests - No global
time.Now(): Pass time as parameter for testability
- Fail fast: No retries, no fallbacks
- Wrap errors: Use
fmt.Errorf("context: %w", err)for stack context - Descriptive messages: Include operation and values in error messages
- Log warnings for non-fatal issues: e.g., cache write failures
- Pure functions: No I/O, no side effects
- Table-driven tests: Use subtests with test cases
- 100% coverage target: Domain logic must be fully tested
- No dependencies: Domain package imports only stdlib + api types
- Status display:
[PREFIX]/custom/trash - Alarm notification:
[PREFIX]/notify - QoS 0: Fire-and-forget
- Clean session: Always use clean session
- No retained messages
Color chips (status display):
- Size: 4×7 pixels (width × height)
- Command:
df(filled rectangle) - Spacing: 5 pixels between chips (x = 0, 5, 10, 15, ...)
- Y position: 0 (top of display)
- Colors: Use normalized hex from API (e.g.,
#566322)
Text display:
- Command:
dt(draw text) - X position: 15 (fixed)
- Y position: 1
- Max length: 4 characters, uppercase
- Color: Red (
#FF0000) for tomorrow, white (#FFFFFF) otherwise
Example draw payload:
{
"draw": [
{"df": [0, 0, 4, 7, "#FFFF00"]},
{"df": [5, 0, 4, 7, "#999999"]},
{"dt": [15, 1, "TOM", "#FF0000"]}
],
"noScroll": true,
"duration": 5
}- Trigger: Only if nearest pickup is tomorrow
- Background: Red (
#FF0000) - Text: Blinking (500ms interval)
- Hold:
true(requires button press to dismiss) - Text format:
"TRASH: [Title1], [Title2], ..."
Example alarm payload:
{
"text": "TRASH: Bio 14-tgl., Gelbe Tonne",
"background": "#FF0000",
"blinkText": 500,
"hold": true,
"duration": 0,
"noScroll": false
}- Pad to 6 characters with leading zeros (e.g.,
"99999"→"099999") - Add
#prefix - Uppercase hex letters
- Truncate if > 6 characters
Before considering a task complete:
- All tests pass (
task test) - Linter passes with zero issues (
task lint) - Test coverage > 95% (check
coverage.html) - Code compiles without warnings
- Manual smoke test (if applicable):
- Status command updates AWTRIX display
- Alarm command sends notification only for tomorrow
- Cache files created correctly
- Documentation updated (README, this file)
- No debugging code or console logs (except intentional output)
- Error messages are clear and actionable
- Location:
internal/domain/*_test.go - Coverage: 100% target
- Style: Table-driven tests with descriptive names
- Focus: Edge cases, boundary conditions, timezone handling
- API Client: Use
httptestfor fake server - Cache: Use
t.TempDir()for isolated tests - MQTT: Mock publisher or use test broker (optional)
Use realistic test data:
- Dates crossing month boundaries
- Multiple pickups on same day
- Empty results
- Malformed API responses (for error handling)
- No code changes needed - use
--ignoreflag - Update README examples if commonly requested
DO NOT change without discussion:
- Chip geometry (4×7)
- Spacing (5px)
- Text position (x=15)
- Color scheme (red for tomorrow, white otherwise)
These are locked by AWTRIX display constraints.
- Create
cmd/newcommand.go - Register in
init()withrootCmd.AddCommand() - Use shared flags from
root.go - Follow
RunEpattern for error handling - Add tests
- Update README
go get -u github.com/spf13/cobra
go get -u github.com/eclipse/paho.mqtt.golang
go mod tidyTest thoroughly after updates.
Add to command:
fmt.Fprintf(os.Stderr, "DEBUG: %v\n", value)Use a local MQTT broker:
docker run -it -p 1883:1883 eclipse-mosquittoSubscribe to messages:
mosquitto_sub -h localhost -t "awtrix_abc123/#" -vcat ~/.cache/bdg/2025-12.json | jq .curl "https://bdg.jumomind.com/mmapp/api.php?r=calendar/2025-12&city_id=XXX&area_id=YYY" | jq .Around the 1st of each month:
- Delete cache:
rm -rf ~/.cache/bdg/ - Run status:
./bdg status <flags> - Verify cache created
- Check AWTRIX display
If API response format changes:
- Update
internal/api/types.go - Update
internal/api/client.goparsing - Add tests for new format
- Consider backward compatibility
Current implementation is optimized for:
- Small data sets (< 100 pickups per month)
- Infrequent execution (hourly/daily via cron)
- Low memory footprint (< 10MB)
If these constraints change, revisit architecture.
Remember: This is a boring, reliable tool. Resist the urge to add features. Keep it simple, deterministic, and well-tested.