import { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react'; import api from '../services/api'; const LicenseContext = createContext(null); const SERIAL_URL = process.env.REACT_APP_SERIAL_URL || 'http://localhost:5000'; const HEARTBEAT_MS = 30000; // server releases a slot after 120s, so 4 missed beats export const useLicense = () => { const ctx = useContext(LicenseContext); if (!ctx) throw new Error('useLicense must be used within LicenseProvider'); return ctx; }; /** Stable identifier for the bridge this browser is using. * * The bridge deliberately never talks to Django, so the browser reports which * station it is on. Built from the bridge address plus its COM port, so two * browsers on the same station share one slot rather than consuming two. */ async function readStationKey() { try { const res = await fetch(`${SERIAL_URL}/status`); if (!res.ok) return null; const status = await res.json(); const raw = `${SERIAL_URL}|${status.com_port || 'unknown'}`; // Short stable hash - this is an identifier, not a secret let hash = 0; for (let i = 0; i < raw.length; i++) { hash = ((hash << 5) - hash + raw.charCodeAt(i)) | 0; } return { station_key: `st-${Math.abs(hash).toString(36)}`, com_port: status.com_port || '', bridge_version: status.version || '', }; } catch { return null; } } export function LicenseProvider({ children }) { const [state, setState] = useState({ allowed: true, // assume allowed until told otherwise, so a slow stationsUsed: null, // first heartbeat never blocks a working station stationsLicensed: null, detail: null, stationKey: null, }); const [status, setStatus] = useState(null); const stationRef = useRef(null); const beat = useCallback(async () => { if (!stationRef.current) { stationRef.current = await readStationKey(); } const station = stationRef.current; if (!station) return; // bridge unreachable; nothing to register try { const res = await api.post('/api/license/heartbeat/', station); setState({ allowed: res.data.allowed, stationsUsed: res.data.stations_used, stationsLicensed: res.data.stations_licensed, detail: null, stationKey: station.station_key, }); } catch (err) { if (err.response?.status === 403) { const d = err.response.data || {}; setState({ allowed: false, stationsUsed: d.stations_used ?? null, stationsLicensed: d.stations_licensed ?? null, detail: d.detail || 'Licence limit reached', stationKey: station.station_key, }); } // Other failures (server down) leave the previous state alone - // a licence check must not stop a working weighbridge. } }, []); useEffect(() => { beat(); const timer = setInterval(beat, HEARTBEAT_MS); return () => clearInterval(timer); }, [beat]); const loadStatus = useCallback(async () => { try { const res = await api.get('/api/license/'); setStatus(res.data); return res.data; } catch { return null; } }, []); useEffect(() => { loadStatus(); }, [loadStatus]); return ( {children} ); }