feat: NetworkAdvertisementManager UI control

this closes #1168
This commit is contained in:
Sören Beye 2022-02-14 12:37:09 +01:00
parent dceea16f5a
commit bd5fa9c1d1
22 changed files with 427 additions and 41 deletions

View File

@ -23,24 +23,48 @@ class NetworkAdvertisementManager {
this.networkStateCheckTimeout = undefined;
this.ipAddresses = "";
this.config.onUpdate((key) => {
if (key === "networkAdvertisement") {
this.restart().catch((err) => {
Logger.warn("Error while restarting NetworkAdvertisementManager due to config change", err);
});
}
});
this.setUp();
}
/**
* @public
* @return {{port: number, zeroconfHostname: string}}
*/
getProperties() {
return {
port: this.webserverPort,
zeroconfHostname: Tools.GET_ZEROCONF_HOSTNAME()
};
}
/**
* @private
*/
setUp() {
const networkAdvertisementConfig = this.config.get("networkAdvertisement");
if (networkAdvertisementConfig.enabled === true && this.config.get("embedded") === true) {
this.setUpSSDP();
this.setUpBonjour();
if (this.config.get("embedded") === true) {
if (networkAdvertisementConfig.enabled === true) {
this.setUpSSDP();
this.setUpBonjour();
this.ipAddresses = Tools.GET_CURRENT_HOST_IP_ADDRESSES().sort().join();
this.networkStateCheckTimeout = setTimeout(() => {
this.checkNetworkStateAndReschedule();
}, NETWORK_STATE_CHECK_INTERVAL);
this.ipAddresses = Tools.GET_CURRENT_HOST_IP_ADDRESSES().sort().join();
this.networkStateCheckTimeout = setTimeout(() => {
this.checkNetworkStateAndReschedule();
}, NETWORK_STATE_CHECK_INTERVAL);
}
} else {
Logger.info("Not starting NetworkAdvertisementManager because we're not in embedded mode");
}
}
/**

View File

@ -73,10 +73,16 @@ class Valetudo {
robot: this.robot
});
this.networkAdvertisementManager = new NetworkAdvertisementManager({
config: this.config,
robot: this.robot
});
this.webserver = new Webserver({
config: this.config,
robot: this.robot,
mqttController: this.mqttController,
networkAdvertisementManager: this.networkAdvertisementManager,
ntpClient: this.ntpClient,
updater: this.updater,
valetudoEventStore: this.valetudoEventStore
@ -89,10 +95,6 @@ class Valetudo {
ntpClient: this.ntpClient
});
this.networkAdvertisementManager = new NetworkAdvertisementManager({
config: this.config,
robot: this.robot
});
this.setupDebuggingFeatures();
this.setupMemoryManagement();

View File

@ -31,7 +31,7 @@ class NTPClientRouter {
this.router.put("/config", this.validator, (req, res) => {
this.config.set("ntpClient", req.body);
res.sendStatus(202);
res.sendStatus(200);
});
}

View File

@ -0,0 +1,52 @@
const express = require("express");
class NetworkAdvertisementManagerRouter {
/**
*
* @param {object} options
* @param {import("../NetworkAdvertisementManager")} options.networkAdvertisementManager
* @param {import("../Configuration")} options.config
* @param {*} options.validator
*/
constructor(options) {
this.router = express.Router({mergeParams: true});
this.networkAdvertisementManager = options.networkAdvertisementManager;
this.config = options.config;
this.validator = options.validator;
this.initRoutes();
}
initRoutes() {
this.router.get("/config", (req, res) => {
res.json(this.config.get("networkAdvertisement"));
});
this.router.put("/config", (req, res) => {
if (
req.body && typeof req.body === "object" &&
typeof req.body.enabled === "boolean"
) {
const conf = this.config.get("networkAdvertisement");
this.config.set("networkAdvertisement", Object.assign({}, conf, {enabled: req.body.enabled}));
res.sendStatus(200);
} else {
res.status(400).send("bad request body");
}
});
this.router.get("/properties", (req, res) => {
res.json(this.networkAdvertisementManager.getProperties());
});
}
getRouter() {
return this.router;
}
}
module.exports = NetworkAdvertisementManagerRouter;

View File

@ -83,7 +83,7 @@ class TimerRouter {
storedTimers[newTimer.id] = newTimer;
this.config.set("timers", storedTimers);
res.sendStatus(201);
res.sendStatus(200);
} else {
res.sendStatus(400);
}

View File

@ -62,7 +62,7 @@ class ValetudoRouter {
if (req.body && req.body.level && typeof req.body.level === "string") {
Logger.setLogLevel(req.body.level);
res.sendStatus(202);
res.sendStatus(200);
} else {
res.sendStatus(400);
}
@ -94,7 +94,7 @@ class ValetudoRouter {
this.config.set("mqtt", mqttConfig);
res.sendStatus(202);
res.sendStatus(200);
});
this.router.get("/config/interfaces/http/auth/basic", (req, res) => {
@ -123,7 +123,7 @@ class ValetudoRouter {
webserverConfig.basicAuth = options;
this.config.set("webserver", webserverConfig);
res.sendStatus(201);
res.sendStatus(200);
}
} else {
res.status(400).send("bad request body");

View File

@ -22,6 +22,7 @@ const ValetudoRouter = require("./ValetudoRouter");
const fs = require("fs");
const MiioValetudoRobot = require("../robots/MiioValetudoRobot");
const MQTTRouter = require("./MQTTRouter");
const NetworkAdvertisementManagerRouter = require("./NetworkAdvertisementManagerRouter");
const NTPClientRouter = require("./NTPClientRouter");
const SSDPRouter = require("./SSDPRouter");
const SystemRouter = require("./SystemRouter");
@ -35,6 +36,7 @@ class WebServer {
* @param {object} options
* @param {import("../core/ValetudoRobot")} options.robot
* @param {import("../mqtt/MqttController")} options.mqttController
* @param {import("../NetworkAdvertisementManager")} options.networkAdvertisementManager
* @param {import("../NTPClient")} options.ntpClient
* @param {import("../updater/Updater")} options.updater
* @param {import("../ValetudoEventStore")} options.valetudoEventStore
@ -116,6 +118,8 @@ class WebServer {
this.app.use("/api/v2/mqtt/", new MQTTRouter({config: this.config, mqttController: options.mqttController, validator: this.validator}).getRouter());
this.app.use("/api/v2/networkadvertisement/", new NetworkAdvertisementManagerRouter({config: this.config, networkAdvertisementManager: options.networkAdvertisementManager, validator: this.validator}).getRouter());
this.app.use("/api/v2/ntpclient/", new NTPClientRouter({config: this.config, ntpClient: options.ntpClient, validator: this.validator}).getRouter());
this.app.use("/api/v2/timers/", new TimerRouter({config: this.config, robot: this.robot, validator: this.validator}).getRouter());

View File

@ -92,7 +92,7 @@ class GoToLocationCapabilityRouter extends CapabilityRouter {
goToLocationPresets[newPreset.id] = newPreset;
this.capability.robot.config.set("goToLocationPresets", goToLocationPresets);
res.sendStatus(201);
res.sendStatus(200);
} catch (e) {
Logger.warn("Error while saving new goToLocationPreset", req.body);
res.status(500).json(e.message);
@ -144,7 +144,7 @@ class GoToLocationCapabilityRouter extends CapabilityRouter {
this.capability.robot.config.set("goToLocationPresets", presets);
res.sendStatus(201);
res.sendStatus(200);
} else {
res.sendStatus(400);
}

View File

@ -71,7 +71,7 @@ class ZoneCleaningCapabilityRouter extends CapabilityRouter {
zoneSettings[newPreset.id] = newPreset;
this.capability.robot.config.set("zonePresets", zoneSettings);
res.sendStatus(201);
res.sendStatus(200);
} catch (e) {
Logger.warn("Error while saving new zone", req.body);
res.status(500).json(e.message);
@ -149,7 +149,7 @@ class ZoneCleaningCapabilityRouter extends CapabilityRouter {
this.capability.robot.config.set("zonePresets", zonePresets);
res.sendStatus(201);
res.sendStatus(200);
} else {
res.sendStatus(400);
}

View File

@ -169,8 +169,8 @@
}
},
"responses": {
"201": {
"$ref": "#/components/responses/201"
"200": {
"$ref": "#/components/responses/200"
},
"400": {
"$ref": "#/components/responses/400"

View File

@ -66,8 +66,8 @@
}
},
"responses": {
"202": {
"$ref": "#/components/responses/202"
"200": {
"$ref": "#/components/responses/200"
}
}
}

View File

@ -0,0 +1,71 @@
{
"/api/v2/networkadvertisement/config": {
"get": {
"tags": [
"NetworkAdvertisement"
],
"summary": "Get NetworkAdvertisementManager configuration",
"responses": {
"200": {
"description": "Ok",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NetworkAdvertisementConfigDTO"
}
}
}
}
}
},
"put": {
"tags": [
"NetworkAdvertisement"
],
"summary": "Update NetworkAdvertisementManager configuration",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NetworkAdvertisementConfigDTO"
}
}
}
},
"responses": {
"200": {
"$ref": "#/components/responses/200"
}
}
}
},
"/api/v2/networkadvertisement/properties": {
"get": {
"tags": [
"NetworkAdvertisement"
],
"summary": "Get NetworkAdvertisement properties",
"responses": {
"200": {
"description": "Ok",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": false,
"properties": {
"port": {
"type": "number"
},
"zeroconfHostname": {
"type": "string"
}
}
}
}
}
}
}
}
}
}

View File

@ -99,8 +99,8 @@
}
},
"responses": {
"201": {
"$ref": "#/components/responses/201"
"200": {
"$ref": "#/components/responses/200"
},
"400": {
"$ref": "#/components/responses/400"
@ -192,8 +192,8 @@
}
},
"responses": {
"201": {
"$ref": "#/components/responses/201"
"200": {
"$ref": "#/components/responses/200"
},
"400": {
"$ref": "#/components/responses/400"

View File

@ -152,8 +152,8 @@
},
"description": "Log level retrieved from GET presets",
"responses": {
"202": {
"$ref": "#/components/responses/202"
"200": {
"$ref": "#/components/responses/200"
}
}
}
@ -192,8 +192,8 @@
}
},
"responses": {
"202": {
"$ref": "#/components/responses/202"
"200": {
"$ref": "#/components/responses/200"
}
}
}
@ -232,8 +232,8 @@
}
},
"responses": {
"201": {
"$ref": "#/components/responses/201"
"200": {
"$ref": "#/components/responses/200"
},
"400": {
"$ref": "#/components/responses/400"

View File

@ -21,6 +21,8 @@ import {
MQTTConfiguration,
MQTTProperties,
MQTTStatus,
NetworkAdvertisementConfiguration,
NetworkAdvertisementProperties,
NTPClientConfiguration,
NTPClientState,
Point,
@ -447,7 +449,7 @@ export const sendValetudoLogLevel = async (logLevel: SetLogLevelRequest): Promis
await valetudoAPI
.put("/valetudo/log/level", logLevel)
.then(({ status }) => {
if (status !== 202) {
if (status !== 200) {
throw new Error("Could not set new log level");
}
});
@ -481,7 +483,7 @@ export const sendMQTTConfiguration = async (mqttConfiguration: MQTTConfiguration
return valetudoAPI
.put("/valetudo/config/interfaces/mqtt", mqttConfiguration)
.then(({status}) => {
if (status !== 202) {
if (status !== 200) {
throw new Error("Could not update MQTT configuration");
}
});
@ -515,12 +517,38 @@ export const sendHTTPBasicAuthConfiguration = async (configuration: HTTPBasicAut
return valetudoAPI
.put("/valetudo/config/interfaces/http/auth/basic", configuration)
.then(({status}) => {
if (status !== 201) {
if (status !== 200) {
throw new Error("Could not update HTTP basic auth configuration");
}
});
};
export const fetchNetworkAdvertisementConfiguration = async (): Promise<NetworkAdvertisementConfiguration> => {
return valetudoAPI
.get<NetworkAdvertisementConfiguration>("/networkadvertisement/config")
.then(({data}) => {
return data;
});
};
export const sendNetworkAdvertisementConfiguration = async (configuration: NetworkAdvertisementConfiguration): Promise<void> => {
return valetudoAPI
.put("/networkadvertisement/config", configuration)
.then(({status}) => {
if (status !== 200) {
throw new Error("Could not update NetworkAdvertisement configuration");
}
});
};
export const fetchNetworkAdvertisementProperties = async (): Promise<NetworkAdvertisementProperties> => {
return valetudoAPI
.get<NetworkAdvertisementProperties>("/networkadvertisement/properties")
.then(({data}) => {
return data;
});
};
export const fetchNTPClientState = async (): Promise<NTPClientState> => {
return valetudoAPI
.get<NTPClientState>("/ntpclient/state")
@ -541,7 +569,7 @@ export const sendNTPClientConfiguration = async (configuration: NTPClientConfigu
return valetudoAPI
.put("/ntpclient/config", configuration)
.then(({status}) => {
if (status !== 202) {
if (status !== 200) {
throw new Error("Could not update NTP client configuration");
}
});
@ -559,7 +587,7 @@ export const deleteTimer = async (id: string): Promise<void> => {
export const sendTimerCreation = async (timerData: Timer): Promise<void> => {
await valetudoAPI.post("/timers", timerData).then(({ status }) => {
if (status !== 201) {
if (status !== 200) {
throw new Error("Could not create timer");
}
});

View File

@ -93,6 +93,9 @@ import {
sendSetQuirkValueCommand,
fetchRobotProperties,
fetchMQTTStatus,
fetchNetworkAdvertisementConfiguration,
fetchNetworkAdvertisementProperties,
sendNetworkAdvertisementConfiguration,
} from "./client";
import {
PresetSelectionState,
@ -113,6 +116,7 @@ import {
MapSegmentEditSplitRequestParameters,
MapSegmentRenameRequestParameters,
MQTTConfiguration,
NetworkAdvertisementConfiguration,
NTPClientConfiguration,
NTPClientState,
Point,
@ -148,6 +152,8 @@ enum CacheKey {
MQTTStatus = "mqtt_status",
MQTTProperties = "mqtt_properties",
HTTPBasicAuth = "http_basic_auth",
NetworkAdvertisementConfiguration = "network_advertisement_configuration",
NetworkAdvertisementProperties = "network_advertisement_properties",
NTPClientState = "ntp_client_state",
NTPClientConfiguration = "ntp_client_configuration",
Timers = "timers",
@ -678,6 +684,28 @@ export const useHTTPBasicAuthConfigurationMutation = () => {
);
};
export const useNetworkAdvertisementConfigurationQuery = () => {
return useQuery(CacheKey.NetworkAdvertisementConfiguration, fetchNetworkAdvertisementConfiguration, {
staleTime: Infinity,
});
};
export const useNetworkAdvertisementConfigurationMutation = () => {
return useValetudoFetchingMutation(
useOnSettingsChangeError("Network Advertisement"),
CacheKey.NetworkAdvertisementConfiguration,
(networkAdvertisementConfiguration: NetworkAdvertisementConfiguration) => {
return sendNetworkAdvertisementConfiguration(networkAdvertisementConfiguration).then(fetchNetworkAdvertisementConfiguration);
}
);
};
export const useNetworkAdvertisementPropertiesQuery = () => {
return useQuery(CacheKey.NetworkAdvertisementProperties, fetchNetworkAdvertisementProperties, {
staleTime: Infinity,
});
};
export const useNTPClientStateQuery = () => {
return useQuery(CacheKey.NTPClientState, fetchNTPClientState, {
staleTime: 5_000,

View File

@ -263,6 +263,15 @@ export interface HTTPBasicAuthConfiguration {
password: string;
}
export interface NetworkAdvertisementConfiguration {
enabled: boolean;
}
export interface NetworkAdvertisementProperties {
port: number;
zeroconfHostname: string;
}
export interface NTPClientState {
__class: "ValetudoNTPClientDisabledState" | "ValetudoNTPClientEnabledState" | "ValetudoNTPClientErrorState" | "ValetudoNTPClientSyncedState";
timestamp: string;

View File

@ -171,6 +171,12 @@ const menuTree: Array<MenuEntry | MenuSubEntry | MenuSubheader> = [
title: "MQTT Connectivity",
parentRoute: "/settings/connectivity"
},
{
kind: "MenuSubEntry",
routeMatch: "/settings/connectivity/networkadvertisement",
title: "Network Advertisement",
parentRoute: "/settings/connectivity"
},
{
kind: "MenuSubEntry",
routeMatch: "/settings/connectivity/ntp",

View File

@ -9,6 +9,7 @@ import Connectivity from "./connectivity/Connectivity";
import NTPConnectivity from "./connectivity/NTPConnectivity";
import AuthSettings from "./connectivity/AuthSettings";
import WifiConnectivity from "./connectivity/WifiConnectivity";
import NetworkAdvertisementSettings from "./connectivity/NetworkAdvertisementSettings";
const SettingsRouter = (): JSX.Element => {
const {path} = useRouteMatch();
@ -61,6 +62,9 @@ const SettingsRouter = (): JSX.Element => {
<Route exact path={path + "/connectivity/mqtt"}>
<MQTTConnectivity/>
</Route>
<Route exact path={path + "/connectivity/networkadvertisement"}>
<NetworkAdvertisementSettings/>
</Route>
<Route exact path={path + "/connectivity/ntp"}>
<NTPConnectivity/>
</Route>

View File

@ -6,7 +6,8 @@ import {MQTTIcon} from "../../components/CustomIcons";
import {
AccessTime as NTPIcon,
VpnKey as AuthIcon,
Wifi as WifiIcon
Wifi as WifiIcon,
AutoFixHigh as NetworkAdvertisementIcon
} from "@mui/icons-material";
import {ListMenu} from "../../components/list_menu/ListMenu";
import {SpacerListMenuItem} from "../../components/list_menu/SpacerListMenuItem";
@ -50,11 +51,21 @@ const Connectivity = (): JSX.Element => {
key="ntpConnectivity"
url="/settings/connectivity/ntp"
primaryLabel="NTP Connectivity"
secondaryLabel="Configure Valetudos integrated Network Time Protocol (NTP) client"
secondaryLabel="Configure the integrated Network Time Protocol (NTP) client"
icon={<NTPIcon/>}
/>
);
items.push(
<LinkListMenuItem
key="networkAdvertisementSettings"
url="/settings/connectivity/networkadvertisement"
primaryLabel="Network Advertisement"
secondaryLabel="Control Bonjour/mDNS and SSDP/UPnP discoverability"
icon={<NetworkAdvertisementIcon/>}
/>
);
items.push(
<LinkListMenuItem
key="authSettings"

View File

@ -0,0 +1,146 @@
import {
Box,
Checkbox,
Divider,
FormControlLabel,
Grid,
TextField,
Typography
} from "@mui/material";
import React from "react";
import {
useNetworkAdvertisementConfigurationMutation,
useNetworkAdvertisementConfigurationQuery,
useNetworkAdvertisementPropertiesQuery
} from "../../api";
import LoadingFade from "../../components/LoadingFade";
import {LoadingButton} from "@mui/lab";
import InfoBox from "../../components/InfoBox";
import PaperContainer from "../../components/PaperContainer";
import {
AutoFixHigh as NetworkAdvertisementIcon
} from "@mui/icons-material";
const NetworkAdvertisementSettings = (): JSX.Element => {
const {
data: storedConfiguration,
isLoading: configurationLoading,
isError: configurationError,
} = useNetworkAdvertisementConfigurationQuery();
const {
data: properties,
isLoading: propertiesLoading,
isError: propertiesLoadError
} = useNetworkAdvertisementPropertiesQuery();
const {mutate: updateConfiguration, isLoading: configurationUpdating} = useNetworkAdvertisementConfigurationMutation();
const [enabled, setEnabled] = React.useState(false);
const [configurationModified, setConfigurationModified] = React.useState<boolean>(false);
React.useEffect(() => {
if (storedConfiguration) {
setEnabled(storedConfiguration.enabled);
}
}, [storedConfiguration]);
if (configurationLoading || propertiesLoading) {
return (
<LoadingFade/>
);
}
if (configurationError || propertiesLoadError || !storedConfiguration) {
return <Typography color="error">Error loading Network Advertisement configuration</Typography>;
}
return (
<PaperContainer>
<Grid container direction="row">
<Box style={{width: "100%"}}>
<Grid item container alignItems="center" spacing={1} justifyContent="space-between">
<Grid item style={{display:"flex"}}>
<Grid item style={{paddingRight: "8px"}}>
<NetworkAdvertisementIcon/>
</Grid>
<Grid item>
<Typography>Network Advertisement</Typography>
</Grid>
</Grid>
</Grid>
<Divider sx={{mt: 1}}/>
<FormControlLabel
control={
<Checkbox
checked={enabled}
onChange={e => {
setEnabled(e.target.checked);
setConfigurationModified(true);
}}
/>
}
label="Network Advertisement enabled"
sx={{mb: 1}}
/>
<Grid container spacing={1} sx={{mb: 1, mt: "1rem"}} direction="row">
<Grid item xs="auto" style={{flexGrow: 1}}>
<TextField
style={{width: "100%"}}
label="Zeroconf Hostname"
value={properties?.zeroconfHostname ?? ""}
variant="standard"
disabled={true}
InputProps={{
readOnly: true,
}}
/>
</Grid>
</Grid>
<InfoBox
boxShadow={5}
style={{
marginTop: "3rem",
marginBottom: "2rem"
}}
>
<Typography color="info">
When running Valetudo in embedded mode, it will advertise its presence on your local network
via both Bonjour/mDNS and SSDP/UPnP to enable other software such as the android companion app
or the windows explorer to discover it.
<br/><br/>
Please note that disabling this feature <em>will break</em> the companion app as well as other
things that may be able to auto-discover Valetudo instances on your network.
</Typography>
</InfoBox>
<Divider sx={{mt: 1}} style={{marginBottom: "1rem"}}/>
<Grid container>
<Grid item style={{marginLeft: "auto"}}>
<LoadingButton
loading={configurationUpdating}
color="primary"
variant="outlined"
disabled={!configurationModified}
onClick={() => {
updateConfiguration({
enabled
});
setConfigurationModified(false);
}}
>
Save configuration
</LoadingButton>
</Grid>
</Grid>
</Box>
</Grid>
</PaperContainer>
);
};
export default NetworkAdvertisementSettings;

View File

@ -25,6 +25,7 @@ const options = {
{name: "Robot", description: "Robot API"},
{name: "System", description: "System API"},
{name: "MQTT", description: "MQTT Controller API"},
{name: "NetworkAdvertisement", description: "Network Advertisement Manager API"},
{name: "NTP", description: "NTP Client API"},
{name: "Timers", description: "Timers API"},
{name: "Updater", description: "Update Valetudo using Valetudo"},