Skip to content

Commit a32ed3a

Browse files
feat: add use_iot optional feature axis
Adds `use_iot` (default false) to copier.yaml. When enabled: - pyproject.toml: [iot] optional-deps group (pyserial, paho-mqtt, gpiozero, smbus2, bleak) - src/{name}/drivers/: SerialPort, DigitalOutput/Input, MQTTClient Protocols with real implementations; lazy hardware imports so tests never require physical devices - src/{name}/sensors/: empty package scaffold for sensor logic - config/device.yaml.example: annotated template for MQTT, serial, GPIO config; device.yaml is gitignored - tests/conftest.py: mock_serial fixture (patches serial.Serial) and autouse mock_gpio fixture (gpiozero MockFactory) - tests/test_drivers.py: serial, GPIO, and MQTT smoke tests; MQTT connects to a real broker, serial/GPIO use mocks - ci.yml: starts eclipse-mosquitto:2 before the test job when use_iot - justfile: deploy recipe — rsync+systemd when use_docker=none, docker-pull+compose when use_docker=cpu/gpu Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 110b0ae commit a32ed3a

File tree

13 files changed

+326
-1
lines changed

13 files changed

+326
-1
lines changed

copier.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,11 @@ use_devcontainer:
8989
default: false
9090
help: Include VS Code dev container config?
9191

92+
use_iot:
93+
type: bool
94+
default: false
95+
help: Include IoT/embedded setup (serial, GPIO, MQTT drivers, device config)?
96+
9297
# --- Post-setup options ---
9398

9499
init_dvc:
@@ -119,6 +124,10 @@ _tasks:
119124
# Remove disabled features
120125
- command: rm -rf .devcontainer/
121126
when: "{{ not use_devcontainer }}"
127+
- command: rm -rf "src/{{ package_name }}/drivers/" "src/{{ package_name }}/sensors/" config/
128+
when: "{{ not use_iot }}"
129+
- command: rm -f tests/test_drivers.py
130+
when: "{{ not use_iot }}"
122131
- command: rm -rf docker/ .dockerignore
123132
when: "{{ use_docker == 'none' }}"
124133
- command: rm -f docker/Dockerfile.gpu

template/.github/workflows/ci.yml.jinja

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ jobs:
4848
version: "0.11.3"
4949
python-version: ${{ '{{' }} matrix.python-version {{ '}}' }}
5050
- run: uv sync --dev
51+
{%- if use_iot %}
52+
- name: Start MQTT broker
53+
run: |
54+
docker run -d --name mosquitto -p 1883:1883 eclipse-mosquitto:2 \
55+
sh -c 'printf "allow_anonymous true\nlistener 1883\n" > /tmp/m.conf && mosquitto -c /tmp/m.conf'
56+
sleep 2
57+
{%- endif %}
5158
- run: uv run pytest -v{% if testing != 'minimal' %} --cov=src --cov-report=xml{% endif %}
5259

5360
{%- if testing != 'minimal' %}

template/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ output/
5252
.env
5353
.env.local
5454

55+
# Device configuration (contains local IPs, pin numbers — not committed)
56+
config/device.yaml
57+
5558
# OS
5659
.DS_Store
5760
Thumbs.db
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Device configuration for {{ project_name }}
2+
#
3+
# Copy to config/device.yaml and fill in your values.
4+
# device.yaml is gitignored — never commit real device addresses or secrets.
5+
6+
mqtt:
7+
host: 192.168.1.100 # broker IP or hostname
8+
port: 1883
9+
client_id: {{ project_name }}-device
10+
topics:
11+
telemetry: "{{ project_name }}/telemetry"
12+
commands: "{{ project_name }}/commands"
13+
14+
serial:
15+
# Linux: /dev/ttyUSB0 or /dev/ttyAMA0 (UART on Pi GPIO header)
16+
# macOS: /dev/cu.usbserial-XXXX
17+
# Windows: COM3
18+
port: /dev/ttyUSB0
19+
baudrate: 115200
20+
timeout: 1.0 # seconds
21+
22+
gpio:
23+
# BCM (Broadcom) pin numbers — not physical board numbers
24+
led_pin: 17
25+
button_pin: 27

template/justfile.jinja

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,20 @@ dvc-init:
137137
uv run dvc config core.autostage true
138138
{% endif %}
139139

140+
{% if use_iot %}
141+
{% if use_docker == 'none' %}
142+
# Deploy to device over SSH (rsync src + restart systemd service)
143+
deploy host:
144+
rsync -av src/ pi@{{ '{{' }}host{{ '}}' }}:/opt/{{ project_name }}/src/
145+
ssh pi@{{ '{{' }}host{{ '}}' }} sudo systemctl restart {{ project_name }}
146+
{% else %}
147+
# Deploy Docker image to device (pull latest and restart)
148+
deploy host tag="latest":
149+
ssh pi@{{ '{{' }}host{{ '}}' }} docker pull ghcr.io/{{ github_user }}/{{ project_name }}:{{ '{{' }}tag{{ '}}' }}
150+
ssh pi@{{ '{{' }}host{{ '}}' }} docker compose -f /opt/{{ project_name }}/docker-compose.yml up -d
151+
{% endif %}
152+
{% endif %}
153+
140154
# Clean build artifacts
141155
clean:
142156
rm -rf dist/ build/{% if use_docs %} site/{% endif %} .pytest_cache/{% if use_typecheck %} .mypy_cache/{% endif %} .ruff_cache/ htmlcov/

template/pyproject.toml.jinja

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,10 @@ dev = [
3232
"commitizen>=4.1",
3333
"copier>=9.0",
3434
]
35-
{% if use_ml %}
35+
{% if use_ml or use_iot %}
3636

3737
[project.optional-dependencies]
38+
{%- if use_ml %}
3839
ml = [
3940
# Uncomment the libraries you need:
4041
# "numpy>=1.26",
@@ -45,6 +46,16 @@ ml = [
4546
# "transformers>=4.40",
4647
# "dvc>=3.50",
4748
]
49+
{%- endif %}
50+
{%- if use_iot %}
51+
iot = [
52+
"pyserial>=3.5", # UART / serial communication
53+
"paho-mqtt>=2.0", # MQTT messaging
54+
"gpiozero>=2.0", # GPIO abstraction with built-in mock support
55+
"smbus2>=0.5", # I2C sensor communication
56+
"bleak>=0.21", # Bluetooth LE (async, cross-platform)
57+
]
58+
{%- endif %}
4859
{% endif %}
4960

5061
[project.scripts]
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Hardware driver abstractions."""
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""GPIO driver — digital I/O abstraction via gpiozero.
2+
3+
gpiozero ships a :class:`~gpiozero.pins.mock.MockFactory` that replaces real
4+
hardware in CI. The ``mock_gpio`` fixture in ``conftest.py`` activates it
5+
automatically for all tests — no extra setup needed.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from typing import Protocol
11+
12+
13+
class DigitalOutput(Protocol):
14+
"""Abstract digital output pin (LED, relay, buzzer …)."""
15+
16+
@property
17+
def value(self) -> int: ...
18+
def on(self) -> None: ...
19+
def off(self) -> None: ...
20+
def toggle(self) -> None: ...
21+
def close(self) -> None: ...
22+
23+
24+
class DigitalInput(Protocol):
25+
"""Abstract digital input pin (button, switch, sensor …)."""
26+
27+
@property
28+
def value(self) -> int: ...
29+
def close(self) -> None: ...
30+
31+
32+
def led(pin: int) -> DigitalOutput:
33+
"""Create a digital output on a BCM pin number.
34+
35+
Args:
36+
pin: BCM pin number (e.g. ``17``).
37+
38+
Returns:
39+
A :class:`DigitalOutput` backed by :class:`~gpiozero.LED`.
40+
"""
41+
from gpiozero import LED # type: ignore[import-untyped]
42+
43+
return LED(pin) # type: ignore[return-value]
44+
45+
46+
def button(pin: int, pull_up: bool = True) -> DigitalInput:
47+
"""Create a digital input on a BCM pin number.
48+
49+
Args:
50+
pin: BCM pin number (e.g. ``27``).
51+
pull_up: Use internal pull-up resistor. Set to ``False`` for pull-down.
52+
53+
Returns:
54+
A :class:`DigitalInput` backed by :class:`~gpiozero.Button`.
55+
"""
56+
from gpiozero import Button # type: ignore[import-untyped]
57+
58+
return Button(pin, pull_up=pull_up) # type: ignore[return-value]
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""MQTT driver — publish/subscribe messaging via paho-mqtt.
2+
3+
Use the :class:`MQTTClient` Protocol as the type annotation throughout the
4+
codebase. Pass a real :func:`create_client` in production and a
5+
:class:`unittest.mock.MagicMock` in tests.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from collections.abc import Callable
11+
from typing import Protocol
12+
13+
14+
MessageCallback = Callable[[str, bytes], None]
15+
16+
17+
class MQTTClient(Protocol):
18+
"""Abstract MQTT client interface."""
19+
20+
def connect(self, host: str, port: int = 1883) -> None: ...
21+
def disconnect(self) -> None: ...
22+
def publish(self, topic: str, payload: str | bytes) -> None: ...
23+
def loop_start(self) -> None: ...
24+
def loop_stop(self) -> None: ...
25+
26+
27+
def create_client(client_id: str = "") -> MQTTClient:
28+
"""Create a paho-mqtt client satisfying :class:`MQTTClient`.
29+
30+
Args:
31+
client_id: Optional client identifier. Auto-generated if empty.
32+
33+
Returns:
34+
A configured paho MQTT client (not yet connected).
35+
36+
Example::
37+
38+
client = create_client("{{ project_name }}-device")
39+
client.connect("192.168.1.100")
40+
client.loop_start()
41+
client.publish("{{ project_name }}/telemetry", '{"temp": 21.5}')
42+
"""
43+
import paho.mqtt.client as mqtt # type: ignore[import-untyped]
44+
45+
return mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=client_id) # type: ignore[return-value]
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""Serial port driver — UART communication abstraction.
2+
3+
Define all serial interactions through the :class:`SerialPort` Protocol so
4+
tests can swap in a mock without touching production code paths.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from typing import Protocol
10+
11+
12+
class SerialPort(Protocol):
13+
"""Abstract serial port interface."""
14+
15+
def write(self, data: bytes) -> int: ...
16+
def read(self, size: int) -> bytes: ...
17+
def readline(self) -> bytes: ...
18+
def close(self) -> None: ...
19+
20+
21+
def open_serial(port: str, baudrate: int = 115200, timeout: float = 1.0) -> SerialPort:
22+
"""Open a real serial port.
23+
24+
Args:
25+
port: Device path — e.g. ``/dev/ttyUSB0``, ``/dev/ttyAMA0``, or ``COM3``.
26+
baudrate: Communication speed in bits/second.
27+
timeout: Read timeout in seconds.
28+
29+
Returns:
30+
An open :class:`SerialPort`.
31+
32+
Example::
33+
34+
port = open_serial("/dev/ttyUSB0", baudrate=9600)
35+
port.write(b"AT\\r\\n")
36+
response = port.readline()
37+
port.close()
38+
"""
39+
import serial # pyserial — imported lazily so tests don't require hardware
40+
41+
return serial.Serial(port, baudrate=baudrate, timeout=timeout) # type: ignore[return-value]

0 commit comments

Comments
 (0)