goosebit 0.2.3__py3-none-any.whl → 0.2.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. goosebit/__init__.py +32 -3
  2. goosebit/api/v1/devices/device/routes.py +10 -4
  3. goosebit/api/v1/devices/responses.py +0 -7
  4. goosebit/api/v1/devices/routes.py +19 -3
  5. goosebit/api/v1/rollouts/responses.py +2 -7
  6. goosebit/api/v1/rollouts/routes.py +7 -3
  7. goosebit/api/v1/software/responses.py +0 -7
  8. goosebit/api/v1/software/routes.py +24 -11
  9. goosebit/auth/__init__.py +12 -8
  10. goosebit/db/__init__.py +12 -1
  11. goosebit/db/migrations/models/1_20241109151811_update.py +11 -0
  12. goosebit/db/models.py +19 -4
  13. goosebit/realtime/logs.py +1 -1
  14. goosebit/schema/devices.py +42 -38
  15. goosebit/schema/rollouts.py +21 -18
  16. goosebit/schema/software.py +24 -19
  17. goosebit/settings/schema.py +2 -0
  18. goosebit/ui/bff/common/__init__.py +0 -0
  19. goosebit/ui/bff/common/requests.py +44 -0
  20. goosebit/ui/bff/common/responses.py +16 -0
  21. goosebit/ui/bff/common/util.py +32 -0
  22. goosebit/ui/bff/devices/responses.py +15 -19
  23. goosebit/ui/bff/devices/routes.py +61 -7
  24. goosebit/ui/bff/rollouts/responses.py +15 -19
  25. goosebit/ui/bff/rollouts/routes.py +8 -6
  26. goosebit/ui/bff/routes.py +4 -2
  27. goosebit/ui/bff/software/responses.py +29 -19
  28. goosebit/ui/bff/software/routes.py +29 -16
  29. goosebit/ui/nav.py +1 -1
  30. goosebit/ui/routes.py +10 -19
  31. goosebit/ui/static/js/devices.js +188 -94
  32. goosebit/ui/static/js/rollouts.js +20 -13
  33. goosebit/ui/static/js/software.js +5 -11
  34. goosebit/ui/static/js/util.js +43 -14
  35. goosebit/ui/templates/devices.html.jinja +77 -49
  36. goosebit/ui/templates/nav.html.jinja +35 -4
  37. goosebit/ui/templates/rollouts.html.jinja +23 -23
  38. goosebit/updater/controller/v1/routes.py +33 -23
  39. goosebit/updater/controller/v1/schema.py +4 -4
  40. goosebit/updater/manager.py +28 -52
  41. goosebit/updater/routes.py +6 -2
  42. goosebit/updates/__init__.py +14 -21
  43. goosebit/updates/swdesc.py +36 -15
  44. {goosebit-0.2.3.dist-info → goosebit-0.2.5.dist-info}/METADATA +23 -7
  45. {goosebit-0.2.3.dist-info → goosebit-0.2.5.dist-info}/RECORD +48 -44
  46. {goosebit-0.2.3.dist-info → goosebit-0.2.5.dist-info}/WHEEL +1 -1
  47. goosebit-0.2.5.dist-info/entry_points.txt +3 -0
  48. goosebit/ui/static/js/index.js +0 -155
  49. goosebit/ui/templates/index.html.jinja +0 -25
  50. {goosebit-0.2.3.dist-info → goosebit-0.2.5.dist-info}/LICENSE +0 -0
@@ -4,70 +4,98 @@
4
4
  <div class="row p-2 d-flex justify-content-center">
5
5
  <div class="col">
6
6
  <table id="device-table" class="table table-hover">
7
- <thead>
8
- <tr>
9
- <th>Up</th>
10
- <th>UUID</th>
11
- <th>Name</th>
12
- <th>Model</th>
13
- <th>Revision</th>
14
- <th>Feed</th>
15
- <th>Installed Software</th>
16
- <th>Target Software</th>
17
- <th>Update Mode</th>
18
- <th>State</th>
19
- <th>Force Update</th>
20
- <th>Progress</th>
21
- <th>Last IP</th>
22
- <th>Last Seen</th>
23
- </tr>
24
- </thead>
25
- <tbody id="devices-list">
26
- </tbody>
27
7
  </table>
28
8
  </div>
29
9
  </div>
30
10
  </div>
31
- <div class="modal" id="device-config-modal">
32
- <div class="modal-dialog modal-lg">
11
+ <div class="modal modal-lg fade" id="device-config-modal">
12
+ <div class="modal-dialog modal-dialog-centered modal-xl">
33
13
  <div class="modal-content">
34
14
  <div class="modal-header">
35
- <h5 class="modal-title">Configure Devices</h5>
15
+ <h5 class="modal-title">Edit Devices</h5>
36
16
  <button type="button"
37
17
  class="btn-close"
38
18
  data-bs-dismiss="modal"
39
19
  aria-label="Close"></button>
40
20
  </div>
41
- <form id="device-config-form" class="needs-validation" novalidate>
42
- <div class="modal-body">
43
- <div class="form-group mb-3">
44
- <label for="device-selected-name">Name</label>
45
- <input id="device-selected-name" class="form-control" placeholder="Name" />
21
+ <div class="modal-body">
22
+ <form id="device-name-form">
23
+ <div class="input-group mb-3">
24
+ <span class="input-group-text">Name</span>
25
+ <input id="device-name" class="form-control" />
26
+ <button type="submit" class="btn btn-outline-light">Apply</button>
46
27
  </div>
47
- <div class="form-group mb-3">
48
- <label for="device-selected-feed">Feed</label>
49
- <input id="device-selected-feed"
50
- class="form-control"
51
- placeholder="Feed"
52
- required />
53
- <div class="invalid-feedback">
54
- Feed missing. Use "default" if working with a single
55
- feed.
56
- </div>
28
+ </form>
29
+ <hr>
30
+ <ul class="nav nav-underline nav-justified w-100" role="tablist">
31
+ <li class="nav-item">
32
+ <button class="nav-link active"
33
+ aria-current="page"
34
+ id="rollout-tab"
35
+ data-bs-toggle="tab"
36
+ data-bs-target="#rollout-tab-content"
37
+ type="button"
38
+ role="tab">Software Rollout</button>
39
+ </li>
40
+ <li class="nav-item">
41
+ <button class="nav-link"
42
+ id="manual-tab"
43
+ data-bs-toggle="tab"
44
+ data-bs-target="#manual-tab-content"
45
+ type="button"
46
+ role="tab">Manual Software</button>
47
+ </li>
48
+ <li class="nav-item">
49
+ <button class="nav-link"
50
+ id="latest-tab"
51
+ data-bs-toggle="tab"
52
+ data-bs-target="#latest-tab-content"
53
+ type="button"
54
+ role="tab">Latest Software</button>
55
+ </li>
56
+ </ul>
57
+ <div class="tab-content mt-3">
58
+ <div class="tab-pane active" id="rollout-tab-content">
59
+ <form id="device-software-rollout-form" class="needs-validation" novalidate>
60
+ <div class="form-group mb-3">
61
+ <div class="input-group mb-3 has-validation">
62
+ <span class="input-group-text">Feed</span>
63
+ <input id="device-selected-feed"
64
+ class="form-control"
65
+ value="default"
66
+ required />
67
+ <div class="invalid-feedback">Feed missing. Use "default" if working with a single feed.</div>
68
+ </div>
69
+ </div>
70
+ <button type="submit" class="btn btn-outline-light w-100">Use Software Rollout</button>
71
+ </form>
57
72
  </div>
58
- <div class="form-group mb-3">
59
- <label for="selected-sw">Update Mode</label>
60
- <select class="form-select" id="selected-sw" required>
61
- <option value="" disabled selected>Select software</option>
62
- </select>
63
- <div class="invalid-feedback">Mode missing.</div>
73
+ <div class="tab-pane" id="manual-tab-content">
74
+ <form id="device-software-manual-form" class="needs-validation" novalidate>
75
+ <div class="form-group mb-3">
76
+ <div class="input-group mb-3 has-validation">
77
+ <span class="input-group-text">Software</span>
78
+ <select class="form-control"
79
+ id="selected-sw"
80
+ data-size="5"
81
+ data-style-base="form-control"
82
+ title="Select software"
83
+ data-live-search="true"
84
+ data-live-search-normalize="true"
85
+ required></select>
86
+ <div class="invalid-feedback">Software missing.</div>
87
+ </div>
88
+ </div>
89
+ <button type="submit" class="btn btn-outline-light w-100">Use Manual Software</button>
90
+ </form>
91
+ </div>
92
+ <div class="tab-pane" id="latest-tab-content">
93
+ <form id="device-software-latest-form" class="needs-validation" novalidate>
94
+ <button type="submit" class="btn btn-outline-light w-100">Use Latest Software</button>
95
+ </form>
64
96
  </div>
65
97
  </div>
66
- <div class="modal-footer">
67
- <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
68
- <button type="submit" class="btn btn-outline-light">Save changes</button>
69
- </div>
70
- </form>
98
+ </div>
71
99
  </div>
72
100
  </div>
73
101
  </div>
@@ -10,8 +10,11 @@
10
10
  rel="stylesheet"
11
11
  integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
12
12
  crossorigin="anonymous" />
13
- <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
14
- integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
13
+ <script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js"
14
+ integrity="sha384-I7E8VVD/ismYTF4hNIPjVp/Zjvgyol6VFvRkX/vR+Vc4jQkC+hVqc2pM8ODewa9r"
15
+ crossorigin="anonymous"></script>
16
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js"
17
+ integrity="sha384-0pUGZvbkm6XF6gxjEnlmuGrJXVbNuzT9qBBavbLwCsOGabYfZo0T0to5eqruptLy"
15
18
  crossorigin="anonymous"></script>
16
19
  <link rel="stylesheet"
17
20
  href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
@@ -27,6 +30,10 @@
27
30
  <link href="https://cdn.jsdelivr.net/npm/sweetalert2@11/dist/sweetalert2.min.css"
28
31
  rel="stylesheet" />
29
32
  <script src="https://cdn.jsdelivr.net/npm/sweetalert2@11/dist/sweetalert2.all.min.js"></script>
33
+ <!-- Bootstrap select (searchable select) -->
34
+ <link rel="stylesheet"
35
+ href="https://cdn.jsdelivr.net/npm/bootstrap-select@1.14.0-beta3/dist/css/bootstrap-select.min.css">
36
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap-select@1.14.0-beta3/dist/js/bootstrap-select.min.js"></script>
30
37
  <!--data tables alignment fix-->
31
38
  <style>
32
39
  th.dt-type-numeric {
@@ -40,13 +47,37 @@
40
47
  background-color: transparent;
41
48
  }
42
49
  </style>
43
- <script>const TABLE_UPDATE_TIME = 3000;</script>
50
+ <!--select search fix-->
51
+ <style>
52
+ .no-results {
53
+ background-color: var(--bs-body-bg)!important;
54
+ }
55
+ .bs-searchbox input {
56
+ border: var(--bs-border-width) solid var(--bs-border-color)!important;
57
+ }
58
+ .was-validated .bs-searchbox input {
59
+ background-image: none!important;
60
+ box-shadow: none!important;
61
+ }
62
+ </style>
63
+ <script>
64
+ const TABLE_UPDATE_TIME = 3000;
65
+ DataTable.ext.errMode = function ( e, settings, helpPage, message ) {
66
+ if (e.jqXHR.status == 401) {
67
+ window.location.reload();
68
+ } else if (e.jqXHR.status >= 200 && e.jqXHR.status < 300) {
69
+ console.error("AJAX query error when reloading datatable");
70
+ }
71
+ console.error("Unknown error when reloading datatable");
72
+ };
73
+
74
+ </script>
44
75
  <script src="{{ url_for('static', path='js/util.js') }}"></script>
45
76
  </head>
46
77
  <body data-bs-theme="dark">
47
78
  <nav class="navbar navbar-expand-lg bg-body-tertiary">
48
79
  <div class="container-fluid">
49
- <a class="navbar-brand" href="{{ request.url_for('home_ui') }}">
80
+ <a class="navbar-brand" href="{{ request.url_for('devices_ui') }}">
50
81
  <img src="{{ request.url_for('static', path='svg/goosebit-logo.svg') }}"
51
82
  class="me-2"
52
83
  height="30px"
@@ -36,35 +36,35 @@
36
36
  </div>
37
37
  <form id="rollout-form" class="needs-validation" novalidate>
38
38
  <div class="modal-body">
39
- <div class="form-group mb-3">
40
- <label for="rollout-selected-name">Name</label>
41
- <input id="rollout-selected-name"
42
- class="form-control"
43
- placeholder="Release 1" />
39
+ <div class="input-group mb-3 has-validation">
40
+ <span class="input-group-text">Name</span>
41
+ <input class="form-control" id="rollout-selected-name" required />
42
+ <div class="invalid-feedback">Name missing.</div>
43
+ </div>
44
+ <div class="input-group mb-3 has-validation">
45
+ <span class="input-group-text">Feed</span>
46
+ <input class="form-control" id="rollout-selected-feed" required />
47
+ <div class="invalid-feedback">Feed missing. Use "default" if working with a single feed.</div>
44
48
  </div>
45
49
  <div class="form-group mb-3">
46
- <label for="rollout-selected-feed">Feed</label>
47
- <input id="rollout-selected-feed"
48
- class="form-control"
49
- placeholder="qa"
50
- required />
51
- <div class="invalid-feedback">
52
- Feed missing. Use "default" if working with a single
53
- feed.
50
+ <div class="input-group mb-3 has-validation">
51
+ <span class="input-group-text">Software</span>
52
+ <select class="form-control"
53
+ id="selected-sw"
54
+ data-size="5"
55
+ data-style-base="form-control"
56
+ title="Select software"
57
+ data-live-search="true"
58
+ data-live-search-normalize="true"
59
+ required></select>
60
+ <div class="invalid-feedback">Software missing.</div>
54
61
  </div>
55
62
  </div>
56
- <div class="form-group mb-3">
57
- <label for="selected-sw">Software</label>
58
- <select class="form-select" id="selected-sw" required>
59
- <option value="" disabled selected>Select software</option>
60
- </select>
61
- <div class="invalid-feedback">Select software for the rollout.</div>
63
+ <div class="modal-footer">
64
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
65
+ <button type="submit" class="btn btn-outline-light">Save changes</button>
62
66
  </div>
63
67
  </div>
64
- <div class="modal-footer">
65
- <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
66
- <button type="submit" class="btn btn-outline-light">Save changes</button>
67
- </div>
68
68
  </form>
69
69
  </div>
70
70
  </div>
@@ -2,7 +2,7 @@ import logging
2
2
 
3
3
  from fastapi import APIRouter, Depends, HTTPException
4
4
  from fastapi.requests import Request
5
- from fastapi.responses import FileResponse, RedirectResponse, Response
5
+ from fastapi.responses import FileResponse, Response
6
6
 
7
7
  from goosebit.db.models import Software, UpdateStateEnum
8
8
  from goosebit.settings import config
@@ -23,11 +23,13 @@ router = APIRouter(prefix="/v1")
23
23
 
24
24
  @router.get("/{dev_id}")
25
25
  async def polling(request: Request, dev_id: str, updater: UpdateManager = Depends(get_update_manager)):
26
- links = {}
26
+ links: dict[str, dict[str, str]] = {}
27
27
 
28
- sleep = updater.poll_time
29
28
  device = await updater.get_device()
30
29
 
30
+ if device is None:
31
+ raise HTTPException(404)
32
+
31
33
  if device.last_state == UpdateStateEnum.UNKNOWN:
32
34
  # device registration
33
35
  sleep = config.poll_time_registration
@@ -42,14 +44,15 @@ async def polling(request: Request, dev_id: str, updater: UpdateManager = Depend
42
44
  logger.info(f"Skip: registration required, device={updater.dev_id}")
43
45
 
44
46
  elif device.last_state == UpdateStateEnum.ERROR and not device.force_update:
45
- logger.warning(f"Skip: device in error state, device={updater.dev_id}")
46
- pass
47
+ sleep = config.poll_time_default
48
+ logger.info(f"Skip: device in error state, device={updater.dev_id}")
47
49
 
48
50
  else:
49
51
  # provide update if available. Note: this is also required while in state "running", otherwise swupdate
50
52
  # won't confirm a successful testing (might be a bug/problem in swupdate)
51
53
  handling_type, software = await updater.get_update()
52
- if handling_type != HandlingType.SKIP:
54
+ if handling_type != HandlingType.SKIP and software is not None:
55
+ sleep = config.poll_time_updating
53
56
  links["deploymentBase"] = {
54
57
  "href": str(
55
58
  request.url_for(
@@ -60,6 +63,11 @@ async def polling(request: Request, dev_id: str, updater: UpdateManager = Depend
60
63
  )
61
64
  }
62
65
  logger.info(f"Forced: update available, device={updater.dev_id}")
66
+ else:
67
+ sleep = config.poll_time_default
68
+
69
+ # update poll time on manager so that UI can properly display if device is overdue
70
+ updater.poll_time = sleep
63
71
 
64
72
  return {
65
73
  "config": {"polling": {"sleep": sleep}},
@@ -99,60 +107,62 @@ async def deployment_feedback(
99
107
  _: Request, data: FeedbackSchema, action_id: int, updater: UpdateManager = Depends(get_update_manager)
100
108
  ):
101
109
  if data.status.execution == FeedbackStatusExecutionState.PROCEEDING:
102
- await updater.update_device_state(UpdateStateEnum.RUNNING)
110
+ device = await updater.get_device()
111
+ if device and device.last_state != UpdateStateEnum.RUNNING:
112
+ await updater.clear_log()
113
+ await updater.update_device_state(UpdateStateEnum.RUNNING)
114
+
103
115
  logger.debug(f"Installation in progress, device={updater.dev_id}")
104
116
 
105
117
  elif data.status.execution == FeedbackStatusExecutionState.CLOSED:
106
118
  await updater.update_force_update(False)
107
- await updater.update_log_complete(True)
108
119
 
109
120
  reported_software = await Software.get_or_none(id=action_id)
110
121
 
111
122
  # From hawkBit docu: DDI defines also a status NONE which will not be interpreted by the update server
112
123
  # and handled like SUCCESS.
113
124
  if data.status.result.finished in [FeedbackStatusResultFinished.SUCCESS, FeedbackStatusResultFinished.NONE]:
125
+ await updater.deployment_action_success()
114
126
  await updater.update_device_state(UpdateStateEnum.FINISHED)
115
127
 
116
- # not guaranteed to be the correct rollout - see next comment.
117
128
  rollout = await updater.get_rollout()
118
129
  if rollout:
119
130
  if rollout.software == reported_software:
120
131
  rollout.success_count += 1
121
132
  await rollout.save()
122
133
  else:
134
+ # edge case where device update mode got changed while update was running
123
135
  logging.warning(
124
136
  f"Updating rollout success stats failed, software={reported_software.id}, device={updater.dev_id}" # noqa: E501
125
137
  )
126
138
 
127
- # setting the currently installed version based on the current assigned software / existing rollouts
128
- # is problematic. Better to assign custom action_id for each update (rollout id? software id? new id?).
129
- # Alternatively - but requires customization on the gateway side - use version reported by the gateway.
130
139
  await updater.update_sw_version(reported_software.version)
131
140
  logger.debug(f"Installation successful, software={reported_software.version}, device={updater.dev_id}")
132
141
 
133
142
  elif data.status.result.finished == FeedbackStatusResultFinished.FAILURE:
134
143
  await updater.update_device_state(UpdateStateEnum.ERROR)
135
144
 
136
- # not guaranteed to be the correct rollout - see comment above.
137
145
  rollout = await updater.get_rollout()
138
146
  if rollout:
139
147
  if rollout.software == reported_software:
140
148
  rollout.failure_count += 1
141
149
  await rollout.save()
142
150
  else:
151
+ # edge case where device update mode got changed while update was running
143
152
  logging.warning(
144
153
  f"Updating rollout failure stats failed, software={reported_software.id}, device={updater.dev_id}" # noqa: E501
145
154
  )
146
155
 
147
156
  logger.debug(f"Installation failed, software={reported_software.version}, device={updater.dev_id}")
148
157
  else:
149
- logging.warning(
158
+ logging.error(
150
159
  f"Device reported unhandled execution state, state={data.status.execution}, device={updater.dev_id}"
151
160
  )
152
161
 
153
162
  try:
154
163
  log = data.status.details
155
- await updater.update_log("\n".join(log))
164
+ if log is not None:
165
+ await updater.update_log("\n".join(log))
156
166
  except AttributeError:
157
167
  logging.warning(f"No details to update device update log, device={updater.dev_id}")
158
168
 
@@ -175,11 +185,11 @@ async def download_artifact(_: Request, updater: UpdateManager = Depends(get_upd
175
185
  _, software = await updater.get_update()
176
186
  if software is None:
177
187
  raise HTTPException(404)
178
- if software.local:
179
- return FileResponse(
180
- software.path,
181
- media_type="application/octet-stream",
182
- filename=software.path.name,
183
- )
184
- else:
185
- return RedirectResponse(url=software.uri)
188
+
189
+ assert software.local, "device requests local software to download"
190
+
191
+ return FileResponse(
192
+ software.path,
193
+ media_type="application/octet-stream",
194
+ filename=software.path.name,
195
+ )
@@ -41,16 +41,16 @@ class FeedbackStatusResultFinished(StrEnum):
41
41
 
42
42
  class FeedbackStatusResultSchema(BaseModel):
43
43
  finished: FeedbackStatusResultFinished
44
- progress: FeedbackStatusProgressSchema = None
44
+ progress: FeedbackStatusProgressSchema | None = None
45
45
 
46
46
 
47
47
  class FeedbackStatusSchema(BaseModel):
48
48
  execution: FeedbackStatusExecutionState
49
49
  result: FeedbackStatusResultSchema
50
- code: int = None
51
- details: list[str] = None
50
+ code: int | None = None
51
+ details: list[str] | None = None
52
52
 
53
53
 
54
54
  class FeedbackSchema(BaseModel):
55
- time: str = None
55
+ time: str | None = None
56
56
  status: FeedbackStatusSchema
@@ -45,7 +45,7 @@ class UpdateManager(ABC):
45
45
  self.dev_id = dev_id
46
46
 
47
47
  async def get_device(self) -> Device | None:
48
- return
48
+ return None
49
49
 
50
50
  async def update_force_update(self, force_update: bool) -> None:
51
51
  return
@@ -59,7 +59,7 @@ class UpdateManager(ABC):
59
59
  async def update_device_state(self, state: UpdateStateEnum) -> None:
60
60
  return
61
61
 
62
- async def update_last_connection(self, last_seen: int, last_ip: str) -> None:
62
+ async def update_last_connection(self, last_seen: int, last_ip: str | None = None) -> None:
63
63
  return
64
64
 
65
65
  async def update_update(self, update_mode: UpdateModeEnum, software: Software | None):
@@ -74,7 +74,10 @@ class UpdateManager(ABC):
74
74
  async def update_config_data(self, **kwargs):
75
75
  return
76
76
 
77
- async def update_log_complete(self, log_complete: bool):
77
+ async def deployment_action_success(self):
78
+ return
79
+
80
+ async def clear_log(self) -> None:
78
81
  return
79
82
 
80
83
  async def get_rollout(self) -> Rollout | None:
@@ -83,15 +86,19 @@ class UpdateManager(ABC):
83
86
  @asynccontextmanager
84
87
  async def subscribe_log(self, callback: Callable):
85
88
  device = await self.get_device()
89
+ # do not modify, breaks when combined
86
90
  subscribers = self.log_subscribers
87
91
  subscribers.append(callback)
88
92
  self.log_subscribers = subscribers
89
- await callback(device.last_log)
93
+
94
+ if device is not None:
95
+ await callback(device.last_log)
90
96
  try:
91
97
  yield
92
98
  except asyncio.CancelledError:
93
99
  pass
94
100
  finally:
101
+ # do not modify, breaks when combined
95
102
  subscribers = self.log_subscribers
96
103
  subscribers.remove(callback)
97
104
  self.log_subscribers = subscribers
@@ -126,28 +133,12 @@ class UpdateManager(ABC):
126
133
  await cb(log_data)
127
134
 
128
135
  @abstractmethod
129
- async def get_update(self) -> tuple[HandlingType, Software]: ...
136
+ async def get_update(self) -> tuple[HandlingType, Software | None]: ...
130
137
 
131
138
  @abstractmethod
132
139
  async def update_log(self, log_data: str) -> None: ...
133
140
 
134
141
 
135
- class UnknownUpdateManager(UpdateManager):
136
- def __init__(self, dev_id: str):
137
- super().__init__(dev_id)
138
- self.poll_time = config.poll_time_updating
139
-
140
- async def _get_software(self) -> Software:
141
- return await Software.latest(await self.get_device())
142
-
143
- async def get_update(self) -> tuple[HandlingType, Software]:
144
- software = await self._get_software()
145
- return HandlingType.FORCED, software
146
-
147
- async def update_log(self, log_data: str) -> None:
148
- return
149
-
150
-
151
142
  class DeviceUpdateManager(UpdateManager):
152
143
  hardware_default = None
153
144
 
@@ -161,9 +152,11 @@ class DeviceUpdateManager(UpdateManager):
161
152
  return (await Device.get_or_create(uuid=self.dev_id, defaults={"hardware": hardware}))[0]
162
153
 
163
154
  async def save_device(self, device: Device, update_fields: list[str]):
155
+ await device.save(update_fields=update_fields)
156
+
157
+ # only update cache after a successful database save
164
158
  result = await caches.get("default").set(self.dev_id, device, ttl=600)
165
159
  assert result, "device being cached"
166
- await device.save(update_fields=update_fields)
167
160
 
168
161
  async def update_force_update(self, force_update: bool) -> None:
169
162
  device = await self.get_device()
@@ -185,10 +178,12 @@ class DeviceUpdateManager(UpdateManager):
185
178
  device.last_state = state
186
179
  await self.save_device(device, update_fields=["last_state"])
187
180
 
188
- async def update_last_connection(self, last_seen: int, last_ip: str) -> None:
181
+ async def update_last_connection(self, last_seen: int, last_ip: str | None = None) -> None:
189
182
  device = await self.get_device()
190
183
  device.last_seen = last_seen
191
- if ":" in last_ip:
184
+ if last_ip is None:
185
+ await self.save_device(device, update_fields=["last_seen"])
186
+ elif ":" in last_ip:
192
187
  device.last_ipv6 = last_ip
193
188
  await self.save_device(device, update_fields=["last_seen", "last_ipv6"])
194
189
  else:
@@ -235,10 +230,10 @@ class DeviceUpdateManager(UpdateManager):
235
230
  if modified:
236
231
  await self.save_device(device, update_fields=["hardware_id", "last_state", "sw_version"])
237
232
 
238
- async def update_log_complete(self, log_complete: bool):
233
+ async def deployment_action_success(self):
239
234
  device = await self.get_device()
240
- device.log_complete = log_complete
241
- await self.save_device(device, update_fields=["log_complete"])
235
+ device.progress = 100
236
+ await self.save_device(device, update_fields=["progress"])
242
237
 
243
238
  async def get_rollout(self) -> Rollout | None:
244
239
  device = await self.get_device()
@@ -276,29 +271,21 @@ class DeviceUpdateManager(UpdateManager):
276
271
  assert device.update_mode == UpdateModeEnum.PINNED
277
272
  return None
278
273
 
279
- async def get_update(self) -> tuple[HandlingType, Software]:
274
+ async def get_update(self) -> tuple[HandlingType, Software | None]:
280
275
  device = await self.get_device()
281
276
  software = await self._get_software()
282
277
 
283
278
  if software is None:
284
279
  handling_type = HandlingType.SKIP
285
- self.poll_time = config.poll_time_default
286
280
 
287
281
  elif software.version == device.sw_version and not device.force_update:
288
282
  handling_type = HandlingType.SKIP
289
- self.poll_time = config.poll_time_default
290
283
 
291
284
  elif device.last_state == UpdateStateEnum.ERROR and not device.force_update:
292
285
  handling_type = HandlingType.SKIP
293
- self.poll_time = config.poll_time_default
294
286
 
295
287
  else:
296
288
  handling_type = HandlingType.FORCED
297
- self.poll_time = config.poll_time_updating
298
-
299
- if device.log_complete:
300
- await self.update_log_complete(False)
301
- await self.clear_log()
302
289
 
303
290
  return handling_type, software
304
291
 
@@ -310,23 +297,15 @@ class DeviceUpdateManager(UpdateManager):
310
297
  if device.last_log is None:
311
298
  device.last_log = ""
312
299
 
300
+ # SWUpdate-specific log parsing to report progress
313
301
  matches = re.findall(r"Downloaded (\d+)%", log_data)
314
302
  if matches:
315
303
  device.progress = matches[-1]
316
304
 
317
- if log_data.startswith("Installing Update Chunk Artifacts."):
318
- # clear log
319
- device.last_log = ""
320
- await self.publish_log(None)
321
-
322
- if not log_data == "Skipped Update.":
323
- device.last_log += f"{log_data}\n"
324
- await self.publish_log(f"{log_data}\n")
305
+ device.last_log += f"{log_data}\n"
306
+ await self.publish_log(f"{log_data}\n")
325
307
 
326
- await self.save_device(
327
- device,
328
- update_fields=["progress", "last_log"],
329
- )
308
+ await self.save_device(device, update_fields=["progress", "last_log"])
330
309
 
331
310
  async def clear_log(self) -> None:
332
311
  device = await self.get_device()
@@ -336,10 +315,7 @@ class DeviceUpdateManager(UpdateManager):
336
315
 
337
316
 
338
317
  async def get_update_manager(dev_id: str) -> UpdateManager:
339
- if dev_id == "unknown":
340
- return UnknownUpdateManager("unknown")
341
- else:
342
- return DeviceUpdateManager(dev_id)
318
+ return DeviceUpdateManager(dev_id)
343
319
 
344
320
 
345
321
  async def delete_devices(ids: list[str]):