Add gate icon and remove gate type
This commit is contained in:
30
README.md
30
README.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user