Skip to content

Commit f95923a

Browse files
kylegordonclaude
andauthored
Add Docker dev environment and charging binary sensor (#67)
## Summary - Add `Dockerfile.dev` — a minimal Python 3.12 image that installs `requirements-test.txt` (which pulls in Home Assistant via `pytest-homeassistant-custom-component`) - Document the Docker-based workflow in `README.md` (Local Development section) and `CLAUDE.md` so all contributors run tests and lint inside the container rather than on the host - Add `PetTracerChargingBinarySensor` to `binary_sensor.py`, wire it into `async_setup_entry`, and add full test coverage in `tests/test_binary_sensor.py` ## Test plan - [ ] `docker build -f Dockerfile.dev -t pettracer-dev .` completes successfully - [ ] `docker run --rm -v "$PWD":/workspace pettracer-dev pytest --cov=custom_components.pettracer --cov-report=term -v` — all tests pass, coverage ≥80% - [ ] CI test and validate jobs pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent dcd3e57 commit f95923a

6 files changed

Lines changed: 214 additions & 3 deletions

File tree

CLAUDE.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Commands
6+
7+
All development runs inside Docker — do not install dependencies directly on the host.
8+
9+
Build the dev image (required once, and after `requirements-test.txt` changes):
10+
```bash
11+
docker build -f Dockerfile.dev -t pettracer-dev .
12+
```
13+
14+
Run tests with coverage:
15+
```bash
16+
docker run --rm -v "$PWD":/workspace pettracer-dev \
17+
pytest --cov=custom_components.pettracer --cov-report=term -v
18+
```
19+
20+
Run a single test file:
21+
```bash
22+
docker run --rm -v "$PWD":/workspace pettracer-dev \
23+
pytest tests/test_sensor.py -v
24+
```
25+
26+
Lint and format:
27+
```bash
28+
docker run --rm -v "$PWD":/workspace pettracer-dev ruff check custom_components/pettracer/
29+
docker run --rm -v "$PWD":/workspace pettracer-dev ruff format custom_components/pettracer/
30+
```
31+
32+
Validate JSON files after editing (CI blocks on invalid JSON):
33+
```bash
34+
docker run --rm -v "$PWD":/workspace pettracer-dev \
35+
python -m json.tool custom_components/pettracer/manifest.json
36+
```
37+
38+
Open an interactive shell in the container:
39+
```bash
40+
docker run --rm -it -v "$PWD":/workspace pettracer-dev bash
41+
```
42+
43+
## Architecture
44+
45+
This is a Home Assistant custom component that bridges the PetTracer GPS collar cloud API to HA entities. The external `pettracer-client` PyPI package handles all API communication.
46+
47+
**Setup flow:** `config_flow.py` collects credentials → `__init__.py` authenticates and creates a `PetTracerDataUpdateCoordinator` → coordinator is stored in `hass.data[DOMAIN][entry_id]` → three platforms (`binary_sensor`, `device_tracker`, `sensor`) each pull the coordinator from `hass.data` and register entities per device.
48+
49+
**Data flow:** The coordinator calls `client.get_all_devices()` every 60 seconds. Each platform's `async_setup_entry` iterates `coordinator.data["devices"]` and creates entities. Entities extend `CoordinatorEntity` and call `_get_device_data()` to look up their specific device by `device.id` from the coordinator's latest data.
50+
51+
**Sensor pattern:** `sensor.py` uses `PetTracerSensorEntityDescription` (a frozen dataclass extending `SensorEntityDescription`) with a `value_fn: Callable[[device], Any]` field. Adding a new sensor means writing a `_get_*` function and adding an entry to `SENSOR_DESCRIPTIONS` — no new class needed.
52+
53+
**Binary sensors:** `binary_sensor.py` has two concrete classes (`PetTracerAtHomeBinarySensor`, `PetTracerChargingBinarySensor`). These follow the same `CoordinatorEntity` + `_get_device_data()` pattern but are explicit classes rather than description-driven.
54+
55+
## CI Behaviour
56+
57+
- **Tests** (`pytest` job): must pass; enforces ≥80% coverage with `coverage report --fail-under=80`.
58+
- **Validate** job: blocks on invalid JSON in `manifest.json`, `strings.json`, `translations/en.json`, `hacs.json`.
59+
- **Lint** job: `continue-on-error: true` — linting failures do not block CI.
60+
- Changing the `version` field in `manifest.json` triggers the auto-release workflow (creates a GitHub release and HACS ZIP). Don't bump the version unless you intend a release.

Dockerfile.dev

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
FROM python:3.12-slim
2+
3+
WORKDIR /workspace
4+
5+
COPY requirements-test.txt .
6+
RUN pip install --no-cache-dir -r requirements-test.txt
7+
8+
COPY . .

README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,44 @@ This integration is built using:
261261
- Home Assistant integration framework
262262
- Config flow for easy setup
263263

264+
### Local Development
265+
266+
All development is done inside Docker to avoid polluting your host with dependencies.
267+
268+
**Build the dev image** (once, and after `requirements-test.txt` changes):
269+
270+
```bash
271+
docker build -f Dockerfile.dev -t pettracer-dev .
272+
```
273+
274+
**Run the full test suite:**
275+
276+
```bash
277+
docker run --rm -v "$PWD":/workspace pettracer-dev \
278+
pytest --cov=custom_components.pettracer --cov-report=term -v
279+
```
280+
281+
**Run a single test file:**
282+
283+
```bash
284+
docker run --rm -v "$PWD":/workspace pettracer-dev pytest tests/test_sensor.py -v
285+
```
286+
287+
**Lint / format:**
288+
289+
```bash
290+
docker run --rm -v "$PWD":/workspace pettracer-dev ruff check custom_components/pettracer/
291+
docker run --rm -v "$PWD":/workspace pettracer-dev ruff format custom_components/pettracer/
292+
```
293+
294+
**Interactive shell:**
295+
296+
```bash
297+
docker run --rm -it -v "$PWD":/workspace pettracer-dev bash
298+
```
299+
300+
The source tree is bind-mounted at `/workspace`, so code changes take effect immediately without rebuilding the image.
301+
264302
### File Structure
265303

266304
```

custom_components/pettracer/binary_sensor.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,10 @@ async def async_setup_entry(
2424
"""Set up PetTracer binary sensors based on a config entry."""
2525
coordinator = hass.data[DOMAIN][config_entry.entry_id]
2626

27-
entities: list[PetTracerAtHomeBinarySensor] = []
27+
entities: list = []
2828
for device in coordinator.data.get("devices", []):
2929
entities.append(PetTracerAtHomeBinarySensor(coordinator, device))
30+
entities.append(PetTracerChargingBinarySensor(coordinator, device))
3031

3132
async_add_entities(entities, True)
3233

@@ -72,3 +73,46 @@ def is_on(self) -> bool | None:
7273
if device and device.home is not None:
7374
return device.home
7475
return None
76+
77+
78+
class PetTracerChargingBinarySensor(CoordinatorEntity, BinarySensorEntity):
79+
"""Representation of a PetTracer charging binary sensor."""
80+
81+
_attr_device_class = BinarySensorDeviceClass.BATTERY_CHARGING
82+
83+
def __init__(self, coordinator, device):
84+
"""Initialize the binary sensor."""
85+
super().__init__(coordinator)
86+
self._device = device
87+
self._device_id = device.id
88+
self._device_name = (
89+
device.details.name if device.details else f"PetTracer {device.id}"
90+
)
91+
self._attr_unique_id = f"pettracer_{device.id}_charging"
92+
self._attr_name = f"{self._device_name} Charging"
93+
94+
@property
95+
def device_info(self) -> dict[str, Any]:
96+
"""Return device information about this sensor."""
97+
return {
98+
"identifiers": {(DOMAIN, self._device_id)},
99+
"name": self._device_name,
100+
"manufacturer": "PetTracer",
101+
"model": "GPS Collar",
102+
"sw_version": self._device.sw if self._device.sw else None,
103+
}
104+
105+
def _get_device_data(self):
106+
"""Get updated device data from coordinator."""
107+
for device in self.coordinator.data.get("devices", []):
108+
if device.id == self._device_id:
109+
return device
110+
return None
111+
112+
@property
113+
def is_on(self) -> bool | None:
114+
"""Return true if the collar is charging."""
115+
device = self._get_device_data()
116+
if device and device.chg is not None:
117+
return bool(device.chg)
118+
return None

tests/conftest.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ def mock_device():
6262
device.status = 0
6363
device.mode = 1
6464
device.home = True
65+
device.chg = 1
6566
device.sw = 656393
6667
device.lastContact = datetime(2026, 1, 11, 10, 30, 0)
6768

@@ -90,6 +91,7 @@ def mock_device_no_position():
9091
device.status = 0
9192
device.mode = 1
9293
device.home = False
94+
device.chg = 0
9395
device.sw = 656393
9496
device.lastContact = datetime(2026, 1, 11, 10, 0, 0)
9597

tests/test_binary_sensor.py

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@
99
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
1010

1111
from custom_components.pettracer.const import DOMAIN
12-
from custom_components.pettracer.binary_sensor import PetTracerAtHomeBinarySensor
12+
from custom_components.pettracer.binary_sensor import (
13+
PetTracerAtHomeBinarySensor,
14+
PetTracerChargingBinarySensor,
15+
)
1316

1417

1518
async def test_binary_sensor_setup(hass, mock_pettracer_client_init, mock_device):
@@ -48,8 +51,9 @@ def mock_add_entities(new_entities, update_before_add):
4851

4952
await binary_sensor_setup(hass, entry, mock_add_entities)
5053

51-
assert len(entities) == 1
54+
assert len(entities) == 2
5255
assert isinstance(entities[0], PetTracerAtHomeBinarySensor)
56+
assert isinstance(entities[1], PetTracerChargingBinarySensor)
5357

5458

5559
async def test_at_home_binary_sensor_true(hass, mock_device):
@@ -105,3 +109,58 @@ async def test_at_home_binary_sensor_device_info(hass, mock_device):
105109
assert device_info["name"] == "Fluffy"
106110
assert device_info["manufacturer"] == "PetTracer"
107111
assert device_info["model"] == "GPS Collar"
112+
113+
114+
async def test_charging_binary_sensor_true(hass, mock_device):
115+
"""Test charging binary sensor when collar is charging."""
116+
coordinator = MagicMock()
117+
coordinator.data = {"devices": [mock_device]}
118+
119+
sensor = PetTracerChargingBinarySensor(coordinator, mock_device)
120+
121+
assert sensor.unique_id == "pettracer_12345_charging"
122+
assert sensor.name == "Fluffy Charging"
123+
assert sensor.device_class == BinarySensorDeviceClass.BATTERY_CHARGING
124+
assert sensor.is_on is True
125+
126+
127+
async def test_charging_binary_sensor_false(hass, mock_device_no_position):
128+
"""Test charging binary sensor when collar is not charging."""
129+
coordinator = MagicMock()
130+
coordinator.data = {"devices": [mock_device_no_position]}
131+
132+
sensor = PetTracerChargingBinarySensor(coordinator, mock_device_no_position)
133+
134+
assert sensor.unique_id == "pettracer_12346_charging"
135+
assert sensor.name == "Rex Charging"
136+
assert sensor.is_on is False
137+
138+
139+
async def test_charging_binary_sensor_none(hass):
140+
"""Test charging binary sensor when chg is None."""
141+
device = MagicMock()
142+
device.id = 99999
143+
device.details = None
144+
device.chg = None
145+
device.sw = None
146+
147+
coordinator = MagicMock()
148+
coordinator.data = {"devices": [device]}
149+
150+
sensor = PetTracerChargingBinarySensor(coordinator, device)
151+
152+
assert sensor.is_on is None
153+
154+
155+
async def test_charging_binary_sensor_device_info(hass, mock_device):
156+
"""Test charging binary sensor device info."""
157+
coordinator = MagicMock()
158+
coordinator.data = {"devices": [mock_device]}
159+
160+
sensor = PetTracerChargingBinarySensor(coordinator, mock_device)
161+
device_info = sensor.device_info
162+
163+
assert device_info["identifiers"] == {(DOMAIN, 12345)}
164+
assert device_info["name"] == "Fluffy"
165+
assert device_info["manufacturer"] == "PetTracer"
166+
assert device_info["model"] == "GPS Collar"

0 commit comments

Comments
 (0)