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