machine_access_control 0.2.3__tar.gz → 0.3.0__tar.gz

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 (17) hide show
  1. {machine_access_control-0.2.3 → machine_access_control-0.3.0}/PKG-INFO +8 -6
  2. {machine_access_control-0.2.3 → machine_access_control-0.3.0}/pyproject.toml +7 -7
  3. {machine_access_control-0.2.3 → machine_access_control-0.3.0}/src/dm_mac/models/machine.py +109 -15
  4. {machine_access_control-0.2.3 → machine_access_control-0.3.0}/LICENSE +0 -0
  5. {machine_access_control-0.2.3 → machine_access_control-0.3.0}/README.rst +0 -0
  6. {machine_access_control-0.2.3 → machine_access_control-0.3.0}/src/dm_mac/__init__.py +0 -0
  7. {machine_access_control-0.2.3 → machine_access_control-0.3.0}/src/dm_mac/cli_utils.py +0 -0
  8. {machine_access_control-0.2.3 → machine_access_control-0.3.0}/src/dm_mac/models/__init__.py +0 -0
  9. {machine_access_control-0.2.3 → machine_access_control-0.3.0}/src/dm_mac/models/users.py +0 -0
  10. {machine_access_control-0.2.3 → machine_access_control-0.3.0}/src/dm_mac/neongetter.py +0 -0
  11. {machine_access_control-0.2.3 → machine_access_control-0.3.0}/src/dm_mac/py.typed +0 -0
  12. {machine_access_control-0.2.3 → machine_access_control-0.3.0}/src/dm_mac/slack_handler.py +0 -0
  13. {machine_access_control-0.2.3 → machine_access_control-0.3.0}/src/dm_mac/utils.py +0 -0
  14. {machine_access_control-0.2.3 → machine_access_control-0.3.0}/src/dm_mac/views/__init__.py +0 -0
  15. {machine_access_control-0.2.3 → machine_access_control-0.3.0}/src/dm_mac/views/api.py +0 -0
  16. {machine_access_control-0.2.3 → machine_access_control-0.3.0}/src/dm_mac/views/machine.py +0 -0
  17. {machine_access_control-0.2.3 → machine_access_control-0.3.0}/src/dm_mac/views/prometheus.py +0 -0
@@ -1,9 +1,9 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: machine_access_control
3
- Version: 0.2.3
3
+ Version: 0.3.0
4
4
  Summary: Decatur Makers Machine Access Control package
5
- Home-page: https://github.com/jantman/machine_access_control
6
5
  License: MIT
6
+ License-File: LICENSE
7
7
  Author: Jason Antman
8
8
  Author-email: jason@jasonantman.com
9
9
  Requires-Python: >=3.12,<4.0
@@ -12,18 +12,20 @@ Classifier: License :: OSI Approved :: MIT License
12
12
  Classifier: Programming Language :: Python :: 3
13
13
  Classifier: Programming Language :: Python :: 3.12
14
14
  Classifier: Programming Language :: Python :: 3.13
15
- Requires-Dist: aiohttp (>=3.11.4,<4.0.0)
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Requires-Dist: aiohttp (>=3.12.14,<4.0.0)
16
17
  Requires-Dist: asyncio (>=3.4.3,<4.0.0)
17
- Requires-Dist: filelock (>=3.15.4,<4.0.0)
18
+ Requires-Dist: filelock (>=3.16.1,<4.0.0)
18
19
  Requires-Dist: humanize (>=4.11.0,<5.0.0)
19
20
  Requires-Dist: jsonschema (>=4.23.0,<5.0.0)
20
21
  Requires-Dist: prometheus-client (>=0.20.0,<0.21.0)
21
22
  Requires-Dist: quart (>=0.19.8,<0.20.0)
22
- Requires-Dist: requests (>=2.32.3,<3.0.0)
23
+ Requires-Dist: requests (>=2.32.4,<3.0.0)
23
24
  Requires-Dist: slack-bolt (>=1.21.3,<2.0.0)
24
25
  Requires-Dist: slack-sdk (>=3.33.5,<4.0.0)
25
26
  Project-URL: Changelog, https://github.com/jantman/machine_access_control/releases
26
27
  Project-URL: Documentation, https://github.com/jantman/machine_access_control
28
+ Project-URL: Homepage, https://github.com/jantman/machine_access_control
27
29
  Project-URL: Repository, https://github.com/jantman/machine_access_control
28
30
  Description-Content-Type: text/x-rst
29
31
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "machine_access_control"
3
- version = "0.2.3"
3
+ version = "0.3.0"
4
4
  description = "Decatur Makers Machine Access Control package"
5
5
  authors = ["Jason Antman <jason@jasonantman.com>"]
6
6
  license = "MIT"
@@ -25,13 +25,13 @@ Changelog = "https://github.com/jantman/machine_access_control/releases"
25
25
  [tool.poetry.dependencies]
26
26
  python = "^3.12"
27
27
  jsonschema = "^4.23.0"
28
- requests = "^2.32.3"
29
- filelock = "^3.15.4"
28
+ requests = "^2.32.4"
29
+ filelock = "^3.16.1"
30
30
  prometheus-client = "^0.20.0"
31
31
  quart = "^0.19.8"
32
32
  asyncio = "^3.4.3"
33
33
  slack-bolt = "^1.21.3"
34
- aiohttp = "^3.11.4"
34
+ aiohttp = "^3.12.14"
35
35
  slack-sdk = "^3.33.5"
36
36
  humanize = "^4.11.0"
37
37
 
@@ -40,7 +40,7 @@ Pygments = ">=2.10.0"
40
40
  black = ">=21.10b0"
41
41
  coverage = {extras = ["toml"], version = ">=6.2"}
42
42
  darglint = ">=1.8.1"
43
- flake8 = ">=4.0.1"
43
+ flake8 = ">=7.1.1"
44
44
  flake8-bandit = ">=2.1.2"
45
45
  flake8-bugbear = ">=21.9.2"
46
46
  flake8-docstrings = ">=1.6.0"
@@ -49,7 +49,7 @@ furo = ">=2021.11.12"
49
49
  isort = ">=5.10.1"
50
50
  mypy = ">=0.930"
51
51
  nox = ">=2024.4.15"
52
- nox-poetry = ">= 1.0.3"
52
+ nox-poetry = ">= 1.2.0"
53
53
  pep8-naming = ">=0.12.1"
54
54
  pre-commit = ">=2.16.0"
55
55
  pre-commit-hooks = ">=4.1.0"
@@ -65,7 +65,7 @@ responses = "^0.25.3"
65
65
  types-requests = "^2.32.0.20240712"
66
66
  faker = "^26.3.0"
67
67
  pytest-html = "^4.1.1"
68
- poetry-plugin-export = "^1.8.0"
68
+ poetry-plugin-export = "^1.9.0"
69
69
  freezegun = "^1.5.1"
70
70
 
71
71
  [tool.isort]
@@ -53,6 +53,13 @@ CONFIG_SCHEMA: Dict[str, Any] = {
53
53
  "but log and display a warning if the "
54
54
  "operator is not authorized.",
55
55
  },
56
+ "always_enabled": {
57
+ "type": "boolean",
58
+ "description": "If set, machine is always enabled and "
59
+ "does not require RFID authentication. "
60
+ "Displays 'Always On' and relay is always "
61
+ "on unless Oopsed or Locked.",
62
+ },
56
63
  },
57
64
  "additionalProperties": False,
58
65
  "description": "Unique machine name, alphanumeric _ and - only.",
@@ -69,6 +76,7 @@ class Machine:
69
76
  name: str,
70
77
  authorizations_or: List[str],
71
78
  unauthorized_warn_only: bool = False,
79
+ always_enabled: bool = False,
72
80
  ):
73
81
  """Initialize a new MachineState instance."""
74
82
  #: The name of the machine
@@ -78,6 +86,8 @@ class Machine:
78
86
  #: Whether to allow anyone to operate machine regardless of
79
87
  #: authorization, just logging/displaying a warning if unauthorized
80
88
  self.unauthorized_warn_only: bool = unauthorized_warn_only
89
+ #: Whether machine is always enabled without RFID authentication
90
+ self.always_enabled: bool = always_enabled
81
91
  #: state of the machine
82
92
  self.state: "MachineState" = MachineState(self)
83
93
 
@@ -142,6 +152,7 @@ class Machine:
142
152
  "name": self.name,
143
153
  "authorizations_or": self.authorizations_or,
144
154
  "unauthorized_warn_only": self.unauthorized_warn_only,
155
+ "always_enabled": self.always_enabled,
145
156
  }
146
157
 
147
158
 
@@ -187,6 +198,8 @@ class MachineState:
187
198
 
188
199
  LOCKOUT_DISPLAY_TEXT: str = "Down for\nmaintenance"
189
200
 
201
+ ALWAYS_ON_DISPLAY_TEXT: str = "Always On"
202
+
190
203
  STATUS_LED_BRIGHTNESS: float = 0.5
191
204
 
192
205
  def __init__(self, machine: Machine, load_state: bool = True):
@@ -290,18 +303,25 @@ class MachineState:
290
303
  async def _handle_reboot(self) -> None:
291
304
  """Handle when the ESP32 (MCU) has rebooted since last checkin.
292
305
 
293
- This logs out the current user if logged in and turns off the relay if
294
- turned on.
306
+ This logs out the current user if logged in and resets the machine state.
307
+ For always-enabled machines, restores the always-on state.
295
308
  """
296
309
  logging.getLogger("AUTH").warning(
297
310
  "Machine %s rebooted; resetting relay and RFID state", self.machine.name
298
311
  )
299
312
  # locking handled in update()
300
- self.relay_desired_state = False
301
313
  self.current_user = None
302
- self.display_text = self.DEFAULT_DISPLAY_TEXT
303
- self.status_led_rgb = (0.0, 0.0, 0.0)
304
- self.status_led_brightness = 0.0
314
+ # Restore always-enabled state if applicable
315
+ if self.machine.always_enabled:
316
+ self.relay_desired_state = True
317
+ self.display_text = self.ALWAYS_ON_DISPLAY_TEXT
318
+ self.status_led_rgb = (0.0, 1.0, 0.0)
319
+ self.status_led_brightness = self.STATUS_LED_BRIGHTNESS
320
+ else:
321
+ self.relay_desired_state = False
322
+ self.display_text = self.DEFAULT_DISPLAY_TEXT
323
+ self.status_led_rgb = (0.0, 0.0, 0.0)
324
+ self.status_led_brightness = 0.0
305
325
  # log to Slack, if enabled
306
326
  slack: Optional["SlackHandler"] = current_app.config.get("SLACK_HANDLER")
307
327
  if not slack:
@@ -329,11 +349,18 @@ class MachineState:
329
349
  )
330
350
  with self._lock:
331
351
  self.is_locked_out = False
332
- self.relay_desired_state = False
333
352
  self.current_user = None
334
- self.display_text = self.DEFAULT_DISPLAY_TEXT
335
- self.status_led_rgb = (0.0, 0.0, 0.0)
336
- self.status_led_brightness = 0.0
353
+ # Restore always-enabled state if applicable
354
+ if self.machine.always_enabled:
355
+ self.relay_desired_state = True
356
+ self.display_text = self.ALWAYS_ON_DISPLAY_TEXT
357
+ self.status_led_rgb = (0.0, 1.0, 0.0)
358
+ self.status_led_brightness = self.STATUS_LED_BRIGHTNESS
359
+ else:
360
+ self.relay_desired_state = False
361
+ self.display_text = self.DEFAULT_DISPLAY_TEXT
362
+ self.status_led_rgb = (0.0, 0.0, 0.0)
363
+ self.status_led_brightness = 0.0
337
364
 
338
365
  def oops(self, do_locking: bool = True) -> None:
339
366
  """Oops the machine."""
@@ -355,11 +382,18 @@ class MachineState:
355
382
  locker = self._lock if do_locking else nullcontext()
356
383
  with locker:
357
384
  self.is_oopsed = False
358
- self.relay_desired_state = False
359
385
  self.current_user = None
360
- self.display_text = self.DEFAULT_DISPLAY_TEXT
361
- self.status_led_rgb = (0.0, 0.0, 0.0)
362
- self.status_led_brightness = 0
386
+ # Restore always-enabled state if applicable
387
+ if self.machine.always_enabled:
388
+ self.relay_desired_state = True
389
+ self.display_text = self.ALWAYS_ON_DISPLAY_TEXT
390
+ self.status_led_rgb = (0.0, 1.0, 0.0)
391
+ self.status_led_brightness = self.STATUS_LED_BRIGHTNESS
392
+ else:
393
+ self.relay_desired_state = False
394
+ self.display_text = self.DEFAULT_DISPLAY_TEXT
395
+ self.status_led_rgb = (0.0, 0.0, 0.0)
396
+ self.status_led_brightness = 0
363
397
 
364
398
  async def update(
365
399
  self,
@@ -398,7 +432,21 @@ class MachineState:
398
432
  if oops:
399
433
  await self._handle_oops(users)
400
434
  self.last_update = time()
401
- if rfid_value != self.rfid_value:
435
+ # Handle always-enabled machines - track RFID but maintain always-on state
436
+ if (
437
+ self.machine.always_enabled
438
+ and not self.is_oopsed
439
+ and not self.is_locked_out
440
+ ):
441
+ self.relay_desired_state = True
442
+ self.display_text = self.ALWAYS_ON_DISPLAY_TEXT
443
+ self.status_led_rgb = (0.0, 1.0, 0.0)
444
+ self.status_led_brightness = self.STATUS_LED_BRIGHTNESS
445
+ self.last_update = time()
446
+ # Track RFID changes for logging/auditing purposes
447
+ if rfid_value != self.rfid_value:
448
+ await self._handle_rfid_tracking_always_enabled(users, rfid_value)
449
+ elif rfid_value != self.rfid_value:
402
450
  if rfid_value is None:
403
451
  await self._handle_rfid_remove()
404
452
  else:
@@ -553,6 +601,52 @@ class MachineState:
553
601
  f"UNAUTHORIZED user {user.full_name}"
554
602
  )
555
603
 
604
+ async def _handle_rfid_tracking_always_enabled(
605
+ self, users: UsersConfig, rfid_value: Optional[str]
606
+ ) -> None:
607
+ """Track RFID changes for always-enabled machines without changing state.
608
+
609
+ This method logs RFID insertions and removals for auditing purposes while
610
+ maintaining the always-on state of the machine.
611
+ """
612
+ # locking handled in update()
613
+ if rfid_value is None:
614
+ # RFID removed
615
+ logging.getLogger("AUTH").info(
616
+ "RFID removed on always-enabled machine %s (was %s); session duration %d seconds",
617
+ self.machine.name,
618
+ self.current_user.full_name if self.current_user else self.rfid_value,
619
+ (
620
+ time() - cast(float, self.rfid_present_since)
621
+ if self.rfid_present_since
622
+ else 0
623
+ ),
624
+ )
625
+ self.rfid_value = None
626
+ self.rfid_present_since = None
627
+ self.current_user = None
628
+ # State remains always-on (relay/display/LED not changed)
629
+ else:
630
+ # RFID inserted
631
+ self.rfid_present_since = time()
632
+ self.rfid_value = rfid_value
633
+ user: Optional[User] = users.users_by_fob.get(rfid_value)
634
+ if user:
635
+ self.current_user = user
636
+ logging.getLogger("AUTH").info(
637
+ "RFID inserted on always-enabled machine %s by %s (%s)",
638
+ self.machine.name,
639
+ user.full_name,
640
+ rfid_value,
641
+ )
642
+ else:
643
+ logging.getLogger("AUTH").warning(
644
+ "RFID inserted on always-enabled machine %s by unknown fob %s",
645
+ self.machine.name,
646
+ rfid_value,
647
+ )
648
+ # State remains always-on (relay/display/LED not changed)
649
+
556
650
  async def _user_is_authorized(
557
651
  self, user: User, slack: Optional["SlackHandler"] = None
558
652
  ) -> bool: