from datetime import datetime from django.conf import settings from django.db import models from django.db.models import F, Func, Q from django.db.models.functions import Mod from django.utils import timezone from .hid.DoorController import DoorController class Door(models.Model): name = models.CharField(max_length=64, unique=True) ip = models.GenericIPAddressField(protocol="IPv4") @property def controller(self) -> DoorController: return DoorController( self.ip, settings.HID_DOOR_USERNAME, settings.HID_DOOR_PASSWORD, ) def __str__(self): return self.name 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), ) ) class HIDEvent(models.Model): objects = HIDEventQuerySet.as_manager() class EventType(models.IntegerChoices): DENIED_ACCESS_CARD_NOT_FOUND = 1022, "Denied Access: Card Not Found" DENIED_ACCESS_ACCESS_PIN_NOT_FOUND = 1023, "Denied Access Access: PIN Not Found" GRANTED_ACCESS = 2020, "Granted Access" GRANTED_ACCESS_EXTENDED_TIME = 2021, "Granted Access: Extended Time" DENIED_ACCESS_SCHEDULE = 2024, "Denied Access: Schedule" DENIED_ACCESS_WRONG_PIN = 2029, "Denied Access: Wrong PIN" DENIED_ACCESS_CARD_EXPIRED = 2036, "Denied Access: Card Expired" DENIED_ACCESS_PIN_LOCKOUT = 2042, "Denied Access: PIN Lockout" DENIED_ACCESS_UNASSIGNED_CARD = 2043, "Denied Access: Unassigned Card" DENIED_ACCESS_UNASSIGNED_ACCESS_PIN = ( 2044, "Denied Access: Unassigned Access PIN", ) DENIED_ACCESS_PIN_EXPIRED = 2046, "Denied Access: PIN Expired" ALARM_ACKNOWLEDGED = 4034, "Alarm Acknowledged" DOOR_LOCKED_SCHEDULED = 4035, "Door Locked: Scheduled" DOOR_UNLOCKED_SCHEDULED = 4036, "Door Unlocked: Scheduled" DOOR_FORCED_ALARM = 4041, "Door Forced Alarm" DOOR_HELD_ALARM = 4042, "Door Held Alarm" TAMPER_SWITCH_ALARM = 4043, "Tamper Switch Alarm" AC_FAILURE = 4044, "AC Failure" BATTERY_FAILURE = 4045, "Battery Failure" REX_SWITCH_ALARM = 4051, "REX Switch Alarm" TIME_SET_TO = 7020, "Time Set To" GRANTED_ACCESS_MANUAL = 12031, "Granted Access: Manual" DOOR_UNLOCKED = 12032, "Door Unlocked" DOOR_LOCKED = 12033, "Door Locked" door = models.ForeignKey(Door, on_delete=models.CASCADE) timestamp = models.DateTimeField() event_type = models.IntegerField(db_column="eventType", choices=EventType.choices) reader_address = models.IntegerField(db_column="readerAddress") cardholder_id = models.IntegerField(blank=True, null=True, db_column="cardholderID") command_status = models.BooleanField( blank=True, null=True, db_column="commandStatus" ) forename = models.TextField(blank=True, null=True) surname = models.TextField(blank=True, null=True) io_state = models.BooleanField(blank=True, null=True, db_column="ioState") new_time = models.DateTimeField(blank=True, null=True, db_column="newTime") old_time = models.DateTimeField(blank=True, null=True, db_column="oldTime") raw_card_number = models.CharField( max_length=8, blank=True, null=True, db_column="rawCardNumber" ) # Based on `function isRedEvent` from /html/hid-global.js on a HID EDGE EVO Solo is_red = models.GeneratedField( expression=Q( event_type__in=[ EventType.DENIED_ACCESS_CARD_NOT_FOUND, EventType.DENIED_ACCESS_ACCESS_PIN_NOT_FOUND, EventType.DENIED_ACCESS_SCHEDULE, EventType.DENIED_ACCESS_WRONG_PIN, EventType.DENIED_ACCESS_CARD_EXPIRED, EventType.DENIED_ACCESS_PIN_LOCKOUT, EventType.DENIED_ACCESS_UNASSIGNED_CARD, EventType.DENIED_ACCESS_PIN_EXPIRED, EventType.DOOR_FORCED_ALARM, EventType.DOOR_HELD_ALARM, EventType.TAMPER_SWITCH_ALARM, EventType.AC_FAILURE, EventType.BATTERY_FAILURE, ] ), output_field=models.BooleanField(), db_persist=False, ) @classmethod def from_xml_attributes(cls, door: Door, attrib: dict[str:str]): field_lookup = { field.column: field.attname for field in HIDEvent._meta.get_fields() } def attr_to_bool(str): if str is None: return None else: return str == "true" return cls( **{ **{field_lookup[k]: v for k, v in attrib.items()}, "door": door, # fixups for specific attributes "timestamp": timezone.make_aware( datetime.fromisoformat(attrib["timestamp"]) ), "io_state": attr_to_bool(attrib.get("ioState")), "command_status": attr_to_bool(attrib.get("commandStatus")), } ) @property def description(self): """ Based on `Global.localeStrings` from /html/en_EN/en_EN.js and `function eventDataHandler` from /html/modules/hid-dashboard.js on a HID EDGE EVO Solo """ name = f"{self.forename} {self.surname}" direction = "IN" if self.reader_address == 0 else "OUT" event_types = { 1022: f"Denied Access, {direction} Card Not Found {self.raw_card_number}", 1023: f"Denied Access, {direction} Access PIN Not Found {self.raw_card_number}", 2020: f"Granted Access, {direction} {name}", 2021: f"Granted Access, {direction} Extended Time {name}", 2024: f"Denied Access, {direction} Schedule {name}", 2029: f"Denied Access, {direction} Wrong PIN {name}", 2036: f"Denied Access, {direction} Card Expired {name}", 2042: f"Denied Access, {direction} PIN Lockout {name}", 2043: f"Denied Access, {direction} Unassigned Card {self.raw_card_number}", 2044: f"Denied Access, {direction} Unassigned Access PIN {self.raw_card_number}", 2046: f"Denied Access - PIN Expired {name}", 4034: "Alarm Acknowledged", 4035: "Door Locked-Scheduled", 4036: "Door Unlocked-Scheduled", 4041: "Door Forced Alarm", 4042: "Door Held Alarm", 4043: "Tamper Switch Alarm", 4044: "AC Failure", 4045: "Battery Failure", 4051: "REX Switch Alarm", 7020: f"Time Set to: {self.new_time}", 12031: f"Granted Access, {direction} Manual", 12032: "Door Unlocked", 12033: "Door Locked", } return event_types.get(self.event_type, f"Unknown Event Type {self.event_type}") def __str__(self): return f"{self.door.name} {self.timestamp} - {self.description}" def decoded_card_number(self) -> str: """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" class Meta: constraints = [ models.UniqueConstraint( fields=["door", "timestamp", "event_type"], name="unique_hidevent" ) ] db_table = "hidevent" ordering = ("-timestamp",)