Sąsiedzką wymianę ogłoszeń da się zbudować bez portalu ogłoszeniowego. Wystarczy kanał MeshCore i Home Assistant, który nasłuchuje tego kanału. Efektem są dwa lokalne katalogi na jednym kanale: bazar z towarami (jajka, miód, warzywa) i katalog usług (hydraulik, elektryk, mechanik).

Projekt LISmesh

Katalogi powstały w projekcie LISmesh. LIS to skrót od Lokalnej Inicjatywy Społecznej, a LISmesh to jej sieć MeshCore. Wszystko dzieje się na kanale Lisia Nora, który w węźle bota zajmuje pozycję numer 8, więc w przykładach pojawia się właśnie ten numer. To numer na liście kanałów węzła połączonego z Home Assistant, a nie numer kanału w całej sieci: u innych użytkowników Lisia Nora może mieć inny. Jeśli budujesz podobną sieć u siebie, wpisz w kodzie numer, pod którym Twój kanał jest zapisany w węźle bota.

Ten tekst pokazuje tylko automatyzacje. Zakładamy, że integracja meshcore-ha (instalowana przez HACS) już działa, więc jej instalacji nie omawiamy. Przykłady sprawdzaliśmy na wersji 3.0.2.

Jak to wygląda w użyciu

Użytkownik pisze zwykłą wiadomość na kanale, na przykład z aplikacji MeshCore w telefonie. Zaczyna ją słowo uruchamiające katalog (prefiks), a dalej idzie komenda:

Komenda Co robi Przykład odpowiedzi bota
BAZAR MAM JAJKA dodaje ofertę do Twojej listy BAZAR: Dodano do Twojej oferty: jajka.
BAZAR SZUKAM JAJKA szuka pasujących ofert BAZAR: jajka -> @Ala (jajka)
BAZAR MOJE pokazuje Twoje oferty BAZAR: Twoje oferty: jajka, miód 7 zł/l
BAZAR USUŃ JAJKA usuwa Twoje oferty zawierające to słowo BAZAR: Usunięto z Twojej oferty: jajka.
BAZAR USUŃ WSZYSTKO czyści Twoją listę BAZAR: Usunięto wszystkie Twoje oferty.
BAZAR POMOC wysyła krótką instrukcję lista komend
Czat kanału Lisia Nora: użytkownicy dodają i sprawdzają oferty komendami BAZAR, bot potwierdza wpisy
Ilustracja poglądowa, wcześniejsza wersja botaIlustracja: Hektores (wygenerowano AI)
Karta kanału Lisia Nora w Home Assistant z komendami bazaru i usług oraz odpowiedziami bota
Ilustracja poglądowa karty kanału w Home Assistant, wcześniejsza wersja botaIlustracja: Hektores (wygenerowano AI)

Prefiks ma aliasy: TOWAR, PRODUKT, SKLEP i emoji 🛒. Wielkość liter nie ma znaczenia, a USUŃ działa też bez ogonka. Cena jest częścią opisu oferty, na przykład BAZAR MAM MIÓD 7 ZŁ/L. Bot zapisuje oferty małymi literami.

Ala:    BAZAR MAM JAJKA
Bot:    BAZAR: Dodano do Twojej oferty: jajka.
Bartek: BAZAR SZUKAM JAJKA
Bot:    BAZAR: jajka -> @Ala (jajka)
Ala:    BAZAR USUŃ JAJKA
Bot:    BAZAR: Usunięto z Twojej oferty: jajka.

Jak to działa

telefon z aplikacją MeshCore
        │  wiadomość na kanale 8 (radio LoRa)
        ▼
węzeł połączony z Home Assistant
        │  zdarzenie meshcore_message
        ▼
automatyzacja BAZAR (albo USŁUGI)
  filtry: kanał 8 → nie od bota → jest prefiks → nie powtórka
        │
        ├──► helper input_text: baza ofert (nick:oferta;nick:oferta)
        ▼
usługa meshcore.send_channel_message
        │  odpowiedź na kanale 8
        ▼
telefon z aplikacją MeshCore
  1. Integracja MeshCore zamienia każdą wiadomość z radia na zdarzenie meshcore_message. Zawiera ono treść, nazwę nadawcy, numer kanału i czas.
  2. Automatyzacja startuje przy każdym takim zdarzeniu i odrzuca to, co jej nie dotyczy: inny kanał, wiadomości samego bota, wiadomości bez prefiksu i powtórki.
  3. Potem wybiera akcję na podstawie komendy i czyta albo zmienia bazę.
  4. Na końcu wysyła odpowiedź na ten sam kanał.

Baza to zwykły tekst w jednym helperze (pomocniku, czyli encji Home Assistant, która tylko przechowuje wartość), w formacie nick:oferta;nick:oferta. Nick pochodzi z nazwy nadawcy w zdarzeniu, więc każdy zarządza tylko swoimi wpisami.

Czego potrzebujesz

  • Home Assistant z działającą integracją MeshCore. W przykładach użyto zdarzenia meshcore_message i usługi meshcore.send_channel_message. Nazwy zdarzeń możesz sprawdzić w Narzędziach deweloperskich, w zakładce Zdarzenia.
  • Węzeł połączony z Home Assistant, który wysyła odpowiedzi bota. Jego nazwa musi się różnić od nazw użytkowników, bo bot ignoruje wiadomości z własną nazwą i dzięki temu nie odpowiada sam sobie.
  • Kanał, na którym ma działać katalog. W przykładach to kanał numer 8, czyli Lisia Nora z projektu LISmesh.

Krok 1. Helpery w configuration.yaml

Każdy katalog potrzebuje dwóch helperów typu input_text: jednego na bazę i jednego na ostatnio obsłużoną wiadomość, która służy do wykrywania powtórek. Każda automatyzacja ma własny helper powtórek, więc nie blokują się nawzajem. Na kanale mogą działać także inne boty.

input_text:
  mesh_bazar_baza:
    name: "🛒 Bazar MeshCore"
    max: 255

  mesh_bazar_last_msg:
    name: "🛒 Bazar – ostatnia wiadomość"
    max: 255

  mesh_uslugi_baza:
    name: "🛠️ Katalog usług MeshCore"
    max: 255

  mesh_uslugi_last_msg:
    name: "🛠️ Katalog usług – ostatnia wiadomość"
    max: 255

Jeśli w pliku jest już sekcja input_text:, dopisz helpery pod nią. Druga sekcja o tej samej nazwie kończy się błędem konfiguracji. Po zapisaniu sprawdź konfigurację i zrestartuj Home Assistant.

Dwie rzeczy zasługują na uwagę. Limit 255 znaków to maksimum, jakie Home Assistant dopuszcza w stanie encji, więc baza zmieści od kilku do kilkunastu krótkich wpisów, zależnie od długości nicków i opisów. Drugą rzeczą jest brak opcji initial. Według dokumentacji input_text ustawiona wartość initial wraca przy każdym starcie, a bez niej Home Assistant przywraca stan sprzed restartu. W pierwszej wersji tego projektu helpery miały initial: "" i baza sama się czyściła.

Krok 2. Automatyzacja bazaru

Wejdź w Ustawienia → Automatyzacje i sceny → Utwórz automatyzację, otwórz menu z trzema kropkami i wybierz Edytuj w YAML. Zaznacz całą zawartość, wklej kod poniżej i zapisz. Zmień w nim numer kanału (ch == 8 oraz wszystkie pola channel_idx) i nazwę bota w zmiennej is_from_bot.

alias: "MeshCore - BAZAR (kanał 8)"
description: "Katalog produktów na kanale 8. Komendy: BAZAR MAM ..., SZUKAM ..., MOJE, USUŃ ..., USUŃ WSZYSTKO"
mode: queued
max: 10
triggers:
  - trigger: event
    event_type: meshcore_message
actions:
  - condition: template
    value_template: '{{ trigger.event is defined }}'

  - variables:
      raw_msg: '{{ trigger.event.data.message | default("", true) | string }}'
      raw_upper: '{{ raw_msg | upper | trim }}'
      sender: '{{ trigger.event.data.sender_name | default("nieznany", true) | string }}'
      ch: '{{ trigger.event.data.channel_idx | default(-1, true) | int }}'
      msg_type: '{{ trigger.event.data.message_type | default("", true) | string }}'
      ts: '{{ trigger.event.data.timestamp | default(now().isoformat(), true) | string }}'
      dedup_id: '{{ sender }}|{{ raw_msg }}|{{ ts }}'
      baza: '{{ states("input_text.mesh_bazar_baza") }}'
      last_msg: '{{ states("input_text.mesh_bazar_last_msg") }}'

      # fragment nazwy Twojego węzła, który wysyła odpowiedzi bota
      is_from_bot: "{{ 'MojBot' in sender }}"

      is_bazar: >-
        {% set p = raw_upper %}
        {{ p.startswith('BAZAR') or p.startswith('TOWAR') or p.startswith('PRODUKT')
           or p.startswith('SKLEP') or p.startswith('🛒') }}

      cmd: >-
        {{ raw_upper
           | regex_replace('^(BAZAR|TOWAR|PRODUKT|SKLEP)[\\s:,\\-]*', '')
           | regex_replace('^🛒[\\s:,\\-]*', '')
           | trim }}

      is_help: "{{ cmd == 'POMOC' or cmd == 'HELP' or cmd == '' }}"
      is_me: "{{ cmd == 'MOJE' }}"
      is_add: "{{ cmd.startswith('MAM ') }}"
      is_search: "{{ cmd.startswith('SZUKAM ') }}"
      is_del_all: "{{ cmd == 'USUŃ WSZYSTKO' or cmd == 'USUN WSZYSTKO' }}"
      is_del_one: "{{ cmd.startswith('USUŃ ') or cmd.startswith('USUN ') }}"

      add_item: "{{ cmd | regex_replace('^MAM[\\s]+', '') | lower | trim }}"
      search_item: "{{ cmd | regex_replace('^SZUKAM[\\s]+', '') | lower | trim }}"
      del_item: "{{ cmd | regex_replace('^(USUŃ|USUN)[\\s]+', '') | lower | trim }}"

  # numer kanału, na którym działa bazar
  - condition: template
    value_template: '{{ msg_type == "channel" and ch == 8 }}'
  - condition: template
    value_template: '{{ not is_from_bot }}'
  - condition: template
    value_template: '{{ is_bazar }}'
  - condition: template
    value_template: '{{ last_msg != dedup_id }}'

  - action: input_text.set_value
    target:
      entity_id: input_text.mesh_bazar_last_msg
    data:
      value: '{{ dedup_id[:255] }}'

  - choose:

      - conditions:
          - condition: template
            value_template: '{{ is_help }}'
        sequence:
          - action: meshcore.send_channel_message
            data:
              channel_idx: 8
              message: "BAZAR: Dodaj: MAM JAJKA. Szukaj: SZUKAM JAJKA. Moje: MOJE. Usuń: USUŃ JAJKA. Usuń wszystko: USUŃ WSZYSTKO."

      - conditions:
          - condition: template
            value_template: '{{ is_add and add_item != "" }}'
        sequence:
          - variables:
              nowa_baza: >-
                {% set wpisy = baza.split(';') %}
                {% set wynik = namespace(lista=[]) %}
                {% for w in wpisy %}
                  {% if w != '' and w.split(':')[0] != sender %}
                    {% set wynik.lista = wynik.lista + [w] %}
                  {% endif %}
                {% endfor %}
                {% set moje = namespace(lista=[]) %}
                {% for w in wpisy %}
                  {% if w != '' and w.split(':')[0] == sender %}
                    {% set moje.lista = moje.lista + [w.split(':',1)[1]] %}
                  {% endif %}
                {% endfor %}
                {% if add_item not in moje.lista %}
                  {% set moje.lista = moje.lista + [add_item] %}
                {% endif %}
                {% for u in moje.lista %}
                  {% set wynik.lista = wynik.lista + [sender + ':' + u] %}
                {% endfor %}
                {{ wynik.lista | join(';') }}
          - action: input_text.set_value
            target:
              entity_id: input_text.mesh_bazar_baza
            data:
              value: '{{ nowa_baza[:255] }}'
          - action: meshcore.send_channel_message
            data:
              channel_idx: 8
              message: "BAZAR: Dodano do Twojej oferty: {{ add_item }}."

      - conditions:
          - condition: template
            value_template: '{{ is_search and search_item != "" }}'
        sequence:
          - variables:
              wyniki: >-
                {% set wpisy = baza.split(';') %}
                {% set wynik = namespace(lista=[]) %}
                {% set rdzen = search_item[:4] %}
                {% for w in wpisy %}
                  {% if w != '' and ':' in w %}
                    {% set nick = w.split(':',1)[0] %}
                    {% set item = w.split(':',1)[1] %}
                    {% if rdzen in item or item[:4] in search_item %}
                      {% set wynik.lista = wynik.lista + ['@' + nick + ' (' + item + ')'] %}
                    {% endif %}
                  {% endif %}
                {% endfor %}
                {{ wynik.lista | join(', ') }}
          - action: meshcore.send_channel_message
            data:
              channel_idx: 8
              message: >-
                {% if wyniki == "" %}
                BAZAR: Nie znaleziono ofert dla "{{ search_item }}".
                {% else %}
                BAZAR: {{ search_item }} -> {{ wyniki }}
                {% endif %}

      - conditions:
          - condition: template
            value_template: '{{ is_me }}'
        sequence:
          - variables:
              moje: >-
                {% set wpisy = baza.split(';') %}
                {% set wynik = namespace(lista=[]) %}
                {% for w in wpisy %}
                  {% if w != '' and ':' in w and w.split(':',1)[0] == sender %}
                    {% set wynik.lista = wynik.lista + [w.split(':',1)[1]] %}
                  {% endif %}
                {% endfor %}
                {{ wynik.lista | join(', ') }}
          - action: meshcore.send_channel_message
            data:
              channel_idx: 8
              message: >-
                {% if moje == "" %}
                BAZAR: Nie masz jeszcze żadnych ofert.
                {% else %}
                BAZAR: Twoje oferty: {{ moje }}
                {% endif %}

      - conditions:
          - condition: template
            value_template: '{{ is_del_all }}'
        sequence:
          - variables:
              nowa_baza: >-
                {% set wpisy = baza.split(';') %}
                {% set wynik = namespace(lista=[]) %}
                {% for w in wpisy %}
                  {% if w != '' and w.split(':')[0] != sender %}
                    {% set wynik.lista = wynik.lista + [w] %}
                  {% endif %}
                {% endfor %}
                {{ wynik.lista | join(';') }}
          - action: input_text.set_value
            target:
              entity_id: input_text.mesh_bazar_baza
            data:
              value: '{{ nowa_baza[:255] }}'
          - action: meshcore.send_channel_message
            data:
              channel_idx: 8
              message: "BAZAR: Usunięto wszystkie Twoje oferty."

      - conditions:
          - condition: template
            value_template: '{{ is_del_one and del_item != "" and not is_del_all }}'
        sequence:
          - variables:
              nowa_baza: >-
                {% set wpisy = baza.split(';') %}
                {% set wynik = namespace(lista=[]) %}
                {% for w in wpisy %}
                  {% if w != '' and ':' in w %}
                    {% set nick = w.split(':',1)[0] %}
                    {% set item = w.split(':',1)[1] %}
                    {% if nick == sender and (item == del_item or del_item in item) %}
                    {% else %}
                      {% set wynik.lista = wynik.lista + [w] %}
                    {% endif %}
                  {% elif w != '' %}
                    {% set wynik.lista = wynik.lista + [w] %}
                  {% endif %}
                {% endfor %}
                {{ wynik.lista | join(';') }}
          - action: input_text.set_value
            target:
              entity_id: input_text.mesh_bazar_baza
            data:
              value: '{{ nowa_baza[:255] }}'
          - action: meshcore.send_channel_message
            data:
              channel_idx: 8
              message: "BAZAR: Usunięto z Twojej oferty: {{ del_item }}."

Filtry na początku

Cztery warunki po zmiennych przepuszczają wyłącznie wiadomości z wybranego kanału, które nie pochodzą od bota, zaczynają się od prefiksu i nie były jeszcze obsłużone. Powtórkę wykrywa zmienna dedup_id, czyli nick, treść i czas wiadomości zapisane w helperze. Tryb queued ustawia wiadomości w kolejce, gdy kilka przyjdzie naraz.

Komendy

Zmienna cmd to treść bez prefiksu, zapisana wielkimi literami. Blok choose wybiera pierwszą pasującą komendę. Dodawanie przepisuje listę: najpierw cudze wpisy, potem Twoje, a na końcu nowa oferta, jeśli jeszcze jej nie masz. Usuwanie rusza tylko wpisy nadawcy, więc nikt nie skasuje cudzej oferty.

Wyszukiwanie po rdzeniu słowa

Polskie słowa się odmieniają, więc dokładne porównanie nie znajdzie „cebuli” przy ofercie „cebule”. Automatyzacja porównuje pierwsze 4 litery szukanego słowa z treścią oferty. Dzięki temu „cebula”, „cebule”, „cebuli” i „cebulka” znajdują ofertę „cebule 6 zł/kg”. Odpowiedź zawiera nick i opis oferty.

Krok 3. Katalog usług

Katalog usług to ta sama automatyzacja z innymi nazwami. Skopiuj kod bazaru, utwórz drugą automatyzację i zmień w niej:

Co Bazar Katalog usług
Nazwa automatyzacji (alias) MeshCore - BAZAR (kanał 8) MeshCore - Katalog USŁUG (kanał 8)
Prefiksy (w is_bazar i w dwóch wywołaniach regex_replace w cmd) BAZAR, TOWAR, PRODUKT, SKLEP, 🛒 USLUGI, USŁUGI, USŁUGA, KATALOG, FACHOWCY, 🛠️
Słowo dodające (w is_add i add_item) MAM JESTEM
Helpery mesh_bazar_baza, mesh_bazar_last_msg mesh_uslugi_baza, mesh_uslugi_last_msg
Początek odpowiedzi BAZAR: USŁUGI:
Tekst pomocy MAM JAJKA, SZUKAM JAJKA, USUŃ JAJKA JESTEM HYDRAULIK, SZUKAM HYDRAULIKA, USUŃ HYDRAULIK

Nazwy zmiennych (add_item, search_item) możesz zostawić bez zmian. Obie automatyzacje działają niezależnie na tym samym kanale: jedna reaguje na swoje prefiksy, druga na swoje.

Test

Wysyłaj komendy z jednego urządzenia, bo wpisy należą do nazwy nadawcy. Na początek:

BAZAR POMOC
BAZAR MAM JAJKA
BAZAR MAM MIÓD 7 ZŁ/L
BAZAR MOJE
BAZAR SZUKAM MIÓD
BAZAR USUŃ JAJKA
BAZAR MOJE
BAZAR USUŃ WSZYSTKO
BAZAR MOJE

Po ostatniej komendzie bot powinien odpowiedzieć „BAZAR: Nie masz jeszcze żadnych ofert”. Potem sprawdź to samo z drugiego urządzenia, żeby zobaczyć, że wyszukiwanie widzi cudze oferty. Na koniec zrestartuj Home Assistant i sprawdź, czy baza przetrwała.

Na co uważać

  • Prefiks jest obowiązkowy. Samo USUŃ ELEKTRYK bez prefiksu bot zignoruje, bo nie wie, że to komenda katalogu.
  • Wpisy należą do nazwy nadawcy, nie do urządzenia. Ten sam człowiek z drugim węzłem o innym nicku nie usunie własnej oferty. Ktoś, kto ustawi identyczną nazwę, może usunąć cudze wpisy. Nick z dwukropkiem lub średnikiem psuje format bazy. Wiadomość na kanale MeshCore nie niesie klucza publicznego nadawcy, tylko tekst w postaci „Nazwa: treść”. Integracja dopisuje do zdarzenia prefiks klucza (pubkey_prefix) tylko wtedy, gdy nazwa pasuje do kontaktu zapisanego w węźle bota, więc to także jest identyfikacja po nazwie.
  • Szukanie po czterech literach nie łapie każdej odmiany. „Jajek” nie znajdzie oferty „jajka”, a „miodu” nie znajdzie „miód”. Szukaj w pierwszej formie słowa. Skrócenie rdzenia do 3 liter (zamień [:4] na [:3]) naprawia „jajek”, ale kosztem fałszywych trafień: „kapusta” znajdzie też „kapcie”.
  • Gdy baza przekroczy 255 znaków, ostatni wpis zostanie ucięty, a bot i tak potwierdzi dodanie oferty. Przy dłuższych opisach baza zapełni się szybciej.
  • Bot potwierdza usunięcie, także gdy nic nie pasowało do wpisu.
  • Według dokumentacji protokołu companion wiadomość w MeshCore ma najwyżej 133 znaki (dokument zastrzega, że jest w trakcie prac). Pomoc z przykładu ma 106 znaków, ale odpowiedź z kilkoma wynikami wyszukiwania może się nie zmieścić. Trzymaj opisy ofert krótkie.
  • Każda komenda to odpowiedź nadana w eterze. Limit czasu nadawania dotyczy też węzła bota, więc nie rozbudowuj odpowiedzi bez potrzeby.

Co dalej

  • Dzielenie długich wyników na kilka wiadomości, wysyłanych z przerwą kilku sekund.
  • Baza w pliku albo w SQLite zamiast jednego helpera, żeby zdjąć limit 255 znaków.
  • Komendy wysyłane do bota wiadomością prywatną zamiast na kanale. Wiadomość prywatna niesie prefiks klucza publicznego nadawcy, więc oferty można przypisać do klucza, a nie do nazwy.

Artykuł powstał na podstawie konfiguracji Home Assistant i notatek z testów katalogu na kanale Lisia Nora w projekcie LISmesh, z uzupełnieniem o dokumentację input_text i protokołu companion MeshCore. Bazar w działaniu pokazuje krótki film: