Умна сграда: датчици и климат на GX10
Как се събират показания от датчици (температура, влажност, CO₂, брой хора) в културен и спортен комплекс: през MQTT към TimescaleDB, със защитен достъп и ограничено съхранение. Върху данните правим предложения за климата и даваме на локален модел да обясни аномалиите с обикновени думи. Моделът не управлява нищо. Всички датчици и числа в урока са измислени.
paho-mqtt 2.x изисква версия на обратните извиквания (mqtt.Client(CallbackAPIVersion.VERSION2)) — старият mqtt.Client() вече не работи; времето е с часова зона вместо utcnow(); всяко съобщение се проверява и лошите се прескачат; абонаментът е в on_connect; връзката към базата идва от променлива на средата. Старият съвет „пази 90 дни“ беше само бележка; сега е реална политика за съхранение (add_retention_policy) и часов агрегат. Mosquitto 2.x не пуска анонимни връзки, ако е зададен слушател — затова даваме конфигурация с парола. Махнахме: „Llama 14B“ (такъв модел няма; ползваме qwen2.5:14b), „60+ сензора“, правилото „45 минути преди събитие“, адрес на вътрешна система за управление на сградата и твърдението, че CO₂ над 1000 ppm е „норма“ — нямаме източник; праговете в урока са примерни. Аномалиите се смятат с абсолютни отклонения (а не с 15 % от стойност в °C). Добавихме: защитна рамка (ИИ предлага, човек решава; системите за пожарна безопасност не се докосват), часов агрегат, проверка на портовете.
01Какво ще научиш
- Как да пуснеш MQTT брокер със защитен достъп и да го държиш затворен към мрежата.
- Как да записваш показания във времева таблица и да ограничаваш колко стоят.
- Как да проверяваш входящите съобщения и да не се чупиш от лошо съобщение.
- Как да правиш предложение (не команда) със защитни граници.
- Как да поискаш от модел обяснение — и защо то е хипотеза.
- Какво не бива да управлява процес като този.
02Преди да започнеш
- Машина от класа NVIDIA GB10 (например ASUS Ascent GX10 или DGX Spark) с Docker и Ollama; Python 3.10 или по-нов. Основите на Docker и защитата на тайните са в урока n8n на GX10.
- Основи на SQL. Истински датчици не са нужни — ползваме симулатор.
- Образите са многоархитектурни: и
eclipse-mosquitto:2.1.2, иtimescale/timescaledb:2.30.2-pg18иматarm64в Docker Hub (проверено на 03.10.2026). - Моделът:
qwen2.5:14b— 9,0 GB, лиценз Apache-2.0 (ollama.com); заема малка част от общите 128 GB. Страницата му изброява 29+ езика, български не е изброен ⚠️ — текстът се чете от техник.
03Стъпки
-
Как е устроено
схемаДатчици (симулатор) ──MQTT──► Mosquitto (вход с парола, порт само на 127.0.0.1) │ ingest.py (проверка) ──► TimescaleDB ├─ iot_readings (90 дни) └─ iot_hourly (агрегат по час) │ advisor.py → ПРЕДЛОЖЕНИЯ ──► човек решава explain.py → обяснение от локален модел (хипотеза) -
Тайни и конфигурация на брокера
Защо парола: според ръководството на Mosquitto, при зададен слушател анонимните връзки са забранени по подразбиране (
allow_anonymousеfalse); потребителите са във файл с пароли. Портът се публикува само на127.0.0.1— така брокерът не се вижда от мрежата.bash · на машината · не е пусканоmkdir -p mosquitto/config # файл с потребител за брокера (ще поиска парола) docker run --rm -it -v "$PWD/mosquitto/config:/mosquitto/config" \ eclipse-mosquitto:2.1.2 mosquitto_passwd -c /mosquitto/config/passwd iot_agent cat > mosquitto/config/mosquitto.conf <<'EOF' listener 1883 allow_anonymous false password_file /mosquitto/config/passwd EOF # тайни за базата — генерирани, не записани в урока cat > .env <<EOF POSTGRES_USER=iot POSTGRES_PASSWORD=$(openssl rand -hex 24) POSTGRES_DB=building EOF chmod 600 .env⚠️Истински устройства в мрежатаДокато портът е само на127.0.0.1, устройства от други машини не могат да се свържат. Свързването им изисква слушател, достъпен в мрежата, с удостоверяване и TLS, и отделна мрежа за IoT. Това не е в урока и не го сме пробвали ⚠️. -
compose.yaml
Защо PGDATA: както в урока за n8n — Postgres 18 смени мястото на данните по подразбиране, затова го задаваме явно, иначе базата започва празна при нов том.
yaml · compose.yaml · не е пусканоname: smart-building services: mosquitto: image: eclipse-mosquitto:2.1.2 restart: unless-stopped ports: - "127.0.0.1:1883:1883" volumes: - ./mosquitto/config:/mosquitto/config - mqtt_data:/mosquitto/data timescaledb: image: timescale/timescaledb:2.30.2-pg18 restart: unless-stopped environment: POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: ${POSTGRES_DB} PGDATA: /var/lib/postgresql/data ports: - "127.0.0.1:5432:5432" volumes: - ts_data:/var/lib/postgresql/data volumes: mqtt_data: ts_data:bash · не е пусканоdocker compose config --quiet && echo "compose: OK" docker compose up -d docker compose psОчакваш двете услуги да работят и портовете да са само на
127.0.0.1. -
Таблица, съхранение и агрегат по час
Защо хипертаблица и политика: показанията растат непрекъснато;
add_retention_policyмаха автоматично старите парчета, а часовият агрегат пази кратка история на тенденциите. Внимание: прозорецът за опресняване на агрегата (start_offset) трябва да е по-къс от срока на съхранение — иначе изтритите сурови данни могат да презапишат агрегата (виж документацията ⚠️).sql · schema.sql · не е пусканоCREATE TABLE iot_readings ( time timestamptz NOT NULL, sensor_id text NOT NULL, location text, temp_c numeric(5,2), humidity_pct numeric(5,2), co2_ppm integer, occupancy integer ) WITH (tsdb.hypertable, tsdb.partition_column = 'time'); -- сурови показания: 90 дни (срокът е твой избор) SELECT add_retention_policy('iot_readings', drop_after => INTERVAL '90 days'); -- часов агрегат CREATE MATERIALIZED VIEW iot_hourly WITH (timescaledb.continuous) AS SELECT time_bucket('1 hour', time) AS bucket, location, avg(temp_c) AS temp_avg, avg(humidity_pct) AS humidity_avg, avg(co2_ppm) AS co2_avg, max(occupancy) AS occupancy_max FROM iot_readings GROUP BY bucket, location WITH NO DATA; SELECT add_continuous_aggregate_policy('iot_hourly', start_offset => INTERVAL '3 days', end_offset => INTERVAL '1 hour', schedule_interval => INTERVAL '1 hour');Командите се пускат в базата. Първо зареждаш променливите от
.envв обвивката си:bash · не е пусканоset -a; . ./.env; set +a docker compose exec -T timescaledb psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" < schema.sql -
Приемане на показанията
Защо проверка: съобщения идват от устройства — могат да са счупени, празни или с нелепи стойности. Скриптът ги прескача и не се срива. Паролите идват от променливи на средата, не от кода. Съобщението за пример е измислено.
python · ingest.py · не е пускано# ingest.py · MQTT → TimescaleDB (измислени датчици) import json import os from datetime import datetime, timezone import paho.mqtt.client as mqtt import psycopg from paho.mqtt.enums import CallbackAPIVersion TOPIC = "building/sensors/#" # напр. "host=localhost dbname=building user=iot password=<от .env>" conn = psycopg.connect(os.environ["DATABASE_URL"], autocommit=True) def on_connect(client, userdata, flags, reason_code, properties): if reason_code.is_failure: print("MQTT: неуспешна връзка:", reason_code) return client.subscribe(TOPIC) # тук, за да се подновява при повторно свързване def on_message(client, userdata, msg): try: data = json.loads(msg.payload) r = data["readings"] row = ( datetime.now(timezone.utc), str(data["sensor_id"])[:50], str(data.get("location", ""))[:100], float(r["temp"]), float(r["humidity"]), int(r["co2"]), int(r.get("occupancy", 0)), ) except (ValueError, KeyError, TypeError) as exc: print("лошо съобщение, прескочено:", type(exc).__name__) return if not (-30 <= row[3] <= 60 and 0 <= row[4] <= 100 and 0 <= row[5] <= 10000): print("стойност извън разумния диапазон, прескочена") return conn.execute( "INSERT INTO iot_readings " "(time, sensor_id, location, temp_c, humidity_pct, co2_ppm, occupancy) " "VALUES (%s, %s, %s, %s, %s, %s, %s)", row, ) client = mqtt.Client(CallbackAPIVersion.VERSION2) client.username_pw_set(os.environ["MQTT_USER"], os.environ["MQTT_PASS"]) client.on_connect = on_connect client.on_message = on_message client.connect("localhost", 1883) client.loop_forever()Симулиран датчик — едно измислено съобщение:
bash · не е пусканоdocker compose exec mosquitto mosquitto_pub -h localhost -u iot_agent -P "<парола>" \ -t building/sensors/s-01 \ -m '{"sensor_id":"s-01","location":"hall-1","readings":{"temp":21.4,"humidity":45,"co2":620,"occupancy":12}}'Провери в базата, че редът е там:
SELECT * FROM iot_readings ORDER BY time DESC LIMIT 5;. Изпрати и счупено съобщение (например{"x":1}) — скриптът трябва да го прескочи, без да спре. -
Предложение, не команда
Защо: какво да се направи с климата решава човек, който познава сградата. Функцията по-долу връща текст с предложения. Граничните стойности са примерни — задава ги инженерът по проекта или стандарта, не този урок.
python · advisor.py · не е пускано# advisor.py · предложения за климат (НЕ команди). Стойностите са примерни. import os import psycopg SETPOINT_MIN, SETPOINT_MAX = 19.0, 24.0 # защитни граници — задава инженерът CO2_ALERT_PPM = 1000 # примерен праг, не норма def clamp(x: float, lo: float, hi: float) -> float: return max(lo, min(hi, x)) def last_hour(location: str): with psycopg.connect(os.environ["DATABASE_URL"]) as conn: row = conn.execute( "SELECT avg(temp_c), avg(co2_ppm) FROM iot_readings " "WHERE location = %s AND time > now() - interval '1 hour'", (location,), ).fetchone() return row # (avg_temp, avg_co2) — може да са None def suggest(location: str, expected_people: int, capacity: int) -> list[str]: avg_temp, avg_co2 = last_hour(location) if avg_temp is None: return ["Няма данни от датчиците — не предлагаме промяна."] fill = expected_people / capacity if capacity else 0.0 target = clamp(22.0 - (1.0 if fill > 0.6 else 0.0), SETPOINT_MIN, SETPOINT_MAX) notes = [] if float(avg_temp) > target + 1.5: notes.append( f"Предложение: охлаждане към {target:.1f} °C преди събитието " f"(последен час: {float(avg_temp):.1f} °C). Решава техник." ) if avg_co2 is not None and float(avg_co2) > CO2_ALERT_PPM: notes.append("Предложение: по-силна вентилация (CO₂ над настроения праг). Решава техник.") return notes or ["Няма предложения."]Датчик Примерен праг Предложение Кой решава Температура над целта + 1,5 °C охлаждане преди събитие техник CO₂ над настроения праг по-силна вентилация техник Влажност настройва се известие техник Празна зала настройва се намалено осветление отговорник на сградата Всички прагове са примерни — вземи реалните от проекта на сградата или от приложим стандарт.
-
Обяснение на аномалия с локален модел
Защо абсолютни отклонения: „15 % над нормата“ няма смисъл за температура в °C. Задаваме отделен праг на всеки показател. Защо „хипотеза“: моделът вижда числа, не сградата. Той може да предложи „открит прозорец или неизправен климатик“, но това проверява техник.
python · explain.py · не е пускано# explain.py · обяснение на аномалия (хипотеза за техник) import httpx OLLAMA = "http://localhost:11434" MODEL = "qwen2.5:14b" LIMITS = {"temp_c": 2.0, "humidity_pct": 15.0, "co2_ppm": 300.0} # примерни абсолютни прагове def deviations(current: dict, baseline: dict) -> list[str]: out = [] for metric, limit in LIMITS.items(): if metric in current and metric in baseline: delta = float(current[metric]) - float(baseline[metric]) if abs(delta) > limit: out.append(f"{metric}: сега {float(current[metric]):.1f}, обичайно {float(baseline[metric]):.1f} ({delta:+.1f})") return out def explain(location: str, current: dict, baseline: dict) -> str: devs = deviations(current, baseline) if not devs: return "Всички показатели са в обичайните граници." prompt = ( f"Отклонения в зала „{location}“:\n" + "\n".join(devs) + "\n\nНапиши на български 3 кратки точки: възможни причини (като хипотези) " "и какво да провери техник. Не твърди нищо, което не следва от числата." ) r = httpx.post( f"{OLLAMA}/api/chat", json={ "model": MODEL, "stream": False, "messages": [{"role": "user", "content": prompt}], "options": {"temperature": 0.2}, }, timeout=300, ) r.raise_for_status() return r.json()["message"]["content"] -
Граници и безопасност
- ИИ не управлява пожароизвестяване, димоотвеждане и евакуация. Тези системи си остават отделни и сертифицирани.
- Команди към оборудване — само след преглед от квалифициран човек, със защитни граници и ръчен режим за връщане.
- Бройките за заетост са анонимни числа. Преброяване с камери включва лични данни — това е друга тема (виж урока за достъп и видео).
- IoT устройствата са в отделна мрежа, с обновяване и с пароли, които не са фабричните.
- Пази в базата само нужното; срокът от 90 дни е твой избор, не изискване.
04Проверка
docker compose ps: Mosquitto и TimescaleDB работят; публикувани са само портове на127.0.0.1.- Анонимна MQTT връзка е отказана; с потребител и парола работи.
- В базата има хипертаблица, политика за съхранение (90 дни) и часов агрегат.
- Симулираните съобщения се появяват в
iot_readings; счупено съобщение се прескача без срив. advisor.pyвръща само предложения и държи целта в границите, зададени от инженер.- Техник е прочел изхода на
explain.pyкато хипотеза. - Не е свързана система за пожарна безопасност и не се събират лични данни.
Тест
1. Какво е нужно на paho-mqtt 2.x при създаване на клиент?
2. Какво прави add_retention_policy('iot_readings', drop_after => INTERVAL '90 days')?
3. Какво връща advisor.py?
4. Кое НЕ бива да се управлява от такъв процес?
05Какво следва
06Източници
- Mosquitto: mosquitto.conf 🔒 локално —
allow_anonymous,password_file(прочетено 03.10.2026). - Docker Hub: eclipse-mosquitto · timescale/timescaledb — тагове и архитектури (arm64).
- TimescaleDB: CREATE TABLE · add_retention_policy · непрекъснати агрегати · add_continuous_aggregate_policy.
- paho-mqtt за Python — версионирани обратни извиквания (2.x).
- psycopg 3.
- Ollama: qwen2.5 🔒 локално — размер 9,0 GB, лиценз, езици.