From be6e3e6b930c07e7a7bd729358c8b8aaf7f850ff Mon Sep 17 00:00:00 2001 From: Mia Bennett Date: Tue, 21 Apr 2026 12:29:31 +0930 Subject: [PATCH 1/6] feat(visitor_mailer): add previous location fields --- drivers/place/visitor_mailer.cr | 64 ++++++++++++++++++++++----------- 1 file changed, 43 insertions(+), 21 deletions(-) diff --git a/drivers/place/visitor_mailer.cr b/drivers/place/visitor_mailer.cr index 2f1ef27bc5b..ed40fa11129 100644 --- a/drivers/place/visitor_mailer.cr +++ b/drivers/place/visitor_mailer.cr @@ -174,7 +174,7 @@ class Place::VisitorMailer < PlaceOS::Driver @uri = URI.parse(setting?(String, :domain_uri) || "") @jwt_private_key = setting?(String, :jwt_private_key) || PlaceOS::Model::JWTBase.private_key - zones = config.control_system.not_nil!.zones + zones = control_system_zone_list schedule.clear if reminders = @send_reminders schedule.cron(reminders, @time_zone) { send_reminder_emails } @@ -514,9 +514,10 @@ class Place::VisitorMailer < PlaceOS::Driver description: "Notification sent to all visitors on a booking when details change (date, time, location, etc.)", fields: common_fields + [ {name: "room_name", description: "Name of the room or area being visited"}, - {name: "building_name", description: "Name of the building where the booking now occurs"}, {name: "previous_event_date", description: "The original date of the booking before it was changed"}, {name: "previous_event_time", description: "The original time of the booking before it was changed"}, + {name: "previous_room_name", description: "The original room or area name before the booking was moved"}, + {name: "previous_building_name", description: "The original building name before the booking was moved"}, ] ), ] @@ -555,6 +556,25 @@ class Place::VisitorMailer < PlaceOS::Driver return unless fields_changed + # Resolve previous location names from previous zones + previous_building_name = building_zone.display_name.presence || building_zone.name + previous_room_name = @booking_space_name + + if prev_zones = details.previous_zones + prev_zones.each do |zone_id| + begin + zone = ZoneDetails.from_json staff_api.zone(zone_id).get.to_json + if zone.tags.includes?(@invite_zone_tag) + previous_building_name = zone.display_name.presence || zone.name + else + previous_room_name = zone.display_name.presence || zone.name + end + rescue error + logger.warn(exception: error) { "error looking up previous zone #{zone_id}" } + end + end + end + guests = staff_api.booking_guests(details.id).get.as_a guests.each do |guest| visitor_email = guest["email"].as_s @@ -565,25 +585,27 @@ class Place::VisitorMailer < PlaceOS::Driver local_start_time = Time.unix(details.booking_start).in(@time_zone) - previous_date = details.previous_booking_start.try { |t| Time.unix(t).in(@time_zone).to_s(@date_format) } - previous_time = details.previous_booking_start.try { |t| Time.unix(t).in(@time_zone).to_s(@time_format) } + previous_date = details.previous_booking_start.try { |timestamp| Time.unix(timestamp).in(@time_zone).to_s(@date_format) } + previous_time = details.previous_booking_start.try { |timestamp| Time.unix(timestamp).in(@time_zone).to_s(@time_format) } mailer.send_template( visitor_email, {"visitor_invited", @booking_changed_template}, { - visitor_email: visitor_email, - visitor_name: visitor_name, - host_name: get_host_name(details.user_email), - host_email: details.user_email, - room_name: @booking_space_name, - building_name: building_zone.display_name.presence || building_zone.name, - event_title: details.title, - event_start: local_start_time.to_s(@time_format), - event_date: local_start_time.to_s(@date_format), - event_time: local_start_time.to_s(@time_format), - previous_event_date: previous_date, - previous_event_time: previous_time, + visitor_email: visitor_email, + visitor_name: visitor_name, + host_name: get_host_name(details.user_email), + host_email: details.user_email, + room_name: @booking_space_name, + building_name: building_zone.display_name.presence || building_zone.name, + event_title: details.title, + event_start: local_start_time.to_s(@time_format), + event_date: local_start_time.to_s(@date_format), + event_time: local_start_time.to_s(@time_format), + previous_event_date: previous_date, + previous_event_time: previous_time, + previous_room_name: previous_room_name, + previous_building_name: previous_building_name, } ) rescue error @@ -687,7 +709,7 @@ class Place::VisitorMailer < PlaceOS::Driver zones: {building_zone.id} ).get.as_a - guests.uniq! { |g| g["email"].as_s.downcase } + guests.uniq! { |guest| guest["email"].as_s.downcase } guests.each do |guest| begin if event = guest["event"]? @@ -778,7 +800,7 @@ class Place::VisitorMailer < PlaceOS::Driver protected def get_room_details(system_id : String, retries = 0) SystemDetails.from_json staff_api.get_system(system_id).get.to_json - rescue error + rescue raise "issue loading system details #{system_id}" if retries > 3 sleep 1.second get_room_details(system_id, retries + 1) @@ -790,14 +812,14 @@ class Place::VisitorMailer < PlaceOS::Driver protected def get_host_name_from_calendar_driver(host_email) calendar.get_user(host_email).get["name"] - rescue error + rescue logger.error { "issue loading host details #{host_email}" } - return "your host" + "your host" end protected def get_host_name_from_staff_api_driver(host_email, retries = 0) staff_api.staff_details(host_email).get["name"].as_s.split('(')[0] - rescue error + rescue if retries > 3 logger.error { "issue loading host details #{host_email}" } return "your host" From bb4de14681ce5bcb9064287c6f4cbddb8e28a20e Mon Sep 17 00:00:00 2001 From: Mia Bennett Date: Tue, 21 Apr 2026 13:01:27 +0930 Subject: [PATCH 2/6] test(visitor_mailer): booking_changed_event --- drivers/place/visitor_mailer_spec.cr | 343 +++++++++++++++++++++++++++ 1 file changed, 343 insertions(+) diff --git a/drivers/place/visitor_mailer_spec.cr b/drivers/place/visitor_mailer_spec.cr index 1dbd48e16f4..efb97280e41 100644 --- a/drivers/place/visitor_mailer_spec.cr +++ b/drivers/place/visitor_mailer_spec.cr @@ -1,4 +1,347 @@ require "placeos-driver/spec" +require "placeos-driver/interface/mailer" + +# :nodoc: +class MailerMock < DriverSpecs::MockDriver + include PlaceOS::Driver::Interface::Mailer + + def on_load + self[:send_count] = 0 + end + + def send_template( + to : String | Array(String), + template : Tuple(String, String), + args : TemplateItems, + resource_attachments : Array(ResourceAttachment) = [] of ResourceAttachment, + attachments : Array(Attachment) = [] of Attachment, + cc : String | Array(String) = [] of String, + bcc : String | Array(String) = [] of String, + from : (String | Array(String))? = nil, + reply_to : (String | Array(String))? = nil, + ) + self[:last_to] = to + self[:last_template] = template + self[:last_args] = args + self[:send_count] = self[:send_count].as_i + 1 + true + end + + def send_mail( + to : String | Array(String), + subject : String, + message_plaintext : String? = nil, + message_html : String? = nil, + resource_attachments : Array(ResourceAttachment) = [] of ResourceAttachment, + attachments : Array(Attachment) = [] of Attachment, + cc : String | Array(String) = [] of String, + bcc : String | Array(String) = [] of String, + from : (String | Array(String))? = nil, + reply_to : (String | Array(String))? = nil, + ) : Bool + true + end +end + +# :nodoc: +class CalendarMock < DriverSpecs::MockDriver + def get_user(email : String) + {name: "Host User", email: email} + end +end + +# :nodoc: +class StaffAPIMock < DriverSpecs::MockDriver + BUILDING_ZONE = { + id: "zone-building", + name: "Main Building", + display_name: "Main Building", + location: "", + tags: ["building"], + parent_id: "zone-org", + } + + OLD_BUILDING_ZONE = { + id: "zone-old-building", + name: "Old Building", + display_name: "Previous Building", + location: "", + tags: ["building"], + parent_id: "zone-org", + } + + ROOM_ZONE = { + id: "zone-room", + name: "Room 101", + display_name: "Conference Room 101", + location: "", + tags: ["level"], + parent_id: "zone-building", + } + + OLD_ROOM_ZONE = { + id: "zone-old-room", + name: "Room 202", + display_name: "Previous Room 202", + location: "", + tags: ["level"], + parent_id: "zone-old-building", + } + + EXTRA_ZONE = { + id: "zone-extra", + name: "Extra Zone", + display_name: "Extra Zone", + location: "", + tags: ["org"], + parent_id: nil, + } + + def on_load + self[:zone_lookups] = 0 + end + + def zone(id : String) + self[:zone_lookups] = self[:zone_lookups].as_i + 1 + case id + when "zone-building" + BUILDING_ZONE + when "zone-old-building" + OLD_BUILDING_ZONE + when "zone-room" + ROOM_ZONE + when "zone-old-room" + OLD_ROOM_ZONE + when "zone-extra" + EXTRA_ZONE + else + # Return a generic zone tagged as building so on_load find_building succeeds + BUILDING_ZONE + end + end + + def booking_guests(booking_id : Int64) + [ + { + email: "visitor@external.com", + name: "Visitor One", + checked_in: false, + visit_expected: true, + }, + ] + end +end DriverSpecs.mock_driver "Place::VisitorMailer" do + system({ + StaffAPI: {StaffAPIMock}, + Mailer: {MailerMock}, + Calendar: {CalendarMock}, + }) + + # Allow on_load -> on_update -> ensure_building_zone to complete + sleep 1.5 + + # ------------------------------------------------------------------ + # Test 1: booking_changed with previous_zones resolves names correctly + # ------------------------------------------------------------------ + + now = Time.utc.to_unix + + changed_payload_with_zones = { + action: "changed", + id: 100_i64, + booking_type: "desk", + booking_start: now + 3600, + booking_end: now + 7200, + timezone: "GMT", + resource_id: "desk-1", + resource_ids: ["desk-1"], + user_email: "host@example.com", + title: "Team Meeting", + zones: ["zone-building", "zone-room"], + previous_booking_start: now, + previous_booking_end: now + 3600, + previous_zones: ["zone-old-building", "zone-old-room"], + }.to_json + + # Reset counters before publishing + system(:StaffAPI)[:zone_lookups].should_not be_nil + + publish("staff/booking/changed", changed_payload_with_zones) + sleep 1.5 + + # Verify email was sent + system(:Mailer)[:send_count].should eq 1 + system(:Mailer)[:last_to].should eq "visitor@external.com" + system(:Mailer)[:last_template].should eq ["visitor_invited", "booking_changed"] + + # Verify the template args include resolved previous location names + args = system(:Mailer)[:last_args] + args["previous_building_name"].should eq "Previous Building" + args["previous_room_name"].should eq "Previous Room 202" + + # Verify current location names are from the current building/room + args["building_name"].should eq "Main Building" + args["room_name"].should eq "Client Floor" + + # Verify host name was resolved + args["host_name"].should eq "Host User" + args["host_email"].should eq "host@example.com" + args["event_title"].should eq "Team Meeting" + + # ------------------------------------------------------------------ + # Test 2: booking_changed with only time change (no previous_zones) + # should use default building/room names + # ------------------------------------------------------------------ + + changed_payload_time_only = { + action: "changed", + id: 101_i64, + booking_type: "desk", + booking_start: now + 7200, + booking_end: now + 10800, + timezone: "GMT", + resource_id: "desk-1", + resource_ids: ["desk-1"], + user_email: "host@example.com", + title: "Standup", + zones: ["zone-building", "zone-room"], + previous_booking_start: now, + previous_booking_end: now + 3600, + # No previous_zones — location did not change + }.to_json + + publish("staff/booking/changed", changed_payload_time_only) + sleep 1.5 + + system(:Mailer)[:send_count].should eq 2 + + args2 = system(:Mailer)[:last_args] + # Without previous_zones the driver falls back to the current building name and @booking_space_name + args2["previous_building_name"].should eq "Main Building" + args2["previous_room_name"].should eq "Client Floor" + args2["event_title"].should eq "Standup" + + # previous_event_date and previous_event_time should be present (time did change) + args2["previous_event_date"].should_not be_nil + args2["previous_event_time"].should_not be_nil + + # ------------------------------------------------------------------ + # Test 3: action != "changed" is ignored (no extra email sent) + # ------------------------------------------------------------------ + + created_payload = { + action: "create", + id: 102_i64, + booking_type: "desk", + booking_start: now + 3600, + booking_end: now + 7200, + timezone: "GMT", + resource_id: "desk-1", + resource_ids: ["desk-1"], + user_email: "host@example.com", + title: "Ignored Event", + zones: ["zone-building"], + }.to_json + + publish("staff/booking/changed", created_payload) + sleep 0.5 + + # Count should not have increased + system(:Mailer)[:send_count].should eq 2 + + # ------------------------------------------------------------------ + # Test 4: Short-circuit — with 3 previous zones (building, room, extra) + # only 2 zone lookups are needed for previous zone resolution + # ------------------------------------------------------------------ + + lookups_before = system(:StaffAPI)[:zone_lookups].as_i + + changed_payload_short_circuit = { + action: "changed", + id: 103_i64, + booking_type: "desk", + booking_start: now + 3600, + booking_end: now + 7200, + timezone: "GMT", + resource_id: "desk-1", + resource_ids: ["desk-1"], + user_email: "host@example.com", + title: "Short Circuit Test", + zones: ["zone-building", "zone-room"], + previous_booking_start: now, + previous_booking_end: now + 3600, + # Building first, room second, extra third — extra should be skipped + previous_zones: ["zone-old-building", "zone-old-room", "zone-extra"], + }.to_json + + publish("staff/booking/changed", changed_payload_short_circuit) + sleep 1.5 + + system(:Mailer)[:send_count].should eq 3 + + lookups_after = system(:StaffAPI)[:zone_lookups].as_i + # Should have looked up exactly 2 previous zones (building + room) plus 1 for booking_guests flow = 2 + # The third zone (zone-extra) should have been skipped due to short-circuit + previous_zone_lookups = lookups_after - lookups_before + previous_zone_lookups.should eq 2 + + # Verify the resolved names are still correct + args3 = system(:Mailer)[:last_args] + args3["previous_building_name"].should eq "Previous Building" + args3["previous_room_name"].should eq "Previous Room 202" + + # ------------------------------------------------------------------ + # Test 5: Event for a different building is ignored + # ------------------------------------------------------------------ + + changed_payload_wrong_zone = { + action: "changed", + id: 104_i64, + booking_type: "desk", + booking_start: now + 3600, + booking_end: now + 7200, + timezone: "GMT", + resource_id: "desk-1", + resource_ids: ["desk-1"], + user_email: "host@example.com", + title: "Wrong Zone", + zones: ["zone-other-building"], + previous_booking_start: now, + previous_booking_end: now + 3600, + }.to_json + + publish("staff/booking/changed", changed_payload_wrong_zone) + sleep 0.5 + + # Count should not have increased — event was for a different building + system(:Mailer)[:send_count].should eq 3 + + # ------------------------------------------------------------------ + # Test 6: No fields actually changed — should not send email + # ------------------------------------------------------------------ + + changed_payload_no_diff = { + action: "changed", + id: 105_i64, + booking_type: "desk", + booking_start: now + 3600, + booking_end: now + 7200, + timezone: "GMT", + resource_id: "desk-1", + resource_ids: ["desk-1"], + user_email: "host@example.com", + title: "No Real Change", + zones: ["zone-building", "zone-room"], + # Same start time as current — no actual change + previous_booking_start: now + 3600, + previous_booking_end: now + 7200, + # No previous_zones — no location change + }.to_json + + publish("staff/booking/changed", changed_payload_no_diff) + sleep 0.5 + + system(:Mailer)[:send_count].should eq 3 end From d738e8fef4717efe1a1765b0fd9b661765f47211 Mon Sep 17 00:00:00 2001 From: Mia Bennett Date: Tue, 21 Apr 2026 13:01:56 +0930 Subject: [PATCH 3/6] refactor(visitor_mailer): zone name lookup --- drivers/place/visitor_mailer.cr | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/drivers/place/visitor_mailer.cr b/drivers/place/visitor_mailer.cr index ed40fa11129..85f3778273b 100644 --- a/drivers/place/visitor_mailer.cr +++ b/drivers/place/visitor_mailer.cr @@ -561,13 +561,18 @@ class Place::VisitorMailer < PlaceOS::Driver previous_room_name = @booking_space_name if prev_zones = details.previous_zones + found_building = false + found_room = false prev_zones.each do |zone_id| + break if found_building && found_room begin zone = ZoneDetails.from_json staff_api.zone(zone_id).get.to_json if zone.tags.includes?(@invite_zone_tag) previous_building_name = zone.display_name.presence || zone.name + found_building = true else previous_room_name = zone.display_name.presence || zone.name + found_room = true end rescue error logger.warn(exception: error) { "error looking up previous zone #{zone_id}" } From 29ea74f2e187d30e7f6769105b1ccface37b53fb Mon Sep 17 00:00:00 2001 From: Mia Bennett Date: Tue, 21 Apr 2026 13:46:32 +0930 Subject: [PATCH 4/6] feat(visitor_mailer): cache zones --- drivers/place/visitor_mailer.cr | 33 ++++++++++++++++++++++++++-- drivers/place/visitor_mailer_spec.cr | 12 +++++----- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/drivers/place/visitor_mailer.cr b/drivers/place/visitor_mailer.cr index 85f3778273b..16f66ef8f7b 100644 --- a/drivers/place/visitor_mailer.cr +++ b/drivers/place/visitor_mailer.cr @@ -47,6 +47,7 @@ class Place::VisitorMailer < PlaceOS::Driver network_password_minimum_symbols: DEFAULT_PASSWORD_MINIMUM_SYMBOLS, network_group_ids: [] of String, debug: false, + zone_cache_timeout: 300, host_domain_filter: [] of String, disable_event_visitors: true, @@ -112,6 +113,9 @@ class Place::VisitorMailer < PlaceOS::Driver @booking_space_name : String = "Client Floor" @invite_zone_tag : String = "building" + @zone_cache : ZoneCache = ZoneCache.new + @zone_cache_timeout : Int64 = 300 + @reminder_template : String = "visitor" @send_reminders : String? = nil @event_template : String = "event" @@ -173,6 +177,8 @@ class Place::VisitorMailer < PlaceOS::Driver @uri = URI.parse(setting?(String, :domain_uri) || "") @jwt_private_key = setting?(String, :jwt_private_key) || PlaceOS::Model::JWTBase.private_key + @zone_cache_timeout = setting?(Int64, :zone_cache_timeout) || 300_i64 + @zone_cache = ZoneCache.new zones = control_system_zone_list schedule.clear @@ -195,7 +201,7 @@ class Place::VisitorMailer < PlaceOS::Driver protected def find_building(zones : Array(String)) : ZoneDetails zones.each do |zone_id| - zone = ZoneDetails.from_json staff_api.zone(zone_id).get.to_json + zone = fetch_zone(zone_id) if zone.tags.includes?(@invite_zone_tag) @building_zone = zone if @is_parent_zone && (child_zones = Array(ZoneDetails).from_json(staff_api.zones(parent: zone_id).get.to_json)) @@ -210,6 +216,26 @@ class Place::VisitorMailer < PlaceOS::Driver @building_zone.as(ZoneDetails) end + # Fetch a zone from the cache, or from the API if not cached / expired. + # Follows the same pattern as TemplateMailer's template cache. + protected def fetch_zone(zone_id : String) : ZoneDetails + if (cached = @zone_cache[zone_id]?) && cached[0] > Time.utc.to_unix + cached[1] + else + zone = ZoneDetails.from_json staff_api.zone(zone_id).get.to_json + @zone_cache[zone_id] = {Time.utc.to_unix + @zone_cache_timeout, zone} + zone + end + end + + def clear_zone_cache(zone_id : String? = nil) + if zone_id && !zone_id.blank? + @zone_cache.delete(zone_id) + else + @zone_cache = ZoneCache.new + end + end + protected def guest_event(payload) logger.debug { "received guest event payload: #{payload}" } guest_details = GuestNotification.from_json payload @@ -566,7 +592,7 @@ class Place::VisitorMailer < PlaceOS::Driver prev_zones.each do |zone_id| break if found_building && found_room begin - zone = ZoneDetails.from_json staff_api.zone(zone_id).get.to_json + zone = fetch_zone(zone_id) if zone.tags.includes?(@invite_zone_tag) previous_building_name = zone.display_name.presence || zone.name found_building = true @@ -794,6 +820,9 @@ class Place::VisitorMailer < PlaceOS::Driver property parent_id : String? end + # zone_id, timeout, zone + alias ZoneCache = Hash(String, Tuple(Int64, ZoneDetails)) + class SystemDetails include JSON::Serializable diff --git a/drivers/place/visitor_mailer_spec.cr b/drivers/place/visitor_mailer_spec.cr index efb97280e41..5ba3f7f7f90 100644 --- a/drivers/place/visitor_mailer_spec.cr +++ b/drivers/place/visitor_mailer_spec.cr @@ -252,8 +252,9 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do system(:Mailer)[:send_count].should eq 2 # ------------------------------------------------------------------ - # Test 4: Short-circuit — with 3 previous zones (building, room, extra) - # only 2 zone lookups are needed for previous zone resolution + # Test 4: Zone caching — zone-old-building and zone-old-room were + # already looked up (and cached) in Test 1, so repeating them + # here should require zero new API calls. # ------------------------------------------------------------------ lookups_before = system(:StaffAPI)[:zone_lookups].as_i @@ -282,10 +283,11 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do system(:Mailer)[:send_count].should eq 3 lookups_after = system(:StaffAPI)[:zone_lookups].as_i - # Should have looked up exactly 2 previous zones (building + room) plus 1 for booking_guests flow = 2 - # The third zone (zone-extra) should have been skipped due to short-circuit + # zone-old-building and zone-old-room are served from the zone cache + # (populated during Test 1), so no new API calls are made. + # The third zone (zone-extra) is never reached due to short-circuit. previous_zone_lookups = lookups_after - lookups_before - previous_zone_lookups.should eq 2 + previous_zone_lookups.should eq 0 # Verify the resolved names are still correct args3 = system(:Mailer)[:last_args] From dcc6422440409a04854281567c77137ab3d4dcc1 Mon Sep 17 00:00:00 2001 From: Mia Bennett Date: Tue, 21 Apr 2026 14:17:21 +0930 Subject: [PATCH 5/6] Update drivers/place/visitor_mailer_spec.cr Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- drivers/place/visitor_mailer_spec.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/drivers/place/visitor_mailer_spec.cr b/drivers/place/visitor_mailer_spec.cr index 5ba3f7f7f90..84f5c3db754 100644 --- a/drivers/place/visitor_mailer_spec.cr +++ b/drivers/place/visitor_mailer_spec.cr @@ -165,7 +165,7 @@ DriverSpecs.mock_driver "Place::VisitorMailer" do previous_zones: ["zone-old-building", "zone-old-room"], }.to_json - # Reset counters before publishing + # Ensure zone lookup counters are initialized before publishing system(:StaffAPI)[:zone_lookups].should_not be_nil publish("staff/booking/changed", changed_payload_with_zones) From 09ef3a5f0d5e00b578cee4f1c48fa8939f1082bc Mon Sep 17 00:00:00 2001 From: Mia Bennett Date: Tue, 21 Apr 2026 14:18:19 +0930 Subject: [PATCH 6/6] refactor(visitor_mailer): ameba and debug always being true --- drivers/place/visitor_mailer.cr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/drivers/place/visitor_mailer.cr b/drivers/place/visitor_mailer.cr index 16f66ef8f7b..409ce05e719 100644 --- a/drivers/place/visitor_mailer.cr +++ b/drivers/place/visitor_mailer.cr @@ -141,7 +141,7 @@ class Place::VisitorMailer < PlaceOS::Driver @jwt_private_key : String = PlaceOS::Model::JWTBase.private_key def on_update - @debug = setting?(Bool, :debug) || true + @debug = setting?(Bool, :debug) || false @date_time_format = setting?(String, :date_time_format) || "%c" @time_format = setting?(String, :time_format) || "%l:%M%p" @date_format = setting?(String, :date_format) || "%A, %-d %B" @@ -189,7 +189,7 @@ class Place::VisitorMailer < PlaceOS::Driver end def control_system_zone_list - config.control_system.not_nil!.zones + config.control_system.not_nil!.zones # ameba:disable Lint/NotNil end protected def ensure_building_zone(zones) : Nil