import smartHutStore from "./store"; import actions from "./storeActions"; import axios from "axios"; import { endpointURL, socketURL } from "./endpoint"; import { connect, disconnect } from "@giantmachines/redux-websocket"; /** * An object returned by promise rejections in remoteservice * @typedef {Error} RemoteError * @property {String[]} messages a list of user-friendly error messages to show; */ class RemoteError extends Error { messages; constructor(messages) { super( messages && Array.isArray(messages) ? messages.join(" - ") : JSON.stringify(messages, null, 2) ); this.messages = messages; } } const Endpoint = { axiosInstance: axios.create({ baseURL: endpointURL(), validateStatus: (status) => status >= 200 && status < 300, }), /** * Returns token for current session, null if logged out * @returns {String|null} the token */ get token() { return smartHutStore.getState().login.token; }, /** * Performs an authenticated request * @param {get|post|put|delete} the desired method * @param {String} route the desired route (e.g. "/rooms/1/devices") * @param {[String]String} query query ('?') parameters (no params by default) * @param {any} body the JSON request body */ send: (method, route, query = {}, body = null) => { if (!Endpoint.token) { throw new Error("No token while performing authenticated request"); } return Endpoint.axiosInstance(route, { method: method, params: query, data: ["put", "post"].indexOf(method) !== -1 ? body : null, headers: { Authorization: `Bearer ${Endpoint.token}`, }, }).then((res) => { if (!res.data && method !== "delete") { console.error("Response body is empty"); return null; } else { return res; } }); }, /** * Performs a non-authenticated post and put request for registration, reset-password * @param {post} the desired method * @param {String} route the desired route (e.g. "/rooms/1/devices") * @param {[String]String} query query ('?') parameters (no params by default) * @param {any} body the JSON request body */ sendNA: (method, route, query = {}, body = null) => { return Endpoint.axiosInstance(route, { method: method, params: query, data: ["put", "post"].indexOf(method) !== -1 ? body : null, }).then((res) => { if (!res.data) { console.error("Response body is empty"); return null; } else { return res; } }); }, /** * Performs login * @param {String} usernameOrEmail * @param {String} password * @returns {Promise} promise that resolves to the token string * and rejects to the axios error. */ login: (usernameOrEmail, password) => { return Endpoint.axiosInstance .post(`/auth/login`, { usernameOrEmail, password, }) .then((res) => { localStorage.setItem("token", res.data.jwttoken); localStorage.setItem("exp", new Date().getTime() + 5 * 60 * 60 * 1000); return res.data.jwttoken; }); }, /** * Returns an immediately resolved promise for the socket logouts * @return {Promise} An always-resolved promise */ logout: () => { localStorage.removeItem("token"); localStorage.removeItem("exp"); return Promise.resolve(void 0); }, /** * Performs an authenticated GET request * @param {String} route the desired route (e.g. "/rooms/1/devices") * @param {[String]String} query query ('?') parameters (no params by default) * @returns {Promise<*, *>} The Axios-generated promise */ get(route, query = {}) { return this.send("get", route, query); }, /** * Performs an authenticated POST request * @param {String} route the desired route (e.g. "/rooms/1/devices") * @param {[String]String} query query ('?') parameters (no params by default) * @param {any} body the JSON request body * @returns {Promise<*, *>} The Axios-generated promise */ post(route, query, body) { return this.send("post", route, query, body); }, /** * Performs a non-authenticated POST request * @param {String} route the desired route (e.g. "/rooms/1/devices") * @param {[String]String} query query ('?') parameters (no params by default) * @param {any} body the JSON request body * @returns {Promise<*, *>} The Axios-generated promise */ postNA(route, query, body) { return this.sendNA("post", route, query, body); }, /** * Performs an authenticated PUT request * @param {String} route the desired route (e.g. "/rooms/1/devices") * @param {[String]String} query query ('?') parameters (no params by default) * @param {any} body the JSON request body * @returns {Promise<*, *>} The Axios-generated promise */ put(route, query = {}, body = {}) { return this.send("put", route, query, body); }, /** * Performs a non-authenticated PUT request * @param {String} route the desired route (e.g. "/rooms/1/devices") * @param {[String]String} query query ('?') parameters (no params by default) * @param {any} body the JSON request body * @returns {Promise<*, *>} The Axios-generated promise */ putNA(route, query = {}, body = {}) { return this.sendNA("put", route, query, body); }, /** * Performs an authenticated DELETE request * @param {get|post|put|delete} the desired method * @param {String} route the desired route (e.g. "/rooms/1/devices") * @param {[String]String} query query ('?') parameters (no params by default) * @returns {Promise<*, *>} The Axios-generated promise */ delete(route, query = {}) { return this.send("delete", route, query); }, }; /** * Given an error response, returns an array of user * friendly messages to display to the user * @param {*} err the Axios error reponse object * @returns {RemoteError} user friendly error messages */ function parseValidationErrors(err) { if ( err.response && err.response.status === 400 && err.response.data && Array.isArray(err.response.data.errors) ) { throw new RemoteError([ ...new Set(err.response.data.errors.map((e) => e.defaultMessage)), ]); } else { console.warn("Non validation error", err); throw new RemoteError(["Network error"]); } } export const RemoteService = { /** * Performs login * @param {String} usernameOrEmail * @param {String} password * @returns {Promise} promise that resolves to void and rejects * with user-fiendly errors as a RemoteError */ login: (usernameOrEmail, password) => { return (dispatch) => { return Endpoint.login(usernameOrEmail, password) .then((token) => { dispatch(actions.loginSuccess(token)); dispatch(connect(socketURL(token))); }) .catch((err) => { console.warn("login error", err); throw new RemoteError([ err.response && err.response.status === 401 ? "Wrong credentials" : "An error occurred while logging in", ]); }); }; }, /** * Performs logout */ logout: () => { return (dispatch) => Endpoint.logout().then(() => { dispatch(disconnect()); dispatch(actions.logout()); }); }, /** * Fetches user information via REST calls, if it is logged in * @returns {Promise} promise that resolves to void and rejects * with user-fiendly errors as a RemoteError */ fetchUserInfo: () => { return (dispatch) => { return Endpoint.get("/auth/profile") .then((res) => void dispatch(actions.userInfoUpdate(res.data))) .catch((err) => { console.warn("Fetch user info error", err); throw new RemoteError(["Network error"]); }); }; }, /** * Fetches all rooms that belong to this user. This call does not * populate the devices attribute in rooms. * @returns {Promise} promise that resolves to void and rejects * with user-fiendly errors as a RemoteError */ fetchAllRooms: () => { return (dispatch) => { return Endpoint.get("/room") .then((res) => void dispatch(actions.roomsUpdate(res.data))) .catch((err) => { console.error("Fetch all rooms error", err); throw new RemoteError(["Network error"]); }); }; }, /** * Fetches all scenes that belong to this user. This call does not * populate the devices attribute in scenes. * @returns {Promise} promise that resolves to void and rejects * with user-fiendly errors as a RemoteError */ fetchAllScenes: () => { return (dispatch) => { return Endpoint.get("/scene") .then((res) => void dispatch(actions.scenesUpdate(res.data))) .catch((err) => { console.error("Fetch all scenes error", err); throw new RemoteError(["Network error"]); }); }; }, /** * Fetches all devices in a particular room, or fetches all devices. * This also updates the devices attribute on values in the map rooms. * @param {Number|null} roomId the rsoom to which fetch devices * from, null to fetch from all rooms * @returns {Promise} promise that resolves to void and rejects * with user-fiendly errors as a RemoteError */ fetchDevices: (roomId = null) => { return (dispatch) => { return Endpoint.get(roomId ? `/room/${roomId}/device` : "/device") .then((res) => void dispatch(actions.devicesUpdate(roomId, res.data))) .catch((err) => { console.error(`Fetch devices roomId=${roomId} error`, err); throw new RemoteError(["Network error"]); }); }; }, /** * Fetches all the automations * @returns {Promise} promise that resolves to void and rejects * with user-fiendly errors as a RemoteError */ fetchAutomations: () => { return (dispatch) => { return Endpoint.get("/automation/") .then((res) => void dispatch(actions.automationsUpdate(res.data))) .catch((err) => { console.error(`Fetch automations error`, err); throw new RemoteError(["Network error"]); }); }; }, /** * Fetches all devices in a particular scene, or fetches all devices. * This also updates the devices attribute on values in the map scenes. * @param {Number} sceneId the scene to which fetch devices * from, null to fetch from all scenes * @returns {Promise} promise that resolves to void and rejects * with user-fiendly errors as a RemoteError */ fetchStates: (sceneId) => { return (dispatch) => { return Endpoint.get(`/scene/${sceneId}/states`) .then((res) => void dispatch(actions.statesUpdate(sceneId, res.data))) .catch((err) => { console.error(`Fetch devices sceneId=${sceneId} error`, err); throw new RemoteError(["Network error"]); }); }; }, /** * Creates/Updates a room with the given data * @param {String} data.name the room's name, * @param {String} data.icon the room's icon name in SemanticUI icons * @param {String} data.image ths room's image, as base64 * @param {Number|null} roomId the room's id if update, null for creation * @returns {Promise} promise that resolves to void and rejects * with user-fiendly errors as a RemoteError */ saveRoom: (data, roomId = null) => { return (dispatch) => { data = { name: data.name, icon: data.icon, image: data.image, }; return (roomId ? Endpoint.put(`/room/${roomId}`, {}, data) : Endpoint.post(`/room`, {}, data) ) .then((res) => void dispatch(actions.roomSave(res.data))) .catch(parseValidationErrors); }; }, /** * Creates/Updates a scene with the given data * @param {String} data.name the scene's name, * @param {Number|null} sceneId the scene's id if update, null for creation * @returns {Promise} promise that resolves to void and rejects * with user-fiendly errors as a RemoteError */ saveScene: (data, sceneId = null) => { return (dispatch) => { data = { name: data.name, }; return (sceneId ? Endpoint.put(`/scene/${sceneId}`, {}, data) : Endpoint.post(`/scene`, {}, data) ) .then((res) => void dispatch(actions.sceneSave(res.data))) .catch(parseValidationErrors); }; }, // updateState: (data, type) => { return (dispatch) => { let url; if (data.on !== undefined) { url = "/switchableState"; } else { url = "/dimmableState"; } return Endpoint.put(url, {}, data) .then((res) => { dispatch(actions.stateSave(res.data)); return res.data; }) .catch((err) => { console.warn("Update device: ", data, "error: ", err); throw new RemoteError(["Network error"]); }); }; }, deleteState: (id, type) => { return (dispatch) => { let url; if (type === "dimmableState") { url = "/dimmableState"; } else { url = "/switchableState"; } return Endpoint.delete(url + `/${id}`) .then((_) => dispatch(actions.stateDelete(id))) .catch((err) => { console.warn("state delete error", err); throw new RemoteError(["Network error"]); }); }; }, sceneApply: (id) => { return (dispatch) => { let url = `/scene/${id}/apply`; return Endpoint.post(url) .then((res) => dispatch(actions.deviceOperationUpdate(res.data))) .catch((err) => { console.warn("scene apply error", err); throw new RemoteError(["Network error"]); }); }; }, /** * Creates/Updates a device with the given data. If * data.id is truthy, then a update call is performed, * otherwise a create call is performed. The update URL * is computed based on data.kind when data.flowType = * 'OUTPUT', otherwise the PUT "/device" endpoint * is used for updates and the POST "/" * endpoints are used for creation. * @param {Device} data the device to update. * @returns {Promise} promise that resolves to the saved device and rejects * with user-fiendly errors as a RemoteError */ saveDevice: (data) => { return (dispatch) => { let url = "/device"; if ((data.id && data.flowType === "OUTPUT") || !data.id) { url = "/" + data.kind; } return Endpoint[data.id ? "put" : "post"](url, {}, data) .then((res) => { dispatch(actions.deviceSave(res.data)); return res.data; }) .catch((err) => { console.warn("Update device: ", data, "error: ", err); throw new RemoteError(["Network error"]); }); }; }, fastUpdateAutomation: (automation) => { return (dispatch) => { return Endpoint.put("/automation/fast", {}, automation) .then((res) => dispatch(actions.automationSave(res.data))) .catch((err) => { console.warn("Update automation: ", automation, "error: ", err); throw new RemoteError(["Network error"]); }); }; }, /** * Creates/Updates an automation with the given data. If * data.id is truthy, then a update call is performed, * otherwise a create call is performed. * @param {Automation} data the automation to update. * @returns {Promise} promise that resolves to the saved device and rejects * with user-fiendly errors as a RemoteError */ saveAutomation: (data) => { const { automation, triggerList, order } = data; console.log("automation: ", automation, triggerList, order); automation.triggers = []; automation.scenes = []; return (dispatch) => { let urlAutomation = "/automation"; let urlBooleanTrigger = "/booleanTrigger"; let urlRangeTrigger = "/rangeTrigger"; let urlScenePriority = "/scenePriority"; let rangeTriggerList = triggerList.filter((trigger) => trigger.hasOwnProperty("operand") ); let booleanTriggerList = triggerList.filter( (trigger) => !trigger.hasOwnProperty("operand") ); return Endpoint["post"](urlAutomation, {}, automation).then( async (automationRes) => { const { id } = automationRes.data; // Introduce the range triggers in the automation for (let t of rangeTriggerList) { const trigger = { automationId: id, deviceId: t.device, operator: t.operand, range: t.value, }; let resRange = await Endpoint.post(urlRangeTrigger, {}, trigger); automation.triggers.push(resRange.data); } for (let t of booleanTriggerList) { const trigger = { automationId: id, deviceId: t.device, on: t.value, }; let resBoolean = await Endpoint.post( urlBooleanTrigger, {}, trigger ); automation.triggers.push(resBoolean.data); console.log("TRIGGERS: ", automation); } for (let [priority, sceneId] of order.entries()) { const scenePriority = { automationId: id, priority, sceneId, }; let resScenes = await Endpoint["post"]( urlScenePriority, {}, scenePriority ); automation.scenes.push(resScenes.data); } automation.id = id; dispatch(actions.automationSave(automation)); } ); }; }, /** * Creates/Updates a state with the given data. If * data.id is truthy, then a update call is performed, * otherwise a create call is performed. The update URL * is computed based on data.kind when data.flowType = * 'OUTPUT', otherwise the PUT "/device" endpoint * is used for updates and the POST "/" * endpoints are used for creation. * @param {State} data the device to update. * @returns {Promise} promise that resolves to the saved device and rejects * with user-fiendly errors as a RemoteError */ saveState: (data) => { return (dispatch) => { let url = "/" + data.kind + "/" + data.id + "/state?sceneId=" + data.sceneId; return Endpoint["post"](url, {}, data) .then((res) => { dispatch(actions.stateSave(res.data)); return res.data; }) .catch((err) => { console.warn("Update device: ", data, "error: ", err); throw new RemoteError(["Network error"]); }); }; }, /** * Connetcs a series of output devices to an input device. * Output devices for Switch input can be: Normal Light, Dimmable Light, Smart Plug. * Output devices for Dimmers input can be: Dimmable Light. * * @typedef {"switch" | "buttonDimmer" | "knobDimmer"} ConnectableInput * * @param {ConnectableInput} newDevice.kind kind of the input device * @param {Integer} newDevice.id id of the input device * @param {Integer[]} outputs ids of the output device * @returns {Promise} promise that resolves to void and rejects * with user-fiendly errors as a RemoteError */ connectOutputs: (newDevice, outputs) => { return (dispatch) => { let url = `/${newDevice.kind}/${newDevice.id}/lights`; return Endpoint.post(url, {}, outputs) .then((res) => { dispatch(actions.deviceOperationUpdate(res.data)); return res.data; }) .catch((err) => { console.warn( "ConnectOutputs of ", newDevice.id, " with outputs: ", outputs, "error: ", err ); throw new RemoteError(["Network error"]); }); }; }, _operateInput: (url, getUrl, action) => { return (dispatch) => { return Endpoint.put(url, {}, action) .then(async (res) => { const inputDevice = await Endpoint.get(getUrl); delete inputDevice.outputs; dispatch( actions.deviceOperationUpdate([...res.data, inputDevice.data]) ); }) .catch((err) => { console.warn(`${url} error`, err); throw new RemoteError(["Network error"]); }); }; }, /** * Changes the state of a switch, by turning it on, off or toggling it. * * @typedef {"ON" | "OFF" | "TOGGLE"} SwitchOperation * * @param {Number} switchId the switch device id * @param {SwitchOperation} type the operation to perform on the switch * @returns {Promise} promise that resolves to void and rejects * with user-fiendly errors as a RemoteError */ switchOperate: (switchId, type) => { return RemoteService._operateInput( "/switch/operate", `/switch/${switchId}`, { type: type.toUpperCase(), id: switchId, } ); }, /** * Turns a knob dimmer to a specific amount * * @param {Number} dimmerId the knob dimmer id * @param {number} intensity the absolute intensity to dim to. Must be >=0 and <= 100. * @returns {Promise} promise that resolves to void and rejects * with user-fiendly errors as a RemoteError */ knobDimmerDimTo: (dimmerId, intensity) => { return RemoteService._operateInput( "/knobDimmer/dimTo", `/knobDimmer/${dimmerId}`, { intensity, id: dimmerId, } ); }, /** * Turns a button dimmer up or down * * @typedef {"UP" | "DOWN"} ButtonDimmerDimType * * @param {Number} dimmerId the button dimmer id * @param {ButtonDimmerDimType} dimType the type of dim to perform * @returns {Promise} promise that resolves to void and rejects * with user-fiendly errors as a RemoteError */ buttonDimmerDim: (dimmerId, dimType) => { return RemoteService._operateInput( "/buttonDimmer/dim", `/buttonDimmer/${dimmerId}`, { dimType, id: dimmerId, } ); }, /** * Resets the meter on a smart plug * * @param {Number} smartPlugId the smart plug to reset * @returns {Promise} promise that resolves to void and rejects * with user-fiendly errors as a RemoteError */ smartPlugReset(smartPlugId) { return (dispatch) => { return Endpoint.delete(`/smartPlug/${smartPlugId}/meter`) .then((res) => dispatch(actions.deviceOperationUpdate([res.data]))) .catch((err) => { console.warn(`Smartplug reset error`, err); throw new RemoteError(["Network error"]); }); }; }, /** * Deletes a room * @param {Number} roomId the id of the room to delete * @returns {Promise} promise that resolves to void and rejects * with user-fiendly errors as a RemoteError */ deleteRoom: (roomId) => { return (dispatch) => { return Endpoint.delete(`/room/${roomId}`) .then((_) => dispatch(actions.roomDelete(roomId))) .catch((err) => { console.warn("Room deletion error", err); throw new RemoteError(["Network error"]); }); }; }, deleteAutomation: (id) => { console.log("ID OF AUTO ", id); return (dispatch) => { return Endpoint.delete(`/automation/${id}`) .then((_) => dispatch(actions.automationDelete(id))) .catch((err) => { console.warn("Automation deletion error", err); throw new RemoteError(["Network error"]); }); }; }, /** * Deletes a scene * @param {Number} sceneId the id of the scene to delete * @returns {Promise} promise that resolves to void and rejects * with user-fiendly errors as a RemoteError */ deleteScene: (sceneId) => { return (dispatch) => { return Endpoint.delete(`/scene/${sceneId}`) .then((_) => dispatch(actions.sceneDelete(sceneId))) .catch((err) => { console.warn("Scene deletion error", err); throw new RemoteError(["Network error"]); }); }; }, /** * Deletes a device * @param {Device} device the device to delete * @returns {Promise} promise that resolves to void and rejects * with user-fiendly errors as a RemoteError */ deleteDevice: (device) => { return (dispatch) => { return Endpoint.delete(`/${device.kind}/${device.id}`) .then((_) => dispatch(actions.deviceDelete(device.id))) .catch((err) => { console.warn("Device deletion error", err); throw new RemoteError(["Network error"]); }); }; }, }; for (const key in RemoteService) { RemoteService[key] = RemoteService[key].bind(RemoteService); } export class Forms { /** * Attempts to create a new user from the given data. * This method does not update the global state, * please check its return value. * @param {String} data.username the chosen username * @param {String} data.password the chosen password * @param {String} data.email the chosen email * @param {String} data.name the chosen full name * @returns {Promise} promise that resolves to void and rejects * with validation errors as a String array */ static submitRegistration(data) { return Endpoint.postNA( "/register", {}, { username: data.username, password: data.password, name: data.name, email: data.email, } ) .then((_) => void 0) .catch(parseValidationErrors); } /** * Sends a request to perform a password reset. * This method does not update the global state, * please check its return value. * @param {String} email the email to which perform the reset * @returns {Promise} promise that resolves to void and rejects * with validation errors as a String array */ static submitInitResetPassword(email) { return Endpoint.postNA( "/register/init-reset-password", {}, { email: email, } ) .then((_) => void 0) .catch((err) => { console.warn("Init reset password failed", err); throw new RemoteError(["Network error"]); }); } /** * Sends the password for the actual password reset, haviug already * performed email verification * This method does not update the global state, * please check its return value. * @param {String} confirmationToken the confirmation token got from the email * @param {String} password the new password * @returns {Promise} promise that resolves to void and rejects * with validation errors as a String array */ static submitResetPassword(confirmationToken, password) { return Endpoint.putNA( "/register/reset-password", {}, { confirmationToken, password, } ) .then((_) => void 0) .catch(parseValidationErrors); } }