205 Commits
0.1.0 ... 0.5.3

Author SHA1 Message Date
c18e93406a 0.5.3 2026-01-16 19:20:07 +00:00
9af18ba090 0.5.3 2026-01-16 19:19:23 +00:00
fff50a1580 0.5.3 2026-01-16 19:18:47 +00:00
f8e9967c3a 0.5.3 2026-01-16 19:18:09 +00:00
7bc493eb45 0.5.3 2026-01-16 19:16:39 +00:00
b97b970a45 Dump 2026-01-16 19:16:01 +00:00
593e0c367d 0.5.3 2026-01-16 19:14:35 +00:00
8e0817a64b 0.5.3 2026-01-16 19:13:56 +00:00
dfc7e44565 0.5.3 2026-01-16 19:13:17 +00:00
c9c4f99fbf 0.5.3 2026-01-16 19:12:43 +00:00
37cc11c9ee 0.5.3 2026-01-16 19:12:10 +00:00
9c773c07e8 0.5.3 2026-01-16 19:11:26 +00:00
c04612e159 0.5.3 2026-01-16 19:10:35 +00:00
5796012189 0.5.3 2026-01-16 19:09:47 +00:00
01576153d8 Add 0.5.2 2026-01-16 17:31:49 +00:00
30484a08c1 V0. 5.2 2026-01-16 17:30:51 +00:00
faf122aa1c Fic install 2026-01-16 17:27:38 +00:00
1e86df49e9 Fux insta 2026-01-16 17:27:02 +00:00
df631eec9e Fix install 2026-01-16 17:26:22 +00:00
07240d1268 Add 2026-01-16 16:50:25 +00:00
50587ffbbd 12 2026-01-16 16:17:43 +00:00
d6347e7e59 . 2026-01-16 16:06:52 +00:00
870e77ec13 .. 2026-01-15 20:40:04 +00:00
38fb9fb073 .. 2026-01-15 20:32:21 +00:00
c20bd4dd07 .. 2026-01-15 20:25:34 +00:00
296c816633 . 2026-01-15 20:12:30 +00:00
18a2b5529c . 2026-01-15 19:53:44 +00:00
246fab7e1e . 2026-01-15 19:53:07 +00:00
ce5802721f . 2026-01-15 19:52:05 +00:00
2f46966fe2 . 2026-01-15 19:51:26 +00:00
132f9e27c1 revert 6488b434d8
revert custom_components/bahmcloud_store/core.py aktualisiert
2026-01-15 18:02:30 +00:00
618511be73 revert bffc594da5
revert custom_components/bahmcloud_store/views.py aktualisiert
2026-01-15 18:02:11 +00:00
6488b434d8 custom_components/bahmcloud_store/core.py aktualisiert 2026-01-15 18:00:56 +00:00
bffc594da5 custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 18:00:32 +00:00
d78217100c custom_components/bahmcloud_store/manifest.json aktualisiert 2026-01-15 17:53:28 +00:00
09e1ef1af5 CHANGELOG.md aktualisiert 2026-01-15 17:53:13 +00:00
9ad558c9ab custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 17:42:38 +00:00
19df0eea22 custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 17:41:58 +00:00
745979b9a6 custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 17:19:45 +00:00
f861b2490a custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 17:18:45 +00:00
32946c1a98 custom_components/bahmcloud_store/core.py aktualisiert 2026-01-15 17:17:48 +00:00
a9a681d801 custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 17:06:43 +00:00
2ae6ac43a5 custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 17:01:41 +00:00
504c126c2c custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 16:59:33 +00:00
85cc97b557 custom_components/bahmcloud_store/core.py aktualisiert 2026-01-15 16:58:31 +00:00
4ca80a9c88 custom_components/bahmcloud_store/panel/app.js aktualisiert 2026-01-15 16:45:23 +00:00
ac5bc8a6f4 custom_components/bahmcloud_store/core.py aktualisiert 2026-01-15 16:21:14 +00:00
c4361cc8bd custom_components/bahmcloud_store/core.py aktualisiert 2026-01-15 16:13:42 +00:00
1794d579d2 custom_components/bahmcloud_store/panel/app.js aktualisiert 2026-01-15 16:08:48 +00:00
bcfbf7151c custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 16:04:59 +00:00
38730cdd31 custom_components/bahmcloud_store/manifest.json aktualisiert 2026-01-15 15:55:34 +00:00
5d5d78d727 CHANGELOG.md aktualisiert 2026-01-15 15:55:07 +00:00
67297bfc9c custom_components/bahmcloud_store/providers.py aktualisiert 2026-01-15 15:43:05 +00:00
82fda5dfc4 custom_components/bahmcloud_store/core.py aktualisiert 2026-01-15 15:39:10 +00:00
907f14b73c custom_components/bahmcloud_store/providers.py aktualisiert 2026-01-15 15:38:42 +00:00
3eefd447ac custom_components/bahmcloud_store/core.py aktualisiert 2026-01-15 15:29:39 +00:00
72ce95525c custom_components/bahmcloud_store/providers.py aktualisiert 2026-01-15 15:28:35 +00:00
081f277b92 custom_components/bahmcloud_store/core.py aktualisiert 2026-01-15 15:15:47 +00:00
28b86e19e1 custom_components/bahmcloud_store/core.py aktualisiert 2026-01-15 15:09:33 +00:00
e863677428 custom_components/bahmcloud_store/core.py aktualisiert 2026-01-15 15:04:13 +00:00
3f6da60c0d revert 2770e56219
revert custom_components/bahmcloud_store/update.py aktualisiert
2026-01-15 14:39:04 +00:00
8ac0ef103c revert 25b9c79114
revert custom_components/bahmcloud_store/views.py aktualisiert
2026-01-15 14:38:54 +00:00
6a0132a25c revert 2c50765d66
revert custom_components/bahmcloud_store/store.py aktualisiert
2026-01-15 14:38:38 +00:00
60c46aa121 revert 1837ed4a13
revert custom_components/bahmcloud_store/metadata.py aktualisiert
2026-01-15 14:38:23 +00:00
1caad401f6 revert c4f0f94a6f
revert custom_components/bahmcloud_store/providers.py aktualisiert
2026-01-15 14:38:11 +00:00
5684c3d5f1 revert 1dbffcc27c
revert custom_components/bahmcloud_store/core.py aktualisiert
2026-01-15 14:38:00 +00:00
f3863ee227 revert a2d123abbf
revert custom_components/bahmcloud_store/__init__.py aktualisiert
2026-01-15 14:37:48 +00:00
e867e82a2d revert 0e6088070e
revert custom_components/bahmcloud_store/manifest.json aktualisiert
2026-01-15 14:37:01 +00:00
2770e56219 custom_components/bahmcloud_store/update.py aktualisiert 2026-01-15 14:32:31 +00:00
25b9c79114 custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 14:32:17 +00:00
2c50765d66 custom_components/bahmcloud_store/store.py aktualisiert 2026-01-15 14:31:58 +00:00
1837ed4a13 custom_components/bahmcloud_store/metadata.py aktualisiert 2026-01-15 14:31:38 +00:00
c4f0f94a6f custom_components/bahmcloud_store/providers.py aktualisiert 2026-01-15 14:31:16 +00:00
1dbffcc27c custom_components/bahmcloud_store/core.py aktualisiert 2026-01-15 14:30:54 +00:00
a2d123abbf custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 14:30:33 +00:00
0e6088070e custom_components/bahmcloud_store/manifest.json aktualisiert 2026-01-15 14:30:15 +00:00
dc32010bf9 revert 692f0b47da
revert custom_components/bahmcloud_store/providers.py aktualisiert
2026-01-15 14:23:34 +00:00
3ed6a1a18c revert 106872063a
revert custom_components/bahmcloud_store/panel/panel.js aktualisiert
2026-01-15 14:22:11 +00:00
8ff5ab2e31 Dateien nach "custom_components/bahmcloud_store/panel" hochladen 2026-01-15 14:20:58 +00:00
2a0dc9d66c custom_components/bahmcloud_store/styles.css gelöscht 2026-01-15 14:20:03 +00:00
fbfc2e3a6e custom_components/bahmcloud_store/index.html gelöscht 2026-01-15 14:19:54 +00:00
19a5c0fecb custom_components/bahmcloud_store/app.js gelöscht 2026-01-15 14:19:48 +00:00
c0ec7b1797 custom_components/bahmcloud_store/panel.js gelöscht 2026-01-15 14:19:44 +00:00
25f966853a Dateien nach "custom_components/bahmcloud_store" hochladen 2026-01-15 14:09:05 +00:00
3edeab514b custom_components/bahmcloud_store/panel/styles.css gelöscht 2026-01-15 14:08:48 +00:00
d713bf779f custom_components/bahmcloud_store/panel/panel.js gelöscht 2026-01-15 14:08:44 +00:00
2b78feeadf custom_components/bahmcloud_store/panel/index.html gelöscht 2026-01-15 14:08:40 +00:00
a940c68e9e custom_components/bahmcloud_store/panel/app.js gelöscht 2026-01-15 14:08:35 +00:00
6c3cdcde61 Dateien nach "custom_components/bahmcloud_store" hochladen 2026-01-15 14:08:27 +00:00
1fc274bf7c Dateien nach "custom_components/bahmcloud_store" hochladen 2026-01-15 14:08:13 +00:00
4ff94bc185 custom_components/bahmcloud_store/views.py gelöscht 2026-01-15 14:07:49 +00:00
b95b3f5626 custom_components/bahmcloud_store/update.py gelöscht 2026-01-15 14:07:46 +00:00
30d47b775b custom_components/bahmcloud_store/store.py gelöscht 2026-01-15 14:07:42 +00:00
bedf6b6bf8 custom_components/bahmcloud_store/storage.py gelöscht 2026-01-15 14:07:38 +00:00
e2f8b4625a custom_components/bahmcloud_store/providers.py gelöscht 2026-01-15 14:07:34 +00:00
bb340108e2 custom_components/bahmcloud_store/manifest.json gelöscht 2026-01-15 14:07:31 +00:00
a617ca6709 custom_components/bahmcloud_store/metadata.py gelöscht 2026-01-15 14:07:27 +00:00
7789430d4a custom_components/bahmcloud_store/custom_repo_view.py gelöscht 2026-01-15 14:07:23 +00:00
4e8116265d custom_components/bahmcloud_store/core.py gelöscht 2026-01-15 14:07:19 +00:00
06796cf57b custom_components/bahmcloud_store/__init__.py gelöscht 2026-01-15 14:07:15 +00:00
dbcac9df86 custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 13:52:57 +00:00
6ca193580d custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 13:35:18 +00:00
3773b07650 custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 13:27:22 +00:00
24dcc92c00 custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 13:23:27 +00:00
596491f885 custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 13:23:06 +00:00
97c9f01a0a custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 13:22:45 +00:00
c39a948c59 custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 13:09:32 +00:00
4fd0a6ec48 custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 13:05:40 +00:00
b84ab944b3 custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 13:05:24 +00:00
ce4bd4f4f1 custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 13:05:07 +00:00
2dce858a51 custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 12:55:47 +00:00
2c8da4a049 custom_components/bahmcloud_store/providers.py aktualisiert 2026-01-15 12:55:23 +00:00
692f0b47da custom_components/bahmcloud_store/providers.py aktualisiert 2026-01-15 12:54:13 +00:00
106872063a custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 12:45:18 +00:00
597d1556ff custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 12:34:44 +00:00
c4d9f7b393 custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 12:27:56 +00:00
f15d932d54 Tes 2026-01-15 12:23:38 +00:00
ec60211339 Rrst 2026-01-15 12:17:58 +00:00
1305656d10 Test2 2026-01-15 12:12:23 +00:00
1c8a83effc Test 2026-01-15 12:05:24 +00:00
066d1ff2a4 Test 2026-01-15 12:04:50 +00:00
d1a8526d2d . 2026-01-15 11:57:43 +00:00
f60b3a8730 custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 11:46:56 +00:00
b5e98898e0 custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 11:36:15 +00:00
236099e562 custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 11:24:44 +00:00
08a59ec56e custom_components/bahmcloud_store/metadata.py aktualisiert 2026-01-15 11:18:34 +00:00
5cf8e6d40f custom_components/bahmcloud_store/providers.py aktualisiert 2026-01-15 11:18:17 +00:00
8b21d070f3 custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 11:12:50 +00:00
7219f82e7f custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 11:03:12 +00:00
c91a4ecba2 custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 10:53:06 +00:00
2b0bfb4caa custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 10:52:40 +00:00
e10b23a44a custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 10:52:18 +00:00
6d273cc182 custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 10:42:25 +00:00
35839d9c65 custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 10:39:15 +00:00
6f0f588b03 custom_components/bahmcloud_store/panel/panel.js hinzugefügt 2026-01-15 10:33:11 +00:00
5b56b59ae8 custom_components/bahmcloud_store/panel/panel,js gelöscht 2026-01-15 10:32:58 +00:00
8ab487f00a custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 10:22:24 +00:00
0ea9319ba4 custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 10:22:07 +00:00
46508f1c34 custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 10:17:01 +00:00
12f4aec1f7 custom_components/bahmcloud_store/panel/panel,js hinzugefügt 2026-01-15 10:14:00 +00:00
225442f549 custom_components/bahmcloud_store/panel/panel.js gelöscht 2026-01-15 10:13:49 +00:00
714ced5d2c custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 10:13:30 +00:00
64835b719f custom_components/bahmcloud_store/core.py aktualisiert 2026-01-15 10:13:13 +00:00
acb01b9768 custom_components/bahmcloud_store/providers.py aktualisiert 2026-01-15 10:12:52 +00:00
bf29faab04 custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 10:04:25 +00:00
adb117672c custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 09:56:53 +00:00
8cee9e5e4d custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 09:48:25 +00:00
77b4522e3c custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 09:46:41 +00:00
bae4d0b84f custom_components/bahmcloud_store/core.py aktualisiert 2026-01-15 09:46:16 +00:00
c022b90fb5 custom_components/bahmcloud_store/providers.py aktualisiert 2026-01-15 09:45:57 +00:00
97c2672119 custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 09:36:00 +00:00
47e1524aef custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 09:35:34 +00:00
0bc824fe4a custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 09:20:50 +00:00
c500234e1d custom_components/bahmcloud_store/core.py aktualisiert 2026-01-15 09:20:16 +00:00
d27782ea9c custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 09:19:58 +00:00
6088d0a935 custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 09:19:43 +00:00
fbdc8aed0f custom_components/bahmcloud_store/manifest.json aktualisiert 2026-01-15 09:19:21 +00:00
3723c403c7 CHANGELOG.md aktualisiert 2026-01-15 09:18:52 +00:00
95a7a0689b custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 08:34:01 +00:00
9d04aeaa58 custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 08:33:33 +00:00
f65819ffab custom_components/bahmcloud_store/core.py aktualisiert 2026-01-15 08:33:16 +00:00
ad9c4ea421 custom_components/bahmcloud_store/providers.py aktualisiert 2026-01-15 08:32:58 +00:00
e0cecfcc68 custom_components/bahmcloud_store/metadata.py hinzugefügt 2026-01-15 08:32:39 +00:00
fbac0ac57f custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 08:32:22 +00:00
daa51cd59c custom_components/bahmcloud_store/manifest.json aktualisiert 2026-01-15 08:32:08 +00:00
3a88d2c402 CHANGELOG.md aktualisiert 2026-01-15 08:24:17 +00:00
9ff89d18f3 custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 08:12:10 +00:00
93ace71a12 custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 08:06:22 +00:00
30a4daa884 custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 07:59:28 +00:00
b40e509362 custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 07:52:18 +00:00
d04bf2a3f1 custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 07:47:37 +00:00
c490c7856c custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 07:47:09 +00:00
bd274faf88 custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 07:25:52 +00:00
638ac9a7ec custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 07:18:57 +00:00
3eb6d24439 custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 07:18:29 +00:00
013b0baa83 custom_components/bahmcloud_store/core.py aktualisiert 2026-01-15 07:18:13 +00:00
b4b6b2b987 custom_components/bahmcloud_store/providers.py aktualisiert 2026-01-15 07:17:51 +00:00
d226edaac8 custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 07:17:22 +00:00
0e081e8cce custom_components/bahmcloud_store/manifest.json aktualisiert 2026-01-15 07:17:02 +00:00
bd50c487b1 CHANGELOG.md aktualisiert 2026-01-15 07:16:41 +00:00
58e62b864e custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 07:07:45 +00:00
41fc0da76c custom_components/bahmcloud_store/update.py aktualisiert 2026-01-15 07:07:17 +00:00
ce52920c6d custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 07:06:47 +00:00
5c47479f45 custom_components/bahmcloud_store/storage.py aktualisiert 2026-01-15 07:06:30 +00:00
80eefabbc2 custom_components/bahmcloud_store/core.py aktualisiert 2026-01-15 07:05:58 +00:00
0339ad4ecb custom_components/bahmcloud_store/providers.py hinzugefügt 2026-01-15 07:05:32 +00:00
3f07c09c36 custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 07:05:09 +00:00
dd634fca32 custom_components/bahmcloud_store/manifest.json aktualisiert 2026-01-15 07:04:53 +00:00
2aebc45707 CHANGELOG.md aktualisiert 2026-01-15 07:04:29 +00:00
4f3a7fb436 CHANGELOG.md aktualisiert 2026-01-15 07:01:03 +00:00
8e01de3440 custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 06:45:04 +00:00
b7ed65b49d custom_components/bahmcloud_store/custom_repo_view.py aktualisiert 2026-01-15 06:15:58 +00:00
15349d93a2 custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 06:15:36 +00:00
124693e545 custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 06:12:34 +00:00
3aee3886b1 custom_components/bahmcloud_store/update.py aktualisiert 2026-01-15 06:12:19 +00:00
199bda2e0f custom_components/bahmcloud_store/custom_repo_view.py hinzugefügt 2026-01-15 06:11:34 +00:00
8d1ed31431 custom_components/bahmcloud_store/views.py hinzugefügt 2026-01-15 06:11:12 +00:00
c36321db43 custom_components/bahmcloud_store/storage.py hinzugefügt 2026-01-15 06:10:50 +00:00
806524ad33 custom_components/bahmcloud_store/core.py hinzugefügt 2026-01-15 06:10:29 +00:00
2da0cfe07d add v 0.2.0 2026-01-15 06:10:00 +00:00
603277d6f5 change to 0.2.0 2026-01-15 06:09:26 +00:00
a1bdf9dd40 add v 0.2.0 2026-01-15 06:08:31 +00:00
2746c5295a English 2026-01-15 05:45:12 +00:00
7bac73a37f Addend initial 2026-01-15 05:44:31 +00:00
96cdf234db Added chabgelog 2026-01-15 05:43:23 +00:00
15 changed files with 3815 additions and 219 deletions

160
CHANGELOG.md Normal file
View File

@@ -0,0 +1,160 @@
# Changelog
All notable changes to this repository will be documented in this file.
Sections:
- Added
- Changed
- Fixed
- Removed
- Security
---
## [0.5.3] - 2026-01-16
### Added
- Native Home Assistant Update entities for installed repositories (shown under **Settings → System → Updates**).
- Human-friendly update names based on repository name (instead of internal repo IDs like `index:1`).
### Changed
- Update UI now behaves like official Home Assistant integrations (update action is triggered via the HA Updates screen).
## [0.5.2] - 2026-01-16
### Added
- Install and update backend endpoints (`POST /api/bcs/install`, `POST /api/bcs/update`) to install repositories into `/config/custom_components`.
- Installed version tracking based on the actually installed ref (tag/release/branch), stored persistently to support repositories with outdated/`0.0.0` manifest versions.
- API fields `installed_version` (installed ref) and `installed_manifest_version` (informational) to improve transparency in the UI.
### Changed
- Update availability is now evaluated using the stored installed ref (instead of `manifest.json` version), preventing false-positive updates when repositories do not maintain manifest versions.
### Fixed
- Repositories with `manifest.json` version `0.0.0` (or stale versions) no longer appear as constantly requiring updates after installing the latest release/tag.
## [0.5.0] - 2026-01-15
### Added
- Manual refresh button that triggers a full backend refresh (store index + provider data).
- Unified refresh pipeline: startup, timer and UI now use the same refresh logic.
- Cache-busting for store index requests to always fetch the latest store.yaml.
### Improved
- Logging for store index loading and parsing.
- Refresh behavior now deterministic and verifiable via logs.
### Fixed
- Refresh button previously only reloaded cached data.
- Store index was not always reloaded immediately on user action.
## [0.4.1] - 2026-01-15
### Fixed
- Fixed GitLab README loading by using robust raw file endpoints.
- Added support for nested GitLab groups when resolving README paths.
- Added fallback handling for multiple README filenames (`README.md`, `README`, `README.rst`, etc.).
- Added branch fallback logic for README loading (`default`, `main`, `master`).
- Improved error resilience so README loading failures never break the store core.
- No behavior change for GitHub and Gitea providers.
## [0.4.0] - 2026-01-15
### Added
- Initial public release of the Bahmcloud Store integration.
- Sidebar panel with repository browser UI.
- Support for loading repositories from a central `store.yaml` index.
- Support for custom repositories added by the user.
- Provider abstraction for GitHub, GitLab and Gitea:
- Fetch repository information (name, description, default branch).
- Resolve latest version from:
- Releases
- Tags
- Fallback mechanisms.
- Repository metadata support via:
- `bcs.yaml`
- `hacs.yaml`
- `hacs.json`
- README loading and rendering pipeline:
- Fetch raw README files.
- Server-side Markdown rendering.
- Sanitized HTML output for the panel UI.
- Auto refresh mechanism for store index and repository metadata.
- API endpoints:
- List repositories
- Add custom repository
- Remove repository
Persisted via Home Assistant storage (`.storage/bcs_store`).
- Public static asset endpoint for panel JS (`/api/bahmcloud_store_static/...`) without auth (required for HA custom panels).
- Initial API namespace:
- `GET /api/bcs` list merged repositories (index + custom)
- `POST /api/bcs` add custom repository
- `DELETE /api/bcs/custom_repo` remove custom repository
### Changed
- Repository cards are now clickable to open the detail view.
## [0.3.2] - 2026-01-15
### Added
- Metadata resolver:
- Reads `bcs.yaml` (preferred), then `hacs.yaml`, then `hacs.json` from repository root.
- Extracts `name`, `description`, `category`, `author`, `maintainer` (best-effort).
- UI now prefers metadata description over provider description.
- Provider repository name is now only used as a fallback if no metadata name is provided.
### Changed
- Repo display name priority:
1) metadata (`bcs.yaml` / `hacs.*`)
2) store index name (store.yaml)
3) provider repo name
4) repository URL
## [0.3.1] - 2026-01-15
### Fixed
- Panel header version is now derived from `manifest.json` via backend API (no more hardcoded version strings).
- Mobile navigation/header visibility improved by explicitly disabling iframe embedding for the custom panel.
- When adding a custom repository without a display name, the name is now fetched from the git provider (GitHub/Gitea) and shown automatically.
## [0.3.0] - 2026-01-15
### Added
- Repository enrichment for the Store UI:
- GitHub: fetch owner and description via GitHub REST API.
- Gitea: fetch owner and description via Gitea REST API (`/api/v1`).
- Provider detection for GitHub/GitLab/Gitea (best-effort).
- Automatic UI description line populated from provider data (when available).
### Changed
- Panel module URL cache-busting updated to avoid stale frontend assets.
### Fixed
- Store "Refresh" now triggers immediate backend refresh (from 0.2.0).
- Avoided circular imports by using TYPE_CHECKING for type references.
### Notes
- Installation/README details view/update entities will be added in later versions.
## [0.2.0] - 2026-01-15
### Added
- Foundation architecture for BCS (Bahmcloud Component Store) inside a Home Assistant custom component.
- Custom panel (no iframe) using `hass.callApi()` to avoid authentication issues.
- Store index loader (`store.yaml`) with periodic refresh (data only).
- Manual repository management:
- Add repository
- List repositories
- Remove repository
Persisted via Home Assistant storage (`.storage/bcs_store`).
- Public static asset endpoint for panel JS (`/api/bahmcloud_store_static/...`) without auth (required for HA custom panels).
- Initial API namespace:
- `GET /api/bcs` list merged repositories (index + custom)
- `POST /api/bcs` add custom repository
- `DELETE /api/bcs/custom_repo` remove custom repository
### Changed
- Store API/UI terminology standardized to "BCS" (Bahmcloud Component Store), while integration domain remains `bahmcloud_store` for compatibility.
### Notes
- Installation, README rendering, provider enrichment (GitHub/Gitea/GitLab), and Update entities will be implemented in later versions.

View File

@@ -1,3 +1,3 @@
# bahmcloud_store
Bahmcloud Store für installing costum_components to Homeassistant
Bahmcloud Store for installing costum_components to Homeassistant

19
bcs.yaml Normal file
View File

@@ -0,0 +1,19 @@
name: Bahmcloud Store
description: >
Provider-neutral custom integration store for Home Assistant.
Supports GitHub, GitLab, Gitea and Bahmcloud repositories with
a central index, UI panel and API, similar to HACS but independent.
category: Store
author: Bahmcloud
maintainer: Bahmcloud
domains:
- bahmcloud_store
min_ha_version: "2024.1.0"
homepage: https://git.bahmcloud.de/bahmcloud/bahmcloud_store
issues: https://git.bahmcloud.de/bahmcloud/bahmcloud_store/issues
source: https://git.bahmcloud.de/bahmcloud/bahmcloud_store

View File

@@ -1,65 +1,166 @@
from __future__ import annotations
import logging
from datetime import timedelta
from dataclasses import dataclass
from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.const import Platform
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.components.panel_custom import async_register_panel
from homeassistant.components.update import UpdateEntity, UpdateEntityFeature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.entity import EntityCategory
from .store import BahmcloudStore, StoreConfig, StoreError
from .core import DOMAIN, SIGNAL_UPDATED, BCSCore
_LOGGER = logging.getLogger(__name__)
DOMAIN = "bahmcloud_store"
DEFAULT_STORE_URL = "https://git.bahmcloud.de/bahmcloud/ha_store/raw/branch/main/store.yaml"
CONF_STORE_URL = "store_url"
def _pretty_repo_name(core: BCSCore, repo_id: str) -> str:
"""Return a human-friendly name for a repo update entity."""
try:
repo = core.get_repo(repo_id)
if repo and getattr(repo, "name", None):
name = str(repo.name).strip()
if name:
return name
except Exception:
pass
# Fallbacks
if repo_id.startswith("index:"):
return f"BCS Index {repo_id.split(':', 1)[1]}"
if repo_id.startswith("custom:"):
return f"BCS Custom {repo_id.split(':', 1)[1]}"
return f"BCS {repo_id}"
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
cfg = config.get(DOMAIN, {})
store_url = cfg.get(CONF_STORE_URL, DEFAULT_STORE_URL)
@dataclass(frozen=True)
class _RepoKey:
repo_id: str
store = BahmcloudStore(hass, StoreConfig(store_url=store_url))
hass.data[DOMAIN] = store
# HTTP Views (Panel static + JSON API)
await store.register_http_views()
class BCSRepoUpdateEntity(UpdateEntity):
"""Update entity representing a BCS-managed repository."""
# Sidebar Panel (Custom Panel + JS module)
await async_register_panel(
hass,
frontend_url_path="bahmcloud-store",
webcomponent_name="bahmcloud-store-panel",
module_url="/api/bahmcloud_store_static/panel.js",
sidebar_title="Bahmcloud Store",
sidebar_icon="mdi:store",
require_admin=True,
config={},
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_supported_features = UpdateEntityFeature.INSTALL
def __init__(self, core: BCSCore, repo_id: str) -> None:
self._core = core
self._repo_id = repo_id
self._in_progress = False
# Stable unique id (do NOT change)
self._attr_unique_id = f"{DOMAIN}:{repo_id}"
# Human-friendly name in UI
pretty = _pretty_repo_name(core, repo_id)
self._attr_name = pretty
# Title shown in the entity dialog
self._attr_title = pretty
@property
def available(self) -> bool:
repo = self._core.get_repo(self._repo_id)
installed = self._core.get_installed(self._repo_id)
return repo is not None and installed is not None
@property
def in_progress(self) -> bool | None:
return self._in_progress
@property
def installed_version(self) -> str | None:
installed = self._core.get_installed(self._repo_id) or {}
v = installed.get("installed_version") or installed.get("ref")
return str(v) if v else None
@property
def latest_version(self) -> str | None:
repo = self._core.get_repo(self._repo_id)
if not repo:
return None
v = getattr(repo, "latest_version", None)
return str(v) if v else None
@property
def update_available(self) -> bool:
latest = self.latest_version
installed = self.installed_version
if not latest or not installed:
return False
return latest != installed
def version_is_newer(self, latest_version: str, installed_version: str) -> bool:
return latest_version != installed_version
@property
def release_url(self) -> str | None:
repo = self._core.get_repo(self._repo_id)
return getattr(repo, "url", None) if repo else None
async def async_install(self, version: str | None, backup: bool, **kwargs: Any) -> None:
if version is not None:
_LOGGER.debug(
"BCS update entity requested specific version=%s (ignored)", version
)
# Initialer Index-Load
self._in_progress = True
self.async_write_ha_state()
try:
await store.refresh()
except StoreError as e:
_LOGGER.error("Initial store refresh failed: %s", e)
await self._core.update_repo(self._repo_id)
finally:
self._in_progress = False
self.async_write_ha_state()
# Nur Liste + Latest-Versionen regelmäßig aktualisieren (keine Auto-Install)
async def periodic(_now) -> None:
try:
await store.refresh()
store.signal_entities_updated()
except StoreError as e:
_LOGGER.warning("Periodic refresh failed: %s", e)
# Falls store.yaml refresh_seconds enthält, nutze das, sonst 300s
interval_seconds = store.refresh_seconds if getattr(store, "refresh_seconds", None) else 300
async_track_time_interval(hass, periodic, timedelta(seconds=int(interval_seconds)))
@callback
def _sync_entities(
core: BCSCore,
existing: dict[str, BCSRepoUpdateEntity],
async_add_entities: AddEntitiesCallback,
) -> None:
"""Ensure there is one update entity per installed repo."""
installed_map = getattr(core, "_installed_cache", {}) or {}
new_entities: list[BCSRepoUpdateEntity] = []
# Update platform laden (damit Updates in Settings erscheinen)
await async_load_platform(hass, Platform.UPDATE, DOMAIN, {}, config)
for repo_id, data in installed_map.items():
if not isinstance(data, dict):
continue
if repo_id in existing:
continue
return True
ent = BCSRepoUpdateEntity(core, repo_id)
existing[repo_id] = ent
new_entities.append(ent)
if new_entities:
async_add_entities(new_entities)
for ent in existing.values():
ent.async_write_ha_state()
async def async_setup_platform(
hass: HomeAssistant,
config,
async_add_entities: AddEntitiesCallback,
discovery_info=None,
):
"""Set up BCS update entities."""
core: BCSCore | None = hass.data.get(DOMAIN)
if not core:
_LOGGER.debug("BCS core not available, skipping update platform setup")
return
entities: dict[str, BCSRepoUpdateEntity] = {}
_sync_entities(core, entities, async_add_entities)
@callback
def _handle_update() -> None:
_sync_entities(core, entities, async_add_entities)
async_dispatcher_connect(hass, SIGNAL_UPDATED, _handle_update)

View File

@@ -0,0 +1,166 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any
from homeassistant.components.update import UpdateEntity, UpdateEntityFeature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.entity import EntityCategory
from .core import DOMAIN, SIGNAL_UPDATED, BCSCore
_LOGGER = logging.getLogger(__name__)
def _pretty_repo_name(core: BCSCore, repo_id: str) -> str:
"""Return a human-friendly name for a repo update entity."""
try:
repo = core.get_repo(repo_id)
if repo and getattr(repo, "name", None):
name = str(repo.name).strip()
if name:
return name
except Exception:
pass
# Fallbacks
if repo_id.startswith("index:"):
return f"BCS Index {repo_id.split(':', 1)[1]}"
if repo_id.startswith("custom:"):
return f"BCS Custom {repo_id.split(':', 1)[1]}"
return f"BCS {repo_id}"
@dataclass(frozen=True)
class _RepoKey:
repo_id: str
class BCSRepoUpdateEntity(UpdateEntity):
"""Update entity representing a BCS-managed repository."""
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_supported_features = UpdateEntityFeature.INSTALL
def __init__(self, core: BCSCore, repo_id: str) -> None:
self._core = core
self._repo_id = repo_id
self._in_progress = False
# Stable unique id (do NOT change)
self._attr_unique_id = f"{DOMAIN}:{repo_id}"
# Human-friendly name in UI
pretty = _pretty_repo_name(core, repo_id)
self._attr_name = pretty
# Title shown in the entity dialog
self._attr_title = pretty
@property
def available(self) -> bool:
repo = self._core.get_repo(self._repo_id)
installed = self._core.get_installed(self._repo_id)
return repo is not None and installed is not None
@property
def in_progress(self) -> bool | None:
return self._in_progress
@property
def installed_version(self) -> str | None:
installed = self._core.get_installed(self._repo_id) or {}
v = installed.get("installed_version") or installed.get("ref")
return str(v) if v else None
@property
def latest_version(self) -> str | None:
repo = self._core.get_repo(self._repo_id)
if not repo:
return None
v = getattr(repo, "latest_version", None)
return str(v) if v else None
@property
def update_available(self) -> bool:
latest = self.latest_version
installed = self.installed_version
if not latest or not installed:
return False
return latest != installed
def version_is_newer(self, latest_version: str, installed_version: str) -> bool:
return latest_version != installed_version
@property
def release_url(self) -> str | None:
repo = self._core.get_repo(self._repo_id)
return getattr(repo, "url", None) if repo else None
async def async_install(self, version: str | None, backup: bool, **kwargs: Any) -> None:
if version is not None:
_LOGGER.debug(
"BCS update entity requested specific version=%s (ignored)", version
)
self._in_progress = True
self.async_write_ha_state()
try:
await self._core.update_repo(self._repo_id)
finally:
self._in_progress = False
self.async_write_ha_state()
@callback
def _sync_entities(
core: BCSCore,
existing: dict[str, BCSRepoUpdateEntity],
async_add_entities: AddEntitiesCallback,
) -> None:
"""Ensure there is one update entity per installed repo."""
installed_map = getattr(core, "_installed_cache", {}) or {}
new_entities: list[BCSRepoUpdateEntity] = []
for repo_id, data in installed_map.items():
if not isinstance(data, dict):
continue
if repo_id in existing:
continue
ent = BCSRepoUpdateEntity(core, repo_id)
existing[repo_id] = ent
new_entities.append(ent)
if new_entities:
async_add_entities(new_entities)
for ent in existing.values():
ent.async_write_ha_state()
async def async_setup_platform(
hass: HomeAssistant,
config,
async_add_entities: AddEntitiesCallback,
discovery_info=None,
):
"""Set up BCS update entities."""
core: BCSCore | None = hass.data.get(DOMAIN)
if not core:
_LOGGER.debug("BCS core not available, skipping update platform setup")
return
entities: dict[str, BCSRepoUpdateEntity] = {}
_sync_entities(core, entities, async_add_entities)
@callback
def _handle_update() -> None:
_sync_entities(core, entities, async_add_entities)
async_dispatcher_connect(hass, SIGNAL_UPDATED, _handle_update)

View File

@@ -0,0 +1,28 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from homeassistant.components.http import HomeAssistantView
if TYPE_CHECKING:
from .core import BCSCore
class BCSCustomRepoView(HomeAssistantView):
"""
DELETE /api/bcs/custom_repo?id=...
"""
requires_auth = True
name = "bcs_custom_repo_api"
url = "/api/bcs/custom_repo"
def __init__(self, core: "BCSCore") -> None:
self.core = core
async def delete(self, request):
repo_id = request.query.get("id", "").strip()
if not repo_id:
return self.json({"error": "id missing"}, status_code=400)
await self.core.remove_custom_repo(repo_id)
return self.json({"ok": True})

View File

@@ -1,9 +1,10 @@
{
"domain": "bahmcloud_store",
"name": "Bahmcloud Store",
"version": "0.1.0",
"documentation": "https://git.bahmcloud.de/bahmcloud/ha_store",
"version": "0.5.3",
"documentation": "https://git.bahmcloud.de/bahmcloud/bahmcloud_store",
"platforms": ["update"],
"requirements": [],
"codeowners": [],
"codeowners": ["@bahmcloud"],
"iot_class": "local_polling"
}

View File

@@ -0,0 +1,168 @@
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from urllib.parse import urlparse
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util import yaml as ha_yaml
_LOGGER = logging.getLogger(__name__)
@dataclass
class RepoMetadata:
source: str | None = None # "bcs.yaml" | "hacs.yaml" | "hacs.json" | None
name: str | None = None
description: str | None = None
category: str | None = None
author: str | None = None
maintainer: str | None = None
def _normalize_repo_name(name: str | None) -> str | None:
if not name:
return None
n = name.strip()
if n.endswith(".git"):
n = n[:-4]
return n or None
def _split_owner_repo(repo_url: str) -> tuple[str | None, str | None]:
u = urlparse(repo_url.rstrip("/"))
parts = [p for p in u.path.strip("/").split("/") if p]
if len(parts) < 2:
return None, None
owner = parts[0].strip() or None
repo = _normalize_repo_name(parts[1])
return owner, repo
def _is_github(repo_url: str) -> bool:
return "github.com" in urlparse(repo_url).netloc.lower()
def _is_gitlab(repo_url: str) -> bool:
return "gitlab" in urlparse(repo_url).netloc.lower()
def _is_gitea(repo_url: str) -> bool:
host = urlparse(repo_url).netloc.lower()
return host and ("github.com" not in host) and ("gitlab" not in host)
async def _fetch_text(hass: HomeAssistant, url: str) -> str | None:
session = async_get_clientsession(hass)
try:
async with session.get(url, timeout=20) as resp:
if resp.status != 200:
return None
return await resp.text()
except Exception:
return None
def _parse_meta_yaml(raw: str, source: str) -> RepoMetadata:
try:
data = ha_yaml.parse_yaml(raw)
if not isinstance(data, dict):
return RepoMetadata(source=source)
return RepoMetadata(
source=source,
name=data.get("name"),
description=data.get("description"),
category=data.get("category"),
author=data.get("author"),
maintainer=data.get("maintainer"),
)
except Exception:
return RepoMetadata(source=source)
def _parse_meta_hacs_json(raw: str) -> RepoMetadata:
try:
data = json.loads(raw)
if not isinstance(data, dict):
return RepoMetadata(source="hacs.json")
name = data.get("name")
description = data.get("description")
author = data.get("author")
maintainer = data.get("maintainer")
category = data.get("category") or data.get("type")
return RepoMetadata(
source="hacs.json",
name=name if isinstance(name, str) else None,
description=description if isinstance(description, str) else None,
category=category if isinstance(category, str) else None,
author=author if isinstance(author, str) else None,
maintainer=maintainer if isinstance(maintainer, str) else None,
)
except Exception:
return RepoMetadata(source="hacs.json")
async def fetch_repo_metadata(hass: HomeAssistant, repo_url: str, default_branch: str | None) -> RepoMetadata:
owner, repo = _split_owner_repo(repo_url)
if not owner or not repo:
return RepoMetadata()
branch = default_branch or "main"
# Priority:
# 1) bcs.yaml
# 2) hacs.yaml
# 3) hacs.json
filenames = ["bcs.yaml", "hacs.yaml", "hacs.json"]
candidates: list[tuple[str, str]] = []
if _is_github(repo_url):
base = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}"
for fn in filenames:
candidates.append((fn, f"{base}/{fn}"))
elif _is_gitlab(repo_url):
u = urlparse(repo_url.rstrip("/"))
root = f"{u.scheme}://{u.netloc}/{owner}/{repo}"
# GitLab raw format
# https://gitlab.com/<owner>/<repo>/-/raw/<branch>/<file>
for fn in filenames:
candidates.append((fn, f"{root}/-/raw/{branch}/{fn}"))
elif _is_gitea(repo_url):
u = urlparse(repo_url.rstrip("/"))
root = f"{u.scheme}://{u.netloc}/{owner}/{repo}"
bases = [
f"{root}/raw/branch/{branch}",
f"{root}/raw/{branch}",
]
for fn in filenames:
for b in bases:
candidates.append((fn, f"{b}/{fn}"))
else:
return RepoMetadata()
for fn, url in candidates:
raw = await _fetch_text(hass, url)
if not raw:
continue
if fn.endswith(".json"):
meta = _parse_meta_hacs_json(raw)
if meta.source:
return meta
continue
meta = _parse_meta_yaml(raw, fn)
if meta.source:
return meta
return RepoMetadata()

File diff suppressed because it is too large Load Diff

View File

@@ -1,24 +1,950 @@
class BahmcloudStorePanel extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this._hass = null;
this._view = "store"; // store | manage | about | detail
this._data = null;
this._loading = true;
this._error = null;
this._customAddUrl = "";
this._customAddName = "";
this._search = "";
this._category = "all";
this._provider = "all"; // all|github|gitea|gitlab|other|custom
this._detailRepoId = null;
this._detailRepo = null;
this._readmeLoading = false;
this._readmeText = null;
this._readmeHtml = null;
this._readmeError = null;
// Manual refresh UX state
this._refreshing = false;
this._status = "";
// Install/Update UX
this._installingRepoId = null;
this._updatingRepoId = null;
this._restartRequired = false;
this._lastActionMsg = null;
}
set hass(hass) {
if (this._rendered) return;
this._hass = hass;
if (!this._rendered) {
this._rendered = true;
this._render();
this._load();
}
}
const root = this.attachShadow({ mode: "open" });
const iframe = document.createElement("iframe");
async _load() {
if (!this._hass) return;
iframe.src = "/api/bahmcloud_store_static/index.html";
iframe.style.width = "100%";
iframe.style.height = "100%";
iframe.style.border = "0";
iframe.style.display = "block";
this._loading = true;
this._error = null;
this._update();
const style = document.createElement("style");
style.textContent = `
:host { display: block; height: 100vh; }
try {
const data = await this._hass.callApi("get", "bcs");
this._data = data;
// keep detail fresh
if (this._view === "detail" && this._detailRepoId && Array.isArray(data?.repos)) {
const fresh = data.repos.find((r) => this._safeId(r?.id) === this._detailRepoId);
if (fresh) this._detailRepo = fresh;
}
} catch (e) {
this._error = e?.message ? String(e.message) : String(e);
} finally {
this._loading = false;
this._update();
}
}
async _refreshAll() {
if (!this._hass) return;
if (this._refreshing) return;
this._refreshing = true;
this._error = null;
this._status = "Refreshing…";
this._update();
try {
const resp = await this._hass.callApi("post", "bcs?action=refresh", {});
if (!resp?.ok) {
this._status = "";
this._error = this._safeText(resp?.message) || "Refresh failed.";
} else {
this._status = "Refresh done.";
}
} catch (e) {
this._status = "";
this._error = e?.message ? String(e.message) : String(e);
} finally {
this._refreshing = false;
}
await this._load();
}
async _installRepo(repoId) {
if (!this._hass) return;
if (!repoId) return;
if (this._installingRepoId || this._updatingRepoId) return;
this._installingRepoId = repoId;
this._error = null;
this._lastActionMsg = null;
this._update();
try {
const resp = await this._hass.callApi("post", `bcs/install?repo_id=${encodeURIComponent(repoId)}`, {});
if (!resp?.ok) {
this._error = this._safeText(resp?.message) || "Install failed.";
} else {
this._restartRequired = !!resp.restart_required;
this._lastActionMsg = "Installation finished. Restart required.";
}
} catch (e) {
this._error = e?.message ? String(e.message) : String(e);
} finally {
this._installingRepoId = null;
await this._load();
}
}
async _updateRepo(repoId) {
if (!this._hass) return;
if (!repoId) return;
if (this._installingRepoId || this._updatingRepoId) return;
this._updatingRepoId = repoId;
this._error = null;
this._lastActionMsg = null;
this._update();
try {
const resp = await this._hass.callApi("post", `bcs/update?repo_id=${encodeURIComponent(repoId)}`, {});
if (!resp?.ok) {
this._error = this._safeText(resp?.message) || "Update failed.";
} else {
this._restartRequired = !!resp.restart_required;
this._lastActionMsg = "Update finished. Restart required.";
}
} catch (e) {
this._error = e?.message ? String(e.message) : String(e);
} finally {
this._updatingRepoId = null;
await this._load();
}
}
async _restartHA() {
if (!this._hass) return;
try {
await this._hass.callApi("post", "bcs/restart", {});
} catch (e) {
this._error = e?.message ? String(e.message) : String(e);
this._update();
}
}
_isDesktop() {
return window.matchMedia && window.matchMedia("(min-width: 1024px)").matches;
}
_toggleMenu() {
if (this._isDesktop()) return;
try {
const ev = new Event("hass-toggle-menu", { bubbles: true, composed: true });
this.dispatchEvent(ev);
} catch (_) {}
}
_goBack() {
if (this._view === "detail") {
this._view = "store";
this._detailRepoId = null;
this._detailRepo = null;
this._readmeText = null;
this._readmeHtml = null;
this._readmeError = null;
this._status = "";
this._update();
return;
}
try {
history.back();
} catch (_) {
window.location.href = "/";
}
}
async _addCustomRepo() {
if (!this._hass) return;
const url = (this._customAddUrl || "").trim();
const name = (this._customAddName || "").trim() || null;
if (!url) {
this._error = "Please enter a repository URL.";
this._update();
return;
}
this._error = null;
this._update();
try {
await this._hass.callApi("post", "bcs", { op: "add_custom_repo", url, name });
this._customAddUrl = "";
this._customAddName = "";
await this._load();
this._view = "manage";
this._update();
} catch (e) {
this._error = e?.message ? String(e.message) : String(e);
this._update();
}
}
async _removeCustomRepo(id) {
if (!this._hass) return;
try {
await this._hass.callApi("delete", `bcs/custom_repo?id=${encodeURIComponent(id)}`);
await this._load();
this._view = "manage";
this._update();
} catch (e) {
this._error = e?.message ? String(e.message) : String(e);
this._update();
}
}
_openRepoDetail(repoId) {
const repos = Array.isArray(this._data?.repos) ? this._data.repos : [];
const repo = repos.find((r) => this._safeId(r?.id) === repoId);
if (!repo) return;
this._view = "detail";
this._detailRepoId = repoId;
this._detailRepo = repo;
this._readmeText = null;
this._readmeHtml = null;
this._readmeError = null;
this._status = "";
this._restartRequired = false;
this._lastActionMsg = null;
this._update();
this._loadReadme(repoId);
}
async _loadReadme(repoId) {
if (!this._hass) return;
this._readmeLoading = true;
this._readmeError = null;
this._update();
try {
const resp = await this._hass.callApi("get", `bcs/readme?repo_id=${encodeURIComponent(repoId)}`);
if (resp?.ok && typeof resp.readme === "string" && resp.readme.trim()) {
this._readmeText = resp.readme;
this._readmeHtml = typeof resp.html === "string" && resp.html.trim() ? resp.html : null;
} else {
this._readmeText = null;
this._readmeHtml = null;
this._readmeError = this._safeText(resp?.message) || "README not found.";
}
} catch (e) {
this._readmeText = null;
this._readmeHtml = null;
this._readmeError = e?.message ? String(e.message) : String(e);
} finally {
this._readmeLoading = false;
this._update();
}
}
_render() {
const root = this.shadowRoot;
root.innerHTML = `
<style>
:host { display:block; min-height:100%; --bcs-accent:#1E88E5; }
.mobilebar{
position:sticky; top:0; z-index:50;
display:flex; align-items:center; justify-content:space-between;
gap:10px; padding:10px 12px;
background: var(--app-header-background-color, var(--card-background-color));
color: var(--app-header-text-color, var(--primary-text-color));
border-bottom:1px solid var(--divider-color);
}
.mobilebar .left, .mobilebar .right { display:flex; align-items:center; gap:10px; }
.iconbtn{
width:40px; height:40px; border-radius:14px;
display:flex; align-items:center; justify-content:center;
border:1px solid var(--divider-color);
background: var(--card-background-color);
cursor:pointer; user-select:none;
}
.iconbtn:hover{ filter:brightness(0.98); }
.wrap{ max-width:1200px; margin:0 auto; padding:16px; }
.tabs{ display:flex; gap:10px; flex-wrap:wrap; margin:8px 0 16px; }
.tab{
padding:10px 14px; border-radius:999px;
border:1px solid var(--divider-color);
cursor:pointer; user-select:none;
background: var(--card-background-color);
}
.tab.active{ border-color: var(--bcs-accent); box-shadow: 0 0 0 2px rgba(30,136,229,.15); }
.grid{ display:grid; gap:12px; grid-template-columns: repeat(1, minmax(0, 1fr)); }
@media (min-width: 900px){ .grid{ grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.grid2{ display:grid; gap:12px; grid-template-columns: 1fr; }
@media (min-width: 1024px){ .grid2{ grid-template-columns: 1.2fr .8fr; } }
.card{
padding:14px 14px;
border-radius:18px;
background: var(--card-background-color);
border:1px solid var(--divider-color);
box-shadow: 0 1px 0 rgba(0,0,0,.04);
}
.row{ display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.muted{ color: var(--secondary-text-color); }
.small{ font-size: 12px; }
.badge{
padding:6px 10px;
border-radius:999px;
border:1px solid var(--divider-color);
background: rgba(30,136,229,.06);
color: var(--primary-text-color);
font-size: 12px;
white-space:nowrap;
}
.filters{ display:flex; gap:10px; flex-wrap:wrap; margin-bottom:12px; }
input, select{
padding:10px 12px;
border-radius:14px;
border:1px solid var(--divider-color);
background: var(--card-background-color);
color: var(--primary-text-color);
outline:none;
}
input:focus, select:focus{
border-color: var(--bcs-accent);
box-shadow: 0 0 0 2px rgba(30,136,229,.15);
}
button{
padding:10px 12px;
border-radius:14px;
border:1px solid var(--divider-color);
background: var(--card-background-color);
color: var(--primary-text-color);
cursor:pointer;
}
button.primary{
border-color: rgba(30,136,229,.35);
background: rgba(30,136,229,.08);
}
button:disabled{ opacity: .55; cursor: not-allowed; }
.err{
margin:12px 0;
padding:12px 14px;
border-radius:14px;
border:1px solid rgba(255, 82, 82, .35);
background: rgba(255, 82, 82, .08);
}
.fabs{
position: fixed;
right: 16px;
bottom: 16px;
display:flex;
flex-direction:column;
gap:10px;
z-index: 60;
}
.fabbtn{
width:54px; height:54px;
border-radius:18px;
border:1px solid var(--divider-color);
background: var(--card-background-color);
display:flex; align-items:center; justify-content:center;
cursor:pointer;
box-shadow: 0 8px 18px rgba(0,0,0,.12);
user-select:none;
font-size: 18px;
padding: 0;
}
.fabbtn.primary{
border-color: rgba(30,136,229,.35);
background: rgba(30,136,229,.10);
}
.fabbtn:disabled{
opacity: .55;
cursor: not-allowed;
}
pre.readme{
padding: 12px;
border-radius: 14px;
border: 1px solid var(--divider-color);
background: rgba(0,0,0,.04);
overflow:auto;
font-size: 12px;
line-height: 1.4;
}
.md :is(h1,h2,h3){ margin-top: 12px; }
.md code{
padding: 2px 5px;
border-radius: 8px;
border: 1px solid var(--divider-color);
background: rgba(0,0,0,.04);
}
.md pre{
padding: 12px;
border-radius: 14px;
border: 1px solid var(--divider-color);
background: rgba(0,0,0,.04);
overflow:auto;
}
.md table{
width:100%;
border-collapse: collapse;
overflow:auto;
display:block;
}
.md th, .md td{
border: 1px solid var(--divider-color);
padding: 8px;
text-align:left;
}
</style>
<div class="mobilebar">
<div class="left">
<div class="iconbtn" id="menuBtn" title="Menu">☰</div>
<div class="iconbtn" id="backBtn" title="Back">←</div>
<div>
<div style="font-weight:700;">Bahmcloud Store</div>
<div class="muted small" id="subtitle">Store</div>
</div>
</div>
<div class="right">
<button id="refreshBtn" class="primary" style="border-radius:14px; padding:8px 12px;">Refresh</button>
</div>
</div>
<div class="wrap">
<div class="tabs">
<div class="tab" id="tabStore">Store</div>
<div class="tab" id="tabManage">Manage repositories</div>
<div class="tab" id="tabAbout">Settings / About</div>
</div>
<div id="content"></div>
</div>
<div id="fabs"></div>
`;
root.appendChild(style);
root.appendChild(iframe);
root.getElementById("menuBtn").addEventListener("click", () => this._toggleMenu());
root.getElementById("backBtn").addEventListener("click", () => this._goBack());
root.getElementById("refreshBtn").addEventListener("click", () => this._refreshAll());
root.getElementById("tabStore").addEventListener("click", () => { this._view = "store"; this._update(); });
root.getElementById("tabManage").addEventListener("click", () => { this._view = "manage"; this._update(); });
root.getElementById("tabAbout").addEventListener("click", () => { this._view = "about"; this._update(); });
this._update();
}
_update() {
const root = this.shadowRoot;
if (!root) return;
const subtitle = root.getElementById("subtitle");
if (subtitle) subtitle.textContent = this._view === "detail" ? "Details" : this._view[0].toUpperCase() + this._view.slice(1);
const setActive = (id, on) => {
const el = root.getElementById(id);
if (!el) return;
el.classList.toggle("active", !!on);
};
setActive("tabStore", this._view === "store" || this._view === "detail");
setActive("tabManage", this._view === "manage");
setActive("tabAbout", this._view === "about");
const content = root.getElementById("content");
const fabs = root.getElementById("fabs");
if (!content || !fabs) return;
const err = this._error ? `<div class="err"><strong>Error:</strong> ${this._esc(this._error)}</div>` : "";
const status = this._status ? `<div class="muted small" style="margin:10px 0;">${this._esc(this._status)}</div>` : "";
if (this._loading) {
content.innerHTML = `${err}${status}<div class="card">Loading…</div>`;
fabs.innerHTML = "";
return;
}
if (!this._data?.ok) {
content.innerHTML = `${err}${status}<div class="card">No data. Please refresh.</div>`;
fabs.innerHTML = "";
return;
}
let html = "";
if (this._view === "store") html = this._renderStore();
else if (this._view === "manage") html = this._renderManage();
else if (this._view === "about") html = this._renderAbout();
else if (this._view === "detail") html = this._renderDetail();
content.innerHTML = `${err}${status}${html}`;
fabs.innerHTML = this._view === "detail" ? this._renderFabs() : "";
if (this._view === "store") this._wireStore();
if (this._view === "manage") this._wireManage();
if (this._view === "detail") {
this._wireDetail();
this._wireFabs();
}
}
_safeText(v) {
if (v === null || v === undefined) return "";
return String(v);
}
_safeId(v) {
return this._safeText(v).trim();
}
_esc(s) {
return this._safeText(s)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
_asBoolStrict(v) {
// IMPORTANT: only treat literal true as installed
return v === true;
}
_renderStore() {
const repos = Array.isArray(this._data.repos) ? this._data.repos : [];
const categories = Array.from(
new Set(repos.map((r) => this._safeText(r?.category)).filter((c) => !!c))
).sort();
const providers = ["github", "gitlab", "gitea", "other"];
const filtered = repos
.filter((r) => {
const name = (this._safeText(r?.name) || "").toLowerCase();
const url = (this._safeText(r?.url) || "").toLowerCase();
const desc = (this._safeText(r?.description) || "").toLowerCase();
const q = (this._search || "").trim().toLowerCase();
if (q && !(name.includes(q) || url.includes(q) || desc.includes(q))) return false;
const cat = this._safeText(r?.category) || "";
if (this._category !== "all" && this._category !== cat) return false;
const prov = this._safeText(r?.provider) || "other";
if (this._provider !== "all") {
if (this._provider === "custom" && r?.source !== "custom") return false;
if (this._provider !== "custom" && prov !== this._provider) return false;
}
return true;
})
.sort((a, b) => {
const an = (this._safeText(a?.name) || "").toLowerCase();
const bn = (this._safeText(b?.name) || "").toLowerCase();
return an.localeCompare(bn);
});
const cards = filtered
.map((r) => {
const id = this._safeId(r?.id);
const name = this._safeText(r?.name) || "Unnamed repository";
const desc = this._safeText(r?.description) || "";
const latest = this._safeText(r?.latest_version);
const installed = this._asBoolStrict(r?.installed);
const installedVersion = this._safeText(r?.installed_version);
const updateAvailable = installed && !!latest && (!installedVersion || latest !== installedVersion);
const badges = [];
if (r?.source === "custom") badges.push("Custom");
if (installed) badges.push("Installed");
if (updateAvailable) badges.push("Update");
const badgeHtml = badges.length
? `<div class="badge">${this._esc(badges.join(" · "))}</div>`
: `<div class="badge">${this._esc(this._safeText(r?.provider || "repo"))}</div>`;
return `
<div class="card" data-open="${this._esc(id)}">
<div class="row">
<div>
<div><strong>${this._esc(name)}</strong></div>
<div class="muted">${this._esc(desc)}</div>
<div class="muted small" style="margin-top:8px;">
Creator: ${this._esc(this._safeText(r?.owner || "-"))} · Latest: ${this._esc(latest || "-")} · Meta: ${this._esc(this._safeText(r?.meta_source || "-"))}
</div>
</div>
${badgeHtml}
</div>
</div>
`;
})
.join("");
return `
<div class="filters">
<input id="q" placeholder="Search repositories…" value="${this._esc(this._search)}" />
<select id="cat">
<option value="all">All categories</option>
${categories.map((c) => `<option value="${this._esc(c)}" ${this._category === c ? "selected" : ""}>${this._esc(c)}</option>`).join("")}
</select>
<select id="prov">
<option value="all">All providers</option>
<option value="custom" ${this._provider === "custom" ? "selected" : ""}>Custom</option>
${providers.map((p) => `<option value="${p}" ${this._provider === p ? "selected" : ""}>${p.toUpperCase()}</option>`).join("")}
</select>
</div>
<div class="muted small">BCS ${this._esc(this._data.version || "-")} · Repositories: ${repos.length}</div>
<div class="grid" style="margin-top:12px;">
${cards || `<div class="card">No repositories found.</div>`}
</div>
`;
}
_wireStore() {
const root = this.shadowRoot;
const q = root.getElementById("q");
const cat = root.getElementById("cat");
const prov = root.getElementById("prov");
if (q) q.addEventListener("input", (e) => { this._search = e?.target?.value || ""; this._update(); });
if (cat) cat.addEventListener("change", (e) => { this._category = e?.target?.value || "all"; this._update(); });
if (prov) prov.addEventListener("change", (e) => { this._provider = e?.target?.value || "all"; this._update(); });
root.querySelectorAll("[data-open]").forEach((el) => {
const id = el.getAttribute("data-open");
el.addEventListener("click", () => this._openRepoDetail(id));
});
}
_renderAbout() {
return `
<div class="card">
<div><strong>Installation & Updates</strong></div>
<div class="muted" style="margin-top:10px;">
Installation and updates are now available via the Store UI.
</div>
<div class="muted small" style="margin-top:10px;">
Current integration version: <strong>${this._esc(this._data?.version || "-")}</strong>
</div>
</div>
`;
}
_renderDetail() {
const r = this._detailRepo;
if (!r) return `<div class="card">No repository selected.</div>`;
const name = this._safeText(r?.name) || "Unnamed repository";
const url = this._safeText(r?.url) || "";
const desc = this._safeText(r?.description) || "";
const repoId = this._safeId(r?.id);
const installed = this._asBoolStrict(r?.installed);
const installedVersion = this._safeText(r?.installed_version);
const installedDomains = Array.isArray(r?.installed_domains) ? r.installed_domains : [];
const latestVersion = this._safeText(r?.latest_version);
const busyInstall = this._installingRepoId === repoId;
const busyUpdate = this._updatingRepoId === repoId;
const busy = busyInstall || busyUpdate;
const updateAvailable = installed && !!latestVersion && (!installedVersion || latestVersion !== installedVersion);
const readmeBlock = this._readmeLoading
? `<div class="card">Loading README…</div>`
: this._readmeText
? `
<div class="card">
<div class="row" style="align-items:center;">
<div><strong>README</strong></div>
<div class="muted small">Rendered Markdown</div>
</div>
<div id="readmePretty" class="md" style="margin-top:12px;"></div>
<details>
<summary>Show raw Markdown</summary>
<div style="margin-top:10px;">
<pre class="readme">${this._esc(this._readmeText)}</pre>
</div>
</details>
</div>
`
: `
<div class="card">
<div><strong>README</strong></div>
<div class="muted">${this._esc(this._readmeError || "README not found.")}</div>
</div>
`;
const installBtn = `<button class="primary" id="btnInstall" ${installed || busy ? "disabled" : ""}>${busyInstall ? "Installing…" : installed ? "Installed" : "Install"}</button>`;
const updateBtn = `<button class="primary" id="btnUpdate" ${!updateAvailable || busy ? "disabled" : ""}>${busyUpdate ? "Updating…" : updateAvailable ? "Update" : "Up to date"}</button>`;
const restartHint = this._restartRequired
? `
<div class="card" style="margin-top:12px; background: rgba(255, 193, 7, 0.12); border: 1px solid rgba(255, 193, 7, 0.35);">
<div><strong>Restart required</strong></div>
<div class="muted" style="margin-top:6px;">Home Assistant must be restarted to load the installed integration.</div>
<div class="row" style="margin-top:12px;">
<button class="primary" id="btnRestart">Restart now</button>
</div>
</div>
`
: (this._safeText(this._lastActionMsg)
? `<div class="muted small" style="margin-top:12px;">${this._esc(this._lastActionMsg)}</div>`
: "");
return `
<div class="grid2">
<div>
<div class="card">
<div class="row">
<div>
<div><strong style="font-size:16px;">${this._esc(name)}</strong></div>
<div class="muted">${this._esc(desc)}</div>
<div class="muted small" style="margin-top:8px;">
Creator: ${this._esc(this._safeText(r?.owner || "-"))}
· Latest: ${this._esc(latestVersion || "-")}
· Provider: ${this._esc(this._safeText(r?.provider || "-"))}
· Meta: ${this._esc(this._safeText(r?.meta_source || "-"))}
</div>
<div class="muted small" style="margin-top:8px;">
<a href="${this._esc(url)}" target="_blank" rel="noreferrer">Open repository</a>
</div>
</div>
<div class="badge">${this._esc(this._safeText(r?.provider || "repo"))}</div>
</div>
</div>
${readmeBlock}
</div>
<div>
<div class="card">
<div class="row" style="align-items:center;">
<div><strong>Installation & Updates</strong></div>
<div class="muted small">${installed ? "Installed" : "Not installed"}</div>
</div>
<div class="muted" style="margin-top:10px;">
<div><strong>Installed version:</strong> ${this._esc(installedVersion || "-")}</div>
<div><strong>Latest version:</strong> ${this._esc(latestVersion || "-")}</div>
<div style="margin-top:6px;"><strong>Domains:</strong> ${installedDomains.length ? this._esc(installedDomains.join(", ")) : "-"}</div>
</div>
<div class="row" style="margin-top:14px; gap:10px; flex-wrap:wrap;">
${installBtn}
${updateBtn}
</div>
${restartHint}
</div>
</div>
</div>
`;
}
_wireDetail() {
const root = this.shadowRoot;
const btnInstall = root.getElementById("btnInstall");
const btnUpdate = root.getElementById("btnUpdate");
const btnRestart = root.getElementById("btnRestart");
if (btnInstall) btnInstall.addEventListener("click", () => { if (!btnInstall.disabled && this._detailRepoId) this._installRepo(this._detailRepoId); });
if (btnUpdate) btnUpdate.addEventListener("click", () => { if (!btnUpdate.disabled && this._detailRepoId) this._updateRepo(this._detailRepoId); });
if (btnRestart) btnRestart.addEventListener("click", () => this._restartHA());
const mount = root.getElementById("readmePretty");
if (!mount) return;
if (this._readmeText) {
if (this._readmeHtml) {
mount.innerHTML = this._readmeHtml;
this._postprocessRenderedMarkdown(mount);
} else {
mount.innerHTML = `<div class="muted">Rendered HTML not available. Use “Show raw Markdown”.</div>`;
}
} else {
mount.innerHTML = "";
}
}
_postprocessRenderedMarkdown(container) {
if (!container) return;
try {
const links = container.querySelectorAll("a[href]");
links.forEach((a) => {
a.setAttribute("target", "_blank");
a.setAttribute("rel", "noreferrer noopener");
});
} catch (_) {}
}
_renderFabs() {
const r = this._detailRepo;
if (!r) return "";
const repoId = this._safeId(r?.id);
const installed = this._asBoolStrict(r?.installed);
const latest = this._safeText(r?.latest_version);
const installedVersion = this._safeText(r?.installed_version);
const busy = this._installingRepoId === repoId || this._updatingRepoId === repoId;
const updateAvailable = installed && !!latest && (!installedVersion || latest !== installedVersion);
const installDisabled = installed || busy;
const updateDisabled = !updateAvailable || busy;
return `
<div class="fabs">
<button class="fabbtn primary" id="fabOpen" title="Open repository">↗</button>
<button class="fabbtn" id="fabReload" title="Reload README">⟳</button>
<button class="fabbtn" id="fabInstall" title="${installDisabled ? (installed ? "Already installed" : "Installing…") : "Install"}" ${installDisabled ? "disabled" : ""}></button>
<button class="fabbtn" id="fabUpdate" title="${updateDisabled ? (!installed ? "Not installed" : "No update available") : "Update"}" ${updateDisabled ? "disabled" : ""}>↑</button>
<button class="fabbtn" id="fabInfo" title="About">i</button>
</div>
`;
}
_wireFabs() {
const root = this.shadowRoot;
const r = this._detailRepo;
if (!r) return;
const url = this._safeText(r?.url);
const repoId = this._safeId(r?.id);
const open = root.getElementById("fabOpen");
const reload = root.getElementById("fabReload");
const install = root.getElementById("fabInstall");
const update = root.getElementById("fabUpdate");
const info = root.getElementById("fabInfo");
if (open) open.addEventListener("click", () => url && window.open(url, "_blank", "noreferrer"));
if (reload) reload.addEventListener("click", () => this._detailRepoId && this._loadReadme(this._detailRepoId));
if (install) install.addEventListener("click", () => { if (!install.disabled) this._installRepo(repoId); });
if (update) update.addEventListener("click", () => { if (!update.disabled) this._updateRepo(repoId); });
if (info) info.addEventListener("click", () => { this._view = "about"; this._update(); });
}
_renderManage() {
const repos = Array.isArray(this._data.repos) ? this._data.repos : [];
const custom = repos.filter((r) => r?.source === "custom");
const list = custom
.map((r) => {
const id = this._safeId(r?.id);
const name = this._safeText(r?.name) || "Unnamed repository";
const url = this._safeText(r?.url) || "";
return `
<div class="card">
<div class="row">
<div>
<div><strong>${this._esc(name)}</strong></div>
<div class="muted">${this._esc(url)}</div>
</div>
<div>
<button class="primary" data-remove="${this._esc(id)}">Remove</button>
</div>
</div>
</div>
`;
})
.join("");
return `
<div class="card">
<div><strong>Custom Repositories</strong></div>
<div class="muted" style="margin-top:10px;">
Add any public Git repository that contains a Home Assistant integration.
</div>
<div class="filters" style="margin-top:12px;">
<input id="customUrl" placeholder="Repository URL" value="${this._esc(this._customAddUrl)}" />
<input id="customName" placeholder="Optional name" value="${this._esc(this._customAddName)}" />
<button class="primary" id="addCustom">Add</button>
</div>
</div>
<div class="grid" style="margin-top:12px;">
${list || `<div class="card">No custom repositories added.</div>`}
</div>
`;
}
_wireManage() {
const root = this.shadowRoot;
const url = root.getElementById("customUrl");
const name = root.getElementById("customName");
const add = root.getElementById("addCustom");
if (url) url.addEventListener("input", (e) => { this._customAddUrl = e?.target?.value || ""; });
if (name) name.addEventListener("input", (e) => { this._customAddName = e?.target?.value || ""; });
if (add) add.addEventListener("click", () => this._addCustomRepo());
root.querySelectorAll("[data-remove]").forEach((el) => {
const id = el.getAttribute("data-remove");
el.addEventListener("click", () => this._removeCustomRepo(id));
});
}
}

View File

@@ -0,0 +1,507 @@
from __future__ import annotations
import logging
import re
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from urllib.parse import quote_plus, urlparse
from packaging.version import InvalidVersion, Version
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
_LOGGER = logging.getLogger(__name__)
UA = "BahmcloudStore (Home Assistant)"
@dataclass
class RepoInfo:
owner: str | None = None
repo_name: str | None = None
description: str | None = None
provider: str | None = None
default_branch: str | None = None
latest_version: str | None = None
latest_version_source: str | None = None # "release" | "tag" | "atom" | None
def _normalize_repo_name(name: str | None) -> str | None:
if not name:
return None
n = name.strip()
if n.endswith(".git"):
n = n[:-4]
return n or None
def _split_owner_repo(repo_url: str) -> tuple[str | None, str | None]:
u = urlparse(repo_url.rstrip("/"))
parts = [p for p in u.path.strip("/").split("/") if p]
if len(parts) < 2:
return None, None
owner = parts[0].strip() or None
repo = _normalize_repo_name(parts[1])
return owner, repo
def detect_provider(repo_url: str) -> str:
host = urlparse(repo_url).netloc.lower()
if "github.com" in host:
return "github"
if "gitlab" in host:
return "gitlab"
return "gitea"
async def _safe_json(session, url: str, *, headers: dict | None = None, timeout: int = 20):
try:
async with session.get(url, timeout=timeout, headers=headers) as resp:
status = resp.status
if status != 200:
return None, status
return await resp.json(), status
except Exception:
return None, None
async def _safe_text(session, url: str, *, headers: dict | None = None, timeout: int = 20):
try:
async with session.get(url, timeout=timeout, headers=headers) as resp:
status = resp.status
if status != 200:
return None, status
return await resp.text(), status
except Exception:
return None, None
def _extract_tag_from_github_url(url: str) -> str | None:
m = re.search(r"/releases/tag/([^/?#]+)", url or "")
if not m:
return None
return m.group(1).strip() or None
def _extract_meta(html: str, *, prop: str | None = None, name: str | None = None) -> str | None:
if not html:
return None
if prop:
m = re.search(rf'<meta\s+property="{re.escape(prop)}"\s+content="([^"]+)"', html)
if m:
return m.group(1).strip()
if name:
m = re.search(rf'<meta\s+name="{re.escape(name)}"\s+content="([^"]+)"', html)
if m:
return m.group(1).strip()
return None
def _semver_key(tag: str) -> Version | None:
t = (tag or "").strip()
if not t:
return None
if t.startswith(("v", "V")):
t = t[1:]
try:
return Version(t)
except InvalidVersion:
return None
def _pick_highest_semver(tags: list[str]) -> str | None:
parsed: list[tuple[Version, str]] = []
for t in tags:
if not isinstance(t, str):
continue
ts = t.strip()
if not ts:
continue
v = _semver_key(ts)
if v is not None:
parsed.append((v, ts))
if not parsed:
return None
parsed.sort(key=lambda x: x[0], reverse=True)
return parsed[0][1]
async def _github_description_html(hass: HomeAssistant, owner: str, repo: str) -> str | None:
session = async_get_clientsession(hass)
url = f"https://github.com/{owner}/{repo}"
html, status = await _safe_text(session, url, headers={"User-Agent": UA})
if status != 200 or not html:
return None
desc = _extract_meta(html, prop="og:description")
if desc:
return desc
return _extract_meta(html, name="description")
async def _github_latest_version_atom(hass: HomeAssistant, owner: str, repo: str) -> tuple[str | None, str | None]:
session = async_get_clientsession(hass)
url = f"https://github.com/{owner}/{repo}/releases.atom"
atom, status = await _safe_text(session, url, headers={"User-Agent": UA})
if status != 200 or not atom:
return None, None
try:
root = ET.fromstring(atom)
ns = {"a": "http://www.w3.org/2005/Atom"}
entry = root.find("a:entry", ns)
if entry is None:
return None, None
link = entry.find("a:link", ns)
if link is not None and link.attrib.get("href"):
tag = _extract_tag_from_github_url(link.attrib["href"])
if tag:
return tag, "atom"
title = entry.find("a:title", ns)
if title is not None and title.text:
t = title.text.strip()
if t:
return t, "atom"
except Exception:
return None, None
return None, None
async def _github_latest_version_redirect(hass: HomeAssistant, owner: str, repo: str) -> tuple[str | None, str | None]:
session = async_get_clientsession(hass)
url = f"https://github.com/{owner}/{repo}/releases/latest"
try:
async with session.get(url, timeout=20, headers={"User-Agent": UA}, allow_redirects=True) as resp:
if resp.status != 200:
return None, None
final = str(resp.url)
tag = _extract_tag_from_github_url(final)
if tag:
return tag, "release"
except Exception:
return None, None
return None, None
async def _github_latest_version_api(hass: HomeAssistant, owner: str, repo: str) -> tuple[str | None, str | None]:
session = async_get_clientsession(hass)
headers = {"Accept": "application/vnd.github+json", "User-Agent": UA}
data, status = await _safe_json(session, f"https://api.github.com/repos/{owner}/{repo}/releases/latest", headers=headers)
if isinstance(data, dict) and data.get("tag_name"):
return str(data["tag_name"]), "release"
# No releases -> pick highest semver from many tags (instead of per_page=1)
if status == 404:
data, _ = await _safe_json(session, f"https://api.github.com/repos/{owner}/{repo}/tags?per_page=100", headers=headers)
tags: list[str] = []
if isinstance(data, list):
for t in data:
if isinstance(t, dict) and t.get("name"):
tags.append(str(t["name"]))
best = _pick_highest_semver(tags)
if best:
return best, "tag"
# fallback: keep old behavior (first tag)
if tags:
return tags[0], "tag"
return None, None
async def _github_latest_version(hass: HomeAssistant, owner: str, repo: str) -> tuple[str | None, str | None]:
tag, src = await _github_latest_version_redirect(hass, owner, repo)
if tag:
return tag, src
tag, src = await _github_latest_version_api(hass, owner, repo)
if tag:
return tag, src
return await _github_latest_version_atom(hass, owner, repo)
async def _gitea_latest_version(hass: HomeAssistant, base: str, owner: str, repo: str) -> tuple[str | None, str | None]:
session = async_get_clientsession(hass)
# releases: fetch multiple, pick highest semver (instead of limit=1)
data, _ = await _safe_json(session, f"{base}/api/v1/repos/{owner}/{repo}/releases?limit=50")
rel_tags: list[str] = []
if isinstance(data, list):
for r in data:
if isinstance(r, dict) and r.get("tag_name"):
rel_tags.append(str(r["tag_name"]))
best_rel = _pick_highest_semver(rel_tags)
if best_rel:
return best_rel, "release"
if rel_tags:
return rel_tags[0], "release"
# tags: fetch multiple, pick highest semver (instead of limit=1)
data, _ = await _safe_json(session, f"{base}/api/v1/repos/{owner}/{repo}/tags?limit=50")
tags: list[str] = []
if isinstance(data, list):
for t in data:
if isinstance(t, dict) and t.get("name"):
tags.append(str(t["name"]))
best = _pick_highest_semver(tags)
if best:
return best, "tag"
if tags:
return tags[0], "tag"
return None, None
async def _gitlab_latest_version(
hass: HomeAssistant, base: str, owner: str, repo: str
) -> tuple[str | None, str | None]:
session = async_get_clientsession(hass)
headers = {"User-Agent": UA}
project = quote_plus(f"{owner}/{repo}")
# releases: fetch multiple, pick highest semver (instead of per_page=1)
data, _ = await _safe_json(session, f"{base}/api/v4/projects/{project}/releases?per_page=50", headers=headers)
rel_tags: list[str] = []
if isinstance(data, list):
for r in data:
if isinstance(r, dict) and r.get("tag_name"):
rel_tags.append(str(r["tag_name"]))
best_rel = _pick_highest_semver(rel_tags)
if best_rel:
return best_rel, "release"
if rel_tags:
return rel_tags[0], "release"
# tags: fetch multiple, pick highest semver (instead of per_page=1)
data, _ = await _safe_json(session, f"{base}/api/v4/projects/{project}/repository/tags?per_page=50", headers=headers)
tags: list[str] = []
if isinstance(data, list):
for t in data:
if isinstance(t, dict) and t.get("name"):
tags.append(str(t["name"]))
best = _pick_highest_semver(tags)
if best:
return best, "tag"
if tags:
return tags[0], "tag"
# atom fallback
atom, status = await _safe_text(session, f"{base}/{owner}/{repo}/-/tags?format=atom", headers=headers)
if status == 200 and atom:
try:
root = ET.fromstring(atom)
ns = {"a": "http://www.w3.org/2005/Atom"}
entry = root.find("a:entry", ns)
if entry is not None:
title = entry.find("a:title", ns)
if title is not None and title.text:
return title.text.strip(), "atom"
except Exception:
pass
return None, None
async def fetch_repo_info(hass: HomeAssistant, repo_url: str) -> RepoInfo:
provider = detect_provider(repo_url)
owner, repo = _split_owner_repo(repo_url)
info = RepoInfo(
owner=owner,
repo_name=repo,
description=None,
provider=provider,
default_branch=None,
latest_version=None,
latest_version_source=None,
)
if not owner or not repo:
return info
session = async_get_clientsession(hass)
try:
if provider == "github":
headers = {"Accept": "application/vnd.github+json", "User-Agent": UA}
data, status = await _safe_json(session, f"https://api.github.com/repos/{owner}/{repo}", headers=headers)
if isinstance(data, dict):
info.description = data.get("description")
info.repo_name = _normalize_repo_name(data.get("name")) or repo
info.default_branch = data.get("default_branch") or "main"
if isinstance(data.get("owner"), dict) and data["owner"].get("login"):
info.owner = data["owner"]["login"]
else:
if status == 403:
_LOGGER.debug("GitHub API blocked/rate-limited for repo info %s/%s", owner, repo)
info.default_branch = "main"
if not info.description:
desc = await _github_description_html(hass, owner, repo)
if desc:
info.description = desc
ver, src = await _github_latest_version(hass, owner, repo)
info.latest_version = ver
info.latest_version_source = src
return info
if provider == "gitlab":
u = urlparse(repo_url.rstrip("/"))
base = f"{u.scheme}://{u.netloc}"
headers = {"User-Agent": UA}
project = quote_plus(f"{owner}/{repo}")
data, _ = await _safe_json(session, f"{base}/api/v4/projects/{project}", headers=headers)
if isinstance(data, dict):
info.description = data.get("description")
info.repo_name = _normalize_repo_name(data.get("path")) or repo
info.default_branch = data.get("default_branch") or "main"
ns = data.get("namespace")
if isinstance(ns, dict) and ns.get("path"):
info.owner = ns.get("path")
ver, src = await _gitlab_latest_version(hass, base, owner, repo)
info.latest_version = ver
info.latest_version_source = src
return info
if provider == "gitea":
u = urlparse(repo_url.rstrip("/"))
base = f"{u.scheme}://{u.netloc}"
data, _ = await _safe_json(session, f"{base}/api/v1/repos/{owner}/{repo}")
if isinstance(data, dict):
info.description = data.get("description")
info.repo_name = _normalize_repo_name(data.get("name")) or repo
info.default_branch = data.get("default_branch") or "main"
if isinstance(data.get("owner"), dict) and data["owner"].get("login"):
info.owner = data["owner"]["login"]
ver, src = await _gitea_latest_version(hass, base, owner, repo)
info.latest_version = ver
info.latest_version_source = src
return info
except Exception as e:
_LOGGER.debug("fetch_repo_info failed for %s: %s", repo_url, e)
return info
async def fetch_readme_markdown(
hass: HomeAssistant,
repo_url: str,
*,
provider: str | None = None,
default_branch: str | None = None,
) -> str | None:
"""Fetch README Markdown for public repositories (GitHub/GitLab/Gitea).
Defensive behavior:
- tries multiple common README filenames
- tries multiple branches (default, main, master)
- uses public raw endpoints (no tokens required for public repositories)
"""
repo_url = (repo_url or "").strip()
if not repo_url:
return None
prov = (provider or "").strip().lower() if provider else ""
if not prov:
prov = detect_provider(repo_url)
branch_candidates: list[str] = []
if default_branch and str(default_branch).strip():
branch_candidates.append(str(default_branch).strip())
for b in ("main", "master"):
if b not in branch_candidates:
branch_candidates.append(b)
filenames = ["README.md", "readme.md", "README.MD", "README.rst", "README"]
session = async_get_clientsession(hass)
headers = {"User-Agent": UA}
def _normalize_gitlab_path(path: str) -> str | None:
p = (path or "").strip().strip("/")
if not p:
return None
parts = [x for x in p.split("/") if x]
if len(parts) < 2:
return None
if parts[-1].endswith(".git"):
parts[-1] = parts[-1][:-4]
return "/".join(parts)
candidates: list[str] = []
if prov == "github":
owner, repo = _split_owner_repo(repo_url)
if not owner or not repo:
return None
for branch in branch_candidates:
base = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}"
for fn in filenames:
candidates.append(f"{base}/{fn}")
elif prov == "gitea":
owner, repo = _split_owner_repo(repo_url)
if not owner or not repo:
return None
u = urlparse(repo_url.rstrip("/"))
root = f"{u.scheme}://{u.netloc}/{owner}/{repo}"
for branch in branch_candidates:
bases = [
f"{root}/raw/branch/{branch}",
f"{root}/raw/{branch}",
]
for b in bases:
for fn in filenames:
candidates.append(f"{b}/{fn}")
elif prov == "gitlab":
u = urlparse(repo_url.rstrip("/"))
path_repo = _normalize_gitlab_path(u.path)
if not path_repo:
return None
root = f"{u.scheme}://{u.netloc}/{path_repo}"
for branch in branch_candidates:
bases = [
f"{root}/-/raw/{branch}",
f"{root}/raw/{branch}",
]
for b in bases:
for fn in filenames:
candidates.append(f"{b}/{fn}")
else:
return None
for url in candidates:
try:
async with session.get(url, timeout=20, headers=headers) as resp:
if resp.status != 200:
continue
txt = await resp.text()
if txt and txt.strip():
return txt
except Exception:
continue
return None

View File

@@ -0,0 +1,184 @@
from __future__ import annotations
import time
import uuid
from dataclasses import dataclass
from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.helpers.storage import Store
_STORAGE_VERSION = 1
_STORAGE_KEY = "bcs_store"
@dataclass
class CustomRepo:
id: str
url: str
name: str | None = None
@dataclass
class InstalledRepo:
repo_id: str
url: str
domains: list[str]
installed_at: int
installed_version: str | None = None # BCS "installed ref" (tag/release/branch)
installed_manifest_version: str | None = None # informational only
ref: str | None = None # kept for backward compatibility / diagnostics
class BCSStorage:
"""Persistent storage for Bahmcloud Store.
Keys:
- custom_repos: list of manually added repositories
- installed_repos: mapping repo_id -> installed metadata
"""
def __init__(self, hass: HomeAssistant) -> None:
self.hass = hass
self._store: Store[dict[str, Any]] = Store(hass, _STORAGE_VERSION, _STORAGE_KEY)
async def _load(self) -> dict[str, Any]:
data = await self._store.async_load() or {}
if not isinstance(data, dict):
data = {}
if "custom_repos" not in data or not isinstance(data.get("custom_repos"), list):
data["custom_repos"] = []
if "installed_repos" not in data or not isinstance(data.get("installed_repos"), dict):
data["installed_repos"] = {}
return data
async def _save(self, data: dict[str, Any]) -> None:
await self._store.async_save(data)
async def list_custom_repos(self) -> list[CustomRepo]:
data = await self._load()
repos = data.get("custom_repos", [])
out: list[CustomRepo] = []
for r in repos:
if not isinstance(r, dict):
continue
rid = r.get("id")
url = r.get("url")
if not rid or not url:
continue
out.append(CustomRepo(id=str(rid), url=str(url), name=r.get("name")))
return out
async def add_custom_repo(self, url: str, name: str | None) -> CustomRepo:
data = await self._load()
repos = data.get("custom_repos", [])
# De-duplicate by URL
for r in repos:
if isinstance(r, dict) and str(r.get("url") or "").strip() == url.strip():
return CustomRepo(id=str(r["id"]), url=str(r["url"]), name=r.get("name"))
rid = f"custom:{uuid.uuid4().hex[:10]}"
entry = {"id": rid, "url": url.strip(), "name": name.strip() if name else None}
repos.append(entry)
data["custom_repos"] = repos
await self._save(data)
return CustomRepo(id=rid, url=entry["url"], name=entry["name"])
async def remove_custom_repo(self, repo_id: str) -> None:
data = await self._load()
repos = data.get("custom_repos", [])
data["custom_repos"] = [
r for r in repos if not (isinstance(r, dict) and r.get("id") == repo_id)
]
await self._save(data)
async def get_installed_repo(self, repo_id: str) -> InstalledRepo | None:
data = await self._load()
installed = data.get("installed_repos", {})
if not isinstance(installed, dict):
return None
entry = installed.get(repo_id)
if not isinstance(entry, dict):
return None
try:
domains = entry.get("domains") or []
if not isinstance(domains, list):
domains = []
domains = [str(d) for d in domains if str(d).strip()]
installed_version = entry.get("installed_version")
ref = entry.get("ref")
# Backward compatibility:
# If installed_version wasn't stored, fall back to ref.
if (not installed_version) and ref:
installed_version = ref
installed_manifest_version = entry.get("installed_manifest_version")
return InstalledRepo(
repo_id=str(entry.get("repo_id") or repo_id),
url=str(entry.get("url") or ""),
domains=domains,
installed_at=int(entry.get("installed_at") or 0),
installed_version=str(installed_version) if installed_version else None,
installed_manifest_version=str(installed_manifest_version) if installed_manifest_version else None,
ref=str(ref) if ref else None,
)
except Exception:
return None
async def list_installed_repos(self) -> list[InstalledRepo]:
data = await self._load()
installed = data.get("installed_repos", {})
out: list[InstalledRepo] = []
if not isinstance(installed, dict):
return out
for rid in list(installed.keys()):
item = await self.get_installed_repo(str(rid))
if item:
out.append(item)
return out
async def set_installed_repo(
self,
*,
repo_id: str,
url: str,
domains: list[str],
installed_version: str | None,
installed_manifest_version: str | None = None,
ref: str | None,
) -> None:
data = await self._load()
installed = data.get("installed_repos", {})
if not isinstance(installed, dict):
installed = {}
data["installed_repos"] = installed
installed[str(repo_id)] = {
"repo_id": str(repo_id),
"url": str(url),
"domains": [str(d) for d in (domains or []) if str(d).strip()],
"installed_at": int(time.time()),
# IMPORTANT: this is what BCS uses as "installed version" (ref/tag/branch)
"installed_version": installed_version,
# informational only
"installed_manifest_version": installed_manifest_version,
# keep ref too (debug/backward compatibility)
"ref": ref,
}
await self._save(data)
async def remove_installed_repo(self, repo_id: str) -> None:
data = await self._load()
installed = data.get("installed_repos", {})
if isinstance(installed, dict) and repo_id in installed:
installed.pop(repo_id, None)
data["installed_repos"] = installed
await self._save(data)

View File

@@ -0,0 +1,8 @@
{
"issues": {
"restart_required": {
"title": "Restart required",
"description": "One or more integrations were installed or updated by Bahmcloud Store. Restart Home Assistant to load the changes."
}
}
}

View File

@@ -1,11 +1,146 @@
from __future__ import annotations
from homeassistant.components.update import UpdateEntity, UpdateEntityFeature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
import logging
from dataclasses import dataclass
from typing import Any
from . import DOMAIN
from .store import BahmcloudStore, Package
from homeassistant.components.update import UpdateEntity, UpdateEntityFeature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.entity import EntityCategory
from .core import DOMAIN, SIGNAL_UPDATED, BCSCore
_LOGGER = logging.getLogger(__name__)
def _pretty_repo_name(core: BCSCore, repo_id: str) -> str:
"""Return a human-friendly name for a repo update entity."""
try:
repo = core.get_repo(repo_id)
if repo and getattr(repo, "name", None):
name = str(repo.name).strip()
if name:
return name
except Exception:
pass
# Fallbacks
if repo_id.startswith("index:"):
return f"BCS Index {repo_id.split(':', 1)[1]}"
if repo_id.startswith("custom:"):
return f"BCS Custom {repo_id.split(':', 1)[1]}"
return f"BCS {repo_id}"
@dataclass(frozen=True)
class _RepoKey:
repo_id: str
class BCSRepoUpdateEntity(UpdateEntity):
"""Update entity representing a BCS-managed repository."""
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_supported_features = UpdateEntityFeature.INSTALL
def __init__(self, core: BCSCore, repo_id: str) -> None:
self._core = core
self._repo_id = repo_id
self._in_progress = False
# Stable unique id (do NOT change)
self._attr_unique_id = f"{DOMAIN}:{repo_id}"
# Human-friendly name in UI
pretty = _pretty_repo_name(core, repo_id)
self._attr_name = pretty
# Title shown in the entity dialog
self._attr_title = pretty
@property
def available(self) -> bool:
repo = self._core.get_repo(self._repo_id)
installed = self._core.get_installed(self._repo_id)
return repo is not None and installed is not None
@property
def in_progress(self) -> bool | None:
return self._in_progress
@property
def installed_version(self) -> str | None:
installed = self._core.get_installed(self._repo_id) or {}
v = installed.get("installed_version") or installed.get("ref")
return str(v) if v else None
@property
def latest_version(self) -> str | None:
repo = self._core.get_repo(self._repo_id)
if not repo:
return None
v = getattr(repo, "latest_version", None)
return str(v) if v else None
@property
def update_available(self) -> bool:
latest = self.latest_version
installed = self.installed_version
if not latest or not installed:
return False
return latest != installed
def version_is_newer(self, latest_version: str, installed_version: str) -> bool:
return latest_version != installed_version
@property
def release_url(self) -> str | None:
repo = self._core.get_repo(self._repo_id)
return getattr(repo, "url", None) if repo else None
async def async_install(self, version: str | None, backup: bool, **kwargs: Any) -> None:
if version is not None:
_LOGGER.debug(
"BCS update entity requested specific version=%s (ignored)", version
)
self._in_progress = True
self.async_write_ha_state()
try:
await self._core.update_repo(self._repo_id)
finally:
self._in_progress = False
self.async_write_ha_state()
@callback
def _sync_entities(
core: BCSCore,
existing: dict[str, BCSRepoUpdateEntity],
async_add_entities: AddEntitiesCallback,
) -> None:
"""Ensure there is one update entity per installed repo."""
installed_map = getattr(core, "_installed_cache", {}) or {}
new_entities: list[BCSRepoUpdateEntity] = []
for repo_id, data in installed_map.items():
if not isinstance(data, dict):
continue
if repo_id in existing:
continue
ent = BCSRepoUpdateEntity(core, repo_id)
existing[repo_id] = ent
new_entities.append(ent)
if new_entities:
async_add_entities(new_entities)
for ent in existing.values():
ent.async_write_ha_state()
async def async_setup_platform(
@@ -14,81 +149,18 @@ async def async_setup_platform(
async_add_entities: AddEntitiesCallback,
discovery_info=None,
):
store: BahmcloudStore = hass.data[DOMAIN]
entities: dict[str, BahmcloudPackageUpdate] = {}
def should_have_update_entity(pkg: Package) -> bool:
# Store selbst immer als Update
if pkg.type == "store":
return True
# Andere Pakete nur, wenn installiert
return store.is_installed(pkg.domain)
def rebuild_entities() -> None:
# Create entities for packages that qualify
for pkg in store.packages.values():
if not should_have_update_entity(pkg):
continue
uid = f"{DOMAIN}:{pkg.id}"
if uid not in entities:
ent = BahmcloudPackageUpdate(store, pkg.id)
entities[uid] = ent
async_add_entities([ent], update_before_add=True)
# Refresh states
for ent in entities.values():
ent.async_write_ha_state()
store.add_listener(rebuild_entities)
rebuild_entities()
class BahmcloudPackageUpdate(UpdateEntity):
_attr_supported_features = UpdateEntityFeature.INSTALL
def __init__(self, store: BahmcloudStore, package_id: str) -> None:
self.store = store
self.package_id = package_id
self._attr_unique_id = f"{DOMAIN}_{package_id}"
self._attr_name = f"{package_id} update"
@property
def _pkg(self) -> Package | None:
return self.store.packages.get(self.package_id)
@property
def title(self) -> str | None:
pkg = self._pkg
return pkg.name if pkg else None
@property
def installed_version(self) -> str | None:
pkg = self._pkg
if not pkg:
return None
if not self.store.is_installed(pkg.domain):
return None
return self.store.installed_version(pkg.domain)
@property
def latest_version(self) -> str | None:
pkg = self._pkg
return pkg.latest_version if pkg else None
@property
def release_summary(self) -> str | None:
pkg = self._pkg
if not pkg:
return None
if pkg.release_url:
return f"Release: {pkg.release_url}"
return f"Repo: {pkg.repo}"
async def async_install(self, version: str | None, backup: bool, **kwargs) -> None:
pkg = self._pkg
if not pkg:
"""Set up BCS update entities."""
core: BCSCore | None = hass.data.get(DOMAIN)
if not core:
_LOGGER.debug("BCS core not available, skipping update platform setup")
return
await self.store.install_from_zip(pkg)
self.async_write_ha_state()
entities: dict[str, BCSRepoUpdateEntity] = {}
_sync_entities(core, entities, async_add_entities)
@callback
def _handle_update() -> None:
_sync_entities(core, entities, async_add_entities)
async_dispatcher_connect(hass, SIGNAL_UPDATED, _handle_update)

View File

@@ -0,0 +1,351 @@
from __future__ import annotations
import base64
import logging
from dataclasses import asdict
from pathlib import Path
from typing import Any, TYPE_CHECKING
from aiohttp import web
from homeassistant.components.http import HomeAssistantView
if TYPE_CHECKING:
from .core import BCSCore # typing only
_LOGGER = logging.getLogger(__name__)
def _render_markdown_server_side(md: str) -> str | None:
text = (md or "").strip()
if not text:
return None
html: str | None = None
try:
import markdown as mdlib # type: ignore
html = mdlib.markdown(
text,
extensions=["fenced_code", "tables", "sane_lists", "toc"],
output_format="html5",
)
except Exception as e:
_LOGGER.debug("python-markdown render failed: %s", e)
html = None
if not html:
return None
try:
import bleach # type: ignore
allowed_tags = [
"p",
"br",
"hr",
"div",
"span",
"blockquote",
"pre",
"code",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"ul",
"ol",
"li",
"strong",
"em",
"b",
"i",
"u",
"s",
"a",
"img",
"table",
"thead",
"tbody",
"tr",
"th",
"td",
]
allowed_attrs = {
"a": ["href", "title", "target", "rel"],
"img": ["src", "alt", "title"],
"th": ["align"],
"td": ["align"],
"*": ["class"],
}
sanitized = bleach.clean(
html,
tags=allowed_tags,
attributes=allowed_attrs,
protocols=["http", "https", "mailto"],
strip=True,
)
sanitized = sanitized.replace(
'<a href="',
'<a rel="noreferrer noopener" target="_blank" href="',
)
return sanitized
except Exception as e:
_LOGGER.debug("bleach sanitize failed/unavailable: %s", e)
return html
_TEXT_KEYS = ("readme", "markdown", "text", "content", "data", "body")
def _maybe_decode_base64(content: str, encoding: Any) -> str | None:
if not isinstance(content, str):
return None
enc = ""
if isinstance(encoding, str):
enc = encoding.strip().lower()
if "base64" not in enc:
return None
try:
raw = base64.b64decode(content.encode("utf-8"), validate=False)
return raw.decode("utf-8", errors="replace")
except Exception:
return None
def _extract_text_recursive(obj: Any, depth: int = 0) -> str | None:
if obj is None:
return None
if isinstance(obj, bytes):
try:
return obj.decode("utf-8", errors="replace")
except Exception:
return None
if isinstance(obj, str):
return obj
if depth > 8:
return None
if isinstance(obj, dict):
content = obj.get("content")
encoding = obj.get("encoding")
decoded = _maybe_decode_base64(content, encoding)
if decoded:
return decoded
if isinstance(content, str) and (not isinstance(encoding, str) or not encoding.strip()):
return content
for k in _TEXT_KEYS:
v = obj.get(k)
if isinstance(v, str):
return v
if isinstance(v, bytes):
try:
return v.decode("utf-8", errors="replace")
except Exception:
pass
for v in obj.values():
out = _extract_text_recursive(v, depth + 1)
if out:
return out
return None
if isinstance(obj, list):
for item in obj:
out = _extract_text_recursive(item, depth + 1)
if out:
return out
return None
return None
class StaticAssetsView(HomeAssistantView):
url = "/api/bahmcloud_store_static/{path:.*}"
name = "api:bahmcloud_store_static"
requires_auth = False
async def get(self, request: web.Request, path: str) -> web.StreamResponse:
base = Path(__file__).resolve().parent / "panel"
base_resolved = base.resolve()
req_path = (path or "").lstrip("/")
if req_path == "":
req_path = "index.html"
target = (base / req_path).resolve()
if not str(target).startswith(str(base_resolved)):
return web.Response(status=404)
if target.is_dir():
target = (target / "index.html").resolve()
if not target.exists():
_LOGGER.error("BCS static asset not found: %s", target)
return web.Response(status=404)
resp = web.FileResponse(path=target)
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
resp.headers["Pragma"] = "no-cache"
return resp
class BCSApiView(HomeAssistantView):
url = "/api/bcs"
name = "api:bcs"
requires_auth = True
def __init__(self, core: Any) -> None:
self.core: BCSCore = core
async def get(self, request: web.Request) -> web.Response:
return web.json_response(
{"ok": True, "version": self.core.version, "repos": self.core.list_repos_public()}
)
async def post(self, request: web.Request) -> web.Response:
action = request.query.get("action")
if action == "refresh":
_LOGGER.info("BCS manual refresh triggered via API")
try:
await self.core.full_refresh(source="manual")
return web.json_response({"ok": True})
except Exception as e:
_LOGGER.error("BCS manual refresh failed: %s", e)
return web.json_response({"ok": False, "message": "Refresh failed"}, status=500)
try:
data = await request.json()
except Exception:
data = {}
op = data.get("op")
if op == "add_custom_repo":
url = str(data.get("url") or "").strip()
name = data.get("name")
name = str(name).strip() if name else None
if not url:
return web.json_response({"ok": False, "message": "Missing url"}, status=400)
repo = await self.core.add_custom_repo(url=url, name=name)
return web.json_response({"ok": True, "repo": asdict(repo)})
return web.json_response({"ok": False, "message": "Unknown operation"}, status=400)
class BCSCustomRepoView(HomeAssistantView):
url = "/api/bcs/custom_repo"
name = "api:bcs_custom_repo"
requires_auth = True
def __init__(self, core: Any) -> None:
self.core: BCSCore = core
async def delete(self, request: web.Request) -> web.Response:
repo_id = request.query.get("id")
if not repo_id:
return web.json_response({"ok": False, "message": "Missing id"}, status=400)
await self.core.remove_custom_repo(repo_id)
return web.json_response({"ok": True})
class BCSReadmeView(HomeAssistantView):
url = "/api/bcs/readme"
name = "api:bcs_readme"
requires_auth = True
def __init__(self, core: Any) -> None:
self.core: BCSCore = core
async def get(self, request: web.Request) -> web.Response:
repo_id = request.query.get("repo_id")
if not repo_id:
return web.json_response({"ok": False, "message": "Missing repo_id"}, status=400)
maybe_md = await self.core.fetch_readme_markdown(repo_id)
md = _extract_text_recursive(maybe_md)
if not md or not md.strip():
t = type(maybe_md).__name__
return web.json_response(
{"ok": False, "message": f"README not found or unsupported format (got {t})."},
status=404,
)
md_str = str(md)
html = _render_markdown_server_side(md_str)
return web.json_response({"ok": True, "readme": md_str, "html": html})
class BCSInstallView(HomeAssistantView):
url = "/api/bcs/install"
name = "api:bcs_install"
requires_auth = True
def __init__(self, core: Any) -> None:
self.core: BCSCore = core
async def post(self, request: web.Request) -> web.Response:
repo_id = request.query.get("repo_id")
if not repo_id:
return web.json_response({"ok": False, "message": "Missing repo_id"}, status=400)
try:
result = await self.core.install_repo(repo_id)
return web.json_response(result, status=200)
except Exception as e:
_LOGGER.exception("BCS install failed: %s", e)
return web.json_response({"ok": False, "message": str(e) or "Install failed"}, status=500)
class BCSUpdateView(HomeAssistantView):
url = "/api/bcs/update"
name = "api:bcs_update"
requires_auth = True
def __init__(self, core: Any) -> None:
self.core: BCSCore = core
async def post(self, request: web.Request) -> web.Response:
repo_id = request.query.get("repo_id")
if not repo_id:
return web.json_response({"ok": False, "message": "Missing repo_id"}, status=400)
try:
result = await self.core.update_repo(repo_id)
return web.json_response(result, status=200)
except Exception as e:
_LOGGER.exception("BCS update failed: %s", e)
return web.json_response({"ok": False, "message": str(e) or "Update failed"}, status=500)
class BCSRestartView(HomeAssistantView):
url = "/api/bcs/restart"
name = "api:bcs_restart"
requires_auth = True
def __init__(self, core: Any) -> None:
self.core: BCSCore = core
async def post(self, request: web.Request) -> web.Response:
try:
await self.core.request_restart()
return web.json_response({"ok": True})
except Exception as e:
_LOGGER.exception("BCS restart failed: %s", e)
return web.json_response({"ok": False, "message": str(e) or "Restart failed"}, status=500)