Skip to content

Commit f25f17f

Browse files
committed
feat: sync user-provided C-Gate project .db files in managed mode (#9)
In managed mode the add-on installed and ran C-Gate but never populated its project database, so requests like `tree 254` returned `401 Bad object or device ID` and HA Discovery silently failed. The Web UI's .cbz/.xml import was a related red herring: it only extracts labels, never loads the actual C-Gate project. Adds a new `cont-init` script that syncs `/share/cgate/tag/<NAME>.db` into managed C-Gate's `tag/` directory on every container start, gated on `cgate_mode == managed`. Uses a `source -nt dest` mtime check so we never clobber a .db that running C-Gate has written between restarts. Also surfaces the labels-only nature of the Web UI import: the `POST /api/labels/import` response now includes `scope: "labels-only"` plus a notice field pointing users to the managed-mode .db workflow. Docs updated with a "Loading your C-Gate project in managed mode" section documenting the .db sync workflow and why .cbz import alone is insufficient. Bumps add-on to 1.8.10.
1 parent 065d956 commit f25f17f

8 files changed

Lines changed: 283 additions & 3 deletions

File tree

homeassistant-addon/CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ All notable changes to the C-Gate Web Bridge Home Assistant add-on will be docum
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.8.10] - 2026-05-24
9+
10+
### Added
11+
- **Managed mode project sync from `/share/cgate/tag/`**: a new `cont-init` script syncs pre-built C-Gate project database files into the managed C-Gate `tag/` directory on every container start. Place `<PROJECTNAME>.db` (built in C-Bus Toolkit or copied from another C-Gate install) into `/share/cgate/tag/`, restart the add-on, and managed C-Gate will serve the project so TreeXML / HA Discovery succeed. The sync is timestamp-aware (`source -nt dest`) so C-Gate state written between restarts is never clobbered by a stale share copy.
12+
- **Web UI import response now flags itself as labels-only**: `POST /api/labels/import` returns `scope: "labels-only"` and a `notice` field explaining that the `.cbz`/`.xml` import does not load the C-Gate project itself, with a pointer to the managed-mode `.db` workflow. Avoids the trap where users assumed importing in the Web UI was sufficient to populate managed C-Gate (issue #9).
13+
14+
### Documentation
15+
- New "Loading your C-Gate project in managed mode" section in `DOCS.md` explaining the `.db` sync workflow and why `.cbz` import alone is insufficient.
16+
817
## [1.8.9] - 2026-05-09
918

1019
### Added

homeassistant-addon/DOCS.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,38 @@ If you choose `upload` as the install source:
5353
2. Place the `.zip` file in the `/share/cgate/` directory on your Home Assistant instance (accessible via the Samba, SSH, or File Editor add-ons)
5454
3. Restart the add-on -- it will detect and install from the zip file
5555

56+
#### Loading your C-Gate project in managed mode
57+
58+
Installing C-Gate (above) gives you a running C-Gate process but does **not**
59+
populate it with your project. C-Gate stores each project as a single binary
60+
database file at `tag/<PROJECTNAME>.db` inside its install directory. If that
61+
file is missing, requests like `tree 254` return `401 Bad object or device ID`
62+
and Home Assistant Discovery cannot find any devices.
63+
64+
The supported workflow for managed mode is:
65+
66+
1. Build your project in C-Bus Toolkit on a Windows machine, or copy it from an
67+
existing C-Gate install. The file you need is `<PROJECTNAME>.db` from
68+
C-Gate's `tag/` directory (where `<PROJECTNAME>` matches the `cgate_project`
69+
add-on option, case-sensitive).
70+
2. Place the `.db` file in `/share/cgate/tag/` on your Home Assistant instance
71+
(accessible via the Samba, SSH, or File Editor add-ons). Create the
72+
directory if it does not exist.
73+
3. Restart the add-on. On startup it syncs `/share/cgate/tag/*.db` into
74+
C-Gate's `tag/` directory, then C-Gate loads `cgate_project` automatically.
75+
76+
The sync only overwrites files in C-Gate's `tag/` directory when the
77+
`/share/cgate/tag/` copy is newer, so you will not lose state that managed
78+
C-Gate writes back to its own `.db` between restarts. To force a re-sync,
79+
`touch` the file in `/share/cgate/tag/` before restarting.
80+
81+
**Note on the web UI's `.cbz` / `.xml` import**: the add-on's built-in Web UI
82+
(C-Bus Labels) imports labels only - it extracts network/application/group
83+
names from a Toolkit `.cbz` or XML export so they appear in MQTT Discovery
84+
friendly names. It does **not** load the actual C-Gate project; converting
85+
`.cbz` to a runnable C-Gate `.db` requires Toolkit. Use the `.db` workflow
86+
above to make managed C-Gate actually serve your project.
87+
5688
### MQTT Settings
5789

5890
MQTT connection details are **automatically detected** from the Mosquitto add-on. You do not need to configure these unless you are using an external MQTT broker.

homeassistant-addon/config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name: "C-Gate Web Bridge"
2-
version: "1.8.9"
2+
version: "1.8.10"
33
slug: cgateweb
44
description: "Bridge between Clipsal C-Bus systems and MQTT/Home Assistant"
55
url: "https://github.com/dougrathbone/cgateweb"
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
#!/usr/bin/with-contenv bashio
2+
# ==============================================================================
3+
# Sync user-provided C-Gate project DBs from /share/cgate/tag/ into the managed
4+
# C-Gate tag directory. Lets users in managed mode supply a pre-built
5+
# <PROJECTNAME>.db file (exported from Toolkit or another C-Gate instance)
6+
# without rebuilding the C-Gate image. Uses `cp -u` so we never clobber a
7+
# newer .db that running C-Gate may have written.
8+
# ==============================================================================
9+
10+
# Paths are overridable for unit tests.
11+
SHARE_TAG_DIR="${CGATEWEB_SHARE_TAG_DIR:-/share/cgate/tag}"
12+
DATA_TAG_DIR="${CGATEWEB_DATA_TAG_DIR:-/data/cgate/tag}"
13+
14+
CGATE_MODE=$(bashio::config 'cgate_mode' 'remote')
15+
if [[ "${CGATE_MODE}" != "managed" ]]; then
16+
exit 0
17+
fi
18+
19+
if [[ ! -d "${SHARE_TAG_DIR}" ]]; then
20+
bashio::log.info "No project tag directory at ${SHARE_TAG_DIR}; skipping project sync"
21+
exit 0
22+
fi
23+
24+
# Ensure destination exists. If the install script has already populated it
25+
# C-Gate's tag dir will be there; otherwise this is a no-op safety net.
26+
mkdir -p "${DATA_TAG_DIR}"
27+
28+
shopt -s nullglob
29+
SYNCED=0
30+
SKIPPED=0
31+
for src in "${SHARE_TAG_DIR}"/*.db; do
32+
name=$(basename "${src}")
33+
dest="${DATA_TAG_DIR}/${name}"
34+
# Copy only when source is newer than dest or when dest is missing, so we
35+
# never clobber a .db that running C-Gate has written.
36+
if [[ ! -e "${dest}" || "${src}" -nt "${dest}" ]]; then
37+
if cp -p "${src}" "${dest}"; then
38+
bashio::log.info "Synced project tag: ${name}"
39+
SYNCED=$((SYNCED + 1))
40+
else
41+
bashio::log.warning "Failed to sync project tag: ${name}"
42+
fi
43+
else
44+
SKIPPED=$((SKIPPED + 1))
45+
fi
46+
done
47+
shopt -u nullglob
48+
49+
if [[ ${SYNCED} -eq 0 && ${SKIPPED} -eq 0 ]]; then
50+
bashio::log.info "No .db files found in ${SHARE_TAG_DIR}; nothing to sync"
51+
elif [[ ${SKIPPED} -gt 0 ]]; then
52+
bashio::log.info "Skipped ${SKIPPED} project tag(s) - destination newer than share copy"
53+
fi

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "cgateweb",
3-
"version": "1.8.9",
3+
"version": "1.8.10",
44
"description": "Node.js bridge connecting Clipsal C-Bus automation systems to MQTT for Home Assistant integration",
55
"keywords": [
66
"cbus",

src/webServer.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,9 @@ class WebServer {
313313
networks: result.networks,
314314
stats: result.stats,
315315
merged: merge,
316-
saved: true
316+
saved: true,
317+
scope: 'labels-only',
318+
notice: 'Imported labels only. This does NOT load the C-Gate project itself. In managed mode, place a pre-built <PROJECT>.db file in /share/cgate/tag/ for the add-on to sync into C-Gate. See the add-on documentation for the supported managed-mode project workflow.'
317319
});
318320
} catch (err) {
319321
this._sendJSON(res, 400, { error: err.message });

tests/cgateProjectSync.test.js

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
const { execFileSync } = require('child_process');
2+
const fs = require('fs');
3+
const os = require('os');
4+
const path = require('path');
5+
6+
const SCRIPT = path.join(
7+
__dirname,
8+
'..',
9+
'homeassistant-addon',
10+
'rootfs',
11+
'etc',
12+
'cont-init.d',
13+
'cgate-project-sync.sh'
14+
);
15+
16+
// Stub bashio: config keys come from env vars CGW_TEST_<key>.
17+
const BASHIO_STUB = `
18+
bashio::log.info() { :; }
19+
bashio::log.warning() { :; }
20+
bashio::log.error() { :; }
21+
bashio::log.trace() { :; }
22+
bashio::config() {
23+
local key="$1"
24+
local default_value="\${2:-null}"
25+
local var_name="CGW_TEST_\${key}"
26+
if declare -p "$var_name" &>/dev/null; then
27+
printf '%s' "\${!var_name}"
28+
else
29+
printf '%s' "$default_value"
30+
fi
31+
}
32+
`;
33+
34+
function makeTmpDirs({ withShare = true, withData = true } = {}) {
35+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cgate-project-sync-'));
36+
const shareTag = path.join(root, 'share', 'cgate', 'tag');
37+
const dataTag = path.join(root, 'data', 'cgate', 'tag');
38+
if (withShare) fs.mkdirSync(shareTag, { recursive: true });
39+
if (withData) fs.mkdirSync(dataTag, { recursive: true });
40+
return { root, shareTag, dataTag };
41+
}
42+
43+
function runSync({ shareTag, dataTag, configObject = {} }) {
44+
const env = {
45+
...process.env,
46+
CGATEWEB_SHARE_TAG_DIR: shareTag,
47+
CGATEWEB_DATA_TAG_DIR: dataTag
48+
};
49+
for (const [k, v] of Object.entries(configObject)) {
50+
env[`CGW_TEST_${k}`] = v;
51+
}
52+
// Source the script so the stub functions are in scope. The script's
53+
// top-level `exit 0` will terminate this bash -c subshell, which is fine.
54+
const script = `
55+
set -u
56+
${BASHIO_STUB}
57+
source "${SCRIPT}"
58+
`;
59+
return execFileSync('bash', ['-c', script], { encoding: 'utf8', env });
60+
}
61+
62+
describe('cgate-project-sync.sh', () => {
63+
let dirs;
64+
65+
beforeEach(() => {
66+
dirs = makeTmpDirs();
67+
});
68+
69+
afterEach(() => {
70+
fs.rmSync(dirs.root, { recursive: true, force: true });
71+
});
72+
73+
test('skips entirely when cgate_mode is not managed', () => {
74+
fs.writeFileSync(path.join(dirs.shareTag, 'BURSWOOD.db'), 'fake-db');
75+
runSync({
76+
shareTag: dirs.shareTag,
77+
dataTag: dirs.dataTag,
78+
configObject: { cgate_mode: 'remote' }
79+
});
80+
expect(fs.existsSync(path.join(dirs.dataTag, 'BURSWOOD.db'))).toBe(false);
81+
});
82+
83+
test('copies .db files from share into C-Gate tag dir when in managed mode', () => {
84+
fs.writeFileSync(path.join(dirs.shareTag, 'BURSWOOD.db'), 'fake-db-content');
85+
runSync({
86+
shareTag: dirs.shareTag,
87+
dataTag: dirs.dataTag,
88+
configObject: { cgate_mode: 'managed' }
89+
});
90+
const dest = path.join(dirs.dataTag, 'BURSWOOD.db');
91+
expect(fs.existsSync(dest)).toBe(true);
92+
expect(fs.readFileSync(dest, 'utf8')).toBe('fake-db-content');
93+
});
94+
95+
test('skips files that are not .db', () => {
96+
fs.writeFileSync(path.join(dirs.shareTag, 'README.txt'), 'readme');
97+
fs.writeFileSync(path.join(dirs.shareTag, 'PROJECT.xml'), '<x/>');
98+
runSync({
99+
shareTag: dirs.shareTag,
100+
dataTag: dirs.dataTag,
101+
configObject: { cgate_mode: 'managed' }
102+
});
103+
expect(fs.existsSync(path.join(dirs.dataTag, 'README.txt'))).toBe(false);
104+
expect(fs.existsSync(path.join(dirs.dataTag, 'PROJECT.xml'))).toBe(false);
105+
});
106+
107+
test('is a no-op when the share tag dir does not exist', () => {
108+
// Re-create dirs but only data, not share.
109+
fs.rmSync(dirs.root, { recursive: true, force: true });
110+
dirs = makeTmpDirs({ withShare: false, withData: true });
111+
runSync({
112+
shareTag: dirs.shareTag,
113+
dataTag: dirs.dataTag,
114+
configObject: { cgate_mode: 'managed' }
115+
});
116+
const entries = fs.readdirSync(dirs.dataTag);
117+
expect(entries).toEqual([]);
118+
});
119+
120+
test('does not overwrite a newer destination .db (managed C-Gate may have saved state)', () => {
121+
const dest = path.join(dirs.dataTag, 'BURSWOOD.db');
122+
const src = path.join(dirs.shareTag, 'BURSWOOD.db');
123+
fs.writeFileSync(src, 'old-source');
124+
fs.writeFileSync(dest, 'new-cgate-state');
125+
// Force source mtime older than dest mtime.
126+
const past = new Date(Date.now() - 60_000);
127+
const future = new Date(Date.now() + 0);
128+
fs.utimesSync(src, past, past);
129+
fs.utimesSync(dest, future, future);
130+
131+
runSync({
132+
shareTag: dirs.shareTag,
133+
dataTag: dirs.dataTag,
134+
configObject: { cgate_mode: 'managed' }
135+
});
136+
expect(fs.readFileSync(dest, 'utf8')).toBe('new-cgate-state');
137+
});
138+
139+
test('overwrites destination when source is newer', () => {
140+
const dest = path.join(dirs.dataTag, 'BURSWOOD.db');
141+
const src = path.join(dirs.shareTag, 'BURSWOOD.db');
142+
fs.writeFileSync(dest, 'stale');
143+
fs.writeFileSync(src, 'fresh-from-user');
144+
const past = new Date(Date.now() - 60_000);
145+
const future = new Date(Date.now() + 0);
146+
fs.utimesSync(dest, past, past);
147+
fs.utimesSync(src, future, future);
148+
149+
runSync({
150+
shareTag: dirs.shareTag,
151+
dataTag: dirs.dataTag,
152+
configObject: { cgate_mode: 'managed' }
153+
});
154+
expect(fs.readFileSync(dest, 'utf8')).toBe('fresh-from-user');
155+
});
156+
});

tests/webServer.test.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,34 @@ describe('WebServer', () => {
669669
expect(res.body.saved).toBe(true);
670670
});
671671

672+
it('returns a scope=labels-only notice so users do not assume the C-Gate project itself was loaded', async () => {
673+
const res = await new Promise((resolve, reject) => {
674+
const body = Buffer.from('<xml>fake</xml>');
675+
const req = http.request({
676+
hostname: '127.0.0.1',
677+
port,
678+
path: '/api/labels/import',
679+
method: 'POST',
680+
headers: {
681+
'Content-Type': 'application/octet-stream',
682+
'Content-Length': body.length
683+
}
684+
}, (response) => {
685+
let data = '';
686+
response.on('data', (chunk) => { data += chunk; });
687+
response.on('end', () => resolve({ status: response.statusCode, body: JSON.parse(data) }));
688+
});
689+
req.on('error', reject);
690+
req.write(body);
691+
req.end();
692+
});
693+
expect(res.status).toBe(200);
694+
expect(res.body.scope).toBe('labels-only');
695+
expect(res.body.notice).toMatch(/labels only/i);
696+
expect(res.body.notice).toMatch(/managed mode/i);
697+
expect(res.body.notice).toMatch(/\/share\/cgate\/tag/);
698+
});
699+
672700
it('merges imported labels with existing when merge=true', async () => {
673701
const res = await new Promise((resolve, reject) => {
674702
const body = Buffer.from('<xml>fake</xml>');

0 commit comments

Comments
 (0)