import { createStore, applyMiddleware, compose } from "redux"; import thunk from "redux-thunk"; import update from "immutability-helper"; import reduxWebSocket, { connect } from "@giantmachines/redux-websocket"; import { socketURL } from "./endpoint"; import actions from "./storeActions"; function reducer(previousState, action) { let newState, change; const createOrUpdateRoom = (room) => { if (!newState.rooms[room.id]) { newState = update(newState, { rooms: { [room.id]: { $set: { ...room, devices: new Set() } } }, }); } else { newState = update(newState, { rooms: { [room.id]: { name: { $set: room.name }, image: { $set: room.image }, icon: { $set: room.icon }, }, }, }); } if (newState.pendingJoins.rooms[room.id]) { newState = update(newState, { pendingJoins: { rooms: { $unset: [room.id] } }, rooms: { [room.id]: { devices: { $add: [...newState.pendingJoins.rooms[room.id]], }, }, }, }); } }; const createOrUpdateScene = (scene) => { if (!newState.scenes[scene.id]) { newState = update(newState, { scenes: { [scene.id]: { $set: { ...scene, sceneStates: new Set() } } }, }); } else { newState = update(newState, { scenes: { [scene.id]: { name: { $set: scene.name }, }, }, }); } if (newState.pendingJoins.scenes[scene.id]) { newState = update(newState, { pendingJoins: { scenes: { $unset: [scene.id] } }, scenes: { [scene.id]: { sceneStates: { $add: [...newState.pendingJoins.scenes[scene.id]], }, }, }, }); } }; const updateDeviceProps = (device) => { // In some updates the information regarding a device is incomplete // due to a fault in the type system and JPA repository management // in the backend. Therefore to solve this avoid to delete existing // attributes of this device in the previous state, but just update // the new ones. change.devices[device.id] = {}; for (const key in device) { change.devices[device.id][key] = { $set: device[key] }; } }; const updateSceneStateProps = (state) => { change.sceneStates[state.id] = {}; for (const key in state) { change.sceneStates[state.id][key] = { $set: state[key] }; } }; switch (action.type) { case "LOGIN_UPDATE": newState = update(previousState, { login: { $set: action.login } }); break; case "USER_INFO_UPDATE": newState = update(previousState, { userInfo: { $set: action.userInfo } }); break; case "ROOMS_UPDATE": newState = previousState; for (const room of action.rooms) { createOrUpdateRoom(room); } break; case "HOST_ROOMS_UPDATE": change = { hostRooms: { [action.hostId]: { $set: {} }, }, }; const rooms = change.hostRooms[action.hostId].$set; for (const room of action.rooms) { rooms[room.id] = room; } newState = update(previousState, change); break; case "SCENES_UPDATE": newState = previousState; for (const scene of action.scenes) { createOrUpdateScene(scene); } break; case "STATES_UPDATE": //console.log(action.sceneStates); change = null; // if room is given, delete all devices in that room // and remove any join between that room and deleted // devices change = { scenes: { [action.sceneId]: { sceneStates: { $set: new Set() } } }, sceneStates: { $unset: [] }, }; const scene = previousState.scenes[action.sceneId]; for (const stateId of scene.sceneStates) { change.sceneStates.$unset.push(stateId); } newState = update(previousState, change); change = { sceneStates: {}, scenes: {}, pendingJoins: { scenes: {} }, }; for (const sceneState of action.sceneStates) { if (!newState.sceneStates[sceneState.id]) { change.sceneStates[sceneState.id] = { $set: sceneState, }; } else { updateSceneStateProps(sceneState); } if (sceneState.sceneId in newState.scenes) { change.scenes[sceneState.sceneId] = change.scenes[sceneState.sceneId] || {}; change.scenes[sceneState.sceneId].sceneStates = change.scenes[sceneState.sceneId].sceneStates || {}; const sceneStates = change.scenes[sceneState.sceneId].sceneStates; sceneStates.$add = sceneStates.$add || []; sceneStates.$add.push(sceneState.id); } else { // room does not exist yet, so add to the list of pending // joins if (!change.pendingJoins.scenes[sceneState.sceneId]) { change.pendingJoins.scenes[sceneState.sceneId] = { $set: new Set([sceneState.id]), }; } else { change.pendingJoins.scenes[sceneState.sceneId].$set.add( sceneState.id ); } } } newState = update(newState, change); break; case "DEVICES_UPDATE": change = null; // if room is given, delete all devices in that room // and remove any join between that room and deleted // devices if (action.roomId) { change = { rooms: { [action.roomId]: { devices: { $set: new Set() } } }, devices: { $unset: [] }, }; const room = newState.rooms[action.roomId]; for (const deviceId of room.devices) { change.devices.$unset.push(deviceId); } } else if (action.partial) { // if the update is partial and caused by an operation on an input // device (like /switch/operate), iteratively remove deleted // devices and their join with their corresponding room. change = { devices: { $unset: [] }, rooms: {}, }; for (const device of action.devices) { if (!previousState.devices[device.id]) continue; change.devices.$unset.push(device.id); const roomId = previousState.devices[device.id].roomId; if (roomId in previousState.rooms) { change.rooms[roomId] = change.rooms[roomId] || { devices: { $remove: [] }, }; change.rooms[roomId].devices.$remove.push(device.id); } } } else { // otherwise, just delete all devices and all joins // between rooms and devices change = { devices: { $set: {} }, rooms: {}, }; for (const room of Object.values(previousState.rooms)) { if (change.rooms[room.id]) { change.rooms[room.id].devices = { $set: new Set() }; } } } newState = update(previousState, change); change = { devices: {}, rooms: {}, pendingJoins: { rooms: {} }, }; for (const device of action.devices) { if (!newState.devices[device.id]) { change.devices[device.id] = { $set: device }; } else { updateDeviceProps(device); } if (device.roomId in newState.rooms) { change.rooms[device.roomId] = change.rooms[device.roomId] || {}; change.rooms[device.roomId].devices = change.rooms[device.roomId].devices || {}; const devices = change.rooms[device.roomId].devices; devices.$add = devices.$add || []; devices.$add.push(device.id); } else { // room does not exist yet, so add to the list of pending // joins if (!change.pendingJoins.rooms[device.roomId]) { change.pendingJoins.rooms[device.roomId] = { $set: new Set([device.id]), }; } else { change.pendingJoins.rooms[device.roomId].$set.add(device.id); } } } newState = update(newState, change); break; case "HOST_DEVICES_UPDATE": newState = action.partial ? previousState : update(previousState, { hostDevices: { [action.hostId]: { $set: {} } }, }); newState.hostDevices[action.hostId] = newState.hostDevices[action.hostId] || {}; change = { hostDevices: { [action.hostId]: {}, }, }; const deviceMap = change.hostDevices[action.hostId]; for (const device of action.devices) { deviceMap[device.id] = { $set: device }; } newState = update(newState, change); break; case "AUTOMATION_UPDATE": const automations = {}; for (const automation of action.automations) { automations[automation.id] = automation; } change = { automations: { $set: automations }, }; newState = update(previousState, change); break; case "ROOM_SAVE": newState = previousState; createOrUpdateRoom(action.room); break; case "SCENE_SAVE": newState = previousState; createOrUpdateScene(action.scene); break; case "DEVICE_SAVE": change = { devices: { [action.device.id]: { $set: action.device } }, }; if (previousState.rooms[action.device.roomId]) { change.rooms = { [action.device.roomId]: { devices: { $add: [action.device.id], }, }, }; } else { change.pendingJoins = { rooms: { [action.device.roomId]: { $add: [action.device.id], }, }, }; } newState = update(previousState, change); break; case "HOST_DEVICE_SAVE": change = { hostDevices: { [action.hostId]: { [action.device.id]: { $set: action.device, }, }, }, }; newState = update(previousState, change); break; case "AUTOMATION_SAVE": change = { automations: { [action.automation.id]: { $set: action.automation } }, }; newState = update(previousState, change); break; case "STATE_SAVE": change = { sceneStates: { [action.sceneState.id]: { $set: action.sceneState } }, }; if (previousState.scenes[action.sceneState.sceneId]) { change.scenes = { [action.sceneState.sceneId]: { sceneStates: { $add: [action.sceneState.id], }, }, }; } else { change.pendingJoins = { scenes: { [action.sceneState.sceneId]: { $add: [action.sceneState.id], }, }, }; } newState = update(previousState, change); break; case "ROOM_DELETE": if (!(action.roomId in previousState.rooms)) { console.warn(`Room to delete ${action.roomId} does not exist`); break; } // This update does not ensure the consistent update of switchId/dimmerId properties // on output devices connected to an input device in this room. Please manually request // all devices again if consistent update is desired change = { devices: { $unset: [] } }; for (const id of previousState.rooms[action.roomId].devices) { change.devices.$unset.push(id); } change.rooms = { $unset: [action.roomId] }; if (previousState.active.activeRoom === action.roomId) { change.active = { activeRoom: { $set: -1 } }; } newState = update(previousState, change); break; case "AUTOMATION_DELETE": change = { automations: { $unset: [action.id] }, }; newState = update(previousState, change); break; case "SCENE_DELETE": if (!(action.sceneId in previousState.scenes)) { console.warn(`Scene to delete ${action.sceneId} does not exist`); break; } // This update does not ensure the consistent update of switchId/dimmerId properties // on output devices connected to an input device in this room. Please manually request // all devices again if consistent update is desired change = { states: { $unset: [] } }; for (const id of previousState.scenes[action.sceneId].sceneStates) { change.sceneStates.$unset.push(id); } change.scenes = { $unset: [action.sceneId] }; if (previousState.active.activeScene === action.sceneId) { change.active = { activeScene: { $set: -1 } }; } newState = update(previousState, change); break; case "STATE_DELETE": if (!(action.stateId in previousState.sceneStates)) { console.warn(`State to delete ${action.stateId} does not exist`); break; } change = { sceneStates: { $unset: [action.stateId] }, }; if ( previousState.scenes[previousState.sceneStates[action.stateId].sceneId] ) { change.scenes = { [previousState.sceneStates[action.stateId].sceneId]: { sceneStates: { $remove: [action.stateId] }, }, }; } newState = update(previousState, change); break; case "DEVICE_DELETE": if (!(action.deviceId in previousState.devices)) { console.warn(`Device to delete ${action.deviceId} does not exist`); break; } change = { devices: { $unset: [action.deviceId] }, }; if (previousState.rooms[previousState.devices[action.deviceId].roomId]) { change.rooms = { [previousState.devices[action.deviceId].roomId]: { devices: { $remove: [action.deviceId] }, }, }; } newState = update(previousState, change); break; case "LOGOUT": newState = update(initState, {}); break; case "SET_ACTIVE": newState = update(previousState, { active: { [action.key]: { $set: action.value, }, }, }); break; case "REDUX_WEBSOCKET::MESSAGE": const allDevices = JSON.parse(action.payload.message); const devices = allDevices.filter( (d) => (d.fromHostId === null || d.fromHostId === undefined) && !d.deleted ); const hostDevicesMapByHostId = allDevices .filter((d) => d.fromHostId && !d.deleted) .reduce((a, e) => { const hostId = e.fromHostId; //delete e.fromHostId; a[hostId] = a[hostId] || []; a[hostId].push(e); return a; }, {}); newState = reducer(previousState, { type: "DEVICES_UPDATE", partial: true, devices, }); for (const hostId in hostDevicesMapByHostId) { newState = reducer(newState, { type: "HOST_DEVICES_UPDATE", devices: hostDevicesMapByHostId[hostId], partial: true, hostId, }); } break; case "HG_UPDATE": newState = update(previousState, { [action.key]: { $set: action.value }, }); break; default: console.warn(`Action type ${action.type} unknown`, action); return previousState; } return newState; } const initState = { pendingJoins: { rooms: {}, scenes: {}, automations: {}, }, active: { activeRoom: -1, activeTab: "Devices", activeScene: -1, activeAutomation: -1, activeHost: -1, }, login: { loggedIn: false, token: null, }, userInfo: null, /** @type {[integer]Room} */ rooms: {}, /** @type {[integer]Scene} */ scenes: {}, /** @type {[integer]Automation} */ automations: {}, /** @type {[integer]Device} */ devices: {}, /** @type {[integer]SceneState} */ sceneStates: {}, /** @type {User[]} */ guests: [], /** @type {User[]} */ hosts: [], /** @type {[integer]Device} */ hostDevices: {}, /** @type {[integer]Eoom} */ hostRooms: {}, }; function createSmartHutStore() { const token = localStorage.getItem("token"); const exp = localStorage.getItem("exp"); const initialState = update(initState, { login: { token: { $set: token }, loggedIn: { $set: !!(token && exp > new Date().getTime()) }, }, }); if (!initialState.login.loggedIn) { localStorage.removeItem("token"); localStorage.removeItem("exp"); initialState.login.token = null; } const store = createStore( reducer, initialState, compose(applyMiddleware(thunk), applyMiddleware(reduxWebSocket())) ); if (initialState.login.loggedIn) { store.dispatch(connect(socketURL(token))); } return store; } const smartHutStore = createSmartHutStore(); export default smartHutStore;