Skocz do zawartości

Protokół HTTP w zastosowaniach IoT - część 3: tworzymy klienta


ethanak

Pomocna odpowiedź

Po przeczytaniu dwóch poprzednich części znamy już pobieżnie zasady działania HTTP, potrafimy już stworzyć prosty (choć prawdopodobnie użyteczny) serwer. Ale przecież serwer to dopiero połowa - drugą, równie ważną jest klient. I znów będziemy próbować przesyłać między serwerem a klientem dane dotyczące stanu wejść naszego Arduino (czyli najprawdopodobniej jakichś czujników).

Ten wpis brał udział konkursie na najlepszy artykuł o elektronice lub programowaniu. Sprawdź wyniki oraz listę wszystkich prac »
Partnerem tej edycji konkursu (marzec 2020) był popularny producent obwodów drukowanych, firma PCBWay.
PCBway_logotyp-350x233.png

Spis treści serii artykułów:

klient_ok.thumb.png.55b81d76cb5f8f8ddf867f7a3830faea.png

I tu uwaga: ponieważ musimy użyć dwóch urządzeń, potrzebne byłyby dwie identyczne płytki. Ponieważ nie każdy ma akurat w szufladzie dwa takie same układy - możemy zasymulować działanie takiego serwera używając naszego komputera. W tym celu w załączniku znajduje się krótki program napisany w Pythonie. Serwer działa na porcie 8001 zamast 80. Gdybyśmy jednak chcieli zmienić to zachowanie, musimy pamiętać, że:

  • na naszym serwerze nie może działać żaden inny serwer na porcie 80;
  • w przypadku Linuksa aby serwer mógł działać na porcie 80, musimy go uruchamiać z konta root (np. przez sudo) - normalny użytkownik nie może uruchamiać serwerów na portach niższych niż 1024.

Jeśli chcemy, aby nasz serwer uruchamiał się na inym porcie niż 8001 - po prostu musimy znaleźć linijkę:

port = 8001

i zamienić liczbę 8001 na numer portu.

Serwer uruchamiamy z konsoli po wejściu do katalogu zawierającego program poleceniem:

python3 miniserver.py

lub odpowiednikiem dla naszego systemu operacyjnego.

Jeśli nasz komputer ma zainstalowanego firewalla, należy zezwolić na dostęp z zewnątrz dla naszego serwera. Ponieważ różne firewalle mają różne metody służące do takowego zezwalania - odsyłam do dokumentacji naszego firewalla.

Po uruchomieniu serwera możemy sprawdzić jego działanie wchodząc przeglądarką na adres http://ip_naszego_komputera:8001/ lub http://localhost:8001/ - powinny ukazać się trzy liczby. Jako że nasz komputer nie ma najprawdopodobniej podłączonych żadnych potencjometrów czy przycisków - liczba odpowiadająca potencjometrowi jest po prostu brana z generatora losowego, a klawiszowi zmienia się za każdym wywołaniem.

Tyle tytułem przydługiego wstępu, możemy wreszcie zabrać się za tworzenie...

klienta HTTP

Ponieważ do klienta będą potrzebne takie same płytki jak do serwera, przypominam układy połączeń dla Arduino z shieldem Ethernet oraz płytek ESP3266 i ESP32:

arduino_uno.thumb.png.1cc1474899af86900296ea6267c08f00.pngespnode.thumb.png.2fcbe74d61f3b246597e827d84d0deb2.pngnodemcu.thumb.png.030cf79b7e76a505743907f33238cad7.png

I znów jak poprzednio użyjemy wspólnego fragmentu kodu. Będzie on bardzo podobny do kodu używanego przy pisaniu serwera. Jedynymi różnicami są brak deklaracji i uruchomienia serwera oraz zdefiniowanie wielkości bufora (różne dla małego Arduino i większych płytek). Należy pamiętać, że w przypadku Ethernetu musimy zapewnić unikalność adresów MAC!

#ifdef AVR

// część dla Arduino i Ethernet Shield

#include <SPI.h>
#include <Ethernet.h>

#define POT_PIN A1
#define KEY_PIN A0

byte mac[] = { 
  0xDE, 0xAD, 0xBE, 0xEF, 0xFE, 0xEE };

#define MyServer EthernetServer
#define MyClient EthernetClient
#define SERIAL_SPEED 9600

void init_network(void)
{

    Ethernet.begin(mac);
    while (Ethernet.linkStatus() == LinkOFF) {
        Serial.println(F("Ethernet cable is not connected."));
        delay(500);
    }
    // dajmy mu trochę czasu na połączenie
    delay(1000);
    
    Serial.print(F("Adres: "));
    Serial.println(Ethernet.localIP());

}

#else

// część dla ESP

#ifdef ESP32

#include <WiFi.h>
#define POT_PIN 32
#define KEY_PIN 16

#else

#include <ESP8266WiFi.h>
#define POT_PIN A0
#define KEY_PIN 4

#endif

const char* ssid = "My_SSID";
const char* password = "My_Password";

#define MyServer WiFiServer
#define MyClient WiFiClient
#define SERIAL_SPEED 115200


void init_network(void)
{
    WiFi.mode(WIFI_STA);
    WiFi.begin(ssid, password);

    while (WiFi.status() != WL_CONNECTED) {
        delay(500);
        Serial.print(".");
    }
    Serial.println();
    Serial.print("Połączono z WiFi, adres: ");
    Serial.println(WiFi.localIP());
}

#endif

void setup(void)
{
    Serial.begin(SERIAL_SPEED);
#ifdef __AVR_ATmega32U4__
    while (!Serial); // potrzebne tylko dla Leonardo/Micro
#endif
    init_network();
    pinMode(KEY_PIN, INPUT_PULLUP);
}

#ifdef AVR
#define BUFFER_LENGTH 128
#else
#define BUFFER_LENGTH 1024
#endif

Pierwszy klient będzie bardzo prosty. Nie musimy na razie uruchamiać naszego serwera, zamiast tego połączymy się z serwerem Google:

const char Server[]="www.google.pl";
const int ServerPort = 80;

void loop()
{
    MyClient client;
    if (!client.connect(Server, ServerPort)) {
        Serial.println(F("Brak połączenia z serwerem"));
        delay(5000);
        return;
    }
    
    // pytamy googla o stronę główną
    client.print(F("GET / HTTP/1.1\r\nHost: www.google.pl\r\nCpnnection: Close\r\n\r\n"));
    
    
    // po prostu wypisujemy na monitorze serial
    // wszystko co dostaliśmy z serwera
    
    while (client.connected()) {
        if (client.available()) {
            char c = client.read();
            Serial.print(c);
        }
        else {
            delay(5); // poczekamy chwilę aż serwer wyśle coś więcej
        }
    }
    client.stop();

    while(1) delay(1); // koniec pracy!

}

Po uruchomieniu - o ile nasz klient ma dostęp do Internetu - powinien połączyć się z serwerem Google, pobrać zawartość strony głównej i wypisać ją na monitorze Serial:

google.thumb.png.81f551ecad4a1e0d896183007cd9efdd.png

Jak widać, klient bardzo grzecznie pobrał wszystko co mu google wysłał i bez wnikania w szczegóły wyrzucił na wyjście.

No nic - od klienta powinniśmy oczekiwać czegoś więcej. Przede wszystkim reakcji na błędy... a już na pewno stwierdzenia, czy nie wystąpił błąd. Spróbujmy więc stworzyć...

sprytniejszego klienta HTTP

Tym razem będziemy łączyć się z naszym serwerem, więc musimy pamiętać, aby go uruchomić!

// Podaj właściwy adres i port serwera

IPAddress Server(192,168,1,5);
const int ServerPort = 8001;


void loop()
{
    MyClient client;
    char bufor[BUFFER_LENGTH];
    
    if (!client.connect(Server, ServerPort)) {
        Serial.println(F("Brak połączenia z serwerem"));
        delay(5000);
        return;
    }
    
    client.setTimeout(5000);

    client.print(F("GET / HTTP/1.0\r\n\r\n"));
    
    // wczytujemy pierwszą linię odpowiedzi serwera
    
    int n = client.readBytesUntil('\n',bufor, BUFFER_LENGTH-1);
    bufor[n]=0; // uzupełniamy kończące '\0'
    
    // teraz po prostu sprawdzimy, czy w buforze znajduje się
    // string " 200 " - jeśli nie, jest to błąd!
    
    if (!strstr(bufor, " 200 ")) {
        client.stop(); // dalej nie gadamy
        Serial.print(F("Otrzymano odpowiedź: "));
        Serial.println(bufor);
        delay(10000); // czekamy 10 sekund
        return;
    }
    
    // możemy pominąć pozostałe nagłówki jako mało interesujące:
    
    bool naglowki_wczytane = false;
    while(client.connected()) {
        n = client.readBytesUntil('\n',bufor,BUFFER_LENGTH-1);
        bufor[n]=0;
        Serial.print("Nagłówek: ");
        Serial.println(bufor);
        if (n == 1) { // w buforze jest jeden znak '\r'
            naglowki_wczytane = true;
            break;
        }
    }
    if (!naglowki_wczytane) {
        Serial.println(F("Błąd odczytu nagłówków"));
        client.stop();
        delay(10000);
        return;
    }
    
    // teraz całą resztę wczytujemy do bufora
    
    n=client.readBytes(bufor, BUFFER_LENGTH-1);
    bufor[n]=0;
    client.stop();
    
    Serial.print(F("Odpowiedź serwera: "));
    Serial.println(bufor);
    
    delay(5000);
}

Trochę tu pooszukiwaliśmy - nie sprawdzamy całej pierwszej linii, ale wystarczy aby linia zawierała napis "<spacja>200<spacja>" - możemy to uznać za potwierdzenie.

Tym razem zobaczymy, jak działa serwer w połączenia z dwoma klientami. Po ukazaniu się pierwszej informacji wchodzimy przeglądarką na adres:

http://adres_ip_serwera/set/123

Po powrocie do monitora widzimy, że serwer zapamiętał tę liczbę i bardzo grzecznie nam ją przekazuje. A więc już teraz możemy zobaczyć, że serwer może służyć jako pośrednik wymiany danych między dwoma klientami!

klient2.thumb.png.b35012c78105ff9209b3f6dcb0d1efa9.png

Jeśli jednak przyjrzymy się uważniej temu, co wypisuje monitor serial, zobaczymy że coś jest nie w porządku. Na wszelki wypadek możemy włączyć opcję "pokaż znacznik czasu" w monitorze i...

klient_2a.thumb.png.8169ad7591e5ef61a15f13945da6f039.png

O właśnie. Między odebraniem ostatniej linii nagłówków a odebraniem właściwych danych mija dokładnie 5 sekund. Czyżby serwer opóźniał w jakiś sposób wysyłanie danych?

Nie - serwer wysyła tak jak trzeba. Po prostu dla bezpieczeństwa ustawiliśmy w kodzie:

client.timeout(5000);

i w związku z tym klient nie jest w stanie stwierdzić, czy serwer rzeczywiście się rozłączył - na wszelki wypadek czekając 5 sekund. Jak temu zaradzić?

Otóż na razie korzystaliśmy z najprostszej metody: czytamy z klienta aż do końca. Problematyczna jest tu po prostu klasa Stream i jej metoda read(), która nie potrafi jednoznacznie zasygnalizować czy klient już zakończył połączenie, czy namyśla się nad wysłaniem dalszych danych. A readBytes na wszelki wypadek czeka te 5 sekund zanim zwróci wynik...

Co w takiej sytuacji? Teoretycznie można by czytać sobie po znaku i w przypadku braku owego sprawdzać, czy klient się przypadkiem nie rozłączył. Nie byłoby to specjalnie efektywne - poza tym metoda "czytaj do końca" ma jedną zasadniczą wadę: tak naprawdę nie wiemy, z jakich powodów ów koniec nastąpił; być może swerwer wysłał już wszystko co ma do wysłania - a być może jakaś awaria (serwera czy któregoś z pośredniczących routerów) uniemożliwiła mu wysłanie wszystkiego do końca.

Na szczęście istnieje na to bardzo prosty sposób. Serwer wysyła nagłówek ContentLength informujący, ile bajtów będzie liczyła właściwa odpowiedź. Klient powinien zanalizować ten nagłówek i po odebraniu tylu bajtów nie czekać więcej, tylko zamykać połączenie, a w przypadku przedwczesnego zakończenia transmicji (czyli odebrania mniejszej ilości bajtów od zadeklarowanej i wykrycia końca transmisji) zasygnalizować błąd.

Jeśli korzystamy z serwera w pythonie ma on już wbudowaną taką funkcjonalność. Jeśli natomiast jako serwera używamy drugiego egzemplarza płytki - należy nieco zmodyfikować kod serwera. Nowy fragment kodu będzie wyglądać tak:

    int pot = analogRead(POT_PIN);
    int key = digitalRead(KEY_PIN);
    int length = sprintf(bufor, "%d %d %d\n", pot, key, nasza_zmienna);
    
    // wypisujemy nagłówki
    
    client.print(F("HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length="));
    client.print(length);
    client.print(F("\r\n\r\n"));
    
    // teraz jeśli zapytanie było GET wysyłamy przygotowane dane
    if (!isHead) {
        client.print(bufor);
    }

a kompletny kod dostępny jest w załączniku.

Teraz zajmiemy się klientem. Nie możemu już sobie pozwolić na pominięcie wszystkiego aż do pustej linii - musimy analizować wszystkie nagłówki, bo nie wiadomo w którym (i czy w ogóle) będzie interesująca nas wartość. Przyjmijmy, że jeśli nagłówek ContentLength nie wystąpił - wracamy do poprzedniej metody czytania aż do końca połączenia. A więc stwórzmy teraz...

szybszego klienta HTTP

Nowy kod wygląda w ten sposób:

// Podaj właściwy adres i port serwera

IPAddress Server(192,168,1,5);
const int ServerPort = 8001;

#define LENGTH_UNKNOWN -1

void loop()
{
    MyClient client;
    char bufor[BUFFER_LENGTH];
    
    
    if (!client.connect(Server, ServerPort)) {
        Serial.println(F("Brak połączenia z serwerem"));
        delay(5000);
        return;
    }
    client.setTimeout(5000);
    client.print(F("GET / HTTP/1.0\r\n\r\n"));
    
    // wczytujemy pierwszą linię odpowiedzi serwera
    
    int n = client.readBytesUntil('\n',bufor, BUFFER_LENGTH-1);
    bufor[n]=0; // uzupełniamy kończące '\0'
    
    // teraz po prostu sprawdzimy, czy w buforze znajduje się
    // string " 200 " - jeśli nie, jest to błąd!
    
    if (!strstr(bufor, " 200 ")) {
        client.stop(); // dalej nie gadamy
        Serial.print(F("Otrzymano odpowiedź: "));
        Serial.println(bufor);
        delay(10000); // czekamy 10 sekund
        return;
    }
    
    // wczytujemy po kolei nagłówki, szukając Content-Length
    
    int ContentLength = LENGTH_UNKNOWN;
    
    bool naglowki_wczytane = false;
    while(client.connected()) {
        n = client.readBytesUntil('\n',bufor,BUFFER_LENGTH-1);
        bufor[n]=0;
        Serial.print("Nagłówek: ");
        Serial.println(bufor);
        if (n == 1) { // w buforze jest jeden znak '\r'
            naglowki_wczytane = true;
            break;
        }
        if (!strncasecmp(bufor,"content-length:", 15)) {
            ContentLength = atoi(bufor+15);
        }
    }
    if (!naglowki_wczytane) {
        Serial.println(F("Błąd odczytu nagłówków"));
        client.stop();
        delay(10000);
        return;
    }
    
    Serial.print(F("Rozmiar danych: "));
    Serial.println(ContentLength);
    
    // teraz całą resztę wczytujemy do bufora
    
    if (ContentLength > BUFFER_LENGTH -1 ||
        ContentLength == LENGTH_UNKNOWN) {
        ContentLength = BUFFER_LENGTH - 1;
    }
    
    n=client.readBytes(bufor, ContentLength);
    bufor[n]=0;
    client.stop();
    
    if (n < ContentLength) {
        Serial.println(F("Odpowiedź niekompletna"));
    }
    else {
        Serial.print(F("Odpowiedź serwera: "));
        Serial.println(bufor);
    }
    
    delay(5000);
}

Jak widać nieco się skomplikował. Pewnie nie wymaga objaśnień poza jednym drobiazgiem:

Zmienna ContentLength jest zdeklarowana jako int. Nasuwałoby się od razu - dlaczego nie unsigned int? Przecież długość nie może być ujemna...

Owszem, moglibyśmy zdeklarować ją jako unsigned. Tyle że wtedy musielibyśmy wprowadzić dodatkową zmienną tylko do tego, aby zasygnalizować wystąpienie tego nagłówka (bo wartość zero jest jak najbardziej prawidłowa). W tym przypadku podstawiamy liczbę ujemną przed czytaniem nagłówków i po ich odczytaniu od razu wiemy: jeśli liczba jest nieujemna mamy tam wielkość przesyłanych danych, w przeciwnym przypadku wielkość jest nieznana.

Po uruchomieniu wyniki wyglądają następująco:

klient_3.thumb.png.9fb2ab06aa3a49ba185fa8c87fad9fce.png

Jak widać nie ma tu żadnego oczekiwania, wyniki pokazują się natychmiast.

No, to już wiemy po co są nagłówki (przynajmniej niektóre). Jak widać - niosą one ze sobą różne informacje: w przypadku klienta - np. o tym, w jakiej postaci chaciałby najchętniej mieć podane dane; w przpadku serwera - w jakiej postaci te dane podano. Ale może czas już na coś konkretnego... w końcu do płytki mamy podłączony jakiś potencjometr i przycisk a dotychczas go nie używaliśmy... A więc naprawmy tę sytuację tworząc...

prawdziwego klienta HTTP dla IoT

Tym razem nie będziemy jednak wrzucać całego kodu do loop(), stworzymy funkcję, której zadaniem będzie:

  • wysłanie na serwer wartości parametru;
  • odebranie danych z serwera i wypisanie ich na monitorze serial;
  • zwrócenie informacji czy operacja się udała.

Ta funkcja powinna być wywołana po wciśnięciu przycisku, a argumentem funkcji niech będzie wartość odczytana z wejścia analogowego.

Tym razem musimy skonstruować zapytanie. Wbrew pozorom jest to bardzo proste - za pomocą funkcji sprintf umieszczamy w buforze odpowiedni napis i wysyłamy zawartość bufora na serwer. Nowy kod będzie wyglądać następująco:

// Podaj właściwy adres i port serwera

IPAddress Server(192,168,1,5);
const int ServerPort = 8001;

#define LENGTH_UNKNOWN -1

bool zapisz(int dane)
{
    // funkcja zwraca true jeśli udało się zapisać dane
    // false jeśli wystąpił błąd
    
    MyClient client;
    char bufor[BUFFER_LENGTH];
    
    Serial.print(F("Wartość parametru: "));
    Serial.println(dane);
    if (!client.connect(Server, ServerPort)) {
        Serial.println(F("Brak połączenia z serwerem"));
        delay(50);
        return false;
    }
    client.setTimeout(5000);
    
    // tworzymy zapytanie do serwera
    
    sprintf(bufor,"GET /set/%d HTTP/1.0\r\n\r\n", dane);
    client.print(bufor);
    
    // nie stosujemy Serial.println() gdyż w buforze
    // są już znaki końca linii
    Serial.print(F("Wysyłam zapytanie: "));
    Serial.print(bufor);
    
    // wczytujemy pierwszą linię odpowiedzi serwera
    
    int n = client.readBytesUntil('\n',bufor, BUFFER_LENGTH-1);
    bufor[n]=0; // uzupełniamy kończące '\0'
    
    // teraz po prostu sprawdzimy, czy w buforze znajduje się
    // string " 200 " - jeśli nie, jest to błąd!
    
    if (!strstr(bufor, " 200 ")) {
        client.stop(); // dalej nie gadamy
        Serial.print(F("Otrzymano odpowiedź: "));
        Serial.println(bufor);
        delay(1000); // czekamy sekundę
        return false;
    }
    
    // wczytujemy po kolei nagłówki, szukając Content-Length
    
    int ContentLength = LENGTH_UNKNOWN;
    
    bool naglowki_wczytane = false;
    while(client.connected()) {
        n = client.readBytesUntil('\n',bufor,BUFFER_LENGTH-1);
        bufor[n]=0;
        Serial.print(F("Nagłówek: "));
        Serial.println(bufor);
        
        if (n == 1) { // w buforze jest jeden znak '\r'
            naglowki_wczytane = true;
            break;
        }
        if (!strncasecmp(bufor,"content-length:", 15)) {
            ContentLength = atoi(bufor+15);
        }
    }
    if (!naglowki_wczytane) {
        Serial.println(F("Błąd odczytu nagłówków"));
        client.stop();
        delay(1000);
        return false;
    }
    
    Serial.print(F("Rozmiar danych: "));
    
    Serial.println(ContentLength);
    
    // teraz całą resztę wczytujemy do bufora
    
    if (ContentLength > BUFFER_LENGTH -1 ||
        ContentLength == LENGTH_UNKNOWN) {
        ContentLength = BUFFER_LENGTH - 1;
    }
    
    n=client.readBytes(bufor, ContentLength);
    bufor[n]=0;
    client.stop();
    
    if (n < ContentLength) {
        Serial.println(F("Odpowiedź niekompletna"));
    }
    else {
        Serial.print(F("Odpowiedź serwera: "));
        Serial.println(bufor);
    }
    return true;
}

void loop()
{
    static int lastKey = digitalRead(KEY_PIN);
    int key = digitalRead(KEY_PIN);
    
    // jeśli klawisz został wciśnięty, wysyłamy wartość
    // odczytaną z wejścia analogowego na serwer
    
    if (lastKey && !key) {
        int i,pot;
        pot = analogRead(POT_PIN);
        for (i=0; i<10; i++) { // więcej niż 10 prób wysłania nie ma sensu
            
            if (zapisz(pot)) break;
        }
        if (i==10) {
            Serial.println(F("Nie udało się wysłać danych"));
        }
    }
    lastKey=key;
}

Po uruchomieniu programu możemy zobaczyć, że każde naciśnięcie przycisku powoduje zmianę wartości podawanej przez serwer:

klient_4.thumb.png.2069ca27481982422b2d806b074a3042.png

Aby to lepiej zobrazować możemy spróbować podejrzeć co się dzieje na serwerze w czasie rzeczywistym. Jeśli mamy jakiegoś linuksa (prawdopodobnie na maku też to zadziała) możemy wpisać po prostu polecenie:

watch -n 5 wget -q -O - http://127.0.0.1:8001

Oczywiście jeśli korzystamy z innego serwera niż nasz pythonowy musimy wpisać zamiast 127.0.0.1:8001 właściwy adres i port.

Wykonanie tego polecenia spowoduje, że polecenie wget będzie wykonywane co 5 sekund, a wartość odczytana z serwera będzie wyświetlana na ekranie.

W przypadku windowsa nie jest to już takie proste... ale od czego mamy nagłówki serwera? W pliku miniserver.py znajdujemy linię zawierającą:

        #self.send_header("Refresh", 5)

i odkomentowujemy polecenie usuwając znak #:

        self.send_header("Refresh", 5)

i oczywiście restartujemy serwer. Spowoduje to wysłanie dodatkowego nagłówka

Refresh: 5

Jeśli teraz wejdziemy na ten adres zwykłą przeglądarką - będzie ona odświeżać wyświetlane wyniki co 5 sekund. Oczywiście zadziała to również na Linuksie i Maku!

Niezależnie od metody - możemy teraz zobaczyć jak po wciśnięciu przycisku dane wędrują od naszego klienta poprzez serwer do przeglądarki.

I to by było na tyle. Miała być co prawda jeszcze czwarta część poświęcona praktycznym rozwiązaniom - okazało się jednak, że ilość materiału który należałoby omówić wymaga osobnego, kilkuczęściowego artykułu. Toteż na tym na razie musimy poprzestać i pożegnać się do następnego spotkania.

Kody wszystkich programów dostępne są w załączniku: klient.zip

Spis treści serii artykułów:

  • Lubię! 2
Link do komentarza
Share on other sites

Dołącz do dyskusji, napisz odpowiedź!

Jeśli masz już konto to zaloguj się teraz, aby opublikować wiadomość jako Ty. Możesz też napisać teraz i zarejestrować się później.
Uwaga: wgrywanie zdjęć i załączników dostępne jest po zalogowaniu!

Anonim
Dołącz do dyskusji! Kliknij i zacznij pisać...

×   Wklejony jako tekst z formatowaniem.   Przywróć formatowanie

  Dozwolonych jest tylko 75 emoji.

×   Twój link będzie automatycznie osadzony.   Wyświetlać jako link

×   Twoja poprzednia zawartość została przywrócona.   Wyczyść edytor

×   Nie możesz wkleić zdjęć bezpośrednio. Prześlij lub wstaw obrazy z adresu URL.

×
×
  • Utwórz nowe...

Ważne informacje

Ta strona używa ciasteczek (cookies), dzięki którym może działać lepiej. Więcej na ten temat znajdziesz w Polityce Prywatności.