// My Home — the Home Assistant half of the old Home & Phone page, promoted to // a tab of Home and taught about rooms. // // Lights render grouped by Home Assistant areas when the helper can read them // (HomeAssistant.rooms); a setup without areas degrades to one flat section // with no header, which is exactly the old behavior. The favorites editor and // the connection card move here unchanged — the credential boundary they carry // is contract-tested and was not worth re-inventing. import QtQuick import qs.config import qs.services SettingsPage { id: root objectName: "my-home-page" title: "My Home" lede: root.homeStatus() property string lightQuery: searchInput.text.trim().toLowerCase() readonly property var availableLights: HomeAssistant.catalog.filter(entity => { if (HomeAssistant.selectedEntities.some(selected => selected.id === entity.id)) return false; const haystack = (entity.sourceName + " " + entity.id).toLowerCase(); return root.lightQuery === "" || haystack.includes(root.lightQuery); }) readonly property string availableEmptyText: root.availableLights.length > 0 ? "" : (root.lightQuery !== "" ? "No lights match that search" : (HomeAssistant.catalog.length === 0 ? "No lights discovered" : "All discovered lights are already selected")) // The first four favorites appear in Control Center; the pill on a tile // says so. Everything else about featuring is HomePreferences' business. readonly property var featuredIds: HomeAssistant.selectedEntities .slice(0, 4).map(entity => String(entity.id)) readonly property var pageDiagnostics: ({ availableLightIds: root.availableLights.map(entity => entity.id), availableEmptyText: root.availableEmptyText, homeStatus: root.homeStatus(), rooms: (HomeAssistant.rooms ?? []).map(room => ({ name: String(room.name ?? ""), count: (room.lights ?? []).length })) }) function homeStatus(): string { if (HomeAssistant.lastError === "authentication-required") return "Authentication required"; if (HomeAssistant.lastError === "not-configured") return "Home Assistant is not configured"; if (HomeAssistant.phase === "ready") { const named = (HomeAssistant.rooms ?? []).filter(room => String(room.name ?? "") !== "").length; return named > 0 ? `Connected · ${HomeAssistant.discoveredCount} lights across ${named} room${named === 1 ? "" : "s"}` : `Connected · ${HomeAssistant.discoveredCount} lights discovered`; } if (HomeAssistant.phase === "degraded") return "Last update unavailable · showing saved controls"; if (HomeAssistant.phase === "loading") return "Connecting to Home Assistant"; return "Home Assistant is unavailable"; } // The entity list is a one-time migration seed for the Control Center // selection, not the light catalog -- the helper discovers that live from // Home Assistant. It is passed back unchanged so that saving a URL or a // token cannot disturb the seed. function saveHomeAssistantConfig(): void { HomeAssistantConfig.save( homeUrlInput.text, HomeAssistantConfig.entities.join(", "), homeTokenInput.text ); } Connections { target: HomeAssistantConfig function onConfigurationSaved(): void { homeTokenInput.clear(); homeUrlInput.text = HomeAssistantConfig.url; } } // ── Rooms ─────────────────────────────────────────────────────────────── SettingsCard { visible: HomeAssistant.catalog.length > 0 title: "Lights" subtitle: (HomeAssistant.rooms ?? []).some(room => String(room.name ?? "") !== "") ? "Grouped by Home Assistant areas. Tap to toggle; drag a track to dim." : "Tap to toggle; drag a track to dim." Column { width: parent.width spacing: 4 Repeater { model: HomeAssistant.rooms ?? [] delegate: Column { id: roomSection required property var modelData width: parent.width spacing: 8 Text { visible: String(roomSection.modelData.name ?? "") !== "" text: String(roomSection.modelData.name ?? "").toUpperCase() color: Theme.fgMuted font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall font.weight: Font.DemiBold font.letterSpacing: 0.8 topPadding: 6 } Grid { id: roomGrid width: parent.width columns: width >= 560 ? 2 : 1 columnSpacing: 10 rowSpacing: 10 bottomPadding: 6 Repeater { model: roomSection.modelData.lights ?? [] delegate: HomeLightTile { required property var modelData width: roomGrid.columns === 2 ? (roomGrid.width - roomGrid.columnSpacing) / 2 : roomGrid.width entity: modelData featured: root.featuredIds.indexOf(String(modelData.id)) >= 0 } } } } } } } // ── Accessories (the Control Center selection) ────────────────────────── SettingsCard { title: "Accessories" subtitle: HomeAssistant.selectedEntities.length === 0 ? "Select the lights that belong on your Control Center shelf." : `${Math.min(4, HomeAssistant.selectedEntities.length)} in Control Center · ${HomeAssistant.selectedEntities.length} selected` Text { width: parent.width visible: HomeAssistant.selectedEntities.length === 0 text: "Choose lights below to build your Control Center shelf." color: Theme.fgDim font.family: Theme.fontFamily font.pixelSize: Theme.fontSize wrapMode: Text.WordWrap topPadding: 3 bottomPadding: 13 } GridView { id: favoritesGrid width: parent.width height: Math.ceil(count / 2) * cellHeight visible: count > 0 interactive: false clip: false cellWidth: width / 2 cellHeight: 116 model: HomeAssistant.selectedEntities delegate: HomeFavoriteCard { required property var modelData width: GridView.view.cellWidth - 6 height: 108 favorite: ({ id: modelData.id, alias: modelData.name === modelData.sourceName ? "" : modelData.name }) sourceName: modelData.sourceName featured: index < 4 canMoveEarlier: index > 0 canMoveLater: index < favoritesGrid.count - 1 onAliasCommitted: (id, alias) => HomePreferences.setAlias(id, alias) onMoveRequested: (id, targetIndex) => HomePreferences.move(id, targetIndex) onRemoveRequested: id => HomePreferences.remove(id) } } Rectangle { width: parent.width height: 46 visible: HomePreferences.saveError !== "" radius: 9 color: Theme.alpha(Theme.warn, 0.09) border.width: 1 border.color: Theme.alpha(Theme.warn, 0.24) Text { anchors.left: parent.left anchors.leftMargin: 12 anchors.right: retryButton.left anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter text: HomePreferences.saveError color: Theme.warn font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall elide: Text.ElideRight } SettingsButton { id: retryButton anchors.right: parent.right anchors.rightMargin: 8 anchors.verticalCenter: parent.verticalCenter text: "Retry" activeFocusOnTab: true border.width: activeFocus ? 2 : 1 border.color: activeFocus ? Theme.warn : Theme.alpha(Theme.fg, 0.08) onClicked: HomePreferences.retrySave() Keys.onReturnPressed: HomePreferences.retrySave() Keys.onSpacePressed: HomePreferences.retrySave() } } Rectangle { width: parent.width height: 38 radius: 10 color: Theme.alpha(Theme.fg, searchInput.activeFocus ? 0.08 : 0.05) border.width: searchInput.activeFocus ? 2 : 1 border.color: searchInput.activeFocus ? Theme.alpha(Theme.accent, 0.72) : Theme.alpha(Theme.fg, 0.065) Text { anchors.left: parent.left anchors.leftMargin: 11 anchors.verticalCenter: parent.verticalCenter text: "\u{F0349}" color: Theme.fgDim font.family: Theme.fontMono font.pixelSize: 14 } TextInput { id: searchInput anchors.left: parent.left anchors.leftMargin: 36 anchors.right: parent.right anchors.rightMargin: 11 anchors.verticalCenter: parent.verticalCenter activeFocusOnTab: true color: Theme.fg selectionColor: Theme.accent selectedTextColor: Theme.bgDark font.family: Theme.fontFamily font.pixelSize: Theme.fontSize clip: true Text { anchors.fill: parent visible: searchInput.text === "" && !searchInput.activeFocus text: "Search available lights" color: Theme.fgMuted font: searchInput.font verticalAlignment: Text.AlignVCenter } } } Column { width: parent.width visible: root.availableLights.length > 0 Repeater { model: root.availableLights AvailableLightRow { required property var modelData width: parent.width entity: modelData onAddRequested: id => HomePreferences.add(id) } } } Text { width: parent.width visible: root.availableLights.length === 0 text: root.availableEmptyText color: Theme.fgDim font.family: Theme.fontFamily font.pixelSize: Theme.fontSize horizontalAlignment: Text.AlignHCenter wrapMode: Text.WordWrap topPadding: 18 bottomPadding: 10 } } // ── Connection ────────────────────────────────────────────────────────── SettingsCard { title: "Home Assistant" subtitle: root.homeStatus() SettingRow { label: "Connection" detail: HomeAssistantConfig.tokenConfigured ? "A long-lived access token is stored privately" : "Paste a long-lived access token to connect" value: HomeAssistantConfig.configured ? "Configured" : "Not configured" } SettingRow { label: "Server URL" detail: "The local or remote address of Home Assistant" controlWidth: 330 Rectangle { anchors.fill: parent radius: Theme.pillRadius color: Theme.alpha(Theme.fg, 0.07) border.width: homeUrlInput.activeFocus ? 2 : 1 border.color: homeUrlInput.activeFocus ? Theme.alpha(Theme.accent, 0.55) : "transparent" TextInput { id: homeUrlInput anchors.fill: parent anchors.leftMargin: 12 anchors.rightMargin: 12 activeFocusOnTab: true text: HomeAssistantConfig.url color: Theme.fg selectionColor: Theme.alpha(Theme.accent, 0.5) selectedTextColor: Theme.fg font.family: Theme.fontFamily font.pixelSize: Theme.fontSize verticalAlignment: TextInput.AlignVCenter clip: true Text { anchors.fill: parent visible: homeUrlInput.text === "" text: "https://homeassistant.local:8123" color: Theme.fgMuted font: homeUrlInput.font verticalAlignment: Text.AlignVCenter elide: Text.ElideRight } } } } SettingRow { label: "Access token" detail: HomeAssistantConfig.tokenConfigured ? "Stored · leave blank to keep it" : "Create one in your Home Assistant profile" controlWidth: 330 PasswordField { id: homeTokenInput anchors.fill: parent placeholder: HomeAssistantConfig.tokenConfigured ? "Stored token" : "Long-lived access token" onAccepted: root.saveHomeAssistantConfig() } } Text { width: parent.width visible: HomeAssistantConfig.lastError !== "" text: HomeAssistantConfig.lastError color: Theme.danger font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall wrapMode: Text.WordWrap bottomPadding: 9 } SettingRow { label: "Private configuration" detail: "Saved with owner-only permissions in a private environment file" controlWidth: 216 Row { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: 8 SettingsButton { id: clearTokenButton text: "Clear token" enabled: HomeAssistantConfig.tokenConfigured && !HomeAssistantConfig.busy activeFocusOnTab: enabled border.width: activeFocus ? 2 : 1 border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08) onClicked: HomeAssistantConfig.clearToken() Keys.onReturnPressed: if (enabled) HomeAssistantConfig.clearToken() Keys.onSpacePressed: if (enabled) HomeAssistantConfig.clearToken() } SettingsButton { id: saveHomeConfigButton text: HomeAssistantConfig.busy ? "Saving…" : "Save" tone: "accent" enabled: !HomeAssistantConfig.busy activeFocusOnTab: enabled border.width: activeFocus ? 2 : 0 border.color: activeFocus ? Theme.fg : "transparent" onClicked: root.saveHomeAssistantConfig() Keys.onReturnPressed: if (enabled) root.saveHomeAssistantConfig() Keys.onSpacePressed: if (enabled) root.saveHomeAssistantConfig() } } } SettingRow { label: "Light catalog" detail: "Light state is read through the Home Assistant helper." divider: false controlWidth: 176 Row { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: 8 SettingsButton { id: refreshButton text: "Refresh" activeFocusOnTab: true border.width: activeFocus ? 2 : 1 border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08) onClicked: HomeAssistant.refresh() Keys.onReturnPressed: HomeAssistant.refresh() Keys.onSpacePressed: HomeAssistant.refresh() } SettingsButton { id: openHomeButton text: "Open" activeFocusOnTab: true border.width: activeFocus ? 2 : 1 border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08) onClicked: HomeAssistant.open() Keys.onReturnPressed: HomeAssistant.open() Keys.onSpacePressed: HomeAssistant.open() } } } } }