From cbe684d918bf8c3846fe855dca2161ca95e5cf95 Mon Sep 17 00:00:00 2001 From: Adam Goldsmith Date: Mon, 26 Aug 2024 23:23:53 -0400 Subject: [PATCH] doorcontrol: Move HID card number decoding out of database query Not really needed, and hard to make portable --- doorcontrol/admin.py | 3 -- doorcontrol/hid/Credential.py | 73 ++++++++++++++++++++----------- doorcontrol/models.py | 53 +++------------------- doorcontrol/tables.py | 1 + doorcontrol/tasks/update_doors.py | 20 ++++----- doorcontrol/views.py | 7 +-- 6 files changed, 66 insertions(+), 91 deletions(-) diff --git a/doorcontrol/admin.py b/doorcontrol/admin.py index 95ed482..39cba0a 100644 --- a/doorcontrol/admin.py +++ b/doorcontrol/admin.py @@ -44,9 +44,6 @@ class HIDEventAdmin(DjangoObjectActions, admin.ModelAdmin): readonly_fields = ["decoded_card_number"] changelist_actions = ("refresh_all_doors",) - def get_queryset(self, request): - return super().get_queryset(request).with_decoded_card_number() - @admin.display(boolean=True) def _is_red(self, obj): return obj.is_red diff --git a/doorcontrol/hid/Credential.py b/doorcontrol/hid/Credential.py index 25ab94c..101cd12 100644 --- a/doorcontrol/hid/Credential.py +++ b/doorcontrol/hid/Credential.py @@ -1,41 +1,62 @@ +import dataclasses +from typing import Literal + import bitstring # Reference for H10301 card format: # https://www.hidglobal.com/sites/default/files/hid-understanding_card_data_formats-wp-en.pdf +class InvalidHexCode(Exception): + pass + + +class Not26Bit(InvalidHexCode): + def __init__(self) -> None: + super().__init__("Card number > 26 bits") + + +class InvalidParity(InvalidHexCode): + def __init__(self, even_odd: Literal["even", "odd"]) -> None: + super().__init__(f"Bad {even_odd} parity") + + +@dataclasses.dataclass class Credential: - def __init__(self, code=None, hex_code=None): - if code is None and hex_code is None: - raise TypeError("Must set either code or hex for a Credential") - elif code is not None and hex_code is not None: - raise TypeError("Cannot set both code and hex for a Credential") - elif code is not None: - self.bits = bitstring.pack( - "0b000000, 0b0, uint:8=facility, uint:16=number, 0b0", - facility=code[0], - number=code[1], - ) - self.bits[6] = self.bits[7:19].count(1) % 2 # even parity - self.bits[31] = not (self.bits[19:31].count(1) % 2) # odd parity - elif hex_code is not None: - self.bits = bitstring.Bits(hex=hex_code) + bits: bitstring.Bits - def __repr__(self): - return f"Credential({self.code})" + @classmethod + def from_code(cls, facility=int, card_number=int) -> "Credential": + bits = bitstring.pack( + "0b000000, 0b0, uint:8=facility, uint:16=card_number, 0b0", + facility=facility, + card_number=card_number, + ) + bits[6] = bits[7:19].count(1) % 2 # even parity + bits[31] = bits[19:31].count(0) % 2 # odd parity + return cls(bits) - def __eq__(self, other): - return self.bits == other.bits + @classmethod + def from_hex(cls, hex_code: str) -> "Credential": + bits = bitstring.Bits(hex=hex_code) - def __hash__(self): - return self.bits.int + if bits[:6].any(1): + raise Not26Bit + if bits[6] != bits[7:19].count(1) % 2: + raise InvalidParity("even") + if bits[31] != (bits[19:31].count(0) % 2): + raise InvalidParity("odd") + + return cls(bits) @property - def code(self): - facility = self.bits[7:15].uint - code = self.bits[15:31].uint - return (facility, code) + def facility_code(self) -> int: + return self.bits[7:15].uint @property - def hex(self): + def card_number(self) -> int: + return self.bits[15:31].uint + + @property + def hex(self) -> str: return self.bits.hex.upper() diff --git a/doorcontrol/models.py b/doorcontrol/models.py index 41fe89e..3309991 100644 --- a/doorcontrol/models.py +++ b/doorcontrol/models.py @@ -3,14 +3,14 @@ from typing import Self from django.conf import settings from django.db import models -from django.db.models import F, Func, OuterRef, Q, Subquery -from django.db.models.functions import Mod +from django.db.models import OuterRef, Q, Subquery from django.utils import timezone from django.utils.functional import cached_property from membershipworks.models import Flag as MembershipWorksFlag from membershipworks.models import Member +from .hid.Credential import Credential, InvalidHexCode from .hid.DoorController import DoorController @@ -102,42 +102,6 @@ class AttributeScheduleRule(AbstractScheduleRule): class HIDEventQuerySet(models.QuerySet): - def with_decoded_card_number(self): - # TODO: CONV and BIT_COUNT are MySQL/MariaDB specific - class Conv(Func): - function = "CONV" - arity = 3 - # This is technically not true, but fine for my purposes - output_field = models.IntegerField() - - class BitCount(Func): - function = "BIT_COUNT" - arity = 1 - - return ( - self.alias(card_number=Conv(F("raw_card_number"), 16, 10)) - .alias(more_than_26_bits=F("card_number").bitrightshift(26)) - .annotate(card_is_26_bit=Q(more_than_26_bits=0)) - .alias( - parity_a=Mod( - BitCount(F("card_number").bitrightshift(1).bitand(0xFFF)), 2 - ), - parity_b=Mod( - BitCount(F("card_number").bitrightshift(13).bitand(0xFFF)), 2 - ), - ) - .annotate( - card_is_valid_26_bit=~Q(parity_a=F("card_number").bitand(1)) - & Q(parity_b=F("card_number").bitrightshift(25).bitand(1)) - ) - .annotate( - card_number_26_bit=F("card_number").bitrightshift(1).bitand(0xFFFF), - card_facility_code_26_bit=F("card_number") - .bitrightshift(17) - .bitand(0xFF), - ) - ) - def with_member_id(self): return self.annotate( member_id=Subquery( @@ -302,13 +266,10 @@ class HIDEvent(models.Model): return event_types.get(self.event_type, f"Unknown Event Type {self.event_type}") def decoded_card_number(self) -> str | None: - """Requires annotations from `with_decoded_card_number`""" if self.raw_card_number is None: return None - elif self.card_is_26_bit: - if self.card_is_valid_26_bit: - return f"{self.card_facility_code_26_bit} - {self.card_number_26_bit}" - else: - return "Invalid" - else: - return "Not 26 bit card" + try: + cred = Credential.from_hex(self.raw_card_number) + return f"{cred.facility_code} - {cred.card_number}" + except InvalidHexCode as e: + return f"Invalid: {e}" diff --git a/doorcontrol/tables.py b/doorcontrol/tables.py index 404d373..3bfb6d2 100644 --- a/doorcontrol/tables.py +++ b/doorcontrol/tables.py @@ -20,6 +20,7 @@ class UnitTimeTable(tables.Table): class DeniedAccessTable(tables.Table): + decoded_card_number = tables.Column(orderable=False) name = tables.TemplateColumn( "{{ record.forename|default:'' }} {{ record.surname|default:'' }}" ) diff --git a/doorcontrol/tasks/update_doors.py b/doorcontrol/tasks/update_doors.py index 3c155d0..f3952ff 100644 --- a/doorcontrol/tasks/update_doors.py +++ b/doorcontrol/tasks/update_doors.py @@ -38,11 +38,9 @@ class DoorMember: def from_membershipworks_member(cls, member: Member, door: Door): if member.access_card_facility_code and member.access_card_number: credentials = { - Credential( - code=( - member.access_card_facility_code, - member.access_card_number, - ) + Credential.from_code( + member.access_card_facility_code, + member.access_card_number, ) } else: @@ -108,7 +106,7 @@ class DoorMember: }, cardholderID=data.attrib["cardholderID"], credentials={ - Credential(hex_code=(c.attrib["rawCardNumber"])) + Credential.from_hex(c.attrib["rawCardNumber"]) for c in data.findall("{*}Credential") }, schedules={r.attrib["scheduleName"] for r in data.findall("{*}Role")}, @@ -172,9 +170,11 @@ class DoorMember: xml_credentials = [ E.Credential( { - "formatName": str(credential.code[0]), - "cardNumber": str(credential.code[1]), - "formatID": self.door.card_formats[str(credential.code[0])], + "formatName": str(credential.facility_code), + "cardNumber": str(credential.card_number), + "formatID": self.door.card_formats[ + str(credential.facility_code) + ], "isCard": "true", "cardholderID": self.cardholderID, } @@ -222,7 +222,7 @@ def update_door(door: Door, dry_run: bool = False): } existing_door_credentials = { - Credential(hex_code=c.attrib["rawCardNumber"]) + Credential.from_hex(c.attrib["rawCardNumber"]) for c in door.controller.get_credentials() } diff --git a/doorcontrol/views.py b/doorcontrol/views.py index b94bcad..a363388 100644 --- a/doorcontrol/views.py +++ b/doorcontrol/views.py @@ -206,12 +206,7 @@ class DeniedAccess(BaseAccessReport): denied_event_types = [ t for t in HIDEvent.EventType if t.name.startswith("DENIED_ACCESS") ] - return ( - super() - .get_table_data() - .filter(event_type__in=denied_event_types) - .with_decoded_card_number() - ) + return super().get_table_data().filter(event_type__in=denied_event_types) @register_report