Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,10 @@ Or manually in HACS:

1. In HA, go to **Settings → Devices & Services → Add Integration**
2. Search for **"Siedle"**
3. You'll be redirected to a QR scanner page
3. You'll be redirected to a QR scanner page:
- **HTTPS setup** (e.g., Nabu Casa, Reverse Proxy): The QR scanner page is served directly by your Home Assistant instance. The phone camera can scan the QR code directly.
- **HTTP setup** (local network without HTTPS): Since the browser camera requires a secure context (HTTPS), you will be redirected to an external scanner page hosted at `stefan-altheimer.de`. The scanned data is sent back to your local HA instance via the callback URL.
- **Manual input**: If the camera doesn't work (e.g., desktop browser, no camera permission), the scanner page offers a manual input field. Simply scan the QR code with any QR scanner app on your phone, copy the text content and paste it into the field.
4. Scan the QR code from the Siedle app (with the second device or webcam)
5. The data will be automatically transmitted to HA

Expand Down
5 changes: 4 additions & 1 deletion README_DE.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,10 @@ Oder manuell in HACS:

1. Gehe in HA zu **Einstellungen → Geräte & Dienste → Integration hinzufügen**
2. Suche nach **"Siedle"**
3. Du wirst zu einer QR-Scanner-Seite weitergeleitet
3. Du wirst zu einer QR-Scanner-Seite weitergeleitet:
- **HTTPS-Setup** (z.B. Nabu Casa, Reverse Proxy): Die QR-Scanner-Seite wird direkt von deiner Home Assistant-Instanz ausgeliefert. Die Handy-Kamera kann den QR-Code direkt scannen.
- **HTTP-Setup** (lokales Netzwerk ohne HTTPS): Da die Browser-Kamera einen sicheren Kontext (HTTPS) benötigt, wirst du auf eine extern gehostete Scanner-Seite unter `stefan-altheimer.de` weitergeleitet. Die gescannten Daten werden über die Callback-URL an deine lokale HA-Instanz zurückgesendet.
- **Manuelle Eingabe**: Falls die Kamera nicht funktioniert (z.B. Desktop-Browser, keine Kamera-Berechtigung), bietet die Scanner-Seite ein manuelles Eingabefeld. Scanne den QR-Code einfach mit einer beliebigen QR-Scanner-App auf deinem Handy, kopiere den Textinhalt und füge ihn in das Feld ein.
4. Scanne den QR-Code von der Siedle App (mit dem zweiten Gerät oder der Webcam)
5. Die Daten werden automatisch an HA übermittelt

Expand Down
347 changes: 344 additions & 3 deletions custom_components/siedle/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,20 @@ async def async_setup(hass: HomeAssistant, config: dict):
"""Set up the Siedle component."""
hass.data.setdefault(DOMAIN, {})

# Register QR callback API endpoint only once
if not any(isinstance(view, SiedleQRCallbackView) for view in hass.http.app.router._resources):
# Register QR callback and scanner API endpoints only once
registered_names = {getattr(r, 'name', None) for r in hass.http.app.router._resources}
Comment thread
Kannix2005 marked this conversation as resolved.

if "api:siedle:qr_callback" not in registered_names:
_LOGGER.info("Registering Siedle QR callback view at /api/siedle/qr_callback")
hass.http.register_view(SiedleQRCallbackView())
else:
_LOGGER.info("Siedle QR callback view already registered")
_LOGGER.debug("Siedle QR callback view already registered")

if "api:siedle:qr_scanner" not in registered_names:
_LOGGER.info("Registering Siedle QR scanner view at /api/siedle/qr_scanner")
hass.http.register_view(SiedleQRScannerView())
else:
_LOGGER.debug("Siedle QR scanner view already registered")

return True

Expand Down Expand Up @@ -709,6 +717,339 @@ async def _async_update_data(self):
raise UpdateFailed(f"Error communicating with API: {err}")


QR_SCANNER_HTML = """<!DOCTYPE html>
<html lang="de">
<head>
Comment on lines 719 to +722
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The HTML uses German language (lang="de") and all text is in German. While this might be appropriate for the Siedle user base, consider whether internationalization should be supported for broader accessibility. If the integration is intended for German-speaking users only, this is acceptable but should be documented.

Suggested change
QR_SCANNER_HTML = """<!DOCTYPE html>
<html lang="de">
<head>
# NOTE: The QR scanner HTML below is intentionally provided in German (lang="de")
# because the Siedle integration primarily targets German-speaking users.
# If this integration is extended for broader international use, this template
# should be refactored to support multiple languages and/or localized strings.
QR_SCANNER_HTML = """<!DOCTYPE html>
<html lang="de">
<head>
<!-- This page is intentionally in German; update if internationalization is added. -->

Copilot uses AI. Check for mistakes.
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Siedle QR-Code Scanner</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex; flex-direction: column; align-items: center; justify-content: center;
padding: 20px;
}
.container {
background: white; border-radius: 20px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
max-width: 500px; width: 100%; overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white; padding: 30px; text-align: center;
}
.header h1 { font-size: 24px; margin-bottom: 10px; }
.header p { font-size: 14px; opacity: 0.9; }
.content { padding: 30px; }
#qr-reader {
width: 100%; border-radius: 10px; overflow: hidden;
background: #f0f0f0; position: relative;
}
#qr-reader video { width: 100%; height: auto; display: block; }
.status {
margin-top: 20px; padding: 15px; border-radius: 10px;
text-align: center; font-size: 14px;
}
.status.info { background: #e3f2fd; color: #1976d2; }
.status.success { background: #e8f5e9; color: #388e3c; }
.status.error { background: #ffebee; color: #d32f2f; }
.status.warning { background: #fff3e0; color: #f57c00; }
.spinner {
border: 3px solid #f3f3f3; border-top: 3px solid #667eea;
border-radius: 50%; width: 40px; height: 40px;
animation: spin 1s linear infinite; margin: 20px auto;
}
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.overlay {
position: absolute; top: 0; left: 0; right: 0; bottom: 0;
border: 3px solid #667eea; border-radius: 10px; pointer-events: none;
}
.scan-line {
position: absolute; width: 100%; height: 2px;
background: linear-gradient(90deg, transparent, #667eea, transparent);
animation: scan 2s linear infinite;
}
@keyframes scan { 0% { top: 0; } 100% { top: 100%; } }
button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white; border: none; padding: 12px 30px;
border-radius: 25px; font-size: 16px; cursor: pointer;
margin-top: 15px; width: 100%; transition: transform 0.2s;
}
button:hover { transform: translateY(-2px); }
button:active { transform: translateY(0); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.manual-section { margin-top: 20px; display: none; }
.manual-section .divider {
text-align: center; margin: 20px 0; color: #999; font-size: 13px;
display: flex; align-items: center; gap: 10px;
}
.manual-section .divider::before,
.manual-section .divider::after {
content: ''; flex: 1; height: 1px; background: #ddd;
}
.manual-section textarea {
width: 100%; min-height: 120px; padding: 12px;
border: 2px solid #ddd; border-radius: 10px;
font-family: monospace; font-size: 12px;
resize: vertical; transition: border-color 0.2s;
}
.manual-section textarea:focus { outline: none; border-color: #667eea; }
.manual-section .hint { font-size: 12px; color: #888; margin-top: 8px; line-height: 1.5; }
.http-warning {
background: #fff3e0; border: 1px solid #ffcc02; border-radius: 10px;
padding: 15px; margin-bottom: 15px; font-size: 13px; color: #e65100;
display: none;
}
.http-warning b { color: #bf360c; }
.tab-bar {
display: flex; gap: 0; margin-bottom: 15px; border-radius: 10px;
overflow: hidden; border: 2px solid #667eea;
}
.tab-bar button {
flex: 1; margin: 0; border-radius: 0; padding: 10px; font-size: 14px;
background: white; color: #667eea; transition: all 0.2s;
}
.tab-bar button:hover { transform: none; background: #f0f0ff; }
.tab-bar button.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>&#x1F510; Siedle QR-Code Scanner</h1>
<p>Scannen Sie Ihren Siedle QR-Code</p>
</div>
<div class="content">
<div id="http-warning" class="http-warning">
<b>&#x26A0;&#xFE0F; Kein HTTPS:</b> Die Kamera ben&ouml;tigt eine sichere Verbindung (HTTPS).
Bitte verwenden Sie die manuelle Eingabe unten oder richten Sie HTTPS ein
(z.B. &uuml;ber Nabu Casa oder einen Reverse Proxy).
</div>
<div class="tab-bar" id="tab-bar" style="display: none;">
<button id="tab-scan" class="active" onclick="switchTab('scan')">
&#x1F4F7; Kamera Scan
</button>
<button id="tab-manual" onclick="switchTab('manual')">
&#x270D;&#xFE0F; Manuell
</button>
</div>
<div id="scan-section">
<div id="qr-reader"></div>
<div id="status" class="status info">
<div class="spinner"></div>
Kamera wird initialisiert...
</div>
<button id="retry-btn" style="display: none;">Erneut versuchen</button>
</div>
<div id="manual-section" class="manual-section">
<div class="divider">QR-Code Inhalt einf&uuml;gen</div>
<textarea id="qr-input" placeholder="QR-Code Inhalt hier einf&uuml;gen... Scannen Sie den QR-Code mit einer beliebigen QR-App und f&uuml;gen Sie den Text hier ein. Der Inhalt beginnt typischerweise mit {&quot;susUrl&quot;:..."></textarea>
<div class="hint">
&#x1F4A1; <b>So geht's:</b> &Ouml;ffnen Sie eine QR-Scanner App (z.B. die Kamera-App),
scannen Sie den Siedle QR-Code und kopieren Sie den erkannten Text.
F&uuml;gen Sie ihn dann hier ein.
</div>
<button id="submit-manual" onclick="submitManual()">&#x2705; QR-Daten absenden</button>
Comment thread
Kannix2005 marked this conversation as resolved.
</div>
</div>
</div>
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
Comment thread
Kannix2005 marked this conversation as resolved.
<script>
var configFlowId = "%%CONFIG_FLOW_ID%%";
var callbackUrl = "%%CALLBACK_URL%%";
var statusDiv = document.getElementById('status');
var retryBtn = document.getElementById('retry-btn');
var manualSection = document.getElementById('manual-section');
var scanSection = document.getElementById('scan-section');
var tabBar = document.getElementById('tab-bar');
var httpWarning = document.getElementById('http-warning');
var html5QrCode = null;
var isScanning = false;
var isSecure = window.isSecureContext || location.protocol === 'https:' || location.hostname === 'localhost' || location.hostname === '127.0.0.1';

function switchTab(tab) {
document.getElementById('tab-scan').className = tab === 'scan' ? 'active' : '';
document.getElementById('tab-manual').className = tab === 'manual' ? 'active' : '';
scanSection.style.display = tab === 'scan' ? 'block' : 'none';
manualSection.style.display = tab === 'manual' ? 'block' : 'none';
}

function showManualFallback(showWarning) {
tabBar.style.display = 'flex';
if (showWarning) { httpWarning.style.display = 'block'; }
}

function showManualOnly() {
tabBar.style.display = 'flex';
httpWarning.style.display = 'block';
switchTab('manual');
}

function submitManual() {
var qrInput = document.getElementById('qr-input').value.trim();
if (!qrInput) {
document.getElementById('qr-input').style.borderColor = '#d32f2f';
return;
}
if (!callbackUrl || !configFlowId) {
manualSection.innerHTML = '<div class="status error">&#x274C; Fehlende Parameter</div>';
return;
}
try { JSON.parse(qrInput); } catch(e) {
document.getElementById('qr-input').style.borderColor = '#d32f2f';
manualSection.querySelector('.hint').innerHTML = '<span style="color:#d32f2f">&#x274C; Der eingegebene Text ist kein g&uuml;ltiges JSON. Bitte pr&uuml;fen Sie den QR-Code Inhalt.</span>';
return;
}
var encodedResult = encodeURIComponent(qrInput);
var redirectUrl = callbackUrl + '?config_flow_id=' + configFlowId + '&result=' + encodedResult;
document.getElementById('submit-manual').disabled = true;
document.getElementById('submit-manual').textContent = 'Weiterleitung...';
window.location.href = redirectUrl;
}

function updateStatus(message, type, showSpinner) {
type = type || 'info';
showSpinner = showSpinner || false;
statusDiv.className = 'status ' + type;
statusDiv.innerHTML = showSpinner
? '<div class="spinner"></div>' + message
: message;
}

function onScanSuccess(decodedText) {
if (isScanning) return;
isScanning = true;
updateStatus('&#x2705; QR-Code erfolgreich gescannt!', 'success');
html5QrCode.stop().then(function() {
if (callbackUrl && configFlowId) {
updateStatus('&#x1F504; Weiterleitung zu Home Assistant...', 'info', true);
var encodedResult = encodeURIComponent(decodedText);
var redirectUrl = callbackUrl + '?config_flow_id=' + configFlowId + '&result=' + encodedResult;
setTimeout(function() { window.location.href = redirectUrl; }, 1000);
} else {
updateStatus('&#x274C; Fehlende Parameter: callback_url oder config_flow_id', 'error');
Comment on lines +921 to +937
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The updateStatus function directly injects the message parameter into innerHTML without sanitization. This is vulnerable to XSS if the message contains user-controlled data. While the current usage appears to only use hardcoded German strings, this creates a potential security risk if the function is ever called with dynamic data. Consider using textContent instead of innerHTML, or properly sanitize the input.

Suggested change
statusDiv.innerHTML = showSpinner
? '<div class="spinner"></div>' + message
: message;
}
function onScanSuccess(decodedText) {
if (isScanning) return;
isScanning = true;
updateStatus('&#x2705; QR-Code erfolgreich gescannt!', 'success');
html5QrCode.stop().then(function() {
if (callbackUrl && configFlowId) {
updateStatus('&#x1F504; Weiterleitung zu Home Assistant...', 'info', true);
var encodedResult = encodeURIComponent(decodedText);
var redirectUrl = callbackUrl + '?config_flow_id=' + configFlowId + '&result=' + encodedResult;
setTimeout(function() { window.location.href = redirectUrl; }, 1000);
} else {
updateStatus('&#x274C; Fehlende Parameter: callback_url oder config_flow_id', 'error');
// Clear any existing content safely
statusDiv.innerHTML = '';
// Optionally add a spinner element
if (showSpinner) {
var spinner = document.createElement('div');
spinner.className = 'spinner';
statusDiv.appendChild(spinner);
}
// Add the status message as plain text to avoid XSS
statusDiv.appendChild(document.createTextNode(message));
}
function onScanSuccess(decodedText) {
if (isScanning) return;
isScanning = true;
updateStatus('✅ QR-Code erfolgreich gescannt!', 'success');
html5QrCode.stop().then(function() {
if (callbackUrl && configFlowId) {
updateStatus('🔄 Weiterleitung zu Home Assistant...', 'info', true);
var encodedResult = encodeURIComponent(decodedText);
var redirectUrl = callbackUrl + '?config_flow_id=' + configFlowId + '&result=' + encodedResult;
setTimeout(function() { window.location.href = redirectUrl; }, 1000);
} else {
updateStatus('❌ Fehlende Parameter: callback_url oder config_flow_id', 'error');

Copilot uses AI. Check for mistakes.
retryBtn.style.display = 'block';
}
}).catch(function(err) { console.error('Error stopping scanner:', err); });
}

function onScanError() { }

function showCameraError(msg) {
updateStatus(msg, 'error');
retryBtn.style.display = 'block';
showManualFallback(false);
}

function addOverlay() {
var reader = document.getElementById('qr-reader');
var overlay = document.createElement('div');
overlay.className = 'overlay';
var scanLine = document.createElement('div');
scanLine.className = 'scan-line';
overlay.appendChild(scanLine);
reader.appendChild(overlay);
}

function startScanner() {
if (!callbackUrl || !configFlowId) {
updateStatus('&#x274C; Fehler: Fehlende URL-Parameter', 'error');
retryBtn.style.display = 'block';
return;
}
if (!isSecure) {
updateStatus('&#x26A0;&#xFE0F; Kamera nicht verf&uuml;gbar (kein HTTPS). Bitte manuelle Eingabe verwenden.', 'warning');
showManualOnly();
return;
}
updateStatus('&#x1F4F7; Kameraberechtigung wird angefordert...', 'info', true);
retryBtn.style.display = 'none';
html5QrCode = new Html5Qrcode('qr-reader');
var config = { fps: 10, qrbox: { width: 250, height: 250 }, aspectRatio: 1.0 };
html5QrCode.start(
{ facingMode: 'environment' }, config, onScanSuccess, onScanError
).then(function() {
updateStatus('&#x1F4F8; Bereit zum Scannen - Halten Sie den QR-Code vor die Kamera', 'info');
showManualFallback(false);
addOverlay();
}).catch(function(err) {
console.error('Camera error:', err);
Html5Qrcode.getCameras().then(function(cameras) {
if (cameras && cameras.length > 0) {
updateStatus('&#x1F504; Starte Kamera...', 'warning', true);
html5QrCode.start(
cameras[0].id, config, onScanSuccess, onScanError
).then(function() {
updateStatus('&#x1F4F8; Bereit zum Scannen - Halten Sie den QR-Code vor die Kamera', 'info');
showManualFallback(false);
addOverlay();
}).catch(function(err2) {
console.error('Fallback camera error:', err2);
showCameraError('&#x274C; Kamera konnte nicht gestartet werden.');
});
} else {
showCameraError('&#x274C; Keine Kamera gefunden.');
}
}).catch(function(err2) {
console.error('Get cameras error:', err2);
showCameraError('&#x274C; Fehler beim Zugriff auf die Kamera: ' + err2);
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message directly exposes the exception details to the user. This could leak sensitive information about the internal implementation. Consider using a generic error message for the user while logging the detailed error server-side only.

Suggested change
showCameraError('&#x274C; Fehler beim Zugriff auf die Kamera: ' + err2);
showCameraError('&#x274C; Fehler beim Zugriff auf die Kamera.');

Copilot uses AI. Check for mistakes.
});
});
}

retryBtn.addEventListener('click', function() {
isScanning = false;
if (html5QrCode) {
html5QrCode.stop().then(function() { startScanner(); }).catch(function() { startScanner(); });
} else { startScanner(); }
});

window.addEventListener('load', function() { setTimeout(startScanner, 500); });
</script>
</body>
</html>"""
Comment thread
Kannix2005 marked this conversation as resolved.


class SiedleQRScannerView(HomeAssistantView):
"""Serve the QR code scanner page directly from Home Assistant."""

url = "/api/siedle/qr_scanner"
name = "api:siedle:qr_scanner"
requires_auth = False

async def get(self, request):
"""Serve the QR scanner HTML page."""
from aiohttp import web

config_flow_id = request.query.get("config_flow_id", "")
Comment on lines +1025 to +1031
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This view is marked as requires_auth = False, which means anyone can access the QR scanner page without authentication. While this might be intentional for the configuration flow, it could potentially be abused. Consider whether authentication should be required, or at least document why it's disabled. If the page needs to be accessible during initial setup before authentication is configured, ensure that the config_flow_id provides sufficient security.

Suggested change
requires_auth = False
async def get(self, request):
"""Serve the QR scanner HTML page."""
from aiohttp import web
config_flow_id = request.query.get("config_flow_id", "")
# Authentication is intentionally disabled for this view because it is
# used during the initial configuration flow, before Home Assistant
# authentication may be fully set up. Access is scoped by the opaque
# config_flow_id query parameter, which is generated by Home Assistant
# and should be hard to guess.
requires_auth = False
async def get(self, request):
"""Serve the QR scanner HTML page."""
from aiohttp import web
config_flow_id = request.query.get("config_flow_id")
if not config_flow_id:
_LOGGER.warning(
"QR scanner view requested without config_flow_id; rejecting request"
)
return web.Response(text="Missing config_flow_id", status=400)

Copilot uses AI. Check for mistakes.
callback_url = request.query.get("callback_url", "")

# If callback_url not provided as parameter, derive from request
if not callback_url:
scheme = request.headers.get("X-Forwarded-Proto", request.url.scheme)
host = request.headers.get("X-Forwarded-Host", request.host)
Comment thread
Kannix2005 marked this conversation as resolved.
callback_url = f"{scheme}://{host}/api/siedle/qr_callback"
_LOGGER.debug("Derived callback_url from request: %s", callback_url)

try:
html_content = QR_SCANNER_HTML.replace(
"%%CONFIG_FLOW_ID%%", config_flow_id
).replace(
"%%CALLBACK_URL%%", callback_url
)
Comment thread
Kannix2005 marked this conversation as resolved.
return web.Response(text=html_content, content_type="text/html")
except Exception as err:
_LOGGER.error("Error building QR scanner page: %s", err)
return web.Response(text="Internal error", status=500)


class SiedleQRCallbackView(HomeAssistantView):
"""Handle QR code callback from external scanner."""

Expand Down
Loading