System architecture, design patterns, and data flow.
- System Overview
- Component Hierarchy
- Data Flow
- Communication Layer
- State Management
- Design Patterns
- Security Model
┌─────────────────────────────────────────────────────────────┐
│ Browser │
│ ┌──────────────────────────────────────────────────┐ │
│ │ React App (Vite) │ │
│ │ │ │
│ │ ┌──────────────────────────────────────┐ │ │
│ │ │ Page Components │ │ │
│ │ │ (Home, Dashboard, GrowBook, etc.) │ │ │
│ │ └──────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ┌──────────────────────────────────────┐ │ │
│ │ │ Card Components │ │ │
│ │ │ (SensorCards, ControlCards, etc.) │ │ │
│ │ └──────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ┌──────────────────────────────────────┐ │ │
│ │ │ Context Providers │ │ │
│ │ │ (HA, Global, Premium, Medium) │ │ │
│ │ └──────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ┌──────────────────────────────────────┐ │ │
│ │ │ WebSocket Client │ │ │
│ │ └──────────────────────────────────────┘ │ │
│ │ │ │ │
│ └────────────────────┼───────────────────────────┘ │
│ │ │
└───────────────────────┼────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Home Assistant │
│ ┌──────────────────────────────────────────────────┐ │
│ │ WebSocket Server │ │
│ └──────────────────────────────────────┬─────────┘ │
│ │ │
│ ┌───────────────────────────────────────┴─────────┐ │
│ │ State Machine │ │
│ │ (Entities, Devices, Services) │ │
│ └──────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────┐ │
│ │ OGB Backend (Optional) │ │
│ │ (Premium features, advanced logic) │ │
│ └──────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
Frontend:
- React 18 - UI framework
- Vite - Build tool and dev server
- styled-components - CSS-in-JS
- React Router - Client-side routing
- home-assistant-js-websocket - HA WebSocket client
State Management:
- React Context API - Global state
- useState/useReducer - Local state
- Custom hooks - Reusable state logic
Communication:
- WebSocket - Real-time communication
- REST API (via HA) - Initial data fetch
App
├── ErrorBoundary (Wrapper)
├── HomeAssistantProvider
├── OGBPremiumProvider
├── GlobalStateProvider
└── BrowserRouter
└── Routes
├── / → Interface
│ └── Home
├── /home → Home
├── /dashboard → Dashboard
├── /growbook → GrowBook
└── /settings → Settings
DashboardSlider
├── TempCard
├── HumCard
├── VPDCard
├── CO2Card
└── ...
DashboardStats
├── TemperatureMetric
├── HumidityMetric
├── VPD Metric
└── ...
ControlCollection
├── SwitchCard
├── SliderCard
├── SelectCard
├── TimeCard
└── TextCard
DeviceCard
└── DeviceItem (for each device)
App
├── ErrorBoundary
│
├── HomeAssistantProvider (innermost)
│ └── Provides: entities, connection, currentRoom, callService
│
├── GlobalStateProvider
│ └── Provides: HASS object, room options
│
└── OGBPremiumProvider (outermost)
└── Provides: authentication, premium features
- App mounts →
useHomeAssistant()initializes - WebSocket connects → Establishes connection to HA
- Fetch initial data → Gets all entities and state
- Subscribe to events → Listens for state changes
- Render components → UI displays current state
- State change occurs in HA
- WebSocket event →
state_changedevent received - Update context →
entitiesupdated inHomeAssistantContext - Re-render components → React re-renders affected components
- UI reflects new state → User sees updated values
- User clicks button/slider
- Event handler → Component's onChange/onToggle
- Call service →
callService()from context - Send to HA → WebSocket sends service call
- HA executes → Device state changes
- Event received →
state_changedevent - UI updates → Components re-render with new state
User Action
│
▼
Component Handler
│
▼
Context Service Call
│
▼
WebSocket Message (to HA)
│
▼
Home Assistant executes
│
▼
WebSocket Event (from HA)
│
▼
Context Updates State
│
▼
Component Re-renders
│
▼
UI Updated
File: src/Components/Context/HomeAssistantContext.jsx
Initialization:
useEffect(() => {
// Connect to HA WebSocket
const connect = async () => {
const ws = await createConnection({
authToken: token,
});
setConnection(ws);
};
connect();
}, []);Subscriptions:
// Subscribe to state changes
connection.subscribeEvents((event) => {
if (event.event_type === 'state_changed') {
// Update local state
}
}, 'state_changed');
// Subscribe to grow logs
connection.subscribeEvents((event) => {
if (event.event_type === 'LogForClient') {
// Add to log array
}
}, 'LogForClient');State Change Event
{
event_type: "state_changed",
data: {
entity_id: "sensor.temperature",
new_state: {
state: "25",
attributes: {
unit_of_measurement: "°C",
friendly_name: "Temperature"
}
},
old_state: { /* previous state */ }
}
}Log Event
{
event_type: "LogForClient",
time_fired: 1234567890,
data: {
room: "flower tent",
message: "VPD adjusted",
Device: "Exhaust Fan",
Action: "increase"
}
}Medium Update Event
{
event_type: "MediumPlantsUpdate",
time_fired: 1234567890,
data: {
Name: "flower tent",
plants: [
{
id: 1,
name: "Gelato",
stage: "flowering"
}
]
}
}File: src/Components/Context/HomeAssistantContext.jsx
const callService = async (domain, service, serviceData) => {
try {
await connection.callService(domain, service, {
entity_id: serviceData.entity_id,
...serviceData
});
} catch (error) {
console.error('Service call failed:', error);
}
};Usage:
// Turn on a light
await callService('light', 'turn_on', {
entity_id: 'light.led_1'
});
// Set a number
await callService('input_number', 'set_value', {
entity_id: 'input_number.temperature_target',
value: 25
});
// Select an option
await callService('input_select', 'select_option', {
entity_id: 'input_select.control_mode',
option: 'vpd'
});HomeAssistantContext
Location: src/Components/Context/HomeAssistantContext.jsx
State:
connection- WebSocket connection objectentities- All HA entities (keyed by entity_id)currentRoom- Currently selected roomroomOptions- Available roomsaccessToken- Auth token
Methods:
callService()- Call HA servicesubscribeEvents()- Subscribe to WebSocket eventsgetConnection()- Get current connection
Usage Pattern:
const { entities, currentRoom, callService } = useHomeAssistant();
const tempEntity = entities['sensor.temperature'];GlobalStateProvider
Location: src/Components/Context/GlobalContext.jsx
State:
HASS- Home Assistant object (PROD only)roomOptions- Available roomscurrentRoom- Current selected room
Purpose:
- Provides HASS object for PROD mode
- Manages room selection globally
- Legacy context (being phased out)
OGBPremiumProvider
Location: src/Components/Context/OGBPremiumContext.jsx
State:
connection- OGB backend connectionisPremium- Premium statusaccessToken- Premium tokenuser- User data
Methods:
login()- Login to premiumlogout()- LogoutrefreshToken()- Refresh tokensubscribe()- Subscribe to backend events
Purpose:
- Manages premium authentication
- Handles premium features
- Communicates with OGB backend
MediumContext
Location: src/Components/Context/MediumContext.jsx
State:
plants- Plant/medium dataeditingPlant- Currently editing plantmediums- Available mediums
Methods:
updatePlant()- Update plant dataaddPlant()- Add new plantdeletePlant()- Delete plantsetEditing()- Set editing state
Purpose:
- Manages plant/medium data
- Handles grow book functionality
- Syncs with backend
For component-local state, use useState:
const [isExpanded, setIsExpanded] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [logs, setLogs] = useState([]);
// For complex state
const [state, dispatch] = useReducer(reducer, initialState);Best Practices:
- Functional updates for derived state:
// Bad
setCount(count + 1);
// Good
setCount(prev => prev + 1);- Memoize expensive calculations:
const filteredData = useMemo(() => {
return data.filter(item => item.room === currentRoom);
}, [data, currentRoom]);- Batch updates with single setState:
// Bad
setStateA(newState);
setStateB(newState);
// Good
setCombinedState({ a: newState, b: newState });Context providers wrap the app to share state:
<HomeAssistantProvider>
<GlobalStateProvider>
<OGBPremiumProvider>
<App />
</OGBPremiumProvider>
</GlobalStateProvider>
</HomeAssistantProvider>Reusable logic encapsulated in hooks:
// useSafeMode.js
export const useSafeMode = () => {
const [isSafeMode, setIsSafeMode] = useState(false);
const [confirmation, setConfirmation] = useState(null);
const confirmChange = (action) => {
if (isSafeMode) {
setConfirmation(action);
} else {
action();
}
};
return { isSafeMode, confirmation, confirmChange };
};Components that work together:
<DashboardSlider>
<TempCard />
<HumCard />
<VPDCard />
</DashboardSlider>Render function as prop:
<HistoryChart sensorId={sensor.id}>
{({ data, loading }) => (
<div>{loading ? 'Loading...' : renderChart(data)}</div>
)}
</HistoryChart>Separate logic from rendering:
// Container - handles logic
const TempCardContainer = () => {
const { entities } = useHomeAssistant();
const sensors = filterSensors(entities);
return <TempCardPresentation sensors={sensors} />;
};
// Presentation - handles rendering
const TempCardPresentation = ({ sensors }) => (
<Card>
{sensors.map(sensor => <SensorItem key={sensor.id} {...sensor} />)}
</Card>
);Home Assistant Authentication:
- User logs into Home Assistant
- HA generates long-lived access token
- Token stored in localStorage (via
secureTokenStorage.js) - Token sent with WebSocket connection
- HA validates token on connection
Premium Authentication:
- User logs in via OGB backend
- Backend validates credentials
- Backend generates JWT token
- Token stored in localStorage
- Token sent with requests to backend
- Backend validates JWT on each request
Location: src/utils/secureTokenStorage.js
// Secure token storage with encryption
export const setToken = (key, value) => {
const encrypted = encrypt(value);
localStorage.setItem(key, encrypted);
};
export const getToken = (key) => {
const encrypted = localStorage.getItem(key);
return decrypt(encrypted);
};- Never log tokens to console
// Bad
console.log('Token:', token);
// Good
console.log('Token:', '[REDACTED]');- Validate all inputs
const validateEntityId = (entityId) => {
if (!entityId || !entityId.includes('.')) {
throw new Error('Invalid entity ID');
}
return entityId;
};- Sanitize user content
// Use dangerouslySetInnerHTML cautiously
<div dangerouslySetInnerHTML={{ __html: sanitize(userContent) }} />- Use parameterized queries (if using backend)
// Bad
query(`SELECT * FROM users WHERE id = ${userId}`);
// Good
query(`SELECT * FROM users WHERE id = ?`, [userId]);- Handle errors securely
try {
// operation
} catch (error) {
// Don't expose sensitive info to user
console.error('Operation failed:', error.message);
showGenericError();
}- Uses WSS (WebSocket Secure) for encrypted connection
- Token sent in initial connection handshake
- Connection re-establishes with same token
- Automatic reconnection on disconnect
For external hosting, configure CORS:
// vite.config.ts
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://home-assistant:8123',
changeOrigin: true,
ws: true,
}
}
}
});Use React.memo:
const ExpensiveComponent = memo(({ data }) => {
// Only re-renders if data changes
});Use useMemo:
const filtered = useMemo(() => {
return data.filter(/* expensive filter */);
}, [data]);Use useCallback:
const handleClick = useCallback(() => {
// Function reference stays same
}, [dependency]);Limit subscriptions:
// Only subscribe to needed events
connection.subscribeEvents(handler, 'state_changed');Batch updates:
// Collect multiple state changes
const updates = [];
connection.subscribeEvents((event) => {
updates.push(event.data);
// Batch process updates
}, 'state_changed');Clean up listeners:
useEffect(() => {
const unsubscribe = connection.subscribeEvents(handler, 'event');
return () => unsubscribe();
}, []);Clean up on unmount:
useEffect(() => {
// Setup
return () => {
// Cleanup
cancelSubscription();
clearTimers();
};
}, []);Limit data storage:
// Keep only recent logs
setLogs(prev => prev.slice(0, 50));Location: src/misc/ErrorBoundary.jsx
Catches React errors and displays fallback:
class ErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, info) {
logError(error, info);
}
render() {
if (this.state.hasError) {
return <ErrorFallback />;
}
return this.props.children;
}
}Handle connection errors:
try {
await connect();
} catch (error) {
if (isAuthError(error)) {
showLoginModal();
} else {
showGenericError();
}
}Handle service call errors:
try {
await callService('domain', 'service', data);
} catch (error) {
console.error('Service call failed:', error);
showToast('Failed to execute command', 'error');
}Validate entity data:
const validateEntity = (entity) => {
if (!entity || typeof entity !== 'object') {
return null;
}
if (!entity.entity_id || !entity.state) {
return null;
}
return entity;
};Validate WebSocket events:
const validateEvent = (event) => {
if (!event || typeof event !== 'object') {
return null;
}
if (!event.event_type || !event.data) {
return null;
}
return event;
};End of Architecture Documentation