A self-hosted web dashboard that tracks the migration of managed devices from SOTI MobiControl to SureMDM. You load a baseline of the devices that must move, the app polls SureMDM on a schedule, and it reconciles the two sides by serial number to show live, per-model and per-location migration progress.
It is tenant-agnostic — no hardcoded URLs, models, locations, or naming conventions. Any organisation can point it at their own SureMDM (and optionally SOTI) and start tracking in minutes.
Background: this started as an internal tool to track a real migration of ~13,000 devices and report progress to stakeholders during the rollout. It has since been rebuilt and generalised so any organisation can use it for their own SOTI MobiControl → SureMDM migration.
- How it works
- Why group paths / naming don't matter
- Screens
- Quick start (Windows)
- Quick start (Linux)
- First run & login
- Using it
- Configuration
- Project structure
- Security notes
- License
Two device populations are reconciled by a stable hardware identity:
| Baseline | The devices that exist in SOTI and must migrate — your denominator ("what has to move"). Loaded by manual entry, CSV import, or a live SOTI API pull. |
| Target snapshot | The devices currently enrolled in SureMDM, pulled from its DeviceGrid API on a schedule (or imported from a JSON export). |
On every sync the app normalises serial numbers on both sides (uppercase,
strip separators, drop junk like N/A) and matches baseline → target. Each
baseline device is assigned a state:
| State | Meaning |
|---|---|
| 🟡 Pending | In the SOTI baseline, not yet seen in SureMDM |
| 🟢 Migrated | Serial matched in SureMDM and checking in |
| 🔵 Stale | Matched once, but no SureMDM check-in within N days (configurable) |
| 🔴 Unexpected | A device in SureMDM with no matching baseline row — newly enrolled or off the original list |
Progress is migrated / baseline total, sliced by device model and
location/store. Each sync is recorded, feeding a velocity chart and an
audit history.
- Primary key: normalised
SerialNumber(survives OS wipe / re-enrolment). - Fallback: normalised
IMEIwhen a serial is missing on either side. - Every match records which key matched, so you can spot weak matches.
A common question: "Our SureMDM group paths and device names look nothing like the next company's — will it still work?" Yes.
- Migration detection never uses group path or device name. It uses the serial number. A device counts as migrated the moment its serial appears in the SureMDM pull, no matter which folder/group SureMDM keeps it in. The group path is stored only for display (device detail & the Unexpected list).
- The optional "Top locations" breakdown derives a store code from the
device name using a regex you set in the UI (default
^(\d{3,4}), e.g.0012_POS_03→0012). Change it to match your convention, or ignore it and rely on the overall/per-model progress. Nothing about location grouping affects whether a device is counted as migrated.
So the only thing a new company configures is its SureMDM URL + credentials — everything else adapts automatically.
| Baseline | Catalog |
|---|---|
![]() |
![]() |
| Sync & Schedules | Unexpected devices |
|---|---|
![]() |
![]() |
| Device detail | Login |
|---|---|
![]() |
![]() |
Requires Python 3.9+ and git.
git clone https://github.com/Avatorsinc/soti-to-suremdm.git
cd soti-to-suremdm
# create & activate a virtual environment
py -3 -m venv venv
venv\Scripts\Activate.ps1
# install dependencies
pip install -r requirements.txt
# configure secrets (edit SECRET_KEY and the admin password)
copy .env.example .env
notepad .env
# run
python run.pyOpen http://localhost:5000.
If
Activate.ps1is blocked, run PowerShell once as:Set-ExecutionPolicy -Scope CurrentUser RemoteSigned, or just call the venv Python directly:venv\Scripts\python.exe run.py.
Requires Python 3.9+ and git.
git clone https://github.com/Avatorsinc/soti-to-suremdm.git
cd soti-to-suremdm
# create & activate a virtual environment
python3 -m venv venv
source venv/bin/activate
# install dependencies
pip install -r requirements.txt
# configure secrets (edit SECRET_KEY and the admin password)
cp .env.example .env
nano .env
# run
python run.pyOpen http://localhost:5000.
For anything beyond local testing, run it behind a production WSGI server such
as gunicorn (add gunicorn to your environment):
pip install gunicorn
gunicorn -w 2 -b 0.0.0.0:8000 "run:app"
python run.pystarts Flask's debug server — convenient for testing, but do not expose it to a network. Use gunicorn/uwsgi behind nginx for real deployments, and always set a strongSECRET_KEY.
On first start the app creates its SQLite database and seeds a single admin
account from your .env:
- Username:
ADMIN_USERNAME(defaultadmin) - Password:
ADMIN_PASSWORD(defaultchangeme— change this)
Viewing the dashboard is public (read-only); logging in unlocks all editing.
- Catalog — define your device models (with aliases that map raw SureMDM model strings to a canonical name) and, optionally, your locations/stores. Set the store-code regex and the "stale after N days" threshold here.
- Baseline — load the SOTI devices to migrate:
- add them manually,
- import a CSV (needs a serial column:
serial/SerialNumber/DeviceSerial), or - pull from the SOTI API (configure the SOTI connector first).
- Sync & Schedules — set your SureMDM URL + credentials, then either:
- hit Sync SureMDM (live API), or
- Import from JSON export to reconcile against a SureMDM device JSON file (no live API needed — great for a first look), and
- add schedules (every N minutes, daily at a time, or a raw cron expression) to poll automatically.
- Dashboard — watch overall progress, per-model and per-location breakdowns, the velocity chart, and the sync history update on every run. Devices found in SureMDM but not on your baseline appear under Unexpected.
Connectors (SureMDM + SOTI URLs, usernames, passwords, API keys) are managed entirely in the UI on the Sync & Schedules page and stored in the database — nothing tenant-specific lives in the code.
.env (copied from .env.example) holds only host-level settings:
| Variable | Purpose |
|---|---|
SECRET_KEY |
Flask session signing key — set a long random value |
ADMIN_USERNAME / ADMIN_PASSWORD |
Seeded admin login |
DATABASE_URL |
Optional; defaults to local SQLite migration_tracker.db |
DEFAULT_SUREMDM_URL |
Optional pre-fill for the SureMDM URL field (blank by default) |
Matching settings (UI → Catalog): the store-code regex and the stale threshold, applied live on the next reconcile.
The SOTI MobiControl REST API is OAuth2-based and varies per tenant, so
app/connectors/soti.py is a configurable adapter — set the base URL /
credentials in the UI and adjust the field map if your tenant's JSON differs.
Manual entry, CSV import, and the full SureMDM side work without it.
config.py host config (secrets via .env)
run.py dev entry point -> run:app for WSGI
requirements.txt
app/
__init__.py application factory (db, login, scheduler)
models.py User, Connector, Schedule, DeviceModel, Location,
BaselineDevice, TargetDevice, SyncRun, Setting
connectors/
suremdm.py DeviceGrid pagination + JSON-export parsing
soti.py SOTI MobiControl adapter
services/
matching.py serial normalisation + reconcile engine
sync.py SureMDM pull -> snapshot -> reconcile -> SyncRun
baseline.py manual / CSV / SOTI ingestion + catalog alignment
scheduler.py APScheduler jobs from Schedule rows
bootstrap.py first-run seeding
views/ auth, dashboard, baseline, sync, catalog blueprints
templates/ Bootstrap 5 + Chart.js UI
docs/screenshots/ images used in this README
- Always set a strong
SECRET_KEYand change the default admin password. python run.pyis Flask's debug server — local use only. Put a real WSGI server (gunicorn/uwsgi) behind a reverse proxy for production.- Connector credentials are stored in the application database; keep the DB file
and
.envout of source control (both are gitignored) and restrict access to the host.






