Add gate icon and remove gate type

This commit is contained in:
Ettore
2026-05-14 19:47:15 +02:00
parent db3966a1d7
commit e2de0ae1fa
9 changed files with 106 additions and 36 deletions

View File

@@ -2,12 +2,13 @@
# Lagomare Gates # Lagomare Gates
A web-based gate access management and control system. Authorized users can remotely open physical car and pedestrian gates via a mobile-friendly PWA. An admin dashboard provides full management of gates, access codes, and users. A web-based gate access management and control system. Authorized users can remotely open physical gates via a mobile-friendly PWA. An admin dashboard provides full management of gates, access codes, and users.
## Features ## Features
- **Keypass authentication** — users authenticate with an access code; each keypass can have a per-gate allowlist and an optional expiration date - **Keypass authentication** — users authenticate with an access code; each keypass can have a per-gate allowlist and an optional expiration date
- **Remote gate control** — integrates with [AVConnect](https://www.avconnect.it) to trigger gate macros - **Remote gate control** — integrates with [AVConnect](https://www.avconnect.it) and [Shelly Cloud](https://shelly.cloud) to trigger gate macros/relays
- **Gate icons** — each gate can be assigned any UTF-8 character or emoji as its icon, displayed on the user app button and keypass selector
- **Role-based admin panel** — two roles (`admin`, `manager`) with different permission levels - **Role-based admin panel** — two roles (`admin`, `manager`) with different permission levels
- **Two-factor authentication (TOTP)** — admins can enable app-based 2FA (Google Authenticator, Authy, etc.) on their account - **Two-factor authentication (TOTP)** — admins can enable app-based 2FA (Google Authenticator, Authy, etc.) on their account
- **Access audit log** — every open attempt is logged with timestamp, keypass, gate, IP, and result; filterable and paginated - **Access audit log** — every open attempt is logged with timestamp, keypass, gate, IP, and result; filterable and paginated
@@ -26,7 +27,7 @@ A web-based gate access management and control system. Authorized users can remo
| Auth | JWT (HS256) + bcrypt | | Auth | JWT (HS256) + bcrypt |
| 2FA | TOTP (RFC 6238) via pyotp | | 2FA | TOTP (RFC 6238) via pyotp |
| Credential storage | Fernet symmetric encryption | | Credential storage | Fernet symmetric encryption |
| Gate integration | AVConnect HTTP API | | Gate integration | AVConnect HTTP API / Shelly Cloud API |
| Notifications | Telegram Bot API | | Notifications | Telegram Bot API |
| QR generation | qrcode + Pillow | | QR generation | qrcode + Pillow |
| Frontend | Vanilla JS PWA | | Frontend | Vanilla JS PWA |
@@ -51,8 +52,9 @@ src/
│ ├── stats.py # Access log / statistics (paginated, filtered) │ ├── stats.py # Access log / statistics (paginated, filtered)
│ └── telegram.py # Telegram notification configuration │ └── telegram.py # Telegram notification configuration
├── services/ ├── services/
│ ├── avconnect.py # AVConnect session management and macro execution │ ├── avconnect.py # AVConnect API client
│ ├── gates.py # Gate open orchestration │ ├── gates.py # Gate open orchestration
| |── shelly.py # Shelly Cloud API client
│ └── telegram.py # Telegram Bot API client │ └── telegram.py # Telegram Bot API client
└── static/ # Frontend PWA (index.html, admin.html, JS, CSS) └── static/ # Frontend PWA (index.html, admin.html, JS, CSS)
data/ data/
@@ -111,8 +113,10 @@ data/
| Method | Endpoint | Description | | Method | Endpoint | Description |
|---|---|---| |---|---|---|
| GET | `/api/admin/credentials` | View stored credentials | | GET | `/api/admin/credentials/avconnect` | View stored AVConnect credentials |
| PUT | `/api/admin/credentials` | Create or update credentials | | PUT | `/api/admin/credentials/avconnect` | Create or update AVConnect credentials |
| GET | `/api/admin/credentials/shelly` | View stored Shelly Cloud credentials |
| PUT | `/api/admin/credentials/shelly` | Create or update Shelly Cloud credentials |
| GET | `/api/admin/credentials/mock` | Get mock mode status | | GET | `/api/admin/credentials/mock` | Get mock mode status |
| PUT | `/api/admin/credentials/mock` | Enable or disable mock mode | | PUT | `/api/admin/credentials/mock` | Enable or disable mock mode |
@@ -217,14 +221,24 @@ The application is then available at:
## AVConnect Integration ## AVConnect Integration
Gates are controlled through the AVConnect platform. Each gate is mapped to an AVConnect *macro ID*. When a gate open request is received, the service: Gates are controlled through one of two supported API providers.
### AVConnect
Each gate is mapped to an AVConnect *macro ID*. When a gate open request is received, the service:
1. Authenticates with AVConnect using the stored credentials (session is cached in the database) 1. Authenticates with AVConnect using the stored credentials (session is cached in the database)
2. Executes the configured macro for the gate 2. Executes the configured macro for the gate
Credentials (password) are stored encrypted in the database using Fernet symmetric encryption derived from `SECRET_KEY`. Credentials (password) are stored encrypted in the database using Fernet symmetric encryption derived from `SECRET_KEY`.
**Mock mode** — when enabled via the admin dashboard, gate open requests always succeed without contacting AVConnect. Useful for testing. ### Shelly Cloud
Each gate is mapped to a Shelly *device ID*. The service calls the Shelly Cloud API with the stored auth key to activate the device's relay.
The Shelly server URI and auth key are stored encrypted. Configure them under **Admin → Credentials → Shelly Cloud**.
**Mock mode** — when enabled via the admin dashboard, gate open requests always succeed without contacting any external API. Useful for testing.
## Keypass QR Codes ## Keypass QR Codes

View File

@@ -20,7 +20,7 @@ class GateDB(Base):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String, nullable=False) name: Mapped[str] = mapped_column(String, nullable=False)
gate_type: Mapped[str] = mapped_column(String, nullable=False) # 'car' | 'pedestrian' gate_icon: Mapped[str] = mapped_column(String, nullable=False, default="🚪") # any UTF-8 character/emoji
api_provider: Mapped[str] = mapped_column(String, nullable=False, default="avconnect") # 'avconnect' | 'shelly' api_provider: Mapped[str] = mapped_column(String, nullable=False, default="avconnect") # 'avconnect' | 'shelly'
avconnect_macro_id: Mapped[Optional[str]] = mapped_column(String, nullable=True) # AVConnect macro ID avconnect_macro_id: Mapped[Optional[str]] = mapped_column(String, nullable=True) # AVConnect macro ID
shelly_device_id: Mapped[Optional[str]] = mapped_column(String, nullable=True) # Shelly Cloud device ID shelly_device_id: Mapped[Optional[str]] = mapped_column(String, nullable=True) # Shelly Cloud device ID

View File

@@ -80,7 +80,7 @@ class GateResponse(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int
name: str name: str
gate_type: str gate_icon: str
api_provider: str api_provider: str
avconnect_macro_id: Optional[str] = None avconnect_macro_id: Optional[str] = None
shelly_device_id: Optional[str] = None shelly_device_id: Optional[str] = None
@@ -95,7 +95,7 @@ class GatePublicResponse(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int
name: str name: str
gate_type: str gate_icon: str
group_name: Optional[str] = None group_name: Optional[str] = None
lat: Optional[float] = None lat: Optional[float] = None
lon: Optional[float] = None lon: Optional[float] = None
@@ -103,7 +103,7 @@ class GatePublicResponse(BaseModel):
class GateCreate(BaseModel): class GateCreate(BaseModel):
name: str name: str
gate_type: str # 'car' | 'pedestrian' gate_icon: str = "🚪" # any UTF-8 character/emoji
api_provider: str = "avconnect" # 'avconnect' | 'shelly' api_provider: str = "avconnect" # 'avconnect' | 'shelly'
avconnect_macro_id: Optional[str] = None avconnect_macro_id: Optional[str] = None
shelly_device_id: Optional[str] = None shelly_device_id: Optional[str] = None

View File

@@ -16,14 +16,14 @@ router = APIRouter(prefix="/api/admin/credentials", tags=["admin-credentials"])
# ── AVConnect credentials ───────────────────────────────────────────────────── # ── AVConnect credentials ─────────────────────────────────────────────────────
@router.get("", response_model=list[CredentialRead]) @router.get("/avconnect", response_model=list[CredentialRead])
async def list_credentials( async def list_credentials(
db: Session = Depends(get_db), _: dict = Depends(require_admin) db: Session = Depends(get_db), _: dict = Depends(require_admin)
): ):
return [CredentialRead(id=c.id, username=c.username) for c in db.query(ApiCredential).all()] return [CredentialRead(id=c.id, username=c.username) for c in db.query(ApiCredential).all()]
@router.put("", response_model=CredentialRead) @router.put("/avconnect", response_model=CredentialRead)
async def upsert_credential( async def upsert_credential(
req: CredentialUpsert, req: CredentialUpsert,
db: Session = Depends(get_db), db: Session = Depends(get_db),

View File

@@ -36,8 +36,8 @@ def _notify(db: Session, gate_name: str, opened_by: str, ip: str | None) -> None
def _validate_gate_create(req: GateCreate) -> None: def _validate_gate_create(req: GateCreate) -> None:
if req.gate_type not in ("car", "pedestrian"): if not req.gate_icon:
raise HTTPException(400, "gate_type must be 'car' or 'pedestrian'") raise HTTPException(400, "gate_icon must not be empty")
if req.api_provider not in ("avconnect", "shelly"): if req.api_provider not in ("avconnect", "shelly"):
raise HTTPException(400, "api_provider must be 'avconnect' or 'shelly'") raise HTTPException(400, "api_provider must be 'avconnect' or 'shelly'")
if req.api_provider == "avconnect" and not req.avconnect_macro_id: if req.api_provider == "avconnect" and not req.avconnect_macro_id:

View File

@@ -78,6 +78,20 @@
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.section-header h3 { font-size: 1rem; font-weight: 700; } .section-header h3 { font-size: 1rem; font-weight: 700; }
/* ── Gate icon picker ───────────────────────────────────────────────── */
.icon-opt {
background: var(--surface);
border: 1.5px solid var(--border);
border-radius: .4rem;
cursor: pointer;
font-size: 1.3rem;
line-height: 1;
padding: .3rem .45rem;
transition: border-color .12s, background .12s;
}
.icon-opt:hover { border-color: var(--primary); }
.icon-opt.selected { border-color: var(--primary); background: color-mix(in srgb, var(--primary) 15%, transparent); }
</style> </style>
</head> </head>
<body> <body>
@@ -166,7 +180,7 @@
<th>ID</th> <th>ID</th>
<th>Name</th> <th>Name</th>
<th>Group</th> <th>Group</th>
<th>Type</th> <th>Icon</th>
<th>Provider</th> <th>Provider</th>
<th>Device / Macro ID</th> <th>Device / Macro ID</th>
<th>Status</th> <th>Status</th>
@@ -467,11 +481,32 @@
<datalist id="gate-group-list"></datalist> <datalist id="gate-group-list"></datalist>
</div> </div>
<div class="field"> <div class="field">
<label for="gate-type">Type</label> <label>Icon</label>
<select id="gate-type"> <div style="display:flex;flex-direction:column;gap:.5rem">
<option value="car">Car</option> <div style="display:flex;flex-wrap:wrap;gap:.35rem" id="gate-icon-grid">
<option value="pedestrian">Pedestrian</option> <button type="button" class="icon-opt" data-icon="🚘">🚘</button>
</select> <button type="button" class="icon-opt" data-icon="🚗">🚗</button>
<button type="button" class="icon-opt" data-icon="🚙">🚙</button>
<button type="button" class="icon-opt" data-icon="🚕">🚕</button>
<button type="button" class="icon-opt" data-icon="🚌">🚌</button>
<button type="button" class="icon-opt" data-icon="🚛">🚛</button>
<button type="button" class="icon-opt" data-icon="🚲">🚲</button>
<button type="button" class="icon-opt" data-icon="🏍️">🏍️</button>
<button type="button" class="icon-opt" data-icon="🚶">🚶</button>
<button type="button" class="icon-opt" data-icon="🧍">🧍</button>
<button type="button" class="icon-opt" data-icon="🚪">🚪</button>
<button type="button" class="icon-opt" data-icon="⛩️">⛩️</button>
<button type="button" class="icon-opt" data-icon="🏠">🏠</button>
<button type="button" class="icon-opt" data-icon="🏢">🏢</button>
<button type="button" class="icon-opt" data-icon="🔒">🔒</button>
<button type="button" class="icon-opt" data-icon="🔑">🔑</button>
</div>
<div style="display:flex;align-items:center;gap:.6rem">
<span id="gate-icon-preview" style="font-size:1.6rem;line-height:1;min-width:2rem;text-align:center">🚪</span>
<input id="gate-icon-input" type="text" placeholder="Type or paste any character…"
style="flex:1" maxlength="4" autocomplete="off" />
</div>
</div>
</div> </div>
<div class="field"> <div class="field">
<label for="gate-api-provider">API Provider</label> <label for="gate-api-provider">API Provider</label>
@@ -486,7 +521,7 @@
</div> </div>
<div class="field" id="gate-shelly-field" style="display:none"> <div class="field" id="gate-shelly-field" style="display:none">
<label for="gate-shelly-device-id">Shelly Device ID</label> <label for="gate-shelly-device-id">Shelly Device ID</label>
<input id="gate-shelly-device-id" type="text" placeholder="e.g. e0:98:06:xx:xx:xx" /> <input id="gate-shelly-device-id" type="text" placeholder="e.g. ab12cd34ef56" />
</div> </div>
<div class="field"> <div class="field">
<label for="gate-status">Status</label> <label for="gate-status">Status</label>

View File

@@ -203,7 +203,7 @@ async function loadKeypasses() {
const lbl = document.createElement("label"); const lbl = document.createElement("label");
lbl.style.cssText = "display:flex;align-items:center;gap:.75rem;cursor:pointer;padding:.5rem .75rem;background:var(--surface2);border-radius:6px;border:1px solid var(--border);margin:0"; lbl.style.cssText = "display:flex;align-items:center;gap:.75rem;cursor:pointer;padding:.5rem .75rem;background:var(--surface2);border-radius:6px;border:1px solid var(--border);margin:0";
const checked = allowedIds && allowedIds.includes(g.id) ? "checked" : ""; const checked = allowedIds && allowedIds.includes(g.id) ? "checked" : "";
lbl.innerHTML = `<input type="checkbox" name="kp-edit-gate" value="${g.id}" ${checked} style="width:1rem;height:1rem;flex-shrink:0" /> <span>${esc(g.name)}</span> <span style="color:var(--text-muted);font-size:.85em;margin-left:auto">${g.gate_type === 'car' ? '🚘 Car' : '🚶 Pedestrian'}</span>`; lbl.innerHTML = `<input type="checkbox" name="kp-edit-gate" value="${g.id}" ${checked} style="width:1rem;height:1rem;flex-shrink:0" /> <span>${esc(g.name)}</span> <span style="color:var(--text-muted);font-size:.85em;margin-left:auto">${g.gate_icon || ''}</span>`;
checksContainer.appendChild(lbl); checksContainer.appendChild(lbl);
} }
const allGatesCb = document.getElementById("kp-edit-all-gates"); const allGatesCb = document.getElementById("kp-edit-all-gates");
@@ -281,7 +281,7 @@ document.getElementById("btn-new-keypass").addEventListener("click", () => {
for (const g of _allGates) { for (const g of _allGates) {
const lbl = document.createElement("label"); const lbl = document.createElement("label");
lbl.style.cssText = "display:flex;align-items:center;gap:.75rem;cursor:pointer;padding:.5rem .75rem;background:var(--surface2);border-radius:6px;border:1px solid var(--border);margin:0"; lbl.style.cssText = "display:flex;align-items:center;gap:.75rem;cursor:pointer;padding:.5rem .75rem;background:var(--surface2);border-radius:6px;border:1px solid var(--border);margin:0";
lbl.innerHTML = `<input type="checkbox" name="kp-gate" value="${g.id}" style="width:1rem;height:1rem;flex-shrink:0" /> <span>${esc(g.name)}</span> <span style="color:var(--text-muted);font-size:.85em;margin-left:auto">${g.gate_type === 'car' ? '🚘 Car' : '🚶 Pedestrian'}</span>`; lbl.innerHTML = `<input type="checkbox" name="kp-gate" value="${g.id}" style="width:1rem;height:1rem;flex-shrink:0" /> <span>${esc(g.name)}</span> <span style="color:var(--text-muted);font-size:.85em;margin-left:auto">${g.gate_icon || ''}</span>`;
checksContainer.appendChild(lbl); checksContainer.appendChild(lbl);
} }
// Reset All gates checkbox // Reset All gates checkbox
@@ -436,7 +436,7 @@ async function loadGates() {
<td>${g.id}</td> <td>${g.id}</td>
<td>${esc(g.name)}</td> <td>${esc(g.name)}</td>
<td>${g.group_name ? esc(g.group_name) : '<span style="color:var(--text-muted)">\u2014</span>'}</td> <td>${g.group_name ? esc(g.group_name) : '<span style="color:var(--text-muted)">\u2014</span>'}</td>
<td>${g.gate_type === "car" ? "\u{1F698} Car" : "\u{1F6B6} Pedestrian"}</td> <td>${g.gate_icon || ''}</td>
<td><span style="font-size:.85em">${esc(providerLabel)}</span></td> <td><span style="font-size:.85em">${esc(providerLabel)}</span></td>
<td><code style="font-size:.85em">${esc(deviceId)}</code></td> <td><code style="font-size:.85em">${esc(deviceId)}</code></td>
<td>${badge}</td> <td>${badge}</td>
@@ -478,12 +478,35 @@ async function loadGates() {
}); });
} }
function _setGateIcon(icon) {
document.getElementById("gate-icon-input").value = icon;
document.getElementById("gate-icon-preview").textContent = icon;
document.querySelectorAll("#gate-icon-grid .icon-opt").forEach(btn => {
btn.classList.toggle("selected", btn.dataset.icon === icon);
});
}
// Icon grid clicks + manual input sync
document.getElementById("gate-icon-grid").addEventListener("click", e => {
const btn = e.target.closest(".icon-opt");
if (btn) _setGateIcon(btn.dataset.icon);
});
document.getElementById("gate-icon-input").addEventListener("input", e => {
const val = [...e.target.value].slice(0, 2).join(""); // keep at most one composed char
if (val) {
document.getElementById("gate-icon-preview").textContent = val;
document.querySelectorAll("#gate-icon-grid .icon-opt").forEach(btn => {
btn.classList.toggle("selected", btn.dataset.icon === val);
});
}
});
function openGateModal(gate = null) { function openGateModal(gate = null) {
document.getElementById("gate-modal-title").textContent = gate ? "Edit Gate" : "Add Gate"; document.getElementById("gate-modal-title").textContent = gate ? "Edit Gate" : "Add Gate";
document.getElementById("gate-edit-id").value = gate ? gate.id : ""; document.getElementById("gate-edit-id").value = gate ? gate.id : "";
document.getElementById("gate-name").value = gate ? gate.name : ""; document.getElementById("gate-name").value = gate ? gate.name : "";
document.getElementById("gate-group-name").value = gate ? (gate.group_name || "") : ""; document.getElementById("gate-group-name").value = gate ? (gate.group_name || "") : "";
document.getElementById("gate-type").value = gate ? gate.gate_type : "car"; _setGateIcon(gate ? (gate.gate_icon || "🚪") : "🚪");
const provider = gate ? (gate.api_provider || "avconnect") : "avconnect"; const provider = gate ? (gate.api_provider || "avconnect") : "avconnect";
document.getElementById("gate-api-provider").value = provider; document.getElementById("gate-api-provider").value = provider;
document.getElementById("gate-avconnect-macro-id").value = gate ? (gate.avconnect_macro_id || "") : ""; document.getElementById("gate-avconnect-macro-id").value = gate ? (gate.avconnect_macro_id || "") : "";
@@ -536,7 +559,7 @@ document.getElementById("gate-form").addEventListener("submit", async e => {
const provider = document.getElementById("gate-api-provider").value; const provider = document.getElementById("gate-api-provider").value;
const payload = { const payload = {
name: document.getElementById("gate-name").value.trim(), name: document.getElementById("gate-name").value.trim(),
gate_type: document.getElementById("gate-type").value, gate_icon: document.getElementById("gate-icon-input").value.trim() || "\uD83D\uDEAA",
api_provider: provider, api_provider: provider,
avconnect_macro_id: provider === "avconnect" ? document.getElementById("gate-avconnect-macro-id").value.trim() : null, avconnect_macro_id: provider === "avconnect" ? document.getElementById("gate-avconnect-macro-id").value.trim() : null,
shelly_device_id: provider === "shelly" ? document.getElementById("gate-shelly-device-id").value.trim() : null, shelly_device_id: provider === "shelly" ? document.getElementById("gate-shelly-device-id").value.trim() : null,
@@ -565,7 +588,7 @@ document.getElementById("gate-form").addEventListener("submit", async e => {
// ── Credentials ─────────────────────────────────────────────────────────────── // ── Credentials ───────────────────────────────────────────────────────────────
async function loadCredentials() { async function loadCredentials() {
try { try {
const list = await api("GET", "/api/admin/credentials"); const list = await api("GET", "/api/admin/credentials/avconnect");
if (list.length) { if (list.length) {
document.getElementById("cred-username").value = list[0].username; document.getElementById("cred-username").value = list[0].username;
} }
@@ -604,7 +627,7 @@ document.getElementById("credentials-form").addEventListener("submit", async e =
return; return;
} }
try { try {
await api("PUT", "/api/admin/credentials", { username, password }); await api("PUT", "/api/admin/credentials/avconnect", { username, password });
document.getElementById("cred-password").value = ""; document.getElementById("cred-password").value = "";
showToast("AVConnect credentials saved"); showToast("AVConnect credentials saved");
} catch (e) { } catch (e) {

View File

@@ -118,7 +118,6 @@ async function updateMap(gates) {
const popup = L.popup().setContent( const popup = L.popup().setContent(
`<div style="min-width:140px"> `<div style="min-width:140px">
<strong>${gate.name}</strong><br> <strong>${gate.name}</strong><br>
<em style="font-size:.85em;color:#666">${gate.gate_type === "car" ? "Car gate" : "Pedestrian gate"}</em><br>
<a href="https://www.google.com/maps/dir/?api=1&destination=${gate.lat},${gate.lon}" <a href="https://www.google.com/maps/dir/?api=1&destination=${gate.lat},${gate.lon}"
target="_blank" rel="noopener" target="_blank" rel="noopener"
style="display:inline-block;margin-top:.5em;font-size:.85em;text-decoration:underline"> style="display:inline-block;margin-top:.5em;font-size:.85em;text-decoration:underline">
@@ -126,7 +125,7 @@ async function updateMap(gates) {
</a> </a>
</div>` </div>`
); );
const marker = L.marker([gate.lat, gate.lon], { icon: _gateIcon(gate.gate_type) }) const marker = L.marker([gate.lat, gate.lon], { icon: _gateIcon() })
.bindPopup(popup) .bindPopup(popup)
.addTo(_map); .addTo(_map);
_mapMarkers.push(marker); _mapMarkers.push(marker);
@@ -189,9 +188,9 @@ function renderGates(gates) {
groupGrid.className = "gate-group-grid"; groupGrid.className = "gate-group-grid";
for (const gate of groups.get(key)) { for (const gate of groups.get(key)) {
const icon = gate.gate_type === "car" ? "🚘" : "🚶"; const icon = gate.gate_icon || '🚪';
const btn = document.createElement("button"); const btn = document.createElement("button");
btn.className = `gate-btn ${gate.gate_type}`; btn.className = "gate-btn";
btn.dataset.gateId = gate.id; btn.dataset.gateId = gate.id;
btn.innerHTML = `<span class="icon">${icon}</span><span>${gate.name}</span>`; btn.innerHTML = `<span class="icon">${icon}</span><span>${gate.name}</span>`;
btn.addEventListener("click", () => handleOpenGate(btn, gate)); btn.addEventListener("click", () => handleOpenGate(btn, gate));

View File

@@ -105,8 +105,7 @@ label {
overflow: hidden; overflow: hidden;
} }
.gate-btn .icon { font-size: 1.8rem; line-height: 1; } .gate-btn .icon { font-size: 1.8rem; line-height: 1; }
.gate-btn.car { background: var(--primary); color: #fff; } .gate-btn { background: var(--primary); color: #fff; }
.gate-btn.pedestrian { background: var(--green); color: #fff; }
.gate-btn:not(:disabled):active { transform: scale(.94); } .gate-btn:not(:disabled):active { transform: scale(.94); }
.gate-btn:disabled { opacity: .55; cursor: not-allowed; } .gate-btn:disabled { opacity: .55; cursor: not-allowed; }