This commit is contained in:
Mateusz Gruszczyński
2026-09-17 08:52:02 +02:00
parent ff4f2b60e7
commit b7846c3b9f
87 changed files with 3783 additions and 1418 deletions
+296 -81
View File
@@ -1664,7 +1664,7 @@ button.outside-pill:focus-visible {
font-weight: 650;
}
.outdoor-history-links > div {
.outdoor-history-links>div {
display: flex;
align-items: center;
gap: 7px;
@@ -2285,7 +2285,9 @@ button.outside-pill:focus-visible {
font-size: 12px;
}
.custom-chart-edit-note strong { color: var(--text-soft); }
.custom-chart-edit-note strong {
color: var(--text-soft);
}
.saved-chart-section {
display: grid;
@@ -2301,7 +2303,9 @@ button.outside-pill:focus-visible {
gap: 10px;
}
.saved-chart-heading small { color: var(--muted); }
.saved-chart-heading small {
color: var(--muted);
}
.saved-chart-list {
display: grid;
@@ -2939,7 +2943,9 @@ button.outside-pill:focus-visible {
align-items: end;
}
.history-network-controls label { min-width: 0; }
.history-network-controls label {
min-width: 0;
}
.history-jitter-toggle {
min-height: 45px;
@@ -4507,8 +4513,6 @@ body.simulation-standalone [data-view="simulation"] {
height: 100%;
}
/* Dashboard thermostat cards keep their intrinsic height when one zone shows
a local/manual takeover panel. Other grids retain equal-height rows. */
#dashboardZones {
grid-auto-rows: auto;
align-items: start;
@@ -9029,13 +9033,25 @@ body.flow-editor-open {
}
@keyframes cloud-command-pulse {
from { opacity: .72; }
to { opacity: 1; }
from {
opacity: .72;
}
to {
opacity: 1;
}
}
@keyframes cloud-command-dot {
from { transform: scale(.65); opacity: .45; }
to { transform: scale(1); opacity: 1; }
from {
transform: scale(.65);
opacity: .45;
}
to {
transform: scale(1);
opacity: 1;
}
}
@@ -9367,7 +9383,7 @@ body.flow-editor-open {
content: none;
}
}
/* Notification delivery audit in Events. */
.log-row .kind .log-notification-badge {
margin-left: .45rem;
padding: 3px 7px;
@@ -9380,7 +9396,6 @@ body.flow-editor-open {
vertical-align: middle;
}
/* Keep the Flow footer compact before the full mobile layout kicks in. */
@media (min-width:761px) and (max-width:1200px) {
.flow-natural-preview {
display: block;
@@ -9450,14 +9465,13 @@ body.flow-editor-open {
}
}
/* GREE Cloud runtime summary */
.cloud-runtime-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.cloud-runtime-summary > div {
.cloud-runtime-summary>div {
display: grid;
gap: 4px;
min-width: 0;
@@ -9519,7 +9533,6 @@ body.flow-editor-open {
}
}
/* Shared alerts and split/multisplit installation configuration */
.inline-alert {
display: grid;
gap: 4px;
@@ -9550,18 +9563,30 @@ body.flow-editor-open {
color: var(--danger);
}
.inline-alert.error strong { color: var(--danger); }
.inline-alert.error strong {
color: var(--danger);
}
.inline-alert.success {
border-color: color-mix(in srgb, var(--accent) 42%, var(--line));
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
}
.inline-alert.info {
border-color: color-mix(in srgb, var(--info) 38%, var(--line));
background: color-mix(in srgb, var(--info) 9%, var(--surface));
}
.inline-alert.wide { grid-column: 1 / -1; }
.device-installation-summary { display: grid; gap: 8px; margin-bottom: 12px; }
.inline-alert.wide {
grid-column: 1 / -1;
}
.device-installation-summary {
display: grid;
gap: 8px;
margin-bottom: 12px;
}
.installation-summary-card,
.installation-list-row {
display: grid;
@@ -9573,36 +9598,106 @@ body.flow-editor-open {
border-radius: 12px;
background: var(--surface-muted);
}
.installation-summary-card > div,
.installation-list-row > button:first-child { display: grid; gap: 3px; }
.installation-summary-card>div,
.installation-list-row>button:first-child {
display: grid;
gap: 3px;
}
.installation-summary-card strong,
.installation-list-row strong { color: var(--text); }
.installation-list-row strong {
color: var(--text);
}
.installation-summary-card small,
.installation-list-row small { color: var(--muted); }
.installation-list { display: grid; gap: 8px; }
.installation-list-row > button:first-child { border: 0; background: transparent; padding: 0; text-align: left; }
.installation-list-row > div { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
.installation-form { border-top: 1px solid var(--line); padding-top: 14px; margin-top: 14px; }
.installation-device-choices { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; margin-top: 6px; }
.installation-device-choices .check { margin: 0; padding: 8px 9px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface); }
.installation-device-choices .check.disabled { opacity: .55; }
.installation-list-row small {
color: var(--muted);
}
.installation-list {
display: grid;
gap: 8px;
}
.installation-list-row>button:first-child {
border: 0;
background: transparent;
padding: 0;
text-align: left;
}
.installation-list-row>div {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.installation-form {
border-top: 1px solid var(--line);
padding-top: 14px;
margin-top: 14px;
}
.installation-device-choices {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
margin-top: 6px;
}
.installation-device-choices .check {
margin: 0;
padding: 8px 9px;
border: 1px solid var(--line);
border-radius: 9px;
background: var(--surface);
}
.installation-device-choices .check.disabled {
opacity: .55;
}
.installation-energy-metric b {
color: var(--text);
font-size: 18px;
font-variant-numeric: tabular-nums;
letter-spacing: -.01em;
}
.history-context-controls.energy-context-controls {
display: grid;
grid-template-columns: minmax(240px, 1.5fr) repeat(3, minmax(132px, .8fr));
gap: 10px;
align-items: start;
}
.history-energy-targets { display: grid; gap: 7px; min-width: 0; color: var(--muted); font-size: 12px; font-weight: 650; }
.history-energy-targets {
display: grid;
gap: 7px;
min-width: 0;
color: var(--muted);
font-size: 12px;
font-weight: 650;
}
.history-energy-targets small,
.history-range-control small { display: block; color: var(--muted); font-size: 10px; font-weight: 550; line-height: 1.35; }
.history-energy-picker { position: relative; min-width: 0; }
.history-energy-picker > summary {
.history-range-control small {
display: block;
color: var(--muted);
font-size: 10px;
font-weight: 550;
line-height: 1.35;
}
.history-energy-picker {
position: relative;
min-width: 0;
}
.history-energy-picker>summary {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
@@ -9616,9 +9711,18 @@ body.flow-editor-open {
cursor: pointer;
list-style: none;
}
.history-energy-picker > summary::-webkit-details-marker { display: none; }
.history-energy-picker > summary span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.history-energy-picker > summary b {
.history-energy-picker>summary::-webkit-details-marker {
display: none;
}
.history-energy-picker>summary span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-energy-picker>summary b {
min-width: 34px;
padding: 3px 6px;
border-radius: 999px;
@@ -9627,7 +9731,13 @@ body.flow-editor-open {
font-size: 10px;
text-align: center;
}
.history-energy-picker[open] > summary { border-color: var(--accent); outline: 2px solid color-mix(in srgb, var(--accent) 18%, transparent); outline-offset: 1px; }
.history-energy-picker[open]>summary {
border-color: var(--accent);
outline: 2px solid color-mix(in srgb, var(--accent) 18%, transparent);
outline-offset: 1px;
}
.history-energy-options {
position: absolute;
z-index: 20;
@@ -9642,6 +9752,7 @@ body.flow-editor-open {
background: var(--surface);
box-shadow: 0 14px 34px rgba(0, 0, 0, .22);
}
.history-energy-option {
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
@@ -9655,28 +9766,67 @@ body.flow-editor-open {
font-size: 11px;
font-weight: 600;
}
.history-energy-option:hover { background: var(--surface-muted); }
.history-energy-option input { width: 17px; min-height: 17px; margin: 0; padding: 0; accent-color: var(--accent); }
.history-energy-option span { overflow-wrap: anywhere; }
.history-range-control.energy-period-control { min-width: 0; }
.history-energy-option:hover {
background: var(--surface-muted);
}
.history-energy-option input {
width: 17px;
min-height: 17px;
margin: 0;
padding: 0;
accent-color: var(--accent);
}
.history-energy-option span {
overflow-wrap: anywhere;
}
.history-range-control.energy-period-control {
min-width: 0;
}
@media (max-width: 760px) {
.installation-summary-card,
.installation-list-row { grid-template-columns: 1fr; }
.installation-list-row > div { justify-content: flex-start; }
.installation-device-choices { grid-template-columns: 1fr; }
.history-context-controls.energy-context-controls { grid-template-columns: 1fr; }
.history-toolbar-panel .chart-toolbar.energy-toolbar .history-refresh-button { margin-top: 0; }
.history-energy-options { width: min(420px, calc(100vw - 40px)); max-height: 46vh; }
.installation-list-row {
grid-template-columns: 1fr;
}
.installation-list-row>div {
justify-content: flex-start;
}
.installation-device-choices {
grid-template-columns: 1fr;
}
.history-context-controls.energy-context-controls {
grid-template-columns: 1fr;
}
.history-toolbar-panel .chart-toolbar.energy-toolbar .history-refresh-button {
margin-top: 0;
}
.history-energy-options {
width: min(420px, calc(100vw - 40px));
max-height: 46vh;
}
}
@media (max-width: 640px) {
.history-network-controls { grid-template-columns: 1fr; }
.history-jitter-toggle { width: 100%; }
.history-network-controls {
grid-template-columns: 1fr;
}
.history-jitter-toggle {
width: 100%;
}
}
/* Unified custom selects -------------------------------------------------- */
.select-native-proxy {
position: absolute !important;
width: 1px !important;
@@ -9865,8 +10015,8 @@ body.flow-editor-open {
padding: 9px 10px;
}
.has-error > .custom-select .custom-select-trigger,
select.select-native-proxy[aria-invalid="true"] + .custom-select .custom-select-trigger {
.has-error>.custom-select .custom-select-trigger,
select.select-native-proxy[aria-invalid="true"]+.custom-select .custom-select-trigger {
border-color: color-mix(in srgb, var(--danger) 68%, var(--line));
outline: 2px solid color-mix(in srgb, var(--danger) 13%, transparent);
}
@@ -9877,13 +10027,26 @@ select.select-native-proxy[aria-invalid="true"] + .custom-select .custom-select-
}
}
@media (max-width: 900px), (orientation: landscape) and (max-height: 700px) {
.flow-fit-button-icon { display: inline-grid; place-items: center; }
.flow-fit-button-label { display: none; }
@media (max-width: 900px),
(orientation: landscape) and (max-height: 700px) {
.flow-fit-button-icon {
display: inline-grid;
place-items: center;
}
.flow-fit-button-label {
display: none;
}
}
@media (orientation: landscape) and (max-height: 700px) and (min-width: 500px) {
.flow-fit-button-icon { display: none; }
.flow-fit-button-label { display: inline; }
.flow-fit-button-icon {
display: none;
}
.flow-fit-button-label {
display: inline;
}
}
.ha-supervisor-status {
@@ -9896,11 +10059,27 @@ select.select-native-proxy[aria-invalid="true"] + .custom-select .custom-select-
background: var(--surface-2);
}
.ha-supervisor-status strong { font-size: .92rem; }
.ha-supervisor-status span { color: var(--muted); font-size: .82rem; line-height: 1.45; }
.ha-supervisor-status.success { border-left-color: var(--teal); }
.ha-supervisor-status.warning { border-left-color: var(--accent); }
.ha-supervisor-status.error { border-left-color: var(--danger); }
.ha-supervisor-status strong {
font-size: .92rem;
}
.ha-supervisor-status span {
color: var(--muted);
font-size: .82rem;
line-height: 1.45;
}
.ha-supervisor-status.success {
border-left-color: var(--teal);
}
.ha-supervisor-status.warning {
border-left-color: var(--accent);
}
.ha-supervisor-status.error {
border-left-color: var(--danger);
}
.ha-entity-picker {
display: grid;
@@ -9908,8 +10087,13 @@ select.select-native-proxy[aria-invalid="true"] + .custom-select .custom-select-
min-width: 0;
}
.ha-entity-picker > label { margin: 0; }
.ha-entity-picker > .field-note { margin: 0; }
.ha-entity-picker>label {
margin: 0;
}
.ha-entity-picker>.field-note {
margin: 0;
}
.ha-entity-results {
display: grid;
@@ -9942,7 +10126,7 @@ select.select-native-proxy[aria-invalid="true"] + .custom-select .custom-select-
background: var(--surface-2);
}
.ha-entity-option > span {
.ha-entity-option>span {
display: grid;
gap: 2px;
min-width: 0;
@@ -9962,15 +10146,26 @@ select.select-native-proxy[aria-invalid="true"] + .custom-select .custom-select-
font-size: .76rem;
}
.ha-entity-option small { flex: 0 0 auto; }
.ha-entity-empty { padding: 9px 10px; }
.ha-entity-option small {
flex: 0 0 auto;
}
/* Shared Flow HA entity picker: keep search and selected entity visually distinct. */
.ha-entity-search-field input[type="search"] { width: 100%; }
.ha-entity-selected-field { margin-top: 2px; }
.ha-entity-selected-field input { font-family: var(--mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); }
.ha-entity-empty {
padding: 9px 10px;
}
.ha-entity-search-field input[type="search"] {
width: 100%;
}
.ha-entity-selected-field {
margin-top: 2px;
}
.ha-entity-selected-field input {
font-family: var(--mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
}
/* Standalone public custom chart */
.public-custom-chart-page {
height: 100vh;
height: 100dvh;
@@ -10003,7 +10198,9 @@ select.select-native-proxy[aria-invalid="true"] + .custom-select .custom-select-
align-items: end;
}
.public-chart-heading { min-width: 0; }
.public-chart-heading {
min-width: 0;
}
.public-chart-range-control {
display: grid;
@@ -10013,7 +10210,9 @@ select.select-native-proxy[aria-invalid="true"] + .custom-select .custom-select-
font-size: 11px;
}
.public-chart-range-control select { min-width: 126px; }
.public-chart-range-control select {
min-width: 126px;
}
.public-custom-chart-wrap {
flex: 1 1 0;
@@ -10037,7 +10236,9 @@ select.select-native-proxy[aria-invalid="true"] + .custom-select .custom-select-
scrollbar-width: none;
}
.public-custom-chart-card .legend::-webkit-scrollbar { display: none; }
.public-custom-chart-card .legend::-webkit-scrollbar {
display: none;
}
.public-chart-legend-item {
cursor: default;
@@ -10056,9 +10257,23 @@ select.select-native-proxy[aria-invalid="true"] + .custom-select .custom-select-
}
@media (max-width: 700px) {
.public-custom-chart-shell { padding: 8px; }
.public-custom-chart-card { padding: 12px; }
.public-chart-title-row { align-items: center; }
.public-chart-range-control select { min-width: 108px; }
.public-custom-chart-wrap { min-height: 140px; }
}
.public-custom-chart-shell {
padding: 8px;
}
.public-custom-chart-card {
padding: 12px;
}
.public-chart-title-row {
align-items: center;
}
.public-chart-range-control select {
min-width: 108px;
}
.public-custom-chart-wrap {
min-height: 140px;
}
}
+5 -1
View File
@@ -1,5 +1,6 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
@@ -8,8 +9,10 @@
<link rel="icon" href="__GREE_BASE_PATH__/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="__GREE_STYLES_ASSET__">
<script src="__GREE_THEME_INIT_ASSET__"></script>
<script src="__GREE_LANG_INIT_ASSET__"></script>
<title>GREE Controller · Custom chart</title>
</head>
<body class="public-custom-chart-page">
<main class="public-custom-chart-shell">
<section class="panel chart-panel chart-card public-custom-chart-card" aria-live="polite">
@@ -41,4 +44,5 @@
</main>
<script src="__GREE_CUSTOM_CHART_ASSET__"></script>
</body>
</html>
</html>
+280 -127
View File
@@ -31,7 +31,8 @@
</select>
</div>
<div class="toolbar-picker" data-i18n-title="controls.theme" title="Theme">
<span class="toolbar-picker-value theme-picker-value" id="themeIcon" data-ui-icon="system" aria-hidden="true"></span>
<span class="toolbar-picker-value theme-picker-value" id="themeIcon" data-ui-icon="system"
aria-hidden="true"></span>
<span class="toolbar-picker-chevron" data-ui-icon="chevron-down" aria-hidden="true"></span>
<select class="toolbar-picker-select" id="themeSelect" data-i18n-aria="controls.theme" aria-label="Theme">
<option value="system" data-i18n="theme.system">System</option>
@@ -146,7 +147,11 @@
<div><span class="eyebrow" data-i18n="devices.localCloud">Local / GREE Cloud</span>
<h1 data-i18n="nav.devices">Devices</h1>
</div>
<div class="section-heading-actions"><button class="primary compact" id="discoverButton" data-i18n="devices.discoverLocal">Discover Local</button><button class="secondary" type="button" id="cloudDiscoverButton">GREE Cloud</button><button class="secondary" type="button" id="deviceGroupsButton" data-i18n="devices.installations">Split / multisplit installations</button><button class="secondary" data-open="deviceDialog" data-i18n="devices.addLocalManual">Add Local manually</button></div>
<div class="section-heading-actions"><button class="primary compact" id="discoverButton"
data-i18n="devices.discoverLocal">Discover Local</button><button class="secondary" type="button"
id="cloudDiscoverButton">GREE Cloud</button><button class="secondary" type="button" id="deviceGroupsButton"
data-i18n="devices.installations">Split / multisplit installations</button><button class="secondary"
data-open="deviceDialog" data-i18n="devices.addLocalManual">Add Local manually</button></div>
</div>
<p class="lead" data-i18n="devices.technicalDescription">Technical configuration and diagnostics only. Current
operating settings are available in Manual control.</p>
@@ -334,12 +339,13 @@
<div class="panel chart-panel history-toolbar-panel">
<div class="chart-toolbar">
<div id="historyContextControls" class="history-context-controls"></div>
<label class="history-range-control" id="historyRangeControl"><span data-i18n="history.range">Range</span><select id="historyHours"
data-i18n-aria="history.range" aria-label="History range">
<label class="history-range-control" id="historyRangeControl"><span
data-i18n="history.range">Range</span><select id="historyHours" data-i18n-aria="history.range"
aria-label="History range">
<option value="6" data-i18n="history.6h">6 hours</option>
<option value="24" selected data-i18n="history.24h">24 hours</option>
<option value="24" data-i18n="history.24h">24 hours</option>
<option value="168" data-i18n="history.7d">7 days</option>
<option value="720" data-i18n="history.30d">30 days</option>
<option value="720" selected data-i18n="history.30d">30 days</option>
<option value="2160" data-i18n="history.90d">90 days</option>
<option value="8760" data-i18n="history.1y">1 year</option>
</select><small id="historyRangeHint" hidden></small></label>
@@ -414,19 +420,21 @@
</div>
</div>
<div class="form-grid">
<div id="haSupervisorStatus" class="ha-supervisor-status wide" role="status" aria-live="polite" hidden></div>
<div id="haSupervisorStatus" class="ha-supervisor-status wide" role="status" aria-live="polite" hidden>
</div>
<label class="wide" data-ha-manual-field><span>URL</span><input type="url" name="ha_url"
placeholder="http://homeassistant.local:8123"></label>
<label class="wide" data-ha-manual-field><span data-i18n="settings.haLongLivedToken">Long-Lived Access Token</span><input
type="password" name="ha_token" autocomplete="new-password" data-i18n-placeholder="settings.haTokenKeep"
placeholder="Leave empty to keep the saved token"></label>
<label class="wide" data-ha-manual-field><span data-i18n="settings.haLongLivedToken">Long-Lived Access
Token</span><input type="password" name="ha_token" autocomplete="new-password"
data-i18n-placeholder="settings.haTokenKeep" placeholder="Leave empty to keep the saved token"></label>
<label class="check wide" data-ha-manual-field><input type="checkbox" name="ha_allow_invalid_tls"> <span
data-i18n="settings.allowInvalidTls">Allow invalid/self-signed HTTPS certificate</span></label>
<p class="field-note wide warning-note" data-ha-manual-field data-i18n="settings.allowInvalidTlsHint">Use only for a trusted
<p class="field-note wide warning-note" data-ha-manual-field data-i18n="settings.allowInvalidTlsHint">Use
only for a trusted
local Home Assistant server.</p>
<div class="form-actions wide"><button type="button" class="secondary" id="haUseSupervisor"
data-i18n="settings.haUseSupervisor" hidden>Use Supervisor automatically</button><button type="button" class="secondary" id="haTest"
data-i18n="settings.testHa">Test HA</button></div>
data-i18n="settings.haUseSupervisor" hidden>Use Supervisor automatically</button><button type="button"
class="secondary" id="haTest" data-i18n="settings.testHa">Test HA</button></div>
</div>
</section>
@@ -439,9 +447,11 @@
</div>
</div>
<div class="form-grid">
<label class="wide"><span data-i18n="settings.outdoorEntity">Global outdoor temperature entity_id</span><input
name="ha_outdoor_entity_id" list="haEntitySuggestions" placeholder="sensor.outdoor_temperature"></label>
<p class="field-note wide" data-i18n="settings.outdoorEntityHint">Global Home Assistant outdoor temperature sensor. Zones use it by default and may override it with another outdoor sensor in zone settings.</p>
<label class="wide"><span data-i18n="settings.outdoorEntity">Global outdoor temperature
entity_id</span><input name="ha_outdoor_entity_id" list="haEntitySuggestions"
placeholder="sensor.outdoor_temperature"></label>
<p class="field-note wide" data-i18n="settings.outdoorEntityHint">Global Home Assistant outdoor temperature
sensor. Zones use it by default and may override it with another outdoor sensor in zone settings.</p>
<label class="wide"><span data-i18n="settings.haSensorStaleAfterMinutes">Maximum HA sensor reading age
(min)</span><input type="number" name="ha_sensor_stale_after_minutes" min="1" max="1440" step="1"
value="5"></label>
@@ -475,9 +485,10 @@
</div>
<div class="sensor-alias-manager">
<div id="sensorAliasList" class="sensor-alias-list"></div>
<div class="sensor-alias-add"><input id="sensorAliasEntity" list="haEntitySuggestions" placeholder="sensor.gabinet_temperature"><input
id="sensorAliasName" data-i18n-placeholder="settings.aliasPlaceholder" placeholder="Gabinet"><button
type="button" class="secondary" id="addSensorAlias" data-i18n="actions.add">Add</button></div>
<div class="sensor-alias-add"><input id="sensorAliasEntity" list="haEntitySuggestions"
placeholder="sensor.gabinet_temperature"><input id="sensorAliasName"
data-i18n-placeholder="settings.aliasPlaceholder" placeholder="Gabinet"><button type="button"
class="secondary" id="addSensorAlias" data-i18n="actions.add">Add</button></div>
</div>
</section>
@@ -587,7 +598,8 @@
<label class="check"><input type="checkbox" name="notification_alert_automation"> <span
data-i18n="notifications.alertAutomation">Automation errors and conflicts</span></label>
<label class="check"><input type="checkbox" name="notification_alert_sensor_discrepancy"> <span
data-i18n="notifications.alertSensorDiscrepancy">GREE vs Home Assistant temperature difference</span></label>
data-i18n="notifications.alertSensorDiscrepancy">GREE vs Home Assistant temperature
difference</span></label>
<label class="check"><input type="checkbox" name="notification_alert_control_errors"> <span
data-i18n="notifications.alertControlErrors">Thermostat and group control errors</span></label>
<label class="check"><input type="checkbox" name="notification_alert_important_events"> <span
@@ -652,6 +664,9 @@
autocomplete="new-password" data-i18n-placeholder="settings.secretKeep"
placeholder="Leave empty to keep saved secret"></label>
</div>
<div class="form-actions wide"><button type="button" class="secondary" id="testInfluxDbConnection"
data-i18n="settings.influxTest">Test InfluxDB connection</button></div>
<div id="influxDbTestResult" class="inline-alert wide" role="status" aria-live="polite" hidden></div>
</div>
</section>
<section class="panel form-section">
@@ -714,14 +729,19 @@
<div class="form-section-head">
<div>
<h3 data-i18n="settings.connectivityTitle">Connectivity metrics</h3>
<p data-i18n="settings.connectivityHint">Periodic GREE UDP round-trip measurements for Local/LAN units. Results are stored in History.</p>
<p data-i18n="settings.connectivityHint">Periodic GREE UDP round-trip measurements for Local/LAN units.
Results are stored in History.</p>
</div>
</div>
<div class="form-grid">
<label class="check wide"><input type="checkbox" name="ping_metrics_enabled"> <span data-i18n="settings.connectivityEnable">Enable local unit ping measurements</span></label>
<label><span data-i18n="settings.connectivityInterval">Measurement interval (s)</span><input type="number" name="ping_interval_seconds" min="10" max="3600" value="60"></label>
<label><span data-i18n="settings.connectivitySamples">Samples per measurement</span><input type="number" name="ping_sample_count" min="1" max="10" value="3"></label>
<p class="field-note wide" data-i18n="settings.connectivityLocalOnly">Only Local/LAN installations are probed. Cloud devices are not pinged directly.</p>
<label class="check wide"><input type="checkbox" name="ping_metrics_enabled"> <span
data-i18n="settings.connectivityEnable">Enable local unit ping measurements</span></label>
<label><span data-i18n="settings.connectivityInterval">Measurement interval (s)</span><input type="number"
name="ping_interval_seconds" min="10" max="3600" value="60"></label>
<label><span data-i18n="settings.connectivitySamples">Samples per measurement</span><input type="number"
name="ping_sample_count" min="1" max="10" value="3"></label>
<p class="field-note wide" data-i18n="settings.connectivityLocalOnly">Only Local/LAN installations are
probed. Cloud devices are not pinged directly.</p>
</div>
</section>
<section class="panel form-section">
@@ -757,33 +777,70 @@
</div>
<div class="settings-pane" data-settings-pane="cloud" hidden>
<section class="panel form-section">
<div class="form-section-head"><div><h3 data-i18n="settings.cloudAccountTitle">GREE Cloud account</h3><p data-i18n="settings.cloudAccountHint">REST login and device discovery use the selected regional GREE service. The password is never returned by the API after it is saved.</p></div></div>
<div class="form-section-head">
<div>
<h3 data-i18n="settings.cloudAccountTitle">GREE Cloud account</h3>
<p data-i18n="settings.cloudAccountHint">REST login and device discovery use the selected regional GREE
service. The password is never returned by the API after it is saved.</p>
</div>
</div>
<div class="form-grid">
<label class="check wide"><input type="checkbox" name="gree_cloud_enabled"> <span data-i18n="settings.cloudEnable">Enable GREE Cloud</span></label>
<label class="check wide"><input type="checkbox" name="gree_cloud_enabled"> <span
data-i18n="settings.cloudEnable">Enable GREE Cloud</span></label>
<label><span data-i18n="settings.cloudRegion">Region</span><select name="gree_cloud_region">
<option>Australia</option><option>China Mainland</option><option>East South Asia</option><option selected>Europe</option><option>India</option><option>Latin American</option><option>Middle East</option><option>North American</option><option>Russia</option><option>South American</option>
</select></label>
<label><span data-i18n="settings.cloudPolling">Cloud fallback polling interval (s)</span><input type="number" name="gree_cloud_polling_interval_seconds" min="30" max="3600" value="60"></label>
<label class="wide"><span data-i18n="settings.cloudLogin">Login / email</span><input name="gree_cloud_username" autocomplete="username"></label>
<label class="wide"><span data-i18n="settings.cloudPassword">Password</span><input type="password" name="gree_cloud_password" autocomplete="new-password" data-i18n-placeholder="settings.cloudPasswordPlaceholder" placeholder="Leave empty to keep saved secret"></label>
<option>Australia</option>
<option>China Mainland</option>
<option>East South Asia</option>
<option selected>Europe</option>
<option>India</option>
<option>Latin American</option>
<option>Middle East</option>
<option>North American</option>
<option>Russia</option>
<option>South American</option>
</select></label>
<label><span data-i18n="settings.cloudPolling">Cloud fallback polling interval (s)</span><input
type="number" name="gree_cloud_polling_interval_seconds" min="30" max="3600" value="60"></label>
<label class="wide"><span data-i18n="settings.cloudLogin">Login / email</span><input
name="gree_cloud_username" autocomplete="username"></label>
<label class="wide"><span data-i18n="settings.cloudPassword">Password</span><input type="password"
name="gree_cloud_password" autocomplete="new-password"
data-i18n-placeholder="settings.cloudPasswordPlaceholder"
placeholder="Leave empty to keep saved secret"></label>
<div class="wide cloud-runtime-summary" aria-live="polite" hidden>
<div><span data-i18n="settings.cloudAccountStatus">Account status</span><strong id="greeCloudAccountStatus"></strong></div>
<div><span data-i18n="settings.cloudMqttStatus">MQTT status</span><strong id="greeCloudMqttStatus"></strong></div>
<div><span data-i18n="settings.cloudDevicesOnline">Devices online</span><strong id="greeCloudDevicesOnline"></strong></div>
<div><span data-i18n="settings.cloudRestResponseTime">Last REST response time</span><strong id="greeCloudRestResponseTime"></strong></div>
<div><span data-i18n="settings.cloudResponseTime">Last device response time</span><strong id="greeCloudResponseTime"></strong></div>
<div><span data-i18n="settings.cloudLastDeviceResponse">Last device response</span><strong id="greeCloudLastDeviceResponse"></strong></div>
<div><span data-i18n="settings.cloudLastMqttMessage">Last MQTT activity</span><strong id="greeCloudLastMqttMessage"></strong></div>
<div><span data-i18n="settings.cloudConnectedSince">MQTT connected since</span><strong id="greeCloudConnectedSince"></strong></div>
<div><span data-i18n="settings.cloudAccountStatus">Account status</span><strong
id="greeCloudAccountStatus"></strong></div>
<div><span data-i18n="settings.cloudMqttStatus">MQTT status</span><strong
id="greeCloudMqttStatus"></strong></div>
<div><span data-i18n="settings.cloudDevicesOnline">Devices online</span><strong
id="greeCloudDevicesOnline"></strong></div>
<div><span data-i18n="settings.cloudRestResponseTime">REST response time</span><strong
id="greeCloudRestResponseTime"></strong></div>
<div><span data-i18n="settings.cloudResponseTime">Device response time</span><strong
id="greeCloudResponseTime"></strong></div>
<div><span data-i18n="settings.cloudLastDeviceResponse">Last device response</span><strong
id="greeCloudLastDeviceResponse"></strong></div>
<div><span data-i18n="settings.cloudLastMqttMessage">Last MQTT activity</span><strong
id="greeCloudLastMqttMessage"></strong></div>
<div><span data-i18n="settings.cloudConnectedSince">MQTT connected since</span><strong
id="greeCloudConnectedSince"></strong></div>
<div><span data-i18n="settings.cloudBroker">Broker</span><strong id="greeCloudBroker"></strong></div>
<div><span data-i18n="settings.cloudTraffic">Requests / responses / timeouts</span><strong id="greeCloudTraffic"></strong></div>
<div><span data-i18n="settings.cloudTraffic">Requests / responses / timeouts</span><strong
id="greeCloudTraffic"></strong></div>
</div>
<div class="wide cloud-account-meta">
<small hidden><span data-i18n="settings.cloudLastContact">Last successful REST contact</span>: <strong id="greeCloudLastContact"></strong></small>
<small><span data-i18n="settings.cloudInstallationId">Installation ID</span>: <code id="greeCloudInstanceId"></code></small>
<small hidden><span data-i18n="settings.cloudLastContact">Last successful REST contact</span>: <strong
id="greeCloudLastContact"></strong></small>
<small><span data-i18n="settings.cloudInstallationId">Installation ID</span>: <code
id="greeCloudInstanceId"></code></small>
</div>
<p class="field-note wide" data-i18n="settings.cloudPushHint">MQTT push is primary. Polling is used only for initial state, recovery and periodic verification.</p>
<div class="form-actions wide"><button type="button" class="secondary" id="greeCloudTestButton" data-i18n="settings.cloudTest">Test connection</button><button type="button" class="secondary" id="greeCloudRefreshButton" data-i18n="settings.cloudRefreshDevices">Refresh devices</button><button type="button" class="secondary" id="greeCloudReconnectButton" data-i18n="settings.cloudReconnect">Reconnect</button></div>
<p class="field-note wide" data-i18n="settings.cloudPushHint">MQTT push is primary. Polling is used only
for initial state, recovery and periodic verification.</p>
<div class="form-actions wide"><button type="button" class="secondary" id="greeCloudTestButton"
data-i18n="settings.cloudTest">Test connection</button><button type="button" class="secondary"
id="greeCloudRefreshButton" data-i18n="settings.cloudRefreshDevices">Refresh devices</button><button
type="button" class="secondary" id="greeCloudReconnectButton"
data-i18n="settings.cloudReconnect">Reconnect</button></div>
<div id="greeCloudTestResult" class="inline-alert wide" hidden></div>
</div>
</section>
@@ -791,14 +848,19 @@
<div class="form-section-head">
<div>
<h3 data-i18n="settings.cloudConnectivityTitle">Cloud connectivity metrics</h3>
<p data-i18n="settings.cloudConnectivityHint">Optional. Disabled by default. Measures REST login response and MQTT PINGRESP round-trip.</p>
<p data-i18n="settings.cloudConnectivityHint">Optional. Disabled by default. Measures REST login
response and MQTT PINGRESP round-trip.</p>
</div>
</div>
<div class="form-grid">
<label class="check wide"><input type="checkbox" name="gree_cloud_connectivity_metrics_enabled"> <span data-i18n="settings.cloudConnectivityEnable">Measure Cloud REST/MQTT connectivity</span></label>
<label><span data-i18n="settings.connectivityInterval">Measurement interval (s)</span><input type="number" name="gree_cloud_connectivity_metrics_interval_seconds" min="30" max="3600" value="300"></label>
<label><span data-i18n="settings.connectivitySamples">Samples per measurement</span><input type="number" name="gree_cloud_connectivity_metrics_sample_count" min="1" max="10" value="3"></label>
<p class="field-note wide" data-i18n="settings.cloudConnectivityHistoryHint">REST and MQTT measurements are stored in History → Pings together with Local/LAN measurements.</p>
<label class="check wide"><input type="checkbox" name="gree_cloud_connectivity_metrics_enabled"> <span
data-i18n="settings.cloudConnectivityEnable">Measure Cloud REST/MQTT connectivity</span></label>
<label><span data-i18n="settings.connectivityInterval">Measurement interval (s)</span><input type="number"
name="gree_cloud_connectivity_metrics_interval_seconds" min="30" max="3600" value="300"></label>
<label><span data-i18n="settings.connectivitySamples">Samples per measurement</span><input type="number"
name="gree_cloud_connectivity_metrics_sample_count" min="1" max="10" value="3"></label>
<p class="field-note wide" data-i18n="settings.cloudConnectivityHistoryHint">REST and MQTT measurements
are stored in History → Pings together with Local/LAN measurements.</p>
</div>
</section>
</div>
@@ -853,9 +915,9 @@
type="button" data-action="edit-flow-name" data-i18n-title="flow.editName"
title="Edit Flow name"></button><button class="flow-name-edit" type="button"
data-action="edit-flow-name" data-i18n-title="flow.editName" data-i18n-aria="flow.editName"
title="Edit Flow name" aria-label="Edit Flow name" data-ui-icon="edit"></button></div><input id="flowName" maxlength="100"
data-i18n-placeholder="flow.namePlaceholder" data-i18n-title="flow.nameAliasHint"
placeholder="My automation">
title="Edit Flow name" aria-label="Edit Flow name" data-ui-icon="edit"></button></div><input
id="flowName" maxlength="100" data-i18n-placeholder="flow.namePlaceholder"
data-i18n-title="flow.nameAliasHint" placeholder="My automation">
</div>
</div>
</div>
@@ -931,7 +993,8 @@
<div class="flow-workspace-wrap">
<div class="flow-workspace-toolbar">
<div class="flow-workspace-primary-actions"><button type="button" class="primary flow-add-block-button"
data-action="flow-add-block"><span data-ui-icon="plus" aria-hidden="true"></span><span data-i18n="flow.addBlock">Add
data-action="flow-add-block"><span data-ui-icon="plus" aria-hidden="true"></span><span
data-i18n="flow.addBlock">Add
block</span></button><button type="button" class="secondary flow-selection-action"
data-action="flow-select-all" data-i18n="flow.selectAll">Zaznacz wszystko</button><button type="button"
class="secondary flow-selection-action" data-action="flow-clear-selection"
@@ -941,10 +1004,11 @@
<button type="button" class="secondary icon-button" data-action="flow-zoom-out"
data-i18n-title="flow.zoomOut" title="Zoom out" data-ui-icon="minus"></button><span id="flowZoomLabel"
aria-live="polite">100%</span><button type="button" class="secondary icon-button"
data-action="flow-zoom-in" data-i18n-title="flow.zoomIn" title="Zoom in" data-ui-icon="plus"></button><button type="button"
class="secondary flow-fit-button" data-action="flow-fit" data-i18n-title="flow.fitView" title="Fit"><span
class="flow-fit-button-icon" data-ui-icon="fit" aria-hidden="true"></span><span
class="flow-fit-button-label" data-i18n="flow.fitView">Fit</span></button>
data-action="flow-zoom-in" data-i18n-title="flow.zoomIn" title="Zoom in"
data-ui-icon="plus"></button><button type="button" class="secondary flow-fit-button"
data-action="flow-fit" data-i18n-title="flow.fitView" title="Fit"><span class="flow-fit-button-icon"
data-ui-icon="fit" aria-hidden="true"></span><span class="flow-fit-button-label"
data-i18n="flow.fitView">Fit</span></button>
</div>
</div>
<div id="flowWorkspace" class="flow-workspace" tabindex="0">
@@ -991,13 +1055,16 @@
<h2 data-i18n="flow.actions">Actions</h2><button type="button" data-close data-ui-icon="close"></button>
</div>
<div class="menu-list">
<button type="button" data-action="flow-templates"><span
data-i18n="flow.templates">Templates</span><span data-ui-icon="chevron-right" aria-hidden="true"></span></button>
<button type="button" data-action="import-flow"><span data-i18n="flow.import">Import</span><span data-ui-icon="chevron-right" aria-hidden="true"></span></button>
<button type="button" data-action="export-flow"><span data-i18n="flow.export">Export</span><span data-ui-icon="chevron-right" aria-hidden="true"></span></button>
<button type="button" data-action="flow-dry-run"><span
data-i18n="flow.dryRun">Dry-run</span><span data-ui-icon="chevron-right" aria-hidden="true"></span></button>
<button type="button" data-action="flow-logs"><span data-i18n="flow.logs">Logs</span><span data-ui-icon="chevron-right" aria-hidden="true"></span></button>
<button type="button" data-action="flow-templates"><span data-i18n="flow.templates">Templates</span><span
data-ui-icon="chevron-right" aria-hidden="true"></span></button>
<button type="button" data-action="import-flow"><span data-i18n="flow.import">Import</span><span
data-ui-icon="chevron-right" aria-hidden="true"></span></button>
<button type="button" data-action="export-flow"><span data-i18n="flow.export">Export</span><span
data-ui-icon="chevron-right" aria-hidden="true"></span></button>
<button type="button" data-action="flow-dry-run"><span data-i18n="flow.dryRun">Dry-run</span><span
data-ui-icon="chevron-right" aria-hidden="true"></span></button>
<button type="button" data-action="flow-logs"><span data-i18n="flow.logs">Logs</span><span
data-ui-icon="chevron-right" aria-hidden="true"></span></button>
</div>
</dialog>
@@ -1084,26 +1151,36 @@
<nav class="bottom-nav" data-i18n-aria="nav.navigation" aria-label="Navigation">
<span class="nav-section-label desktop-nav-only" data-i18n="nav.sectionControl">Sterowanie</span>
<button class="active" data-nav="dashboard"><span data-ui-icon="home" aria-hidden="true"></span><b data-i18n="nav.dashboard">Dashboard</b></button>
<button data-nav="zones"><span data-ui-icon="zones" aria-hidden="true"></span><b data-i18n="nav.zones">Zones</b></button>
<button class="desktop-nav-only" data-nav="groups"><span data-ui-icon="groups" aria-hidden="true"></span><b data-i18n="nav.groups">Groups</b></button>
<button class="active" data-nav="dashboard"><span data-ui-icon="home" aria-hidden="true"></span><b
data-i18n="nav.dashboard">Dashboard</b></button>
<button data-nav="zones"><span data-ui-icon="zones" aria-hidden="true"></span><b
data-i18n="nav.zones">Zones</b></button>
<button class="desktop-nav-only" data-nav="groups"><span data-ui-icon="groups" aria-hidden="true"></span><b
data-i18n="nav.groups">Groups</b></button>
<span class="nav-section-label desktop-nav-only" data-i18n="nav.sectionAutomation">Automatyzacja</span>
<button data-nav="flows"><span data-ui-icon="flow" aria-hidden="true"></span><b data-i18n="nav.flows">Flow</b></button>
<button data-nav="flows"><span data-ui-icon="flow" aria-hidden="true"></span><b
data-i18n="nav.flows">Flow</b></button>
<button class="desktop-nav-only" data-nav="schedules"><span data-ui-icon="schedule" aria-hidden="true"></span><b
data-i18n="nav.schedules">Schedules</b></button>
<button class="desktop-nav-only" data-nav="automations"><span data-ui-icon="automation" aria-hidden="true"></span><b
data-i18n="nav.automations">Automations</b></button>
<span class="nav-section-label desktop-nav-only" data-i18n="nav.sectionData">Dane i system</span>
<button data-nav="history"><span data-ui-icon="history" aria-hidden="true"></span><b data-i18n="nav.history">History</b></button>
<button class="desktop-nav-only" data-nav="devices"><span data-ui-icon="devices" aria-hidden="true"></span><b data-i18n="nav.devices">Devices</b></button>
<button data-nav="history"><span data-ui-icon="history" aria-hidden="true"></span><b
data-i18n="nav.history">History</b></button>
<button class="desktop-nav-only" data-nav="devices"><span data-ui-icon="devices" aria-hidden="true"></span><b
data-i18n="nav.devices">Devices</b></button>
<button class="desktop-nav-only" data-nav="homeassistant"><span>HA</span><b data-i18n="nav.homeAssistantShort">Home
Assistant</b></button>
<button class="desktop-nav-only" data-nav="simulation"><span data-ui-icon="play" aria-hidden="true"></span><b
data-i18n="nav.simulation">Simulator</b></button>
<button class="desktop-nav-only" data-nav="night"><span data-ui-icon="moon" aria-hidden="true"></span><b data-i18n="nav.nightMode">Night mode</b></button>
<button class="desktop-nav-only" data-nav="logs"><span data-ui-icon="events" aria-hidden="true"></span><b data-i18n="nav.logs">Events</b></button>
<button class="desktop-nav-only" data-nav="settings"><span data-ui-icon="settings" aria-hidden="true"></span><b data-i18n="nav.settings">Settings</b></button>
<button class="mobile-nav-only" data-nav="more"><span data-ui-icon="more-horizontal" aria-hidden="true"></span><b data-i18n="nav.more">More</b></button>
<button class="desktop-nav-only" data-nav="night"><span data-ui-icon="moon" aria-hidden="true"></span><b
data-i18n="nav.nightMode">Night mode</b></button>
<button class="desktop-nav-only" data-nav="logs"><span data-ui-icon="events" aria-hidden="true"></span><b
data-i18n="nav.logs">Events</b></button>
<button class="desktop-nav-only" data-nav="settings"><span data-ui-icon="settings" aria-hidden="true"></span><b
data-i18n="nav.settings">Settings</b></button>
<button class="mobile-nav-only" data-nav="more"><span data-ui-icon="more-horizontal" aria-hidden="true"></span><b
data-i18n="nav.more">More</b></button>
</nav>
<dialog id="moreDialog" class="sheet">
@@ -1112,18 +1189,26 @@
</div>
<div class="menu-list more-menu-list">
<span class="menu-section-label" data-i18n="nav.sectionControl">Sterowanie</span>
<button data-go="devices"><span data-i18n="nav.devices">Devices</span><span data-ui-icon="chevron-right" aria-hidden="true"></span></button>
<button data-go="groups"><span data-i18n="nav.groups">Groups</span><span data-ui-icon="chevron-right" aria-hidden="true"></span></button>
<button data-go="devices"><span data-i18n="nav.devices">Devices</span><span data-ui-icon="chevron-right"
aria-hidden="true"></span></button>
<button data-go="groups"><span data-i18n="nav.groups">Groups</span><span data-ui-icon="chevron-right"
aria-hidden="true"></span></button>
<span class="menu-section-label" data-i18n="nav.sectionAutomation">Automatyzacja</span>
<button data-go="schedules"><span data-i18n="nav.schedules">Schedules</span><span data-ui-icon="chevron-right" aria-hidden="true"></span></button>
<button data-go="automations"><span data-i18n="nav.automations">Automations</span><span data-ui-icon="chevron-right" aria-hidden="true"></span></button>
<button data-go="schedules"><span data-i18n="nav.schedules">Schedules</span><span data-ui-icon="chevron-right"
aria-hidden="true"></span></button>
<button data-go="automations"><span data-i18n="nav.automations">Automations</span><span
data-ui-icon="chevron-right" aria-hidden="true"></span></button>
<span class="menu-section-label" data-i18n="nav.sectionData">Dane i system</span>
<button data-go="simulation"><span data-i18n="nav.simulation">Simulator</span><span data-ui-icon="chevron-right" aria-hidden="true"></span></button>
<button data-go="night"><span data-i18n="nav.nightMode">Night mode</span><span data-ui-icon="chevron-right" aria-hidden="true"></span></button>
<button data-go="simulation"><span data-i18n="nav.simulation">Simulator</span><span data-ui-icon="chevron-right"
aria-hidden="true"></span></button>
<button data-go="night"><span data-i18n="nav.nightMode">Night mode</span><span data-ui-icon="chevron-right"
aria-hidden="true"></span></button>
<button data-go="homeassistant"><span data-i18n="nav.homeAssistant">Home Assistant /
Sensors</span><span data-ui-icon="chevron-right" aria-hidden="true"></span></button>
<button data-go="settings"><span data-i18n="nav.settings">Settings</span><span data-ui-icon="chevron-right" aria-hidden="true"></span></button>
<button data-go="logs"><span data-i18n="nav.logsAndEvents">Events and logs</span><span data-ui-icon="chevron-right" aria-hidden="true"></span></button>
<button data-go="settings"><span data-i18n="nav.settings">Settings</span><span data-ui-icon="chevron-right"
aria-hidden="true"></span></button>
<button data-go="logs"><span data-i18n="nav.logsAndEvents">Events and logs</span><span
data-ui-icon="chevron-right" aria-hidden="true"></span></button>
</div>
</dialog>
@@ -1157,17 +1242,25 @@
<dialog id="cloudDiscoveryDialog" class="dialog-wide">
<div class="dialog-form">
<div class="dialog-head"><div><span class="eyebrow">GREE Cloud</span><h2 data-i18n="devices.cloudDiscoveryTitle">Devices on account</h2></div><button type="button" data-close data-ui-icon="close"></button></div>
<p class="field-note" data-i18n="devices.cloudDiscoveryHint">Devices are loaded from the saved GREE Cloud account. Nothing is added automatically; choose the units you want to register.</p>
<div class="dialog-head">
<div><span class="eyebrow">GREE Cloud</span>
<h2 data-i18n="devices.cloudDiscoveryTitle">Devices on account</h2>
</div><button type="button" data-close data-ui-icon="close"></button>
</div>
<p class="field-note" data-i18n="devices.cloudDiscoveryHint">Devices are loaded from the saved GREE Cloud account.
Nothing is added automatically; choose the units you want to register.</p>
<div id="cloudDiscoveryList" class="discovery-name-list"></div>
<div class="form-actions"><button type="button" class="secondary" id="cloudDiscoveryRefresh" data-i18n="actions.refresh">Refresh</button><button type="button" class="primary" data-close data-i18n="actions.done">Done</button></div>
<div class="form-actions"><button type="button" class="secondary" id="cloudDiscoveryRefresh"
data-i18n="actions.refresh">Refresh</button><button type="button" class="primary" data-close
data-i18n="actions.done">Done</button></div>
</div>
</dialog>
<dialog id="discoverDialog">
<form method="dialog" id="discoverForm" class="dialog-form">
<div class="dialog-head">
<h2 data-i18n="discovery.title">Discover GREE devices</h2><button type="button" data-close data-ui-icon="close"></button>
<h2 data-i18n="discovery.title">Discover GREE devices</h2><button type="button" data-close
data-ui-icon="close"></button>
</div>
<p class="field-note" data-i18n="discovery.help">Auto searches both AES-ECB and AES-GCM devices. Multiple passes
improve discovery when several Wi-Fi modules answer the same broadcast.</p>
@@ -1188,7 +1281,8 @@
<dialog id="discoveryNamesDialog">
<form method="dialog" id="discoveryNamesForm" class="dialog-form">
<div class="dialog-head">
<h2 data-i18n="discovery.nameDevices">Name discovered devices</h2><button type="button" data-close data-ui-icon="close"></button>
<h2 data-i18n="discovery.nameDevices">Name discovered devices</h2><button type="button" data-close
data-ui-icon="close"></button>
</div>
<p class="field-note" data-i18n="discovery.nameDevicesHelp">Give each new unit a friendly room name. The technical
model and MAC remain available in diagnostics.</p>
@@ -1203,7 +1297,8 @@
<form method="dialog" id="renameDeviceForm" class="dialog-form">
<input type="hidden" name="id">
<div class="dialog-head">
<h2 data-i18n="devices.technicalConfig">Technical configuration</h2><button type="button" data-close data-ui-icon="close"></button>
<h2 data-i18n="devices.technicalConfig">Technical configuration</h2><button type="button" data-close
data-ui-icon="close"></button>
</div>
<label><span data-i18n="common.name">Name</span><input name="name" required maxlength="80"></label>
<div class="two"><label><span data-i18n="devices.ipAddress">IP address</span><input name="ip"
@@ -1228,56 +1323,101 @@
<form method="dialog" id="deviceDetailsForm" class="dialog-form">
<input type="hidden" name="id">
<div class="dialog-head">
<div><span class="eyebrow" data-i18n="devices.cloudDetails">Cloud details</span><h2 id="deviceDetailsTitle">Device details</h2></div>
<div><span class="eyebrow" data-i18n="devices.cloudDetails">Cloud details</span>
<h2 id="deviceDetailsTitle">Device details</h2>
</div>
<button type="button" data-close data-ui-icon="close"></button>
</div>
<label><span data-i18n="common.name">Name</span><input name="name" required maxlength="80"></label>
<div class="technical-device-grid" id="deviceDetailsMeta"></div>
<section class="form-section">
<div class="form-section-head"><div><h3 data-i18n="energy.title">Energy</h3><p data-i18n="energy.sourceHint">Choose the cumulative energy source used for charts and period totals.</p></div></div>
<div class="form-section-head">
<div>
<h3 data-i18n="energy.title">Energy</h3>
<p data-i18n="energy.sourceHint">Choose the cumulative energy source used for charts and period totals.</p>
</div>
</div>
<div class="form-grid">
<label><span data-i18n="energy.source">Energy source</span><select name="energy_source">
<option value="auto" data-i18n="energy.auto">Auto</option>
<option value="gree_cloud">GREE Cloud</option>
<option value="home_assistant">Home Assistant</option>
</select></label>
<label class="wide"><span data-i18n="energy.haSensor">Home Assistant energy sensor</span><select name="ha_energy_entity_id"><option value=""></option></select></label>
<option value="auto" data-i18n="energy.auto">Auto</option>
<option value="gree_cloud">GREE Cloud</option>
<option value="home_assistant">Home Assistant</option>
</select></label>
<label class="wide"><span data-i18n="energy.haSensor">Home Assistant energy sensor</span><select
name="ha_energy_entity_id">
<option value=""></option>
</select></label>
<p class="field-note wide" id="deviceEnergySensorMeta"></p>
<div class="inline-alert info wide" id="deviceEnergyGroupNote" hidden></div>
</div>
</section>
<div class="form-actions"><button type="button" class="secondary" data-close data-i18n="actions.cancel">Cancel</button><button class="primary" type="submit" data-i18n="actions.save">Save</button></div>
<div class="form-actions"><button type="button" class="secondary" data-close
data-i18n="actions.cancel">Cancel</button><button class="primary" type="submit"
data-i18n="actions.save">Save</button></div>
</form>
</dialog>
<dialog id="deviceGroupsDialog" class="dialog-wide">
<div class="dialog-form">
<div class="dialog-head"><div><span class="eyebrow" data-i18n="devices.installation">Installation</span><h2 data-i18n="devices.installations">Split / multisplit installations</h2></div><button type="button" data-close data-ui-icon="close"></button></div>
<p class="field-note" data-i18n="devices.installationsHint">Group indoor units that share one outdoor unit. Energy and outdoor temperature can use one shared source.</p>
<div class="dialog-head">
<div><span class="eyebrow" data-i18n="devices.installation">Installation</span>
<h2 data-i18n="devices.installations">Split / multisplit installations</h2>
</div><button type="button" data-close data-ui-icon="close"></button>
</div>
<p class="field-note" data-i18n="devices.installationsHint">Group indoor units that share one outdoor unit. Energy
and outdoor temperature can use one shared source.</p>
<div id="deviceGroupsList" class="installation-list"></div>
<form id="deviceGroupForm" class="installation-form">
<input type="hidden" name="id">
<div class="form-grid">
<label><span data-i18n="devices.installationName">Installation name</span><input name="name" maxlength="80" required></label>
<label><span data-i18n="devices.installationType">Installation type</span><select name="kind"><option value="split" data-i18n="devices.split">Split</option><option value="multisplit" data-i18n="devices.multisplit">Multisplit</option></select></label>
<div class="wide"><span class="field-label" data-i18n="devices.installationDevices">Indoor units</span><div id="deviceGroupDeviceChoices" class="installation-device-choices"></div></div>
<label><span data-i18n="energy.source">Energy source</span><select name="energy_source"><option value="auto" data-i18n="energy.auto">Auto</option><option value="gree_cloud">GREE Cloud</option><option value="home_assistant">Home Assistant</option></select></label>
<label><span data-i18n="devices.energySourceDevice">GREE Cloud energy source</span><select name="energy_device_id"><option value=""></option></select></label>
<label class="wide"><span data-i18n="energy.haSensor">Home Assistant energy sensor</span><select name="ha_energy_entity_id"><option value=""></option></select></label>
<label class="wide"><span data-i18n="devices.outdoorSourceDevice">Outdoor temperature source</span><select name="outdoor_temperature_device_id"><option value="" data-i18n="devices.noSharedOutdoor">No shared source</option></select></label>
<label><span data-i18n="devices.installationName">Installation name</span><input name="name" maxlength="80"
required></label>
<label><span data-i18n="devices.installationType">Installation type</span><select name="kind">
<option value="split" data-i18n="devices.split">Split</option>
<option value="multisplit" data-i18n="devices.multisplit">Multisplit</option>
</select></label>
<div class="wide"><span class="field-label" data-i18n="devices.installationDevices">Indoor units</span>
<div id="deviceGroupDeviceChoices" class="installation-device-choices"></div>
</div>
<label><span data-i18n="energy.source">Energy source</span><select name="energy_source">
<option value="auto" data-i18n="energy.auto">Auto</option>
<option value="gree_cloud">GREE Cloud</option>
<option value="home_assistant">Home Assistant</option>
</select></label>
<label><span data-i18n="devices.energySourceDevice">GREE Cloud energy source</span><select
name="energy_device_id">
<option value=""></option>
</select></label>
<label class="wide"><span data-i18n="energy.haSensor">Home Assistant energy sensor</span><select
name="ha_energy_entity_id">
<option value=""></option>
</select></label>
<label class="wide"><span data-i18n="devices.outdoorSourceDevice">Outdoor temperature source</span><select
name="outdoor_temperature_device_id">
<option value="" data-i18n="devices.noSharedOutdoor">No shared source</option>
</select></label>
<p class="field-note wide" id="deviceGroupEnergySensorMeta"></p>
</div>
<div class="form-actions"><button type="button" class="secondary" id="deviceGroupNew" data-i18n="devices.newInstallation">New installation</button><button type="submit" class="primary" data-i18n="actions.save">Save</button></div>
<div class="form-actions"><button type="button" class="secondary" id="deviceGroupNew"
data-i18n="devices.newInstallation">New installation</button><button type="submit" class="primary"
data-i18n="actions.save">Save</button></div>
</form>
</div>
</dialog>
<dialog id="cloudDiagnosticsDialog" class="dialog-wide">
<div class="dialog-form">
<div class="dialog-head"><div><span class="eyebrow">GREE Cloud</span><h2 data-i18n="devices.cloudDiagnostics">Cloud diagnostics</h2></div><button type="button" data-close data-ui-icon="close"></button></div>
<p class="field-note" data-i18n="devices.cloudDiagnosticsHint">Decrypted and sanitized protocol data. Passwords, tokens and cipher keys are never included.</p>
<div class="dialog-head">
<div><span class="eyebrow">GREE Cloud</span>
<h2 data-i18n="devices.cloudDiagnostics">Cloud diagnostics</h2>
</div><button type="button" data-close data-ui-icon="close"></button>
</div>
<p class="field-note" data-i18n="devices.cloudDiagnosticsHint">Decrypted and sanitized protocol data. Passwords,
tokens and cipher keys are never included.</p>
<pre id="cloudDiagnosticsPayload" class="mono cloud-diagnostics-payload"></pre>
<div class="form-actions"><button type="button" class="secondary" id="cloudDiagnosticsRefresh" data-i18n="actions.refresh">Refresh</button><button type="button" class="primary" data-close data-i18n="actions.done">Done</button></div>
<div class="form-actions"><button type="button" class="secondary" id="cloudDiagnosticsRefresh"
data-i18n="actions.refresh">Refresh</button><button type="button" class="primary" data-close
data-i18n="actions.done">Done</button></div>
</div>
</dialog>
@@ -1359,17 +1499,20 @@
°C</span><input type="text" inputmode="decimal" step="0.1" min="0.1" max="5" name="heat_hysteresis"
value="0.6"></label></div>
<div id="externalSensorFields">
<label><span data-i18n="zones.roomSensorEntity">Room sensor entity_id</span><input name="ha_entity_id" list="haEntitySuggestions"
placeholder="sensor.living_room_temperature"></label>
<label><span data-i18n="zones.roomSensorEntity">Room sensor entity_id</span><input name="ha_entity_id"
list="haEntitySuggestions" placeholder="sensor.living_room_temperature"></label>
<div class="two"><label><span data-i18n="zones.roomSensorWeight">Room sensor weight %</span><input type="number"
step="5" min="0" max="100" name="external_sensor_weight_percent" value="40"></label><label><span
data-i18n="zones.maxDifference">Max. sensor difference °C</span><input type="text" inputmode="decimal"
step="0.1" min="0.1" max="20" name="max_sensor_difference" value="3.0"></label></div>
<p class="field-note" data-i18n="zones.sensorHelp">This room sensor is required when the temperature strategy uses Home Assistant. If it becomes unavailable, the controller falls back to the GREE sensor.</p>
<p class="field-note" data-i18n="zones.sensorHelp">This room sensor is required when the temperature strategy
uses Home Assistant. If it becomes unavailable, the controller falls back to the GREE sensor.</p>
</div>
<label><span data-i18n="zones.outdoorSensorEntity">Outdoor temperature entity_id override</span><input name="ha_outdoor_entity_id" list="haEntitySuggestions"
placeholder="sensor.outdoor_temperature"></label>
<p class="field-note" data-i18n="zones.outdoorSensorHelp">Leave empty to use the global Outdoor temperature entity_id. Set another entity_id to use a different outdoor sensor only for this zone. It is used by outdoor-temperature assist and recorded in history/metrics.</p>
<label><span data-i18n="zones.outdoorSensorEntity">Outdoor temperature entity_id override</span><input
name="ha_outdoor_entity_id" list="haEntitySuggestions" placeholder="sensor.outdoor_temperature"></label>
<p class="field-note" data-i18n="zones.outdoorSensorHelp">Leave empty to use the global Outdoor temperature
entity_id. Set another entity_id to use a different outdoor sensor only for this zone. It is used by
outdoor-temperature assist and recorded in history/metrics.</p>
<div class="two"><label><span data-i18n="zones.minAdjust">Minimum adjustment interval (s)</span><input
type="number" min="15" name="min_adjust_seconds" value="120"></label><label><span
data-i18n="zones.standbyOffset">Standby setpoint offset °C</span><input type="text" inputmode="decimal"
@@ -1641,7 +1784,8 @@
<dialog id="generatedTokenDialog" class="auth-dialog">
<div class="dialog-form">
<div class="dialog-head">
<h2 data-i18n="settings.tokenCreated">Token created</h2><button type="button" data-close data-ui-icon="close"></button>
<h2 data-i18n="settings.tokenCreated">Token created</h2><button type="button" data-close
data-ui-icon="close"></button>
</div>
<p class="field-note" data-i18n="settings.tokenCreatedHint">Copy this token now. It will not be shown again.</p>
<label><span data-i18n="auth.token">Access token</span><input id="generatedAccessToken" class="mono"
@@ -1655,18 +1799,27 @@
<dialog id="outdoorHistoryDialog" class="dialog-wide outdoor-history-dialog">
<div class="dialog-form">
<div class="dialog-head">
<div><span class="eyebrow" data-i18n="history.24h">24 hours</span><h2 data-i18n="history.allOutdoor">Outdoor temperatures</h2></div>
<button type="button" data-close data-i18n-aria="actions.close" aria-label="Close" data-ui-icon="close"></button>
<div><span class="eyebrow" data-i18n="history.24h">24 hours</span>
<h2 data-i18n="history.allOutdoor">Outdoor temperatures</h2>
</div>
<button type="button" data-close data-i18n-aria="actions.close" aria-label="Close"
data-ui-icon="close"></button>
</div>
<div class="outdoor-history-current"><small data-i18n="house.outdoor">Outdoor</small><strong id="outdoorHistoryCurrent">--</strong></div>
<div class="outdoor-history-current"><small data-i18n="house.outdoor">Outdoor</small><strong
id="outdoorHistoryCurrent">--</strong></div>
<div id="outdoorHistoryChartHost"></div>
<div class="outdoor-history-links" data-i18n-aria="house.outdoorHistoryMore" aria-label="More metrics and ranges in history">
<div class="outdoor-history-links" data-i18n-aria="house.outdoorHistoryMore"
aria-label="More metrics and ranges in history">
<span data-i18n="house.outdoorHistoryMore">More in history</span>
<div>
<a href="/history/overview?hours=168" data-history-route data-history-hours="168" data-i18n="history.range7d">7 days</a>
<a href="/history/overview?hours=720" data-history-route data-history-hours="720" data-i18n="history.range30d">30 days</a>
<a href="/history/overview?hours=8760" data-history-route data-history-hours="8760" data-i18n="history.range1y">1 year</a>
<a href="/history/overview" data-history-route class="outdoor-history-all" data-i18n="house.outdoorHistoryFull">Full history</a>
<a href="/history/overview?hours=168" data-history-route data-history-hours="168"
data-i18n="history.range7d">7 days</a>
<a href="/history/overview?hours=720" data-history-route data-history-hours="720"
data-i18n="history.range30d">30 days</a>
<a href="/history/overview?hours=8760" data-history-route data-history-hours="8760"
data-i18n="history.range1y">1 year</a>
<a href="/history/overview" data-history-route class="outdoor-history-all"
data-i18n="house.outdoorHistoryFull">Full history</a>
</div>
</div>
</div>
@@ -1695,8 +1848,8 @@
data-i18n="debug.requests">Requests</button>
<button type="button" role="tab" aria-selected="false" data-debug-filter="gree"
data-i18n="debug.gree">GREE</button>
<button type="button" role="tab" aria-selected="false" data-debug-filter="cloud"
data-i18n="debug.cloud">Cloud API</button>
<button type="button" role="tab" aria-selected="false" data-debug-filter="cloud" data-i18n="debug.cloud">Cloud
API</button>
<button type="button" role="tab" aria-selected="false" data-debug-filter="mqtt"
data-i18n="debug.mqtt">MQTT</button>
</div>
@@ -46,7 +46,7 @@ function normalizeEnergyHistoryTargets() {
const ids = new Set(available.map(item => item.id));
app.historyEnergyTargets = (app.historyEnergyTargets || []).filter(id => ids.has(id));
if (!app.historyEnergyTargets.length && app.historyEnergyDevice && ids.has(app.historyEnergyDevice)) app.historyEnergyTargets = [app.historyEnergyDevice];
if (!app.historyEnergyTargets.length && available.length) app.historyEnergyTargets = [available[0].id];
if (!app.historyEnergyTargets.length && available.length) app.historyEnergyTargets = available.slice(0, 8).map(item => item.id);
app.historyEnergyTargets = app.historyEnergyTargets.slice(0, 8);
app.historyEnergyDevice = app.historyEnergyTargets[0] || '';
return available;
+21 -17
View File
@@ -20,7 +20,7 @@ const setCookie = (name, value) => {
document.cookie = `${name}=${encodeURIComponent(value)}; Max-Age=31536000; Path=${APP_BASE || '/'}; SameSite=Lax`;
};
const DEFAULT_LANGUAGE = 'en';
const DEFAULT_LANGUAGE = String(document.documentElement.lang || '').trim();
const preferredLanguage = getCookie('gree_controller_language') || DEFAULT_LANGUAGE;
const preferredTheme = ['system', 'light', 'dark'].includes(getCookie('gree_controller_theme')) ? getCookie('gree_controller_theme') : 'system';
@@ -28,9 +28,9 @@ const app = {
devices: [], zones: [], groups: [], deviceGroups: [], deviceGroupEnergy: {}, schedules: [], automations: [], flows: [], accessTokens: [], settings: null, system: {}, outdoorTemperature: null,
token: localStorage.getItem('gree_controller_token') || '', ws: null, wsTimer: null,
currentView: 'dashboard', loading: false, bootstrapReloadPending: false, language: preferredLanguage, theme: preferredTheme, connectionStatus: 'connecting',
languages: [], translations: {}, locales: {},
languages: [], translations: {}, locales: {}, defaultLanguage: DEFAULT_LANGUAGE,
historyTab: 'overview', historyData: { zones: [], devices: [], sensors: [] }, historyCounts: {},
historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyNetworkTarget: 'all', historyNetworkShowJitter: true, historyNetwork: [], historyNetworkTargets: [], historyEnergyDevice: '', historyEnergyTargets: [], historyEnergyInterval: 'daily', historyEnergyCompare: 'none', historyEnergy: [], historyLoading: false, historyReloadPending: false,
historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyNetworkTarget: 'all', historyNetworkShowJitter: true, historyNetwork: [], historyNetworkTargets: [], historyEnergyDevice: '', historyEnergyTargets: [], historyEnergyHours: '720', historyEnergyInterval: 'daily', historyEnergyCompare: 'none', historyEnergy: [], historyLoading: false, historyReloadPending: false,
customChartSeries: [], savedCharts: [], customChartEditingId: null, customChartNameDraft: null, chartZooms: {}, chartHiddenSeries: {}, zoneControlSeq: {}, groupControlSeq: {}, zoneControlQueue: {}, groupControlQueue: {}, deviceControlQueue: {}, houseControlQueue: null, climateControlQueue: null, groupCustomDrafts: {}, zoneTemperatureTimers: {}, deviceTemperatureDrafts: {},
controlPlan: null, controlPlanRevision: null, controlPlanPushReady: false, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, debugFilter: 'all', sensorAliases: {}, flowSharedInputs: [], flowSharedInputValueCache: {}, flowSharedInputValueRequests: {}, flowSharedInputValueTimer: null,
haManualFallbackVisible: false, haSupervisorTestState: null, haEntityCatalog: [], haEntityCatalogConfigured: false, haEntityCatalogLoadedAt: 0, haEntityCatalogLoading: false,
@@ -97,9 +97,9 @@ function hydrateUiIcons(root = document) {
node.innerHTML = uiIcon(name, className);
});
}
const locale = () => app.locales[app.language] || app.locales[DEFAULT_LANGUAGE] || 'en-GB';
const locale = () => app.locales[app.language] || app.locales[app.defaultLanguage] || app.language || app.defaultLanguage || DEFAULT_LANGUAGE;
const tr = (key, params = {}) => {
const template = app.translations[app.language]?.[key] ?? app.translations[DEFAULT_LANGUAGE]?.[key] ?? key;
const template = app.translations[app.language]?.[key] ?? app.translations[app.defaultLanguage]?.[key] ?? key;
return String(template).replace(/\{([a-zA-Z0-9_]+)\}/g, (_, name) => params[name] ?? `{${name}}`);
};
const fmtTemp = value => value !== null && value !== undefined && value !== '' && Number.isFinite(Number(value)) ? `${Number(value).toFixed(1)}°C` : '--';
@@ -275,7 +275,7 @@ function updateConnectionIndicator(status) {
function updateToolbarControls() {
const languageLabel = $('#languageLabel');
if (languageLabel) languageLabel.textContent = String(app.language || DEFAULT_LANGUAGE).toUpperCase();
if (languageLabel) languageLabel.textContent = String(app.language || app.defaultLanguage || DEFAULT_LANGUAGE).toUpperCase();
const themeSelect = $('#themeSelect');
const themeIcon = $('#themeIcon');
const iconName = { system: 'system', light: 'sun', dark: 'moon' }[app.theme] || 'system';
@@ -328,7 +328,7 @@ async function loadLanguagePack(language) {
async function setLanguage(language) {
const available = app.languages.some(item => item.code === language);
const nextLanguage = available ? language : DEFAULT_LANGUAGE;
const nextLanguage = available ? language : (app.defaultLanguage || app.languages[0]?.code || DEFAULT_LANGUAGE);
const previousLanguage = app.language;
const select = $('#languageSelect');
if (nextLanguage === previousLanguage && app.translations[nextLanguage]) return;
@@ -364,29 +364,33 @@ async function loadLanguages() {
if (!response.ok) throw new Error(`Language index HTTP ${response.status}`);
const manifest = await response.json();
const languages = Array.isArray(manifest.languages) ? manifest.languages : [];
if (!languages.some(item => item.code === DEFAULT_LANGUAGE)) throw new Error('Default English language pack is missing');
if (!languages.length) throw new Error('Language manifest is empty');
app.languages = languages;
app.defaultLanguage = app.languages.some(item => item.code === manifest.default)
? manifest.default
: app.languages[0].code;
app.translations = {};
app.locales = Object.fromEntries(languages.map(item => [item.code, item.locale || item.code]));
app.language = app.languages.some(item => item.code === preferredLanguage)
? preferredLanguage
: (manifest.default || DEFAULT_LANGUAGE);
if (!app.languages.some(item => item.code === app.language)) app.language = DEFAULT_LANGUAGE;
: app.defaultLanguage;
if (!app.languages.some(item => item.code === app.language)) app.language = app.defaultLanguage;
try {
await loadLanguagePack(app.language);
} catch (error) {
if (app.language === DEFAULT_LANGUAGE) throw error;
if (app.language === app.defaultLanguage) throw error;
console.error(`Unable to load preferred language ${app.language}:`, error);
app.language = DEFAULT_LANGUAGE;
await loadLanguagePack(DEFAULT_LANGUAGE);
app.language = app.defaultLanguage;
await loadLanguagePack(app.defaultLanguage);
}
renderLanguageOptions();
} catch (error) {
console.error('Unable to load language packs:', error);
app.languages = [{ code: DEFAULT_LANGUAGE, name: 'English', native_name: 'English', locale: 'en-GB' }];
app.translations = { [DEFAULT_LANGUAGE]: {} };
app.locales = { [DEFAULT_LANGUAGE]: 'en-GB' };
app.language = DEFAULT_LANGUAGE;
const fallbackLanguage = app.defaultLanguage || DEFAULT_LANGUAGE;
app.languages = fallbackLanguage ? [{ code: fallbackLanguage, name: fallbackLanguage.toUpperCase(), native_name: fallbackLanguage.toUpperCase(), locale: fallbackLanguage }] : [];
app.translations = fallbackLanguage ? { [fallbackLanguage]: {} } : {};
app.locales = fallbackLanguage ? { [fallbackLanguage]: fallbackLanguage } : {};
app.language = fallbackLanguage;
renderLanguageOptions();
}
}
@@ -261,7 +261,7 @@ $('#cloudDiscoverButton')?.addEventListener('click', async event => {
finally { button.disabled = false; }
});
$('#historyRefresh').addEventListener('click', loadHistory);
$('#historyHours').addEventListener('change', () => { updateBrowserUrl(currentHistoryPath(), true); loadHistory(); });
$('#historyHours').addEventListener('change', () => { if (app.historyTab === 'energy') app.historyEnergyHours = $('#historyHours').value || '720'; updateBrowserUrl(currentHistoryPath(), true); loadHistory(); });
$('#logsRefresh').addEventListener('click', loadLogs);
$('#languageSelect').addEventListener('change', event => setLanguage(event.target.value));
$('#themeSelect')?.addEventListener('change', event => setTheme(event.target.value));
+5 -5
View File
@@ -728,7 +728,7 @@ function markFlowPresetRecent(id) {
function flowPresetText(value) {
if (typeof value === 'string') return value;
if (!value || typeof value !== 'object') return '';
return value[app.language] || value.en || value.pl || Object.values(value).find(item => typeof item === 'string') || '';
return value[app.language] || value[app.defaultLanguage] || Object.values(value).find(item => typeof item === 'string') || '';
}
async function loadFlowPresets({ force = false } = {}) {
@@ -741,11 +741,11 @@ async function loadFlowPresets({ force = false } = {}) {
const entries = Array.isArray(manifest?.presets) ? manifest.presets : [];
const presets = await Promise.all(entries.map(async entry => {
const filename = String(entry?.file || '');
if (!filename || !/^[a-zA-Z0-9_-]+\.json$/.test(filename)) throw new Error('Invalid preset filename');
if (!filename || !/^[a-zA-Z0-9_-]+\.json$/.test(filename)) throw new Error(tr('flow.invalidPresetFilename'));
const response = await fetch(withBase(`/presets/${encodeURIComponent(filename)}`), { headers: { Accept: 'application/json' }, cache: 'no-store' });
if (!response.ok) throw new Error(`${filename}: HTTP ${response.status}`);
const preset = await response.json();
if (!preset?.id || !preset?.flow || !Array.isArray(preset.flow.nodes) || !Array.isArray(preset.flow.edges)) throw new Error(`${filename}: invalid preset`);
if (!preset?.id || !preset?.flow || !Array.isArray(preset.flow.nodes) || !Array.isArray(preset.flow.edges)) throw new Error(tr('flow.invalidPresetFile', { filename }));
return preset;
}));
app.flowPresets = presets;
@@ -923,7 +923,7 @@ async function openFlowTemplates() {
renderFlowPresetBrowser(flowPresetActiveCategory);
} catch (error) {
if (tabs) tabs.innerHTML = '';
host.innerHTML = `<div class="empty compact"><strong>${esc(tr('flow.templatesLoadFailed') || 'Unable to load presets')}</strong><span>${esc(error.message)}</span></div>`;
host.innerHTML = `<div class="empty compact"><strong>${esc(tr('flow.templatesLoadFailed'))}</strong><span>${esc(error.message)}</span></div>`;
}
}
@@ -936,7 +936,7 @@ function previewFlowTemplate(key) {
function applyFlowTemplate(key) {
if (!app.flowDraft) return;
const preset = (app.flowPresets || []).find(item => item.id === key);
if (!preset) return toast(tr('flow.templatesLoadFailed') || 'Preset not found', true);
if (!preset) return toast(tr('flow.presetNotFound'), true);
if (app.flowDraft.nodes.length && !confirm(tr('flow.templateReplaceConfirm'))) return;
const graph = materializeFlowPreset(preset);
app.flowDraft.nodes = graph.nodes; app.flowDraft.edges = graph.edges;
@@ -392,7 +392,7 @@ async function handleHistoryAction(button) {
title: $('#customChartName')?.value.trim() || tr('history.customChart'),
series: [...app.customChartSeries],
hours: Number($('#historyHours')?.value || 24),
lang: app.language === 'pl' ? 'pl' : 'en',
lang: app.language,
},
});
const link = publicCustomChartUrl(share.path);
@@ -29,7 +29,10 @@ function showView(name, { push = true, scroll = true } = {}) {
}
function showHistoryTab(tab, { push = true, load = true } = {}) {
app.historyTab = HISTORY_TABS.includes(tab) ? tab : 'overview';
const nextTab = HISTORY_TABS.includes(tab) ? tab : 'overview';
if (app.historyTab === 'energy' && $('#historyHours')) app.historyEnergyHours = $('#historyHours').value || app.historyEnergyHours || '720';
app.historyTab = nextTab;
if (nextTab === 'energy' && $('#historyHours')) $('#historyHours').value = app.historyEnergyHours || '720';
renderHistoryNavigation();
if (push) updateBrowserUrl(currentHistoryPath());
if (load) loadHistory();
@@ -85,7 +85,13 @@ function applyRouteFromLocation() {
app.historyNetworkTarget = params.get('target') || 'all';
app.historySensor = params.get('sensor') || 'all';
const hours = params.get('hours');
if (hours && ['6', '24', '168', '720', '2160', '8760'].includes(hours) && $('#historyHours')) $('#historyHours').value = hours;
const validHours = hours && ['6', '24', '168', '720', '2160', '8760'].includes(hours) ? hours : null;
if (app.historyTab === 'energy') {
app.historyEnergyHours = validHours || app.historyEnergyHours || '720';
if ($('#historyHours')) $('#historyHours').value = app.historyEnergyHours;
} else if (validHours && $('#historyHours')) {
$('#historyHours').value = validHours;
}
if (app.historyTab === 'custom' && params.get('chart')) app.customChartSeries = decodeChartSpec(params.get('chart'));
if (!showView('history', { push: false, scroll: false })) restoreCurrentRoute(previousView);
return;
@@ -345,6 +345,38 @@ $('#haUseSupervisor')?.addEventListener('click', async event => {
} finally { button.disabled = false; button.textContent = idle; }
});
$('#testInfluxDbConnection')?.addEventListener('click', async event => {
const form = $('#settingsForm');
const button = event.currentTarget;
const resultBox = $('#influxDbTestResult');
if (!form) return;
const idle = button.textContent;
button.disabled = true;
button.textContent = tr('settings.influxTesting');
clearFormErrors(form);
if (resultBox) { resultBox.hidden = true; resultBox.classList.remove('success', 'error'); }
try {
const result = await api('/api/integrations/influxdb/test', { method: 'POST', body: influxDbSettingsBodyFromForm(form) });
const message = tr('settings.influxTestSuccess', { ms: result.response_time_ms ?? 0 });
if (resultBox) {
resultBox.hidden = false;
resultBox.classList.add('success');
resultBox.innerHTML = `<span>${esc(tr('status.connected'))}</span><strong>${esc(message)}</strong>`;
}
toast(message);
} catch (error) {
if (resultBox) {
resultBox.hidden = false;
resultBox.classList.add('error');
resultBox.innerHTML = `<span>${esc(tr('devices.lastError'))}</span><strong>${esc(error.message)}</strong>`;
}
toast(error.message, true);
} finally {
button.disabled = false;
button.textContent = idle;
}
});
$('#exportSettings').addEventListener('click', async () => {
try {
const data = await api('/api/configuration/export');
@@ -509,7 +541,7 @@ $('#greeCloudTestButton')?.addEventListener('click', async event => {
if (resultBox) {
resultBox.hidden = false;
resultBox.classList.add(result.ok ? 'success' : 'error');
const message = result.ok ? `Connected. ${Number(result.device_count || 0)} device(s) found.` : (result.message || result.status || 'Connection failed');
const message = result.ok ? tr('settings.cloudConnected', { count: Number(result.device_count || 0) }) : (result.message || result.status || tr('settings.cloudConnectionFailed'));
resultBox.innerHTML = `<span>${esc(result.ok ? tr('status.online') : tr('devices.lastError'))}</span><strong>${esc(message)}</strong>`;
}
if (result.ok) {
+86 -35
View File
@@ -16,16 +16,55 @@ function basePathFromScript() {
const PUBLIC_BASE = basePathFromScript();
const withBase = path => `${PUBLIC_BASE}${path.startsWith('/') ? path : `/${path}`}`;
const PUBLIC_DEFAULT_LANGUAGE = String(document.documentElement.lang || '').trim();
const PUBLIC_RANGE_KEYS = ['history.6h', 'history.24h', 'history.7d', 'history.30d', 'history.90d', 'history.1y'];
let publicLanguageManifest = null;
let publicChartLanguage = PUBLIC_DEFAULT_LANGUAGE;
let publicChartTranslations = {};
let publicChartLocale = PUBLIC_DEFAULT_LANGUAGE;
function publicCookie(name) {
const row = document.cookie.split('; ').find(item => item.startsWith(`${name}=`));
return row ? decodeURIComponent(row.split('=').slice(1).join('=')) : '';
}
function publicTr(key, params = {}) {
const template = publicChartTranslations[key] ?? key;
return String(template).replace(/\{([a-zA-Z0-9_]+)\}/g, (_, name) => params[name] ?? `{${name}}`);
}
async function loadPublicLanguageManifest() {
if (publicLanguageManifest) return publicLanguageManifest;
const response = await fetch(withBase('/lang/index.json'), { headers: { Accept: 'application/json' }, cache: 'no-store' });
if (!response.ok) throw new Error(`language index HTTP ${response.status}`);
publicLanguageManifest = await response.json();
return publicLanguageManifest;
}
async function setPublicChartLanguage(language) {
const manifest = await loadPublicLanguageManifest();
const languages = Array.isArray(manifest.languages) ? manifest.languages : [];
const fallbackCode = manifest.default || PUBLIC_DEFAULT_LANGUAGE;
const item = languages.find(entry => entry.code === language)
|| languages.find(entry => entry.code === fallbackCode)
|| languages[0];
if (!item) throw new Error('language pack unavailable');
const response = await fetch(withBase(item.path || `/lang/${encodeURIComponent(item.code)}.json`), { headers: { Accept: 'application/json' } });
if (!response.ok) throw new Error(`language ${item.code} HTTP ${response.status}`);
const pack = await response.json();
publicChartLanguage = item.code;
publicChartTranslations = pack.translations || {};
publicChartLocale = pack.meta?.locale || item.locale || item.code;
document.documentElement.lang = item.code;
document.title = `GREE Controller · ${publicTr('publicChart.title')}`;
setPublicRangeLanguage();
}
function cssColor(name, fallback) {
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return value || fallback;
}
function localeFor(lang) {
return lang === 'pl' ? 'pl-PL' : 'en-GB';
}
function timeLabel(timestamp, hours, locale) {
const options = Number(hours) > 48
? { day: '2-digit', month: '2-digit', hour: '2-digit' }
@@ -142,7 +181,7 @@ function drawChart(canvas, payload, locale) {
renderLegend(series);
const allPoints = series.flatMap(item => item.points.map(point => ({ ...point, value: Number(point.value) })))
.filter(point => Number.isFinite(point.value) && Number.isFinite(new Date(point.timestamp).getTime()));
if (!allPoints.length) return drawEmpty(canvas, payload.no_data_label || 'No data');
if (!allPoints.length) return drawEmpty(canvas, payload.no_data_label || publicTr('publicChart.noData'));
const { ctx, width, height } = prepareCanvas(canvas);
const text = cssColor('--muted', '#888');
@@ -196,7 +235,6 @@ function drawChart(canvas, payload, locale) {
}
let publicChartPayload = null;
let publicChartLocale = 'en-GB';
let publicChartResizeFrame = 0;
function publicChartToken() {
@@ -208,17 +246,16 @@ function requestedPublicChartHours() {
return Number.isInteger(value) && value >= 1 && value <= 87600 ? value : null;
}
function setPublicRangeLanguage(lang) {
const pl = lang === 'pl';
function setPublicRangeLanguage() {
const label = $('#publicChartRangeLabel');
const select = $('#publicChartHours');
if (label) label.textContent = pl ? 'Zakres' : 'Range';
if (label) label.textContent = publicTr('history.range');
if (!select) return;
const labels = pl
? ['6 godzin', '24 godziny', '7 dni', '30 dni', '90 dni', '1 rok']
: ['6 hours', '24 hours', '7 days', '30 days', '90 days', '1 year'];
[...select.options].forEach((option, index) => { if (labels[index]) option.textContent = labels[index]; });
select.setAttribute('aria-label', pl ? 'Zakres wykresu' : 'Chart range');
[...select.options].forEach((option, index) => {
const key = PUBLIC_RANGE_KEYS[index];
if (key) option.textContent = publicTr(key);
});
select.setAttribute('aria-label', publicTr('publicChart.rangeAria'));
}
function syncPublicRangeControl(hours) {
@@ -228,7 +265,7 @@ function syncPublicRangeControl(hours) {
if (![...select.options].some(option => option.value === value)) {
const option = document.createElement('option');
option.value = value;
option.textContent = `${value} h`;
option.textContent = publicTr('publicChart.hoursShort', { hours: value });
select.append(option);
}
select.value = value;
@@ -255,15 +292,16 @@ async function loadPublicChart(hours = requestedPublicChartHours()) {
const range = $('#publicChartHours');
if (!token.startsWith('chart_')) {
title.textContent = 'Custom chart';
title.textContent = publicTr('publicChart.title');
hint.textContent = '';
error.hidden = false;
error.textContent = 'Invalid chart link.';
error.textContent = publicTr('publicChart.invalidLink');
drawEmpty(canvas, error.textContent);
return;
}
if (range) range.disabled = true;
if (hint) hint.textContent = publicTr('publicChart.loading');
error.hidden = true;
const response = await fetch(withBase(publicChartApiPath(token, hours)), { headers: { Accept: 'application/json' }, cache: 'no-store' });
if (!response.ok) {
@@ -272,17 +310,17 @@ async function loadPublicChart(hours = requestedPublicChartHours()) {
throw new Error(message);
}
const payload = await response.json();
const lang = payload.lang === 'pl' ? 'pl' : 'en';
const locale = localeFor(lang);
document.documentElement.lang = payload.lang || lang;
title.textContent = payload.title || (lang === 'pl' ? 'Wykres niestandardowy' : 'Custom chart');
hint.textContent = payload.hint || '';
await setPublicChartLanguage(payload.lang || publicChartLanguage);
title.textContent = payload.title || publicTr('publicChart.title');
hint.textContent = payload.hint || publicTr('publicChart.periodHint', { hours: payload.hours });
canvas.setAttribute('aria-label', title.textContent);
setPublicRangeLanguage(lang);
syncPublicRangeControl(payload.hours);
publicChartPayload = payload;
publicChartLocale = locale;
drawChart(canvas, payload, locale);
const localizedSeries = (payload.series || []).map(item => ({
...item,
label: item.label_key ? `${item.label} · ${publicTr(item.label_key)}` : item.label,
}));
publicChartPayload = { ...payload, series: localizedSeries, no_data_label: payload.no_data_label || publicTr('publicChart.noData') };
drawChart(canvas, publicChartPayload, publicChartLocale);
if (range) range.disabled = false;
}
@@ -297,7 +335,7 @@ $('#publicChartHours')?.addEventListener('change', async event => {
} catch (error) {
const host = $('#publicChartError');
host.hidden = false;
host.textContent = `${document.documentElement.lang === 'pl' ? 'Nie udało się wczytać wykresu' : 'Unable to load chart'}: ${error.message}`;
host.textContent = publicTr('publicChart.loadFailed', { error: error.message });
event.currentTarget.disabled = false;
}
});
@@ -307,11 +345,24 @@ window.addEventListener('resize', () => {
publicChartResizeFrame = requestAnimationFrame(redrawPublicChart);
});
loadPublicChart().catch(error => {
const host = $('#publicChartError');
host.hidden = false;
host.textContent = `Unable to load chart: ${error.message}`;
$('#publicChartHint').textContent = '';
$('#publicChartHours').disabled = false;
drawEmpty($('#publicCustomChart'), host.textContent);
});
async function initPublicChart() {
try {
await setPublicChartLanguage(publicCookie('gree_controller_language') || PUBLIC_DEFAULT_LANGUAGE);
} catch (error) {
console.error('Public chart language initialization failed:', error);
}
try {
await loadPublicChart();
} catch (error) {
const host = $('#publicChartError');
host.hidden = false;
host.textContent = publicChartTranslations['publicChart.loadFailed']
? publicTr('publicChart.loadFailed', { error: error.message })
: error.message;
$('#publicChartHint').textContent = '';
$('#publicChartHours').disabled = false;
drawEmpty($('#publicCustomChart'), host.textContent);
}
}
initPublicChart();
+2 -2
View File
@@ -1,7 +1,7 @@
'use strict';
(() => {
const row = document.cookie.split('; ').find(item => item.startsWith('gree_controller_language='));
const language = row ? decodeURIComponent(row.split('=').slice(1).join('=')) : 'en';
document.documentElement.lang = language || 'en';
const language = row ? decodeURIComponent(row.split('=').slice(1).join('=')) : document.documentElement.lang;
if (language) document.documentElement.lang = language;
document.documentElement.classList.add('i18n-loading');
})();
View File