Skip to content

Commit 79a9c16

Browse files
committed
feat(integriti_visitor_access): initial commit
1 parent a5512cc commit 79a9c16

File tree

2 files changed

+247
-0
lines changed

2 files changed

+247
-0
lines changed
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
require "placeos-driver"
2+
require "placeos-driver/interface/mailer"
3+
require "placeos-driver/interface/mailer_templates"
4+
5+
# other data
6+
require "../wiegand/models"
7+
require "../place/visitor_models"
8+
9+
class InnerRange::IntegritiBookingCheckin < PlaceOS::Driver
10+
include PlaceOS::Driver::Interface::MailerTemplates
11+
12+
descriptive_name "Integriti Visitor Access"
13+
generic_name :VisitorAccess
14+
15+
default_settings({
16+
timezone: "GMT",
17+
date_time_format: "%c",
18+
time_format: "%l:%M%p",
19+
date_format: "%A, %-d %B",
20+
visitor_access_template: "visitor_access",
21+
determine_host_name_using: "calendar-driver",
22+
})
23+
24+
@time_zone : Time::Location = Time::Location.load("GMT")
25+
26+
# See: https://crystal-lang.org/api/0.35.1/Time/Format.html
27+
@date_time_format : String = "%c"
28+
@time_format : String = "%l:%M%p"
29+
@date_format : String = "%A, %-d %B"
30+
31+
@visitor_access_template : String = "visitor_access"
32+
@determine_host_name_using : String = "calendar-driver"
33+
34+
@users_granted_access : UInt64 = 0_u64
35+
36+
def on_load
37+
# Guest has arrived in the lobby
38+
monitor("staff/guest/checkin") { |_subscription, payload| guest_checked_in(payload.gsub(/[^[:print:]]/, "")) }
39+
on_update
40+
end
41+
42+
def on_update
43+
@date_time_format = setting?(String, :date_time_format) || "%c"
44+
@time_format = setting?(String, :time_format) || "%l:%M%p"
45+
@date_format = setting?(String, :date_format) || "%A, %-d %B"
46+
@visitor_access_template = setting?(String, :visitor_access_template) || "visitor_access"
47+
@determine_host_name_using = setting?(String, :determine_host_name_using) || "calendar-driver"
48+
49+
time_zone = setting?(String, :timezone).presence || config.control_system.try(&.timezone) || "GMT"
50+
@time_zone = Time::Location.load(time_zone)
51+
52+
@control_system_zone_list = nil
53+
@building_id = nil
54+
@building_zone = nil
55+
end
56+
57+
accessor locations : LocationServices_1
58+
accessor integriti : Integriti_1
59+
accessor staff_api : StaffAPI_1
60+
accessor calendar : Calendar_1
61+
62+
def mailer
63+
system.implementing(Interface::Mailer)[0]
64+
end
65+
66+
getter control_system_zone_list : Array(String) do
67+
config.control_system.not_nil!.zones
68+
end
69+
70+
getter building_id : String do
71+
locations.building_id.get.as_s
72+
end
73+
74+
class ZoneDetails
75+
include JSON::Serializable
76+
77+
property id : String
78+
property name : String
79+
property display_name : String?
80+
property location : String?
81+
property tags : Array(String)
82+
property parent_id : String?
83+
end
84+
85+
getter building_zone : ZoneDetails do
86+
ZoneDetails.from_json staff_api.zone(building_id).get.to_json
87+
end
88+
89+
protected def guest_checked_in(payload)
90+
logger.debug { "received guest event payload: #{payload}" }
91+
guest_details = Place::GuestNotification.from_json payload
92+
zones = guest_details.zones
93+
return unless zones
94+
95+
# ensure the event is for this building
96+
if (config.control_system.not_nil!.zones & zones).empty?
97+
logger.debug { "ignoring event as does not match any zones" }
98+
return
99+
end
100+
101+
case guest_details
102+
when Place::GuestCheckin
103+
grant_and_notify_access(
104+
guest_details.attendee_email,
105+
guest_details.attendee_name.as(String),
106+
guest_details.host.as(String),
107+
guest_details.event_summary,
108+
guest_details.event_starting
109+
)
110+
self[:users_granted_access] = @users_granted_access += 1
111+
else
112+
logger.debug { "ignoring event as not a checkin: #{guest_details.class}" }
113+
end
114+
end
115+
116+
def grant_and_notify_access(
117+
visitor_email : String,
118+
visitor_name : String,
119+
host_email : String,
120+
event_title : String?,
121+
event_start : Int64
122+
)
123+
local_start_time = Time.unix(event_start).in(@time_zone)
124+
late_in_day = local_start_time.at_end_of_day - 7.hours
125+
126+
access_from = (local_start_time - 15.minutes).to_unix
127+
access_until = local_start_time < late_in_day ? late_in_day : (local_start_time + 2.hours)
128+
card_details = integriti.grant_guest_access(visitor_name, visitor_email, access_from, access_until).get
129+
card_facility = card_details["card_facility"].as_i64.to_u32
130+
card_number = card_details["card_number"].as_i64.to_u32
131+
132+
# remove the 2 sign bits
133+
wiegand = Wiegand::Wiegand26.from_components(facility: card_facility, card_number: card_number)
134+
raw = (wiegand.wiegand & (Wiegand::Wiegand26::FACILITY_MASK | Wiegand::Wiegand26::CARD_MASK)) >> 1
135+
data = raw.to_s(16).upcase.rjust(6, '0')
136+
137+
# convert to hex and create QR code
138+
qr_png = mailer.generate_png_qrcode(text: data, size: 256).get.as_s
139+
attach = [
140+
{
141+
file_name: "access.png",
142+
content: qr_png,
143+
content_id: visitor_email,
144+
},
145+
]
146+
147+
mailer.send_template(
148+
visitor_email,
149+
{"visitor_invited", @visitor_access_template},
150+
{
151+
visitor_email: visitor_email,
152+
visitor_name: visitor_name,
153+
host_name: get_host_name(host_email),
154+
host_email: host_email,
155+
building_name: building_zone.display_name.presence || building_zone.name,
156+
event_title: event_title,
157+
event_start: local_start_time.to_s(@time_format),
158+
event_date: local_start_time.to_s(@date_format),
159+
event_time: local_start_time.to_s(@time_format),
160+
},
161+
attach
162+
)
163+
end
164+
165+
def template_fields : Array(TemplateFields)
166+
time_now = Time.utc.in(@time_zone)
167+
168+
invitation_fields = [
169+
{name: "visitor_email", description: "Email address of the visiting guest"},
170+
{name: "visitor_name", description: "Full name of the visiting guest"},
171+
{name: "host_name", description: "Name of the person hosting the visitor"},
172+
{name: "host_email", description: "Email address of the host"},
173+
{name: "building_name", description: "Name of the building where the visit occurs"},
174+
{name: "event_title", description: "Title or purpose of the visit"},
175+
{name: "event_start", description: "Start time (e.g., #{time_now.to_s(@time_format)})"},
176+
{name: "event_date", description: "Date of the visit (e.g., #{time_now.to_s(@date_format)})"},
177+
{name: "event_time", description: "Time of the visit (or 'all day' for 24-hour events)"},
178+
]
179+
180+
[
181+
TemplateFields.new(
182+
trigger: {"visitor_invited", @visitor_access_template},
183+
name: "Visitor invited",
184+
description: "Visitor entry security email with QR code for access",
185+
fields: invitation_fields
186+
),
187+
]
188+
end
189+
190+
protected def get_host_name(host_email)
191+
@determine_host_name_using == "staff-api-driver" ? get_host_name_from_staff_api_driver(host_email) : get_host_name_from_calendar_driver(host_email)
192+
end
193+
194+
protected def get_host_name_from_calendar_driver(host_email)
195+
calendar.get_user(host_email).get["name"]
196+
rescue error
197+
logger.error { "issue loading host details #{host_email}" }
198+
return "your host"
199+
end
200+
201+
protected def get_host_name_from_staff_api_driver(host_email, retries = 0)
202+
staff_api.staff_details(host_email).get["name"].as_s.split('(')[0]
203+
rescue error
204+
if retries > 3
205+
logger.error { "issue loading host details #{host_email}" }
206+
return "your host"
207+
end
208+
sleep 1.second
209+
get_host_name_from_staff_api_driver(host_email, retries + 1)
210+
end
211+
end
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
require "placeos-driver/spec"
2+
3+
DriverSpecs.mock_driver "InnerRange::IntegritiHIDVirtualPass" do
4+
system({
5+
StaffAPI: {StaffAPIMock},
6+
Integriti: {IntegritiMock},
7+
})
8+
end
9+
10+
# :nodoc:
11+
class StaffAPIMock < DriverSpecs::MockDriver
12+
def zone(id : String)
13+
{
14+
name: "Building 1234",
15+
}
16+
end
17+
end
18+
19+
# :nodoc:
20+
class IntegritiMock < DriverSpecs::MockDriver
21+
end
22+
23+
# :nodoc:
24+
class CalendarMock < DriverSpecs::MockDriver
25+
end
26+
27+
# :nodoc:
28+
class LocationsMock < DriverSpecs::MockDriver
29+
def building_id : String
30+
"building-1234"
31+
end
32+
end
33+
34+
# :nodoc:
35+
class MailerMock < DriverSpecs::MockDriver
36+
end

0 commit comments

Comments
 (0)