Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/homestation/ESP32/.micropico
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"info": "This file is just used to identify a project folder."
}
7 changes: 7 additions & 0 deletions packages/homestation/ESP32/.vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"recommendations": [
"ms-python.python",
"ms-python.vscode-pylance",
"paulober.pico-w-go"
]
}
16 changes: 16 additions & 0 deletions packages/homestation/ESP32/.vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"python.languageServer": "Pylance",
"python.analysis.typeCheckingMode": "basic",
"python.analysis.diagnosticSeverityOverrides": {
"reportMissingModuleSource": "none"
},
"python.terminal.activateEnvironment": false,
"micropico.openOnStart": true,
"python.analysis.typeshedPaths": [
"~/.micropico-stubs/included"
],
"python.analysis.extraPaths": [
"~/.micropico-stubs/included",
"./lib"
]
}
101 changes: 101 additions & 0 deletions packages/homestation/ESP32/SETUP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Homestation ESP32 Setup

## Requirements

- ESP32 with MicroPython v1.24.1+ flashed
- VS Code with MicroPico extension

## 1. Install packages on the ESP32

Connect to the board via the MicroPico REPL and run the following. The board must be connected to WiFi first.

```python
# Connect to WiFi
import network
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
wlan.connect("your-ssid", "your-password")

# Install packages
import mip
mip.install("aioble")
```

## 2. Configure

Edit `config.json` with your settings before uploading:

```json
{
"wifi": {
"ssid": "your-wifi-ssid",
"password": "your-wifi-password"
},
"mqtt": {
"broker": "your-mqtt-broker-hostname",
"port": 1883,
"topic": "/your-traccar-device-topic",
"client_id": "your-findmycat-device-imei"
},
"location": {
"latitude": 0.0,
"longitude": 0.0,
"accuracy": 5.00
},
"ble": {
"collar_name_prefix": "FMC", ## This MUST correspond to the collars advertising BLE packet on the indoor location engine DEFAULT: FMC
"homestation_name": "FindMyCatHomeStation", ## This MUST correspond to what the indoor location engine is looking for DEFAULT: FindMyCatHomeStation
"presence_timeout_ms": 30000 ## Don't change this if you're not sure what you're doing
}
}
```

## 3. Upload files

Right-click the project root in VS Code and select **Upload project to Pico**. This uploads all files preserving the directory structure, including `lib/mqtt_as/__init__.py`.

## 4. Verify files on the board

In the MicroPico REPL, confirm all files landed correctly:

```python
import os

# Root — should contain main.py, ble_handler.py, mqtt_handler.py, config.json
os.listdir("/")

# lib — should contain mqtt_as
os.listdir("/lib")

# mqtt_as package — should contain __init__.py
os.listdir("/lib/mqtt_as")
```

## 5. Run

Reset the board (press EN/RST or run `machine.reset()` in the REPL). You should see:

```
[MQTT] Connected to broker
[BLE] Advertising as: FindMyCatHomeStation
[BLE] Collar not home
```

When the collar comes into range:

```
[BLE] Collar Found
[BLE] Collar home
[MQTT] Published: {"msg": "..."}
```

## Testing without a collar

To verify the WiFi → MQTT pipeline without a collar nearby, uncomment `periodic_trigger_loop` in `main.py`. This publishes a location update every 60 seconds regardless of BLE state.

---

## Additional Info

- **mqtt_as** (Peter Hinch) — the MQTT client library bundled in `lib/mqtt_as/`. Handles WiFi and MQTT reconnection automatically: https://github.com/peterhinch/micropython-mqtt
- **aioble** (MicroPython) — official async BLE library, installed via `mip.install("aioble")`: https://github.com/micropython/micropython-lib/tree/master/micropython/bluetooth/aioble
95 changes: 95 additions & 0 deletions packages/homestation/ESP32/ble_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import aioble # type: ignore — installed on device via mip
import uasyncio as asyncio
import time
from micropython import const

try:
from typing import Optional
except ImportError:
pass # typing is not available on MicroPython at runtime

# Advertising interval in microseconds (20ms)
_ADV_INTERVAL_US = const(20_000)

# BLE scan parameters
_SCAN_DURATION_MS = const(5_000)
_SCAN_INTERVAL_US = const(30_000)
_SCAN_WINDOW_US = const(30_000)

_STATUS_LOG_INTERVAL_MS = const(10_000)


class BLEHandler:
def __init__(self, config: dict) -> None:
self._collar_prefix: str = config["ble"]["collar_name_prefix"]
self._homestation_name: str = config["ble"]["homestation_name"]
self._presence_timeout_ms: int = config["ble"]["presence_timeout_ms"]
self._last_seen_ms: Optional[int] = None
self._last_battery: Optional[str] = None
self._last_publish_ms: Optional[int] = None
self._last_status_log_ms: int = 0

async def advertise_loop(self) -> None:
"""Broadcast as FindMyCatHomeStation indefinitely so the collar can detect us."""
print("[BLE] Advertising as:", self._homestation_name)
while True:
try:
await aioble.advertise(
_ADV_INTERVAL_US,
name=self._homestation_name,
connectable=False,
)
except Exception as e:
print("[BLE] Advertise error:", e)
await asyncio.sleep(1)

async def scan_loop(self) -> None:
"""Continuously scan for the collar and record its battery level."""
while True:
try:
async with aioble.scan(
_SCAN_DURATION_MS,
interval_us=_SCAN_INTERVAL_US,
window_us=_SCAN_WINDOW_US,
active=True,
) as scanner:
async for result in scanner:
if result.name() and result.name().startswith(self._collar_prefix): ## TODO: Maybe we should do exact match rather than startswith?
self._last_seen_ms = time.ticks_ms()
mfr = result.manufacturer()
if mfr:
try:
_, data = mfr
self._last_battery = bytes(data).decode("utf-8").strip() ## TODO: Untested, need to test but in theory this should work (I think :D)
except Exception:
pass
except Exception as e:
print("[BLE] Scan error:", e)

now = time.ticks_ms()
if time.ticks_diff(now, self._last_status_log_ms) >= _STATUS_LOG_INTERVAL_MS: ## Just so we don't spam the console with log events (useful when debugging)
if self.collar_is_home():
print("[BLE] Collar Found")
else:
print("[BLE] Collar not home")
self._last_status_log_ms = now

def collar_is_home(self) -> bool:
"""Return True if collar was seen within the presence timeout window."""
if self._last_seen_ms is None:
return False
return time.ticks_diff(time.ticks_ms(), self._last_seen_ms) < self._presence_timeout_ms

def should_publish(self) -> bool:
"""Return True if collar is home and we haven't published in the last 30 seconds."""
if not self.collar_is_home():
return False
if self._last_publish_ms is None:
return True
return time.ticks_diff(time.ticks_ms(), self._last_publish_ms) >= self._presence_timeout_ms

def mark_published(self) -> None:
self._last_publish_ms = time.ticks_ms()

def get_battery(self) -> Optional[str]:
return self._last_battery
22 changes: 22 additions & 0 deletions packages/homestation/ESP32/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"wifi": {
"ssid": "your-wifi-ssid",
"password": "your-wifi-password"
},
"mqtt": {
"broker": "your-mqtt-broker-hostname",
"port": 1883,
"topic": "/your-traccar-device-topic",
"client_id": "your-findmycat-device-imei"
},
"location": {
"latitude": 0.0,
"longitude": 0.0,
"accuracy": 5.00
},
"ble": {
"collar_name_prefix": "FMC",
"homestation_name": "FindMyCatHomeStation",
"presence_timeout_ms": 30000
}
}
Loading