diff --git a/Nginx/dev.conf.d/local.conf b/Nginx/dev.conf.d/local.conf
index 54e052edc..3b497b3b3 100644
--- a/Nginx/dev.conf.d/local.conf
+++ b/Nginx/dev.conf.d/local.conf
@@ -54,6 +54,17 @@ server {
alias /usr/share/nginx/django_file_storage/;
}
+ location /ws/ {
+ proxy_pass http://django_server;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_read_timeout 86400;
+ }
+
location /sockjs-node {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index ec4b67a96..1f9c3fb4d 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -60,7 +60,7 @@ services:
django:
image: "docker.pkg.github.com/frg-fossee/esim-cloud/django:dev"
build: ./esim-cloud-backend/
- command: "python3 manage.py runserver 0.0.0.0:8000"
+ command: "daphne -b 0.0.0.0 -p 8000 esimCloud.asgi:application"
ports:
- "8000:8000"
volumes:
@@ -117,7 +117,7 @@ services:
# - ./mysql_data:/var/lib/mysql
db:
- image: postgres
+ image: postgres:13
volumes:
- ./postgres_data:/var/lib/postgresql/data/
env_file:
diff --git a/eda-frontend/src/components/SchematicEditor/Header.js b/eda-frontend/src/components/SchematicEditor/Header.js
index 7d6c227c9..609615132 100644
--- a/eda-frontend/src/components/SchematicEditor/Header.js
+++ b/eda-frontend/src/components/SchematicEditor/Header.js
@@ -419,7 +419,7 @@ function Header ({ gridRef }) {
{shared === true
?
: <> Turn On sharing >
diff --git a/eda-frontend/src/components/SchematicEditor/Helper/SideBar.js b/eda-frontend/src/components/SchematicEditor/Helper/SideBar.js
index 383d8212b..e5ae24e93 100644
--- a/eda-frontend/src/components/SchematicEditor/Helper/SideBar.js
+++ b/eda-frontend/src/components/SchematicEditor/Helper/SideBar.js
@@ -28,6 +28,9 @@ export function AddComponent (component, imgref) {
return null
}
var funct = function (graph, evt, target, x, y) {
+ // Viewer mode: editing is disabled — reject component drops silently
+ if (!graph.isEnabled()) return
+
var parent = graph.getDefaultParent()
var model = graph.getModel()
diff --git a/eda-frontend/src/components/SchematicEditor/Helper/ToolbarTools.js b/eda-frontend/src/components/SchematicEditor/Helper/ToolbarTools.js
index 7b7da98da..629df2b1b 100644
--- a/eda-frontend/src/components/SchematicEditor/Helper/ToolbarTools.js
+++ b/eda-frontend/src/components/SchematicEditor/Helper/ToolbarTools.js
@@ -1063,6 +1063,42 @@ export function renderGalleryXML(xml) {
var xmlDoc = mxUtils.parseXml(xml)
parseXmlToGraph(xmlDoc, graph)
}
+
+/**
+ * Register a callback that fires whenever the graph model changes.
+ * Returns an unregister function — call it on cleanup to avoid leaks.
+ * Returns a no-op if called before the graph is initialised.
+ *
+ * Used by Phase 2 live sync to debounce-and-broadcast XML snapshots.
+ * Phase 3 incremental events can register additional listeners the same way.
+ */
+export function registerChangeListener(callback) {
+ if (!graph) return function () {}
+ var listener = function () { callback() }
+ graph.getModel().addListener(mxEvent.CHANGE, listener)
+ return function unregister() {
+ graph.getModel().removeListener(listener)
+ }
+}
+
+/**
+ * Enable or disable viewer mode on the graph.
+ *
+ * Viewer mode (isViewer = true):
+ * - Disables cell selection, moving, connecting, and deleting
+ * - Keeps panning active so the viewer can navigate
+ * - Zoom toolbar buttons continue to work (they call graph.zoomIn/Out directly)
+ * - Keyboard shortcuts that call graph.isEnabled() are blocked automatically
+ *
+ * Editor mode (isViewer = false): restores full editing capability.
+ */
+export function setViewerMode(isViewer) {
+ if (!graph) return
+ graph.setEnabled(!isViewer)
+ if (isViewer) {
+ graph.setPanning(true)
+ }
+}
// Certain Variables need to be Defined before Saving the Circuit, XML Wire Connections does that
function XMLWireConnections() {
var erc = true
diff --git a/eda-frontend/src/components/SchematicEditor/PresencePanel.js b/eda-frontend/src/components/SchematicEditor/PresencePanel.js
new file mode 100644
index 000000000..b37638c27
--- /dev/null
+++ b/eda-frontend/src/components/SchematicEditor/PresencePanel.js
@@ -0,0 +1,183 @@
+import React, { useEffect } from 'react'
+import {
+ Chip,
+ Collapse,
+ Divider,
+ IconButton,
+ List,
+ ListItem,
+ ListItemIcon,
+ ListItemText,
+ Snackbar,
+ Tooltip,
+ Typography
+} from '@material-ui/core'
+import ExpandLessIcon from '@material-ui/icons/ExpandLess'
+import ExpandMoreIcon from '@material-ui/icons/ExpandMore'
+import FiberManualRecordIcon from '@material-ui/icons/FiberManualRecord'
+import PersonIcon from '@material-ui/icons/Person'
+import CloseIcon from '@material-ui/icons/Close'
+import { makeStyles } from '@material-ui/core/styles'
+import { useSelector, useDispatch } from 'react-redux'
+import { presenceClearNotification } from '../../redux/actions/collaborationActions'
+import { useIsEditor } from '../../utils/useIsEditor'
+
+const NOTIFICATION_DURATION_MS = 4000
+
+const useStyles = makeStyles((theme) => ({
+ root: {
+ borderTop: `1px solid ${theme.palette.divider}`,
+ marginTop: theme.spacing(1)
+ },
+ header: {
+ display: 'flex',
+ alignItems: 'center',
+ padding: theme.spacing(0.5, 1),
+ cursor: 'pointer',
+ userSelect: 'none',
+ '&:hover': {
+ backgroundColor: theme.palette.action.hover
+ }
+ },
+ dot: {
+ fontSize: 10,
+ marginRight: theme.spacing(0.75),
+ flexShrink: 0
+ },
+ dotOnline: { color: '#52c41a' },
+ dotOffline: { color: '#bfbfbf' },
+ countText: {
+ fontWeight: 600,
+ fontSize: '0.82rem',
+ flexGrow: 1
+ },
+ userItem: {
+ paddingTop: 2,
+ paddingBottom: 2,
+ paddingLeft: theme.spacing(3)
+ },
+ userIcon: {
+ minWidth: 28,
+ color: theme.palette.text.secondary
+ },
+ userName: {
+ fontSize: '0.80rem'
+ },
+ guestName: {
+ fontSize: '0.80rem',
+ color: theme.palette.text.disabled,
+ fontStyle: 'italic'
+ },
+ emptyText: {
+ padding: theme.spacing(0.5, 3),
+ fontSize: '0.78rem',
+ color: theme.palette.text.disabled,
+ fontStyle: 'italic'
+ },
+ roleChip: {
+ height: 18,
+ fontSize: '0.68rem',
+ marginLeft: theme.spacing(0.75)
+ }
+}))
+
+export default function PresencePanel () {
+ const classes = useStyles()
+ const dispatch = useDispatch()
+ const { connected, users, notification } = useSelector(state => state.presenceReducer)
+ const saveId = useSelector(state => state.saveSchematicReducer.details.save_id)
+ const isEditor = useIsEditor()
+
+ const [expanded, setExpanded] = React.useState(true)
+
+ // Auto-dismiss notifications
+ useEffect(() => {
+ if (!notification) return
+ const timer = setTimeout(() => {
+ dispatch(presenceClearNotification())
+ }, NOTIFICATION_DURATION_MS)
+ return () => clearTimeout(timer)
+ }, [notification, dispatch])
+
+ // Don't render until a schematic is saved/loaded (no room to join)
+ if (!saveId) return null
+
+ const onlineCount = users.length
+ const statusLabel = connected
+ ? `${onlineCount} Online`
+ : 'Connecting...'
+
+ const notificationMessage = notification
+ ? notification.type === 'joined'
+ ? `${notification.username} joined`
+ : `${notification.username} left`
+ : ''
+
+ return (
+
+
setExpanded(prev => !prev)}
+ role="button"
+ tabIndex={0}
+ onKeyDown={(e) => e.key === 'Enter' && setExpanded(prev => !prev)}
+ >
+
+
+
+ {statusLabel}
+ {saveId && (
+
+ )}
+ {expanded ? : }
+
+
+
+
+ {users.length === 0 ? (
+ No viewers
+ ) : (
+ users.map((u, idx) => (
+
+
+
+
+
+
+ ))
+ )}
+
+
+
+
+
+ {/* Join / leave toast */}
+
dispatch(presenceClearNotification())}
+ >
+
+
+ }
+ />
+
+ )
+}
diff --git a/eda-frontend/src/pages/SchematiEditor.js b/eda-frontend/src/pages/SchematiEditor.js
index 464d8118f..493cd4f74 100644
--- a/eda-frontend/src/pages/SchematiEditor.js
+++ b/eda-frontend/src/pages/SchematiEditor.js
@@ -13,9 +13,13 @@ import RightSidebar from '../components/SchematicEditor/RightSidebar'
import PropertiesSidebar from '../components/SchematicEditor/PropertiesSidebar'
import LoadGrid from '../components/SchematicEditor/Helper/ComponentDrag.js'
import ComponentProperties from '../components/SchematicEditor/ComponentProperties'
+import PresencePanel from '../components/SchematicEditor/PresencePanel'
import '../components/SchematicEditor/Helper/SchematicEditor.css'
import { fetchSchematic, fetchGallerySchematic } from '../redux/actions/index'
-import { useDispatch } from 'react-redux'
+import { useDispatch, useSelector } from 'react-redux'
+import { usePresence } from '../utils/usePresence'
+import { useCircuitSync } from '../utils/useCircuitSync'
+import { useIsEditor } from '../utils/useIsEditor'
const useStyles = makeStyles((theme) => ({
root: {
@@ -36,6 +40,12 @@ export default function SchematiEditor (props) {
const [mobileOpen, setMobileOpen] = React.useState(false)
const [ltiSimResult, setLtiSimResult] = React.useState(false)
+ const saveId = useSelector(state => state.saveSchematicReducer.details.save_id)
+ const isEditor = useIsEditor()
+
+ usePresence(saveId)
+ useCircuitSync(saveId, isEditor)
+
const handleDrawerToggle = () => {
setMobileOpen(!mobileOpen)
}
@@ -95,6 +105,7 @@ export default function SchematiEditor (props) {
{/* Schematic editor Right side pane */}
+
diff --git a/eda-frontend/src/redux/actions/actions.js b/eda-frontend/src/redux/actions/actions.js
index 25f09b8d8..605d25001 100644
--- a/eda-frontend/src/redux/actions/actions.js
+++ b/eda-frontend/src/redux/actions/actions.js
@@ -76,3 +76,11 @@ export const FETCH_REPORTS = 'FETCH_REPORTS'
export const RESOLVE_REPORTS = 'RESOLVE_REPORTS'
export const GET_STATES = 'GET_STATES'
export const SET_STATE = 'SET_STATE'
+
+// Collaboration — Presence
+export const PRESENCE_CONNECTED = 'PRESENCE_CONNECTED'
+export const PRESENCE_DISCONNECTED = 'PRESENCE_DISCONNECTED'
+export const PRESENCE_LIST_UPDATED = 'PRESENCE_LIST_UPDATED'
+export const PRESENCE_USER_JOINED = 'PRESENCE_USER_JOINED'
+export const PRESENCE_USER_LEFT = 'PRESENCE_USER_LEFT'
+export const PRESENCE_CLEAR_NOTIFICATION = 'PRESENCE_CLEAR_NOTIFICATION'
diff --git a/eda-frontend/src/redux/actions/collaborationActions.js b/eda-frontend/src/redux/actions/collaborationActions.js
new file mode 100644
index 000000000..66f5c4a6a
--- /dev/null
+++ b/eda-frontend/src/redux/actions/collaborationActions.js
@@ -0,0 +1,28 @@
+import * as actions from './actions'
+
+export const presenceConnected = () => ({
+ type: actions.PRESENCE_CONNECTED
+})
+
+export const presenceDisconnected = () => ({
+ type: actions.PRESENCE_DISCONNECTED
+})
+
+export const presenceListUpdated = (users) => ({
+ type: actions.PRESENCE_LIST_UPDATED,
+ payload: { users }
+})
+
+export const presenceUserJoined = (user, users) => ({
+ type: actions.PRESENCE_USER_JOINED,
+ payload: { user, users }
+})
+
+export const presenceUserLeft = (user, users) => ({
+ type: actions.PRESENCE_USER_LEFT,
+ payload: { user, users }
+})
+
+export const presenceClearNotification = () => ({
+ type: actions.PRESENCE_CLEAR_NOTIFICATION
+})
diff --git a/eda-frontend/src/redux/reducers/index.js b/eda-frontend/src/redux/reducers/index.js
index 3338cca02..6b6e02ef7 100644
--- a/eda-frontend/src/redux/reducers/index.js
+++ b/eda-frontend/src/redux/reducers/index.js
@@ -9,6 +9,7 @@ import dashboardReducer from './dashboardReducer'
import accountReducer from './accountReducer'
import projectReducer from './projectReducer'
import galleryReducer from './galleryReducer'
+import presenceReducer from './presenceReducer'
export default combineReducers({
schematicEditorReducer,
componentPropertiesReducer,
@@ -19,5 +20,6 @@ export default combineReducers({
dashboardReducer,
accountReducer,
projectReducer,
- galleryReducer
+ galleryReducer,
+ presenceReducer
})
diff --git a/eda-frontend/src/redux/reducers/presenceReducer.js b/eda-frontend/src/redux/reducers/presenceReducer.js
new file mode 100644
index 000000000..599476451
--- /dev/null
+++ b/eda-frontend/src/redux/reducers/presenceReducer.js
@@ -0,0 +1,40 @@
+import * as actions from '../actions/actions'
+
+const initialState = {
+ connected: false,
+ users: [],
+ notification: null // { type: 'joined' | 'left', username: string }
+}
+
+export default function presenceReducer (state = initialState, action) {
+ switch (action.type) {
+ case actions.PRESENCE_CONNECTED:
+ return { ...state, connected: true }
+
+ case actions.PRESENCE_DISCONNECTED:
+ return { ...state, connected: false, users: [] }
+
+ case actions.PRESENCE_LIST_UPDATED:
+ return { ...state, users: action.payload.users }
+
+ case actions.PRESENCE_USER_JOINED:
+ return {
+ ...state,
+ users: action.payload.users,
+ notification: { type: 'joined', username: action.payload.user.username }
+ }
+
+ case actions.PRESENCE_USER_LEFT:
+ return {
+ ...state,
+ users: action.payload.users,
+ notification: { type: 'left', username: action.payload.user.username }
+ }
+
+ case actions.PRESENCE_CLEAR_NOTIFICATION:
+ return { ...state, notification: null }
+
+ default:
+ return state
+ }
+}
diff --git a/eda-frontend/src/utils/useCircuitSync.js b/eda-frontend/src/utils/useCircuitSync.js
new file mode 100644
index 000000000..dfcfc17fa
--- /dev/null
+++ b/eda-frontend/src/utils/useCircuitSync.js
@@ -0,0 +1,176 @@
+import { useEffect, useRef } from 'react'
+import { useSelector } from 'react-redux'
+import {
+ Save,
+ renderGalleryXML,
+ registerChangeListener,
+ setViewerMode
+} from '../components/SchematicEditor/Helper/ToolbarTools'
+
+// ─── tuneable constants ────────────────────────────────────────────────────────
+
+// How long after the last graph change before broadcasting.
+// Short enough to feel live; long enough to avoid flooding during drags.
+const DEBOUNCE_MS = 300
+
+const PING_INTERVAL_MS = 25000 // keepalive — same as usePresence
+const RECONNECT_BASE_MS = 2000
+const RECONNECT_MAX_MS = 30000
+
+// ─── helpers ──────────────────────────────────────────────────────────────────
+
+function buildSyncWsUrl (saveId, token) {
+ const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
+ const base = `${proto}://${window.location.host}/ws/sync/${saveId}/`
+ return token ? `${base}?token=${encodeURIComponent(token)}` : base
+}
+
+// ─── hook ─────────────────────────────────────────────────────────────────────
+
+/**
+ * Phase 2 — Live Shared Viewing.
+ *
+ * Editor (isEditor = true):
+ * • Listens to mxGraph model changes via registerChangeListener.
+ * • Debounces at DEBOUNCE_MS, then serialises the graph with Save() and
+ * sends { type: "PROJECT_SNAPSHOT", xml } over the sync WebSocket.
+ * • Enables graph editing (setViewerMode(false)).
+ *
+ * Viewer (isEditor = false):
+ * • Receives PROJECT_SNAPSHOT messages and calls renderGalleryXML(xml).
+ * View transform (zoom / pan) is preserved across redraws — mxGraph does
+ * not reset graph.view.scale or graph.view.translate on removeCells.
+ * • Disables graph editing (setViewerMode(true)) while keeping pan active.
+ *
+ * Failure model: if the WebSocket cannot connect or drops, editing and
+ * viewing continue without sync — all existing functionality is unaffected.
+ * Reconnection uses exponential back-off up to RECONNECT_MAX_MS.
+ *
+ * Phase 3 note: incremental events (COMPONENT_ADDED, COMPONENT_MOVED, …) will
+ * be dispatched via the same /ws/sync// channel and handled in the
+ * onmessage switch below without any structural changes to this hook.
+ */
+export function useCircuitSync (saveId, isEditor) {
+ const token = useSelector((state) => state.authReducer.token)
+
+ // Refs hold mutable state that must not trigger re-renders
+ const wsRef = useRef(null)
+ const pingRef = useRef(null)
+ const reconnectRef = useRef(null)
+ const debounceRef = useRef(null)
+ const delayRef = useRef(RECONNECT_BASE_MS)
+ const activeRef = useRef(false) // false → cleanup running, suppress reconnect
+ const unregRef = useRef(null) // cleanup fn returned by registerChangeListener
+
+ useEffect(() => {
+ if (!saveId) return
+
+ activeRef.current = true
+
+ // Apply role immediately — graph must be initialised (LoadGrid must have run)
+ setViewerMode(!isEditor)
+
+ // ── WebSocket teardown ────────────────────────────────────────────────────
+ function teardownWs () {
+ clearInterval(pingRef.current)
+ clearTimeout(reconnectRef.current)
+ clearTimeout(debounceRef.current)
+
+ if (unregRef.current) {
+ unregRef.current()
+ unregRef.current = null
+ }
+
+ if (wsRef.current) {
+ wsRef.current.onopen = null
+ wsRef.current.onmessage = null
+ wsRef.current.onerror = null
+ wsRef.current.onclose = null
+ if (wsRef.current.readyState < WebSocket.CLOSING) {
+ wsRef.current.close()
+ }
+ wsRef.current = null
+ }
+ }
+
+ // ── WebSocket connect ─────────────────────────────────────────────────────
+ function connect () {
+ if (!activeRef.current) return
+ teardownWs()
+
+ const socket = new WebSocket(buildSyncWsUrl(saveId, token))
+ wsRef.current = socket
+
+ socket.onopen = () => {
+ if (!activeRef.current) { socket.close(); return }
+ delayRef.current = RECONNECT_BASE_MS
+
+ if (isEditor) {
+ // Register change listener — fires on every model mutation
+ unregRef.current = registerChangeListener(() => {
+ clearTimeout(debounceRef.current)
+ debounceRef.current = setTimeout(() => {
+ if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
+ const xml = Save()
+ wsRef.current.send(JSON.stringify({ type: 'PROJECT_SNAPSHOT', xml }))
+ }
+ }, DEBOUNCE_MS)
+ })
+ }
+
+ pingRef.current = setInterval(() => {
+ if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
+ wsRef.current.send(JSON.stringify({ type: 'PING' }))
+ }
+ }, PING_INTERVAL_MS)
+ }
+
+ socket.onmessage = (evt) => {
+ let msg
+ try { msg = JSON.parse(evt.data) } catch (_) { return }
+
+ switch (msg.type) {
+ case 'PROJECT_SNAPSHOT':
+ // Only viewers apply incoming snapshots — editors ignore their own echo
+ if (!isEditor) {
+ renderGalleryXML(msg.xml)
+ }
+ break
+
+ // Phase 3 incremental events (COMPONENT_ADDED, COMPONENT_MOVED, etc.)
+ // will be handled here with additional case branches.
+
+ default:
+ break // PONG and unknown types ignored
+ }
+ }
+
+ socket.onerror = () => {
+ // onclose always fires after onerror; reconnect logic lives there
+ }
+
+ socket.onclose = () => {
+ clearInterval(pingRef.current)
+ if (unregRef.current) {
+ unregRef.current()
+ unregRef.current = null
+ }
+ if (!activeRef.current) return
+ reconnectRef.current = setTimeout(() => {
+ delayRef.current = Math.min(delayRef.current * 2, RECONNECT_MAX_MS)
+ connect()
+ }, delayRef.current)
+ }
+ }
+
+ connect()
+
+ return () => {
+ activeRef.current = false
+ teardownWs()
+ // Restore full editor mode on unmount so a remount starts clean
+ setViewerMode(false)
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [saveId, isEditor, token])
+}
diff --git a/eda-frontend/src/utils/useCircuitSync.js.tmp.962.34f1636168a0 b/eda-frontend/src/utils/useCircuitSync.js.tmp.962.34f1636168a0
new file mode 100644
index 000000000..732d135ff
--- /dev/null
+++ b/eda-frontend/src/utils/useCircuitSync.js.tmp.962.34f1636168a0
@@ -0,0 +1,176 @@
+import { useEffect, useRef } from 'react'
+import { useSelector } from 'react-redux'
+import {
+ Save,
+ renderGalleryXML,
+ registerChangeListener,
+ setViewerMode
+} from '../components/SchematicEditor/Helper/ToolbarTools'
+
+// ─── tuneable constants ────────────────────────────────────────────────────────
+
+// How long after the last graph change before broadcasting.
+// Short enough to feel live; long enough to avoid flooding during drags.
+const DEBOUNCE_MS = 300
+
+const PING_INTERVAL_MS = 25000 // keepalive — same as usePresence
+const RECONNECT_BASE_MS = 2000
+const RECONNECT_MAX_MS = 30000
+
+// ─── helpers ──────────────────────────────────────────────────────────────────
+
+function buildSyncWsUrl (saveId, token) {
+ const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
+ const base = `${proto}://${window.location.host}/ws/sync/${saveId}/`
+ return token ? `${base}?token=${encodeURIComponent(token)}` : base
+}
+
+// ─── hook ─────────────────────────────────────────────────────────────────────
+
+/**
+ * Phase 2 — Live Shared Viewing.
+ *
+ * Editor (isEditor = true):
+ * • Listens to mxGraph model changes via registerChangeListener.
+ * • Debounces at DEBOUNCE_MS, then serialises the graph with Save() and
+ * sends { type: "PROJECT_SNAPSHOT", xml } over the sync WebSocket.
+ * • Enables graph editing (setViewerMode(false)).
+ *
+ * Viewer (isEditor = false):
+ * • Receives PROJECT_SNAPSHOT messages and calls renderGalleryXML(xml).
+ * View transform (zoom / pan) is preserved across redraws — mxGraph does
+ * not reset graph.view.scale or graph.view.translate on removeCells.
+ * • Disables graph editing (setViewerMode(true)) while keeping pan active.
+ *
+ * Failure model: if the WebSocket cannot connect or drops, editing and
+ * viewing continue without sync — all existing functionality is unaffected.
+ * Reconnection uses exponential back-off up to RECONNECT_MAX_MS.
+ *
+ * Phase 3 note: incremental events (COMPONENT_ADDED, COMPONENT_MOVED, …) will
+ * be dispatched via the same /ws/sync// channel and handled in the
+ * onmessage switch below without any structural changes to this hook.
+ */
+export function useCircuitSync (saveId, isEditor) {
+ const token = useSelector((state) => state.authReducer.token)
+
+ // Refs hold mutable state that must not trigger re-renders
+ const wsRef = useRef(null)
+ const pingRef = useRef(null)
+ const reconnectRef = useRef(null)
+ const debounceRef = useRef(null)
+ const delayRef = useRef(RECONNECT_BASE_MS)
+ const activeRef = useRef(false) // false → cleanup running, suppress reconnect
+ const unregRef = useRef(null) // cleanup fn returned by registerChangeListener
+
+ useEffect(() => {
+ if (!saveId) return
+
+ activeRef.current = true
+
+ // Apply role immediately — graph must be initialised (LoadGrid must have run)
+ setViewerMode(!isEditor)
+
+ // ── WebSocket teardown ────────────────────────────────────────────────────
+ function teardownWs () {
+ clearInterval(pingRef.current)
+ clearTimeout(reconnectRef.current)
+ clearTimeout(debounceRef.current)
+
+ if (unregRef.current) {
+ unregRef.current()
+ unregRef.current = null
+ }
+
+ if (wsRef.current) {
+ wsRef.current.onopen = null
+ wsRef.current.onmessage = null
+ wsRef.current.onerror = null
+ wsRef.current.onclose = null
+ if (wsRef.current.readyState < WebSocket.CLOSING) {
+ wsRef.current.close()
+ }
+ wsRef.current = null
+ }
+ }
+
+ // ── WebSocket connect ─────────────────────────────────────────────────────
+ function connect () {
+ if (!activeRef.current) return
+ teardownWs()
+
+ const socket = new WebSocket(buildSyncWsUrl(saveId, token))
+ wsRef.current = socket
+
+ socket.onopen = () => {
+ if (!activeRef.current) { socket.close(); return }
+ delayRef.current = RECONNECT_BASE_MS
+
+ if (isEditor) {
+ // Register change listener — fires on every model mutation
+ unregRef.current = registerChangeListener(() => {
+ clearTimeout(debounceRef.current)
+ debounceRef.current = setTimeout(() => {
+ if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
+ const xml = Save()
+ wsRef.current.send(JSON.stringify({ type: 'PROJECT_SNAPSHOT', xml }))
+ }
+ }, DEBOUNCE_MS)
+ })
+ }
+
+ pingRef.current = setInterval(() => {
+ if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
+ wsRef.current.send(JSON.stringify({ type: 'PING' }))
+ }
+ }, PING_INTERVAL_MS)
+ }
+
+ socket.onmessage = (evt) => {
+ let msg
+ try { msg = JSON.parse(evt.data) } catch (_) { return }
+
+ switch (msg.type) {
+ case 'PROJECT_SNAPSHOT':
+ // Only viewers apply incoming snapshots — editors ignore their own echo
+ if (!isEditor) {
+ renderGalleryXML(msg.xml)
+ }
+ break
+
+ // Phase 3 incremental events (COMPONENT_ADDED, COMPONENT_MOVED, etc.)
+ // will be handled here with additional case branches.
+
+ default:
+ break // PONG and unknown types ignored
+ }
+ }
+
+ socket.onerror = () => {
+ // onclose always fires after onerror; reconnect logic lives there
+ }
+
+ socket.onclose = () => {
+ clearInterval(pingRef.current)
+ if (unregRef.current) {
+ unregRef.current()
+ unregRef.current = null
+ }
+ if (!activeRef.current) return
+ reconnectRef.current = setTimeout(() => {
+ delayRef.current = Math.min(delayRef.current * 2, RECONNECT_MAX_MS)
+ connect()
+ }, delayRef.current)
+ }
+ }
+
+ connect()
+
+ return () => {
+ activeRef.current = false
+ teardownWs()
+ // Restore full editor mode on unmount so a remount starts clean
+ setViewerMode(false)
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [saveId, isEditor, token])
+}
diff --git a/eda-frontend/src/utils/useIsEditor.js b/eda-frontend/src/utils/useIsEditor.js
new file mode 100644
index 000000000..ba6f2dee7
--- /dev/null
+++ b/eda-frontend/src/utils/useIsEditor.js
@@ -0,0 +1,25 @@
+import { useSelector } from 'react-redux'
+
+/**
+ * Returns true when the authenticated user is the owner of the currently
+ * loaded schematic — i.e. they have editor privileges in a collaboration room.
+ *
+ * The API returns `owner` as a username string (views.py replaces the integer
+ * PK with owner_name.username before responding), so we compare by username.
+ *
+ * Returns false for:
+ * - anonymous users
+ * - authenticated users who are not the owner
+ * - any session where no schematic is loaded (saveId absent)
+ */
+export function useIsEditor () {
+ const saveId = useSelector(state => state.saveSchematicReducer.details.save_id)
+ const owner = useSelector(state => state.saveSchematicReducer.details.owner)
+ const auth = useSelector(state => state.authReducer)
+ return !!(
+ auth.isAuthenticated &&
+ auth.user &&
+ saveId &&
+ owner === auth.user.username
+ )
+}
diff --git a/eda-frontend/src/utils/usePresence.js b/eda-frontend/src/utils/usePresence.js
new file mode 100644
index 000000000..50f077da1
--- /dev/null
+++ b/eda-frontend/src/utils/usePresence.js
@@ -0,0 +1,114 @@
+import { useEffect, useRef } from 'react'
+import { useDispatch, useSelector } from 'react-redux'
+import {
+ presenceConnected,
+ presenceDisconnected,
+ presenceUserJoined,
+ presenceUserLeft,
+ presenceListUpdated
+} from '../redux/actions/collaborationActions'
+
+const PING_INTERVAL_MS = 25000 // keepalive: server drops idle WS after 30 s on some proxies
+const RECONNECT_BASE_MS = 2000
+const RECONNECT_MAX_MS = 30000
+
+function buildWsUrl (saveId, token) {
+ const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
+ const base = `${proto}://${window.location.host}/ws/presence/${saveId}/`
+ return token ? `${base}?token=${encodeURIComponent(token)}` : base
+}
+
+/**
+ * Manages the WebSocket lifecycle for presence in a project room.
+ * Call inside the schematic editor page when a save_id is known.
+ * Reconnects automatically with exponential back-off.
+ * Safe to call with a null/undefined saveId — does nothing until one is provided.
+ */
+export function usePresence (saveId) {
+ const dispatch = useDispatch()
+ const token = useSelector(state => state.authReducer.token)
+
+ const wsRef = useRef(null)
+ const pingRef = useRef(null)
+ const reconnectRef = useRef(null)
+ const delayRef = useRef(RECONNECT_BASE_MS)
+ const activeRef = useRef(false) // becomes false on cleanup → suppresses reconnect
+
+ useEffect(() => {
+ if (!saveId) return
+
+ activeRef.current = true
+
+ function teardown () {
+ clearInterval(pingRef.current)
+ clearTimeout(reconnectRef.current)
+ if (wsRef.current) {
+ // Null handlers before close to prevent stale callbacks firing
+ wsRef.current.onopen = null
+ wsRef.current.onmessage = null
+ wsRef.current.onerror = null
+ wsRef.current.onclose = null
+ if (wsRef.current.readyState < WebSocket.CLOSING) {
+ wsRef.current.close()
+ }
+ wsRef.current = null
+ }
+ }
+
+ function connect () {
+ if (!activeRef.current) return
+ teardown()
+
+ const socket = new WebSocket(buildWsUrl(saveId, token))
+ wsRef.current = socket
+
+ socket.onopen = () => {
+ if (!activeRef.current) { socket.close(); return }
+ dispatch(presenceConnected())
+ delayRef.current = RECONNECT_BASE_MS
+ pingRef.current = setInterval(() => {
+ if (socket.readyState === WebSocket.OPEN) {
+ socket.send(JSON.stringify({ type: 'PING' }))
+ }
+ }, PING_INTERVAL_MS)
+ }
+
+ socket.onmessage = (evt) => {
+ let msg
+ try { msg = JSON.parse(evt.data) } catch (_) { return }
+ switch (msg.type) {
+ case 'USER_JOINED':
+ dispatch(presenceUserJoined(msg.user, msg.users)); break
+ case 'USER_LEFT':
+ dispatch(presenceUserLeft(msg.user, msg.users)); break
+ case 'PRESENCE_LIST':
+ dispatch(presenceListUpdated(msg.users)); break
+ default: break // PONG — no action needed
+ }
+ }
+
+ socket.onerror = () => {
+ // onclose always fires after onerror; let onclose handle reconnect
+ }
+
+ socket.onclose = () => {
+ clearInterval(pingRef.current)
+ dispatch(presenceDisconnected())
+ if (!activeRef.current) return
+ reconnectRef.current = setTimeout(() => {
+ delayRef.current = Math.min(delayRef.current * 2, RECONNECT_MAX_MS)
+ connect()
+ }, delayRef.current)
+ }
+ }
+
+ connect()
+
+ return () => {
+ activeRef.current = false
+ teardown()
+ dispatch(presenceDisconnected())
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [saveId, token])
+}
diff --git a/esim-cloud-backend/Dockerfile.daphne b/esim-cloud-backend/Dockerfile.daphne
new file mode 100644
index 000000000..a789c2a7e
--- /dev/null
+++ b/esim-cloud-backend/Dockerfile.daphne
@@ -0,0 +1,2 @@
+FROM docker.pkg.github.com/frg-fossee/esim-cloud/django:dev
+RUN pip install daphne==3.0.2 channels==3.0.5 channels-redis==3.4.1
diff --git a/esim-cloud-backend/collaborationAPI/__init__.py b/esim-cloud-backend/collaborationAPI/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/esim-cloud-backend/collaborationAPI/apps.py b/esim-cloud-backend/collaborationAPI/apps.py
new file mode 100644
index 000000000..51990ea84
--- /dev/null
+++ b/esim-cloud-backend/collaborationAPI/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class CollaborationConfig(AppConfig):
+ name = 'collaborationAPI'
+ verbose_name = 'Collaboration'
diff --git a/esim-cloud-backend/collaborationAPI/auth.py b/esim-cloud-backend/collaborationAPI/auth.py
new file mode 100644
index 000000000..85b83667e
--- /dev/null
+++ b/esim-cloud-backend/collaborationAPI/auth.py
@@ -0,0 +1,17 @@
+from django.contrib.auth.models import AnonymousUser
+
+
+def get_user_from_token(token_key):
+ """Resolve a DRF auth token string to a User instance.
+
+ Returns AnonymousUser when the token is absent or invalid so the
+ consumer can handle authenticated and anonymous viewers uniformly.
+ """
+ if not token_key:
+ return AnonymousUser()
+ try:
+ from rest_framework.authtoken.models import Token
+ token = Token.objects.select_related('user').get(key=token_key)
+ return token.user
+ except Exception:
+ return AnonymousUser()
diff --git a/esim-cloud-backend/collaborationAPI/consumers.py b/esim-cloud-backend/collaborationAPI/consumers.py
new file mode 100644
index 000000000..478dff95b
--- /dev/null
+++ b/esim-cloud-backend/collaborationAPI/consumers.py
@@ -0,0 +1,211 @@
+import json
+import logging
+import urllib.parse
+
+from asgiref.sync import sync_to_async
+from channels.generic.websocket import AsyncWebsocketConsumer
+
+from .auth import get_user_from_token
+from .presence import PresenceStore
+
+logger = logging.getLogger(__name__)
+
+
+class PresenceConsumer(AsyncWebsocketConsumer):
+ """
+ Tracks which users are currently viewing a specific project.
+
+ URL route: /ws/presence//
+ Query param: token= (omit for anonymous)
+
+ Channel group: presence__
+ All consumers in the group receive presence updates and forward them
+ to their respective WebSocket clients.
+
+ Protocol (server → client):
+ { "type": "USER_JOINED", "user": {...}, "users": [...] }
+ { "type": "USER_LEFT", "user": {...}, "users": [...] }
+ { "type": "PRESENCE_LIST", "users": [...] }
+ { "type": "PONG" }
+
+ Protocol (client → server):
+ { "type": "PING" }
+ """
+
+ async def connect(self):
+ self.save_id = self.scope['url_route']['kwargs']['save_id']
+ # Channel group name — hyphens are valid per channels-redis spec
+ self.room_group = f'presence__{self.save_id}'
+
+ token = self._parse_token(
+ self.scope.get('query_string', b'').decode('utf-8')
+ )
+ self.user = await sync_to_async(get_user_from_token)(token)
+
+ if self.user.is_anonymous:
+ # Unique key per connection so anonymous users are tracked individually
+ self.user_key = f'anon__{self.channel_name}'
+ self.user_info = {'username': 'Guest', 'is_anonymous': True}
+ else:
+ # Deduplicate across tabs: one user_key per authenticated user
+ self.user_key = f'user__{self.user.pk}'
+ self.user_info = {
+ 'username': self.user.username,
+ 'is_anonymous': False,
+ }
+
+ await sync_to_async(PresenceStore.add)(
+ self.save_id, self.channel_name, self.user_key, self.user_info
+ )
+
+ await self.channel_layer.group_add(self.room_group, self.channel_name)
+ await self.accept()
+
+ users = await sync_to_async(PresenceStore.get_users)(self.save_id)
+ await self.channel_layer.group_send(
+ self.room_group,
+ {
+ 'type': 'presence.broadcast',
+ 'event': 'USER_JOINED',
+ 'user': self.user_info,
+ 'users': users,
+ },
+ )
+
+ async def disconnect(self, close_code):
+ user_fully_left = await sync_to_async(PresenceStore.remove)(
+ self.save_id, self.channel_name, self.user_key
+ )
+
+ if user_fully_left:
+ users = await sync_to_async(PresenceStore.get_users)(self.save_id)
+ await self.channel_layer.group_send(
+ self.room_group,
+ {
+ 'type': 'presence.broadcast',
+ 'event': 'USER_LEFT',
+ 'user': self.user_info,
+ 'users': users,
+ },
+ )
+
+ await self.channel_layer.group_discard(self.room_group, self.channel_name)
+
+ async def receive(self, text_data):
+ try:
+ msg = json.loads(text_data)
+ except (json.JSONDecodeError, TypeError):
+ return
+
+ if msg.get('type') == 'PING':
+ await self.send(json.dumps({'type': 'PONG'}))
+
+ # ------------------------------------------------------------------ #
+ # Group message handler — name maps to type 'presence.broadcast' #
+ # ------------------------------------------------------------------ #
+
+ async def presence_broadcast(self, event):
+ """Forward a presence event to this consumer's WebSocket client."""
+ await self.send(
+ json.dumps(
+ {
+ 'type': event['event'],
+ 'user': event['user'],
+ 'users': event['users'],
+ }
+ )
+ )
+
+ # ------------------------------------------------------------------ #
+ # Helpers #
+ # ------------------------------------------------------------------ #
+
+ @staticmethod
+ def _parse_token(query_string):
+ """Extract 'token' from a URL query string."""
+ params = urllib.parse.parse_qs(query_string)
+ values = params.get('token', [])
+ return values[0] if values else None
+
+
+class SyncConsumer(AsyncWebsocketConsumer):
+ """
+ Relays circuit sync events between an editor and viewers in a project room.
+ Deliberately isolated from PresenceConsumer — no presence side-effects.
+
+ URL route: /ws/sync//
+ Query param: token= (omit for anonymous)
+
+ Channel group: sync__
+
+ Protocol (client → server):
+ { "type": "PING" }
+ { "type": "PROJECT_SNAPSHOT", "xml": "..." }
+
+ Protocol (server → client):
+ { "type": "PONG" }
+ { "type": "PROJECT_SNAPSHOT", "xml": "..." }
+
+ The consumer that sent PROJECT_SNAPSHOT does NOT receive its own echo.
+ Future incremental events (COMPONENT_ADDED, COMPONENT_MOVED, …) follow
+ the same relay pattern via sync_relay without requiring consumer changes.
+ """
+
+ async def connect(self):
+ self.save_id = self.scope['url_route']['kwargs']['save_id']
+ self.room_group = f'sync__{self.save_id}'
+
+ token = self._parse_token(
+ self.scope.get('query_string', b'').decode('utf-8')
+ )
+ self.user = await sync_to_async(get_user_from_token)(token)
+
+ await self.channel_layer.group_add(self.room_group, self.channel_name)
+ await self.accept()
+
+ async def disconnect(self, close_code):
+ await self.channel_layer.group_discard(self.room_group, self.channel_name)
+
+ async def receive(self, text_data):
+ try:
+ msg = json.loads(text_data)
+ except (json.JSONDecodeError, TypeError):
+ return
+
+ msg_type = msg.get('type')
+
+ if msg_type == 'PING':
+ await self.send(json.dumps({'type': 'PONG'}))
+
+ elif msg_type == 'PROJECT_SNAPSHOT':
+ xml = msg.get('xml', '')
+ if xml:
+ await self.channel_layer.group_send(
+ self.room_group,
+ {
+ 'type': 'sync.relay',
+ 'event_type': 'PROJECT_SNAPSHOT',
+ 'payload': {'xml': xml},
+ 'sender_channel': self.channel_name,
+ }
+ )
+
+ # Phase 3 hook: COMPONENT_ADDED, COMPONENT_MOVED, PROPERTY_CHANGED, etc.
+ # will be handled here with the same group_send / sync.relay pattern.
+
+ async def sync_relay(self, event):
+ """Forward a sync event to this consumer's client, skipping the sender."""
+ if event.get('sender_channel') == self.channel_name:
+ return
+ await self.send(
+ json.dumps({
+ 'type': event['event_type'],
+ **event['payload'],
+ })
+ )
+
+ @staticmethod
+ def _parse_token(query_string):
+ params = urllib.parse.parse_qs(query_string)
+ values = params.get('token', [])
+ return values[0] if values else None
diff --git a/esim-cloud-backend/collaborationAPI/presence.py b/esim-cloud-backend/collaborationAPI/presence.py
new file mode 100644
index 000000000..003670500
--- /dev/null
+++ b/esim-cloud-backend/collaborationAPI/presence.py
@@ -0,0 +1,97 @@
+import json
+import logging
+
+import redis as sync_redis
+from django.conf import settings
+
+logger = logging.getLogger(__name__)
+
+_client = None
+
+
+def _get_client():
+ global _client
+ if _client is None:
+ url = getattr(settings, 'REDIS_URL', 'redis://redis:6379')
+ _client = sync_redis.from_url(url, decode_responses=True)
+ return _client
+
+
+def _channel_key(save_id):
+ return f'presence:channels:{save_id}'
+
+
+def _user_key(save_id):
+ return f'presence:users:{save_id}'
+
+
+_TTL = 86400 # 24 h — refreshed on every join
+
+
+class PresenceStore:
+ """
+ Redis-backed presence store.
+
+ Two hashes per room:
+ presence:channels: → { channel_name: user_key }
+ presence:users: → { user_key: user_info_json }
+
+ A user appears in the presence list as long as they have ≥ 1 active
+ channel (i.e. ≥ 1 open browser tab). Closing a tab removes its channel
+ entry; the user entry is removed only when no channel maps to them.
+ """
+
+ @classmethod
+ def add(cls, save_id, channel_name, user_key, user_info):
+ """Register channel_name for user_key in save_id's room."""
+ try:
+ r = _get_client()
+ ck = _channel_key(save_id)
+ uk = _user_key(save_id)
+ pipe = r.pipeline()
+ pipe.hset(ck, channel_name, user_key)
+ pipe.hset(uk, user_key, json.dumps(user_info))
+ pipe.expire(ck, _TTL)
+ pipe.expire(uk, _TTL)
+ pipe.execute()
+ except Exception:
+ logger.exception('PresenceStore.add failed')
+
+ @classmethod
+ def remove(cls, save_id, channel_name, user_key):
+ """Remove channel_name from the room.
+
+ Returns True when the user has no remaining channels (last tab closed)
+ so the caller knows to broadcast USER_LEFT.
+ Returns False when the user still has other open tabs.
+ """
+ try:
+ r = _get_client()
+ ck = _channel_key(save_id)
+ uk = _user_key(save_id)
+ r.hdel(ck, channel_name)
+ remaining = r.hvals(ck)
+ if user_key not in remaining:
+ r.hdel(uk, user_key)
+ return True
+ return False
+ except Exception:
+ logger.exception('PresenceStore.remove failed')
+ return True
+
+ @classmethod
+ def get_users(cls, save_id):
+ """Return list of user_info dicts for every user currently in the room."""
+ try:
+ r = _get_client()
+ raw_values = r.hvals(_user_key(save_id))
+ users = []
+ for v in raw_values:
+ try:
+ users.append(json.loads(v))
+ except (json.JSONDecodeError, TypeError):
+ pass
+ return users
+ except Exception:
+ logger.exception('PresenceStore.get_users failed')
+ return []
diff --git a/esim-cloud-backend/collaborationAPI/routing.py b/esim-cloud-backend/collaborationAPI/routing.py
new file mode 100644
index 000000000..114a14637
--- /dev/null
+++ b/esim-cloud-backend/collaborationAPI/routing.py
@@ -0,0 +1,14 @@
+from django.urls import re_path
+
+from . import consumers
+
+websocket_urlpatterns = [
+ re_path(
+ r'^ws/presence/(?P[0-9a-f-]+)/$',
+ consumers.PresenceConsumer.as_asgi(),
+ ),
+ re_path(
+ r'^ws/sync/(?P[0-9a-f-]+)/$',
+ consumers.SyncConsumer.as_asgi(),
+ ),
+]
diff --git a/esim-cloud-backend/esimCloud/asgi.py b/esim-cloud-backend/esimCloud/asgi.py
index 96e05871e..849c5531b 100644
--- a/esim-cloud-backend/esimCloud/asgi.py
+++ b/esim-cloud-backend/esimCloud/asgi.py
@@ -1,16 +1,16 @@
-"""
-ASGI config for esimCloud project.
-
-It exposes the ASGI callable as a module-level variable named ``application``.
-
-For more information on this file, see
-https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/
-"""
-
import os
+import django
-from django.core.asgi import get_asgi_application
+from channels.auth import AuthMiddlewareStack
+from channels.routing import ProtocolTypeRouter, URLRouter
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'esimCloud.settings')
+django.setup()
+
+from collaborationAPI.routing import websocket_urlpatterns # noqa: E402
-application = get_asgi_application()
+application = ProtocolTypeRouter({
+ 'websocket': AuthMiddlewareStack(
+ URLRouter(websocket_urlpatterns)
+ ),
+})
diff --git a/esim-cloud-backend/esimCloud/settings.py b/esim-cloud-backend/esimCloud/settings.py
index 14542bf9f..0cff82458 100644
--- a/esim-cloud-backend/esimCloud/settings.py
+++ b/esim-cloud-backend/esimCloud/settings.py
@@ -44,6 +44,7 @@
'social_django',
'inline_actions',
'djoser',
+ 'channels',
'simulationAPI',
'authAPI',
'libAPI',
@@ -52,6 +53,7 @@
'arduinoAPI',
'workflowAPI',
'ltiAPI',
+ 'collaborationAPI',
]
MIDDLEWARE = [
@@ -85,6 +87,18 @@
]
WSGI_APPLICATION = 'esimCloud.wsgi.application'
+ASGI_APPLICATION = 'esimCloud.asgi.application'
+
+CHANNEL_LAYERS = {
+ 'default': {
+ 'BACKEND': 'channels_redis.core.RedisChannelLayer',
+ 'CONFIG': {
+ 'hosts': [os.environ.get('REDIS_URL', 'redis://redis:6379')],
+ },
+ },
+}
+
+REDIS_URL = os.environ.get('REDIS_URL', 'redis://redis:6379')
AUTH_USER_MODEL = 'authAPI.User'
# Database config Defaults to sqlite3 if not provided in environment files
diff --git a/esim-cloud-backend/requirements.txt b/esim-cloud-backend/requirements.txt
index f3b0b8ebc..f6c433493 100644
--- a/esim-cloud-backend/requirements.txt
+++ b/esim-cloud-backend/requirements.txt
@@ -1,3 +1,6 @@
+channels==3.0.5
+channels-redis==3.4.1
+daphne==3.0.2
amqp==2.5.2
asgiref==3.2.7
billiard==3.6.3.0