This shows you the differences between two versions of the page.
pm:prj2025:mdinica:bogdan.rusu1707 [2025/05/07 00:11] bogdan.rusu1707 created |
pm:prj2025:mdinica:bogdan.rusu1707 [2025/05/30 03:52] (current) bogdan.rusu1707 [Bibliografie/Resurse] |
||
---|---|---|---|
Line 5: | Line 5: | ||
Proiectul meu consta in realizarea unei console de jocuri arcade retro (Tetris, Pacman si altele) din dorinta de a face un proiect pe care sa il pot folosi si dupa PM. | Proiectul meu consta in realizarea unei console de jocuri arcade retro (Tetris, Pacman si altele) din dorinta de a face un proiect pe care sa il pot folosi si dupa PM. | ||
- | Consola va fi encapsulata intr-o carcasa printata 3d si va avea un ecran color pe care se desfasoara jocurile, incluzand un meniu de selectie. Pe langa acestea, vor fi amplasate butoane pentru controlul in cadrul jocurilor, pentru controlul sunetului sau a statusul consolei (on, on, sleep). | + | Consola va fi encapsulata intr-o carcasa printata 3d si va avea un ecran color pe care se desfasoara jocurile, incluzand un meniu de selectie. Pe langa acestea, vor fi amplasate butoane pentru controlul in cadrul jocurilor, pentru controlul sunetului sau a statusul consolei (on, sound, sleep). |
Ceea ce doresc sa realizez este si functionalitatea de power management a consolei pentru conservarea energiei cand nu se joaca nimeni pe ea. | Ceea ce doresc sa realizez este si functionalitatea de power management a consolei pentru conservarea energiei cand nu se joaca nimeni pe ea. | ||
Ideea a pornit mai mult din dorinta de a face ceva fun si interactiv si sa invat mai mult despre cum se poate construi un proiect practic pana la stadiul de a fi chiar utilizabil. | Ideea a pornit mai mult din dorinta de a face ceva fun si interactiv si sa invat mai mult despre cum se poate construi un proiect practic pana la stadiul de a fi chiar utilizabil. | ||
+ | |||
+ | Pentru salvarea high scores sau a nivelurilor la care jucatorul a ajuns voi folosi un card micro sd conectat la placuta care sa memoreze aceste date. | ||
+ | |||
</note> | </note> | ||
===== Descriere generală ===== | ===== Descriere generală ===== | ||
+ | Placuta de ESP32 va actiona drept 'creierul' consolei de joc. De la acesta se vor trimite comenzile de desenare prin SPI catre ecran, se vor salva si respectiv retrage datele despre highscores si nivelurile la care a ajuns jucatorul si va interpreta comenzile de la butoane prin intreruperi. De asemenea prin timerele interne ale placutei se va monitoriza perioada de inactivitate pentru a trece consola intr-un sistem de low power pentru conservarea bateriei. | ||
{{ :pm:prj2025:mdinica:ca:rusu_bogdan:diagrama_pm.jpg |}} | {{ :pm:prj2025:mdinica:ca:rusu_bogdan:diagrama_pm.jpg |}} | ||
===== Hardware Design ===== | ===== Hardware Design ===== | ||
+ | {{ :pm:prj2025:mdinica:ca:rusu_bogdan:schema_bogdan_pm_bb.png?300 |}} | ||
+ | ===== Placa de dezvoltare ESP32 ====== | ||
+ | <note tip> | ||
+ | Este “creierul” consolei: un microcontroller dual-core Tensilica LX6 la 240 MHz, cu 520 KB SRAM și Wi-Fi/Bluetooth integrate. Primește alimentare prin pinul VIN (5 V) sau prin USB, iar regulatorul onboard îi trimite stabil 3,3 V la nucleu. Toate perifericele (SPI, ADC, GPIO, PWM, Deep-Sleep) sunt gestionate de ESP32. | ||
+ | </note> | ||
+ | ===== TFT 2.2″ ILI9341 cu microSD ===== | ||
<note tip> | <note tip> | ||
- | Aici puneţi tot ce ţine de hardware design: | + | Un display color 320×240 px care comunică prin SPI. Pe aceeași magistrală împarte datele între ecran și cardul microSD, având două CS-uri separate. Backlight-ul are un pin marcat “BL” cu “PWM SAFE” – silkscreen-ul indică faptul că placa conține intern tranzistor și rezistență de limitare, astfel încât poți aplica direct un semnal PWM din ESP32 pentru reglaj de luminozitate. |
- | * listă de piese | + | |
- | * scheme electrice (se pot lua şi de pe Internet şi din datasheet-uri, e.g. http://www.captain.at/electronic-atmega16-mmc-schematic.png) | + | |
- | * diagrame de semnal | + | |
- | * rezultatele simulării | + | |
</note> | </note> | ||
+ | ^ Modul Pin ^ ESP32 Pin ^ Protocol ^ Funcție ^ | ||
+ | | VCC | 3V3 | Power | Alimentare 3.3 V | | ||
+ | | GND | GND | Power | Masă comună | | ||
+ | | SCK | GPIO18 | SPI_CLK | Ceas SPI | | ||
+ | | MISO | GPIO19 | SPI_MISO | Date SPI din SD | | ||
+ | | MOSI | GPIO23 | SPI_MOSI | Date SPI către TFT/SD | | ||
+ | | CS | GPIO5 | SPI_CS | Chip-select pentru TFT | | ||
+ | | DC | GPIO2 | GPIO/SPI | Data/Command TFT | | ||
+ | | RST | GPIO16 | GPIO | Reset hardware TFT | | ||
+ | | SDCS | GPIO4 | SPI_CS | Chip-select pentru microSD | | ||
+ | | BL | GPIO15 | PWM | Reglaj backlight (PWM SAFE) | | ||
+ | |||
+ | ===== Card microSD ===== | ||
+ | <note tip> | ||
+ | Se introduce direct în slotul modulului TFT. Servește la stocarea high-score-urilor, configurațiilor și eventual a altor asset-uri (hărţi de nivel, fonturi). Accesul se face cu biblioteca FatFS prin API-ul SD.begin(SD_CS) și operaţiuni standard SD.open() | ||
+ | </note> | ||
+ | |||
+ | ===== Elemente de control (9 butoane INPUT_PULLUP + întreruperi) ===== | ||
+ | <note tip> | ||
+ | Butoane (9×, INPUT_PULLUP + întreruperi) | ||
+ | |||
+ | * 4× direcţionale (D-pad) pentru navigare în joc. | ||
+ | |||
+ | * 2× A/B pentru acţiuni principale şi secundare. | ||
+ | |||
+ | * Start/Select pentru control de meniu și pauză. | ||
+ | |||
+ | * Power pentru deep-sleep/wake. | ||
+ | |||
+ | * Fiecare e configurat ca pinMode(..., INPUT_PULLUP) și declanșează ISR pe front FALLING, asigurând reacţie instantă fără polling. | ||
+ | </note> | ||
+ | ^ Funcție ^ GPIO Pin ^ Config ^ Interrupție ^ | ||
+ | | D-pad Sus | GPIO32 | INPUT_PULLUP | FALLING (apăsare) | | ||
+ | | D-pad Jos | GPIO33 | INPUT_PULLUP | FALLING (apăsare) | | ||
+ | | D-pad Stânga | GPIO25 | INPUT_PULLUP | FALLING (apăsare) | | ||
+ | | D-pad Dreapta | GPIO26 | INPUT_PULLUP | FALLING (apăsare) | | ||
+ | | Buton A | GPIO27 | INPUT_PULLUP | FALLING (apăsare) | | ||
+ | | Buton B | GPIO14 | INPUT_PULLUP | FALLING (apăsare) | | ||
+ | | Sound| GPIO21 | INPUT_PULLUP | FALLING (apăsare) | | ||
+ | | Menu| GPIO22 | INPUT_PULLUP | FALLING (apăsare) | | ||
+ | | Power | GPIO12 | INPUT_PULLUP + WAKE | FALLING (deep-sleep wake) | | ||
+ | |||
+ | ===== Monitorizare nivel baterie ===== | ||
+ | <note tip> | ||
+ | Două rezistoare (100kΩ/100 kΩ) formează un divizor care aduce maxim 4,2 V de la BAT+ la ~2,1 V, sigur pentru ADC-ul ESP32. Se conecteaza punctul de măsură la GPIO35 (ADC1_CH7) cu atenuare de 11 dB și măsori nivelul bateriei în cod, transformând valoarea ADC invers la tensiunea reală a pachetului. | ||
+ | </note> | ||
+ | ^ Divizor node ^ ESP32 Pin ^ Protocol ^ Descriere ^ | ||
+ | | Între R1 și R2 | GPIO35 | ADC1_CH7 (ADC_11db) | Măsurare tensiune baterie (factor 2×) | | ||
+ | |||
+ | ===== Alimentare și încărcare baterie ===== | ||
+ | <note tip> | ||
+ | Două celule Li-Ion Samsung 25R (3,7 V nominal, 2 500 mAh fiecare) montate paralel într-un holder “go-bare”. În paralel se obtin ~5 000 mAh și tensiune constantă de 3,7 V, cu curent disponibil de zeci de amperi pentru perioade scurte. | ||
+ | |||
+ | Încărcător CC/CV pentru o singură celulă Li-Ion, alimentat la 5 V (micro-USB sau boost). Conține circuit de protecție la supraîncărcare, scurt-circuit și sub-tensiune. Pinii B+/B– se conectează la baterii, iar OUT+/OUT– livrează tensiunea protejată către consolă. | ||
+ | </note> | ||
+ | |||
+ | * TP4056 VIN (+) → 5 V de la boost/USB | ||
+ | |||
+ | * TP4056 GND (–) → GND comun | ||
+ | |||
+ | * TP4056 B+ / B– → suportul 2×18650 (celule în paralel) | ||
+ | |||
+ | * TP4056 OUT+ / OUT– → rail de alimentare (spre VIN al ESP32) | ||
+ | ===== Bill of materials ===== | ||
+ | <note tip> | ||
+ | ^ Nume piesă ^ Cantitate ^ Link ^ | ||
+ | | Placă de dezvoltare ESP32 cu WiFi & Bluetooth 4.2 | 1 | [[https://www.optimusdigital.ro/ro/placi-cu-bluetooth/4371-placa-de-dezvoltare-esp32-cu-wifi-i-bluetooth-42.html?search_query=esp32&results=28|ESP32]] | | ||
+ | | Display LCD TFT 320×240 2.2″ cu slot microSD | 1 | [[https://www.optimusdigital.ro/ro/optoelectronice-lcd-uri/3164-display-lcd-tft-320x240-de-22-cu-slot-microsd.html?search_query=Display+LCD+TFT+320x240+de+2.2%27%27+cu+Slot+MicroSD&results=1|Display TFT 2.2″]] | | ||
+ | | Modul DC-DC 1.5–5 V → 3.3 V | 1 | [[https://www.optimusdigital.ro/en/3-v-step-up-power-supplies/12537-dc-dc-18v-5v-to-33v-booster-and-buck-power-modules.html?search_query=power+supply&results=1196|DC-DC 1.5–5 V→3.3 V]] | | ||
+ | | Modul încărcător TP4056 LiPo 1 A cu protecție | 1 | [[https://www.optimusdigital.ro/en/chargers/7534-incarcator-tp4056-cu-micro-usb-pt-baterie-lipo-1a-cu-protectie-pentru-circuite.html?search_query=tp4056&results=4|TP4056 charger]] | | ||
+ | | Card de memorie MicroSDHC 4 GB, Class 10 | 1 | [[https://www.emag.ro/card-de-memorie-maxell-micro-sdhc-4gb-class-10-ml-sdmicro-4gb-class10/pd/DVYNWQBBM/?utm_source=mobile%20app&utm_medium=ios&utm_campaign=share%20product|MicroSD card 4 GB]] | | ||
+ | | Suport baterii 2×18650 | 1 | [[https://www.optimusdigital.ro/en/battery-holders/941-2x18650-battery-case.html?search_query=18650&results=80|Battery holder 2×18650]] | | ||
+ | | Celule Li-Ion 18650 3.7 V | 2 | [[https://sigmanortec.ro/baterie-lithium-18650-3-7v-2600mah|18650 Li-Ion 3.7 V]] | | ||
+ | | Buzzer pasiv | 1 | – | | ||
+ | | Push-buttons tactile (momentary) | 9 | – | | ||
+ | | Fire jumper (duse-întoarse) | 10 | – | | ||
+ | | Rezistoare 100 kΩ | 2 | – | | ||
+ | </note> | ||
+ | ===== Proof of concept ===== | ||
+ | {{ :pm:prj2025:mdinica:ca:rusu_bogdan:power.jpg?300 |}} | ||
+ | {{ :pm:prj2025:mdinica:ca:rusu_bogdan:sdok.jpg?300 |}} | ||
+ | {{ :pm:prj2025:mdinica:ca:rusu_bogdan:baterie.jpg?300 |}} | ||
===== Software Design ===== | ===== Software Design ===== | ||
+ | Link proiect Github: https://github.com/Bogdan-Rusu17/Retro_Games_Console | ||
+ | |||
+ | Link video cu demo: https://youtu.be/d9HI2PYjIhI | ||
+ | |||
+ | |||
+ | Cateva fragmente de cod interesante: | ||
+ | ===== Cum se reprezinta piesele de tetris? ===== | ||
<note tip> | <note tip> | ||
- | Descrierea codului aplicaţiei (firmware): | + | |
- | * mediu de dezvoltare (if any) (e.g. AVR Studio, CodeVisionAVR) | + | |
- | * librării şi surse 3rd-party (e.g. Procyon AVRlib) | + | <code cpp> |
- | * algoritmi şi structuri pe care plănuiţi să le implementaţi | + | |
- | * (etapa 3) surse şi funcţii implementate | + | const int8_t shapes[7][4][4][2] = { |
+ | // I-piece | ||
+ | { // 0° 90° 180° 270° | ||
+ | {{-2,0},{-1,0},{0,0},{1,0}}, //orizontal | ||
+ | {{0,-2},{0,-1},{0,0},{0,1}}, // vertical | ||
+ | {{-2,-1},{-1,-1},{0,-1},{1,-1}},// orizontal | ||
+ | {{-1,-2},{-1,-1},{-1,0},{-1,1}} // vertical | ||
+ | }, | ||
+ | //... | ||
+ | }; | ||
+ | </code> | ||
+ | |||
+ | * formele in coordonate carteziene pentru piesele de tetris | ||
+ | * se porneste de la o forma fixa pentru fiecare piesa | ||
+ | * si cel mai simplu mod de a obtine coordonatele pentru o rotatie | ||
+ | * e sa inmultim cu matricea de rotatie 90 de grade: | ||
+ | { | ||
+ | {cos(pi / 2), sin(pi / 2)}, | ||
+ | {cos(pi / 2), -sin(pi / 2)} | ||
+ | } | ||
</note> | </note> | ||
+ | ===== Intreruperi pe butoane ===== | ||
+ | <note tip> | ||
- | ===== Rezultate Obţinute ===== | + | <code cpp> |
+ | void IRAM_ATTR onBtn(volatile uint32_t &lastTime, uint32_t debounce, volatile bool &flag) { | ||
+ | uint32_t now = micros(); | ||
+ | if (now - lastTime > debounce) flag = true; | ||
+ | lastTime = now; | ||
+ | } | ||
+ | </code> | ||
+ | * helper de ISR pt butoane ca sa nu am cod duplicat | ||
+ | * verific la debounce sa fi trecut un numar de secunde ca sa iau apasarea in considerare | ||
+ | |||
+ | |||
+ | <code cpp> | ||
+ | void IRAM_ATTR onUp() { | ||
+ | if (state == TETRIS) { | ||
+ | onBtn(lastUp, DEB_SOUND, btnUpFlag); | ||
+ | } else if (state == SNAKE) { | ||
+ | onBtn(lastUp, DEB_FAST, btnUpFlag); | ||
+ | } | ||
+ | |||
+ | onBtn(lastUp, DEB_SOUND, btnUpFlag); | ||
+ | } | ||
+ | |||
+ | // alte butoane | ||
+ | </code> | ||
+ | |||
+ | * interuperi pentru butoane cu rate custom de debounce dependent de status consola | ||
+ | * de exemplu la butonul UP cand suntem in jocul de tetris cu el rotim piesa si nu | ||
+ | * vrem sa se roteasca foarte repede ca sa controlam precizia | ||
+ | * cand suntem pe meniu vrem cam tot acelasi debounce rate | ||
+ | * dar cand suntem pe snake vrem sa avem rapiditate | ||
+ | </note> | ||
+ | |||
+ | ===== PWM pentru controlul backlight ecran ===== | ||
<note tip> | <note tip> | ||
- | Care au fost rezultatele obţinute în urma realizării proiectului vostru. | + | |
+ | <code cpp> | ||
+ | pinMode(TFT_BL, OUTPUT); | ||
+ | analogWrite(TFT_BL, brightness); | ||
+ | |||
+ | ledcAttachChannel(BUZZER_PIN, 2000, 8, 2); | ||
+ | ledcWriteTone(BUZZER_PIN, 0); | ||
+ | //... | ||
+ | |||
+ | </code> | ||
+ | * initializez PWM-ul pe pinul capabil PWM TFT_BL pe canalul 0 pe care si-l ia implicit | ||
+ | * initializez buzzer ul pe canalul 2 de PWM ca sa nu intre in conflict cu backlight-ul | ||
+ | |||
+ | <code cpp> | ||
+ | if (digitalRead(BTN_A)==LOW && now - lastBright > BRIGHT_DEBOUNCE_MS) { | ||
+ | brightness = min(brightness + 16, 255); | ||
+ | analogWrite(TFT_BL, brightness); | ||
+ | lastBright = now; | ||
+ | } | ||
+ | if (digitalRead(BTN_B)==LOW && now - lastBright > BRIGHT_DEBOUNCE_MS) { | ||
+ | brightness = max(brightness - 16, 0); | ||
+ | analogWrite(TFT_BL, brightness); | ||
+ | lastBright = now; | ||
+ | } | ||
+ | </code> | ||
+ | |||
+ | Prin functia `analogWrite` cresc sau descresc factorul de umplere ca sa maresc/micsorez luminozitatea ecranului in termeni discreti pana la rezolutia maxima a timer-ului 0 pe care se bazeaza acest PWM. | ||
</note> | </note> | ||
- | ===== Concluzii ===== | + | ===== SPI pentru comunicatie eficienta prin API-ul ecranului ===== |
+ | <note tip> | ||
+ | <code cpp> | ||
+ | #define TFT_DC 2 | ||
+ | #define TFT_RST -1 // pus la 3v3 | ||
+ | #define TFT_BL 15 // pwm pt luminozitate ecran | ||
+ | #define SD_CS 4 // select la card | ||
- | ===== Download ===== | + | // instantiaza obiectul tft cu care am acces la functiile ecranului |
+ | Adafruit_ILI9341 tft(TFT_CS, TFT_DC, TFT_RST); | ||
+ | /* {...} */ | ||
+ | SPI.begin(18, 19, 23); | ||
+ | tft.begin(); | ||
+ | tft.setRotation(0); | ||
+ | </code> | ||
+ | * initializam liniile protocolului SPI specificand in ordine SCLK, MISO si MOSI | ||
+ | * setam ecranul sa functioneze in regimul de rotatie obisnuita adica cu verticala mai lunga decat orizontala | ||
+ | </note> | ||
+ | |||
+ | ===== Cum folosesc ADC pentru a determina nivelul bateriei? ===== | ||
+ | <note tip> | ||
+ | |||
+ | <code cpp> | ||
+ | analogReadResolution(12); | ||
+ | analogSetAttenuation(ADC_11db); | ||
+ | pinMode(BAT_ADC_PIN, INPUT); | ||
+ | </code> | ||
+ | * ADC pentru baterie, 12 biti rezolutie, 11 db pt zgomot atenuare | ||
+ | |||
+ | <code cpp> | ||
+ | void drawBattery() { | ||
+ | // updateaza bateria doar odata la 1s ca sa nu fie flicker | ||
+ | int now = millis(); | ||
+ | if (now - lastBatUpdate < 1000) { | ||
+ | return; | ||
+ | } | ||
- | <note warning> | + | lastBatUpdate = now; |
- | O arhivă (sau mai multe dacă este cazul) cu fişierele obţinute în urma realizării proiectului: surse, scheme, etc. Un fişier README, un ChangeLog, un script de compilare şi copiere automată pe uC crează întotdeauna o impresie bună ;-). | + | |
- | Fişierele se încarcă pe wiki folosind facilitatea **Add Images or other files**. Namespace-ul în care se încarcă fişierele este de tipul **:pm:prj20??:c?** sau **:pm:prj20??:c?:nume_student** (dacă este cazul). **Exemplu:** Dumitru Alin, 331CC -> **:pm:prj2009:cc:dumitru_alin**. | + | int raw = analogRead(BAT_ADC_PIN); |
+ | float m = (raw / 4095.0f) * 3.3f, v = m * 2.0f * BAT_CAL_FACTOR; | ||
+ | int pct = constrain(int((v - BAT_EMPTY_VOLTAGE) | ||
+ | / (BAT_FULL_VOLTAGE - BAT_EMPTY_VOLTAGE) * 100 + 0.5), 0, 100); | ||
+ | tft.fillRect(0, 0, tft.width(), 20, ILI9341_BLACK); | ||
+ | tft.setCursor(2,2); | ||
+ | tft.setTextSize(2); | ||
+ | tft.setTextColor(ILI9341_WHITE); | ||
+ | tft.print(v,2); | ||
+ | tft.print("V"); | ||
+ | tft.setCursor(tft.width()-50, 2); | ||
+ | tft.print(pct); | ||
+ | tft.print("%"); | ||
+ | } | ||
+ | </code> | ||
+ | * citeste valoarea pe 12 biti de pe pinul de ADC | ||
+ | * obtinem tensiunea la pinul ADC inmultind cu valoarea de referinta | ||
+ | * apoi obtinem valoarea reala a tensiunii din BAT+ a TP4056 inmultind cu 2 | ||
+ | * pt ca avem divizor de tensiune cu 2 rezistenta egale | ||
+ | * calculam procentul de baterie rotunjit la cel mai apropiat int | ||
</note> | </note> | ||
+ | |||
+ | |||
+ | ===== Rezultate Obţinute ===== | ||
+ | |||
+ | <note tip> | ||
+ | [[https://youtube.com/shorts/nCQ47WgPAMU?si=CRBGvuyvwzp_i7NO | Link varianta finala]] | ||
+ | </note> | ||
+ | |||
+ | ===== Concluzii ===== | ||
+ | |||
+ | Per total a fost un proiect destul de interesant de realizat in care am imbinat si cunostinte de electronica si de programare intr-un mod destul de placut si sunt destul de multumit de cum a functionat totul intr-un final. | ||
+ | |||
+ | Mi-ar fi placut in schimb sa gasesc niste fire mai bune de lipit cu care sa realizez proiectul pe perfboard intrucat ar fi aratat mult mai clean..., dar a fost interesant sa imi reamintesc cum se fac lipituri, pentru partea de baterii si la ecran (nu avea pinii pusi). | ||
===== Jurnal ===== | ===== Jurnal ===== | ||
<note tip> | <note tip> | ||
- | Puteți avea și o secțiune de jurnal în care să poată urmări asistentul de proiect progresul proiectului. | + | - 29 Aprilie - Alegere tema proiect |
+ | - 11 Mai - Realizare schema bloc si schema electrica in Fritzing | ||
+ | - 18 Mai - Realizarea hardware-ului in prima etapa | ||
+ | - 25 Mai - Realizarea software in prima etapa | ||
+ | - 29 Mai - Definitivarea hardware, software si a aspectului final al proiectului | ||
</note> | </note> | ||
Line 61: | Line 305: | ||
<note> | <note> | ||
- | Listă cu documente, datasheet-uri, resurse Internet folosite, eventual grupate pe **Resurse Software** şi **Resurse Hardware**. | + | [[https://www.espressif.com/sites/default/files/documentation/esp32_datasheet_en.pdf|Datasheet ESP32]] |
+ | |||
+ | [[https://docs.espressif.com/projects/arduino-esp32/en/latest/api/ledc.html|Ledc library for PWM]] | ||
+ | |||
+ | [[https://github.com/semibran/tetromino|Inspiratie pentru reprezentarea de tetromino-uri]] | ||
+ | |||
+ | [[https://en.wikipedia.org/wiki/Tetromino|Tetromino]] | ||
+ | |||
+ | [[https://cdn-learn.adafruit.com/downloads/pdf/adafruit-gfx-graphics-library.pdf | Adafruit library for gfx]] | ||
+ | |||
+ | [[https://cdn-learn.adafruit.com/downloads/pdf/2-0-inch-320-x-240-color-ips-tft-display.pdf | Adafruit manual pentru ecran]] | ||
</note> | </note> | ||
- | <html><a class="media mediafile mf_pdf" href="?do=export_pdf">Export to PDF</a></html> | + | |