用 Python 玩转树莓派与物联网:从零基础到专业的 100 个示例

用 Python 玩转树莓派与物联网:从零基础到专业的 100 个示例

树莓派是一台小型电脑,可以读取传感器、控制继电器,并通过网络发送数据,因此常被用作物联网(IoT)项目的基础。本文用 100 个示例,带你从第一个 Python 程序一直做到完整的传感器数据流水线。你将学习 Python 基础、处理传感器数据、用 GPIO 控制引脚、通过 I2C、SPI 和 1-Wire 读取传感器、用 HTTP 和 MQTT 发送数据、搭建小型网页仪表盘、把读数保存到 SQLite,并让程序作为服务在开机时自动运行。诚实说明:能在普通电脑上运行的示例已实际运行,并在代码之后显示真实输出。其余示例需要树莓派、网络服务或系统服务,因此只做了语法检查,不显示输出。

需要准备的东西

运行 Raspberry Pi OS 的树莓派 3、4 或 5、一张 microSD 卡、电源以及网络连接。Raspberry Pi OS 已包含 Python 3.10 或更高版本。硬件示例需要 LED、电阻、按键、杜邦线以及若干传感器。大多数可运行的示例可以在任何装有 Python 3.10 或更高版本的电脑上运行,这也是可运行示例的测试方式。

如何运行示例

把示例保存为 main.py,然后用 python3 main.py 运行。标注为可运行的示例会显示在电脑上实际运行的输出。bash 示例是树莓派上的配置文件,这里没有运行。

知识点 1: 在树莓派上开始使用 Python

Python 易读,并且几乎每种传感器都有对应的库,因此是树莓派项目的常用语言。前几个示例涵盖输出、变量、类型和运算。

运行第一个程序

保存一个扩展名为 .py 的文件,然后在树莓派上用 python3 运行。print 把文字输出到终端,f 字符串用 {name} 把变量放进文字中。Python 从上到下读取文件,所以代码行的顺序很重要。

类型及其含义

数字可以是 int(整数)或 float(小数),文字是 str,True 和 False 是 bool,None 表示没有值。用 type() 可以检查一个值的类型。传感器读数通常是 float,而引脚编号通常是 int。

传感器计算所需的算术

/ 总是得到 float,// 整除并舍去小数部分,% 得到余数。round() 限制显示的小数位数。用这些运算符可以把原始读数换算成有意义的数值,并对数值分组。

示意图

物联网系统如何工作:传感器把数据送给树莓派,树莓派过滤并保存数据,然后通过网络把读数发布到代理服务器或人们可以查看的网络服务上
物联网系统如何工作:传感器把数据送给树莓派,树莓派过滤并保存数据,然后通过网络把读数发布到代理服务器或人们可以查看的网络服务上

示例 1: 来自 Pi 的问候

在 Raspberry Pi OS 上编写第一个 Python 程序。

print("Hello from my Raspberry Pi!")

输出:

Hello from my Raspberry Pi!

示例 2: 变量与 f 字符串

保存值,并在文字中显示。

sensor_name = "DHT22"
temperature = 27.5
print(f"{sensor_name} reads {temperature} °C")

输出:

DHT22 reads 27.5 °C

示例 3: 数据类型

Python 的基本类型及其名称。

for value in (42, 3.14, "pi", True, None):
    print(repr(value), type(value).__name__)

输出:

42 int
3.14 float
'pi' str
True bool
None NoneType

示例 4: 算术、整除与取整

为传感器计算使用的除法、取余与四舍五入。

readings = 17
per_group = 5
print(readings / per_group)
print(readings // per_group, readings % per_group)
print(round(3.14159, 2))

输出:

3.4
3 2
3.14

示例 5: 文本处理

清理设备名称并检查前缀。

status = "  ONLINE  "
clean = status.strip().lower()
print(clean, clean.startswith("on"), len(clean))

输出:

online True 6

示例 6: 单位换算

用函数把摄氏度转换为华氏度。

def celsius_to_fahrenheit(c):
    return c * 9 / 5 + 32

for c in (0, 25, 37.5):
    print(f"{c} C = {celsius_to_fahrenheit(c):.1f} F")

输出:

0 C = 32.0 F
25 C = 77.0 F
37.5 C = 99.5 F

知识点 2: 判断与循环

树莓派项目会对输入作出反应。条件决定会发生什么,循环则重复执行工作,例如每隔几秒采样一次传感器。

if、elif 与 else

条件从上到下依次检查,第一个为真的分支会执行。应把最具体的判断放在前面。像 20 <= t <= 25 这样的链式比较可以在一行里检查整个范围。

for、while、break 与 continue

for 在一个范围或序列上重复执行。while 在条件改变之前一直重复。break 立即跳出循环,continue 跳过本轮。当你需要忽略一个错误读数并继续运行时,这些语句很有用。

真值判断与 match 语句

空字符串或空列表等空值被视为假。Python 3.10 引入的 match 语句可以把命令与多个模式匹配,是处理手机应用发来的文字命令的简洁方式。

示例 7: if、elif 与 else

根据温度选择动作。

temperature = 31
if temperature >= 35:
    print("Too hot: stop the fan test")
elif temperature >= 28:
    print("Warm: run the fan")
else:
    print("Comfortable")

输出:

Warm: run the fan

示例 8: 带 range 的 for 循环

按固定次数重复执行。

for minute in range(0, 15, 5):
    print(f"minute {minute}")

输出:

minute 0
minute 5
minute 10

示例 9: while 循环

重复执行直到条件改变。

countdown = 3
while countdown > 0:
    print("sampling in", countdown)
    countdown -= 1
print("start")

输出:

sampling in 3
sampling in 2
sampling in 1
start

示例 10: break 与 continue

跳过错误读数,并在故障时停止。

for reading in [21, None, 23, 99, 24]:
    if reading is None:
        continue
    if reading > 50:
        print("sensor fault, stop")
        break
    print("ok", reading)

输出:

ok 21
ok 23
sensor fault, stop

示例 11: 真值判断

哪些值被视为真,哪些为假。

for value in ("", "Pi", 0, 5, [], [1]):
    print(repr(value), "is true" if value else "is false")

输出:

'' is false
'Pi' is true
0 is false
5 is true
[] is false
[1] is true

示例 12: 链式比较

检查一个值是否在范围之内。

humidity = 55
print(40 <= humidity <= 60)
print(humidity > 60 or humidity < 40)

输出:

True
False

示例 13: 条件表达式

一行写出的 if-else,并返回一个值。

reading = 12
state = "ON" if reading < 15 else "OFF"
print(state)

输出:

ON

示例 14: match 语句

把文本命令映射到动作。

def describe(command):
    match command:
        case "on" | "1":
            return "turn relay on"
        case "off" | "0":
            return "turn relay off"
        case _:
            return "unknown command"

for c in ("on", "0", "blink"):
    print(c, "->", describe(c))

输出:

on -> turn relay on
0 -> turn relay off
blink -> unknown command

知识点 3: 函数与标准库

函数为你重复执行的任务起一个名字。标准库已经包含数学、统计和时间相关的函数,因此你很少需要自己编写。

定义函数

def 用于创建函数,return 把值返回。当调用者省略参数时,默认参数提供合理的值;关键字参数让调用更易读,例如 sample(4, unit='volts')。

返回与传递多个值

函数可以把多个值作为元组返回,由调用者解包。*args 接收任意数量的位置参数,**kwargs 接收任意数量的关键字参数,很适合日志辅助函数。

lambda、map、filter 与递归

lambda 是一个很小的匿名函数,与 map 和 filter 配合可以变换列表。递归函数会调用自身,必须有终止条件,阶乘是典型例子。

示例 15: 带默认参数的函数

为函数设置合理的默认值。

def sample(pin, samples=3, unit="raw"):
    return f"read pin {pin} x{samples} ({unit})"

print(sample(4))
print(sample(4, 10, unit="volts"))

输出:

read pin 4 x3 (raw)
read pin 4 x10 (volts)

示例 16: 返回多个值

同时返回最小值和最大值。

def min_max(values):
    return min(values), max(values)

low, high = min_max([19.5, 22.0, 18.2])
print(f"low {low}, high {high}")

输出:

low 18.2, high 22.0

示例 17: *args 与 **kwargs

接收任意数量的位置参数和关键字参数。

def log_event(*parts, **fields):
    text = " ".join(str(p) for p in parts)
    extra = ", ".join(f"{k}={v}" for k, v in fields.items())
    print(text, "|", extra)

log_event("door", "opened", sensor="reed-1", room="kitchen")

输出:

door opened | sensor=reed-1, room=kitchen

示例 18: lambda、map 与 filter

用一行代码转换原始 ADC 数值。

raw = [512, 1023, 0, 768]
volts = list(map(lambda x: round(x * 3.3 / 1023, 2), raw))
nonzero = list(filter(lambda v: v > 0, volts))
print(volts)
print(nonzero)

输出:

[1.65, 3.3, 0.0, 2.48]
[1.65, 3.3, 2.48]

示例 19: 标准库:math 与 statistics

使用内置的数学和统计模块。

import math
import statistics

samples = [2.0, 4.0, 4.5, 3.5]
print(round(math.sqrt(16), 2), math.pi > 3.14)
print(statistics.mean(samples), statistics.median(samples))

输出:

4.0 True
3.5 3.75

示例 20: 递归

调用自身的函数,并带有终止条件。

def factorial(n):
    if n <= 1:
        return 1
    return n * factorial(n - 1)

print(factorial(5), factorial(10))

输出:

120 3628800

知识点 4: 设备与读数的数据结构

树莓派项目需要保存读数列表、设备设置的字典,以及不重复的名称集合。选对数据结构可以让代码更短、更快。

列表与推导式

列表是可以修改的有序序列。列表推导式可以用一行代码由旧列表生成新列表,例如把一组摄氏温度转换为华氏温度。

用字典保存带名称的值

字典把键映射到值,适合保存某台设备的设置,或一次带有多个字段的读数。对可能缺失的键使用 get(),用 sorted() 按顺序输出键。

元组、集合与切片

元组是固定的一组值。集合保存不重复的元素,并支持并集和交集。像 values[-2:] 这样的切片取出列表的一部分,[::-1] 则把列表反转。

示例 21: 列表

添加、插入和删除元素。

readings = [20.1, 20.4]
readings.append(20.9)
readings.insert(0, 19.8)
readings.remove(20.4)
print(readings, len(readings), readings[-1])

输出:

[19.8, 20.1, 20.9] 3 20.9

示例 22: 列表推导式

由旧列表生成新列表。

celsius = [0, 10, 20, 30]
fahrenheit = [c * 9 / 5 + 32 for c in celsius if c != 10]
print(fahrenheit)

输出:

[32.0, 68.0, 86.0]

示例 23: 元组与解包

不可更改的值的分组。

location = ("kitchen", 2, 3.5)
room, floor, height = location
print(room, floor, height)
try:
    location[0] = "garage"
except TypeError as e:
    print("tuples cannot change:", type(e).__name__)

输出:

kitchen 2 3.5
tuples cannot change: TypeError

示例 24: 字典

为设备保存带名称的值。

device = {"name": "node-1", "pin": 17, "online": True}
device["pin"] = 22
device["room"] = "garage"
print(device.get("name"), device.get("temp", "n/a"), len(device))

输出:

node-1 n/a 4

示例 25: 有序输出字典

按顺序打印每种事件的计数。

counts = {"motion": 4, "door": 1, "temp": 9}
for name in sorted(counts):
    print(f"{name:<8}{counts[name]:>3}")

输出:

door      1
motion    4
temp      9

示例 26: 集合

唯一的值以及集合运算。

seen = {"kitchen", "garage"}
seen.add("kitchen")
new = {"garage", "attic"}
print(len(seen), sorted(seen | new), sorted(seen & new))

输出:

2 ['attic', 'garage', 'kitchen'] ['garage']

示例 27: 切片

截取列表的一部分,并反转。

samples = [1, 2, 3, 4, 5, 6, 7]
print(samples[:3], samples[-2:], samples[::2])
print(samples[::-1])

输出:

[1, 2, 3] [6, 7] [1, 3, 5, 7]
[7, 6, 5, 4, 3, 2, 1]

示例 28: 字典组成的列表

筛选在线的设备列表。

devices = [
    {"id": "a1", "online": True},
    {"id": "b2", "online": False},
    {"id": "c3", "online": True},
]
online = [d["id"] for d in devices if d["online"]]
print("online:", online)

输出:

online: ['a1', 'c3']

知识点 5: 文本、文件与数据格式

传感器数据需要保存和共享。文本文件、CSV 和 JSON 是大多数物联网项目使用的格式,标准库可以读写这三种格式。

读写文件

用 with 代码块打开文件,它会自动关闭;处理文本时使用 encoding='utf-8'。pathlib 中的 Path 对象可以在任何操作系统上工作,并且便于拼接文件名。

CSV 与 JSON

csv 模块可以写出带表头的行,电子表格和数据工具都能识别。json 模块把字典转换为文本,用于网络服务和 MQTT 消息,也能再转换回来。

表格与日期格式化

像 {name:<10} 这样的宽度格式可以让终端中的列对齐。datetime 用 strftime 格式化时间戳,并用 timedelta 进行时间加减,因此可以按时间对读数分组。

示例 29: 格式化表格输出

用格式宽度对齐列。

rows = [("Temp", 27.456), ("Humidity", 55.0), ("Pressure", 1013.2)]
for name, value in rows:
    print(f"{name:<10}{value:>10.1f}")

输出:

Temp            27.5
Humidity        55.0
Pressure      1013.2

示例 30: 读写文本文件

把传感器数值保存到文件并读回。

from pathlib import Path

log = Path("sensor_log.txt")
with log.open("w", encoding="utf-8") as f:
    f.write("21.5\n22.0\n22.4\n")
with log.open("r", encoding="utf-8") as f:
    values = [float(line) for line in f]
log.unlink()
print("read", len(values), "values, last", values[-1])

输出:

read 3 values, last 22.4

示例 31: CSV 文件

写入并读取测量数据表。

import csv
from pathlib import Path

path = Path("readings.csv")
with path.open("w", newline="", encoding="utf-8") as f:
    writer = csv.writer(f)
    writer.writerow(["time", "temp_c"])
    writer.writerows([["08:00", 21.5], ["08:05", 21.9]])
with path.open(newline="", encoding="utf-8") as f:
    for row in csv.DictReader(f):
        print(row["time"], float(row["temp_c"]) + 1)
path.unlink()

输出:

08:00 22.5
08:05 22.9

示例 32: JSON

在数据和 JSON 文本之间转换。

import json

payload = {"device": "node-1", "readings": [21.5, 21.9], "ok": True}
text = json.dumps(payload, indent=2)
print(text)
print(json.loads(text)["readings"][1])

输出:

{
  "device": "node-1",
  "readings": [
    21.5,
    21.9
  ],
  "ok": true
}
21.9

示例 33: 路径

安全地处理文件名和文件夹。

from pathlib import PurePosixPath

path = PurePosixPath("/home/pi/projects/iot/config.json")
print(path.name, path.suffix, path.parent)
print(path.parent / "logs")

输出:

config.json .json /home/pi/projects/iot
/home/pi/projects/iot/logs

示例 34: 日期和时间

格式化日期时间,并进行加减。

from datetime import datetime, timedelta

start = datetime(2026, 1, 15, 8, 0)
later = start + timedelta(minutes=90)
print(start.strftime("%Y-%m-%d %H:%M"), "->", later.strftime("%H:%M"))

输出:

2026-01-15 08:00 -> 09:30

知识点 6: 错误处理、日志与计时

设备可能在无人看管的情况下运行数月。它必须能承受错误读数、记录发生的事情,并测量自身的运行时间。

用 try 与 except 处理错误

把可能失败的代码放进 try,捕获特定的异常,然后继续运行。无论是否发生错误,finally 都会执行,因此它是关闭输出或关闭文件的合适位置。

用 logging 代替 print

logging 模块以 INFO、WARNING 等级别记录消息,并可以选择显示哪个级别。当树莓派没有屏幕时,日志需要写入文件,这一点很重要。

防御性检查与计时

assert 在开发过程中尽早拒绝不可能的值。time.perf_counter 能精确测量经过的时间,帮助你在小型电脑上找到运行缓慢的代码。

示例 35: try 与 except

处理错误读数,程序不中断。

def parse_temperature(text):
    try:
        return float(text)
    except ValueError:
        print(f"bad reading: {text!r}")
        return None

print(parse_temperature("23.4"))
print(parse_temperature("23,4"))

输出:

23.4
bad reading: '23,4'
None

示例 36: finally 与清理

即使出错也始终执行清理。

from pathlib import Path

target = Path("demo_state.txt")
try:
    with target.open("w", encoding="utf-8") as f:
        f.write("on")
    print("saved")
finally:
    target.unlink(missing_ok=True)
    print("cleaned up")

输出:

saved
cleaned up

示例 37: 自定义异常

对不可能的传感器数值给出清晰的错误。

class SensorError(Exception):
    """Raised when a sensor gives an impossible value."""

def check(value):
    if value < -40 or value > 85:
        raise SensorError(f"value {value} is outside the sensor range")
    return value

try:
    check(120)
except SensorError as e:
    print("sensor error:", e)

输出:

sensor error: value 120 is outside the sensor range

示例 38: 日志记录

用带级别的日志代替 print。

import logging

logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
log = logging.getLogger("node-1")
log.info("started")
log.warning("battery at 18%")
log.debug("this line is hidden at INFO level")

输出:


示例 39: 用 assert 做防御性检查

尽早拒绝无效输入。

def set_pwm(duty):
    assert 0 <= duty <= 100, "duty must be 0 to 100"
    return duty

print(set_pwm(50))
try:
    set_pwm(150)
except AssertionError as e:
    print("rejected:", e)

输出:

50
rejected: duty must be 0 to 100

示例 40: 测量经过的时间

用精确的时钟为代码计时。

import time

start = time.perf_counter()
total = sum(i * i for i in range(200_000))
elapsed = time.perf_counter() - start
print("sum of squares:", total)
print("took measurable time:", elapsed >= 0)

输出:

sum of squares: 2666646666700000
took measurable time: True

知识点 7: 处理传感器数据

原始传感器数值通常有噪声,而且单位不对。这些示例把原始数字变成有用的读数:经过校准、平滑处理、检查异常值,并与阈值比较。

校准与单位换算

ADC 返回的是原始计数值。两个已知的参考点可以给出一条直线校准,从而把计数值换算成电压。每个传感器都不同,因此应把校准值与设备一起保存。

平滑与滞回

对最近几个读数做移动平均,可以去掉突发的尖峰。滞回使用两个阈值,一个用于开启,一个用于关闭,这样当数值停在某个界限附近时,继电器不会反复跳动。

统计与异常值

平均值、中位数和标准差描述一段时间内的读数。中位数不受极端值影响,因此在求平均之前,用它去除错误的尖峰是很好的做法。

示例 41: 校准原始 ADC 数值

用两个参考点把原始数值换算为电压。

RAW_MIN, RAW_MAX = 102, 921      # readings at 0 V and at full scale
VOLT_MAX = 3.3

def to_volts(raw):
    return (raw - RAW_MIN) * VOLT_MAX / (RAW_MAX - RAW_MIN)

for raw in (102, 512, 921):
    print(raw, "->", round(to_volts(raw), 3), "V")

输出:

102 -> 0.0 V
512 -> 1.652 V
921 -> 3.3 V

示例 42: 移动平均滤波

在一个窗口内平滑有噪声的读数。

from collections import deque

window = deque(maxlen=3)
def smooth(value):
    window.append(value)
    return sum(window) / len(window)

for reading in [20.0, 25.0, 21.0, 40.0, 22.0]:
    print(f"raw {reading:5.1f}  smoothed {smooth(reading):6.2f}")

输出:

raw  20.0  smoothed  20.00
raw  25.0  smoothed  22.50
raw  21.0  smoothed  22.00
raw  40.0  smoothed  28.67
raw  22.0  smoothed  27.67

示例 43: 风扇继电器的滞回

防止继电器频繁地开关。

def relay_state(temp, currently_on, on_at=24.0, off_at=22.0):
    if not currently_on and temp >= on_at:
        return True
    if currently_on and temp <= off_at:
        return False
    return currently_on

state = False
for t in [21.0, 23.0, 24.5, 23.5, 22.5, 21.9]:
    state = relay_state(t, state)
    print(t, "fan", "ON" if state else "OFF")

输出:

21.0 fan OFF
23.0 fan OFF
24.5 fan ON
23.5 fan ON
22.5 fan ON
21.9 fan OFF

示例 44: 气压单位与取整

把 hPa 转换为 inHg,并正确取整。

def hpa_to_inhg(hpa):
    return hpa * 0.02953

pressure = 1013.25
print(f"{pressure} hPa = {hpa_to_inhg(pressure):.2f} inHg")
print(round(2.5), round(3.5))

输出:

1013.25 hPa = 29.92 inHg
2 4

示例 45: 窗口内的统计

最近读数的平均值、离散程度和范围。

import statistics

window = [21.4, 21.6, 21.5, 21.9, 21.7]
print("mean", round(statistics.mean(window), 2))
print("stdev", round(statistics.pstdev(window), 3))
print("range", round(max(window) - min(window), 2))

输出:

mean 21.62
stdev 0.172
range 0.5

示例 46: 用中位数去除异常值

忽略明显偏离典型读数的数值。

import statistics

def remove_outliers(values, tolerance=3.0):
    med = statistics.median(values)
    return [v for v in values if abs(v - med) <= tolerance]

raw = [21.5, 21.7, 85.0, 21.6, -40.0, 21.8]
print(remove_outliers(raw))

输出:

[21.5, 21.7, 21.6, 21.8]

示例 47: 变化速率

数值每分钟上升了多少。

def rate_per_minute(prev_value, prev_minute, value, minute):
    if minute == prev_minute:
        return 0.0
    return (value - prev_value) / (minute - prev_minute)

print(round(rate_per_minute(20.0, 0, 21.2, 4), 2), "C per minute")

输出:

0.3 C per minute

示例 48: 把读数做成数据类

用一个小类描述一次测量。

from dataclasses import dataclass, asdict

@dataclass
class Reading:
    device: str
    temperature_c: float
    humidity_pct: float

r = Reading("node-1", 21.5, 48.0)
print(r)
print(asdict(r))

输出:

Reading(device='node-1', temperature_c=21.5, humidity_pct=48.0)
{'device': 'node-1', 'temperature_c': 21.5, 'humidity_pct': 48.0}

知识点 8: GPIO:控制 LED、按键、电机与继电器

40 针的排针让树莓派可以切换输出、读取输入。这些示例包括 LED、按键、PWM 调光、舵机、距离传感器和继电器。接通电源之前,务必对照引脚图检查接线。

输出、输入与上拉电阻

把引脚设为输出,可以通过电阻驱动 LED;设为输入,可以读取按键。上拉电阻使输入保持高电平,直到按键把它接到地,这样引脚就不会悬空。

PWM 与舵机

脉宽调制会快速地开关引脚,导通时间所占的比例决定亮度或舵机的位置。gpiozero 库隐藏了大部分的计时工作。

继电器、传感器与安全关闭

继电器用于切换功率较大的设备,例如水泵,因此树莓派只需驱动它小小的控制线圈。应始终在 finally 代码块中关闭输出,并调用 cleanup,这样即使程序崩溃,电机也不会一直运转。

示例 49: 让 LED 闪烁(GPIO)

从 Raspberry Pi 的引脚开关 LED。

import time
import RPi.GPIO as GPIO

LED_PIN = 17
GPIO.setmode(GPIO.BCM)
GPIO.setup(LED_PIN, GPIO.OUT)

try:
    for _ in range(5):
        GPIO.output(LED_PIN, GPIO.HIGH)
        time.sleep(0.5)
        GPIO.output(LED_PIN, GPIO.LOW)
        time.sleep(0.5)
finally:
    GPIO.cleanup()

此示例需要树莓派、网络服务或系统服务,因此只做了语法检查,不显示输出。

示例 50: 用上拉电阻读取按钮

用去抖延时安全地检测按键。

import time
import RPi.GPIO as GPIO

BUTTON_PIN = 27
GPIO.setmode(GPIO.BCM)
GPIO.setup(BUTTON_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP)

try:
    while True:
        if GPIO.input(BUTTON_PIN) == GPIO.LOW:   # pressed connects the pin to GND
            print("button pressed")
            time.sleep(0.2)                      # simple debounce
        time.sleep(0.01)
finally:
    GPIO.cleanup()

此示例需要树莓派、网络服务或系统服务,因此只做了语法检查,不显示输出。

示例 51: 用 PWM 调光

用脉宽调制改变 LED 的亮度。

import time
import RPi.GPIO as GPIO

GPIO.setmode(GPIO.BCM)
GPIO.setup(18, GPIO.OUT)
pwm = GPIO.PWM(18, 1000)          # 1 kHz
pwm.start(0)
try:
    for duty in range(0, 101, 10):
        pwm.ChangeDutyCycle(duty)
        time.sleep(0.1)
finally:
    pwm.stop()
    GPIO.cleanup()

此示例需要树莓派、网络服务或系统服务,因此只做了语法检查,不显示输出。

示例 52: 用 gpiozero 控制 LED

更简单、更安全的 GPIO 库。

from gpiozero import LED
from time import sleep

led = LED(17)
for _ in range(3):
    led.on()
    sleep(0.5)
    led.off()
    sleep(0.5)

此示例需要树莓派、网络服务或系统服务,因此只做了语法检查,不显示输出。

示例 53: 按钮回调

不需要轮询循环即可响应按下。

from gpiozero import Button
from signal import pause

button = Button(27)
button.when_pressed = lambda: print("pressed")
button.when_released = lambda: print("released")
pause()

此示例需要树莓派、网络服务或系统服务,因此只做了语法检查,不显示输出。

示例 54: 舵机控制

把舵机转到设定的位置。

from gpiozero import Servo
from time import sleep

servo = Servo(12)
for position in (-1, 0, 1, 0):   # -1 = left, 0 = centre, 1 = right
    servo.value = position
    sleep(1)

此示例需要树莓派、网络服务或系统服务,因此只做了语法检查,不显示输出。

示例 55: 超声波测距传感器

用 HC-SR04 传感器测量距离。

import time
import RPi.GPIO as GPIO

TRIG, ECHO = 23, 24
GPIO.setmode(GPIO.BCM)
GPIO.setup(TRIG, GPIO.OUT)
GPIO.setup(ECHO, GPIO.IN)

def distance_cm():
    GPIO.output(TRIG, False)
    time.sleep(0.05)
    GPIO.output(TRIG, True)
    time.sleep(0.00001)
    GPIO.output(TRIG, False)
    while GPIO.input(ECHO) == 0:
        start = time.time()
    while GPIO.input(ECHO) == 1:
        end = time.time()
    return (end - start) * 34300 / 2   # speed of sound, there and back

print(round(distance_cm(), 1), "cm")
GPIO.cleanup()

此示例需要树莓派、网络服务或系统服务,因此只做了语法检查,不显示输出。

示例 56: 默认安全的继电器

安全地切换水泵,并保证结束时关闭。

from gpiozero import OutputDevice

relay = OutputDevice(22, active_high=True, initial_value=False)
relay.on()     # power the pump
relay.off()    # always end with the relay off

此示例需要树莓派、网络服务或系统服务,因此只做了语法检查,不显示输出。

示例 57: 蜂鸣器音调

在蜂鸣器上播放简单的音调。

from gpiozero import TonalBuzzer
from time import sleep

buzzer = TonalBuzzer(26)
for note in (0.0, 0.5, 1.0):
    buzzer.play(note)
    sleep(0.3)
buzzer.stop()

此示例需要树莓派、网络服务或系统服务,因此只做了语法检查,不显示输出。

示例 58: 安全关闭 GPIO

程序退出前让输出处于安全状态。

import RPi.GPIO as GPIO

GPIO.setmode(GPIO.BCM)
GPIO.setup(17, GPIO.OUT)
try:
    GPIO.output(17, GPIO.HIGH)
    raise RuntimeError("simulated fault")
except RuntimeError as e:
    print("error:", e)
finally:
    GPIO.output(17, GPIO.LOW)   # make the output safe before cleanup
    GPIO.cleanup()

此示例需要树莓派、网络服务或系统服务,因此只做了语法检查,不显示输出。

知识点 9: 通过 I2C、SPI、1-Wire 与摄像头连接的传感器

许多传感器通过总线与树莓派通信。I2C 使用两根线和一个地址,SPI 使用四根线和片选,1-Wire 只使用一根数据线。摄像头模块有自己的库,是一种特殊情况。

I2C:扫描与读取寄存器

每个 I2C 设备都有一个地址。先扫描总线,找出连接了什么。读取芯片 ID 寄存器来确认传感器,然后写入控制寄存器,再读取数据字节。

SPI 与模拟输入

树莓派没有模拟输入,因此需要像 MCP3008 这样的 ADC 通过 SPI 转换电压。发送通道号,然后从返回的字节中组合出 10 位的数值。

1-Wire、DHT 传感器与摄像头

1-Wire 上的 DS18B20 会以文件的形式出现在 /sys/bus/w1 中;需要先在 raspi-config 中启用该接口。DHT 传感器有时会读取失败,因此应当重试。摄像头模块使用 picamera2 拍摄静态图片和帧。

示例 59: 扫描 I2C 总线

查找连接了哪些传感器。

from smbus2 import SMBus

with SMBus(1) as bus:
    found = []
    for address in range(0x03, 0x78):
        try:
            bus.read_byte(address)
            found.append(hex(address))
        except OSError:
            pass
print("devices:", found)

此示例需要树莓派、网络服务或系统服务,因此只做了语法检查,不显示输出。

示例 60: 读取 BMP280 气压传感器

检查芯片 ID,并通过 I2C 读取原始气压字节。

from smbus2 import SMBus

ADDRESS = 0x76
with SMBus(1) as bus:
    chip_id = bus.read_byte_data(ADDRESS, 0xD0)
    print("chip id", hex(chip_id), "(expect 0x58)")
    bus.write_byte_data(ADDRESS, 0xF4, 0b00100111)   # normal mode, oversampling x1
    raw = bus.read_i2c_block_data(ADDRESS, 0xF7, 6)
print("raw bytes", raw)

此示例需要树莓派、网络服务或系统服务,因此只做了语法检查,不显示输出。

示例 61: DHT22 温度与湿度

读取 DHT22,未就绪时重试。

import time
import board
import adafruit_dht

dht = adafruit_dht.DHT22(board.D4)
for _ in range(5):
    try:
        print(f"{dht.temperature:.1f} C  {dht.humidity:.0f} %")
    except RuntimeError as e:
        print("sensor not ready:", e)   # DHT sensors fail sometimes; just retry
    time.sleep(2)

此示例需要树莓派、网络服务或系统服务,因此只做了语法检查,不显示输出。

示例 62: 用 MCP3008 进行模拟输入

通过 SPI 读取模拟电压。

import spidev

spi = spidev.SpiDev()
spi.open(0, 0)                  # bus 0, device 0
spi.max_speed_hz = 1_350_000

def read_channel(channel):
    cmd = [1, (8 + channel) << 4, 0]
    reply = spi.xfer2(cmd)
    return ((reply[1] & 3) << 8) | reply[2]   # 10-bit value, 0 to 1023

print("channel 0 raw:", read_channel(0))
spi.close()

此示例需要树莓派、网络服务或系统服务,因此只做了语法检查,不显示输出。

示例 63: 通过 1-Wire 读取 DS18B20 温度

从内核读取防水型温度传感器。

from pathlib import Path
import glob

device_dirs = glob.glob("/sys/bus/w1/devices/28-*")
if not device_dirs:
    raise SystemExit("no DS18B20 found: enable 1-Wire in raspi-config")
lines = (Path(device_dirs[0]) / "w1_slave").read_text().splitlines()
if lines[0].endswith("YES"):
    milli = int(lines[1].split("t=")[1])
    print(f"{milli / 1000:.3f} C")

此示例需要树莓派、网络服务或系统服务,因此只做了语法检查,不显示输出。

示例 64: 不稳定传感器的重试

放弃之前再读一次。

import time

def read_with_retries(read_fn, attempts=3, delay=1.0):
    for attempt in range(1, attempts + 1):
        try:
            return read_fn()
        except OSError as e:
            print(f"attempt {attempt} failed: {e}")
            time.sleep(delay)
    raise RuntimeError("sensor unavailable after retries")

此示例需要树莓派、网络服务或系统服务,因此只做了语法检查,不显示输出。

示例 65: 把校准值保存到 JSON

将校准值保存在文件中,并能重新加载。

import json
from pathlib import Path

CONFIG = Path("calibration.json")
DEFAULTS = {"offset_c": 0.0, "scale": 1.0}

def load():
    if CONFIG.exists():
        return {**DEFAULTS, **json.loads(CONFIG.read_text(encoding="utf-8"))}
    return dict(DEFAULTS)

def save(cfg):
    CONFIG.write_text(json.dumps(cfg, indent=2), encoding="utf-8")

cfg = load()
cfg["offset_c"] = -0.4
save(cfg)
print("saved:", load())
CONFIG.unlink()

输出:

saved: {'offset_c': -0.4, 'scale': 1.0}

示例 66: 拍摄静态照片

用树莓派摄像头模块拍照。

from picamera2 import Picamera2
from time import sleep

cam = Picamera2()
cam.configure(cam.create_still_configuration())
cam.start()
sleep(2)                      # let the auto-exposure settle
cam.capture_file("photo.jpg")
cam.stop()

此示例需要树莓派、网络服务或系统服务,因此只做了语法检查,不显示输出。

示例 67: 根据摄像头画面检测运动

比较相邻画面来发现移动。

import numpy as np
from picamera2 import Picamera2

cam = Picamera2()
cam.configure(cam.create_preview_configuration(main={"size": (320, 240), "format": "YUV420"}))
cam.start()
previous = None
for _ in range(20):
    frame = cam.capture_array()[:240].astype(np.int16)
    if previous is not None and np.mean(np.abs(frame - previous)) > 10:
        print("motion")
    previous = frame
cam.stop()

此示例需要树莓派、网络服务或系统服务,因此只做了语法检查,不显示输出。

知识点 10: 使用套接字与 HTTP 进行网络通信

物联网设备必须与其他电脑通信。套接字是底层基础,HTTP 是网络服务与 API 的工作方式,健壮的代码要处理超时、重试和离线的情况。

TCP 与 UDP 套接字

TCP 套接字是可靠的连接,UDP 则在没有连接的情况下发送单条消息。绑定到端口 0 时,操作系统会选择一个空闲端口,这样示例之间就不会冲突。

带超时的 HTTP 请求

requests 用一行代码就能完成 HTTP 调用。务必设置超时,否则一个无响应的网络会让程序卡住。raise_for_status 会把错误响应转换为可以处理的异常。

重试、退避与离线队列

对失败的请求使用指数退避重试,每次等待的时间更长,以免压垮繁忙的服务器。网络中断时,把读数保存在队列中,等网络恢复后再发送。

示例 68: TCP 回显服务器与客户端

向服务器发送字节并读取回复。

import socket
import threading

def serve(server):
    conn, _ = server.accept()
    with conn:
        data = conn.recv(1024)
        conn.sendall(b"echo: " + data)

server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(("127.0.0.1", 0))            # port 0: let the OS choose a free port
server.listen(1)
port = server.getsockname()[1]
thread = threading.Thread(target=serve, args=(server,))
thread.start()

with socket.create_connection(("127.0.0.1", port)) as client:
    client.sendall(b"temp=21.5")
    print(client.recv(1024).decode())
thread.join()
server.close()

输出:

echo: temp=21.5

示例 69: 环回地址上的 UDP 消息

无需建立连接即可发送数据报。

import socket

receiver = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
receiver.bind(("127.0.0.1", 0))
addr = receiver.getsockname()

sender = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sender.sendto(b"humidity=48", addr)
data, _ = receiver.recvfrom(1024)
print("received:", data.decode())
sender.close()
receiver.close()

输出:

received: humidity=48

示例 70: 向 Web 服务 POST 读数

以 JSON 通过 HTTPS 发送传感器读数。

import requests

reading = {"device": "node-1", "temperature_c": 21.5}
response = requests.post(
    "https://api.example.com/readings",
    json=reading,
    timeout=5,
)
response.raise_for_status()
print("server said:", response.status_code)

此示例需要树莓派、网络服务或系统服务,因此只做了语法检查,不显示输出。

示例 71: 带超时的 API JSON 获取

获取数据并处理失败,不会卡住。

import requests

try:
    r = requests.get("https://api.example.com/forecast", params={"city": "Bangkok"}, timeout=5)
    r.raise_for_status()
    data = r.json()
    print(data.get("temp_c"))
except requests.RequestException as e:
    print("request failed:", e)

此示例需要树莓派、网络服务或系统服务,因此只做了语法检查,不显示输出。

示例 72: 指数退避重试

每次失败后等待更长时间。

import random
import time

def with_backoff(fn, tries=4, base=0.1):
    for n in range(tries):
        try:
            return fn()
        except ConnectionError:
            wait = base * (2 ** n)
            print(f"try {n + 1} failed, waiting {wait:.1f}s")
            time.sleep(wait)
    raise ConnectionError("gave up")

random.seed(3)
calls = {"n": 0}
def flaky():
    calls["n"] += 1
    if calls["n"] < 3:
        raise ConnectionError("network down")
    return "uploaded"

print(with_backoff(flaky))

输出:

try 1 failed, waiting 0.1s
try 2 failed, waiting 0.2s
uploaded

示例 73: 用 urllib 发送 JSON

只用标准库发送请求。

import json
import urllib.request

body = json.dumps({"device": "node-1", "humidity_pct": 48}).encode()
req = urllib.request.Request(
    "https://api.example.com/readings",
    data=body,
    headers={"Content-Type": "application/json"},
    method="POST",
)
with urllib.request.urlopen(req, timeout=5) as resp:
    print("status", resp.status)

此示例需要树莓派、网络服务或系统服务,因此只做了语法检查,不显示输出。

示例 74: 状态网页服务器

在树莓派上提供一个小型状态页面。

from http.server import BaseHTTPRequestHandler, HTTPServer
import json

class Status(BaseHTTPRequestHandler):
    def do_GET(self):
        body = json.dumps({"status": "ok", "uptime_s": 42}).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(body)

HTTPServer(("0.0.0.0", 8000), Status).serve_forever()

此示例需要树莓派、网络服务或系统服务,因此只做了语法检查,不显示输出。

示例 75: 发送前检查网络

网络断开时先把数据排队。

import socket

def internet_ok(host="1.1.1.1", port=53, timeout=3):
    try:
        with socket.create_connection((host, port), timeout=timeout):
            return True
    except OSError:
        return False

print("online" if internet_ok() else "offline, queue the data")

此示例需要树莓派、网络服务或系统服务,因此只做了语法检查,不显示输出。

知识点 11: MQTT:发布与订阅

MQTT 是物联网的标准消息协议。设备把读数发布到主题,代理服务器负责投递,任意数量的订阅者都能收到。它足够轻量,适合小型设备,也能容忍不稳定的网络。

主题、发布与订阅

主题是类似 home/kitchen/temperature 的路径。发布通过代理服务器把消息发送到某个主题。订阅可以使用通配符:+ 匹配一层,# 匹配其下的所有内容。

服务质量与保留消息

QoS 0 是发后即忘,QoS 1 保证至少送达一次,QoS 2 保证恰好送达一次。保留消息由代理服务器存储,因此新的订阅者会立即收到最后一个值。

遗嘱、TLS 与发现

当设备意外断开时,代理服务器会发布遗嘱消息,这可以用来发现离线的设备。对于互联网上的代理服务器,应使用 TLS,并从环境变量中读取密码。Home Assistant 可以通过发送到特定主题的发现消息找到传感器。

示例 76: MQTT:发布遥测数据

把读数发送到主题上的代理。

import json
import time
import paho.mqtt.client as mqtt

client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="node-1")
client.connect("broker.local", 1883, keepalive=60)
client.loop_start()

payload = {"temp_c": 21.5, "ts": int(time.time())}
client.publish("home/kitchen/temperature", json.dumps(payload), qos=1)
time.sleep(1)
client.loop_stop()
client.disconnect()

此示例需要树莓派、网络服务或系统服务,因此只做了语法检查,不显示输出。

示例 77: MQTT:订阅消息

用通配符接收多台设备的数据。

import paho.mqtt.client as mqtt

def on_connect(client, userdata, flags, reason_code, properties):
    client.subscribe("home/+/temperature")

def on_message(client, userdata, msg):
    print(msg.topic, "->", msg.payload.decode())

client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
client.on_connect = on_connect
client.on_message = on_message
client.connect("broker.local", 1883)
client.loop_forever()

此示例需要树莓派、网络服务或系统服务,因此只做了语法检查,不显示输出。

示例 78: MQTT:QoS 与保留消息

选择传输保证,并保留最后一个值。

import paho.mqtt.client as mqtt

client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
client.connect("broker.local", 1883)
client.loop_start()

# QoS 0 = at most once, 1 = at least once, 2 = exactly once
client.publish("home/door/state", "closed", qos=1, retain=True)   # last value is kept
client.loop_stop()
client.disconnect()

此示例需要树莓派、网络服务或系统服务,因此只做了语法检查,不显示输出。

示例 79: MQTT:遗嘱消息检测离线

设备掉线时通知所有人。

import paho.mqtt.client as mqtt

client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="node-1")
client.will_set("home/node-1/status", payload="offline", qos=1, retain=True)
client.publish("home/node-1/status", "online", qos=1, retain=True)   # set on connect
client.connect("broker.local", 1883)
client.loop_forever()

此示例需要树莓派、网络服务或系统服务,因此只做了语法检查,不显示输出。

示例 80: 基于 TLS 的 MQTT

加密与代理的连接。

import ssl
import paho.mqtt.client as mqtt

client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
client.tls_set(ca_certs="/etc/ssl/certs/ca-certificates.crt",
               tls_version=ssl.PROTOCOL_TLS_CLIENT)
client.username_pw_set("node-1", "use-a-secret-from-env-not-code")
client.connect("broker.example.com", 8883)
client.loop_forever()

此示例需要树莓派、网络服务或系统服务,因此只做了语法检查,不显示输出。

示例 81: Home Assistant 发现消息

发布传感器,使 Home Assistant 自动找到它。

import json

config = {
    "name": "Kitchen temperature",
    "unique_id": "node-1_kitchen_temp",
    "state_topic": "home/kitchen/temperature",
    "unit_of_measurement": "°C",
    "device_class": "temperature",
    "value_template": "{{ value_json.temp_c }}",
}
topic = "homeassistant/sensor/node-1_kitchen_temp/config"
print(topic)
print(json.dumps(config, ensure_ascii=False, indent=2))

输出:

homeassistant/sensor/node-1_kitchen_temp/config
{
  "name": "Kitchen temperature",
  "unique_id": "node-1_kitchen_temp",
  "state_topic": "home/kitchen/temperature",
  "unit_of_measurement": "°C",
  "device_class": "temperature",
  "value_template": "{{ value_json.temp_c }}"
}

示例 82: MQTT 主题通配符

检查订阅匹配哪些主题。

def topic_matches(pattern, topic):
    p_parts = pattern.split("/")
    t_parts = topic.split("/")
    for i, part in enumerate(p_parts):
        if part == "#":
            return True
        if i >= len(t_parts):
            return False
        if part != "+" and part != t_parts[i]:
            return False
    return len(p_parts) == len(t_parts)

tests = [
    ("home/+/temperature", "home/kitchen/temperature", True),
    ("home/#", "home/kitchen/humidity", True),
    ("home/+/temperature", "home/kitchen/humidity", False),
]
for pattern, topic, expected in tests:
    matched = topic_matches(pattern, topic)
    print(pattern, topic, "match" if matched else "no match", "(as expected)" if matched == expected else "(UNEXPECTED)")

输出:

home/+/temperature home/kitchen/temperature match (as expected)
home/# home/kitchen/humidity match (as expected)
home/+/temperature home/kitchen/humidity no match (as expected)

知识点 12: 用 Flask 构建网页仪表盘与 API

Flask 是一个小型 Python 网络框架。只需几行代码,它就能提供状态页面、返回 JSON,或接收其他设备发来的读数,让你可以在手机或浏览器中查看树莓派。

路由与响应

像 @app.get('/') 这样的路由装饰器把 URL 与函数关联起来。函数可以返回文本、页面,或用 jsonify 返回 JSON。使用 0.0.0.0 地址可以接受来自局域网的连接。

带验证的数据接收

POST 路由从请求中读取 JSON。在保存任何内容之前,先检查类型和范围;数据有误时返回 400 状态码,让发送方知道该修改什么。

后台线程与仪表盘

后台线程在网络服务器响应请求的同时读取传感器。共享的数据应保持简单且安全。一个小的 HTML 模板可以把最新读数呈现为网络中任何人都能查看的页面。

示例 83: Flask 的 hello 接口

在树莓派上提供第一个网页路由。

from flask import Flask

app = Flask(__name__)

@app.get("/")
def hello():
    return "Raspberry Pi is alive"

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)

此示例需要树莓派、网络服务或系统服务,因此只做了语法检查,不显示输出。

示例 84: Flask 的 JSON 接口

以 JSON 返回最新读数。

from flask import Flask, jsonify

app = Flask(__name__)
latest = {"temp_c": 21.5, "humidity_pct": 48}

@app.get("/api/latest")
def get_latest():
    return jsonify(latest)

app.run(host="0.0.0.0", port=5000)

此示例需要树莓派、网络服务或系统服务,因此只做了语法检查,不显示输出。

示例 85: 带校验的 Flask POST

接收读数,并用错误码拒绝无效数值。

from flask import Flask, request, jsonify

app = Flask(__name__)
readings = []

@app.post("/api/readings")
def add_reading():
    data = request.get_json(silent=True) or {}
    temp = data.get("temp_c")
    if not isinstance(temp, (int, float)) or not -40 <= temp <= 85:
        return jsonify(error="temp_c must be a number from -40 to 85"), 400
    readings.append(temp)
    return jsonify(saved=len(readings)), 201

app.run(port=5000)

此示例需要树莓派、网络服务或系统服务,因此只做了语法检查,不显示输出。

示例 86: 一个小型 HTML 仪表盘

渲染显示最新温度的页面。

from flask import Flask, render_template_string

app = Flask(__name__)
PAGE = """
<h1>Kitchen</h1>
<p>Temperature: {{ temp }} °C</p>
"""

@app.get("/")
def home():
    return render_template_string(PAGE, temp=21.5)

app.run(port=5000)

此示例需要树莓派、网络服务或系统服务,因此只做了语法检查,不显示输出。

示例 87: Flask 加后台传感器线程

Web 服务器运行时持续读取传感器。

import threading
import time
from flask import Flask, jsonify

app = Flask(__name__)
state = {"temp_c": None}

def sensor_loop():
    while True:
        state["temp_c"] = 21.5          # replace with the real sensor read
        time.sleep(5)

threading.Thread(target=sensor_loop, daemon=True).start()

@app.get("/api/temp")
def temp():
    return jsonify(state)

app.run(port=5000)

此示例需要树莓派、网络服务或系统服务,因此只做了语法检查,不显示输出。

知识点 13: 保存与汇总读数

一台要记录一整年数据的设备,需要简单且不会占满 SD 卡的存储。SQLite 是随 Python 附带的单文件数据库,CSV 文件则适合简单的历史记录。

SQLite 表与插入

SQLite 把表保存在一个文件中。INSERT 语句中使用 ? 占位符,可以防止 SQL 注入,并自动处理引号。写入之后调用 commit,数据才会被保存。

聚合与时间分桶

GROUP BY 配合 AVG 和 COUNT 可以按设备汇总读数。把时间戳按分钟分组,例如取 ISO 时间的前五个字符,就能得到简单的每分钟平均值。

数据保留与仅追加日志

按计划删除旧数据,让存储保持较小。仅追加的 CSV 文件每次读数添加一行,表头只写一次,简单又可靠。

示例 88: SQLite:建表与插入

把读数保存到数据库文件中。

import sqlite3

conn = sqlite3.connect(":memory:")   # use "readings.db" to keep data on the Pi
conn.execute("""CREATE TABLE readings (
    ts TEXT NOT NULL, device TEXT NOT NULL, temp_c REAL NOT NULL)""")
rows = [("2026-01-15T08:00", "node-1", 21.5), ("2026-01-15T08:05", "node-1", 21.9)]
conn.executemany("INSERT INTO readings VALUES (?, ?, ?)", rows)
conn.commit()
print(conn.execute("SELECT COUNT(*) FROM readings").fetchone()[0], "rows stored")
conn.close()

输出:

2 rows stored

示例 89: SQLite:聚合查询

按设备计算平均值和数量。

import sqlite3

conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE readings (device TEXT, temp_c REAL)")
conn.executemany("INSERT INTO readings VALUES (?, ?)",
                 [("node-1", 21.5), ("node-1", 22.5), ("node-2", 19.0)])
for device, avg, n in conn.execute(
        "SELECT device, AVG(temp_c), COUNT(*) FROM readings GROUP BY device ORDER BY device"):
    print(f"{device}: average {avg:.2f} from {n} readings")
conn.close()

输出:

node-1: average 22.00 from 2 readings
node-2: average 19.00 from 1 readings

示例 90: CSV 历史记录器

把读数追加到带表头的历史文件中。

import csv
from pathlib import Path

LOG = Path("sensor_history.csv")
FIELDS = ["ts", "temp_c"]

def log_reading(ts, temp_c):
    new_file = not LOG.exists()
    with LOG.open("a", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=FIELDS)
        if new_file:
            writer.writeheader()
        writer.writerow({"ts": ts, "temp_c": temp_c})

log_reading("08:00", 21.5)
log_reading("08:05", 21.9)
print(LOG.read_text(encoding="utf-8"))
LOG.unlink()

输出:

ts,temp_c
08:00,21.5
08:05,21.9

示例 91: 按分钟分组读数

把读数合并为每分钟的平均值。

from collections import defaultdict
from statistics import mean

readings = [("08:00:10", 21.4), ("08:00:40", 21.6), ("08:01:05", 21.9)]
buckets = defaultdict(list)
for ts, value in readings:
    buckets[ts[:5]].append(value)     # keep HH:MM
for minute in sorted(buckets):
    print(minute, round(mean(buckets[minute]), 2))

输出:

08:00 21.5
08:01 21.9

示例 92: 数据保留

删除旧数据,让树莓派的存储保持较小。

import sqlite3

conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE readings (day INTEGER, temp_c REAL)")
conn.executemany("INSERT INTO readings VALUES (?, ?)", [(d, 20.0 + d / 10) for d in range(1, 11)])
keep_from = 6
deleted = conn.execute("DELETE FROM readings WHERE day < ?", (keep_from,)).rowcount
conn.commit()
remaining = conn.execute("SELECT COUNT(*) FROM readings").fetchone()[0]
print("deleted", deleted, "kept", remaining)
conn.close()

输出:

deleted 5 kept 5

知识点 14: 作为服务运行与配置

项目只有在断电后能自行启动并持续运行时,才真正有用。systemd 在开机时运行服务,cron 按计划运行任务,环境变量则把设置放在代码之外。

systemd 服务

单元文件描述了如何运行程序、工作目录、运行用户,以及失败时的处理方式(Restart=on-failure)。启用服务后,它会在开机时启动,并可以用 journalctl 查看日志。

用 cron 定时任务

crontab 中的一行可以在固定时间运行命令,例如每五分钟运行一次。把输出重定向到日志文件,你离开时发生的事情也能查到。

从环境变量读取配置

设备 ID、上报间隔和代理服务器地址等设置,可以从环境变量中读取,并提供安全的默认值。你无需修改代码,只需在服务文件中更改它们。

示例 93: 开机启动的 systemd 服务

树莓派开机时自动运行记录程序。

# /etc/systemd/system/sensor-logger.service
[Unit]
Description=Sensor logger
After=network-online.target
Wants=network-online.target

[Service]
ExecStart=/usr/bin/python3 /home/pi/iot/logger.py
WorkingDirectory=/home/pi/iot
Restart=on-failure
RestartSec=5
User=pi

[Install]
WantedBy=multi-user.target

# enable and start it:
# sudo systemctl daemon-reload
# sudo systemctl enable --now sensor-logger.service
# journalctl -u sensor-logger -f

此示例需要树莓派、网络服务或系统服务,因此只做了语法检查,不显示输出。

示例 94: 用 cron 定时任务

每五分钟运行一次脚本。

# edit the schedule with: crontab -e
# every 5 minutes, record the time and the readings file size
*/5 * * * * /usr/bin/python3 /home/pi/iot/snapshot.py >> /home/pi/iot/cron.log 2>&1
# every day at 02:30, remove logs older than 30 days
30 2 * * * find /home/pi/iot/logs -name "*.csv" -mtime +30 -delete

此示例需要树莓派、网络服务或系统服务,因此只做了语法检查,不显示输出。

示例 95: 看门狗的心跳文件

写入时间戳,让看门狗知道程序仍在运行。

import time
from pathlib import Path

HEARTBEAT = Path("heartbeat.txt")

def beat(count):
    HEARTBEAT.write_text(f"{count} {time.time():.0f}", encoding="utf-8")

for i in range(3):
    beat(i)
    print("beat", i, "written")
    time.sleep(0.05)
print("last:", HEARTBEAT.read_text(encoding="utf-8").split()[0])
HEARTBEAT.unlink()

输出:

beat 0 written
beat 1 written
beat 2 written
last: 2

示例 96: 从环境变量读取配置

以安全的默认值读取设置。

import os

def get_config():
    return {
        "device_id": os.environ.get("DEVICE_ID", "node-1"),
        "interval_s": int(os.environ.get("INTERVAL_S", "60")),
        "broker": os.environ.get("MQTT_HOST", "localhost"),
    }

print(get_config())

输出:

{'device_id': 'node-1', 'interval_s': 60, 'broker': 'localhost'}

知识点 15: 日志、安全与完整的流水线

最后的主题把所有内容整合起来:滚动日志防止 SD 卡被占满,秘密信息不写入代码,对接收到的数据进行验证,以及从传感器到报警的完整流水线。

滚动日志与心跳

滚动文件处理器限制每个日志文件的大小,并保留几个旧文件。带时间戳的心跳文件可以让看门狗检查程序是否仍在运行。

秘密信息与验证

把密码和令牌保存在环境变量中,绝不打印出来。在数据进入存储或报警之前,检查每个传入值的类型和范围,因为传感器和网络可能发送任何内容。

组装流水线

流水线读取传感器,用窗口对数值进行平滑,与阈值比较,设置报警标志,并保存每一步的结果。每个环节都很小且可以测试,这正是专业物联网代码的组织方式。

示例 97: 滚动日志文件

防止日志占满 SD 卡。

import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path

path = Path("iot.log")
handler = RotatingFileHandler(path, maxBytes=200, backupCount=2, encoding="utf-8")
log = logging.getLogger("iot")
log.setLevel(logging.INFO)
log.addHandler(handler)
for i in range(20):
    log.info("reading %d: temp=21.%d", i, i % 10)
handler.close()
print("files:", sorted(p.name for p in Path(".").glob("iot.log*")))
for p in Path(".").glob("iot.log*"):
    p.unlink()

输出:

files: ['iot.log', 'iot.log.1', 'iot.log.2']

示例 98: 从环境变量读取机密

从环境加载密码,且绝不打印出来。

import os

def require_secret(name):
    value = os.environ.get(name)
    if not value:
        raise SystemExit(f"set {name} in the environment (do not write it in the code)")
    return value

def mask(secret):
    return secret[:2] + "*" * (len(secret) - 2)

os.environ["MQTT_PASSWORD"] = "example-secret"   # demo only
token = require_secret("MQTT_PASSWORD")
print("loaded secret:", mask(token))

输出:

loaded secret: ex************

示例 99: 验证传入的传感器数据

存储前检查类型和范围。

from dataclasses import dataclass

@dataclass
class Sample:
    device: str
    temp_c: float

def validate(raw):
    errors = []
    if not isinstance(raw.get("device"), str) or not raw["device"]:
        errors.append("device is required")
    temp = raw.get("temp_c")
    if not isinstance(temp, (int, float)):
        errors.append("temp_c must be a number")
    elif not -40 <= temp <= 85:
        errors.append("temp_c out of sensor range")
    if errors:
        return None, errors
    return Sample(raw["device"], float(temp)), []

print(validate({"device": "node-1", "temp_c": 21.5}))
print(validate({"device": "", "temp_c": 300}))

输出:

(Sample(device='node-1', temp_c=21.5), [])
(None, ['device is required', 'temp_c out of sensor range'])

示例 100: 一个完整的小型流水线

读取、平滑、判断报警并保存结果。

import sqlite3
from collections import deque

def fake_sensor(n):
    base = [21.0, 21.2, 21.1, 35.0, 21.3, 21.4]     # 35.0 is a spike
    return base[n % len(base)]

window = deque(maxlen=3)
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE readings (tick INTEGER, raw REAL, smooth REAL, alarm INTEGER)")

for tick in range(6):
    raw = fake_sensor(tick)
    window.append(raw)
    smooth = sum(window) / len(window)
    alarm = 1 if smooth > 25 else 0
    conn.execute("INSERT INTO readings VALUES (?, ?, ?, ?)", (tick, raw, smooth, alarm))
    print(f"tick {tick}: raw {raw:5.1f} smooth {smooth:5.2f} alarm {alarm}")

alarms = conn.execute("SELECT COUNT(*) FROM readings WHERE alarm = 1").fetchone()[0]
print("alarm ticks:", alarms)
conn.close()

输出:

tick 0: raw  21.0 smooth 21.00 alarm 0
tick 1: raw  21.2 smooth 21.10 alarm 0
tick 2: raw  21.1 smooth 21.10 alarm 0
tick 3: raw  35.0 smooth 25.77 alarm 1
tick 4: raw  21.3 smooth 25.80 alarm 1
tick 5: raw  21.4 smooth 25.90 alarm 1
alarm ticks: 3

练习项目:房间监测器

构建一个运用各节思路的监测器。先自己编写,然后与示例比较。在连接硬件之前,先在电脑上测试纯 Python 的部分。

1. 编写一个函数,用两个校准点把原始读数换算为温度,并用三个数值进行测试。

2. 用最近五个值的移动平均平滑读数,并为阈值为 24 和 22 的风扇加入滞回。

3. 把每个读数保存到 SQLite 表中,读取十个数值后打印每分钟的平均值。

4. 每 60 秒把最新读数发布到 MQTT 主题;代理服务器离线时,把读数放入队列。

5. 创建一个 systemd 服务,使监测器在开机时启动,并用 journalctl 读取它的日志。

下一步

接下来,学习 Home Assistant 或 Node-RED 等物联网平台,加入安全的设备身份和无线升级,并学习面向微控制器的 Raspberry Pi Pico。先用你自己的接线测试每个硬件示例,并查阅 Raspberry Pi 官方文档,确认你所用型号的引脚编号。

总结

当树莓派能够可靠地读取传感器、处理数据、保存数据、通过网络发送数据,并且无需人工干预地持续运行时,它就成为了真正的物联网设备。通过 100 个示例,本文涵盖了 Python 基础、判断与循环、函数、数据结构、文件与格式、错误处理与日志、传感器数据处理、GPIO、I2C、SPI、1-Wire 与摄像头、套接字与 HTTP、MQTT、Flask、SQLite、systemd 与 cron,以及完整的流水线。在实验中构建房间监测器,每次只改动一处,以了解各部分的行为。