import {deepEqual} from 'fast-equals'; declare var PUBLIC_URL: string; console.log("Url is " + PUBLIC_URL); console.log("Public url is " + PUBLIC_URL); document.body.innerHTML = require('./main.html') as string; import {TimeView} from "./timeview"; import {PlantViews, PLANT_COUNT} from "./plant"; import {NetworkConfigView} from "./network"; import {NightLampView} from "./nightlightview"; import {TankConfigView} from "./tankview"; import {SubmitView} from "./submitView"; import {ProgressView} from "./progress"; import {OTAView} from "./ota"; import {BatteryView} from "./batteryview"; import {FileView} from './fileview'; import {LogView} from './log'; import {HardwareConfigView} from "./hardware"; import { BackupHeader, BatteryState, GetTime, LogArray, LogLocalisation, Moistures, NightLampCommand, PlantControllerConfig, SetTime, SSIDList, TankInfo, TestPump, VersionInfo, FileList, SolarState, PumpTestResult, Detection, CanPower } from "./api"; import {SolarView} from "./solarview"; import {toast} from "./toast"; export class Controller { loadTankInfo(): Promise { return fetch(PUBLIC_URL + "/tank") .then(response => response.json()) .then(json => json as TankInfo) .then(tankinfo => { controller.tankView.setTankInfo(tankinfo) }) .catch(error => { console.log(error); }); } loadLogLocaleConfig() { return fetch(PUBLIC_URL + "/log_localization") .then(response => response.json()) .then(json => json as LogLocalisation) .then(loglocale => { controller.logView.setLogLocalisation(loglocale) }) .catch(error => { console.log(error); }); } loadLog() { return fetch(PUBLIC_URL + "/log") .then(response => response.json()) .then(json => json as LogArray) .then(logs => { controller.logView.setLog(logs) }) .catch(error => { console.log(error); }); } async getBackupInfo(): Promise { try { const response = await fetch(PUBLIC_URL + "/backup_info"); const json = await response.json(); const header = json as BackupHeader; controller.submitView.setBackupInfo(header); } catch (error) { console.log(error); } } async populateTimezones(): Promise { try { const response = await fetch(PUBLIC_URL + '/timezones'); const json = await response.json(); const timezones = json as string[]; controller.timeView.timezones(timezones); } catch (error) { return console.error('Error fetching timezones:', error); } } async updateFileList(): Promise { try { const response = await fetch(PUBLIC_URL + "/files"); const json = await response.json(); const filelist = json as FileList; controller.fileview.setFileList(filelist, PUBLIC_URL); } catch (error) { console.log(error); } } uploadFile(file: File, name: string) { let current = 0; let max = 100; controller.progressview.addProgress("file_upload", (current / max) * 100, "Uploading File " + name + "(" + current + "/" + max + ")") const ajax = new XMLHttpRequest(); ajax.upload.addEventListener("progress", event => { current = event.loaded / 1000; max = event.total / 1000; controller.progressview.addProgress("file_upload", (current / max) * 100, "Uploading File " + name + "(" + current + "/" + max + ")") }, false); ajax.addEventListener("load", () => { controller.progressview.removeProgress("file_upload") controller.updateFileList() }, false); ajax.addEventListener("error", () => { alert("Error upload") controller.progressview.removeProgress("file_upload") controller.updateFileList() }, false); ajax.addEventListener("abort", () => { alert("abort upload") controller.progressview.removeProgress("file_upload") controller.updateFileList() }, false); ajax.open("POST", PUBLIC_URL + "/file?filename=" + name); ajax.send(file); } deleteFile(name: string) { controller.progressview.addIndeterminate("file_delete", "Deleting " + name); const ajax = new XMLHttpRequest(); ajax.open("DELETE", PUBLIC_URL + "/file?filename=" + name); ajax.send(); ajax.addEventListener("error", () => { controller.progressview.removeProgress("file_delete") alert("Error delete") controller.updateFileList() }, false); ajax.addEventListener("abort", () => { controller.progressview.removeProgress("file_delete") alert("Error upload") controller.updateFileList() }, false); ajax.addEventListener("load", () => { controller.progressview.removeProgress("file_delete") controller.updateFileList() }, false); controller.updateFileList() } async updateRTCData(): Promise { try { const response = await fetch(PUBLIC_URL + "/time"); const json = await response.json(); const time = json as GetTime; controller.timeView.update(time.native, time.rtc); } catch (error) { controller.timeView.update("n/a", "n/a"); console.log(error); } } async updateBatteryData(): Promise { try { const response = await fetch(PUBLIC_URL + "/battery"); const json = await response.json(); const battery = json as BatteryState; controller.batteryView.update(battery); } catch (error) { controller.batteryView.update(null); console.log(error); } } async updateSolarData(): Promise { try { const response = await fetch(PUBLIC_URL + "/solar"); const json = await response.json(); const solar = json as SolarState; controller.solarView.update(solar); } catch (error) { controller.solarView.update(null); console.log(error); } } uploadNewFirmware(file: File) { let current = 0; let max = 100; controller.progressview.addProgress("ota_upload", (current / max) * 100, "Uploading firmeware (" + current + "/" + max + ")") const ajax = new XMLHttpRequest(); ajax.upload.addEventListener("progress", event => { current = event.loaded / 1000; max = event.total / 1000; controller.progressview.addProgress("ota_upload", (current / max) * 100, "Uploading firmeware (" + current + "/" + max + ")") }, false); ajax.addEventListener("load", () => { controller.progressview.removeProgress("ota_upload") const status = ajax.status; if (status >= 200 && status < 300) { controller.reboot(); } else { const statusText = ajax.statusText || ""; const body = ajax.responseText || ""; toast.error(`OTA update error (${status}${statusText ? ' ' + statusText : ''}): ${body}`); } }, false); ajax.addEventListener("error", () => { controller.progressview.removeProgress("ota_upload") toast.error("OTA upload failed due to a network error."); }, false); ajax.addEventListener("abort", () => { controller.progressview.removeProgress("ota_upload") toast.error("OTA upload was aborted."); }, false); ajax.open("POST", PUBLIC_URL + "/ota"); ajax.send(file); } async version(): Promise { controller.progressview.addIndeterminate("version", "Getting buildVersion") const response = await fetch(PUBLIC_URL + "/version"); const json = await response.json(); const versionInfo = json as VersionInfo; controller.progressview.removeProgress("version"); controller.firmWareView.setVersion(versionInfo); } getBackupConfig() { controller.progressview.addIndeterminate("get_backup_config", "Downloading Backup") fetch(PUBLIC_URL + "/get_backup_config") .then(response => response.text()) .then(loaded => { controller.progressview.removeProgress("get_backup_config") controller.submitView.setBackupJson(loaded); }) } async downloadConfig(): Promise { controller.progressview.addIndeterminate("get_config", "Downloading Config") const response = await fetch(PUBLIC_URL + "/get_config"); const loaded = await response.json(); const currentConfig = loaded as PlantControllerConfig; controller.setInitialConfig(currentConfig); controller.setConfig(currentConfig); //sync json view initially controller.configChanged(); controller.progressview.removeProgress("get_config"); } setInitialConfig(currentConfig: PlantControllerConfig) { this.initialConfig = currentConfig } uploadConfig(json: string, statusCallback: (status: string) => void) { controller.progressview.addIndeterminate("set_config", "Uploading Config") fetch(PUBLIC_URL + "/set_config", { method: "POST", body: json, }) .then(response => response.text()) .then(text => statusCallback(text)) .then(_ => { controller.progressview.removeProgress("set_config"); setTimeout(() => { controller.downloadConfig() }, 250) }) } async backupConfig(json: string): Promise { const response = await fetch(PUBLIC_URL + "/backup_config", { method: "POST", body: json, }); return await response.text(); } syncRTCFromBrowser() { controller.progressview.addIndeterminate("write_rtc", "Writing RTC") const value: SetTime = { time: new Date().toISOString() }; const pretty = JSON.stringify(value, undefined, 1); fetch(PUBLIC_URL + "/time", { method: "POST", body: pretty }).then( _ => controller.progressview.removeProgress("write_rtc") ) } configChanged() { const current = controller.getConfig(); var pretty = JSON.stringify(current, undefined, 0); controller.submitView.setJson(pretty); if (deepEqual(current, controller.initialConfig)) { document.title = "PlantCtrl" } else { document.title = "*PlantCtrl" } } selfTest() { fetch(PUBLIC_URL + "/boardtest", { method: "POST" }) } testNightLamp(active: boolean) { const body: NightLampCommand = { active: active }; var pretty = JSON.stringify(body, undefined, 1); fetch(PUBLIC_URL + "/lamptest", { method: "POST", body: pretty }) } testPlant(plantId: number) { let counter = 0 let limit = 30 controller.progressview.addProgress("test_pump", counter / limit * 100, "Testing pump " + (plantId + 1) + " for " + (limit - counter) + "s") let timerId: string | number | NodeJS.Timeout | undefined function updateProgress() { counter++; controller.progressview.addProgress("test_pump", counter / limit * 100, "Testing pump " + (plantId + 1) + " for " + (limit - counter) + "s") timerId = setTimeout(updateProgress, 1000); } timerId = setTimeout(updateProgress, 1000); var body: TestPump = { pump: plantId } var pretty = JSON.stringify(body, undefined, 1); fetch(PUBLIC_URL + "/pumptest", { method: "POST", body: pretty }) .then(response => response.json() as Promise) .then( response => { controller.plantViews.setPumpTestCurrent(plantId, response); clearTimeout(timerId); controller.progressview.removeProgress("test_pump"); } ) } async detectSensors(detection: Detection, silent: boolean = false) { let counter = 0 let limit = 5 if (!silent) { controller.progressview.addProgress("detect_sensors", counter / limit * 100, "Detecting sensors " + (limit - counter) + "s") } let timerId: string | number | NodeJS.Timeout | undefined function updateProgress() { counter++; if (!silent) { controller.progressview.addProgress("detect_sensors", counter / limit * 100, "Detecting sensors " + (limit - counter) + "s") } timerId = setTimeout(updateProgress, 1000); } timerId = setTimeout(updateProgress, 1000); var pretty = JSON.stringify(detection, undefined, 1); return fetch(PUBLIC_URL + "/detect_sensors", {method: "POST", body: pretty}) .then(response => response.json()) .then(json => json as Detection) .then(json => { clearTimeout(timerId); if (!silent) { controller.progressview.removeProgress("detect_sensors"); } const pretty = JSON.stringify(json); toast.info("Detection result: " + pretty); console.log(pretty); this.plantViews.applyDetectionResult(json); }) .catch(error => { clearTimeout(timerId); if (!silent) { controller.progressview.removeProgress("detect_sensors"); } toast.error("Autodetect failed: " + error); }); } getConfig(): PlantControllerConfig { return { hardware: controller.hardwareView.getConfig(), network: controller.networkView.getConfig(), tank: controller.tankView.getConfig(), night_lamp: controller.nightLampView.getConfig(), plants: controller.plantViews.getConfig(), timezone: controller.timeView.getTimeZone() } } scanWifi() { let counter = 0 let limit = 5 controller.progressview.addProgress("scan_ssid", counter / limit * 100, "Scanning for SSIDs for " + (limit - counter) + "s") let timerId: string | number | NodeJS.Timeout | undefined function updateProgress() { counter++; controller.progressview.addProgress("scan_ssid", counter / limit * 100, "Scanning for SSIDs for " + (limit - counter) + "s") timerId = setTimeout(updateProgress, 1000); } timerId = setTimeout(updateProgress, 1000); const ajax = new XMLHttpRequest(); ajax.responseType = 'json'; ajax.onreadystatechange = () => { if (ajax.readyState === 4) { clearTimeout(timerId); controller.progressview.removeProgress("scan_ssid"); this.networkView.setScanResult(ajax.response as SSIDList) } }; ajax.onerror = (_) => { clearTimeout(timerId); controller.progressview.removeProgress("scan_ssid"); alert("Failed to start see console") } ajax.open("POST", PUBLIC_URL + "/wifiscan"); ajax.send(); } setConfig(current: PlantControllerConfig) { // Show Detect/Test button only for V4 HAL if (current.hardware && (current.hardware as any).board === "V4") { this.detectBtn.style.display = "inline-block"; } else { this.detectBtn.style.display = "none"; } this.tankView.setConfig(current.tank); this.networkView.setConfig(current.network); this.nightLampView.setConfig(current.night_lamp); this.plantViews.setConfig(current.plants); this.timeView.setTimeZone(current.timezone); this.hardwareView.setConfig(current.hardware); } measure_moisture(silent: boolean = false) { let counter = 0 let limit = 2 if (!silent) { controller.progressview.addProgress("measure_moisture", counter / limit * 100, "Measure Moisture " + (limit - counter) + "s") } let timerId: string | number | NodeJS.Timeout | undefined function updateProgress() { counter++; if (!silent) { controller.progressview.addProgress("measure_moisture", counter / limit * 100, "Measure Moisture " + (limit - counter) + "s") } timerId = setTimeout(updateProgress, 1000); } timerId = setTimeout(updateProgress, 1000); return fetch(PUBLIC_URL + "/moisture") .then(response => response.json()) .then(json => json as Moistures) .then(time => { controller.plantViews.update(time.moisture_a, time.moisture_b) clearTimeout(timerId); if (!silent) { controller.progressview.removeProgress("measure_moisture"); } }) .catch(error => { clearTimeout(timerId); if (!silent) { controller.progressview.removeProgress("measure_moisture"); } console.log(error); }); } exit() { fetch(PUBLIC_URL + "/exit", { method: "POST", }) controller.progressview.addIndeterminate("rebooting", "Returned to normal mode, you can close this site now") } waitForReboot() { console.log("Check if controller online again") fetch(PUBLIC_URL + "/version", { method: "GET", signal: AbortSignal.timeout(5000) }).then(response => { if (response.status != 200) { console.log("Not reached yet, retrying") setTimeout(controller.waitForReboot, 1000) } else { console.log("Reached controller, reloading") controller.progressview.addIndeterminate("rebooting", "Reached Controller, reloading") setTimeout(function () { window.location.reload() }, 2000); } }) .catch(_ => { console.log("Not reached yet, retrying") setTimeout(controller.waitForReboot, 1000) }) } reboot() { fetch(PUBLIC_URL + "/reboot", { method: "POST", }) controller.progressview.addIndeterminate("rebooting", "Rebooting") setTimeout(this.waitForReboot, 1000) } private setCanPower(checked: boolean) { var body: CanPower = { state: checked } var pretty = JSON.stringify(body, undefined, 1); fetch(PUBLIC_URL + "/can_power", { method: "POST", body: pretty }) } initialConfig: PlantControllerConfig | null = null readonly rebootBtn: HTMLButtonElement readonly exitBtn: HTMLButtonElement readonly timeView: TimeView; readonly plantViews: PlantViews; readonly networkView: NetworkConfigView; readonly hardwareView: HardwareConfigView; readonly tankView: TankConfigView; readonly nightLampView: NightLampView; readonly submitView: SubmitView; readonly firmWareView: OTAView; readonly progressview: ProgressView; readonly batteryView: BatteryView; readonly solarView: SolarView; readonly fileview: FileView; readonly logView: LogView readonly detectBtn: HTMLButtonElement readonly can_power: HTMLInputElement; readonly auto_refresh_moisture_sensors: HTMLInputElement; private auto_refresh_timer: NodeJS.Timeout | undefined; constructor() { this.timeView = new TimeView(this) this.plantViews = new PlantViews(this) this.networkView = new NetworkConfigView(this, PUBLIC_URL) this.tankView = new TankConfigView(this) this.batteryView = new BatteryView(this) this.solarView = new SolarView(this) this.nightLampView = new NightLampView(this) this.submitView = new SubmitView(this) this.firmWareView = new OTAView(this) this.progressview = new ProgressView(this) this.fileview = new FileView(this) this.logView = new LogView(this) this.hardwareView = new HardwareConfigView(this) this.detectBtn = document.getElementById("detect_sensors") as HTMLButtonElement this.detectBtn.onclick = () => { const detection: Detection = { plant: Array.from({length: PLANT_COUNT}, () => ({ sensor_a: true, sensor_b: true, })), }; controller.detectSensors(detection); } this.rebootBtn = document.getElementById("reboot") as HTMLButtonElement this.rebootBtn.onclick = () => { controller.reboot(); } this.exitBtn = document.getElementById("exit") as HTMLButtonElement this.exitBtn.onclick = () => { controller.exit(); } this.can_power = document.getElementById("can_power") as HTMLInputElement this.can_power.onchange = () => { controller.setCanPower(this.can_power.checked); } this.auto_refresh_moisture_sensors = document.getElementById("auto_refresh_moisture_sensors") as HTMLInputElement this.auto_refresh_moisture_sensors.onchange = () => { if (this.auto_refresh_timer) { clearTimeout(this.auto_refresh_timer) } if (this.auto_refresh_moisture_sensors.checked) { this.autoRefreshLoop() } } } private async autoRefreshLoop() { if (!this.auto_refresh_moisture_sensors.checked) { return; } try { await this.measure_moisture(true); const detection: Detection = { plant: Array.from({length: PLANT_COUNT}, () => ({ sensor_a: true, sensor_b: true, })), }; await this.detectSensors(detection, true); } catch (e) { console.error("Auto-refresh error", e); } if (this.auto_refresh_moisture_sensors.checked) { this.auto_refresh_timer = setTimeout(() => this.autoRefreshLoop(), 1000); } } } const controller = new Controller(); controller.progressview.removeProgress("rebooting"); const tasks = [ {task: controller.populateTimezones, displayString: "Populating Timezones"}, {task: controller.updateRTCData, displayString: "Updating RTC Data"}, {task: controller.updateBatteryData, displayString: "Updating Battery Data"}, {task: controller.updateSolarData, displayString: "Updating Solar Data"}, {task: controller.downloadConfig, displayString: "Downloading Configuration"}, {task: controller.version, displayString: "Fetching Version Information"}, {task: controller.updateFileList, displayString: "Updating File List"}, {task: controller.getBackupInfo, displayString: "Fetching Backup Information"}, {task: controller.loadLogLocaleConfig, displayString: "Loading Log Localization Config"}, {task: controller.loadTankInfo, displayString: "Loading Tank Information"}, ]; async function executeTasksSequentially() { let current = 0; for (const {task, displayString} of tasks) { current++; let ratio = current / tasks.length; controller.progressview.addProgress("initial", ratio * 100, displayString); try { await task(); } catch (error) { console.error(`Error executing task '${displayString}':`, error); // Optionally, you can decide whether to continue or break on errors break; } } } executeTasksSequentially().then(r => { controller.progressview.removeProgress("initial") }); controller.progressview.removeProgress("rebooting"); window.addEventListener("beforeunload", (event) => { const currentConfig = controller.getConfig(); // Check if the current state differs from the initial configuration if (!deepEqual(currentConfig, controller.initialConfig)) { const confirmationMessage = "You have unsaved changes. Are you sure you want to leave this page?"; // Standard behavior for displaying the confirmation dialog event.preventDefault(); event.returnValue = confirmationMessage; // This will trigger the browser's default dialog return confirmationMessage; } });