diff --git a/CHANGELOG_LATEST.md b/CHANGELOG_LATEST.md
index b40755b3d..f67cbcf99 100644
--- a/CHANGELOG_LATEST.md
+++ b/CHANGELOG_LATEST.md
@@ -22,6 +22,7 @@ There are breaking changes in 3.6.0. Please read carefully before applying the u
- AM200 code 10 [#1161](https://github.com/emsesp/EMS-ESP32/issues/1161)
- Ventilation device [#1172](https://github.com/emsesp/EMS-ESP32/issues/1172)
- Turn ETH off on wifi connect [#1167](https://github.com/emsesp/EMS-ESP32/issues/1167)
+- Support for multiple EMS-ESPs with HA [#1196](https://github.com/emsesp/EMS-ESP32/issues/1196)
## Fixed
diff --git a/interface/.env.development b/interface/.env.development
new file mode 100644
index 000000000..19cc804fe
--- /dev/null
+++ b/interface/.env.development
@@ -0,0 +1,2 @@
+VITE_ALOVA_TIPS=0
+REACT_APP_ALOVA_TIPS=0
\ No newline at end of file
diff --git a/interface/package.json b/interface/package.json
index d7dff3ee4..1dca171fe 100644
--- a/interface/package.json
+++ b/interface/package.json
@@ -1,7 +1,7 @@
{
"name": "EMS-ESP",
"version": "3.6.0",
- "description": "build EMS-ESP TypeScript WebUI",
+ "description": "build EMS-ESP WebUI",
"homepage": "https://emsesp.github.io/docs",
"author": "proddy",
"license": "MIT",
@@ -19,6 +19,8 @@
"lint": "eslint . --cache --fix"
},
"dependencies": {
+ "@alova/adapter-xhr": "^1.0.1",
+ "@alova/scene-react": "^1.1.3",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.11.16",
@@ -29,11 +31,12 @@
"@types/react": "^18.2.14",
"@types/react-dom": "^18.2.6",
"@types/react-router-dom": "^5.3.3",
+ "alova": "^2.9.0",
"async-validator": "^4.2.5",
- "axios": "^1.4.0",
"history": "^5.3.0",
"jwt-decode": "^3.1.2",
"lodash-es": "^4.17.21",
+ "mime-types": "^2.1.35",
"react": "latest",
"react-dom": "latest",
"react-dropzone": "^14.2.3",
@@ -45,6 +48,7 @@
"typescript": "^5.1.6"
},
"devDependencies": {
+ "@types/mime-types": "^2",
"@typescript-eslint/eslint-plugin": "^5.60.1",
"@typescript-eslint/parser": "^5.60.1",
"@vitejs/plugin-react-swc": "^3.3.2",
diff --git a/interface/src/AuthenticatedRouting.tsx b/interface/src/AuthenticatedRouting.tsx
index eaf6041f2..0dc0da8b1 100644
--- a/interface/src/AuthenticatedRouting.tsx
+++ b/interface/src/AuthenticatedRouting.tsx
@@ -1,15 +1,10 @@
-import { useCallback, useEffect } from 'react';
-import { Navigate, Routes, Route, useNavigate, useLocation } from 'react-router-dom';
+import { Navigate, Routes, Route } from 'react-router-dom';
import Dashboard from './project/Dashboard';
import Help from './project/Help';
import Settings from './project/Settings';
-import type { AxiosError } from 'axios';
import type { FC } from 'react';
-import * as AuthenticationApi from 'api/authentication';
-import { AXIOS } from 'api/endpoints';
import { Layout, RequireAdmin } from 'components';
-
import AccessPoint from 'framework/ap/AccessPoint';
import Mqtt from 'framework/mqtt/Mqtt';
import NetworkConnection from 'framework/network/NetworkConnection';
@@ -17,57 +12,54 @@ import NetworkTime from 'framework/ntp/NetworkTime';
import Security from 'framework/security/Security';
import System from 'framework/system/System';
-const AuthenticatedRouting: FC = () => {
- const location = useLocation();
- const navigate = useNavigate();
+const AuthenticatedRouting: FC = () => (
+ // TODO not sure if this is needed, to redirect on 401. If so add incerceptor to Alova
+ // const location = useLocation();
+ // const navigate = useNavigate();
+ // const handleApiResponseError = useCallback(
+ // (error: AxiosError) => {
+ // if (error.response && error.response.status === 401) {
+ // AuthenticationApi.storeLoginRedirect(location);
+ // navigate('/unauthorized');
+ // }
+ // return Promise.reject(error);
+ // },
+ // [location, navigate]
+ // );
+ // useEffect(() => {
+ // const axiosHandlerId = AXIOS.interceptors.response.use((response) => response, handleApiResponseError);
+ // return () => AXIOS.interceptors.response.eject(axiosHandlerId);
+ // }, [handleApiResponseError]);
- const handleApiResponseError = useCallback(
- (error: AxiosError) => {
- if (error.response && error.response.status === 401) {
- AuthenticationApi.storeLoginRedirect(location);
- navigate('/unauthorized');
- }
- return Promise.reject(error);
- },
- [location, navigate]
- );
+
+
+ } />
+
+
+
+ }
+ />
+ } />
- useEffect(() => {
- const axiosHandlerId = AXIOS.interceptors.response.use((response) => response, handleApiResponseError);
- return () => AXIOS.interceptors.response.eject(axiosHandlerId);
- }, [handleApiResponseError]);
-
- return (
-
-
- } />
-
-
-
- }
- />
- } />
-
- } />
- } />
- } />
- } />
-
-
-
- }
- />
- } />
- } />
-
-
- );
-};
+ } />
+ } />
+ } />
+ } />
+
+
+
+ }
+ />
+ } />
+ } />
+
+
+);
export default AuthenticatedRouting;
diff --git a/interface/src/SignIn.tsx b/interface/src/SignIn.tsx
index 63f453bdc..5513da4ae 100644
--- a/interface/src/SignIn.tsx
+++ b/interface/src/SignIn.tsx
@@ -1,5 +1,6 @@
import ForwardIcon from '@mui/icons-material/Forward';
import { Box, Fab, Paper, Typography, Button } from '@mui/material';
+import { useRequest } from 'alova';
import { useContext, useState } from 'react';
import { toast } from 'react-toastify';
import type { ValidateFieldsError } from 'async-validator';
@@ -24,7 +25,7 @@ import { ReactComponent as SVflag } from 'i18n/SV.svg';
import { ReactComponent as TRflag } from 'i18n/TR.svg';
import { I18nContext } from 'i18n/i18n-react';
import { loadLocaleAsync } from 'i18n/i18n-util.async';
-import { extractErrorMessage, onEnterCallback, updateValue } from 'utils';
+import { onEnterCallback, updateValue } from 'utils';
import { SIGN_IN_REQUEST_VALIDATOR, validate } from 'validators';
const SignIn: FC = () => {
@@ -39,22 +40,27 @@ const SignIn: FC = () => {
const [processing, setProcessing] = useState(false);
const [fieldErrors, setFieldErrors] = useState();
+ const { send: callSignIn, onSuccess } = useRequest((request: SignInRequest) => AuthenticationApi.signIn(request), {
+ immediate: false
+ });
+
+ onSuccess((response) => {
+ if (response.data) {
+ authenticationContext.signIn(response.data.access_token);
+ }
+ });
+
const updateLoginRequestValue = updateValue(setSignInRequest);
const signIn = async () => {
- try {
- const { data: loginResponse } = await AuthenticationApi.signIn(signInRequest);
- authenticationContext.signIn(loginResponse.access_token);
- } catch (error) {
- if (error.response) {
- if (error.response?.status === 401) {
- toast.warn(LL.INVALID_LOGIN());
- }
+ await callSignIn(signInRequest).catch((event) => {
+ if (event.message === 'Unauthorized') {
+ toast.warn(LL.INVALID_LOGIN());
} else {
- toast.error(extractErrorMessage(error, LL.ERROR()));
+ toast.error(LL.ERROR() + ' ' + event.message);
}
setProcessing(false);
- }
+ });
};
const validateAndSignIn = async () => {
diff --git a/interface/src/api/ap.ts b/interface/src/api/ap.ts
index 734a40f64..7ffda7668 100644
--- a/interface/src/api/ap.ts
+++ b/interface/src/api/ap.ts
@@ -1,16 +1,7 @@
-import { AXIOS } from './endpoints';
-import type { AxiosPromise } from 'axios';
+import { alovaInstance } from './endpoints';
import type { APSettings, APStatus } from 'types';
-export function readAPStatus(): AxiosPromise {
- return AXIOS.get('/apStatus');
-}
-
-export function readAPSettings(): AxiosPromise {
- return AXIOS.get('/apSettings');
-}
-
-export function updateAPSettings(apSettings: APSettings): AxiosPromise {
- return AXIOS.post('/apSettings', apSettings);
-}
+export const readAPStatus = () => alovaInstance.Get('/rest/apStatus');
+export const readAPSettings = () => alovaInstance.Get('/rest/apSettings');
+export const updateAPSettings = (data: APSettings) => alovaInstance.Post('/rest/apSettings', data);
diff --git a/interface/src/api/authentication.ts b/interface/src/api/authentication.ts
index 9b8b01030..b4f3a9bd3 100644
--- a/interface/src/api/authentication.ts
+++ b/interface/src/api/authentication.ts
@@ -1,6 +1,5 @@
import jwtDecode from 'jwt-decode';
-import { ACCESS_TOKEN, AXIOS } from './endpoints';
-import type { AxiosPromise } from 'axios';
+import { ACCESS_TOKEN, alovaInstance } from './endpoints';
import type * as H from 'history';
import type { Path } from 'react-router-dom';
@@ -9,13 +8,8 @@ import type { Me, SignInRequest, SignInResponse } from 'types';
export const SIGN_IN_PATHNAME = 'loginPathname';
export const SIGN_IN_SEARCH = 'loginSearch';
-export function verifyAuthorization(): AxiosPromise {
- return AXIOS.get('/verifyAuthorization');
-}
-
-export function signIn(request: SignInRequest): AxiosPromise {
- return AXIOS.post('/signIn', request);
-}
+export const verifyAuthorization = () => alovaInstance.Get('/rest/verifyAuthorization');
+export const signIn = (request: SignInRequest) => alovaInstance.Post('/rest/signIn', request);
export function getStorage() {
return localStorage || sessionStorage;
diff --git a/interface/src/api/endpoints.ts b/interface/src/api/endpoints.ts
index 7c003693b..9cad000d8 100644
--- a/interface/src/api/endpoints.ts
+++ b/interface/src/api/endpoints.ts
@@ -1,95 +1,61 @@
-import axios from 'axios';
-import { unpack } from './unpack';
+import { xhrRequestAdapter } from '@alova/adapter-xhr';
+import { createAlova } from 'alova';
+import ReactHook from 'alova/react';
+import { unpack } from '../api/unpack';
-import type { AxiosPromise, CancelToken, AxiosProgressEvent } from 'axios';
-
-export const WS_BASE_URL = '/ws/';
-export const API_BASE_URL = '/rest/';
-export const ES_BASE_URL = '/es/';
-export const EMSESP_API_BASE_URL = '/api/';
export const ACCESS_TOKEN = 'access_token';
-const location = window.location;
-const webProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
-export const WEB_SOCKET_ROOT = webProtocol + '//' + location.host + WS_BASE_URL;
-export const EVENT_SOURCE_ROOT = location.protocol + '//' + location.host + ES_BASE_URL;
+const host = window.location.host;
+export const WEB_SOCKET_ROOT = 'ws://' + host + '/ws/';
+export const EVENT_SOURCE_ROOT = 'http://' + host + '/es/';
-export const AXIOS = axios.create({
- baseURL: API_BASE_URL,
- headers: {
- 'Content-Type': 'application/json'
- },
- transformRequest: [
- (data, headers) => {
- if (headers) {
- if (localStorage.getItem(ACCESS_TOKEN)) {
- headers.Authorization = 'Bearer ' + localStorage.getItem(ACCESS_TOKEN);
- }
- if (headers['Content-Type'] !== 'application/json') {
- return data;
- }
- }
- return JSON.stringify(data);
+export const alovaInstance = createAlova({
+ statesHook: ReactHook,
+ // timeout: 3000, // timeout not used because of uploading firmware
+ // localCache: null,
+ localCache: {
+ GET: {
+ mode: 'placeholder', // see https://alova.js.org/learning/response-cache/#cache-replaceholder-mode
+ expire: 2000
}
- ]
+ },
+ requestAdapter: xhrRequestAdapter(),
+ beforeRequest(method) {
+ if (localStorage.getItem(ACCESS_TOKEN)) {
+ method.config.headers.Authorization = 'Bearer ' + localStorage.getItem(ACCESS_TOKEN);
+ }
+ },
+
+ responded: {
+ onSuccess: async (response) => {
+ // if (response.status === 202) {
+ // throw new Error('Wait'); // wifi scan in progress
+ // } else
+ if (response.status === 205) {
+ throw new Error('Reboot required');
+ } else if (response.status === 400) {
+ throw new Error('Request Failed');
+ } else if (response.status >= 400) {
+ throw new Error(response.statusText);
+ }
+ const data = await response.data;
+ if (response.data instanceof ArrayBuffer) {
+ return unpack(data);
+ }
+ return data;
+ }
+
+ // Interceptor for request failure. This interceptor will be entered when the request is wrong.
+ // TODO how best to handle http errors like 401 (unauthorized)
+ // but I think this is handled correctly in AppRouting? See AuthenticatedRouting()
+ // onError: (error, method) => {
+ // alert(error.message);
+ // }
+ }
});
-export const AXIOS_API = axios.create({
- baseURL: EMSESP_API_BASE_URL,
- headers: {
- 'Content-Type': 'application/json'
- },
- transformRequest: [
- (data, headers) => {
- if (headers) {
- if (localStorage.getItem(ACCESS_TOKEN)) {
- headers.Authorization = 'Bearer ' + localStorage.getItem(ACCESS_TOKEN);
- }
- if (headers['Content-Type'] !== 'application/json') {
- return data;
- }
- }
- return JSON.stringify(data);
- }
- ]
+export const alovaInstanceGH = createAlova({
+ baseURL: 'https://api.github.com/repos/emsesp/EMS-ESP32',
+ statesHook: ReactHook,
+ requestAdapter: xhrRequestAdapter()
});
-
-export const AXIOS_BIN = axios.create({
- baseURL: API_BASE_URL,
- headers: {
- 'Content-Type': 'application/json'
- },
- responseType: 'arraybuffer',
- transformRequest: [
- (data, headers) => {
- if (headers) {
- if (localStorage.getItem(ACCESS_TOKEN)) {
- headers.Authorization = 'Bearer ' + localStorage.getItem(ACCESS_TOKEN);
- }
- if (headers['Content-Type'] !== 'application/json') {
- return data;
- }
- }
- return JSON.stringify(data);
- }
- ],
- // transformResponse: [(data) => decode(data)]
- transformResponse: [(data) => unpack(data)] // new using msgpackr
-});
-
-export interface FileUploadConfig {
- cancelToken?: CancelToken;
- onUploadProgress?: (progressEvent: AxiosProgressEvent) => void;
-}
-
-export const startUploadFile = (url: string, file: File, config?: FileUploadConfig): AxiosPromise => {
- const formData = new FormData();
- formData.append('file', file);
-
- return AXIOS.post(url, formData, {
- headers: {
- 'Content-Type': 'multipart/form-data'
- },
- ...(config || {})
- });
-};
diff --git a/interface/src/api/features.ts b/interface/src/api/features.ts
index 6ada2405c..c0dd66e4f 100644
--- a/interface/src/api/features.ts
+++ b/interface/src/api/features.ts
@@ -1,8 +1,5 @@
-import { AXIOS } from './endpoints';
-import type { AxiosPromise } from 'axios';
+import { alovaInstance } from './endpoints';
import type { Features } from 'types';
-export function readFeatures(): AxiosPromise {
- return AXIOS.get('/features');
-}
+export const readFeatures = () => alovaInstance.Get('/rest/features');
diff --git a/interface/src/api/mqtt.ts b/interface/src/api/mqtt.ts
index d599a3121..d75dc4134 100644
--- a/interface/src/api/mqtt.ts
+++ b/interface/src/api/mqtt.ts
@@ -1,15 +1,6 @@
-import { AXIOS } from './endpoints';
-import type { AxiosPromise } from 'axios';
+import { alovaInstance } from './endpoints';
import type { MqttSettings, MqttStatus } from 'types';
-export function readMqttStatus(): AxiosPromise {
- return AXIOS.get('/mqttStatus');
-}
-
-export function readMqttSettings(): AxiosPromise {
- return AXIOS.get('/mqttSettings');
-}
-
-export function updateMqttSettings(mqttSettings: MqttSettings): AxiosPromise {
- return AXIOS.post('/mqttSettings', mqttSettings);
-}
+export const readMqttStatus = () => alovaInstance.Get('/rest/mqttStatus');
+export const readMqttSettings = () => alovaInstance.Get('/rest/mqttSettings');
+export const updateMqttSettings = (data: MqttSettings) => alovaInstance.Post('/rest/mqttSettings', data);
diff --git a/interface/src/api/network.ts b/interface/src/api/network.ts
index a9535336c..77610f9ed 100644
--- a/interface/src/api/network.ts
+++ b/interface/src/api/network.ts
@@ -1,24 +1,14 @@
-import { AXIOS } from './endpoints';
-import type { AxiosPromise } from 'axios';
+import { alovaInstance } from './endpoints';
import type { WiFiNetworkList, NetworkSettings, NetworkStatus } from 'types';
-export function readNetworkStatus(): AxiosPromise {
- return AXIOS.get('/networkStatus');
-}
-
-export function scanNetworks(): AxiosPromise {
- return AXIOS.get('/scanNetworks');
-}
-
-export function listNetworks(): AxiosPromise {
- return AXIOS.get('/listNetworks');
-}
-
-export function readNetworkSettings(): AxiosPromise {
- return AXIOS.get('/networkSettings');
-}
-
-export function updateNetworkSettings(wifiSettings: NetworkSettings): AxiosPromise {
- return AXIOS.post('/networkSettings', wifiSettings);
-}
+export const readNetworkStatus = () => alovaInstance.Get('/rest/networkStatus');
+export const scanNetworks = () => alovaInstance.Get('/rest/scanNetworks');
+export const listNetworks = () =>
+ alovaInstance.Get('/rest/listNetworks', {
+ name: 'listNetworks'
+ });
+export const readNetworkSettings = () =>
+ alovaInstance.Get('/rest/networkSettings', { name: 'networkSettings' });
+export const updateNetworkSettings = (wifiSettings: NetworkSettings) =>
+ alovaInstance.Post('/rest/networkSettings', wifiSettings);
diff --git a/interface/src/api/ntp.ts b/interface/src/api/ntp.ts
index dcd3c7ca9..74da189d1 100644
--- a/interface/src/api/ntp.ts
+++ b/interface/src/api/ntp.ts
@@ -1,19 +1,11 @@
-import { AXIOS } from './endpoints';
-import type { AxiosPromise } from 'axios';
+import { alovaInstance } from './endpoints';
import type { NTPSettings, NTPStatus, Time } from 'types';
-export function readNTPStatus(): AxiosPromise {
- return AXIOS.get('/ntpStatus');
-}
+export const readNTPStatus = () => alovaInstance.Get('/rest/ntpStatus');
+export const readNTPSettings = () =>
+ alovaInstance.Get('/rest/ntpSettings', {
+ name: 'ntpSettings'
+ });
+export const updateNTPSettings = (data: NTPSettings) => alovaInstance.Post('/rest/ntpSettings', data);
-export function readNTPSettings(): AxiosPromise {
- return AXIOS.get('/ntpSettings');
-}
-
-export function updateNTPSettings(ntpSettings: NTPSettings): AxiosPromise {
- return AXIOS.post('/ntpSettings', ntpSettings);
-}
-
-export function updateTime(time: Time): AxiosPromise