Paolo Tuttoilmondo

To boldly go where no one has gone before

Database per collezionisti gratuito Parte 12

Con questo ariticolo in cui spiegherò come implementare il multiutente per il DB con relativo PopUp di Login, aggiungerò anche un altro aspetto fondamentale che è quello della gestione della variante nelle carte (Holo, Reverse etc..), concluderò questa serie di articoli.

L’idea di base è quella di rendere questo DB utilizzabile da un numero limitato di utenti, non prevederò quindi un form di registrazione per chiunque voglia registrarsi. La creazione dell’utente la farà manualmente il gestore del DB che comunicherà al futuro utente login e password (di libera scelta).

Fase 1: creare la tabella utenti, creare gli utenti, modificare gli attributi utenti, impostare una password in chiaro e oscurata.

Entrare in PostgreSQL con il comando:
docker exec -it pokemon-postgres psql -U pokemon -d pokemon

digitare:
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    username VARCHAR(50) UNIQUE NOT NULL,
    password_hash TEXT NOT NULL,
    is_admin BOOLEAN DEFAULT FALSE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

questo comando potrebbe essere stato già dato in articoli precedenti e generare quindi un errore del tipo "ERROR:  relation "users" already exists" provate quindi a dare il comando:
\d users

per visualizzare la struttura delle tabelle del DB e poi:
SELECT
    id,
    username,
    email,
    is_admin
FROM users;

dovrebbe comparire solo l'utente paolo o altro nome se l'avete cambiato. Per inserire altri utenti per esempio pietro il comando è:
INSERT INTO users (
username,
email,
password_hash,
display_name
)
VALUES
(
'pietro',
'pietro@local',
'TEMP',
'Pietro'
);

per visualizzare l'elenco utenti dare il comando:
SELECT id, username, email, is_admin FROM users;

Dovrebbero esserci due utenti Paolo e Pietro, se volete cambiare dei parametri ad un utente il comando è:
Cambiare username:
UPDATE users
SET username = 'nuovo_username'
WHERE username = 'vecchio_username';

Cambiare email:
UPDATE users
SET email = 'nuova@email.it'
WHERE username = 'pietro';

Cambiare display_name:
UPDATE users
SET display_name = 'Pietro Rossi'
WHERE username = 'pietro';

Cambiare password:
UPDATE users
SET password_hash = '$2b$12$A1B2C3D4'
WHERE username = 'paolo';

Eliminare un utente:
DELETE FROM users
WHERE username = 'test';

Prima di procedere è necessario fare un piccolo passo di sicurezza per far in modo che le password non siano visibili in chiaro ma con hash. uscire dal comando "pokemon=#" con exit e dare il comando:

apt update
apt install -y python3-bcrypt

poi:
python3

import bcrypt

password = "Paolo123"

hashed = bcrypt.hashpw(
    password.encode(),
    bcrypt.gensalt()
)

print(hashed.decode())

Nel campo password scrivi pa password che vuoi uscurare e dopo aver eseguito i comandi Otterrai qualcosa tipo:
$2b$12$A1B2C3D4

copiare tutta la passwor ottenuta e rientrare in PostgreSQL con il comando:
docker exec -it pokemon-postgres psql -U pokemon -d pokemon

digitare:
UPDATE users
SET password_hash = '$2b$12$PR4S04QkiMS1VfKHz29TNuj0SNE23rqjiJ7mQJr1CsL/luuKgKbya'
WHERE username = 'paolo';


Dopo queste modifiche il mio container è andato in crash per farlo ripartire ho dovuto modificare il file database.py con il comando:

nano /opt/pokemon/backend/app/database.py

e sostituire la riga:
DATABASE_URL = "postgresql://pokemon:pokemon123@postgres:5432/pokemon"

con:
DATABASE_URL = "postgresql+psycopg2://pokemon:pokemon123@postgres:5432/pokemon"

Modificare il file requirements.txt:
nano /opt/pokemon/backend/requirements.txt

sostituire il contenuto con:
fastapi
uvicorn[standard]
sqlalchemy
psycopg2-binary
requests
bcrypt
itsdangerous

Per poi ricompilare:
cd /opt/pokemon
docker compose build --no-cache
docker compose up -d

Fase 2: Creare la pagina di login e logout con protezione delle altre da accessi diretti

Creare la pagina di login:
nano /opt/pokemon/backend/app/static/login.html

con questo contenuto:
<!DOCTYPE html>
<html lang="it">
<head>
    <meta charset="utf-8">
    <title>Login - Pokemon DB</title>
    <link rel="stylesheet" href="/static/style.css">
</head>

<body>
    <div class="header">
        <div class="logo">Pokemon DB by Paolo Tuttoilmondo</div>
    </div>

    <div style="display:flex; justify-content:center; align-items:center; min-height:calc(100vh - 70px);">
        <div class="set-info" style="width:400px; max-width:90vw; padding:30px;">

            <h2 style="text-align:center; margin-bottom:30px;">Accesso</h2>
            <div class="form-group">
                <label>Username</label>
                <input id="username" type="text" autocomplete="username">
            </div>
            <div class="form-group">
                <label>Password</label>
                <input id="password" type="password" autocomplete="current-password">
            </div>

            <button id="loginButton" style=" width:100%; margin-top:20px;">Accedi</button>

            <div id="loginMessage" style="margin-top:15px; text-align:center; font-weight:bold; color:#d32f2f;"></div>

        </div>

    </div>

<script>

document
    .getElementById('loginButton')
    .addEventListener('click',
        async function () {
            const username = document.getElementById('username').value;

            const password = document.getElementById('password').value;

            const response = await fetch('/login',
                    {
                        method: 'POST',
                        headers: {
                            'Content-Type':
                                'application/json'
                        },
                        body: JSON.stringify({
                            username,
                            password
                        })
                    }
                );

            const result = await response.json();

            if (result.success) {

                window.location = '/';

            } else {

                document.getElementById('loginMessage').textContent = 'Username o password non validi';
            }
        }
    );

document
    .getElementById('password')
    .addEventListener(
        'keydown',
        function(event) {

            if (
                event.key === 'Enter'
            ) {

                document
                    .getElementById(
                        'loginButton'
                    )
                    .click();
            }
        }
    );

</script>
</body>
</html>


Le modifiche ai file index.html e main.py sono radicali, quindi vi darò l'intero codice cimpleto:

Main.py:
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import bcrypt
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy import text
from app.database import engine
from starlette.middleware.sessions import SessionMiddleware
from fastapi import Request

app = FastAPI(
    title="Pokemon Collection API",
    docs_url=None,
    redoc_url=None,
    openapi_url=None
)

app.add_middleware(
    SessionMiddleware,
    secret_key="pokemon-db-2026"
)

app.mount(
    "/static",
    StaticFiles(directory="app/static"),
    name="static"
)

class CollectionAdd(BaseModel):
    card_id: str
    language: str
    condition: str
    variant: str
    graded: bool = False
    quantity: int = 1

class CollectionUpdate(BaseModel):
    quantity: int
    language: str
    condition: str
    variant: str
    graded: bool

class LoginRequest(BaseModel):
    username: str
    password: str

@app.delete("/collection/{item_id}")
def delete_collection_item(
    item_id: int,
    request: Request
):

    user_id = request.session.get(
        "user_id"
    )

    if not user_id:

        raise HTTPException(
            status_code=401,
            detail="Not authenticated"
        )

    with engine.connect() as conn:

        result = conn.execute(
            text("""
                DELETE FROM collection
                WHERE id = :item_id
                AND user_id = :user_id
            """),
            {
                "item_id": item_id,
                "user_id": user_id
            }
        )

        conn.commit()

    return {
        "success": True,
        "deleted_id": item_id
    }

@app.get("/")
def root(request: Request):

    if request.session.get("user_id"):

        return FileResponse(
            "app/static/index.html"
        )

    return FileResponse(
        "app/static/login.html"
    )

@app.get("/cards")
def get_cards():

    with engine.connect() as conn:

        result = conn.execute(
            text("""
                SELECT *
                FROM cards
                ORDER BY name
                LIMIT 100
            """)
        )

        rows = result.mappings().all()

    return rows

@app.get("/collection")
def get_collection(request: Request):

    user_id = request.session.get(
        "user_id"
    )
    if not user_id:
        raise HTTPException(
            status_code=401,
            detail="Not authenticated"
        )

    with engine.connect() as conn:

        result = conn.execute(
            text("""
                SELECT
                    col.id,
                    col.card_id,

                    c.name,
                    c.card_number,

                    s.name AS set_name,

                    col.quantity,
                    col.language,
                    col.condition,
                    col.variant,
                    col.graded

                FROM collection col

                JOIN cards c
                    ON c.id = col.card_id

                JOIN sets s
                    ON s.id = c.set_id

                WHERE col.user_id = :user_id

                ORDER BY c.name
            """),
            {
                "user_id": user_id
            }
        )

        return result.mappings().all()

@app.get("/current-user")
def current_user(request: Request):

    return {
        "user_id":
            request.session.get("user_id"),

        "username":
            request.session.get("username")
    }

@app.get("/dashboard")
def dashboard():

    return FileResponse(
        "app/static/dashboard.html"
    )

@app.get("/index")
def index(request: Request):

    if not request.session.get(
        "user_id"
    ):

        return FileResponse(
            "app/static/login.html"
        )

    return FileResponse(
        "app/static/index.html"
    )

@app.get("/logout")
def logout(request: Request):

    request.session.clear()

    return {
        "success": True
    }

@app.get("/me")
def me(request: Request):

    return {
        "user_id":
            request.session.get(
                "user_id"
            ),
        "username":
            request.session.get(
                "username"
            )
    }

@app.get("/health")
def health():
    return {
        "status": "ok"
    }

@app.get("/sets")
def get_sets():

    with engine.connect() as conn:

        result = conn.execute(
            text("""
                SELECT *
                FROM sets
                ORDER BY release_date DESC
            """)
        )

        rows = result.mappings().all()

    return rows

@app.get("/set/{set_id}/cards")
def get_set_cards(
    set_id: str,
    request: Request
):

    user_id = request.session.get("user_id")

    if not user_id:
        raise HTTPException(
            status_code=401,
            detail="Not authenticated"
        )

    with engine.connect() as conn:


        result = conn.execute(
            text("""
                SELECT
                    c.id,
                    c.name,
                    c.card_number,
                    c.rarity,
                    c.image_small,
                    c.image_large,

                    s.name AS set_name,
                    s.total_cards,

                    EXISTS (
                        SELECT 1
                        FROM collection col
                        WHERE col.card_id = c.id
                        AND col.user_id = :user_id
                    ) AS owned

                FROM cards c

                JOIN sets s
                    ON s.id = c.set_id

                WHERE c.set_id = :set_id

                ORDER BY
                    CASE
                        WHEN c.card_number ~ '^[0-9]+$'
                        THEN c.card_number::integer
                    END NULLS LAST,
                    c.card_number
            """),
            {
                "set_id": set_id,
                "user_id": user_id
            }
        )

        return result.mappings().all()

@app.get("/set/{set_id}/info")
def get_set_info(set_id: str):

    with engine.connect() as conn:

        result = conn.execute(
            text("""
                SELECT
                    id,
                    name,
                    printed_total
                FROM sets
                WHERE id = :set_id
            """),
            {"set_id": set_id}
        )

        return result.mappings().first()

@app.get("/stats")
def get_stats(request: Request):

    user_id = request.session.get("user_id")

    if not user_id:
        raise HTTPException(
            status_code=401,
            detail="Not authenticated"
        )

    with engine.connect() as conn:

        result = conn.execute(
            text("""
                SELECT
                    COALESCE(SUM(quantity), 0) AS total_cards,
                    COUNT(*) AS unique_cards,
                    COUNT(DISTINCT c.set_id) AS sets_started
                FROM collection col
                JOIN cards c
                    ON c.id = col.card_id
                WHERE col.user_id = :user_id
            """),
            {"user_id": user_id}
        )

        stats = result.mappings().first()

        return stats

@app.get("/stats/sets")
def get_set_stats(request: Request):

    user_id = request.session.get("user_id")

    if not user_id:
        raise HTTPException(
            status_code=401,
            detail="Not authenticated"
        )

    with engine.connect() as conn:

        result = conn.execute(
            text("""
                SELECT
                    s.id,
                    s.name,
                    s.release_date,
                    s.total_cards,

                    COUNT(
                        DISTINCT CASE
                            WHEN col.id IS NOT NULL
                            THEN c.id
                        END
                    ) AS owned_cards,

                    ROUND(
                        COUNT(
                            DISTINCT CASE
                                WHEN col.id IS NOT NULL
                                THEN c.id
                            END
                        ) * 100.0 / s.total_cards,
                        1
                    ) AS completion

                FROM sets s

                LEFT JOIN cards c
                    ON c.set_id = s.id

                LEFT JOIN collection col
                    ON col.card_id = c.id
                    AND col.user_id = :user_id

                GROUP BY
                    s.id,
                    s.name,
                    s.release_date,
                    s.total_cards

                ORDER BY
                    completion DESC,
                    owned_cards DESC,
                    s.release_date DESC,
                    s.name
            """),
            {
                "user_id": user_id
            }
        )

        return result.mappings().all()

@app.get("/users")
def get_users():

    with engine.connect() as conn:

        result = conn.execute(
            text("""
                SELECT
                    id,
                    username,
                    display_name,
                    is_admin
                FROM users
                ORDER BY id
            """)
        )

        rows = result.mappings().all()

    return rows

@app.post("/collection/add")
def add_to_collection(
    item: CollectionAdd,
    request: Request
):

    user_id = request.session.get("user_id")

    if not user_id:
        raise HTTPException(
            status_code=401,
            detail="Not authenticated"
        )


    with engine.connect() as conn:

        conn.execute(
            text("""
                INSERT INTO collection (
                    user_id,
                    card_id,
                    quantity,
                    language,
                    condition,
                    variant,
                    graded
                )
                VALUES (
                    :user_id,
                    :card_id,
                    :quantity,
                    :language,
                    :condition,
                    :variant,
                    :graded
                )
                ON CONFLICT (
                    user_id,
                    card_id,
                    language,
                    condition,
                    variant,
                    graded
                )
                DO UPDATE
                SET quantity =
                    collection.quantity +
                    EXCLUDED.quantity
            """),
            {
                "user_id": user_id,
                "card_id": item.card_id,
                "quantity": item.quantity,
                "language": item.language,
                "condition": item.condition,
                "variant": item.variant,
                "graded": item.graded
            }
        )

        conn.commit()

    return {
        "success": True,
        "card_id": item.card_id
    }

@app.post("/login")
def login(
    login_request: LoginRequest,
    request: Request
):

    with engine.connect() as conn:

        result = conn.execute(
            text("""
                SELECT
                    id,
                    username,
                    password_hash
                FROM users
                WHERE username = :username
            """),
            {
                "username": login_request.username
            }
        )

        user = result.mappings().first()

    if not user:

        return {
            "success": False
        }

    if not bcrypt.checkpw(
        login_request.password.encode(),
        user["password_hash"].encode()
    ):

        return {
            "success": False
        }
    request.session["user_id"] = user["id"]
    request.session["username"] = user["username"]
    return {
        "success": True,
        "user_id": user["id"],
        "username": user["username"]
    }

@app.put("/collection/{item_id}")
def update_collection_item(
    item_id: int,
    item: CollectionUpdate,
    request: Request
):

    user_id = request.session.get(
        "user_id"
    )

    if not user_id:

        raise HTTPException(
            status_code=401,
            detail="Not authenticated"
        )

    with engine.connect() as conn:

        conn.execute(
            text("""
                UPDATE collection
                SET
                    quantity = :quantity,
                    language = :language,
                    condition = :condition,
                    variant = :variant,
                    graded = :graded
                WHERE id = :item_id
                AND user_id = :user_id
            """),
            {
                "item_id": item_id,
                "user_id": user_id,
                "quantity": item.quantity,
                "language": item.language,
                "condition": item.condition,
                "variant": item.variant,
                "graded": item.graded
            }
        )

        conn.commit()

    return {
        "success": True,
        "updated_id": item_id
    }

Index.html:
<!DOCTYPE html>
<html lang="it">
<head>
    <meta charset="utf-8">
    <title>Pokemon DB by Paolo Tuttoilmondo</title>
    <link rel="stylesheet" href="/static/style.css">
</head>
<body>

<div class="header">

    <div class="logo">Pokemon DB by Paolo Tuttoilmondo</div>

    <div class="user"><span id="currentUsername"></span>
        <button id="logoutButton" onclick="logout()">Logout</button>
    </div>

</div>

<div class="container">
    <div class="toolbar">
        <button onclick="window.location='/static/dashboard.html'">Statistiche</button>
    <div class="toolbar-search">
        <input type="text" id="searchBox" placeholder="Cerca carta..."></div>
    </div>

    <div class="set-info"><label>Set: </label>
      <img id="setSymbol" style="width:32px; height:32px; vertical-align:middle; margin:0 10px;">
      <select id="setSelect"></select>
    </div>

    <div class="set-info">
        <h2 style="display:flex; align-items:center; gap:10px;">
        <img id="setTitleSymbol" style="width:40px; height:40px;">
        <span id="setTitle"></span>
        </h2>
    </div>
    <div id="card-grid" class="card-grid"></div>
</div>

<script>
let allCards = [];
let selectedCardIndex = -1;
let selectedCard = null;
let selectedCollectionItem = null;
let currentUser = null;

async function loadCards(setId = 'me5') {

    const response = await fetch(`/set/${setId}/cards`);

    const cards = await response.json();

    allCards = cards;

    renderCards(cards);
}

async function loadCollectionData() {

    const response = await fetch(`/collection`);

    return await response.json();
}

async function loadCurrentUser() {

    const response = await fetch('/current-user');

    currentUser = await response.json();

    document.getElementById('currentUsername').textContent = currentUser.username;
}

async function logout() {

    await fetch('/logout');

    window.location = '/static/login.html';
}

async function loadSets() {

    const response = await fetch('/sets');
    const sets = await response.json();

    const select = document.getElementById('setSelect');

    select.innerHTML = '';

    sets.forEach((set, index) => {

        const option = document.createElement('option');

        option.value = set.id;

        const year = set.release_date.substring(0, 4);

        option.textContent = `${year} - ${set.name}`;

        if (index === 0) {
        option.selected = true;
        document.getElementById('setTitle').textContent = `${year} - ${set.name}`;
        document.getElementById('setSymbol').src = set.symbol_url;
        document.getElementById('setTitleSymbol').src = set.symbol_url;
        }

        select.appendChild(option);
    });

    select.addEventListener('change', () => {

    const selectedSet = sets.find(s => s.id === select.value);

    document.getElementById('setTitle').textContent = select.options[select.selectedIndex].text;

    document.getElementById('setSymbol').src = selectedSet.symbol_url;

    document.getElementById('setTitleSymbol').src = selectedSet.symbol_url;

    loadCards(select.value);
    });

    if (sets.length > 0) {

        loadCards(sets[0].id);
    }
}

async function openPopup(index) {

    selectedCardIndex = index;

    selectedCard = allCards[index];

    const collection = await loadCollectionData();

    selectedCollectionItem = collection.find(item => item.card_id === selectedCard.id);

    if (selectedCollectionItem) {

      document.getElementById('standardLanguage').value = selectedCollectionItem.language;

      document.getElementById('standardCondition').value = selectedCollectionItem.condition;

      document.getElementById('standardQuantity').value = selectedCollectionItem.quantity;

        } else {

        document.getElementById('standardLanguage').value = 'IT';

        document.getElementById('standardCondition').value = 'NM';

        document.getElementById('standardQuantity').value = 0;

        }

    document.getElementById('popupTitle').textContent = selectedCard.name;

    document.getElementById('popupImage').src = selectedCard.image_small.replace('.png', '_hires.png');

    document.getElementById('popupNumber').textContent = selectedCard.card_number.toString().padStart(3, '0');

    document.getElementById('popupTotal').textContent = selectedCard.total_cards;

    document.getElementById('popupSet').textContent = selectedCard.set_name;

    document.getElementById('popupRarity').textContent = selectedCard.rarity || '';

    document.getElementById('popup').style.display = 'flex';
}

async function saveCard() {

    const payload = {

        quantity: parseInt(
            document.getElementById(
                'standardQuantity'
            ).value
        ),

        language:
            document.getElementById(
                'standardLanguage'
            ).value,

        condition:
            document.getElementById(
                'standardCondition'
            ).value,

        variant: 'Standard',

        graded: false
    };

    const quantity = parseInt(
    document.getElementById(
        'standardQuantity'
    ).value
    );

    let response;

    if (selectedCollectionItem && quantity === 0) {

        response = await fetch(
            `/collection/${selectedCollectionItem.id}`,
            {
                method: 'DELETE'
            }
        );

        selectedCollectionItem = null;

    } else if (selectedCollectionItem) {

        response = await fetch(
            `/collection/${selectedCollectionItem.id}`,
            {
                method: 'PUT',
                headers: {
                    'Content-Type':
                        'application/json'
                },
                body: JSON.stringify(payload)
            }
        );

    } else {

        response = await fetch(
            '/collection/add',
            {
                method: 'POST',
                headers: {
                    'Content-Type':
                        'application/json'
                },
                body: JSON.stringify({
                    card_id: selectedCard.id,
                    ...payload
                })
            }
        );
    }

    const result = await response.json();

    console.log(result);

    loadCards(
        document.getElementById(
            'setSelect'
        ).value
    );
}

function previousCard() {

    if (selectedCardIndex <= 0) {
        return;
    }

    openPopup(selectedCardIndex - 1);
}

function nextCard() {

    if (selectedCardIndex >= allCards.length - 1) {
        return;
    }

    openPopup(selectedCardIndex + 1);
}

function closePopup() {

    document.getElementById('popup').style.display =
        'none';
}

function renderCards(cards) {

    const grid =
        document.getElementById(
            'card-grid'
        );

    grid.innerHTML = '';

    cards.forEach((card) => {

        const originalIndex =
            allCards.findIndex(
                c => c.id === card.id
            );

        const div =
            document.createElement('div');

        div.className =
            card.owned
            ? 'card'
            : 'card not-owned';

        div.onclick =
            () => openPopup(originalIndex);

        div.innerHTML = `
            <img src="${card.image_small}" alt="${card.name}">

            <div class="card-body">

                <div class="card-number">
                    #${card.card_number}
                </div>

                <div class="card-name">
                    ${card.name}
                </div>

                <div class="card-rarity">
                    ${card.rarity || ''}
                </div>

            </div>
        `;

        grid.appendChild(div);
    });
}

document
    .getElementById('searchBox')
    .addEventListener(
        'input',
        function () {

            const search =
                this.value
                .toLowerCase()
                .trim();

            const filtered =
                allCards.filter(card =>

                    card.name
                        .toLowerCase()
                        .includes(search)

                    ||

                    card.card_number
                        .toString()
                        .includes(search)
                );

            renderCards(filtered);
        }
    );

(async () => {

    await loadCurrentUser();

    loadSets();

})();

document.addEventListener(
    'keydown',
    function(event) {

        const popup =
            document.getElementById('popup');

        if (
            popup.style.display !== 'flex'
        ) {
            return;
        }

        if (event.key === 'ArrowLeft') {
            previousCard();
        }

        if (event.key === 'ArrowRight') {
            nextCard();
        }

        if (event.key === 'ArrowUp') {

            event.preventDefault();

            const qty =
                document.getElementById(
                    'standardQuantity'
                );

            qty.value =
                parseInt(qty.value || 0) + 1;
        }

        if (event.key === 'ArrowDown') {

            event.preventDefault();

            const qty =
                document.getElementById(
                    'standardQuantity'
                );

            qty.value =
                Math.max(
                    0,
                    parseInt(qty.value || 0) - 1
                );
        }

        if (event.key === 'Enter') {

            event.preventDefault();

            saveCard();
        }
    }
);

</script>
<div id="popup" class="popup-overlay">

<div class="popup">
  <h2 id="popupTitleBar">
    <span id="popupTitle"></span>
    | Set: <span id="popupSet"></span>
    | Numero: <span id="popupNumber"></span> / <span id="popupTotal">120</span>
    | Rarità: <span id="popupRarity"></span>
  </h2>
  <div class="popup-top">
    <img id="popupImage">

<div class="popup-right">
    <h3>STANDARD</h3>
    <div class="form-group">
        <label>Lingua</label>
        <select id="standardLanguage">
            <option value="IT">Italiano</option>
            <option value="EN">Inglese</option>
            <option value="JP">Giapponese</option>
        </select>
    </div>
    <div class="form-group">
        <label>Condizione</label>
        <select id="standardCondition">
            <option value="NM">Near Mint</option>
            <option value="LP">Light Played</option>
            <option value="MP">Moderately Played</option>
            <option value="HP">Heavily Played</option>
        </select>
    </div>
    <div class="form-group">
        <label>Quantità</label>
        <input
            id="standardQuantity"
            type="number"
            value="0"
            min="0"
        >
    </div>
    <button id="addStandardButton" onclick="saveCard()">Salva</button>
</div>

</div>

<hr>
  <div style="display:flex; justify-content:space-between; margin-top:20px;">
  <button onclick="previousCard()">
  ← Carta precedente
  </button>
  <button onclick="nextCard()">
  Carta successiva →
  </button>
  </div>
<hr>
  <button onclick="closePopup()">
    Chiudi
  </button>
 </div>
</div>
</body>
</html>

Aprire il CSS e sostituire .user con:
.user {

    display: flex;
 
    align-items: center;
 
    gap: 12px;

    color: white;

    font-size: 22px;

    font-weight: bold;
}

aggiungere in coda al file:
#logoutButton {

    background: transparent;

    border: 2px solid rgba(255,255,255,0.4);

    border-radius: 8px;

    padding: 6px 12px;

    color: white;

    font-size: 14px;

    font-weight: bold;

    cursor: pointer;

    box-shadow: none;
}

#logoutButton:hover {

    background: rgba(255,255,255,0.15);

    transform: none;
}

#logoutButton:active {

    transform: none;

    box-shadow: none;
}

Ricompilare

Adesso se vi collegate alla pagina http://IP_SERVER:8000 comparirà la schermata di login, inserendo utente e password precedentemente scelti, potrete accedere alla home page. Attenzione però che al momento è possibile bypassare questo controllo, collegandosi direttamente a index.html o ad altre pagine.

Ultimo aspetto importante è poter gestire le carte con varianti tipo Holo, Reverse etc.. al momento infatti si possono gestire solo carte standard.

Step 1 – Aggiungere la colonna delle varianti

Entrare in PostgreSQL con:
docker exec -it pokemon-postgres psql -U pokemon -d pokemon

e digitare il comando:
ALTER TABLE cards
ADD COLUMN variants JSONB;

Bisogna adesso importare nel DB da TGC tutti i dati che riguardano le variazioni di carta, bisogna quindi modificare il file import_cards.py:
nano /opt/pokemon/imports/import_cards.py

Subito prima di cur.execute ma prima di (""" inserire:
prices = (
    card.get("tcgplayer", {})
        .get("prices", {})
)
variants = list(prices.keys())

sostituire il blocco da INSERT INTO cards ( con questo:
INSERT INTO cards (
                    id,
                    set_id,
                    card_number,
                    name,
                    rarity,
                    supertype,
                    image_small,
                    image_large,
                    variants
                )
                VALUES (
                    %s,%s,%s,%s,
                    %s,%s,%s,%s,%s
                )
                ON CONFLICT (id)
                DO UPDATE
                SET variants = EXCLUDED.variants
            """, (
                card["id"],
                card["set"]["id"],
                card.get("number"),
                card.get("name"),
                card.get("rarity"),
                card.get("supertype"),
                card.get("images", {}).get("small"),
                card.get("images", {}).get("large"),
                json.dumps(variants)
            ))

Salvare il file e lanciare lo script:
python3 /opt/pokemon/imports/import_cards.py

Se è la prima volta che eseguito questo scripts tutto funzionerà, se l'avete già usato però non funzionerà perchè non troverà alcun aggiornamento da fare, per ovviare al problema basta togliere la riga:
HAVING COUNT(c.id) < s.total_cards

e rilanciare lo script .. l'importazione durerà probabilmente ore.

Finita l'importazione facciamo una verifica:
docker exec -it pokemon-postgres psql -U pokemon -d pokemon

poi la query:
SELECT
    id,
    name,
    variants
FROM cards
WHERE variants IS NOT NULL
LIMIT 20;

mi aspetto un risultato del genere con la colonna "variants" valorizzata
Modifichiamo la query "/set/{set_id}/cards" in main.py:
nano /opt/pokemon/backend/app/main.py

e aggiungiamo a SELECT in coda il valore:
c.variants,

Entriamo in modifica di index.html:

Sostituiamo tutto il blocco async function openPopup(index) { con:
async function openPopup(index) {

    selectedCardIndex = index;

    selectedCard = allCards[index];

    const variantSelect = document.getElementById('standardVariant' );

    variantSelect.innerHTML = '';

    let variants = selectedCard.variants || [];

    if (variants.length === 0) {variants = ['normal'];}

    const variantLabels = {
        normal: 'Standard',
        holofoil: 'Holo',
        reverseHolofoil: 'Reverse Holo',
        unlimitedHolofoil: 'Unlimited Holo',
        '1stEditionHolofoil': '1st Edition Holo'
    };

    variants.forEach(variant => {
        const option = document.createElement('option');
        option.value = variant;
        option.textContent =
            variantLabels[variant] || variant;
        variantSelect.appendChild(option);
    });

    const collection = await loadCollectionData();

    const selectedVariant = document.getElementById('standardVariant').value;

    selectedCollectionItem = collection.find(item =>

        item.card_id === selectedCard.id

        &&

        item.variant === selectedVariant
    );

    if (selectedCollectionItem) {

      document.getElementById('standardLanguage').value = selectedCollectionItem.language;

      document.getElementById('standardCondition').value = selectedCollectionItem.condition;

      document.getElementById('standardQuantity').value = selectedCollectionItem.quantity;

        } else {

        document.getElementById('standardLanguage').value = 'IT';

        document.getElementById('standardCondition').value = 'NM';

        document.getElementById('standardQuantity').value = 0;

        }

    document.getElementById('popupTitle').textContent = selectedCard.name;

    document.getElementById('popupImage').src = selectedCard.image_small.replace('.png', '_hires.png');

    document.getElementById('popupNumber').textContent = selectedCard.card_number.toString().padStart(3, '0');

    document.getElementById('popupTotal').textContent = selectedCard.total_cards;

    document.getElementById('popupSet').textContent = selectedCard.set_name;

    document.getElementById('popupRarity').textContent = selectedCard.rarity || '';

    document.getElementById('popup').style.display = 'flex';
}

e il blocco async function saveCard() { con:
async function saveCard() {

    const payload = {

        quantity: parseInt(document.getElementById('standardQuantity').value),

        language:
            document.getElementById('standardLanguage').value,

        condition:
            document.getElementById('standardCondition').value,

        variant:
            document.getElementById('standardVariant').value, graded: false};

    const quantity = parseInt(
    document.getElementById(
        'standardQuantity'
    ).value
    );

    let response;

    if (selectedCollectionItem && quantity === 0) {

        response = await fetch(
            `/collection/${selectedCollectionItem.id}`,
            {
                method: 'DELETE'
            }
        );

        selectedCollectionItem = null;

    } else if (selectedCollectionItem) {

        response = await fetch(
            `/collection/${selectedCollectionItem.id}`,
            {
                method: 'PUT',
                headers: {
                    'Content-Type':
                        'application/json'
                },
                body: JSON.stringify(payload)
            }
        );

    } else {

        response = await fetch(
            '/collection/add',
            {
                method: 'POST',
                headers: {
                    'Content-Type':
                        'application/json'
                },
                body: JSON.stringify({
                    card_id: selectedCard.id,
                    ...payload
                })
            }
        );
    }

    const result = await response.json();

    console.log(result);

    loadCards(
        document.getElementById(
            'setSelect'
        ).value
    );
}

Dopo il blocco:
<div class="form-group">
    <label>Condizione</label>
    <select id="standardCondition">
    </select>
</div>

aggiungiamo:
<div class="form-group">
    <label>Variante</label>
    <select id="standardVariant">
    </select>
</div>

Poi modifica saveCard(), Trova "variant: 'Standard'," e sostituisci con:
variant:
    document.getElementById(
        'standardVariant'
    ).value,

Ricompila

Adesso mi aspetto che il PopUp abbia il campo Variante che mostra sole le possibili variazione per quella carta

Al momento però cliccare su salva non

<—– Articolo Parte 11

Database per collezionisti gratuito Parte 3

Prossimo passo: progettare il database. Prima di creare le tabelle voglio che decidiamo bene come gestire la collezione. Una proposta di base è:

Catalogo Ufficiale (contiene tutte le carte esistenti)
- sets
- cards

Collezione Paolo (Marco.. Giada etc..) contiene solo quelle possedute
- collection

Carta: Charizard ex 125/197
- Quantità: 3
- Lingua: ITA
- Condizione: Near Mint

e una seconda riga (per le carte aggiuntive con lingua diversa)
- Quantità: 1
- Lingua: ENG
- Condizione: Mint

Il DB sarà anche multiutente

Creazione schema database V1

Entriamo in PostgreSQL:
docker exec -it pokemon-postgres psql -U pokemon -d pokemon

Creiamo la Tabella utenti digitando:
CREATE TABLE users (
    id              BIGSERIAL PRIMARY KEY,
    username        VARCHAR(50) UNIQUE NOT NULL,
    email           VARCHAR(255) UNIQUE NOT NULL,
    password_hash   VARCHAR(255) NOT NULL,
    display_name    VARCHAR(100),
    is_admin        BOOLEAN DEFAULT FALSE,
    created_at      TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

Per la Tabella Espansioni digitare:
CREATE TABLE sets (
    id              VARCHAR(50) PRIMARY KEY,
    name            VARCHAR(255) NOT NULL,
    series          VARCHAR(255),
    release_date    DATE,
    total_cards     INTEGER,
    created_at      TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

Per la Tabella Carte digitare:
CREATE TABLE cards (
    id              VARCHAR(100) PRIMARY KEY,
    set_id          VARCHAR(50) NOT NULL,

    card_number     VARCHAR(20),
    name            VARCHAR(255) NOT NULL,

    rarity          VARCHAR(100),
    supertype       VARCHAR(100),

    image_small     TEXT,
    image_large     TEXT,

    created_at      TIMESTAMP DEFAULT CURRENT_TIMESTAMP,

    CONSTRAINT fk_cards_set
      FOREIGN KEY (set_id)
      REFERENCES sets(id)
);

Per la Tabella Collezioni digitare:
CREATE TABLE collection (
    id              BIGSERIAL PRIMARY KEY,

    user_id         BIGINT NOT NULL,
    card_id         VARCHAR(100) NOT NULL,

    quantity        INTEGER NOT NULL DEFAULT 1,

    language        VARCHAR(10) NOT NULL,
    condition       VARCHAR(30) NOT NULL,

    variant         VARCHAR(30) NOT NULL,

    graded          BOOLEAN DEFAULT FALSE,

    notes           TEXT,

    created_at      TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at      TIMESTAMP DEFAULT CURRENT_TIMESTAMP,

    CONSTRAINT fk_collection_user
      FOREIGN KEY (user_id)
      REFERENCES users(id),

    CONSTRAINT fk_collection_card
      FOREIGN KEY (card_id)
      REFERENCES cards(id)
);

Per la Tabella Wishlist digitare:
CREATE TABLE wishlist (
    id              BIGSERIAL PRIMARY KEY,

    user_id         BIGINT NOT NULL,
    card_id         VARCHAR(100) NOT NULL,

    priority        SMALLINT DEFAULT 1,
    notes           TEXT,

    created_at      TIMESTAMP DEFAULT CURRENT_TIMESTAMP,

    CONSTRAINT fk_wishlist_user
      FOREIGN KEY (user_id)
      REFERENCES users(id),

    CONSTRAINT fk_wishlist_card
      FOREIGN KEY (card_id)
      REFERENCES cards(id)
);

Per la Tabella Indici digitare:
CREATE INDEX idx_cards_name
ON cards(name);

CREATE INDEX idx_cards_set
ON cards(set_id);

CREATE INDEX idx_collection_user
ON collection(user_id);

CREATE INDEX idx_collection_card
ON collection(card_id);

Primo utente amministratore:
INSERT INTO users (
    username,
    email,
    password_hash,
    display_name,
    is_admin
)
VALUES (
    'paolo',
    'paolo@local',
    'TEMP',
    'Paolo',
    TRUE
);

Verifica finale:
\dt

Dovresti vedere:
cards
collection
sets
users
wishlist

Per verifica l'utente:
SELECT * FROM users;

Prossimo passo: Backend FastAPI

/opt/pokemon

├── backend
├── frontend
├── postgres
├── imports
└── docker-compose.yml

Creiamo il backend:
cd /opt/pokemon/backend
mkdir app
cd app
touch main.py

Verifica: (se non hai tree: apt install tree -y)
tree /opt/pokemon/backend

Dovresti vedere:
backend
└── app
    └── main.py

Esegui:
mkdir -p /opt/pokemon/backend/app
touch /opt/pokemon/backend/app/main.py

Adesso creiamo il primo backend minimale e lo facciamo girare in Docker. Passo 1: file Python

nano /opt/pokemon/backend/app/main.py

e inserisci:
from fastapi import FastAPI

app = FastAPI(
    title="Pokemon Collection API"
)


@app.get("/")
def root():
    return {
        "app": "Pokemon Collection",
        "status": "running"
    }


@app.get("/health")
def health():
    return {
        "status": "ok"
    }

Salva ed esci con CTRL X e poi Y e invio

nano /opt/pokemon/backend/requirements.txt

e inserisci:
fastapi
uvicorn[standard]

Salva ed esci con CTRL X e poi Y e invio

nano /opt/pokemon/backend/Dockerfile

e inserisci:
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

Salva ed esci con CTRL X e poi Y e invio

nano /opt/pokemon/docker-compose.yml

e sostituisci il contenuto con:
services:

  postgres:
    image: postgres:17
    container_name: pokemon-postgres
    restart: unless-stopped

    environment:
      POSTGRES_DB: pokemon
      POSTGRES_USER: pokemon
      POSTGRES_PASSWORD: pokemon123

    ports:
      - "5432:5432"

    volumes:
      - ./postgres:/var/lib/postgresql/data

  api:
    build: ./backend
    container_name: pokemon-api
    restart: unless-stopped

    ports:
      - "8000:8000"

    depends_on:
      - postgres

Salva ed esci con CTRL X e poi Y e invio

Build e avvio

cd /opt/pokemon
docker-compose up -d --build

partirà la compilazione, se è andato tutto bene vedrai:
Container pokemon-postgres  Running                                                                                                          Container pokemon-api       Started

Per ulteriore verifixa comando:
curl http://localhost:8000/health
la risposta dovrebbe essere {"status":"ok"}

Se tutto funziona sarà già visibile l'accesso web al db dall'indirizzo http://ip vm:8000/docs dove ip vm è l'indirizzo ip della tua virtual machine o PC, dovrebbe comparire la pagina "Pokemon Collection API"

Creiamo il file database.py

nano /opt/pokemon/backend/app/database.py

con dentro:
from sqlalchemy import create_engine

DATABASE_URL = "postgresql://pokemon:pokemon123@postgres:5432/pokemon"

engine = create_engine(DATABASE_URL)

Salva ed esci con CTRL X e poi Y e invio

Creiamo il file requirements.txt

nano /opt/pokemon/backend/requirements.txt

Sostituisci il contenuto con:
fastapi
uvicorn[standard]
sqlalchemy
psycopg2-binary

Ricostruisci il container

cd /opt/pokemon
docker-compose down
docker-compose build --no-cache
docker-compose up -d

Verifichiamo aprendo la pagina web http://vm ip:8000/db dovrebbe comparire {"users":1}

Creiamo le API reali

nano /opt/pokemon/backend/app/main.py

Aggiungi in calce:
@app.get("/users")
def get_users():

    with engine.connect() as conn:

        result = conn.execute(
            text("""
                SELECT
                    id,
                    username,
                    display_name,
                    is_admin
                FROM users
                ORDER BY id
            """)
        )

        rows = result.mappings().all()

    return rows


@app.get("/sets")
def get_sets():

    with engine.connect() as conn:

        result = conn.execute(
            text("""
                SELECT *
                FROM sets
                ORDER BY name
            """)
        )

        rows = result.mappings().all()

    return rows


@app.get("/cards")
def get_cards():

    with engine.connect() as conn:

        result = conn.execute(
            text("""
                SELECT *
                FROM cards
                ORDER BY name
                LIMIT 100
            """)
        )

        rows = result.mappings().all()

    return rows

Salva ed esci con CTRL X e poi Y e invio
Ricostruzione solita con:
cd /opt/pokemon
docker-compose up -d --build

finita la ricompilazione dovrebbe dare nuovamente "Runnig" e "Started" Verifichiamo aprendo la pagina web http://vm ip:8000/db dovrebbe comparire:
[
  {
    "id": 1,
    "username": "paolo",
    "display_name": "Paolo",
    "is_admin": true
  }
]

<—– Articolo Parte 2 | Articolo Parte 4 —–>

Database per collezionisti gratuito Parte 2

Creare la struttura del progetto:

mkdir -p /opt/pokemon
cd /opt/pokemon

verifica con:
pwd

deve mostrare:
/opt/pokemon

Installa quindi Docker-Compose:
apt install -y docker.io docker-compose git curl nano

Poi verifica:
systemctl enable --now docker
systemctl status docker

Dovrebbe comparire:
active (running)

Creare le cartelle che servono:

mkdir postgres backend frontend backups imports
ls -l

Dovresti vedere:
backend
backups
frontend
imports
postgres

Creare il file docker-compose.yml

cd /opt/pokemon
nano docker-compose.yml

Aperto il file inserisci:
services:
  postgres:
    image: postgres:17
    container_name: pokemon-postgres
    restart: unless-stopped

    environment:
      POSTGRES_DB: pokemon
      POSTGRES_USER: pokemon
      POSTGRES_PASSWORD: pokemon123

    ports:
      - "5432:5432"

    volumes:
      - ./postgres:/var/lib/postgresql/data

Per salvare CTRL+X e poi Y e invio

Avvio del database

docker-compose up -d
docker ps

Dovresti vedere qualcosa simile a:
pokemon-postgres
postgres:17
Up

Per verificare PostGres sempre da Root esegui:

docker exec -it pokemon-postgres psql -U pokemon -d pokemon

Se tutto è corretto vedrai:
pokemon=#

digita:
SELECT version();

Vedrai una struttura vuota che però indica che il DB è funzionante e pronto ad essere riempito.

per uscire digita il comando:
q

e torni al prompt PostgreSQL:
pokemon=#

Poi esci da PostgreSQL con il comando:
\q

Abbiamo completato tutta la parte infrastrutturale. Prossimo passo: progettare il database.

<—– Articolo Parte 1 | Articolo Parte 3 —–>

Database per collezionisti gratuito

Parte 1

Per la mia collezione di carte Pokemon (poteva essere di monete o altro) uso un App a pagamento sfruttabile via PC, Tablet etc.. per catalogare tutte le carte. Il costo però è piuttosto alto, circa 50 euro ad anno, ho deciso quindi di creare qualcosa di “home made”. Con un catalogo gestito in autonomia, si evita anche di condividere le proprie informazioni, con persone di cui spesso non si sa nulla.

In pochi passi si otterrà un risultato come quello dell’immagine sotto riportata dove le carte sono tutte in bianco e nero perchè il DB è vuoto, sarebbe a colori se posseduta.

Stato progetto (14/09/2026)

✅ VM Debian

✅ Docker

✅ PostgreSQL

✅ FastAPI

✅ Import catalogo Pokémon

✅ 20.479 carte

✅ Ricerca carte

✅ Collezione personale

✅ Dashboard Web

✅ Carte possedute evidenziate

⬜ Login utenti

⬜ Wishlist

⬜ Gestione set multipli

Anticipo già per chi ha intenzione di seguire questa guida, che i primi due o tre articoli saranno di pubblico accesso, per i successivi, bisognerà appartenere al gruppo Contributor. Fanno parte di questo gruppo gli utenti che hanno fatto una donazione per un’importo non inferiore ad 1 euro.

Avendo un PC su cui ho installato il sistema di virtualizzazione Proxmox “open source”, posso creare facilmente virtual machine, di seguito semplicemente “VM”. Avendo anche una linea internet in Fibra da 2.5Gbit, anche la parte di connettività è eccellente.

Partiamo quindi con la creazione di una VM, va bene anche un PC dedicato, un raspberry etc.. l’importante è che abbia un minimo di caratteristiche HW come 2 CPU, Ram 4 gb e un disco da 40 gb o superiore. Nel mio caso ho creato una nuova VM linux su base Debian ultima release, dando 2 CPU, 4G di Ram e 40Gb di disco.

Installazione Debian

Non essendo una guida su come si installa un sistema operativo sorvolerò sulle fasi iniziali, descrivendo solo le parti che ci permettano di avere un server minimale. Durante l’installazione dare come nome macchina “pokemon” o quello che volete, nella sezione dominio non mettere nulla e infine alla schermata “Software selection” lascia selezionate solo le opzioni sotto riportate:

✅ SSH server

✅ Standard system utilities

e deseleziona:

❌ Debian desktop environment

❌ GNOME

❌ KDE

❌ XFCE

❌ Web server

❌ Tutto il resto

Conclusa l’installazione eseguire i canonici aggiornamenti del S.O .

Da root esegui:
apt update
apt full-upgrade -y

al termine:
reboot

Rientra poi nella VM e verifica che sia tutto ok:

uname -a
apt list --upgradable

Se il secondo comando non mostra pacchetti da aggiornare (o ne mostra pochissimi), siamo pronti per il passo successivo.

Da root esegui:
apt install -y docker.io docker-compose-v2 git curl nano

Al termine verifica: 
docker --version

e anche:
docker compose version

Dovresti ottenere qualcosa di simile: Docker version 28.x, Docker Compose version v2.x

<—– | Articolo Parte 2 —–>

Come collegare un inverter FEB PowerX o simile a Raspberry Pi tramite Modbus RS485

Come accade spesso questi inverter pubblicano i dati di produzione solo via App proprietaria, questo non permette di usare i dati di produzione del proprio impianto fotovoltaico sulla propria domotica. Il mio obiettivo era quello di poter visualizzare sulla mia domotica basata su Home Assistant dei dati di produzione del mio impianto fotovoltaico.

Dopo alcune prove sono riuscito a collegare il mio inverter FEB PowerX a un Raspberry Pi 4 e a leggere correttamente i registri Modbus RTU tramite RS485.

Hardware utilizzato

  • Raspberry Pi 3, 4 o 5 con Raspberry Pi OS
  • Dongle USB RS485 CH340
  • Inverter FEB PowerX
  • Cavo RJ45 modificato

Questo è il dongle che ho acquistato:

Questo è il cavo che ho realizzato:

Il mio inverter ha una porta RS485, questa è impegnata dal cavo di rete che mette in comunicazione le batterie con l’inverter, le batterie però usano il canale BMS e non il canale EMS come si vede dalla figura sotto:

al posto di cercare un deviatore come quello di figura sopra, di difficile reperibilità, ho creato con due connettore di rete RJ45, uno maschio e uno femmina, un cavo che estrae i soli PIN 7 e 8.

Con questo cavo i PIN dal 1 al 6 continuano ad andare dall’inverter alla batteria, questo è fondamentale per il funzionamento corretto dell’inverter e dell’App. I soli PIN 7 e 8 adesso non vanno più alla batteria ma con i cavetti rosso e bianco sono pronti per essere collegato al dongle RS485 per la comunicazione RS485.

Ultimo passaggio è quello di impostare nell’App il canale slave:

Adesso è tutto pronto per testare la comunicazione RS485.

Collegamento RS485

Collegare il cavo del PIN 7 alla porta A del dongle e il PIN 8 alla porta B. Per tratte brevi generalmente A e B sono sufficienti; per collegamenti più lunghi o in ambienti elettricamente disturbati può essere utile collegare anche il riferimento GND.

Verifica del dongle USB

Inserire il dongle in una porta USB del raspberry e verificare che venga riconosciuto con il comando:

ls /dev/ttyUSB*

nel mio caso il risultato è: /dev/ttyUSB0

Per identificare il dispositivo: dmesg | grep ttyUSB

Utilizzare il nome persistente del dispositivo

Se si dovesse riavviare il Raspberry la porta potrebbe cambiare, per evitare questo problema dare il comando: ls -l /dev/serial/by-id/

Nel mio caso la risposta è stata:

/dev/serial/by-id/usb-1a86_USB_Serial-if00-port0

lo useremo da ora in poi per identificare il dongle.

Installazione di mbpoll

sudo apt update

sudo apt install mbpoll

Verifica:

which mbpoll

Parametri Modbus corretti

Dopo vari tentativi i parametri funzionanti sono risultati:

Protocollo : Modbus RTU

Slave ID   : 1

Baudrate   : 115200

Parity     : None

Stop bit   : 1

Primo test di comunicazione

Lettura della versione protocollo:

mbpoll -m rtu -a 1 -b 115200 -P none -t 4:hex -0 -r 8192 -c 1 /dev/serial/by-id/usb-1a86_USB_Serial-if00-port0

Risultato:

0x0681

Questo conferma che la comunicazione Modbus è attiva. Se non dovesse funzionare potreste provare a invertire i cavi quindi PIN 7 a B e PIN 8 ad A.

Un altro comando utile identifica il modello di Inverter:

mbpoll -m rtu -a 1 -b 115200 -P none -t 4:hex -0 -r 8198 -c 1 /dev/serial/by-id/usb-1a86_USB_Serial-if00-port0

Risultato:

0x0001 che tradatto vuol dire 1 = Single Phase

Con il comando seguente leggiamo il SOC ovvero la percentuale di carica della batteria:

Registro: 0x1212 Decimale: 4626

Comando:

mbpoll -m rtu -a 1 -b 115200 -P none -t 4:hex -0 -r 4626 -c 2 /dev/serial/by-id/usb-1a86_USB_Serial-if00-port0

Risultato:

0x4290

0x0000

Convertito in float IEEE754:

72%

Verificare per sicurezza se coincide con il valore che legge l’App.

Disabilitare la console seriale

Anche se viene utilizzato un dongle USB, conviene disabilitare la console seriale:

sudo systemctl disable serial-getty@ttyS0.service

Verifica:

systemctl is-enabled serial-getty@ttyS0.service

il risultato dovrebbe essere: disable o static

Risultato finale

Al termine della configurazione:

✅ Comunicazione RS485 funzionante

✅ Accesso diretto all’inverter

✅ Lettura registri Modbus

✅ SOC batteria verificato

Parametri finali

port: /dev/serial/by-id/usb-1a86_USB_Serial-if00-port0

slave: 1

baudrate: 115200

parity: N

stopbits: 1

protocol: modbus_rtu

Le chiavi che ho trovato valide per il mio inverter sono:

“soc”: 4626
“battery1_soc”: 45318,
“pv_power”: 4720,
“pv_today”: 4708,
“pv_total”: 4700,
“battery_power”: 4722,
“load_power”: 4724,
“grid_power”: 4726,
“grid_import_today”: 4710,
“grid_import_total”: 4672,
“grid_export_total”: 4674,

Nella mia configurazione ho un Raspberry 4 collegato all’inverter e un Raspberry 5 con Home Assistan, il problema era a questo punto, catturare il dati con il RB4 e inviarli via rete interna al RB5. Ho optato per passare i dati con MQTT.

Premesso che tutto il lavoro fino ad ora spiegato sia stato fatto sul RB collegato all’inverter , installiamo Python con il comando:

sudo apt update
sudo apt install python3-pip
pip3 install pymodbus paho-mqtt
sudo apt install python3-pymodbus python3-paho-mqtt

Per verificare digitare:
python3 -c "import pymodbus; import paho.mqtt.client; print('OK')"

la risposta dovrebbe essere OK

Sul RB dove è installato Home Assistant installare l’Add-On Mosquitto broker e segnarsi indirizzo IP interno es. 192.168.0.7 .

Verifichiamo adesso che il RB Inverter (raspberry collegato all’inverter) veda il servizio MQTT che abbiamo installato sul RB HA (Raspberry con Home Assistant) con il comando: nc -vz 192.168.0.7 1883 se non fosse installato usare il comando: sudo apt install netcat-openbsd se funziona si otterrà una risposta simile a: Connection to 192.168.0.7 1883 port [tcp/*] succeeded!

Abbiamo verificato:

✅ Mosquitto attivo su Home Assistant
✅ Home Assistant raggiungibile (es. 192.168.0.7)
✅ Porta MQTT 1883 aperta e raggiungibile
✅ Librerie Python installate sul Raspberry collegato all’inverter

Sul RB HA digitare il comando: mosquitto_pub -h 192.168.0.7 -t test/powerx -m “ciao”

Probabilmente la riposta sarà connessione negata perchè non sono accettate connessioni anonime. Verificare intanto che sul RB HA in Dispositivi e Servizi sotto Impostazioni sia presente MQTT, spesso va aggiunto o configurato di base. Sempre nelle impostazioni aggiunger un nuovo utente con nome: mqtt_powerx e impostare una password (attenzione deve essere un utente e non una persona). Tornare sul RB Inverter e dare il comando: mosquitto_pub -h 192.168.0.7 -u mqtt_powerx -P PASSWORD -t test/powerx -m “ciao” Sostituisci PASSWORD con la password dell’utente che hai creato. Se la password contiene caratteri speciale mettila tra apici es. ‘XXx!!x’ .

Creiamo lo script Python che legge i dati dell’inverter e li spedisce via MQTT al Raspberry HA

posizionarsi su una cartella, per esempio il desktop e creare il file con il comanado: nano inverter_feb.py

import time
import struct
import json
import paho.mqtt.publish as publish
from pymodbus.client import ModbusSerialClient

MQTT_BROKER = "192.168.0.7"
MQTT_USER = "mqtt_powerx"
MQTT_PASSWORD = "PASSWORD"

MODBUS_PORT = "/dev/serial/by-id/usb-1a86_USB_Serial-if00-port0"

REGISTERS = {
    "soc": 4626,

    "battery1_soc": 45318,

    "pv_power": 4720,
    "pv_today": 4708,
    "pv_total": 4700,

    "battery_power": 4722,

    "load_power": 4724,

    "grid_power": 4726,
    "grid_import_today": 4710,
    "grid_import_total": 4672,
    "grid_export_total": 4674,
}

UNITS = {
    "soc": "%",
    "battery1_soc": "%",

    "pv_power": "kW",
    "pv_today": "kWh",
    "pv_total": "kWh",

    "battery_power": "kW",

    "load_power": "kW",

    "grid_power": "kW",
    "grid_import_today": "kWh",
    "grid_import_total": "kWh",
    "grid_export_total": "kWh",
}

client = ModbusSerialClient(
    port=MODBUS_PORT,
    baudrate=115200,
    parity="N",
    stopbits=1,
    bytesize=8,
    timeout=2
)

discovery_sent = False


def read_float(address):

    result = client.read_input_registers(
        address=address,
        count=2,
        slave=1
    )

    if result.isError():
        return None

    raw = struct.pack(
        ">HH",
        result.registers[0],
        result.registers[1]
    )

    value = struct.unpack(">f", raw)[0]

    return round(value, 3)


def publish_value(topic, value):

    publish.single(
        topic,
        str(value),
        hostname=MQTT_BROKER,
        auth={
            "username": MQTT_USER,
            "password": MQTT_PASSWORD
        }
    )


def publish_discovery(sensor, unit):

    payload = {
        "name": f"PowerX {sensor}",
        "state_topic": f"powerx/{sensor}",
        "unique_id": f"powerx_{sensor}",
        "unit_of_measurement": unit,
    }

    if sensor in [
        "pv_total",
        "grid_import_total",
        "grid_export_total"
    ]:

        payload["state_class"] = "total_increasing"
        payload["device_class"] = "energy"

    else:

        payload["state_class"] = "measurement"

    publish.single(
        f"homeassistant/sensor/powerx_{sensor}/config",
        payload=json.dumps(payload),
        hostname=MQTT_BROKER,
        retain=True,
        auth={
            "username": MQTT_USER,
            "password": MQTT_PASSWORD
        }
    )


while True:

    try:

        if client.connect():

            if not discovery_sent:

                for sensor in REGISTERS:
                    publish_discovery(
                        sensor,
                        UNITS[sensor]
                    )

                discovery_sent = True

            values = {}

            for sensor, address in REGISTERS.items():

                values[sensor] = read_float(address)

                if values[sensor] is not None:

                    publish_value(
                        f"powerx/{sensor}",
                        values[sensor]
                    )

            print(
                f"SOC={values['soc']}%  "
                f"PV={values['pv_power']}kW  "
                f"PVToday={values['pv_today']}kWh  "
                f"Load={values['load_power']}kW  "
                f"Grid={values['grid_power']}kW"
            )

            client.close()

    except Exception as e:
        print(e)

    time.sleep(10)

inserendo la PASSWORD corretta. Dalla cartella dove avete creato il file eseguite il comando: python3 inverter_feb.py dovrebbe aprisi una finestra Terminal con per ogni riga tutti i valori generati che dovrebbero essere:

“soc”: 4626
“battery1_soc”: 45318,
“pv_power”: 4720,
“pv_today”: 4708,
“pv_total”: 4700,
“battery_power”: 4722,
“load_power”: 4724,
“grid_power”: 4726,
“grid_import_today”: 4710,
“grid_import_total”: 4672,
“grid_export_total”: 4674,

Su Home Assistant nell’Integrazione MQTT dovrebbero comparire 12 nuove entità ciascuno con i valori del vostro inverter.

Installando un Plug-In tipo Lumina si possono integrare tutte queste informazioni su un’interfaccia graficamente molto moderna come quella di seguito allegata.

Attenzione i dati arriveranno ad Home Assistant solo lo script è in esecuzione, se il RB Inverter si dovesse riavviare, chiaramente non arriverà più alcun dato finchè lo scrip non verrà eseguito nuovamente, esistono molti modi per automatazzare l’esecuzione.

Risparmiare energia con la domotica

Oggi risparmiare gas non significa più rinunciare al comfort o vivere con il maglione in casa. Con un piccolo investimento in domotica è possibile ridurre i consumi in modo intelligente, automatico e sorprendentemente semplice.

Controllo intelligente della temperatura

Il primo passo è sostituire o affiancare il termostato tradizionale con un sistema smart. I termostati domotici permettono di:

Programmare fasce orarie precise

Regolare la temperatura stanza per stanza

Spegnere automaticamente il riscaldamento quando non c’è nessuno in casa

Controllare tutto da smartphone

Spesso il gas viene sprecato perché la caldaia continua a lavorare quando la casa è vuota o quando la temperatura è già adeguata. Un sistema intelligente evita questi sprechi in modo automatico.

Integrazione con climatizzatori e pompe di calore

Molti climatizzatori moderni funzionano anche come pompe di calore. Integrandoli nella domotica è possibile:

Utilizzarli nelle mezze stagioni al posto della caldaia

Attivarli solo in presenza di persone

Coordinare il loro funzionamento con il riscaldamento per evitare sovrapposizioni

La gestione centralizzata evita che più sistemi lavorino contemporaneamente senza necessità, riducendo consumi inutili.

Automazioni semplici, grande risparmio

Con piattaforme come Home Assistant si possono creare automazioni molto semplici ma efficaci, ad esempio:

Se una finestra resta aperta per più di 5 minuti → il riscaldamento si spegne.

Se la casa entra in modalità “Assente” → temperatura ridotta automaticamente.

Di notte → abbassamento controllato di 1-2 gradi.

Anche solo abbassare la temperatura di 1°C può ridurre i consumi fino al 5-7%.

Costi contenuti, rientro rapido

Il bello è che non servono impianti costosi o interventi strutturali. Bastano:

Un hub domotico (spesso già presente)

Valvole termostatiche smart o un termostato intelligente

Eventuali moduli Wi-Fi per i climatizzatori

Con una spesa relativamente contenuta si può ottenere un risparmio annuale che ripaga l’investimento in poco tempo.

Comfort e controllo totale

Oltre al risparmio economico, la domotica offre un vantaggio importante: il controllo totale. Sapere quanta energia si consuma, visualizzare grafici, ricevere notifiche e ottimizzare nel tempo rende l’utente più consapevole e autonomo.

In conclusione, risparmiare gas oggi è facile: basta lasciare che sia la tecnologia a fare il lavoro al posto nostro. La domotica non è un lusso, ma uno strumento pratico per consumare meno, spendere meno e vivere meglio.

Se sei interessato leggi l’articolo su come ho organizzato la mai domotica

Paesi al Tramonto

Benvenuti su Paesi al Tramonto, il mio mondo visto dall’alto! Qui troverete spettacolari riprese aeree in alta definizione, catturate con il mio drone al tramonto o in condizioni di luce uniche. Esploriamo insieme i paesi italiani, castelli, ville, luoghi speciali e paesaggi mozzafiato..

La mia flotta è costituita da due droni un piccolo DJI Mini 2SE e un DJI Air 2S.

Ho conseguito le licenza di volo ENAV A1, A2 e A3 e a breve otterrò la STS01 e 02.

L’attività principale svolta è il girare mini documentati aerei per il mio canale YouTube “Paesi al Tramonto”, di seguito gli articoli su gli ultimi video pubblicati.

YouTube Channel : https://www.youtube.com/@PaesiAlTramonto

Instagram: https://www.instagram.com/paesialtramonto5

Vigonovo documentario aereo by Paesi al Tramonto

16-11-2023 – Vigonovo (PD) documentario aereo by Paesi al Tramonto. Tutte le riprese sono effettuate con pilota e drone regolarmente registrati su D-Flight, azienda autorizzata da ENAV S.p.A. (acronimo di Ente Nazionale per l’Assistenza al Volo). Le aree riprese non hanno divieti di sorvolo alle altezze in cui sono state effettuate.

Vigonovo (Vigonóvo in veneto) è un comune italiano di 9 839 abitanti della città metropolitana di Venezia in Veneto.

Geografia fisica
Vigonovo si trova nel punto più estremo della Riviera del Brenta. Il comune si situa nel territorio d’incrocio tra il corso del Brenta e quello del Naviglio, al confine con la provincia di Padova. Con le sue frazioni di Galta, Tombelle e Celeseo, Vigonovo si estende per circa 13 km² a ovest del Naviglio su terreno pianeggiante.

Ricchissimo di corsi d’acqua, oltre al Brenta, il territorio è percorso dal canale Piovego, dallo scolo Consorzio Piovega e dallo scolo Consorzio Cornio.

Origini del nome
Anticamente il paese era chiamato Sarmazza (o Sermazza) perché sede di un insediamento di Sarmati. Solo nel periodo medievale il nome verrà tramutato in Vicus Novus, cioè villaggio nuovo. L’attuale toponimo è chiaramente derivazione diretta di quest’ultima denominazione.

Storia
Il primo insediamento della zona doveva trovarsi presso l’attuale località Sarmazza, come testimonia il ritrovamento di una necropoli paleoveneta collocabile nel III secolo a.C. Poco dopo, con l’arrivo dei Romani, fu tracciata la via Annia che collegava Padova ad Aquileia toccando anche le odierne Tombelle e Sarmazza.

Nel IV secolo l’area fu evangelizzata. Il primo luogo di culto cristiano, costruito sui resti di un fortilizio romano, si trovava sempre a Sarmazza ed era dedicato al Santissimo Salvatore. Pare che il nome della località rimandi ai Sarmati, popolazione barbarica che invase l’Italia nella metà del V secolo: si ipotizza che, percorrendo la via Annia, l’orda abbia devastato Sarmazza, obbligando gli abitanti a rifugiarsi sulle isole della Laguna Veneta.

Sul finire del VI secolo, esauritesi le minacce dei barbari, gli abitanti tornarono a ripopolare la zona e ricostruirono il paese, che da allora si chiamò Vigonovo (ovvero “villaggio nuovo”). La nuova chiesa, dedicata all’Assunta, continuò a lungo a portare il titolo di “Santa Maria di Sarmazza di Vigonovo”; si trattava di una pieve matrice, da cui dipendevano diverse cappelle distribuite tra Camin e Lova.

Nell’874 viene attestato per la prima volta il toponimo Sarmazza. All’inizio del X secolo il territorio fu nuovamente devastato dalle scorribande degli Ungari, che distrussero definitivamente la chiesa di San Salvatore.

Al 1035 risale il primo documento che cita Vigonovo.

Nel corso del Medioevo il territorio a est di Padova fu coinvolto nelle guerre che opposero quest’ultima alla Repubblica di Venezia, a causa di dispute confinarie e per gli interventi idraulici che modificavano il corso del fiume Brenta. Nel 1405 Padova, e così Vigonovo, fu definitivamente assoggettata alla Serenissima e tale rimase sino alla sua caduta nel 1797.

Il periodo veneziano si caratterizzò per la costruzione di numerose ville venete, tra cui la splendida villa Sagredo.

Alla successiva amministrazione Lombardo-Veneta si colloca la deviazione del Brenta verso Chioggia e la formazione dell’attuale Naviglio del Brenta, allo scopo di intervenire sul problema delle inondazioni e dell’interramento della Laguna Veneta, ma anche per creare posti di lavoro.

Nel secondo dopoguerra Vigonovo ha visto un notevole sviluppo economico grazie alla crescita del settore calzaturiero.

Fonte Wikipedia

Limena documentario aereo by Paesi al Tramonto

03-11-2023 – Limena (PD) documentario aereo by Paesi al Tramonto. Tutte le riprese sono effettuate con pilota e drone regolarmente registrati su D-Flight, azienda autorizzata da ENAV S.p.A. (acronimo di Ente Nazionale per l’Assistenza al Volo). Le aree riprese non hanno divieti di sorvolo alle altezze in cui sono state effettuate.

Lìmena (Imena in veneto) è un comune italiano di 8 248 abitanti della provincia di Padova in Veneto, situato a nord del capoluogo. A Limena dal fiume Brenta si stacca il canale Brentella. È un comune facente parte dell’hinterland di Padova.

Evolutasi, soprattutto nel secondo dopoguerra, da paese a eminente vocazione agricola a centro di produzione prevalentemente industriale, la località mostra urbanisticamente parlando — una ricca presenza di uno sviluppo industriale.

Noventa Padovana documentario aereo by Paesi al Tramonto

03-11-2023 – Noventa (PD) documentario aereo by Paesi al Tramonto. Tutte le riprese sono effettuate con pilota e drone regolarmente registrati su D-Flight, azienda autorizzata da ENAV S.p.A. (acronimo di Ente Nazionale per l’Assistenza al Volo). Le aree riprese non hanno divieti di sorvolo alle altezze in cui sono state effettuate.

Noventa Padovana (Noénta in veneto) è un comune italiano di 11 281 abitanti della provincia di Padova in Veneto, parte integrante dell’area metropolitana del capoluogo.

Con un reddito medio (dichiarato) di 25.808 € per abitante nel 2019, Noventa risulta il comune più benestante della provincia superando anche il capoluogo; inoltre è l’ultimo comune della Riviera del Brenta partendo da Venezia.

« Articoli meno recenti

© 2026 Paolo Tuttoilmondo

Tema di Anders Noren — Su ↑