Real-time data synchronization in multi-user mode between multiple running
KiCad instances. When several people edit the same project concurrently,
each change appears almost immediately for the other session participants;
conflicts resolve last-write-wins (LWW). The board (pcbnew) is the first
synchronized domain — every board object (footprints, tracks, vias, zones,
graphics, etc.) is covered today; other KiCad editors are a natural
extension of the same architecture (see "Project status" below).
Installed like a regular KiCad plugin (a button on the pcbnew toolbar) — no
separate terminal window is required.
Actively developed. Python requirement: 3.10+ (see plugin.json's
runtime.min_version).
| Area | Status | Details |
|---|---|---|
Diff engine (sync/) |
✅ | sync/README.md |
| Synchronization server, multi-session support | ✅ | server/README.md |
| Synchronization client, full object coverage | ✅ | client/README.md |
| Resilience (anti-entropy by revision, reconnect) | ✅ | client/README.md, "Сверка с сессией" |
| Whole-project sync (git commit + files outside IPC) | ✅ | client/README.md, "Project-wide sync" |
| Schematic (eeschema) as a second sync domain | ⛔ blocked by KiCad | client/README.md, "Known limitations" — the installed KiCad does not implement schematic IPC commands |
| Client as a KiCad plugin | ✅ | plugin/README.md |
| Packaging as KiCad PCM + CI | ✅ | PCM/README.md |
| Server deployment in Docker | ✅ | server/README.md, "Deployment (Docker)" |
| Folder | What it is | Details |
|---|---|---|
plugin/ |
In-KiCad control panel for sync (IPC plugin, KiCad 9+) — starts/stops the client/ |
plugin/README.md |
client/ |
Background synchronization process: polls the board via kipy, sends/receives deltas |
client/README.md |
server/ |
Synchronization server (asyncio WebSocket) — holds session and participant state, LWW logic | server/README.md |
sync/ |
Shared diff/apply/snapshot code for the board; used by both the client and (partly) the server | sync/README.md |
PCM/ |
Packaging the plugin into a KiCad PCM installer archive; standalone/official repository | PCM/README.md |
kico is distributed as a KiCad PCM (Plugin and Content Manager) repository —
no manual download/unzip needed, and updates are offered automatically on
every new release (see PCM/README.md, "Самостоятельный
(self-hosted) репозиторий"):
- In pcbnew/eeschema: Tools → Plugin and Content Manager → Manage...
- Click +, add this repository URL, OK:
https://github.com/0x12net/kico/releases/download/pcm-repository/repository.json - Close the dialog, select the repository in the dropdown at the top of the PCM window, then Apply Pending Changes to install "kico".
This is a one-time setup per machine. After this, a "kico" button appears on
the pcbnew toolbar; new versions show up in PCM as a regular Update, nothing
to reinstall by hand. (For local development instead — running from a
checkout with live edits — see plugin/README.md for the
symlink-based install.)
Only needed if you're not already connecting to someone else's server — one server per set of session participants. Either directly:
python3 -m server.ws_server --port 8765or in Docker (see server/README.md, "Deployment (Docker)"):
docker compose up -d --buildIn KiCad, open the plugin (toolbar button "kico"), fill in the server address, a session ID (leave empty to create a new session — the plugin generates one and shows it, share it with the other participants) and your name, then Start.
client/, server/, sync/, and the test suite all share one virtual
environment at the repository root. plugin/requirements.txt is the
superset that covers all of them (kicad-python for kipy, websockets
for the client/server transport) — it's also exactly what CI installs (see
.github/workflows/tests-python.yml). The one thing it deliberately doesn't
include is wxPython: the plugin panel (plugin/panel.py) only ever runs
inside KiCad's own interpreter, which already has wx via
--system-site-packages (see that file's docstring), so it's not needed
here and isn't exercised by the test suite.
python3 -m venv .venv
source .venv/bin/activate # .venv\Scripts\activate on Windows
pip install -r plugin/requirements.txtRun the test suite (same command CI runs):
python -m unittest discover -s . -p "test_*.py" -v.venv/ is local and machine-specific (already in .gitignore) — recreate
it with the commands above after cloning or moving the repository rather
than copying one from elsewhere; a virtualenv embeds the absolute path it
was created at (interpreter path, activate script, script shebangs) and
silently breaks once that path no longer exists.
Every participant polls its own board, sends what changed, and applies what others changed. On top of that, each client periodically checks that its board still matches the session and repairs it where it doesn't. That check is the part that touches the board without anyone asking it to, so it is worth knowing how it decides:
- Items are compared by a normalized content digest, never by raw serialized bytes. Two KiCad instances holding the same board routinely disagree byte-for-byte (KiCad renormalizes an item when it writes it, and differing builds order unknown protobuf fields differently) - treating that as a difference is what used to produce endless correction loops between participants.
- A difference is only repaired when the server holds a revision this client has not applied. Same revision, different bytes means there is nothing to fix and nothing to argue about.
- An item this board has and the session doesn't is re-sent, never deleted - unless the session recorded an actual deletion for it (a tombstone). Without that distinction, work that merely failed to send is indistinguishable from work somebody deleted.
Four limits apply on top, all adjustable per client:
| Flag | Default | What it protects |
|---|---|---|
--reconcile-mode |
safe |
observe reports differences without touching the board at all (also the panel's "Report drift only" checkbox); full restores the old behaviour of deleting anything the session has no record of |
--reconcile-max-repairs |
50 | one reconciliation pass may not rewrite more items than this - beyond it, nothing is applied and the reason is logged |
--reconcile-quiet-period |
3s | items changed locally within this window are left alone, so a repair can't revert an edit in progress |
--inherit-max-deletes |
50 | joining a session that would delete more than this many items from your board is refused outright, and the board is left untouched |
--keyframe-interval 0 disables the periodic check entirely. Full reasoning,
including the six live-testing findings that led here, is in
client/README.md, "Сверка с сессией".
Client and server are expected to come from the same checkout: a mismatched
protocol_version is refused at join. Nothing has been published yet, so that
version is still 1 even though the change above would otherwise have warranted
a bump - there is no older peer in existence to protect. Update both sides
together.
Building a background sync client on top of KiCad's IPC API (kipy) surfaced
a number of API limitations and quirks in mainline KiCad / kicad-python
itself — not bugs in this project's own logic. Collected here from the
per-component READMEs (each has the full live-testing write-up) so they don't
have to be rediscovered by digging through docstrings one file at a time.
| # | Issue (KiCad / kicad-python side) | Impact if not worked around | Workaround in kico | Details |
|---|---|---|---|---|
| 1 | The IPC API has no subscribe/push mechanism at all — only request/reply (pynng.Req0) |
KiCad can't tell a client "something changed"; true event-driven sync is impossible | Client polls the board on a fixed interval (default 0.2s) | client/README.md, "Период опроса" |
| 2 | update_items()/create_items() can silently re-serialize an object's bytes on write, even for a logical no-op (one footprint measured: 7880 → 7865 bytes) |
Naively trusting the delta's own field values as the new baseline causes an endless delta ping-pong between clients (~1/s, no errors logged) | Re-read exactly the affected items from the live board right after applying a delta, and baseline from that instead of the delta's values (sync/snapshot.py::capture_by_id()); every comparison that crosses participants goes through a normalized content digest rather than raw bytes (sync/snapshot.py::content_digest()) |
client/README.md, "Бесконечный пинг-понг дельт"; sync/README.md, "Грабли" |
| 3 | KiCad never accepts a caller-chosen id for a new item, and create_items() matches by id — passing an existing item's id doesn't create a sibling, it silently overwrites that item |
Found live on a real project: a footprint briefly vanished, recovered via Undo | Always clear the id before every create_items() call; keep an explicit canonical↔local id translation table for items created by other peers (client/idmap.py - a checked bijection, pruned against the live board and cached on disk so a restart doesn't look like "every item is missing") |
sync/README.md, "Грабли"; client/README.md, "Исправлен баг с id при добавлении" |
| 4 | update_items() replaces the entire object — there is no partial/single-field update |
Can't patch one property without already holding the item's complete, current state - and any automated write therefore replaces whatever the user has in progress on that item, not just the field being synced | apply.py always decodes and reapplies the item's whole protobuf message rather than hand-written per-field setters; the background consistency check additionally leaves alone anything that changed locally in the last few seconds (--reconcile-quiet-period) |
sync/README.md, "Грабли" |
| 5 | AS_BUSY can stay set for an entire user gesture (a continuous multi-second drag), not a brief blip |
A short, bounded exponential-backoff retry can give up before the object is ever released (observed live: 20/20 attempts busy for the whole ~6s drag) | Poll at a fixed interval against an overall time budget instead (sync/apply.py::call_with_busy_retry()) |
sync/apply.py module docstring |
| 6 | Zone corner smoothing (outline corner type + radius — Zone Properties → General → "Corner Smoothing") isn't exposed over IPC at all, not even read-only. Still true on KiCad's master branch (checked directly against api/proto/board/board_types.proto on gitlab.com/kicad/code/kicad, not just the 0.7.1 release) — the Zone/CopperZoneSettings messages haven't gained a field for it as of this writing, though CopperZoneSettings did gain an unrelated thieving_settings field since 0.7.1, so the schema is actively evolving, just not on this front |
Can't be synchronized in either direction; any other synced change to the same zone likely resets it to KiCad's default on the receiving side, since the field has no slot in the wire format to begin with. Anti-entropy no longer rewrites items it has no newer revision for, so this is now confined to genuine edits of that zone instead of firing on every periodic check | None possible yet — documented as a known gap rather than silently dropped | client/README.md, "Известные ограничения"; sync/snapshot.py |
| 7 | Board stackup, net classes, and design rules are read-only in kipy — get_stackup()/get_net_classes() exist, no matching write call exists at all |
Nothing to apply even if these were captured | Out of scope, documented rather than half-implemented | client/README.md, sync/README.md, "Осознанно не покрыто" |
| 8 | set_enabled_layers() is destructive — kipy's own docstring warns content on removed layers is deleted with no undo |
An automated background sync loop could destroy board content irreversibly | Never called automatically from the sync loop | client/README.md, "Известные ограничения" |
| 9 | Schematic (eeschema) IPC handlers aren't implemented in the installed KiCad release — GetItems/GetSchematicHierarchy/GetTitleBlockInfo all fail "no handler available", although the equivalent board/pcbnew calls work; kipy.schematic.Schematic is marked versionadded:: 0.7.0 (KiCad 11) |
Schematic can't be a second sync domain yet on this KiCad version | Implementation done and tested (71 unit tests), shelved until a KiCad release with working handlers ships; --files-sync covers .kicad_sch at the whole-file level meanwhile (LWW, no object-level diff) |
client/README.md, "Известные ограничения" |
| 10 | The IPC plugin process lifecycle (repeated launches, timeouts, etc.) isn't documented by KiCad | Risk of duplicate/orphaned background client processes | Implemented independently via a pid file (plugin/panel.py), not relying on KiCad's behavior |
plugin/README.md, "Известные ограничения" |
| 11 | An item's serialized protobuf bytes are not comparable between two different KiCad builds. Fields a build's descriptors don't know about are preserved on parse, but re-emitted after every known field instead of in field-number order - so whenever a newer release fills a gap in an existing message's numbering, an older build round-trips the very same item to different bytes. KiCad's schema has such gaps today: common.types.Text uses field numbers 2, 3, 5, 6 and TextBox uses 2, 3, 4, 6. (A field merely appended at the end round-trips byte-identically - this bites specifically on gap-filling ones.) |
Two participants on different KiCad/kipy versions produce permanently different bytes for a semantically identical item. Any design that compares serialized bytes across participants therefore reports drift that can never be resolved, and "repairing" it rewrites the item, which re-normalizes it again (issue 2) - a correction loop with no fixed point | Every cross-participant comparison goes through a normalized digest instead of raw bytes: parse -> DiscardUnknownFields() -> deterministic serialize -> sha256 (sync/snapshot.py::content_digest()). The raw _full_proto is still what gets written to the board - only the equality test is normalized |
sync/README.md, "Дайджест содержимого"; sync/tests/test_snapshot_digest.py |
| 12 | The IPC API exposes no per-item revision, modification counter or timestamp - get_items() returns the current state and nothing else |
A client can only ask "do these two byte strings differ", never "did this change since I last looked" - and given issues 2 and 11 those are not the same question. Worse, with nothing to compare but presence, "the session doesn't have this item" cannot be told apart from "my addition never got there", so a background consistency check has to guess between deleting and re-sending | kico assigns its own revision per item on the sync server, stamped with the sequence number of the delta that wrote it, plus explicit tombstones for deletions (server/session.py). Anti-entropy compares those, never bytes |
client/README.md, "Сверка с сессией"; server/README.md, "ревизии, tombstone'ы" |
| 13 | There is no way to apply a change through the IPC API without it entering KiCad's undo history. begin_commit()/push_commit() only choose between one undo step and several - kipy's own docstrings are explicit about it ("If you do not call begin_commit, any changes made to the board will be committed immediately, which will result in multiple steps being added to the undo history"; push_commit "will result in a single undo step being added to the undo history"). No flag suppresses the entry, and drop_commit() only cancels a commit that was never pushed |
Every edit arriving from another participant lands in the local user's Ctrl+Z stack, indistinguishable in kind from their own actions - so Ctrl+Z undoes other people's work. And because an undo modifies the board like any other edit, the next poll picks it up as a local change and broadcasts it: the undo propagates to everyone, which makes the undo history effectively shared across the whole session rather than per-user. (The API limitation is documented fact; the propagation follows from how sync works and has not been separately measured live) | None possible - the API offers no opt-out. kico only makes the entries identifiable: every write it performs is one grouped commit carrying a descriptive message (remote #12 from alice, kico reconcile with session, inherit session project on join), so an undo entry that came from sync can at least be recognized as such in the history |
sync/apply.py; client/README.md, "Известные ограничения" |