mirror of
https://github.com/Hypfer/Valetudo.git
synced 2025-10-26 11:27:27 +00:00
feat: Updater
This commit is contained in:
parent
c4c31bb5a6
commit
5db2c554dc
4
.github/workflows/manual_build.yml
vendored
4
.github/workflows/manual_build.yml
vendored
@ -107,8 +107,8 @@
|
||||
'uses': 'actions/upload-artifact@v2',
|
||||
'with':
|
||||
{
|
||||
'name': 'manifest.json',
|
||||
'path': './build/manifest.json',
|
||||
'name': 'valetudo_release_manifest.json',
|
||||
'path': './build/valetudo_release_manifest.json',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@ -126,8 +126,8 @@
|
||||
'with':
|
||||
{
|
||||
'upload_url': '${{ github.event.release.upload_url }}',
|
||||
'asset_path': './build/manifest.json',
|
||||
'asset_name': 'manifest.json',
|
||||
'asset_path': './build/valetudo_release_manifest.json',
|
||||
'asset_name': 'valetudo_release_manifest.json',
|
||||
'asset_content_type': 'application/json',
|
||||
},
|
||||
},
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const spawnSync = require("child_process").spawnSync;
|
||||
const uuid = require("uuid");
|
||||
const {generateId} = require("zoo-ids");
|
||||
|
||||
@ -163,6 +164,59 @@ class Tools {
|
||||
static CLONE(obj) {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the total and free size in bytes
|
||||
*
|
||||
* @param {string} path
|
||||
*/
|
||||
static GET_DISK_SPACE_INFO(path) {
|
||||
try {
|
||||
//Inspired by https://github.com/Alex-D/check-disk-space
|
||||
const dfResult = spawnSync("df", ["-Pk", "--", path]);
|
||||
const dfOutput = dfResult.stdout.toString().trim().split("\n").slice(1).map(l => {
|
||||
return l.trim().split(/\s+(?=[\d/])/);
|
||||
});
|
||||
|
||||
if (dfOutput.length !== 1 || dfOutput[0].length !== 6) {
|
||||
return {
|
||||
total: 0,
|
||||
free: 0
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
total: parseInt(dfOutput[0][1], 10) * 1024,
|
||||
free: parseInt(dfOutput[0][3], 10) * 1024,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
total: 0,
|
||||
free: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
static IS_UPX_COMPRESSED(path) {
|
||||
let is_upx = false;
|
||||
|
||||
try {
|
||||
const fd = fs.openSync(path, "r");
|
||||
const buf = Buffer.alloc(256);
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const fstat = fs.fstatSync(fd);
|
||||
|
||||
fs.readSync(fd, buf, {length: 256});
|
||||
fs.closeSync(fd);
|
||||
|
||||
is_upx = buf.toString().includes("UPX");
|
||||
} catch (e) {
|
||||
//intentional
|
||||
}
|
||||
|
||||
return is_upx;
|
||||
}
|
||||
}
|
||||
|
||||
const VALETUDO_NAMESPACE = "be5f1ffc-c150-4785-9ebb-08fcfe90c933";
|
||||
|
||||
@ -12,6 +12,7 @@ const Webserver = require("./webserver/WebServer");
|
||||
|
||||
const NetworkAdvertisementManager = require("./NetworkAdvertisementManager");
|
||||
const Scheduler = require("./scheduler/Scheduler");
|
||||
const Updater = require("./updater/Updater");
|
||||
const ValetudoEventHandlerFactory = require("./valetudo_events/ValetudoEventHandlerFactory");
|
||||
const ValetudoRobotFactory = require("./core/ValetudoRobotFactory");
|
||||
|
||||
@ -62,10 +63,16 @@ class Valetudo {
|
||||
|
||||
this.robot.startup();
|
||||
|
||||
this.updater = new Updater({
|
||||
config: this.config,
|
||||
robot: this.robot
|
||||
});
|
||||
|
||||
this.webserver = new Webserver({
|
||||
config: this.config,
|
||||
robot: this.robot,
|
||||
ntpClient: this.ntpClient,
|
||||
updater: this.updater,
|
||||
valetudoEventStore: this.valetudoEventStore
|
||||
});
|
||||
|
||||
|
||||
@ -56,6 +56,9 @@
|
||||
"networkAdvertisement": {
|
||||
"$ref": "#/components/schemas/NetworkAdvertisementConfigDTO"
|
||||
},
|
||||
"updater": {
|
||||
"$ref": "#/components/schemas/UpdaterConfigDTO"
|
||||
},
|
||||
"timers": {
|
||||
"type": "object"
|
||||
},
|
||||
@ -306,6 +309,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpdaterConfigDTO": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"enabled"
|
||||
],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"updateProvider": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"github"
|
||||
]
|
||||
},
|
||||
"implementationSpecificConfig": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ValetudoTimer": {
|
||||
"type": "object",
|
||||
"description": "Everything time-related is in UTC",
|
||||
|
||||
@ -0,0 +1,24 @@
|
||||
const ValetudoUpdaterState = require("./ValetudoUpdaterState");
|
||||
|
||||
class ValetudoUpdaterApplyPendingState extends ValetudoUpdaterState {
|
||||
/**
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {object} [options.metaData]
|
||||
*
|
||||
* @param {string} options.version The version (e.g. 2021.10.0)
|
||||
* @param {Date} options.releaseTimestamp The release date as found in the manifest
|
||||
* @param {string} options.downloadPath The path the new binary was downloaded to
|
||||
*
|
||||
* @class
|
||||
*/
|
||||
constructor(options) {
|
||||
super(options);
|
||||
|
||||
this.version = options.version;
|
||||
this.releaseTimestamp = options.releaseTimestamp;
|
||||
this.downloadPath = options.downloadPath;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ValetudoUpdaterApplyPendingState;
|
||||
@ -0,0 +1,30 @@
|
||||
const ValetudoUpdaterState = require("./ValetudoUpdaterState");
|
||||
|
||||
class ValetudoUpdaterApprovalPendingState extends ValetudoUpdaterState {
|
||||
/**
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {object} [options.metaData]
|
||||
*
|
||||
* @param {string} options.version The version to be installed (e.g. 2021.10.0)
|
||||
* @param {Date} options.releaseTimestamp The release date as found in the manifest
|
||||
* @param {string} options.changelog The changelog as provided by the github api. Github flavoured Markdown
|
||||
* @param {string} options.downloadUrl The url from which the binary will be downloaded from
|
||||
* @param {string} options.expectedHash The expected sha256sum of the downloaded binary
|
||||
* @param {string} options.downloadPath The path the new binary is downloaded to
|
||||
*
|
||||
* @class
|
||||
*/
|
||||
constructor(options) {
|
||||
super(options);
|
||||
|
||||
this.version = options.version;
|
||||
this.releaseTimestamp = options.releaseTimestamp;
|
||||
this.changelog = options.changelog;
|
||||
this.downloadUrl = options.downloadUrl;
|
||||
this.expectedHash = options.expectedHash;
|
||||
this.downloadPath = options.downloadPath;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ValetudoUpdaterApprovalPendingState;
|
||||
@ -0,0 +1,15 @@
|
||||
const ValetudoUpdaterState = require("./ValetudoUpdaterState");
|
||||
|
||||
class ValetudoUpdaterDisabledState extends ValetudoUpdaterState {
|
||||
/**
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {object} [options.metaData]
|
||||
* @class
|
||||
*/
|
||||
constructor(options) {
|
||||
super(options);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ValetudoUpdaterDisabledState;
|
||||
@ -0,0 +1,30 @@
|
||||
const ValetudoUpdaterState = require("./ValetudoUpdaterState");
|
||||
|
||||
//TODO: Further iterations could provide a download percentage
|
||||
|
||||
class ValetudoUpdaterDownloadingState extends ValetudoUpdaterState {
|
||||
/**
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {object} [options.metaData]
|
||||
*
|
||||
* @param {string} options.version The version (e.g. 2021.10.0)
|
||||
* @param {Date} options.releaseTimestamp The release date as found in the manifest
|
||||
* @param {string} options.downloadUrl The url from which the binary will be downloaded from
|
||||
* @param {string} options.expectedHash The expected sha256sum of the downloaded binary
|
||||
* @param {string} options.downloadPath The path the new binary is downloaded to
|
||||
*
|
||||
* @class
|
||||
*/
|
||||
constructor(options) {
|
||||
super(options);
|
||||
|
||||
this.version = options.version;
|
||||
this.releaseTimestamp = options.releaseTimestamp;
|
||||
this.downloadUrl = options.downloadUrl;
|
||||
this.expectedHash = options.expectedHash;
|
||||
this.downloadPath = options.downloadPath;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ValetudoUpdaterDownloadingState;
|
||||
@ -0,0 +1,38 @@
|
||||
const ValetudoUpdaterState = require("./ValetudoUpdaterState");
|
||||
|
||||
class ValetudoUpdaterErrorState extends ValetudoUpdaterState {
|
||||
/**
|
||||
* The update process aborted with type, message at timestamp
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {ValetudoUpdaterErrorType} options.type
|
||||
* @param {string} options.message
|
||||
* @param {object} [options.metaData]
|
||||
* @class
|
||||
*/
|
||||
constructor(options) {
|
||||
super(options);
|
||||
|
||||
this.type = options.type;
|
||||
this.message = options.message;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {string} ValetudoUpdaterErrorType
|
||||
* @enum {string}
|
||||
*
|
||||
*/
|
||||
ValetudoUpdaterErrorState.ERROR_TYPE = Object.freeze({
|
||||
UNKNOWN: "unknown",
|
||||
NOT_EMBEDDED: "not_embedded",
|
||||
NOT_DOCKED: "not_docked",
|
||||
NOT_WRITABLE: "not_writable",
|
||||
NOT_ENOUGH_SPACE: "not_enough_space",
|
||||
DOWNLOAD_FAILED: "download_failed",
|
||||
NO_RELEASE: "no_release",
|
||||
NO_MATCHING_BINARY: "no_matching_binary",
|
||||
INVALID_CHECKSUM: "invalid_checksum",
|
||||
});
|
||||
|
||||
module.exports = ValetudoUpdaterErrorState;
|
||||
@ -0,0 +1,19 @@
|
||||
const ValetudoUpdaterState = require("./ValetudoUpdaterState");
|
||||
|
||||
class ValetudoUpdaterIdleState extends ValetudoUpdaterState {
|
||||
/**
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {object} [options.metaData]
|
||||
*
|
||||
* @param {string} options.currentVersion The currently running valetudo version
|
||||
* @class
|
||||
*/
|
||||
constructor(options) {
|
||||
super(options);
|
||||
|
||||
this.currentVersion = options.currentVersion;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ValetudoUpdaterIdleState;
|
||||
19
backend/lib/entities/core/updater/ValetudoUpdaterState.js
Normal file
19
backend/lib/entities/core/updater/ValetudoUpdaterState.js
Normal file
@ -0,0 +1,19 @@
|
||||
const SerializableEntity = require("../../SerializableEntity");
|
||||
|
||||
|
||||
class ValetudoUpdaterState extends SerializableEntity {
|
||||
/**
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {object} [options.metaData]
|
||||
* @class
|
||||
* @abstract
|
||||
*/
|
||||
constructor(options) {
|
||||
super(options);
|
||||
|
||||
this.timestamp = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ValetudoUpdaterState;
|
||||
@ -0,0 +1,31 @@
|
||||
{
|
||||
"components": {
|
||||
"schemas": {
|
||||
"ValetudoUpdaterApplyPendingState": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ValetudoUpdaterState"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "The valetudo version to be installed"
|
||||
},
|
||||
"releaseTimestamp": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "The release date as found in the manifest"
|
||||
},
|
||||
"downloadPath": {
|
||||
"type": "string",
|
||||
"description": "The path the new binary was downloaded to"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
{
|
||||
"components": {
|
||||
"schemas": {
|
||||
"ValetudoUpdaterApprovalPendingState": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ValetudoUpdaterState"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "The valetudo version to be installed"
|
||||
},
|
||||
"releaseTimestamp": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "The release date as found in the manifest"
|
||||
},
|
||||
"changelog": {
|
||||
"type": "string",
|
||||
"description": "The changelog as provided by the github api. Github flavoured Markdown"
|
||||
},
|
||||
"downloadUrl": {
|
||||
"type": "string",
|
||||
"description": "The url from which the binary will be downloaded from"
|
||||
},
|
||||
"expectedHash": {
|
||||
"type": "string",
|
||||
"description": "The expected sha256sum of the downloaded binary"
|
||||
},
|
||||
"downloadPath": {
|
||||
"type": "string",
|
||||
"description": "The path the new binary is downloaded to"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
{
|
||||
"components": {
|
||||
"schemas": {
|
||||
"ValetudoUpdaterDisabledState": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ValetudoUpdaterState"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
{
|
||||
"components": {
|
||||
"schemas": {
|
||||
"ValetudoUpdaterDownloadingState": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ValetudoUpdaterState"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "The valetudo version to be installed"
|
||||
},
|
||||
"releaseTimestamp": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "The release date as found in the manifest"
|
||||
},
|
||||
"downloadUrl": {
|
||||
"type": "string",
|
||||
"description": "The url from which the binary will be downloaded from"
|
||||
},
|
||||
"expectedHash": {
|
||||
"type": "string",
|
||||
"description": "The expected sha256sum of the downloaded binary"
|
||||
},
|
||||
"downloadPath": {
|
||||
"type": "string",
|
||||
"description": "The path the new binary is downloaded to"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
{
|
||||
"components": {
|
||||
"schemas": {
|
||||
"ValetudoUpdaterErrorState": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ValetudoUpdaterState"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"unknown",
|
||||
"not_embedded",
|
||||
"not_docked",
|
||||
"not_writable",
|
||||
"not_enough_space",
|
||||
"download_failed",
|
||||
"no_matching_binary",
|
||||
"missing_manifest",
|
||||
"invalid_manifest",
|
||||
"invalid_checksum"
|
||||
]
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
{
|
||||
"components": {
|
||||
"schemas": {
|
||||
"ValetudoUpdaterIdleState": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ValetudoUpdaterState"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"currentVersion": {
|
||||
"type": "string",
|
||||
"description": "The currently running valetudo version"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
{
|
||||
"components": {
|
||||
"schemas": {
|
||||
"ValetudoUpdaterState": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SerializableEntity"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"timestamp": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "The time this state was entered"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
9
backend/lib/entities/core/updater/index.js
Normal file
9
backend/lib/entities/core/updater/index.js
Normal file
@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
ValetudoUpdaterApplyPendingState: require("./ValetudoUpdaterApplyPendingState"),
|
||||
ValetudoUpdaterApprovalPendingState: require("./ValetudoUpdaterApprovalPendingState"),
|
||||
ValetudoUpdaterDisabledState: require("./ValetudoUpdaterDisabledState"),
|
||||
ValetudoUpdaterDownloadingState: require("./ValetudoUpdaterDownloadingState"),
|
||||
ValetudoUpdaterErrorState: require("./ValetudoUpdaterErrorState"),
|
||||
ValetudoUpdaterIdleState: require("./ValetudoUpdaterIdleState"),
|
||||
ValetudoUpdaterState: require("./ValetudoUpdaterState")
|
||||
};
|
||||
@ -75,5 +75,12 @@
|
||||
},
|
||||
"networkAdvertisement": {
|
||||
"enabled": true
|
||||
},
|
||||
"updater": {
|
||||
"enabled": true,
|
||||
"updateProvider": {
|
||||
"type": "github",
|
||||
"implementationSpecificConfig": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
374
backend/lib/updater/Updater.js
Normal file
374
backend/lib/updater/Updater.js
Normal file
@ -0,0 +1,374 @@
|
||||
// @ts-nocheck Required due to too many annoyances
|
||||
|
||||
const crypto = require("crypto");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path/posix");
|
||||
const spawnSync = require("child_process").spawnSync;
|
||||
const {pipeline} = require("stream/promises");
|
||||
|
||||
|
||||
const axios = require("axios").default;
|
||||
const uuid = require("uuid");
|
||||
|
||||
const GithubValetudoUpdateProvider = require("./update_provider/GithubValetudoUpdateProvider");
|
||||
const Logger = require("../Logger");
|
||||
const stateAttrs = require("../entities/state/attributes");
|
||||
const States = require("../entities/core/updater");
|
||||
const Tools = require("../Tools");
|
||||
|
||||
|
||||
|
||||
class Updater {
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {import("../Configuration")} options.config
|
||||
* @param {import("../core/ValetudoRobot")} options.robot
|
||||
*/
|
||||
constructor(options) {
|
||||
this.config = options.config;
|
||||
this.robot = options.robot;
|
||||
|
||||
this.updaterConfig = this.config.get("updater");
|
||||
|
||||
this.state = undefined;
|
||||
this.updateProvider = undefined;
|
||||
|
||||
if (this.updaterConfig.enabled === true) {
|
||||
this.state = new States.ValetudoUpdaterIdleState({
|
||||
currentVersion: Tools.GET_VALETUDO_VERSION()
|
||||
});
|
||||
} else {
|
||||
this.state = new States.ValetudoUpdaterDisabledState({});
|
||||
}
|
||||
|
||||
|
||||
switch (this.updaterConfig.updateProvider.type) {
|
||||
case "github":
|
||||
this.updateProvider = new GithubValetudoUpdateProvider();
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Invalid UpdateProvider ${this.updaterConfig.updateProvider.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* As everything regarding networking might take a long time, we just accept this request
|
||||
* and then asynchronously process it.
|
||||
* Updates are reported via the updaters state
|
||||
*
|
||||
* @return {boolean}
|
||||
*/
|
||||
triggerStart() {
|
||||
if (!(this.state instanceof States.ValetudoUpdaterIdleState || this.state instanceof States.ValetudoUpdaterErrorState)) {
|
||||
throw new Error("Updates can only be started when the updaters state is idle or error");
|
||||
}
|
||||
|
||||
|
||||
this.start().catch(err => {
|
||||
//This should never happen
|
||||
Logger.error("Unexpected error during startUpdate", err);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
triggerDownload() {
|
||||
if (!(this.state instanceof States.ValetudoUpdaterApprovalPendingState)) {
|
||||
throw new Error("Downloads can only be started when there's pending approval");
|
||||
}
|
||||
|
||||
this.download().catch(err => {
|
||||
//This should never happen
|
||||
Logger.error("Unexpected error during download", err);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
triggerApply() {
|
||||
if (!(this.state instanceof States.ValetudoUpdaterApplyPendingState)) {
|
||||
throw new Error("Can only apply if there's finalization pending");
|
||||
}
|
||||
|
||||
this.apply().catch(err => {
|
||||
//This should never happen
|
||||
Logger.error("Unexpected error during apply", err);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async apply() {
|
||||
const applyPendingState = this.state;
|
||||
|
||||
fs.unlinkSync(process.argv0);
|
||||
fs.copyFileSync(applyPendingState.downloadPath, process.argv0);
|
||||
fs.chmodSync(process.argv0, fs.constants.S_IXUSR | fs.constants.S_IXGRP | fs.constants.S_IXOTH);
|
||||
fs.unlinkSync(applyPendingState.downloadPath);
|
||||
|
||||
spawnSync("sync");
|
||||
spawnSync("reboot");
|
||||
}
|
||||
|
||||
async download() {
|
||||
const approvalState = this.state;
|
||||
const downloadingState = new States.ValetudoUpdaterDownloadingState({
|
||||
version: approvalState.version,
|
||||
releaseTimestamp: approvalState.releaseTimestamp,
|
||||
downloadUrl: approvalState.downloadUrl,
|
||||
expectedHash: approvalState.expectedHash,
|
||||
downloadPath: approvalState.downloadPath
|
||||
});
|
||||
|
||||
this.state = downloadingState;
|
||||
|
||||
try {
|
||||
const downloadResponse = await axios.get(downloadingState.downloadUrl, {responseType: "stream"});
|
||||
await pipeline(
|
||||
downloadResponse.data,
|
||||
fs.createWriteStream(downloadingState.downloadPath)
|
||||
);
|
||||
} catch (e) {
|
||||
Logger.error("Error while downloading release binary", e);
|
||||
|
||||
this.state = new States.ValetudoUpdaterErrorState({
|
||||
type: States.ValetudoUpdaterErrorState.ERROR_TYPE.DOWNLOAD_FAILED,
|
||||
message: "Error while downloading release binary"
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let checksum;
|
||||
|
||||
try {
|
||||
checksum = await new Promise((resolve, reject) => {
|
||||
const hash = crypto.createHash("sha256");
|
||||
const readStream = fs.createReadStream(downloadingState.downloadPath);
|
||||
|
||||
readStream.on("error", err => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
readStream.on("data", data => {
|
||||
hash.update(data);
|
||||
});
|
||||
|
||||
readStream.on("end", () => {
|
||||
resolve(hash.digest("hex"));
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
Logger.error("Error while calculating downloaded binary checksum", e);
|
||||
|
||||
this.state = new States.ValetudoUpdaterErrorState({
|
||||
type: States.ValetudoUpdaterErrorState.ERROR_TYPE.UNKNOWN,
|
||||
message: "Error while calculating downloaded binary checksum"
|
||||
});
|
||||
}
|
||||
|
||||
if (checksum !== downloadingState.expectedHash) {
|
||||
this.state = new States.ValetudoUpdaterErrorState({
|
||||
type: States.ValetudoUpdaterErrorState.ERROR_TYPE.INVALID_CHECKSUM,
|
||||
message: `Expected Checksum: ${downloadingState.expectedHash}. Actual: ${checksum}`
|
||||
});
|
||||
} else {
|
||||
this.state = new States.ValetudoUpdaterApplyPendingState({
|
||||
version: downloadingState.version,
|
||||
releaseTimestamp: downloadingState.releaseTimestamp,
|
||||
downloadPath: downloadingState.downloadPath
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
Logger.warn("Updater: User confirmation timeout.");
|
||||
fs.unlinkSync(downloadingState.downloadPath);
|
||||
|
||||
this.state = new States.ValetudoUpdaterIdleState({
|
||||
currentVersion: Tools.GET_VALETUDO_VERSION()
|
||||
});
|
||||
|
||||
}, 10 * 60 * 1000);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
async start() {
|
||||
const lowmemRequired = os.totalmem() < Updater.LOWMEM_THRESHOLD;
|
||||
const archRequired = Updater.ARCHITECTURES[process.arch];
|
||||
let binaryRequired = `valetudo-${archRequired}${lowmemRequired ? "-lowmem" : ""}${Tools.IS_UPX_COMPRESSED(process.argv0) ? ".upx" : ""}`;
|
||||
|
||||
if (this.config.get("embedded") !== true) {
|
||||
this.state = new States.ValetudoUpdaterErrorState({
|
||||
type: States.ValetudoUpdaterErrorState.ERROR_TYPE.NOT_EMBEDDED,
|
||||
message: "Updating is only possible in embedded mode"
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.robot.pollState();
|
||||
} catch (e) {
|
||||
this.state = new States.ValetudoUpdaterErrorState({
|
||||
type: States.ValetudoUpdaterErrorState.ERROR_TYPE.UNKNOWN,
|
||||
message: "Error while polling the robots state"
|
||||
});
|
||||
}
|
||||
|
||||
const statusAttribute = this.robot.state.getFirstMatchingAttributeByConstructor(
|
||||
stateAttrs.StatusStateAttribute
|
||||
);
|
||||
|
||||
if (!(statusAttribute && statusAttribute.value === stateAttrs.StatusStateAttribute.VALUE.DOCKED)) {
|
||||
this.state = new States.ValetudoUpdaterErrorState({
|
||||
type: States.ValetudoUpdaterErrorState.ERROR_TYPE.NOT_DOCKED,
|
||||
message: "Updating is only possible while the robot is docked"
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
const downloadPath = this.getDownloadPath();
|
||||
|
||||
if (!downloadPath) { //This works because getDownloadPath already set the correct current state
|
||||
return;
|
||||
}
|
||||
|
||||
let releases;
|
||||
try {
|
||||
releases = await this.updateProvider.fetchReleases();
|
||||
} catch (e) {
|
||||
Logger.error("Error while fetching releases", e);
|
||||
|
||||
this.state = new States.ValetudoUpdaterErrorState({
|
||||
type: States.ValetudoUpdaterErrorState.ERROR_TYPE.DOWNLOAD_FAILED,
|
||||
message: "Error while fetching releases"
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const releaseVersions = releases.map(r => {
|
||||
return r.version;
|
||||
});
|
||||
const currentVersionIndex = releaseVersions.indexOf(Tools.GET_VALETUDO_VERSION());
|
||||
let releaseToDownload;
|
||||
|
||||
/*
|
||||
Always try to pick the next chronological release if possible
|
||||
*/
|
||||
if (currentVersionIndex > 0) {
|
||||
releaseToDownload = releases[currentVersionIndex-1];
|
||||
} else {
|
||||
releaseToDownload = releases[0];
|
||||
}
|
||||
|
||||
if (!releaseToDownload) {
|
||||
this.state = new States.ValetudoUpdaterErrorState({
|
||||
type: States.ValetudoUpdaterErrorState.ERROR_TYPE.NO_RELEASE,
|
||||
message: "No release found"
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let releaseBinaries;
|
||||
try {
|
||||
releaseBinaries = await this.updateProvider.fetchBinariesForRelease(releaseToDownload);
|
||||
} catch (e) {
|
||||
Logger.error(`Error while fetching release binaries for ${releaseToDownload.version}`, e);
|
||||
|
||||
this.state = new States.ValetudoUpdaterErrorState({
|
||||
type: States.ValetudoUpdaterErrorState.ERROR_TYPE.DOWNLOAD_FAILED,
|
||||
message: `Error while fetching release binaries for ${releaseToDownload.version}`
|
||||
});
|
||||
}
|
||||
|
||||
const binaryToUse = releaseBinaries.find(b => {
|
||||
return b.name === binaryRequired;
|
||||
});
|
||||
|
||||
if (!binaryToUse) {
|
||||
this.state = new States.ValetudoUpdaterErrorState({
|
||||
type: States.ValetudoUpdaterErrorState.ERROR_TYPE.NO_MATCHING_BINARY,
|
||||
message: `Release ${releaseToDownload.version} doesn't feature a ${binaryRequired} binary.`
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.state = new States.ValetudoUpdaterApprovalPendingState({
|
||||
version: releaseToDownload.version,
|
||||
releaseTimestamp: releaseToDownload.releaseTimestamp,
|
||||
changelog: releaseToDownload.changelog,
|
||||
downloadUrl: binaryToUse.downloadUrl,
|
||||
expectedHash: binaryToUse.sha256sum,
|
||||
downloadPath: path.join(downloadPath, uuid.v4())
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @return {string|null}
|
||||
*/
|
||||
getDownloadPath() {
|
||||
let downloadLocation = os.tmpdir();
|
||||
let space;
|
||||
|
||||
try {
|
||||
fs.accessSync(process.argv0, fs.constants.W_OK);
|
||||
} catch (e) {
|
||||
this.state = new States.ValetudoUpdaterErrorState({
|
||||
type: States.ValetudoUpdaterErrorState.ERROR_TYPE.NOT_WRITABLE,
|
||||
message: `Updating is impossible because binary location "${process.argv0}" is not writable.`
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
space = Tools.GET_DISK_SPACE_INFO(downloadLocation);
|
||||
|
||||
if (space.free < Updater.SPACE_REQUIREMENTS) {
|
||||
downloadLocation = "/dev/shm";
|
||||
|
||||
if (space.free < Updater.SPACE_REQUIREMENTS) {
|
||||
this.state = new States.ValetudoUpdaterErrorState({
|
||||
type: States.ValetudoUpdaterErrorState.ERROR_TYPE.NOT_ENOUGH_SPACE,
|
||||
message: `Updating is impossible because ${downloadLocation} only has ${space.free} bytes of free space. Required: ${Updater.SPACE_REQUIREMENTS} bytes.`
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
fs.accessSync(downloadLocation, fs.constants.W_OK);
|
||||
} catch (e) {
|
||||
this.state = new States.ValetudoUpdaterErrorState({
|
||||
type: States.ValetudoUpdaterErrorState.ERROR_TYPE.NOT_WRITABLE,
|
||||
message: `Updating is impossible because download location "${downloadLocation}" is not writable.`
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return downloadLocation;
|
||||
}
|
||||
}
|
||||
|
||||
Updater.SPACE_REQUIREMENTS = 40 * 1024 * 1024;
|
||||
Updater.LOWMEM_THRESHOLD = 300 * 1024 * 1024;
|
||||
Updater.ARCHITECTURES = {
|
||||
"arm": "armv7",
|
||||
"arm64": "aarch64"
|
||||
};
|
||||
|
||||
module.exports = Updater;
|
||||
@ -0,0 +1,96 @@
|
||||
const ValetudoRelease = require("./ValetudoRelease");
|
||||
const ValetudoReleaseBinary = require("./ValetudoReleaseBinary");
|
||||
const ValetudoUpdateProvider = require("./ValetudoUpdateProvider");
|
||||
const {default: axios} = require("axios");
|
||||
|
||||
class GithubValetudoUpdateProvider extends ValetudoUpdateProvider {
|
||||
|
||||
/**
|
||||
* @return {Promise<Array<import("./ValetudoRelease")>>}
|
||||
*/
|
||||
async fetchReleases() {
|
||||
let rawReleasesResponse = await axios.get(GithubValetudoUpdateProvider.RELEASES_URL);
|
||||
let releases = [];
|
||||
|
||||
if (!Array.isArray(rawReleasesResponse?.data)) {
|
||||
throw new Error("GithubValetudoUpdateProvider: Received invalid releases response");
|
||||
}
|
||||
|
||||
releases = rawReleasesResponse.data.map(rR => {
|
||||
return new ValetudoRelease({
|
||||
version: rR.tag_name,
|
||||
releaseTimestamp: new Date(rR.published_at),
|
||||
changelog: rR.body,
|
||||
metaData: {
|
||||
githubReleaseUrl: rR.url
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
releases.sort((a, b) => {
|
||||
return b.releaseTimestamp.getTime() - a.releaseTimestamp.getTime();
|
||||
});
|
||||
|
||||
return releases;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("./ValetudoRelease")} release
|
||||
* @return {Promise<Array<import("./ValetudoReleaseBinary")>>}
|
||||
*/
|
||||
async fetchBinariesForRelease(release) {
|
||||
if (!release.metaData.githubReleaseUrl) {
|
||||
throw new Error("Missing Github Release URL in Release Metadata");
|
||||
}
|
||||
|
||||
let rawReleaseResponse = await axios.get(release.metaData.githubReleaseUrl);
|
||||
let releaseBinaries = [];
|
||||
|
||||
|
||||
|
||||
|
||||
// @ts-ignore
|
||||
if (!Array.isArray(rawReleaseResponse?.data?.assets)) {
|
||||
throw new Error("GithubValetudoUpdateProvider: Received invalid release response");
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
let manifestAsset = rawReleaseResponse.data.assets.find(a => {
|
||||
return a.name === GithubValetudoUpdateProvider.MANIFEST_NAME;
|
||||
});
|
||||
|
||||
if (!manifestAsset) {
|
||||
throw new Error(`GithubValetudoUpdateProvider: Missing ${GithubValetudoUpdateProvider.MANIFEST_NAME}`);
|
||||
}
|
||||
|
||||
let rawManifestResponse = await axios.get(manifestAsset.browser_download_url);
|
||||
|
||||
// @ts-ignore
|
||||
if (!rawManifestResponse.data || rawManifestResponse.data.version !== release.version) {
|
||||
throw new Error(`GithubValetudoUpdateProvider: Invalid ${GithubValetudoUpdateProvider.MANIFEST_NAME}`);
|
||||
}
|
||||
|
||||
const manifest = rawManifestResponse.data;
|
||||
|
||||
// @ts-ignore
|
||||
releaseBinaries = rawReleaseResponse.data.assets.filter(a => {
|
||||
return a.name !== GithubValetudoUpdateProvider.MANIFEST_NAME;
|
||||
}).map(a => {
|
||||
return new ValetudoReleaseBinary({
|
||||
name: a.name,
|
||||
// @ts-ignore
|
||||
sha256sum: manifest.sha256sums[a.name] ?? "", //This will cause any install to fail but at least it's somewhat valid
|
||||
downloadUrl: a.browser_download_url
|
||||
});
|
||||
});
|
||||
|
||||
return releaseBinaries;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
GithubValetudoUpdateProvider.RELEASES_URL = "https://api.github.com/repos/Hypfer/Valetudo/releases";
|
||||
GithubValetudoUpdateProvider.MANIFEST_NAME = "valetudo_release_manifest.json";
|
||||
|
||||
|
||||
module.exports = GithubValetudoUpdateProvider;
|
||||
21
backend/lib/updater/update_provider/ValetudoRelease.js
Normal file
21
backend/lib/updater/update_provider/ValetudoRelease.js
Normal file
@ -0,0 +1,21 @@
|
||||
|
||||
class ValetudoRelease {
|
||||
/**
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {string} options.version
|
||||
* @param {Date} options.releaseTimestamp
|
||||
* @param {string} options.changelog Github Flavoured Markdown
|
||||
*
|
||||
* @param {object} [options.metaData]
|
||||
*/
|
||||
constructor(options) {
|
||||
this.version = options.version;
|
||||
this.releaseTimestamp = options.releaseTimestamp;
|
||||
this.changelog = options.changelog;
|
||||
|
||||
this.metaData = options.metaData ?? {};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ValetudoRelease;
|
||||
17
backend/lib/updater/update_provider/ValetudoReleaseBinary.js
Normal file
17
backend/lib/updater/update_provider/ValetudoReleaseBinary.js
Normal file
@ -0,0 +1,17 @@
|
||||
|
||||
class ValetudoReleaseBinary {
|
||||
/**
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {string} options.name
|
||||
* @param {string} options.sha256sum
|
||||
* @param {string} options.downloadUrl
|
||||
*/
|
||||
constructor(options) {
|
||||
this.name = options.name;
|
||||
this.sha256sum = options.sha256sum;
|
||||
this.downloadUrl = options.downloadUrl;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ValetudoReleaseBinary;
|
||||
@ -0,0 +1,26 @@
|
||||
const NotImplementedError = require("../../core/NotImplementedError");
|
||||
|
||||
class ValetudoUpdateProvider {
|
||||
constructor() {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* @abstract
|
||||
* @return {Promise<Array<import("./ValetudoRelease")>>} These have to be sorted by release date. Element 0 should be the most recent one
|
||||
*/
|
||||
async fetchReleases() {
|
||||
throw new NotImplementedError();
|
||||
}
|
||||
|
||||
/**
|
||||
* @abstract
|
||||
* @param {import("./ValetudoRelease")} release
|
||||
* @return {Promise<Array<import("./ValetudoReleaseBinary")>>}
|
||||
*/
|
||||
async fetchBinariesForRelease(release) {
|
||||
throw new NotImplementedError();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ValetudoUpdateProvider;
|
||||
65
backend/lib/webserver/UpdaterRouter.js
Normal file
65
backend/lib/webserver/UpdaterRouter.js
Normal file
@ -0,0 +1,65 @@
|
||||
const express = require("express");
|
||||
const Logger = require("../Logger");
|
||||
|
||||
class UpdaterRouter {
|
||||
/**
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {import("../updater/Updater")} options.updater
|
||||
* @param {import("../Configuration")} options.config
|
||||
* @param {*} options.validator
|
||||
*/
|
||||
constructor(options) {
|
||||
this.router = express.Router({mergeParams: true});
|
||||
|
||||
this.config = options.config;
|
||||
this.updater = options.updater;
|
||||
this.validator = options.validator;
|
||||
|
||||
this.initRoutes();
|
||||
}
|
||||
|
||||
|
||||
initRoutes() {
|
||||
this.router.get("/state", (req, res) => {
|
||||
res.json(this.updater.state);
|
||||
});
|
||||
|
||||
this.router.put("/", async (req, res) => {
|
||||
if (req.body && req.body.action) {
|
||||
try {
|
||||
switch (req.body.action) {
|
||||
case "start":
|
||||
this.updater.triggerStart();
|
||||
|
||||
break;
|
||||
case "download":
|
||||
this.updater.triggerDownload();
|
||||
break;
|
||||
|
||||
case "apply":
|
||||
this.updater.triggerApply();
|
||||
break;
|
||||
|
||||
default:
|
||||
// noinspection ExceptionCaughtLocallyJS
|
||||
throw new Error("Invalid action");
|
||||
}
|
||||
|
||||
res.sendStatus(200);
|
||||
} catch (e) {
|
||||
Logger.warn("Error while executing action \"" + req.body.action + "\" for Updater", e);
|
||||
res.status(400).json(e.message);
|
||||
}
|
||||
} else {
|
||||
res.status(400).send("Missing action in request body");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getRouter() {
|
||||
return this.router;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = UpdaterRouter;
|
||||
@ -23,6 +23,7 @@ const NTPClientRouter = require("./NTPClientRouter");
|
||||
const SSDPRouter = require("./SSDPRouter");
|
||||
const SystemRouter = require("./SystemRouter");
|
||||
const TimerRouter = require("./TimerRouter");
|
||||
const UpdaterRouter = require("./UpdaterRouter");
|
||||
const ValetudoEventRouter = require("./ValetudoEventRouter");
|
||||
|
||||
class WebServer {
|
||||
@ -30,6 +31,7 @@ class WebServer {
|
||||
* @param {object} options
|
||||
* @param {import("../core/ValetudoRobot")} options.robot
|
||||
* @param {import("../NTPClient")} options.ntpClient
|
||||
* @param {import("../updater/Updater")} options.updater
|
||||
* @param {import("../ValetudoEventStore")} options.valetudoEventStore
|
||||
* @param {import("../Configuration")} options.config
|
||||
*/
|
||||
@ -119,6 +121,8 @@ class WebServer {
|
||||
|
||||
this.app.use("/api/v2/events/", new ValetudoEventRouter({valetudoEventStore: this.valetudoEventStore, validator: this.validator}).getRouter());
|
||||
|
||||
this.app.use("/api/v2/updater/", new UpdaterRouter({config: this.config, updater: options.updater, validator: this.validator}).getRouter());
|
||||
|
||||
this.app.use("/_ssdp/", new SSDPRouter({config: this.config, robot: this.robot}).getRouter());
|
||||
|
||||
this.app.use(express.static(path.join(__dirname, "../../..", "frontend/build")));
|
||||
|
||||
77
backend/lib/webserver/doc/UpdaterRouter.openapi.json
Normal file
77
backend/lib/webserver/doc/UpdaterRouter.openapi.json
Normal file
@ -0,0 +1,77 @@
|
||||
{
|
||||
"/api/v2/updater/state": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Updater"
|
||||
],
|
||||
"summary": "Get Updater state",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The Updaters current state.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ValetudoUpdaterIdleState"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/ValetudoUpdaterErrorState"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/ValetudoUpdaterApprovalPendingState"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/ValetudoUpdaterDownloadingState"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/ValetudoUpdaterApplyPendingState"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/ValetudoUpdaterDisabledState"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v2/updater": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"Updater"
|
||||
],
|
||||
"summary": "Request and execute an update of Valetudo",
|
||||
"description": "Please do keep in mind that this is a potentially dangerous operation.",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"start",
|
||||
"download",
|
||||
"apply"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/200"
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#/components/responses/400"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -35,6 +35,7 @@
|
||||
"dependencies": {
|
||||
"@destinationstransfers/ntp": "^2.0.0",
|
||||
"ajv": "8.6.0",
|
||||
"axios": "0.21.1",
|
||||
"async-mqtt": "^2.6.1",
|
||||
"body-parser": "^1.18.3",
|
||||
"compression": "^1.7.2",
|
||||
@ -65,7 +66,7 @@
|
||||
"@types/express-list-endpoints": "^4.0.1",
|
||||
"@types/mocha": "^7.0.2",
|
||||
"@types/multer": "^1.4.2",
|
||||
"@types/node": "^13.9.1",
|
||||
"@types/node": "^16.11.1",
|
||||
"@types/on-headers": "^1.0.0",
|
||||
"@types/semaphore": "^1.1.1",
|
||||
"@types/jstoxml": "^2.0.1",
|
||||
|
||||
@ -43,6 +43,9 @@
|
||||
"react": "17.0.2",
|
||||
"react-div-100vh": "0.6.0",
|
||||
"react-dom": "17.0.2",
|
||||
"rehype-raw": "6.1.0",
|
||||
"react-markdown": "7.0.1",
|
||||
"remark-gfm": "3.0.0",
|
||||
"react-query": "3.24.4",
|
||||
"react-router-dom": "5.3.0",
|
||||
"react-scripts": "4.0.3",
|
||||
|
||||
@ -34,6 +34,7 @@ import {
|
||||
Timer,
|
||||
TimerInformation,
|
||||
TimerProperties,
|
||||
UpdaterState,
|
||||
ValetudoEvent,
|
||||
ValetudoEventInteractionContext,
|
||||
ValetudoVersion,
|
||||
@ -777,3 +778,23 @@ export const sendCombinedVirtualRestrictionsUpdate = async (
|
||||
parameters
|
||||
);
|
||||
};
|
||||
|
||||
export const fetchUpdaterState = async (): Promise<UpdaterState> => {
|
||||
return valetudoAPI
|
||||
.get<UpdaterState>("/updater/state")
|
||||
.then(({data}) => {
|
||||
return data;
|
||||
});
|
||||
};
|
||||
|
||||
export const sendUpdaterCommand = async (
|
||||
command: "start" | "download" | "apply"
|
||||
): Promise<void> => {
|
||||
await valetudoAPI.put(
|
||||
"/updater",
|
||||
{
|
||||
"action": command
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -39,6 +39,7 @@ import {
|
||||
fetchSystemRuntimeInfo,
|
||||
fetchTimerInformation,
|
||||
fetchTimerProperties,
|
||||
fetchUpdaterState,
|
||||
fetchValetudoEvents,
|
||||
fetchValetudoInformation,
|
||||
fetchValetudoLog,
|
||||
@ -72,6 +73,7 @@ import {
|
||||
sendStartMappingPass,
|
||||
sendTimerCreation,
|
||||
sendTimerUpdate,
|
||||
sendUpdaterCommand,
|
||||
sendValetudoEventInteraction,
|
||||
sendValetudoLogLevel,
|
||||
sendVoicePackManagementCommand,
|
||||
@ -149,10 +151,11 @@ enum CacheKey {
|
||||
Wifi = "wifi",
|
||||
ManualControl = "manual_control",
|
||||
ManualControlProperties = "manual_control_properties",
|
||||
CombinedVirtualRestrictionsProperties = "combined_virtual_restrictions_properties"
|
||||
CombinedVirtualRestrictionsProperties = "combined_virtual_restrictions_properties",
|
||||
UpdaterState = "updater_state"
|
||||
}
|
||||
|
||||
const useOnCommandError = (capability: Capability): ((error: unknown) => void) => {
|
||||
const useOnCommandError = (capability: Capability | string): ((error: unknown) => void) => {
|
||||
const {enqueueSnackbar} = useSnackbar();
|
||||
|
||||
return React.useCallback((error: any) => {
|
||||
@ -993,3 +996,14 @@ export const useCombinedVirtualRestrictionsMutation = (
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const useUpdaterStateQuery = () => {
|
||||
return useQuery(CacheKey.UpdaterState, fetchUpdaterState);
|
||||
};
|
||||
|
||||
export const useUpdaterCommandMutation = () => {
|
||||
const onError = useOnCommandError("Updater");
|
||||
|
||||
return useMutation(sendUpdaterCommand, {onError});
|
||||
};
|
||||
|
||||
|
||||
@ -389,3 +389,17 @@ export interface CombinedVirtualRestrictionsUpdateRequestParameters {
|
||||
export interface CombinedVirtualRestrictionsProperties {
|
||||
supportedRestrictedZoneTypes: Array<ValetudoRestrictedZoneType>
|
||||
}
|
||||
|
||||
export interface UpdaterState {
|
||||
__class: "ValetudoUpdaterIdleState" | "ValetudoUpdaterErrorState" | "ValetudoUpdaterApprovalPendingState" | "ValetudoUpdaterDownloadingState" | "ValetudoUpdaterApplyPendingState" | "ValetudoUpdaterDisabledState";
|
||||
timestamp: string;
|
||||
type?: "unknown" | "not_embedded" | "not_docked" | "not_writable" | "not_enough_space" | "download_failed" | "no_matching_binary" | "missing_manifest" | "invalid_manifest" | "invalid_checksum";
|
||||
message?: string;
|
||||
currentVersion?: string;
|
||||
version?: string;
|
||||
releaseTimestamp?: string;
|
||||
changelog?: string;
|
||||
downloadUrl?: string;
|
||||
expectedHash?: string;
|
||||
downloadPath?: string;
|
||||
}
|
||||
|
||||
@ -27,6 +27,7 @@ import {
|
||||
Menu as MenuIcon,
|
||||
PendingActions as PendingActionsIcon,
|
||||
Power as PowerIcon,
|
||||
SystemUpdateAlt as UpdaterIcon,
|
||||
SettingsRemote as SettingsRemoteIcon,
|
||||
Elderly as OldFrontendIcon,
|
||||
GitHub as GithubIcon,
|
||||
@ -150,6 +151,13 @@ const menuTree: Array<MenuEntry | MenuSubheader> = [
|
||||
title: "Interfaces",
|
||||
menuIcon: PowerIcon,
|
||||
menuText: "Interfaces"
|
||||
},
|
||||
{
|
||||
kind: "MenuEntry",
|
||||
routeMatch: "/settings/updater",
|
||||
title: "Updater",
|
||||
menuIcon: UpdaterIcon,
|
||||
menuText: "Updater"
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import About from "./About";
|
||||
import Timers from "./timers";
|
||||
import Log from "./Log";
|
||||
import Interfaces from "./interfaces";
|
||||
import Updater from "./Updater";
|
||||
|
||||
const SettingsRouter = (): JSX.Element => {
|
||||
const {path} = useRouteMatch();
|
||||
@ -22,6 +23,9 @@ const SettingsRouter = (): JSX.Element => {
|
||||
<Route exact path={path + "/interfaces"}>
|
||||
<Interfaces/>
|
||||
</Route>
|
||||
<Route exact path={path + "/updater"}>
|
||||
<Updater/>
|
||||
</Route>
|
||||
<Route path="*">
|
||||
<h3>Unknown route</h3>
|
||||
</Route>
|
||||
|
||||
5
frontend/src/settings/Updater.module.css
Normal file
5
frontend/src/settings/Updater.module.css
Normal file
@ -0,0 +1,5 @@
|
||||
.reactMarkDown {}
|
||||
|
||||
.reactMarkDown img {
|
||||
max-width: 100%;
|
||||
}
|
||||
299
frontend/src/settings/Updater.tsx
Normal file
299
frontend/src/settings/Updater.tsx
Normal file
@ -0,0 +1,299 @@
|
||||
import {
|
||||
UpdaterState,
|
||||
useUpdaterCommandMutation,
|
||||
useUpdaterStateQuery
|
||||
} from "../api";
|
||||
import {
|
||||
Refresh as RefreshIcon,
|
||||
SystemUpdateAlt as UpdaterIcon,
|
||||
Warning as ErrorIcon,
|
||||
Download as DownloadIcon,
|
||||
PendingActions as ApprovalPendingIcon,
|
||||
Info as IdleIcon,
|
||||
RestartAlt as ApplyPendingIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
UpdateDisabled as UpdaterDisabledIcon
|
||||
} from "@mui/icons-material";
|
||||
import {
|
||||
Accordion, AccordionDetails,
|
||||
AccordionSummary,
|
||||
Box,
|
||||
Container,
|
||||
Divider,
|
||||
Grid,
|
||||
IconButton,
|
||||
Paper,
|
||||
Typography
|
||||
} from "@mui/material";
|
||||
import React, {FunctionComponent} from "react";
|
||||
import LoadingFade from "../components/LoadingFade";
|
||||
import {LoadingButton} from "@mui/lab";
|
||||
import ConfirmationDialog from "../components/ConfirmationDialog";
|
||||
|
||||
import style from "./Updater.module.css";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import gfm from "remark-gfm";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
|
||||
const Updater = (): JSX.Element => {
|
||||
const {
|
||||
data: updaterState,
|
||||
isLoading: updaterStateLoading,
|
||||
isError: updaterStateError,
|
||||
refetch: refetchUpdaterState,
|
||||
} = useUpdaterStateQuery();
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Paper style={{marginBottom: "16px"}}>
|
||||
<Grid container direction="row">
|
||||
<Box px={2} pt={1} style={{width: "100%"}}>
|
||||
<Grid item container alignItems="center" spacing={1} justifyContent="space-between">
|
||||
<Grid item style={{display:"flex"}}>
|
||||
<Grid item style={{paddingRight: "8px"}}><UpdaterIcon/></Grid>
|
||||
<Grid item>
|
||||
<Typography>Updater</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<IconButton onClick={() => {
|
||||
refetchUpdaterState();
|
||||
}}>
|
||||
<RefreshIcon/>
|
||||
</IconButton>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Divider sx={{mt: 1}}/>
|
||||
|
||||
<UpdaterStateComponent
|
||||
state={updaterState}
|
||||
stateLoading={updaterStateLoading}
|
||||
stateError={updaterStateError}
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
const UpdaterStateComponent : React.FunctionComponent<{ state: UpdaterState | undefined, stateLoading: boolean, stateError: boolean }> = ({
|
||||
state,
|
||||
stateLoading,
|
||||
stateError
|
||||
}) => {
|
||||
if (stateLoading || !state) {
|
||||
return (
|
||||
<LoadingFade/>
|
||||
);
|
||||
}
|
||||
|
||||
if (stateError) {
|
||||
return <Typography color="error">Error loading Updater state</Typography>;
|
||||
}
|
||||
|
||||
const getIconForState = () : JSX.Element => {
|
||||
switch (state.__class) {
|
||||
case "ValetudoUpdaterErrorState":
|
||||
return <ErrorIcon sx={{ fontSize: "3rem" }}/>;
|
||||
case "ValetudoUpdaterDownloadingState":
|
||||
return <DownloadIcon sx={{ fontSize: "3rem" }}/>;
|
||||
case "ValetudoUpdaterApprovalPendingState":
|
||||
return <ApprovalPendingIcon sx={{ fontSize: "3rem" }}/>;
|
||||
case "ValetudoUpdaterIdleState":
|
||||
return <IdleIcon sx={{ fontSize: "3rem" }}/>;
|
||||
case "ValetudoUpdaterApplyPendingState":
|
||||
return <ApplyPendingIcon sx={{ fontSize: "3rem" }}/>;
|
||||
case "ValetudoUpdaterDisabledState":
|
||||
return <UpdaterDisabledIcon sx={{ fontSize: "3rem" }}/>;
|
||||
}
|
||||
};
|
||||
|
||||
const getContentForState = () : JSX.Element | undefined => {
|
||||
switch (state.__class) {
|
||||
case "ValetudoUpdaterErrorState":
|
||||
return (
|
||||
<Typography color="red"> {state.message}</Typography>
|
||||
);
|
||||
case "ValetudoUpdaterDownloadingState":
|
||||
return (
|
||||
<>
|
||||
<Typography>Valetudo is currently downloading release {state.version}</Typography>
|
||||
<br/>
|
||||
<Typography>Please be patient...</Typography>
|
||||
</>
|
||||
);
|
||||
case "ValetudoUpdaterApprovalPendingState":
|
||||
return (
|
||||
<Accordion
|
||||
defaultExpanded={true}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon/>}>
|
||||
<Typography>Changelog for Valetudo {state.version}</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box style={{width:"100%", paddingLeft: "16px", paddingRight:"16px"}}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[gfm]}
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
className={style.reactMarkDown}
|
||||
>
|
||||
{state.changelog ? state.changelog: ""}
|
||||
</ReactMarkdown>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
case "ValetudoUpdaterIdleState":
|
||||
return (
|
||||
<Typography>You're current running {state.currentVersion}</Typography>
|
||||
);
|
||||
case "ValetudoUpdaterApplyPendingState":
|
||||
return (
|
||||
<Typography>Successfully downloaded {state.version}</Typography>
|
||||
);
|
||||
case "ValetudoUpdaterDisabledState":
|
||||
return (
|
||||
<Typography>The Updater was disabled in the Valetudo config.</Typography>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid container alignItems="center" direction="column" style={{paddingBottom:"16px"}}>
|
||||
<Grid item style={{marginTop:"8px"}}>
|
||||
{getIconForState()}
|
||||
</Grid>
|
||||
<Grid item>
|
||||
{getContentForState()}
|
||||
</Grid>
|
||||
{
|
||||
state.__class === "ValetudoUpdaterApplyPendingState" &&
|
||||
<Typography color="red" style={{marginTop:"1rem", width: "80%"}}>
|
||||
Please keep in mind that updating can be a dangerous operation.<br/>
|
||||
Make sure that you've thoroughly read the changelog to be aware of possible breaking changes.<br/><br/>
|
||||
Also, during updates, you should always be prepared for some troubleshooting so please do not click apply if you currently don't have time for that.
|
||||
</Typography>
|
||||
}
|
||||
</Grid>
|
||||
<Divider sx={{mt: 1}}/>
|
||||
<UpdaterControls
|
||||
state={state}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const UpdaterControls : React.FunctionComponent<{ state: UpdaterState}> = ({
|
||||
state,
|
||||
}) => {
|
||||
return (
|
||||
<Grid container justifyContent="flex-end" direction="row" style={{paddingTop: "16px", paddingBottom:"16px"}}>
|
||||
<Grid item>
|
||||
{
|
||||
(
|
||||
state.__class === "ValetudoUpdaterIdleState" ||
|
||||
state.__class === "ValetudoUpdaterErrorState"
|
||||
) &&
|
||||
<StartUpdateControls/>
|
||||
}
|
||||
{
|
||||
(
|
||||
state.__class === "ValetudoUpdaterApprovalPendingState"
|
||||
) &&
|
||||
<DownloadUpdateControls/>
|
||||
}
|
||||
{
|
||||
(
|
||||
state.__class === "ValetudoUpdaterApplyPendingState"
|
||||
) &&
|
||||
<ApplyUpdateControls/>
|
||||
}
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
const StartUpdateControls: FunctionComponent = () => {
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||
const {mutate: sendCommand, isLoading: commandExecuting} = useUpdaterCommandMutation();
|
||||
|
||||
const {
|
||||
refetch: refetchUpdaterState,
|
||||
} = useUpdaterStateQuery();
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoadingButton loading={commandExecuting} variant="outlined" onClick={() => {
|
||||
setDialogOpen(true);
|
||||
}} sx={{mt: 1, mb: 1}}>Start Update</LoadingButton>
|
||||
<ConfirmationDialog title="Start Update?"
|
||||
text="Do you want to look for a new version of Valetudo?"
|
||||
open={dialogOpen} onClose={() => {
|
||||
setDialogOpen(false);
|
||||
}} onAccept={() => {
|
||||
sendCommand("start");
|
||||
setTimeout(() => {
|
||||
refetchUpdaterState().then();
|
||||
}, 2000); //TODO: this could be better
|
||||
}}/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const DownloadUpdateControls: FunctionComponent = () => {
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||
const {mutate: sendCommand, isLoading: commandExecuting} = useUpdaterCommandMutation();
|
||||
|
||||
const {
|
||||
refetch: refetchUpdaterState,
|
||||
} = useUpdaterStateQuery();
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoadingButton loading={commandExecuting} variant="outlined" onClick={() => {
|
||||
setDialogOpen(true);
|
||||
}} sx={{mt: 1, mb: 1}}>Download Update</LoadingButton>
|
||||
<ConfirmationDialog title="Download Update?"
|
||||
text="Do you want to download the displayed Valetudo update? Please make sure to fully read the provided changelog as it may contain breaking changes as well as other relevant information."
|
||||
open={dialogOpen} onClose={() => {
|
||||
setDialogOpen(false);
|
||||
}} onAccept={() => {
|
||||
sendCommand("download");
|
||||
setTimeout(() => {
|
||||
refetchUpdaterState().then();
|
||||
}, 100); //TODO: this could be better
|
||||
}}/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ApplyUpdateControls: FunctionComponent = () => {
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||
const {mutate: sendCommand, isLoading: commandExecuting} = useUpdaterCommandMutation();
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoadingButton loading={commandExecuting} variant="outlined" onClick={() => {
|
||||
setDialogOpen(true);
|
||||
}} sx={{mt: 1, mb: 1}}>Apply Update</LoadingButton>
|
||||
<ConfirmationDialog title="Apply Update?"
|
||||
text="Do you want to apply the downloaded update? The robot will reboot during this procedure."
|
||||
open={dialogOpen} onClose={() => {
|
||||
setDialogOpen(false);
|
||||
}} onAccept={() => {
|
||||
sendCommand("apply");
|
||||
}}/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
export default Updater;
|
||||
2427
package-lock.json
generated
2427
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -26,6 +26,7 @@ const options = {
|
||||
{name: "System", description: "System API"},
|
||||
{name: "NTP", description: "NTP Client API"},
|
||||
{name: "Timers", description: "Timers API"},
|
||||
{name: "Updater", description: "Update Valetudo using Valetudo"},
|
||||
|
||||
{name: "BasicControlCapability", description: "Basic control capability"},
|
||||
{name: "FanSpeedControlCapability", description: "Fan speed control capability"},
|
||||
@ -85,6 +86,7 @@ const options = {
|
||||
path.join(__dirname, "./backend/lib/entities/map/doc/*.openapi.json"),
|
||||
path.join(__dirname, "./backend/lib/entities/core/doc/*.openapi.json"),
|
||||
path.join(__dirname, "./backend/lib/entities/core/ntpClient/doc/*.openapi.json"),
|
||||
path.join(__dirname, "./backend/lib/entities/core/updater/doc/*.openapi.json"),
|
||||
path.join(__dirname, "./backend/lib/entities/state/doc/*.openapi.json"),
|
||||
path.join(__dirname, "./backend/lib/entities/state/attributes/doc/*.openapi.json"),
|
||||
path.join(__dirname, "./backend/lib/core/capabilities/doc/*.openapi.json")
|
||||
|
||||
@ -38,4 +38,4 @@ Object.values(binaries).forEach((path, i) => {
|
||||
}
|
||||
})
|
||||
|
||||
fs.writeFileSync("./build/manifest.json", JSON.stringify(manifest, null, 2))
|
||||
fs.writeFileSync("./build/valetudo_release_manifest.json", JSON.stringify(manifest, null, 2))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user