- Published on
Smart RGB lights [Part 4] - State management and API calls
- Authors
- Name
- Branimir Kirilov
Modifying the Wemos Server
The initial chapter involved setting up a server on the Wemos board that listens for GET
requests on /desk/rgb
. However, for the sake of consistency in the event of multiple light sources on multiple devices, I intend to modify this to a POST
request on light/desk
, with the parameters being passed in the request body.
// changing the mapping
server.on("/lights/desk", HTTP_POST, handleLightUpdate);
// the updated POST handler that reads the JSON body
void handleLightUpdate()
{
// Check if body received
if (server.hasArg("plain") == false)
{
sendBadRequestResponse();
return;
}
StaticJsonDocument<200> jsonDoc;
deserializeJson(jsonDoc, server.arg("plain"));
int r = jsonDoc["r"].as<int>();
int g = jsonDoc["g"].as<int>();
int b = jsonDoc["b"].as<int>();
float brightness = jsonDoc["brightness"].as<float>();
writeColor(r, g, b, brightness);
}
I have also extracted the code that sets the pin values to a different method - writeColor
you can check the full device code for its implementation.
State management
Why?
Maintaining a tidy and organized codebase is essential when developing applications, especially those that are intended to be expanded over time. As I continue to build out my home automation project, I knew that effective state management would be a key factor in keeping the project maintainable in the long term.
After some research, I decided to utilize Redux Toolkit
to handle state management. The toolkit provides a set of tools and guidelines that make it easier to write Redux code while also improving performance and reducing boilerplate.
Redux boilerplate
To get started, I followed the Redux Toolkit's quick start guide which provided me with all the necessary information and packages required to use it. Once all the necessary packages were installed, I created a /store
folder to initialize my state, and I defined the rootReducer in rootReducer.ts
, which combines all the reducers used in the application. This centralizes the application's state and makes it more accessible to all components.
// store.ts
import {
configureStore,
ThunkAction,
Action,
} from '@reduxjs/toolkit';
import { rootReducer } from './rootReducer';
export const store = configureStore({
reducer: rootReducer,
});
export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof store.getState>;
export type AppThunk<ReturnType = void> = ThunkAction<
ReturnType,
RootState,
unknown,
Action<string>
>;
// rootReducer.ts
import { combineReducers } from '@reduxjs/toolkit';
import lightsSlice from './lights/lightsSlice';
export const rootReducer = combineReducers({
lights: lightsSlice
});
Lights slice
From there, I defined the lightsSlice
- the part of the store that manages the state of the lights in my application. To keep things organized, I placed all relevant files in store/lights
. As my light state management essentially consisted of simple CRUD operations, I made use of the createEntityAdapter provided by Redux Toolkit. This generated a set of pre-built reducers and selectors
for performing CRUD operations on a normalized state structure containing instances of a particular type of data object. In my case - EntityState<LightSouce>
.
The lightsSlice also includes an extraReducers
field to handle the async thunk that will load the lights list (I'll write it later). Additionally, I defined the lightUpdated action which uses the lightsAdapter's updateOne
to update an individual light.
Finally, the code exports the lightUpdated action creator
, which can be used to update a single light entity within the store. The lightSelectors constant uses the getSelectors
function from createEntityAdapter to generate a set of selectors for querying the lights slice of the store. These selectors can be used to access entity fields of the lights state, as well as any additional state fields added to the LightsState interface. By using these selectors, components can easily retrieve and display relevant portions of the store's state.
// lightsSlice.ts
import { createEntityAdapter, createSlice, EntityState } from '@reduxjs/toolkit';
import { RootState } from '../store';
import { LightSource, Status } from '../../types/types';
import { fetchLightsData } from './lightsThunks';
interface LightsState extends EntityState<LightSource> {
status: Status;
error: string | null;
}
const lightsAdapter = createEntityAdapter<LightSource>({
selectId: (lightSource) => lightSource.id,
// temporarily sort desc by notImplemented field
sortComparer: (a, b) => Number(b.notImplemented) - Number(a.notImplemented)
});
export const lightsSlice = createSlice({
name: 'lightsSlice',
initialState: lightsAdapter.getInitialState<LightsState>({
ids: [],
entities: {},
status: Status.IDLE,
error: null,
}),
reducers: {
lightUpdated: lightsAdapter.updateOne,
},
extraReducers: (builder) => {
builder
.addCase(fetchLightsData.pending, (state) => {
state.status = Status.LOADING;
})
.addCase(fetchLightsData.fulfilled, (state, action) => {
state.status = Status.IDLE;
lightsAdapter.upsertMany(state, action.payload);
})
.addCase(fetchLightsData.rejected, (state) => {
state.status = Status.FAILED;
})
},
});
export const { lightUpdated } = lightsSlice.actions;
export const lightSelectors = lightsAdapter.getSelectors<RootState>(
(state) => state.lights
)
export default lightsSlice.reducer;
Service layer
In addition to the lightsSlice I previously discussed, I'll also be implementing a service class
to handle API calls. For now, it's only a dummy function because I don't have an actual API to fetch this data from, so I'll just resolve the promise with the data I defined earlier in the static JSON file. But someday I might want to call an actual server for this, so this abstraction would come in handy. I'll also be using the same class later to make the API calls to my device.
// LightService.ts
...
import Constants from 'expo-constants';
export default class LightService {
// A mock function to mimic making an async GET request for light sources data
static fetchLightsData() {
return new Promise<{ data: LightSource[] }>((resolve) =>
setTimeout(() => resolve({ data: lightsListData }), 0)
);
}
}
Hooks
I'll also have to define the hooks
that I'll use to access the state from my React components:
// hooks.ts
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from '../store/store';
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
With the service layer defined, I can now create the async thunk that makes an "API call" using LightService and returns the response:
// lightsThunks.ts
export const fetchLightsData = createAsyncThunk(
'lights/fetchLightsData',
async () => {
const response = await LightService.fetchLightsData();
return response.data;
}
);
Using the store
Now, I can test my store by changing my <LightsList.tsx />
component to dispatch a fetchLightsData
action instead of importing the light data from the static file:
// LightsList.tsx
import { useEffect } from 'react';
import { useAppDispatch, useAppSelector } from '../../hooks/hooks';
import { fetchLightsData } from '../../store/lights/lightsThunks';
import { lightSelectors } from '../../store/lights/lightsSlice';
export default function LightsList({ navigation }: LightsListProps) {
const lights = useAppSelector(lightSelectors.selectAll); // selects all lights
const dispatch = useAppDispatch();
useEffect(() => {
// dispatch an action to "fetch" the lights them when the component mounts
dispatch(fetchLightsData());
}, []);
return (
<>
<FlatList
data={lights} // use the lights
....
}
🎉 And voila, now the FlatList
in <LightsList />
is populated from the state:
In the same manner, I can use a selector to get a light source entity by an id in the <LightSourceDetails />
component:
export default function LightDetails({ route }: LightDetailsProps) {
const id = route.params.id;
const dispatch = useAppDispatch();
const item = useAppSelector((state) => selectLightById(state, id));
// use the item
}
Neat! Now all my components are updated when the state is changed.
Calling the server
I now have to define the actual update of a LightSource
entity. I'll do this by defining a new async thunk first. It will be calling my LightService
again, but this time, I'll be doing the API call to the WEMOS server with the params of the entity:
// lightThunks.ts
export const updateLight = createAsyncThunk<
void,
{ id: string, changes: Partial<LightSource> },
{ rejectValue: string }
>('lights/updateLight', async ({ id, changes }, { getState, rejectWithValue }) => {
try {
// get a light from the state
const state = getState() as RootState;
const light = state.lights.entities[id];
if (!light) {
throw new Error(`Light with id ${id} not found`);
}
// get all of the light properties including the changed ones and call the service
await LightService.changeLight({ ...light, ...changes });
} catch (error) {
return rejectWithValue('Error updating light');
}
});
Then, I'll define a new static function in my service. It will call the Wemos server with the RGB values and brightness of the light entity.
// LightService.ts
...
const WEMOS_URL = Constants?.expoConfig?.extra?.WEMOS_HOST;
static changeLight(data: LightSource): Promise<Response> {
const url = `${WEMOS_URL}/lights/${data.id}`;
const req: RequestInit = {
method: 'POST',
body: JSON.stringify({
...hexToRgb(data.color),
brightness: data.brightness / 100
})
};
return fetch(url, req);
}
I've injected the WEMOS_HOST through my expo config. There are other ways to do this and you can read about them here.
Disclaimer
Apparently, the application needs to make HTTP requests to a local server running on a board. Typically, this would require authentication and authorization, but since the app is for personal use only, these security measures are not that necessary. The server is only accessible within the local network, so only those connected to it and aware of the server's IP address and port can interact with it. While this may be considered a form of "security through obscurity", the potential risk is minimal as the worst that could happen is the lights on the my desk may change color or turn on when they shouldn't. While this may be inconvenient and result in higher electricity bills, I'm willing to accept the risk. :) However, if the application deals with sensitive data, it is not recommended to use this solution. It's important to prioritize security, especially when dealing with IoT and there are already a ton of solutions that do this out of the box (e.g. AWS IoT Core)
Updating the lights slice
Now, it's time to make the lightSlice
aware of this new async thunk that I created. I'll make use of the extraReducer
and the updateOne
that is also predefined by the createEntityAdapter
. When the promise status changes, I'll also update the entity Status
field. I'll be using this to create some loading overlays, as network calls may take time.
// lightsSlice.ts
+ .addCase(updateLight.pending, (state, action) => {
+ lightsAdapter.updateOne(state, {
+ id: action.meta.arg.id,
+ changes: { status: Status.LOADING }
+ })
+ })
+ .addCase(updateLight.fulfilled, (state, action) => {
+ lightsAdapter.updateOne(state, {
+ id: action.meta.arg.id,
+ changes: { status: Status.IDLE, ...action.meta.arg.changes }
+ })
+ })
+ .addCase(updateLight.rejected, (state, action) => {
+ lightsAdapter.updateOne(state, {
+ id: action.meta.arg.id,
+ changes: { status: Status.FAILED }
+ })
+ });
Dispatching update action
I'll add a handler for the color picker in my <LightSourceDetails />
screen and I'll dispatch the action from there. It's a good idea to use the useCallback hook to memoize this function, otherwise, it will be created every time the component renders and this may cause performance impact.
// LightSourceDetails.tsx
+ const onColorChangeComplete = useCallback((color: string) => {
+ dispatch(updateLight({ id: item.id, changes: { color } }));
+ }, [item, dispatch, updateLight]);
...
<ColorPicker
+ onColorChangeComplete={onColorChangeComplete}
/>
...
Testing the integration
And now it's time to test the whole thing! I have to make sure my device is connected to the power outlet and the expectation is that whenever I change the color by dragging the wheel the color will change ... and ... it does! 🎉
Demo
The next step is to make the remaining buttons and sliders dispatch the updateLight
action and finish the styling of the UI. After I did this, I would say I'm pretty much done with the first iteration of this application. I can now enable/disable the desk light, and change its brightness and color. You can check the video below on how it works.
You can find all the source code on GitHub.
Since the application is still dependent on the Expo server to function, I'll need to generate an APK that I can install on my actual Android device. In the upcoming part of this blog series, I will explain the steps for doing so by using EAS Build.