Знакът на КАГАМИ КАГАМИ
kagami.bg/academy · lesson · machine-readable viewUPDATED 2026-10-03
IDENTITY
module
GX10-04-163 · Sensor data pipeline and climate suggestions for a cultural and sports complex
series
GX10 (local AI server class: NVIDIA GB10, e.g. ASUS Ascent GX10 / DGX Spark)
level
Intermediate
duration
2-3 h
prerequisites
A GB10-class machine with Docker and Ollama, Python 3.10+; basic SQL; no real sensors needed (a simulator is used)
trust_label
UPDATED 2026-10-03 (Mosquitto configuration manual, Docker Hub tags and architectures, Tiger Data TimescaleDB reference pages, paho-mqtt README and migration notes, PyPI versions, Ollama model page, all read on 2026-10-03) · NOT TESTED on a GB10 machine · no VERIFIED label · thresholds are examples, not standards · all data is invented
versions
eclipse-mosquitto 2.1.2 · timescale/timescaledb 2.30.2-pg18 (both publish arm64 on Docker Hub) · paho-mqtt 2.1.0 · psycopg 3.3.6 · qwen2.5:14b = 9.0 GB, Apache-2.0
language
human view: bg · english edition: /en/academy/gx10/ (same file name)
previous / next
04-162_Sub_Processor_DPA_Engine.html / 04-164_Ticketing_Chatbot.html
PURPOSE

Build a small, safe sensor pipeline: simulated temperature, humidity, CO2 and occupancy-count readings go over MQTT to Mosquitto (authenticated, bound to loopback on the host), a Python client validates and stores them in a TimescaleDB hypertable with a 90-day retention policy and an hourly continuous aggregate, a rule function turns data into suggestions (not commands), and a local model explains anomalies in plain language. The model never controls equipment; life-safety systems are out of scope.

KEY CONCEPTS
COMMANDS / PATHS
CHECKLIST
NEXT MODULE

04-164 · Ticketing chatbot (04-164_Ticketing_Chatbot.html) · series index: kagami.bg/academy/gx10/ · offer: Quick experiment (kagami.bg/stalbata/)

SOURCES
TAGS
gx10nvidia-gb10mqttmosquittotimescaledbiotollamasafety
ОБНОВЕНО · 03.10.2026

Умна сграда: датчици и климат на GX10

Как се събират показания от датчици (температура, влажност, CO₂, брой хора) в културен и спортен комплекс: през MQTT към TimescaleDB, със защитен достъп и ограничено съхранение. Върху данните правим предложения за климата и даваме на локален модел да обясни аномалиите с обикновени думи. Моделът не управлява нищо. Всички датчици и числа в урока са измислени.

⏱ 2–3 ч Средно GX10 MQTT · TimescaleDB · Ollama
Mosquitto (MQTT брокерът)🔒 локално TimescaleDB върху Postgres 18 (показанията)🔒 локално Ollama · qwen2.5:14b (обясненията)🔒 локално
🔄
ОБНОВЕНО · 03.10.2026 — какво
Обобщихме примера. Урокът вече не е за конкретна сграда; датчиците, залите и числата са измислени. Поправихме кода: 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). Добавихме: защитна рамка (ИИ предлага, човек решава; системите за пожарна безопасност не се докосват), часов агрегат, проверка на портовете.
⚠️
Какво не сме пускали сами
Нямахме машина от класа GB10: нито една команда и нито един ред код тук не са пускани, затова няма етикет „ТЕСТВАНО“ и няма „ПРОВЕРЕНО“. Не сме свързвали истински датчици или система за управление на сградата. Фактите за версиите, настройките и командите са сверени с официалните страници на 03.10.2026.
🛑
Безопасност преди всичко
Този урок събира данни и дава предложения. Не пращай команди към климатични, отоплителни или други системи без квалифициран човек, защитни граници и ръчен режим за връщане. Пожароизвестяване, димоотвеждане и евакуация не се свързват с такъв процес.

01Какво ще научиш

02Преди да започнеш

03Стъпки

  1. Как е устроено

    схема
    Датчици (симулатор) ──MQTT──► Mosquitto (вход с парола, порт само на 127.0.0.1)
                                      │
                             ingest.py (проверка) ──► TimescaleDB
                                                        ├─ iot_readings (90 дни)
                                                        └─ iot_hourly (агрегат по час)
                                                              │
                              advisor.py → ПРЕДЛОЖЕНИЯ ──► човек решава
                              explain.py → обяснение от локален модел (хипотеза)
  2. Тайни и конфигурация на брокера

    Защо парола: според ръководството на 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. Това не е в урока и не го сме пробвали ⚠️.
  3. 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.

  4. Таблица, съхранение и агрегат по час

    Защо хипертаблица и политика: показанията растат непрекъснато; 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
  5. Приемане на показанията

    Защо проверка: съобщения идват от устройства — могат да са счупени, празни или с нелепи стойности. Скриптът ги прескача и не се срива. Паролите идват от променливи на средата, не от кода. Съобщението за пример е измислено.

    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}) — скриптът трябва да го прескочи, без да спре.

  6. Предложение, не команда

    Защо: какво да се направи с климата решава човек, който познава сградата. Функцията по-долу връща текст с предложения. Граничните стойности са примерни — задава ги инженерът по проекта или стандарта, не този урок.

    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₂над настроения прагпо-силна вентилациятехник
    Влажностнастройва сеизвестиетехник
    Празна заланастройва сенамалено осветлениеотговорник на сградата

    Всички прагове са примерни — вземи реалните от проекта на сградата или от приложим стандарт.

  7. Обяснение на аномалия с локален модел

    Защо абсолютни отклонения: „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"]
  8. Граници и безопасност

    • ИИ не управлява пожароизвестяване, димоотвеждане и евакуация. Тези системи си остават отделни и сертифицирани.
    • Команди към оборудване — само след преглед от квалифициран човек, със защитни граници и ръчен режим за връщане.
    • Бройките за заетост са анонимни числа. Преброяване с камери включва лични данни — това е друга тема (виж урока за достъп и видео).
    • IoT устройствата са в отделна мрежа, с обновяване и с пароли, които не са фабричните.
    • Пази в базата само нужното; срокът от 90 дни е твой избор, не изискване.

04Проверка

Тест

1. Какво е нужно на paho-mqtt 2.x при създаване на клиент?

2. Какво прави add_retention_policy('iot_readings', drop_after => INTERVAL '90 days')?

3. Какво връща advisor.py?

4. Кое НЕ бива да се управлява от такъв процес?

05Какво следва

06Източници

  1. Mosquitto: mosquitto.conf 🔒 локално — allow_anonymous, password_file (прочетено 03.10.2026).
  2. Docker Hub: eclipse-mosquitto · timescale/timescaledb — тагове и архитектури (arm64).
  3. TimescaleDB: CREATE TABLE · add_retention_policy · непрекъснати агрегати · add_continuous_aggregate_policy.
  4. paho-mqtt за Python — версионирани обратни извиквания (2.x).
  5. psycopg 3.
  6. Ollama: qwen2.5 🔒 локално — размер 9,0 GB, лиценз, езици.