├── .gitignore ├── LICENSE ├── README.md ├── cmd └── haystack │ ├── go.mod │ ├── go.sum │ ├── keys.go │ ├── main.go │ ├── save.go │ ├── scan_darwin.go │ └── scan_others.go ├── firmware ├── README.md ├── go.mod ├── go.sum ├── main.go ├── mcu.go └── os.go ├── flash.sh ├── go.mod ├── go.sum ├── haystack.go ├── images ├── go-haystack.png ├── macless-haystack.png ├── tinygo-beacons.jpg └── tinyscan.gif ├── lib └── findmy │ ├── data.go │ └── data_test.go └── tinyscan ├── README.md ├── badger2040w.go ├── clue.go ├── go.mod ├── go.sum ├── main.go ├── pybadge.go └── pyportal.go /.gitignore: -------------------------------------------------------------------------------- 1 | *.keys 2 | *.json 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2025 The Hybrid Group and friends 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-haystack 2 | 3 | ![Go Haystack gopher](./images/go-haystack.png) 4 | 5 | Go Haystack lets you track personal Bluetooth devices via Apple's massive ["Find My"](https://developer.apple.com/find-my/) network. 6 | 7 | It uses [OpenHaystack](https://github.com/seemoo-lab/openhaystack) together with [Macless-Haystack](https://github.com/dchristl/macless-haystack) to help you setup a custom FindMy network with tools written in Go/TinyGo. No Apple hardware required! 8 | 9 | ![image of macless-haystack web UI](./images/macless-haystack.png) 10 | 11 | ## Build Your Own Beacon 12 | 13 | This package provides firmware written using [TinyGo](https://tinygo.org/) and the [TinyGo Bluetooth package](https://github.com/tinygo-org/bluetooth). 14 | 15 | ![tinygo beacons](./images/tinygo-beacons.jpg) 16 | 17 | As a result, any of the following hardware devices should work: 18 | 19 | - [Adafruit Bluefruit boards using nRF SoftDevice](https://github.com/tinygo-org/bluetooth?tab=readme-ov-file#adafruit-bluefruit-boards) 20 | - [BBC Microbit using nRF SoftDevice](https://github.com/tinygo-org/bluetooth?tab=readme-ov-file#bbc-microbit) 21 | - [Seeed Studio XIAO nRF52840](https://wiki.seeedstudio.com/XIAO_BLE) 22 | - [Other Nordic Semi SoftDevice boards](https://github.com/tinygo-org/bluetooth?tab=readme-ov-file#flashing-the-softdevice-on-other-boards) 23 | - [Boards using the NINA-FW with an ESP32 co-processor](https://github.com/tinygo-org/bluetooth?tab=readme-ov-file#esp32-nina) 24 | - [Boards such as the RP2040 Pico-W using the CYW43439 co-processor](https://github.com/tinygo-org/bluetooth?tab=readme-ov-file#cyw43439-rp2040-w) 25 | 26 | The beacon code is located in this repository in the [firmware](./firmware/) directory. 27 | 28 | ## Linux Beacons 29 | 30 | You can also run the beacon code on any Linux that has Bluetooth hardware, such as a Raspberry Pi or other embedded system. 31 | 32 | The beacon code is the same for embedded Linux as for microcontrollers, and is located in this repo in the [firmware](./firmware/) directory. 33 | 34 | ## TinyScan 35 | 36 | Go Haystack also includes TinyScan, a hardware scanner for local devices. 37 | 38 | ![tinyscan](./images/tinyscan.gif) 39 | 40 | TinyScan runs on several different microcontrollers boards with Bluetooth and miniature displays, such as those made by [Adafruit](https://www.adafruit.com/) and [Pimoroni](https://shop.pimoroni.com/) 41 | 42 | The TinyScan code is located in the [tinyscan](./tinyscan/) directory in this repository. 43 | 44 | ## How to install 45 | 46 | ### Apple ID 47 | 48 | You must have an Apple-ID with 2FA enabled. Only sms/text message as second factor is supported! 49 | 50 | ### anisette-v3-server 51 | 52 | Start [`anisette-v3-server`](https://github.com/Dadoum/anisette-v3-server) 53 | 54 | ```bash 55 | docker network create mh-network 56 | docker run -d --restart always --name anisette -p 6969:6969 --volume anisette-v3_data:/home/Alcoholic/.config/anisette-v3 --network mh-network dadoum/anisette-v3-server 57 | ``` 58 | 59 | ### macless-haystack 60 | 61 | 1. Start and set up your Macless Haystack endpoint in interactive mode: 62 | 63 | ```bash 64 | docker run -it --restart unless-stopped --name macless-haystack -p 6176:6176 --volume mh_data:/app/endpoint/data --network mh-network christld/macless-haystack 65 | ``` 66 | 67 | ###### You will be asked for your Apple-ID, password and your 2FA. If you see `serving at port 6176 over HTTP` you have all set up correctly 68 | 69 | Hit ctrl-C to exit the process once it has been configured. 70 | 71 | 2. Restart the macless-haystack server 72 | 73 | ```bash 74 | docker restart macless-haystack 75 | ``` 76 | 77 | See https://github.com/dchristl/macless-haystack/blob/main/README.md#server-setup for the original instructions. 78 | 79 | ### go-haystack 80 | 81 | Install the go-haystack command line tool 82 | 83 | ```shell 84 | go install github.com/hybridgroup/go-haystack/cmd/haystack@latest 85 | ``` 86 | 87 | ## How to use 88 | 89 | ### Scanning for local devices 90 | 91 | ```shell 92 | haystack scan 93 | ``` 94 | 95 | Should return any local devices within range: 96 | 97 | ```shell 98 | $ haystack scan 99 | CE:8B:AD:5F:8A:02 -53 ce8bad5f8a0271538ff5afda87498cb067e9a020d6e4167801d55d83 - battery full 100 | FE:B0:67:9B:9A:5C -55 feb0679b9a5c55b1141c5cc6c8f65224ae9bc6bc2d998ccf5c56a02d - battery full 101 | CE:8B:AD:5F:8A:02 -53 ce8bad5f8a0271538ff5afda87498cb067e9a020d6e4167801d55d83 - battery full 102 | CE:8B:AD:5F:8A:02 -53 ce8bad5f8a0271538ff5afda87498cb067e9a020d6e4167801d55d83 - battery full 103 | FE:B0:67:9B:9A:5C -56 feb0679b9a5c55b1141c5cc6c8f65224ae9bc6bc2d998ccf5c56a02d - battery full 104 | CE:8B:AD:5F:8A:02 -53 ce8bad5f8a0271538ff5afda87498cb067e9a020d6e4167801d55d83 - battery full 105 | FE:B0:67:9B:9A:5C -56 feb0679b9a5c55b1141c5cc6c8f65224ae9bc6bc2d998ccf5c56a02d - battery full 106 | CE:8B:AD:5F:8A:02 -53 ce8bad5f8a0271538ff5afda87498cb067e9a020d6e4167801d55d83 - battery full 107 | ``` 108 | 109 | ### Adding a new device 110 | 111 | 1. Generate keys for a device 112 | 113 | ```shell 114 | haystack keys DEVICENAME 115 | ``` 116 | 117 | The keys will be saved in a file named `DEVICENAME.keys` and the configuration file for Haystack will be saved in `DEVICENAME.json`. Replace "DEVICENAME" with whatever you want to name the actual device. 118 | 119 | 120 | 2. Flash the hardware with the TinyGo target and the name of your device. 121 | 122 | For example: 123 | 124 | ```shell 125 | haystack flash DEVICENAME nano-rp2040 126 | ``` 127 | 128 | This will use TinyGo to compile the firmware using your keys, and then flash it to the device. See [https://tinygo.org/getting-started/overview/](https://tinygo.org/getting-started/overview/) for more information about TinyGo. 129 | 130 | 131 | 3. Upload the JSON file for that device to your running instance of `macless-haystack` using the web UI. 132 | 133 | Point your web browser to [`https://dchristl.github.io/macless-haystack/`](https://dchristl.github.io/macless-haystack/) which is a single-page web application that only reads/writes local data. Click on the link for "Accessories", then on the "+" button. Choose the `DEVICENAME.json` file for your device. 134 | 135 | That's it, your device is now setup. 136 | 137 | ## Objects in your data may be closer than they appear 138 | 139 | Eventually, if your device is in range of any iPhone, they will appear in your Macless-Haystack data in the web UI. 140 | 141 | Note that it might take a while for the first data to show up. 142 | 143 | Have fun, be good! 144 | -------------------------------------------------------------------------------- /cmd/haystack/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/hybridgroup/go-haystack/cmd/haystack 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/hybridgroup/go-haystack v0.0.0-20250112154723-07ab9075fa2b 7 | tinygo.org/x/bluetooth v0.10.1-0.20250110080820-c6dfccb1a90b 8 | ) 9 | 10 | require ( 11 | github.com/go-ole/go-ole v1.2.6 // indirect 12 | github.com/godbus/dbus/v5 v5.1.0 // indirect 13 | github.com/saltosystems/winrt-go v0.0.0-20240509164145-4f7860a3bd2b // indirect 14 | github.com/sirupsen/logrus v1.9.3 // indirect 15 | github.com/soypat/cyw43439 v0.0.0-20241116210509-ae1ce0e084c5 // indirect 16 | github.com/soypat/seqs v0.0.0-20240527012110-1201bab640ef // indirect 17 | github.com/tinygo-org/cbgo v0.0.4 // indirect 18 | github.com/tinygo-org/pio v0.0.0-20231216154340-cd888eb58899 // indirect 19 | golang.org/x/exp v0.0.0-20230728194245-b0cb94b80691 // indirect 20 | golang.org/x/sys v0.11.0 // indirect 21 | ) 22 | -------------------------------------------------------------------------------- /cmd/haystack/go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= 5 | github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= 6 | github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= 7 | github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 8 | github.com/hybridgroup/go-haystack v0.0.0-20250112154723-07ab9075fa2b h1:2jOBNonPqxWCxjY1szygLvHXvM7+oc86HsIVGzS93gM= 9 | github.com/hybridgroup/go-haystack v0.0.0-20250112154723-07ab9075fa2b/go.mod h1:DvH8VgHcL/L57TkU2JjwjZSIb+CBD5kD4H2ijx/9f7w= 10 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 11 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 12 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 13 | github.com/saltosystems/winrt-go v0.0.0-20240509164145-4f7860a3bd2b h1:du3zG5fd8snsFN6RBoLA7fpaYV9ZQIsyH9snlk2Zvik= 14 | github.com/saltosystems/winrt-go v0.0.0-20240509164145-4f7860a3bd2b/go.mod h1:CIltaIm7qaANUIvzr0Vmz71lmQMAIbGJ7cvgzX7FMfA= 15 | github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo= 16 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 17 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 18 | github.com/soypat/cyw43439 v0.0.0-20241116210509-ae1ce0e084c5 h1:arwJFX1x5zq+wUp5ADGgudhMQEXKNMQOmTh+yYgkwzw= 19 | github.com/soypat/cyw43439 v0.0.0-20241116210509-ae1ce0e084c5/go.mod h1:1Otjk6PRhfzfcVHeWMEeku/VntFqWghUwuSQyivb2vE= 20 | github.com/soypat/seqs v0.0.0-20240527012110-1201bab640ef h1:phH95I9wANjTYw6bSYLZDQfNvao+HqYDom8owbNa0P4= 21 | github.com/soypat/seqs v0.0.0-20240527012110-1201bab640ef/go.mod h1:oCVCNGCHMKoBj97Zp9znLbQ1nHxpkmOY9X+UAGzOxc8= 22 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 23 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 24 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 25 | github.com/stretchr/testify v1.7.5 h1:s5PTfem8p8EbKQOctVV53k6jCJt3UX4IEJzwh+C324Q= 26 | github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 27 | github.com/tinygo-org/cbgo v0.0.4 h1:3D76CRYbH03Rudi8sEgs/YO0x3JIMdyq8jlQtk/44fU= 28 | github.com/tinygo-org/cbgo v0.0.4/go.mod h1:7+HgWIHd4nbAz0ESjGlJ1/v9LDU1Ox8MGzP9mah/fLk= 29 | github.com/tinygo-org/pio v0.0.0-20231216154340-cd888eb58899 h1:/DyaXDEWMqoVUVEJVJIlNk1bXTbFs8s3Q4GdPInSKTQ= 30 | github.com/tinygo-org/pio v0.0.0-20231216154340-cd888eb58899/go.mod h1:LU7Dw00NJ+N86QkeTGjMLNkYcEYMor6wTDpTCu0EaH8= 31 | golang.org/x/exp v0.0.0-20230728194245-b0cb94b80691 h1:/yRP+0AN7mf5DkD3BAI6TOFnd51gEoDEb8o35jIFtgw= 32 | golang.org/x/exp v0.0.0-20230728194245-b0cb94b80691/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= 33 | golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 34 | golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 35 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 36 | golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= 37 | golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 38 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 39 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 40 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 41 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 42 | tinygo.org/x/bluetooth v0.10.1-0.20250110080820-c6dfccb1a90b h1:BVFpFhNd0umlK744qtzCfe4W7Dp20Tj2Eb+FVCpggCE= 43 | tinygo.org/x/bluetooth v0.10.1-0.20250110080820-c6dfccb1a90b/go.mod h1:XLRopLvxWmIbofpZSXc7BGGCpgFOV5lrZ1i/DQN0BCw= 44 | -------------------------------------------------------------------------------- /cmd/haystack/keys.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/ecdsa" 5 | "crypto/elliptic" 6 | "crypto/rand" 7 | "crypto/sha256" 8 | "encoding/base64" 9 | "errors" 10 | "strings" 11 | ) 12 | 13 | var errInvalidHash = errors.New("Hash contains '/'") 14 | 15 | func generateKey() (string, string, string, error) { 16 | // Generate ECDSA private key using P-224 curve 17 | pk, err := ecdsa.GenerateKey(elliptic.P224(), rand.Reader) 18 | if err != nil { 19 | return "", "", "", err 20 | } 21 | 22 | // Extract the raw private key bytes 23 | privateKeyBytes := pk.D.Bytes() 24 | 25 | // Ensure the private key is 28 bytes long (P-224 curve) 26 | if len(privateKeyBytes) != 28 { 27 | return "", "", "", errors.New("Private key is not 28 bytes long") 28 | } 29 | 30 | // Encode the raw private key to Base64 31 | privateKeyBase64 := base64.StdEncoding.EncodeToString(privateKeyBytes) 32 | 33 | // extract raw public key bytes 34 | publicKeyBytes := pk.PublicKey.X.Bytes() 35 | 36 | // Encode the public key to Base64 37 | publicKeyBase64 := base64.StdEncoding.EncodeToString(publicKeyBytes) 38 | 39 | // Hash the public key using SHA-256 40 | hash := sha256.Sum256(publicKeyBytes) 41 | hashBase64 := base64.StdEncoding.EncodeToString(hash[:]) 42 | 43 | // make sure not '/' in the base64 string 44 | if strings.Contains(hashBase64, "/") { 45 | return "", "", "", errInvalidHash 46 | } 47 | 48 | return privateKeyBase64, publicKeyBase64, hashBase64, nil 49 | } 50 | -------------------------------------------------------------------------------- /cmd/haystack/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | "os" 8 | "os/exec" 9 | "path/filepath" 10 | "strings" 11 | ) 12 | 13 | func main() { 14 | verboseFlag := flag.Bool("v", false, "enable verbose mode") 15 | flag.Parse() 16 | 17 | args := flag.Args() 18 | if len(args) < 1 { 19 | fmt.Println("subcommand required. valid subcommands are 'keys' 'flash' 'scan'") 20 | return 21 | } 22 | 23 | switch args[0] { 24 | case "keys": 25 | if len(args) < 2 { 26 | fmt.Println("Please provide a device name") 27 | return 28 | } 29 | if err := generateKeys(args[1], verboseFlag); err != nil { 30 | fmt.Println("failed to generate keys:", err) 31 | } 32 | case "flash": 33 | if len(args) < 3 { 34 | fmt.Println("Please provide a device name and target") 35 | return 36 | } 37 | if err := flashDevice(args[1], args[2], verboseFlag); err != nil { 38 | fmt.Println("failed to flash device:", err) 39 | } 40 | case "scan": 41 | if err := scanDevices(verboseFlag); err != nil { 42 | fmt.Println("failed to scan devices:", err) 43 | } 44 | default: 45 | fmt.Println("subcommand required. valid subcommands are 'keys' 'flash' 'scan'") 46 | return 47 | } 48 | } 49 | 50 | func generateKeys(name string, verboseFlag *bool) error { 51 | // TODO: check if overwriting keys 52 | 53 | priv, pub, hash, err := generateKey() 54 | if err != nil { 55 | return err 56 | } 57 | 58 | // Print the keys and hash 59 | if *verboseFlag { 60 | fmt.Printf("Private key: %s\n", priv) 61 | fmt.Printf("Advertisement key: %s\n", pub) 62 | fmt.Printf("Hashed adv key: %s\n", hash) 63 | } 64 | 65 | // save keys file 66 | if err := saveKeys(name, priv, pub, hash); err != nil { 67 | return err 68 | } 69 | 70 | // save device file 71 | return saveDevice(name, priv) 72 | } 73 | 74 | func flashDevice(name string, target string, verboseFlag *bool) error { 75 | key, err := readKey(name) 76 | if err != nil { 77 | return err 78 | } 79 | 80 | pwd := os.Getenv("PWD") 81 | pth := filepath.Join(pwd, "firmware") 82 | if err := os.Chdir(pth); err != nil { 83 | panic(err) 84 | } 85 | defer os.Chdir(pwd) 86 | 87 | keyVal := fmt.Sprintf("-X main.AdvertisingKey='%s'", key) 88 | if *verboseFlag { 89 | fmt.Println("tinygo", "flash", "-target", target, "-ldflags", keyVal, ".") 90 | } 91 | 92 | cmd := exec.Command("tinygo", "flash", "-target", target, "-ldflags", keyVal, ".") 93 | cmd.Stdout = os.Stdout 94 | cmd.Stderr = os.Stderr 95 | return cmd.Run() 96 | } 97 | 98 | func readKey(name string) (string, error) { 99 | f, err := os.Open(name + ".keys") 100 | if err != nil { 101 | return "", err 102 | } 103 | defer f.Close() 104 | 105 | b := make([]byte, 1024) 106 | n, err := f.Read(b) 107 | if err != nil { 108 | return "", err 109 | } 110 | 111 | lines := strings.Split(string(b[:n]), "\n") 112 | for _, line := range lines { 113 | if strings.Contains(line, "Advertisement key") { 114 | s := strings.Split(line, ":") 115 | return strings.TrimLeft(s[1], " "), nil 116 | } 117 | } 118 | 119 | return "", errors.New("key not found") 120 | } 121 | -------------------------------------------------------------------------------- /cmd/haystack/save.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | "os" 7 | "strconv" 8 | "text/template" 9 | ) 10 | 11 | func saveKeys(name string, priv string, pub string, hash string) error { 12 | f, err := os.Create(name + ".keys") 13 | if err != nil { 14 | return err 15 | } 16 | 17 | defer f.Close() 18 | 19 | f.Write([]byte(fmt.Sprintf("Private key: %s\n", priv))) 20 | f.Write([]byte(fmt.Sprintf("Advertisement key: %s\n", pub))) 21 | f.Write([]byte(fmt.Sprintf("Hashed adv key: %s\n", hash))) 22 | 23 | return nil 24 | } 25 | 26 | const deviceTemplate = `[ 27 | { 28 | "id": {{.ID}}, 29 | "colorComponents": [ 30 | 0, 31 | 1, 32 | 0, 33 | 1 34 | ], 35 | "name": "{{.Name}}", 36 | "privateKey": "{{.PrivateKey}}", 37 | "icon": "", 38 | "isDeployed": true, 39 | "colorSpaceName": "kCGColorSpaceExtendedSRGB", 40 | "usesDerivation": false, 41 | "isActive": false, 42 | "additionalKeys": [] 43 | } 44 | ] 45 | ` 46 | 47 | func saveDevice(name string, priv string) error { 48 | t, err := template.New("device").Parse(deviceTemplate) 49 | if err != nil { 50 | return err 51 | } 52 | 53 | f, err := os.Create(name + ".json") 54 | if err != nil { 55 | return err 56 | } 57 | 58 | defer f.Close() 59 | 60 | err = t.Execute(f, map[string]string{ 61 | "ID": randomInt(1000, 999999), 62 | "Name": name, 63 | "PrivateKey": priv, 64 | }) 65 | if err != nil { 66 | return err 67 | } 68 | 69 | return nil 70 | } 71 | 72 | // Returns an int >= min, < max 73 | func randomInt(min, max int) string { 74 | return strconv.Itoa(min + rand.Intn(max-min)) 75 | } 76 | -------------------------------------------------------------------------------- /cmd/haystack/scan_darwin.go: -------------------------------------------------------------------------------- 1 | //go:build darwin 2 | 3 | package main 4 | 5 | import ( 6 | "encoding/hex" 7 | 8 | "github.com/hybridgroup/go-haystack/lib/findmy" 9 | "tinygo.org/x/bluetooth" 10 | ) 11 | 12 | // unknownMAC is a MAC address w use on macOS because there is no way to obtain the actual MAC address of a device. 13 | // see https://developer.radiusnetworks.com/2013/10/21/corebluetooth-doesnt-let-you-see-ibeacons.html 14 | var unknownMAC = bluetooth.MAC{0xff, 0xff, 0xff, 0xff, 0xff, 0xff} 15 | 16 | func scanDevices(verboseFlag *bool) error { 17 | bluetooth.DefaultAdapter.Enable() 18 | 19 | return bluetooth.DefaultAdapter.Scan(func(adapter *bluetooth.Adapter, device bluetooth.ScanResult) { 20 | if *verboseFlag { 21 | println("found device:", device.Address.String(), device.RSSI, device.LocalName()) 22 | } 23 | 24 | if device.ManufacturerData() != nil && device.ManufacturerData()[0].CompanyID == findmy.AppleCompanyID { 25 | status, key, err := findmy.ParseData(unknownMAC, device.ManufacturerData()[0].Data) 26 | switch { 27 | case err != nil && err == findmy.ErrorUnregistered: 28 | println(device.Address.String(), device.RSSI, "(unregistered)") 29 | case err != nil: 30 | if *verboseFlag { 31 | println(device.Address.String(), " - failed to parse data:", err.Error(), hex.EncodeToString(device.ManufacturerData()[0].Data)) 32 | } 33 | return 34 | } 35 | println(device.Address.String(), device.RSSI, hex.EncodeToString(key), "- battery", findmy.BatteryStatus(status)) 36 | } 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /cmd/haystack/scan_others.go: -------------------------------------------------------------------------------- 1 | //go:build !darwin 2 | 3 | package main 4 | 5 | import ( 6 | "encoding/hex" 7 | 8 | "github.com/hybridgroup/go-haystack/lib/findmy" 9 | "tinygo.org/x/bluetooth" 10 | ) 11 | 12 | func scanDevices(verboseFlag *bool) error { 13 | bluetooth.DefaultAdapter.Enable() 14 | 15 | return bluetooth.DefaultAdapter.Scan(func(adapter *bluetooth.Adapter, device bluetooth.ScanResult) { 16 | if *verboseFlag { 17 | println("found device:", device.Address.String(), device.RSSI, device.LocalName()) 18 | } 19 | 20 | if device.ManufacturerData() != nil && device.ManufacturerData()[0].CompanyID == findmy.AppleCompanyID { 21 | status, key, err := findmy.ParseData(device.Address.MAC, device.ManufacturerData()[0].Data) 22 | switch { 23 | case err != nil && err == findmy.ErrorUnregistered: 24 | println(device.Address.String(), device.RSSI, " - unregistered device") 25 | case err != nil: 26 | if *verboseFlag { 27 | println(device.Address.String(), " - failed to parse data:", err.Error(), hex.EncodeToString(device.ManufacturerData()[0].Data)) 28 | } 29 | return 30 | } 31 | println(device.Address.String(), device.RSSI, hex.EncodeToString(key), "- battery", findmy.BatteryStatus(status)) 32 | } 33 | }) 34 | } 35 | -------------------------------------------------------------------------------- /firmware/README.md: -------------------------------------------------------------------------------- 1 | # Firmware 2 | 3 | Any device supported by the TinyGo Bluetooth package can be used to create a beacon recognized by OpenHaystack. 4 | 5 | ## How to flash 6 | 7 | ```shell 8 | tinygo flash -target nano-rp2040 -ldflags="-X main.AdvertisingKey='SGVsbG8sIFdvcmxkIQ=='" . 9 | 10 | ``` 11 | -------------------------------------------------------------------------------- /firmware/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/hybridgroup/go-haystack/firmware 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/hybridgroup/go-haystack v0.0.0-20250112154723-07ab9075fa2b 7 | tinygo.org/x/bluetooth v0.10.1-0.20250110080820-c6dfccb1a90b 8 | ) 9 | 10 | require ( 11 | github.com/go-ole/go-ole v1.2.6 // indirect 12 | github.com/godbus/dbus/v5 v5.1.0 // indirect 13 | github.com/saltosystems/winrt-go v0.0.0-20240509164145-4f7860a3bd2b // indirect 14 | github.com/sirupsen/logrus v1.9.3 // indirect 15 | github.com/soypat/cyw43439 v0.0.0-20241116210509-ae1ce0e084c5 // indirect 16 | github.com/soypat/seqs v0.0.0-20240527012110-1201bab640ef // indirect 17 | github.com/tinygo-org/cbgo v0.0.4 // indirect 18 | github.com/tinygo-org/pio v0.0.0-20231216154340-cd888eb58899 // indirect 19 | golang.org/x/exp v0.0.0-20230728194245-b0cb94b80691 // indirect 20 | golang.org/x/sys v0.11.0 // indirect 21 | ) 22 | -------------------------------------------------------------------------------- /firmware/go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= 5 | github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= 6 | github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= 7 | github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 8 | github.com/hybridgroup/go-haystack v0.0.0-20250112154723-07ab9075fa2b h1:2jOBNonPqxWCxjY1szygLvHXvM7+oc86HsIVGzS93gM= 9 | github.com/hybridgroup/go-haystack v0.0.0-20250112154723-07ab9075fa2b/go.mod h1:DvH8VgHcL/L57TkU2JjwjZSIb+CBD5kD4H2ijx/9f7w= 10 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 11 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 12 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 13 | github.com/saltosystems/winrt-go v0.0.0-20240509164145-4f7860a3bd2b h1:du3zG5fd8snsFN6RBoLA7fpaYV9ZQIsyH9snlk2Zvik= 14 | github.com/saltosystems/winrt-go v0.0.0-20240509164145-4f7860a3bd2b/go.mod h1:CIltaIm7qaANUIvzr0Vmz71lmQMAIbGJ7cvgzX7FMfA= 15 | github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo= 16 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 17 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 18 | github.com/soypat/cyw43439 v0.0.0-20241116210509-ae1ce0e084c5 h1:arwJFX1x5zq+wUp5ADGgudhMQEXKNMQOmTh+yYgkwzw= 19 | github.com/soypat/cyw43439 v0.0.0-20241116210509-ae1ce0e084c5/go.mod h1:1Otjk6PRhfzfcVHeWMEeku/VntFqWghUwuSQyivb2vE= 20 | github.com/soypat/seqs v0.0.0-20240527012110-1201bab640ef h1:phH95I9wANjTYw6bSYLZDQfNvao+HqYDom8owbNa0P4= 21 | github.com/soypat/seqs v0.0.0-20240527012110-1201bab640ef/go.mod h1:oCVCNGCHMKoBj97Zp9znLbQ1nHxpkmOY9X+UAGzOxc8= 22 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 23 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 24 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 25 | github.com/stretchr/testify v1.7.5 h1:s5PTfem8p8EbKQOctVV53k6jCJt3UX4IEJzwh+C324Q= 26 | github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 27 | github.com/tinygo-org/cbgo v0.0.4 h1:3D76CRYbH03Rudi8sEgs/YO0x3JIMdyq8jlQtk/44fU= 28 | github.com/tinygo-org/cbgo v0.0.4/go.mod h1:7+HgWIHd4nbAz0ESjGlJ1/v9LDU1Ox8MGzP9mah/fLk= 29 | github.com/tinygo-org/pio v0.0.0-20231216154340-cd888eb58899 h1:/DyaXDEWMqoVUVEJVJIlNk1bXTbFs8s3Q4GdPInSKTQ= 30 | github.com/tinygo-org/pio v0.0.0-20231216154340-cd888eb58899/go.mod h1:LU7Dw00NJ+N86QkeTGjMLNkYcEYMor6wTDpTCu0EaH8= 31 | golang.org/x/exp v0.0.0-20230728194245-b0cb94b80691 h1:/yRP+0AN7mf5DkD3BAI6TOFnd51gEoDEb8o35jIFtgw= 32 | golang.org/x/exp v0.0.0-20230728194245-b0cb94b80691/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= 33 | golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 34 | golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 35 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 36 | golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= 37 | golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 38 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 39 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 40 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 41 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 42 | tinygo.org/x/bluetooth v0.10.1-0.20250110080820-c6dfccb1a90b h1:BVFpFhNd0umlK744qtzCfe4W7Dp20Tj2Eb+FVCpggCE= 43 | tinygo.org/x/bluetooth v0.10.1-0.20250110080820-c6dfccb1a90b/go.mod h1:XLRopLvxWmIbofpZSXc7BGGCpgFOV5lrZ1i/DQN0BCw= 44 | -------------------------------------------------------------------------------- /firmware/main.go: -------------------------------------------------------------------------------- 1 | // Firmware to advertise a FindMy compatible device aka AirTag 2 | // see https://github.com/biemster/FindMy for more information. 3 | // 4 | // To build: 5 | // tinygo flash -target nano-rp2040 -ldflags="-X main.AdvertisingKey='SGVsbG8sIFdvcmxkIQ=='" . 6 | // 7 | // For Linux: 8 | // go run . SGVsbG8sIFdvcmxkIQ== 9 | package main 10 | 11 | import ( 12 | "encoding/base64" 13 | "errors" 14 | "time" 15 | 16 | "github.com/hybridgroup/go-haystack/lib/findmy" 17 | "tinygo.org/x/bluetooth" 18 | ) 19 | 20 | var adapter = bluetooth.DefaultAdapter 21 | 22 | func main() { 23 | // wait for USB serial to be available 24 | time.Sleep(2 * time.Second) 25 | 26 | key, err := getKeyData() 27 | if err != nil { 28 | fail("failed to get key data: " + err.Error()) 29 | } 30 | println("key is", AdvertisingKey, "(", len(key), "bytes)") 31 | 32 | opts := bluetooth.AdvertisementOptions{ 33 | AdvertisementType: bluetooth.AdvertisingTypeNonConnInd, 34 | Interval: bluetooth.NewDuration(1285000 * time.Microsecond), // 1285ms 35 | ManufacturerData: []bluetooth.ManufacturerDataElement{findmy.NewData(key)}, 36 | } 37 | 38 | must("enable BLE stack", adapter.Enable()) 39 | 40 | // Set the address to the first 6 bytes of the public key. 41 | adapter.SetRandomAddress(bluetooth.MAC{key[5], key[4], key[3], key[2], key[1], key[0] | 0xC0}) 42 | 43 | println("configure advertising...") 44 | adv := adapter.DefaultAdvertisement() 45 | must("config adv", adv.Configure(opts)) 46 | 47 | println("start advertising...") 48 | must("start adv", adv.Start()) 49 | 50 | address, _ := adapter.Address() 51 | for { 52 | println("FindMy device using", address.MAC.String()) 53 | time.Sleep(time.Second) 54 | } 55 | } 56 | 57 | // getKeyData returns the public key data from the base64 encoded string. 58 | func getKeyData() ([]byte, error) { 59 | val, err := base64.StdEncoding.DecodeString(AdvertisingKey) 60 | if err != nil { 61 | return nil, err 62 | } 63 | if len(val) != 28 { 64 | return nil, errors.New("public key must be 28 bytes long") 65 | } 66 | 67 | return val, nil 68 | } 69 | 70 | // must calls a function and fails if an error occurs. 71 | func must(action string, err error) { 72 | if err != nil { 73 | fail("failed to " + action + ": " + err.Error()) 74 | } 75 | } 76 | 77 | // fail prints a message over and over forever. 78 | func fail(msg string) { 79 | for { 80 | println(msg) 81 | time.Sleep(time.Second) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /firmware/mcu.go: -------------------------------------------------------------------------------- 1 | //go:build tinygo 2 | 3 | package main 4 | 5 | // AdvertisingKey is the public key of the device. Must be base64 encoded. 6 | var AdvertisingKey string 7 | -------------------------------------------------------------------------------- /firmware/os.go: -------------------------------------------------------------------------------- 1 | //go:build !tinygo 2 | 3 | package main 4 | 5 | import "os" 6 | 7 | // AdvertisingKey is the public key of the device. Must be base64 encoded. 8 | var AdvertisingKey = os.Args[1] 9 | -------------------------------------------------------------------------------- /flash.sh: -------------------------------------------------------------------------------- 1 | # This script will flash the target device with the keys for the keys file 2 | TARGET=$1 3 | KEYSFILE=$2 4 | echo "Flashing $TARGET device with keys $KEYSFILE" 5 | 6 | ADVKEY=$(awk -F: 'NR==2 {gsub(/^ +/, "", $2); print $2}' ${KEYSFILE}) 7 | cd ./firmware 8 | tinygo flash -target $TARGET -ldflags="-X main.AdvertisingKey='$ADVKEY'" . 9 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/hybridgroup/go-haystack 2 | 3 | go 1.23.0 4 | 5 | require tinygo.org/x/bluetooth v0.10.0 6 | 7 | require ( 8 | github.com/go-ole/go-ole v1.2.6 // indirect 9 | github.com/godbus/dbus/v5 v5.1.0 // indirect 10 | github.com/saltosystems/winrt-go v0.0.0-20240509164145-4f7860a3bd2b // indirect 11 | github.com/sirupsen/logrus v1.9.3 // indirect 12 | github.com/soypat/cyw43439 v0.0.0-20240609122733-da9153086796 // indirect 13 | github.com/soypat/seqs v0.0.0-20240527012110-1201bab640ef // indirect 14 | github.com/tinygo-org/cbgo v0.0.4 // indirect 15 | github.com/tinygo-org/pio v0.0.0-20231216154340-cd888eb58899 // indirect 16 | golang.org/x/exp v0.0.0-20230728194245-b0cb94b80691 // indirect 17 | golang.org/x/sys v0.11.0 // indirect 18 | ) 19 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= 5 | github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= 6 | github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= 7 | github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 8 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 9 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 10 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 11 | github.com/saltosystems/winrt-go v0.0.0-20240509164145-4f7860a3bd2b h1:du3zG5fd8snsFN6RBoLA7fpaYV9ZQIsyH9snlk2Zvik= 12 | github.com/saltosystems/winrt-go v0.0.0-20240509164145-4f7860a3bd2b/go.mod h1:CIltaIm7qaANUIvzr0Vmz71lmQMAIbGJ7cvgzX7FMfA= 13 | github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo= 14 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 15 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 16 | github.com/soypat/cyw43439 v0.0.0-20240609122733-da9153086796 h1:1/r2URInjjFtWqT61gU7YGVCq3BRyXt/C7z4oLRF9Lo= 17 | github.com/soypat/cyw43439 v0.0.0-20240609122733-da9153086796/go.mod h1:1Otjk6PRhfzfcVHeWMEeku/VntFqWghUwuSQyivb2vE= 18 | github.com/soypat/seqs v0.0.0-20240527012110-1201bab640ef h1:phH95I9wANjTYw6bSYLZDQfNvao+HqYDom8owbNa0P4= 19 | github.com/soypat/seqs v0.0.0-20240527012110-1201bab640ef/go.mod h1:oCVCNGCHMKoBj97Zp9znLbQ1nHxpkmOY9X+UAGzOxc8= 20 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 21 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 22 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 23 | github.com/stretchr/testify v1.7.5 h1:s5PTfem8p8EbKQOctVV53k6jCJt3UX4IEJzwh+C324Q= 24 | github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 25 | github.com/tinygo-org/cbgo v0.0.4 h1:3D76CRYbH03Rudi8sEgs/YO0x3JIMdyq8jlQtk/44fU= 26 | github.com/tinygo-org/cbgo v0.0.4/go.mod h1:7+HgWIHd4nbAz0ESjGlJ1/v9LDU1Ox8MGzP9mah/fLk= 27 | github.com/tinygo-org/pio v0.0.0-20231216154340-cd888eb58899 h1:/DyaXDEWMqoVUVEJVJIlNk1bXTbFs8s3Q4GdPInSKTQ= 28 | github.com/tinygo-org/pio v0.0.0-20231216154340-cd888eb58899/go.mod h1:LU7Dw00NJ+N86QkeTGjMLNkYcEYMor6wTDpTCu0EaH8= 29 | golang.org/x/exp v0.0.0-20230728194245-b0cb94b80691 h1:/yRP+0AN7mf5DkD3BAI6TOFnd51gEoDEb8o35jIFtgw= 30 | golang.org/x/exp v0.0.0-20230728194245-b0cb94b80691/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= 31 | golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 32 | golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 33 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 34 | golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= 35 | golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 36 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 37 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 38 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 39 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 40 | tinygo.org/x/bluetooth v0.10.0 h1:42n8qj2tuF5AfdbAUR2Nv45EhtVmbDFH6UoWnt6lzZQ= 41 | tinygo.org/x/bluetooth v0.10.0/go.mod h1:t/Vm2a/rslsBoqFQKCBsWQw/cmRicQq+8Tl3tj5RCRI= 42 | -------------------------------------------------------------------------------- /haystack.go: -------------------------------------------------------------------------------- 1 | package haystack 2 | -------------------------------------------------------------------------------- /images/go-haystack.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hybridgroup/go-haystack/2f163691da88f07dc118abf6146eaa6674710f92/images/go-haystack.png -------------------------------------------------------------------------------- /images/macless-haystack.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hybridgroup/go-haystack/2f163691da88f07dc118abf6146eaa6674710f92/images/macless-haystack.png -------------------------------------------------------------------------------- /images/tinygo-beacons.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hybridgroup/go-haystack/2f163691da88f07dc118abf6146eaa6674710f92/images/tinygo-beacons.jpg -------------------------------------------------------------------------------- /images/tinyscan.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hybridgroup/go-haystack/2f163691da88f07dc118abf6146eaa6674710f92/images/tinyscan.gif -------------------------------------------------------------------------------- /lib/findmy/data.go: -------------------------------------------------------------------------------- 1 | package findmy 2 | 3 | import ( 4 | "errors" 5 | 6 | "tinygo.org/x/bluetooth" 7 | ) 8 | 9 | const ( 10 | // Apple, Inc. 11 | AppleCompanyID = 0x004C 12 | 13 | // Not yet registered 14 | PayloadUnregistered = 0x07 15 | 16 | // Registered for offline finding 17 | PayloadTypeRegistered = 0x12 18 | 19 | // Length of the payload 20 | PayloadLength = 0x19 21 | 22 | // Hint byte 23 | Hint = 0x00 24 | 25 | // Battery full 26 | StatusBatteryFull = 0x10 27 | 28 | // Battery medium 29 | StatusBatteryMedium = 0x40 30 | 31 | // Battery low 32 | StatusBatteryLow = 0x80 33 | 34 | // Battery critical 35 | StatusBatteryCritical = 0xC0 36 | ) 37 | 38 | var ( 39 | ErrorNoData = errors.New("findmy: no data") 40 | ErrorDataTooShort = errors.New("findmy: data is too short") 41 | ErrorUnregistered = errors.New("findmy: unregistered device") 42 | ErrorInvalidPayloadType = errors.New("findmy: invalid payload type") 43 | ErrorInvalidPayloadLength = errors.New("findmy: invalid payload length") 44 | ErrorInvalidHint = errors.New("findmy: invalid hint") 45 | ) 46 | 47 | // ParseData parses the data from a FindMy device. 48 | // It returns the status byte, the advertising key, and an error if any. 49 | func ParseData(mac bluetooth.MAC, data []byte) (byte, []byte, error) { 50 | if len(data) == 0 { 51 | return 0, nil, ErrorNoData 52 | } 53 | 54 | switch data[0] { 55 | case PayloadTypeRegistered: 56 | // registered for offline finding, so go ahead 57 | case PayloadUnregistered: 58 | return 0, nil, ErrorUnregistered 59 | default: 60 | return 0, nil, ErrorInvalidPayloadType 61 | } 62 | 63 | if len(data) < 27 { 64 | return 0, nil, ErrorDataTooShort 65 | } 66 | 67 | if data[1] != PayloadLength { 68 | return 0, nil, ErrorInvalidPayloadLength 69 | } 70 | 71 | if data[26] != Hint { 72 | return 0, nil, ErrorInvalidHint 73 | } 74 | 75 | findMyStatus := data[2] 76 | var key [28]byte 77 | copy(key[6:], data[3:25]) 78 | 79 | // turn address into key bytes 80 | key[0] = mac[5] 81 | key[1] = mac[4] 82 | key[2] = mac[3] 83 | key[3] = mac[2] 84 | key[4] = mac[1] 85 | key[5] = mac[0] 86 | 87 | return findMyStatus, key[:], nil 88 | } 89 | 90 | // NewData creates the ManufacturerDataElement for the advertising data used by FindMy devices. 91 | // See https://adamcatley.com/AirTag.html#advertising-data 92 | func NewData(keyData []byte) bluetooth.ManufacturerDataElement { 93 | data := make([]byte, 0, 27) 94 | data = append(data, PayloadTypeRegistered, PayloadLength) 95 | data = append(data, StatusBatteryFull) 96 | data = append(data, keyData[6:]...) // copy last 22 bytes of advertising key 97 | data = append(data, (keyData[0] >> 6)) // first two bits of advertising key 98 | data = append(data, Hint) 99 | 100 | return bluetooth.ManufacturerDataElement{ 101 | CompanyID: AppleCompanyID, 102 | Data: data, 103 | } 104 | } 105 | 106 | // BatteryStatus returns a string representation of the battery status. 107 | func BatteryStatus(status byte) string { 108 | switch status { 109 | case StatusBatteryFull: 110 | return "full" 111 | case StatusBatteryMedium: 112 | return "medium" 113 | case StatusBatteryLow: 114 | return "low" 115 | case StatusBatteryCritical: 116 | return "critical" 117 | default: 118 | return "unknown" 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /lib/findmy/data_test.go: -------------------------------------------------------------------------------- 1 | package findmy 2 | 3 | import ( 4 | "testing" 5 | 6 | "tinygo.org/x/bluetooth" 7 | ) 8 | 9 | func TestNewData(t *testing.T) { 10 | key := []byte{0xce, 0x8b, 0xad, 0x5f, 0x8a, 0x02, 0x71, 0x53, 0x8f, 0xf5, 0xaf, 0xda, 0x87, 0x49, 0x8c, 0xb0, 0x67, 0xe9, 0xa0, 0x20, 0xd6, 0xe4, 0x16, 0x78, 0x01, 0xd5, 0x5d, 0x83} 11 | data := NewData(key) 12 | if data.Data[2] != StatusBatteryFull { 13 | t.Errorf("expected 0x%02x, got 0x%02x", StatusBatteryFull, data.Data[2]) 14 | } 15 | if data.Data[3] != 0x71 { 16 | t.Errorf("expected 0x71, got 0x%02x", data.Data[3]) 17 | } 18 | if data.Data[4] != 0x53 { 19 | t.Errorf("expected 0x53, got 0x%02x", data.Data[4]) 20 | } 21 | } 22 | 23 | func TestParseData(t *testing.T) { 24 | address := bluetooth.MAC{0x02, 0x8a, 0x5f, 0xad, 0x8b, 0xce} 25 | startingkey := []byte{0xce, 0x8b, 0xad, 0x5f, 0x8a, 0x02, 0x71, 0x53, 0x8f, 0xf5, 0xaf, 0xda, 0x87, 0x49, 0x8c, 0xb0, 0x67, 0xe9, 0xa0, 0x20, 0xd6, 0xe4, 0x16, 0x78, 0x01, 0xd5, 0x5d, 0x83} 26 | data := NewData(startingkey) 27 | status, key, err := ParseData(address, data.Data) 28 | if err != nil { 29 | t.Fatal(err) 30 | } 31 | if status != StatusBatteryFull { 32 | t.Errorf("expected 0x%02x, got 0x%02x", StatusBatteryFull, status) 33 | } 34 | if !bytesEqual(key, startingkey) { 35 | t.Errorf("expected %v, got %v", startingkey, key) 36 | } 37 | } 38 | 39 | func TestBatteryStatus(t *testing.T) { 40 | tests := []struct { 41 | status byte 42 | want string 43 | }{ 44 | {StatusBatteryFull, "full"}, 45 | {StatusBatteryMedium, "medium"}, 46 | {StatusBatteryLow, "low"}, 47 | {0xff, "unknown"}, 48 | } 49 | for _, test := range tests { 50 | got := BatteryStatus(test.status) 51 | if got != test.want { 52 | t.Errorf("BatteryStatus(%d) = %q, want %q", test.status, got, test.want) 53 | } 54 | } 55 | } 56 | 57 | func bytesEqual(a, b []byte) bool { 58 | if len(a) != len(b) { 59 | return false 60 | } 61 | for i, av := range a { 62 | if av != b[i] { 63 | return false 64 | } 65 | } 66 | return true 67 | } 68 | -------------------------------------------------------------------------------- /tinyscan/README.md: -------------------------------------------------------------------------------- 1 | # TinyScan 2 | 3 | ![tinyscan](../images/tinyscan.gif) 4 | 5 | Scanner for local FindMy devices that runs on small microcontrollers that have Bluetooth and also a screen attached. 6 | 7 | Looks for any devices nearby that are broadcasting the correct manufacturer data, and displays the MAC address and the public key for that device on the display. 8 | 9 | ## Supported hardware 10 | 11 | The following devices currently work with the Go Haystack TinyScan firmware. 12 | 13 | ### Pimoroni Badger-2040W 14 | 15 | https://shop.pimoroni.com/products/badger-2040-w?variant=40514062188627 16 | 17 | 18 | ```shell 19 | tinygo flash -target badger2040-w -stack-size 8kb . 20 | ``` 21 | 22 | ### Adafruit Clue 23 | 24 | 25 | https://www.adafruit.com/clue 26 | 27 | 28 | ```shell 29 | tinygo flash -target clue -stack-size 8kb . 30 | ``` 31 | 32 | 33 | ### Adafruit PyBadge with Airlift Featherwing 34 | 35 | 36 | https://www.adafruit.com/product/4200 37 | 38 | https://www.adafruit.com/product/4264 39 | 40 | 41 | ```shell 42 | tinygo flash -target pybadge -stack-size 8kb . 43 | ``` 44 | 45 | ### Adafruit Pyportal 46 | 47 | 48 | https://www.adafruit.com/product/4116 49 | 50 | 51 | ```shell 52 | tinygo flash -target pyportal -stack-size 8kb . 53 | ``` 54 | 55 | ## Debugging 56 | 57 | To show scanning errors on the TinyScan display, use the `ldflags` flag in your flash command like this: 58 | 59 | ``` 60 | tinygo flash -target badger2040-w -stack-size 8kb -ldflags="-X main.showErrors=true" . 61 | ``` -------------------------------------------------------------------------------- /tinyscan/badger2040w.go: -------------------------------------------------------------------------------- 1 | //go:build badger2040_w 2 | 3 | package main 4 | 5 | import ( 6 | "machine" 7 | 8 | "tinygo.org/x/tinyfont" 9 | "tinygo.org/x/tinyterm" 10 | "tinygo.org/x/tinyterm/displays" 11 | ) 12 | 13 | var ( 14 | font = &tinyfont.TomThumb 15 | ) 16 | 17 | func initTerminal() { 18 | led3v3 := machine.ENABLE_3V3 19 | led3v3.Configure(machine.PinConfig{Mode: machine.PinOutput}) 20 | led3v3.High() 21 | 22 | display := displays.Init() 23 | 24 | terminal = tinyterm.NewTerminal(display) 25 | terminal.Configure(&tinyterm.Config{ 26 | Font: font, 27 | FontHeight: 8, 28 | FontOffset: 6, 29 | UseSoftwareScroll: true, 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /tinyscan/clue.go: -------------------------------------------------------------------------------- 1 | //go:build clue_alpha 2 | 3 | package main 4 | 5 | import ( 6 | "tinygo.org/x/tinyfont/proggy" 7 | "tinygo.org/x/tinyterm" 8 | "tinygo.org/x/tinyterm/displays" 9 | ) 10 | 11 | var ( 12 | font = &proggy.TinySZ8pt7b 13 | ) 14 | 15 | func initTerminal() { 16 | display := displays.Init() 17 | 18 | terminal = tinyterm.NewTerminal(display) 19 | terminal.Configure(&tinyterm.Config{ 20 | Font: font, 21 | FontHeight: 10, 22 | FontOffset: 6, 23 | UseSoftwareScroll: true, 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /tinyscan/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/hybridgroup/go-haystack/tinyscan 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/hybridgroup/go-haystack v0.0.0-20250112154723-07ab9075fa2b 7 | tinygo.org/x/bluetooth v0.11.0 8 | tinygo.org/x/drivers v0.29.0 9 | tinygo.org/x/tinyfont v0.5.0 10 | tinygo.org/x/tinyterm v0.4.1-0.20250110161638-0af95c3b0d98 11 | ) 12 | 13 | require ( 14 | github.com/go-ole/go-ole v1.2.6 // indirect 15 | github.com/godbus/dbus/v5 v5.1.0 // indirect 16 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect 17 | github.com/saltosystems/winrt-go v0.0.0-20240509164145-4f7860a3bd2b // indirect 18 | github.com/sirupsen/logrus v1.9.3 // indirect 19 | github.com/soypat/cyw43439 v0.0.0-20241116210509-ae1ce0e084c5 // indirect 20 | github.com/soypat/seqs v0.0.0-20240527012110-1201bab640ef // indirect 21 | github.com/tinygo-org/cbgo v0.0.4 // indirect 22 | github.com/tinygo-org/pio v0.0.0-20231216154340-cd888eb58899 // indirect 23 | golang.org/x/exp v0.0.0-20230728194245-b0cb94b80691 // indirect 24 | golang.org/x/sys v0.11.0 // indirect 25 | ) 26 | -------------------------------------------------------------------------------- /tinyscan/go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= 5 | github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= 6 | github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= 7 | github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 8 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= 9 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= 10 | github.com/hybridgroup/go-haystack v0.0.0-20250112154723-07ab9075fa2b h1:2jOBNonPqxWCxjY1szygLvHXvM7+oc86HsIVGzS93gM= 11 | github.com/hybridgroup/go-haystack v0.0.0-20250112154723-07ab9075fa2b/go.mod h1:DvH8VgHcL/L57TkU2JjwjZSIb+CBD5kD4H2ijx/9f7w= 12 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 13 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 14 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 15 | github.com/saltosystems/winrt-go v0.0.0-20240509164145-4f7860a3bd2b h1:du3zG5fd8snsFN6RBoLA7fpaYV9ZQIsyH9snlk2Zvik= 16 | github.com/saltosystems/winrt-go v0.0.0-20240509164145-4f7860a3bd2b/go.mod h1:CIltaIm7qaANUIvzr0Vmz71lmQMAIbGJ7cvgzX7FMfA= 17 | github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo= 18 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 19 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 20 | github.com/soypat/cyw43439 v0.0.0-20241116210509-ae1ce0e084c5 h1:arwJFX1x5zq+wUp5ADGgudhMQEXKNMQOmTh+yYgkwzw= 21 | github.com/soypat/cyw43439 v0.0.0-20241116210509-ae1ce0e084c5/go.mod h1:1Otjk6PRhfzfcVHeWMEeku/VntFqWghUwuSQyivb2vE= 22 | github.com/soypat/seqs v0.0.0-20240527012110-1201bab640ef h1:phH95I9wANjTYw6bSYLZDQfNvao+HqYDom8owbNa0P4= 23 | github.com/soypat/seqs v0.0.0-20240527012110-1201bab640ef/go.mod h1:oCVCNGCHMKoBj97Zp9znLbQ1nHxpkmOY9X+UAGzOxc8= 24 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 25 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 26 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 27 | github.com/stretchr/testify v1.7.5 h1:s5PTfem8p8EbKQOctVV53k6jCJt3UX4IEJzwh+C324Q= 28 | github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 29 | github.com/tinygo-org/cbgo v0.0.4 h1:3D76CRYbH03Rudi8sEgs/YO0x3JIMdyq8jlQtk/44fU= 30 | github.com/tinygo-org/cbgo v0.0.4/go.mod h1:7+HgWIHd4nbAz0ESjGlJ1/v9LDU1Ox8MGzP9mah/fLk= 31 | github.com/tinygo-org/pio v0.0.0-20231216154340-cd888eb58899 h1:/DyaXDEWMqoVUVEJVJIlNk1bXTbFs8s3Q4GdPInSKTQ= 32 | github.com/tinygo-org/pio v0.0.0-20231216154340-cd888eb58899/go.mod h1:LU7Dw00NJ+N86QkeTGjMLNkYcEYMor6wTDpTCu0EaH8= 33 | golang.org/x/exp v0.0.0-20230728194245-b0cb94b80691 h1:/yRP+0AN7mf5DkD3BAI6TOFnd51gEoDEb8o35jIFtgw= 34 | golang.org/x/exp v0.0.0-20230728194245-b0cb94b80691/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= 35 | golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 36 | golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 37 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 38 | golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= 39 | golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 40 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 41 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 42 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 43 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 44 | tinygo.org/x/bluetooth v0.11.0 h1:32ludjNnqz6RyVRpmw2qgod7NvDePbBTWXkJm6jj4cg= 45 | tinygo.org/x/bluetooth v0.11.0/go.mod h1:XLRopLvxWmIbofpZSXc7BGGCpgFOV5lrZ1i/DQN0BCw= 46 | tinygo.org/x/drivers v0.29.0 h1:xHuq8Fr1D/D2+1V/3d+aXufqP81/CLi1itdVbrYgrE0= 47 | tinygo.org/x/drivers v0.29.0/go.mod h1:q/mU8G/wz821p8xXqbkBACOlmZFDHXd//DnYnCW+dDQ= 48 | tinygo.org/x/tinyfont v0.5.0 h1:+ApIQzuUuibx/LACLnGY5MWZ/zFwed0RJAnCJCRi2bk= 49 | tinygo.org/x/tinyfont v0.5.0/go.mod h1:2mKugz6aud3EO2IIBNQ2AbDv13kRD+s7R1U1FZ21Lkw= 50 | tinygo.org/x/tinyterm v0.4.1-0.20250110161638-0af95c3b0d98 h1:tE0DNmV83qHun9JF743ZFFMxq0UT2FXUhc398oQgTNc= 51 | tinygo.org/x/tinyterm v0.4.1-0.20250110161638-0af95c3b0d98/go.mod h1:gSsU5YIh0NsiU5cstRrcecrpXoJsdsQUNrOZw00/5TU= 52 | -------------------------------------------------------------------------------- /tinyscan/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/hex" 5 | "fmt" 6 | "image/color" 7 | "time" 8 | 9 | "github.com/hybridgroup/go-haystack/lib/findmy" 10 | "tinygo.org/x/bluetooth" 11 | "tinygo.org/x/tinyterm" 12 | ) 13 | 14 | var ( 15 | terminal *tinyterm.Terminal 16 | 17 | black = color.RGBA{0, 0, 0, 255} 18 | adapter = bluetooth.DefaultAdapter 19 | 20 | showErrors string 21 | ) 22 | 23 | func main() { 24 | initTerminal() 25 | 26 | terminalOutput("enable interface...") 27 | 28 | must("enable BLE interface", adapter.Enable()) 29 | time.Sleep(time.Second) 30 | 31 | terminalOutput("start scan...") 32 | 33 | must("start scan", adapter.Scan(scanHandler)) 34 | 35 | for { 36 | time.Sleep(time.Minute) 37 | terminalOutput("scanning...") 38 | } 39 | } 40 | 41 | func scanHandler(adapter *bluetooth.Adapter, device bluetooth.ScanResult) { 42 | if device.ManufacturerData() != nil && device.ManufacturerData()[0].CompanyID == findmy.AppleCompanyID { 43 | status, key, err := findmy.ParseData(device.Address.MAC, device.ManufacturerData()[0].Data) 44 | terminalOutput("--------------------------------") 45 | switch { 46 | case err != nil && err == findmy.ErrorUnregistered: 47 | terminalOutput(fmt.Sprintf("%s %d (unregistered)", device.Address.String(), device.RSSI)) 48 | return 49 | case err != nil: 50 | if showErrors != "" { 51 | terminalOutput("ERROR: failed to parse data:" + err.Error()) 52 | } 53 | return 54 | } 55 | 56 | terminalOutput(fmt.Sprintf("%s %d (battery %s)", device.Address.String(), device.RSSI, findmy.BatteryStatus(status))) 57 | terminalOutput(hex.EncodeToString(key)) 58 | } 59 | } 60 | 61 | func must(action string, err error) { 62 | if err != nil { 63 | for { 64 | terminalOutput("failed to " + action + ": " + err.Error()) 65 | 66 | time.Sleep(time.Second) 67 | } 68 | } 69 | } 70 | 71 | func terminalOutput(s string) { 72 | println(s) 73 | fmt.Fprintf(terminal, "\n%s", s) 74 | 75 | terminal.Display() 76 | } 77 | -------------------------------------------------------------------------------- /tinyscan/pybadge.go: -------------------------------------------------------------------------------- 1 | //go:build pybadge 2 | 3 | package main 4 | 5 | import ( 6 | "tinygo.org/x/tinyfont" 7 | "tinygo.org/x/tinyterm" 8 | "tinygo.org/x/tinyterm/displays" 9 | ) 10 | 11 | var ( 12 | font = &tinyfont.Picopixel 13 | ) 14 | 15 | func initTerminal() { 16 | display := displays.Init() 17 | 18 | terminal = tinyterm.NewTerminal(display) 19 | terminal.Configure(&tinyterm.Config{ 20 | Font: font, 21 | FontHeight: 8, 22 | FontOffset: 4, 23 | UseSoftwareScroll: true, 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /tinyscan/pyportal.go: -------------------------------------------------------------------------------- 1 | //go:build pyportal 2 | 3 | package main 4 | 5 | import ( 6 | "tinygo.org/x/drivers/ili9341" 7 | "tinygo.org/x/tinyfont" 8 | "tinygo.org/x/tinyterm" 9 | "tinygo.org/x/tinyterm/displays" 10 | ) 11 | 12 | var ( 13 | font = &tinyfont.TomThumb 14 | ) 15 | 16 | func initTerminal() { 17 | display := displays.Init() 18 | display.SetRotation(ili9341.Rotation270) 19 | 20 | terminal = tinyterm.NewTerminal(display) 21 | terminal.Configure(&tinyterm.Config{ 22 | Font: font, 23 | FontHeight: 8, 24 | FontOffset: 6, 25 | UseSoftwareScroll: true, 26 | }) 27 | } 28 | --------------------------------------------------------------------------------