FastAPI app that pulls interval usage from Sungrow iSolarCloud (OAuth2), caches AER Consumer Data Right electricity plan data, and compares actual metered usage against every available plan to estimate real annual cost (TOU, tiered feed-in, and flat tariffs). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
339 lines
14 KiB
HTML
339 lines
14 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>
|
|
<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.';
|
|
}
|
|
|
|
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-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 %}
|