All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 25s
Settings always showed the full "how to connect" steps and code-paste form for oauth2 mode regardless of connection status, which read as broken even when the token was live and polling successfully. Collapse it behind a "Reconnect" toggle once connected. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
355 lines
15 KiB
HTML
355 lines
15 KiB
HTML
{% extends "base.html" %}
|
|
{% block content %}
|
|
<h1>Settings</h1>
|
|
|
|
<div class="card">
|
|
<h3>Household</h3>
|
|
<form class="settings-form" id="household-form">
|
|
<label>Distributor
|
|
<select name="distributor">
|
|
<option value="Energex">Energex (South East QLD)</option>
|
|
<option value="Ergon">Ergon Energy (regional QLD)</option>
|
|
</select>
|
|
</label>
|
|
<label>Postcode <input name="postcode" placeholder="e.g. 4000"></label>
|
|
<button type="submit">Save</button>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>iSolarCloud connection</h3>
|
|
{% if use_mock_isolarcloud %}
|
|
<p class="muted">
|
|
Currently running on <strong>synthetic mock data</strong> (<code>USE_MOCK_ISOLARCLOUD=true</code>).
|
|
Once you have your App Key, Secret Key and iSolarCloud username/password filled in your
|
|
<code>.env</code> (see SETUP.md), set <code>USE_MOCK_ISOLARCLOUD=false</code> and restart,
|
|
then test the connection below.
|
|
</p>
|
|
{% elif isolarcloud_auth_mode == "oauth2" %}
|
|
<p id="isolarcloud-status" class="muted">Checking connection status…</p>
|
|
<button type="button" id="oauth-reconnect-toggle" style="display:none;">Reconnect / use a different account</button>
|
|
<div id="oauth-connect-block">
|
|
<p class="muted">
|
|
1. Open the authorization link below in your own browser, log in with your iSolarCloud account, and approve access.<br>
|
|
2. You'll land on a page that fails to load (that's expected -- it's an https://localhost redirect) -- copy the
|
|
<code>code=</code> value from the address bar.<br>
|
|
3. Paste just the code below and click Connect.
|
|
</p>
|
|
<p><a id="oauth-authorize-link" href="#" target="_blank" rel="noopener">Open authorization link</a></p>
|
|
<label>Authorization code <input id="oauth-code-input" placeholder="paste the code= value here"></label>
|
|
<button type="button" id="oauth-connect-btn">Connect</button>
|
|
</div>
|
|
<div id="isolarcloud-plants" style="margin-top:1rem;"></div>
|
|
{% else %}
|
|
<p id="isolarcloud-status" class="muted">Checking connection status…</p>
|
|
<button type="button" id="isolarcloud-connect">Test connection</button>
|
|
<div id="isolarcloud-plants" style="margin-top:1rem;"></div>
|
|
{% endif %}
|
|
<div id="isolarcloud-flash"></div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Your current plan</h3>
|
|
<p class="muted">Entered manually so the comparison has a baseline to measure savings against.</p>
|
|
<form class="settings-form" id="plan-form">
|
|
<label>Retailer name <input name="retailer_name" required></label>
|
|
<label>Plan name <input name="plan_name" required></label>
|
|
<label>Supply charge ($/day) <input name="supply_charge_dollars_per_day" type="number" step="0.001" required></label>
|
|
<label>Plan discount already applied, if any (%) <input name="discount_percent" type="number" step="0.1"></label>
|
|
|
|
<label>Feed-in tariff type
|
|
<select name="feed_in_type" id="feed-in-type">
|
|
<option value="flat">Flat rate</option>
|
|
<option value="tiered">Tiered by daily export (e.g. "Solar Balance")</option>
|
|
</select>
|
|
</label>
|
|
|
|
<div id="flat-fit-fields">
|
|
<label>Feed-in tariff (c/kWh) <input name="feed_in_tariff_c_per_kwh" type="number" step="0.01" value="0"></label>
|
|
</div>
|
|
|
|
<div id="tiered-fit-fields" style="display:none;">
|
|
<label>Daily threshold (kWh/day) <input name="tiered_daily_threshold_kwh" type="number" step="0.01" placeholder="e.g. 10"></label>
|
|
<label>Rate below threshold (c/kWh) <input name="tiered_rate_below_c_per_kwh" type="number" step="0.01"></label>
|
|
<label>Rate above threshold (c/kWh) <input name="tiered_rate_above_c_per_kwh" type="number" step="0.01"></label>
|
|
</div>
|
|
|
|
<label>Tariff type
|
|
<select name="tariff_type" id="tariff-type">
|
|
<option value="flat">Flat rate</option>
|
|
<option value="time_of_use">Time of use</option>
|
|
</select>
|
|
</label>
|
|
|
|
<div id="flat-fields">
|
|
<label>Usage rate (c/kWh) <input name="flat_rate_c_per_kwh" type="number" step="0.01"></label>
|
|
</div>
|
|
|
|
<div id="tou-fields" style="display:none;">
|
|
<div id="tou-periods"></div>
|
|
<button type="button" id="add-period">+ Add time period</button>
|
|
</div>
|
|
|
|
<button type="submit">Save current plan</button>
|
|
</form>
|
|
</div>
|
|
|
|
<script>
|
|
async function loadHousehold() {
|
|
const cfg = await fetch('/api/settings/household').then(r => r.json());
|
|
const form = document.getElementById('household-form');
|
|
form.distributor.value = cfg.distributor || 'Energex';
|
|
form.postcode.value = cfg.postcode || '';
|
|
}
|
|
|
|
document.getElementById('household-form').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const form = e.target;
|
|
await fetch('/api/settings/household', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ distributor: form.distributor.value, postcode: form.postcode.value }),
|
|
});
|
|
alert('Saved.');
|
|
});
|
|
|
|
const tariffType = document.getElementById('tariff-type');
|
|
const flatFields = document.getElementById('flat-fields');
|
|
const touFields = document.getElementById('tou-fields');
|
|
const touPeriods = document.getElementById('tou-periods');
|
|
const feedInType = document.getElementById('feed-in-type');
|
|
const flatFitFields = document.getElementById('flat-fit-fields');
|
|
const tieredFitFields = document.getElementById('tiered-fit-fields');
|
|
|
|
feedInType.addEventListener('change', () => {
|
|
const isFlat = feedInType.value === 'flat';
|
|
flatFitFields.style.display = isFlat ? 'block' : 'none';
|
|
tieredFitFields.style.display = isFlat ? 'none' : 'block';
|
|
});
|
|
|
|
const ALL_DAYS = ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'];
|
|
|
|
function periodRow(values) {
|
|
values = values || { label: '', rate_c_per_kwh: '', start_time: '00:00', end_time: '00:00', days: null };
|
|
const days = values.days || ALL_DAYS;
|
|
const div = document.createElement('div');
|
|
div.className = 'tou-period';
|
|
const dayCheckboxes = ALL_DAYS.map(d => `
|
|
<label class="p-day-label"><input type="checkbox" class="p-day" value="${d}" ${days.includes(d) ? 'checked' : ''}>${d}</label>
|
|
`).join('');
|
|
div.innerHTML = `
|
|
<label>Label <input class="p-label" value="${values.label}" placeholder="e.g. Peak"></label>
|
|
<label>Rate (c/kWh) <input class="p-rate" type="number" step="0.01" value="${values.rate_c_per_kwh}"></label>
|
|
<label>Start time <input class="p-start" type="time" value="${values.start_time}"></label>
|
|
<label>End time <input class="p-end" type="time" value="${values.end_time}"></label>
|
|
<div class="p-days-group">Days: ${dayCheckboxes}</div>
|
|
<button type="button" class="p-remove">Remove</button>
|
|
`;
|
|
div.querySelector('.p-remove').addEventListener('click', () => div.remove());
|
|
touPeriods.appendChild(div);
|
|
}
|
|
|
|
document.getElementById('add-period').addEventListener('click', () => periodRow());
|
|
|
|
tariffType.addEventListener('change', () => {
|
|
const isFlat = tariffType.value === 'flat';
|
|
flatFields.style.display = isFlat ? 'block' : 'none';
|
|
touFields.style.display = isFlat ? 'none' : 'block';
|
|
if (!isFlat && touPeriods.children.length === 0) {
|
|
periodRow({ label: 'Peak', rate_c_per_kwh: '', start_time: '16:00', end_time: '21:00' });
|
|
periodRow({ label: 'Off-peak', rate_c_per_kwh: '', start_time: '21:00', end_time: '16:00' });
|
|
}
|
|
});
|
|
|
|
async function loadCurrentPlan() {
|
|
const plan = await fetch('/api/settings/current-plan').then(r => r.json());
|
|
if (!plan) return;
|
|
const form = document.getElementById('plan-form');
|
|
form.retailer_name.value = plan.retailer;
|
|
form.plan_name.value = plan.plan_name;
|
|
form.supply_charge_dollars_per_day.value = (plan.supply_charge_c_per_day / 100).toFixed(4);
|
|
|
|
if (plan.tiered_feed_in) {
|
|
feedInType.value = 'tiered';
|
|
form.tiered_daily_threshold_kwh.value = plan.tiered_feed_in.daily_threshold_kwh;
|
|
form.tiered_rate_below_c_per_kwh.value = plan.tiered_feed_in.rate_below_c_per_kwh;
|
|
form.tiered_rate_above_c_per_kwh.value = plan.tiered_feed_in.rate_above_c_per_kwh;
|
|
} else {
|
|
feedInType.value = 'flat';
|
|
form.feed_in_tariff_c_per_kwh.value = plan.feed_in_tariffs[0] ? plan.feed_in_tariffs[0].rate_c_per_kwh : 0;
|
|
}
|
|
feedInType.dispatchEvent(new Event('change'));
|
|
|
|
const discount = (plan.discounts || [])[0];
|
|
if (discount && discount.percent) form.discount_percent.value = discount.percent;
|
|
|
|
const isFlat = plan.usage_components.length === 1 && plan.usage_components[0].time_windows.length === 0;
|
|
tariffType.value = isFlat ? 'flat' : 'time_of_use';
|
|
tariffType.dispatchEvent(new Event('change'));
|
|
if (isFlat) {
|
|
form.flat_rate_c_per_kwh.value = plan.usage_components[0].rate_c_per_kwh;
|
|
} else {
|
|
touPeriods.innerHTML = '';
|
|
plan.usage_components.forEach(c => {
|
|
const w = c.time_windows[0] || { start_time: '00:00', end_time: '00:00', days: null };
|
|
periodRow({
|
|
label: c.label,
|
|
rate_c_per_kwh: c.rate_c_per_kwh,
|
|
start_time: w.start_time.slice(0, 5),
|
|
end_time: w.end_time.slice(0, 5),
|
|
days: w.days,
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
document.getElementById('plan-form').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const form = e.target;
|
|
const payload = {
|
|
retailer_name: form.retailer_name.value,
|
|
plan_name: form.plan_name.value,
|
|
supply_charge_dollars_per_day: parseFloat(form.supply_charge_dollars_per_day.value),
|
|
discount_percent: form.discount_percent.value ? parseFloat(form.discount_percent.value) : null,
|
|
tariff_type: tariffType.value,
|
|
feed_in_type: feedInType.value,
|
|
};
|
|
if (tariffType.value === 'flat') {
|
|
payload.flat_rate_c_per_kwh = parseFloat(form.flat_rate_c_per_kwh.value);
|
|
} else {
|
|
payload.tou_periods = Array.from(touPeriods.children).map(div => {
|
|
const checkedDays = Array.from(div.querySelectorAll('.p-day:checked')).map(cb => cb.value);
|
|
return {
|
|
label: div.querySelector('.p-label').value,
|
|
rate_c_per_kwh: parseFloat(div.querySelector('.p-rate').value),
|
|
start_time: div.querySelector('.p-start').value,
|
|
end_time: div.querySelector('.p-end').value,
|
|
days: checkedDays.length ? checkedDays : null,
|
|
};
|
|
});
|
|
}
|
|
if (feedInType.value === 'flat') {
|
|
payload.feed_in_tariff_c_per_kwh = parseFloat(form.feed_in_tariff_c_per_kwh.value || 0);
|
|
} else {
|
|
payload.tiered_daily_threshold_kwh = parseFloat(form.tiered_daily_threshold_kwh.value);
|
|
payload.tiered_rate_below_c_per_kwh = parseFloat(form.tiered_rate_below_c_per_kwh.value);
|
|
payload.tiered_rate_above_c_per_kwh = parseFloat(form.tiered_rate_above_c_per_kwh.value);
|
|
}
|
|
const resp = await fetch('/api/settings/current-plan', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (resp.ok) {
|
|
alert('Current plan saved.');
|
|
} else {
|
|
const err = await resp.json();
|
|
alert('Failed to save: ' + JSON.stringify(err));
|
|
}
|
|
});
|
|
|
|
async function checkIsolarcloudStatus() {
|
|
const el = document.getElementById('isolarcloud-status');
|
|
if (!el) return;
|
|
const status = await fetch('/api/isolarcloud/status').then(r => r.json());
|
|
el.textContent = status.connected
|
|
? 'Connected -- a valid token is cached.'
|
|
: 'Not connected yet.';
|
|
|
|
// Only oauth2 mode has the connect-block/reconnect-toggle pair.
|
|
const connectBlock = document.getElementById('oauth-connect-block');
|
|
const reconnectToggle = document.getElementById('oauth-reconnect-toggle');
|
|
if (!connectBlock || !reconnectToggle) return;
|
|
if (status.connected) {
|
|
connectBlock.style.display = 'none';
|
|
reconnectToggle.style.display = 'inline-block';
|
|
} else {
|
|
connectBlock.style.display = 'block';
|
|
reconnectToggle.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
function renderPlantPicker(plants) {
|
|
const plantsEl = document.getElementById('isolarcloud-plants');
|
|
if (!plants || !plants.length) {
|
|
plantsEl.innerHTML = '';
|
|
return;
|
|
}
|
|
const options = plants.map(p => `<option value="${p.ps_id}">${p.ps_name || p.ps_id} (${p.ps_id})</option>`).join('');
|
|
plantsEl.innerHTML = `
|
|
<label>Plant to poll
|
|
<select id="plant-select">${options}</select>
|
|
</label>
|
|
<button type="button" id="save-plant">Use this plant</button>
|
|
`;
|
|
fetch('/api/settings/household').then(r => r.json()).then(household => {
|
|
if (household.isolarcloud_ps_id) document.getElementById('plant-select').value = household.isolarcloud_ps_id;
|
|
});
|
|
document.getElementById('save-plant').addEventListener('click', async () => {
|
|
const household = await fetch('/api/settings/household').then(r => r.json());
|
|
await fetch('/api/settings/household', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ ...household, isolarcloud_ps_id: document.getElementById('plant-select').value }),
|
|
});
|
|
alert('Saved.');
|
|
});
|
|
}
|
|
|
|
async function connectIsolarcloud() {
|
|
const flash = document.getElementById('isolarcloud-flash');
|
|
flash.innerHTML = '<p class="muted">Connecting…</p>';
|
|
document.getElementById('isolarcloud-plants').innerHTML = '';
|
|
const resp = await fetch('/api/isolarcloud/connect', { method: 'POST' });
|
|
const data = await resp.json();
|
|
if (!data.ok) {
|
|
flash.innerHTML = `<p style="color: var(--bad);">Connection failed: ${data.error}</p>`;
|
|
return;
|
|
}
|
|
flash.innerHTML = '<p style="color: var(--good);">Connected successfully.</p>';
|
|
renderPlantPicker(data.plants);
|
|
}
|
|
|
|
const connectButton = document.getElementById('isolarcloud-connect');
|
|
if (connectButton) connectButton.addEventListener('click', connectIsolarcloud);
|
|
|
|
const oauthAuthorizeLink = document.getElementById('oauth-authorize-link');
|
|
if (oauthAuthorizeLink) {
|
|
fetch('/api/isolarcloud/oauth/authorize-url').then(r => r.json()).then(data => {
|
|
oauthAuthorizeLink.href = data.url;
|
|
});
|
|
document.getElementById('oauth-reconnect-toggle').addEventListener('click', () => {
|
|
document.getElementById('oauth-connect-block').style.display = 'block';
|
|
});
|
|
document.getElementById('oauth-connect-btn').addEventListener('click', async () => {
|
|
const flash = document.getElementById('isolarcloud-flash');
|
|
let code = document.getElementById('oauth-code-input').value.trim();
|
|
// Accept a full pasted URL too, not just the bare code.
|
|
const match = code.match(/[?&]code=([^&]+)/);
|
|
if (match) code = decodeURIComponent(match[1]);
|
|
if (!code) {
|
|
flash.innerHTML = '<p style="color: var(--bad);">Paste the authorization code first.</p>';
|
|
return;
|
|
}
|
|
flash.innerHTML = '<p class="muted">Exchanging code…</p>';
|
|
document.getElementById('isolarcloud-plants').innerHTML = '';
|
|
const resp = await fetch('/api/isolarcloud/oauth/exchange', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ code }),
|
|
});
|
|
const data = await resp.json();
|
|
if (!data.ok) {
|
|
flash.innerHTML = `<p style="color: var(--bad);">Connection failed: ${data.error}</p>`;
|
|
return;
|
|
}
|
|
flash.innerHTML = '<p style="color: var(--good);">Connected successfully.</p>';
|
|
renderPlantPicker(data.authorized_plants);
|
|
});
|
|
}
|
|
|
|
loadHousehold();
|
|
loadCurrentPlan();
|
|
checkIsolarcloudStatus();
|
|
</script>
|
|
{% endblock %}
|