<?xml version="1.0" encoding="utf-8"?>
<Templates format="2" protocolVersion="83">
  <Template id="a3f7c2e1-58b4-4d09-9e76-2c1f0b3d5a8e" revision="1.0">
    <SuggestedCCUParameters/>
    <RevisionHistory>
      <Revision id="1.0">Initial version</Revision>
    </RevisionHistory>
    <Name>Glofox Room Bookings</Name>
    <CcuModel>PacketParserCCU</CcuModel>
    <Producer>Glofox (ABC Fitness)</Producer>
    <Model>Glofox API v2.2/v3.0</Model>
    <Description>Reads facility (room) booking state from the Glofox cloud booking system. Exposes booked-now, minutes-to-next-booking, and prepare-room signals for one room per module.</Description>
    <Category>5</Category>
    <ImportParameters>
      <Parameter>
        <Id>ApiKey</Id>
        <Name>Glofox API key (x-api-key header)</Name>
        <Value/>
      </Parameter>
      <Parameter>
        <Id>ApiToken</Id>
        <Name>Glofox API token (x-glofox-api-token header)</Name>
        <Value/>
      </Parameter>
      <Parameter>
        <Id>BranchId</Id>
        <Name>Glofox Branch ID (x-glofox-branch-id header)</Name>
        <Value/>
      </Parameter>
      <Parameter>
        <Id>FacilityId</Id>
        <Name>Glofox Facility ID for this room (model_id in booking response)</Name>
        <Value/>
      </Parameter>
      <Parameter>
        <Id>LeadMinutes</Id>
        <Name>Lead time in minutes — Prepare Room activates this many minutes before a booking</Name>
        <Value>60</Value>
      </Parameter>
    </ImportParameters>
    <Module>
      <Name>Glofox Room</Name>
      <Id>-1</Id>
      <Model>PacketParserModuleHttp</Model>
      <DeviceProperties>
        <IpAddress>gf-api.aws.glofox.com</IpAddress>
        <Port>443</Port>
        <UseHttps>True</UseHttps>
        <HttpAuthentification>0</HttpAuthentification>
        <InternalPollInterval>60000</InternalPollInterval>
        <CustomVariables>[{&quot;Name&quot;:&quot;ApiKey&quot;,&quot;ValueTypeId&quot;:2,&quot;NumericValue&quot;:0,&quot;StringValue&quot;:&quot;$[ApiKey]&quot;},{&quot;Name&quot;:&quot;ApiToken&quot;,&quot;ValueTypeId&quot;:2,&quot;NumericValue&quot;:0,&quot;StringValue&quot;:&quot;$[ApiToken]&quot;},{&quot;Name&quot;:&quot;BranchId&quot;,&quot;ValueTypeId&quot;:2,&quot;NumericValue&quot;:0,&quot;StringValue&quot;:&quot;$[BranchId]&quot;},{&quot;Name&quot;:&quot;FacilityId&quot;,&quot;ValueTypeId&quot;:2,&quot;NumericValue&quot;:0,&quot;StringValue&quot;:&quot;$[FacilityId]&quot;},{&quot;Name&quot;:&quot;LeadMinutes&quot;,&quot;ValueTypeId&quot;:1,&quot;NumericValue&quot;:$[LeadMinutes]},{&quot;Name&quot;:&quot;facilityCheck&quot;,&quot;ValueTypeId&quot;:1,&quot;NumericValue&quot;:0},{&quot;Name&quot;:&quot;failCount&quot;,&quot;ValueTypeId&quot;:1,&quot;NumericValue&quot;:0},{&quot;Name&quot;:&quot;bookedNow&quot;,&quot;ValueTypeId&quot;:1,&quot;NumericValue&quot;:0},{&quot;Name&quot;:&quot;minsToNext&quot;,&quot;ValueTypeId&quot;:1,&quot;NumericValue&quot;:1440},{&quot;Name&quot;:&quot;prepareRoom&quot;,&quot;ValueTypeId&quot;:1,&quot;NumericValue&quot;:0}]</CustomVariables>
        <ReadScriptPacketParser># Bookings of this room (FacilityId) that run now or start within 24 h. Glofox times are the studio&apos;s local time
# (&quot;yyyy-MM-dd HH:mm:ss&quot;), so the Core&apos;s time zone must be the studio&apos;s. Texts in that format sort like the times,
# which lets JSONPath filters compare them directly.
VAR now := NOW();
VAR nowText := TOSTRING(now, &quot;yyyy-MM-dd HH:mm:ss&quot;);
VAR leadText := TOSTRING(DATETIMEADD(now, 0, 0, 0, 0, LeadMinutes), &quot;yyyy-MM-dd HH:mm:ss&quot;);
VAR nowLocal := ROUND(now.UNIXTIME + LOCALTIMEZONE() / 1000);
VAR hKey := &quot;x-api-key: &quot; + ApiKey;
VAR hToken := &quot;x-glofox-api-token: &quot; + ApiToken;
VAR hBranch := &quot;x-glofox-branch-id: &quot; + BranchId;
VAR msg := &quot;&quot;;
VAR i := 0;
VAR fid := &quot;&quot;;

# Every failed poll ends in RETURN before the values are written. After five failed polls in a row (Glofox or the
# internet down) the room is released, so an old &quot;Prepare&quot; does not keep heating and lights on.
failCount := failCount + 1;
IF failCount &gt; 5
    bookedNow := 0;
    prepareRoom := 0;
    minsToNext := 1440;
END

# After start and then once an hour: the configured room must exist; if not, the error lists the rooms of the branch
# with their ids. facilityCheck counts the polls until the next check.
IF facilityCheck &lt;= 0
    VAR fresp := SENDHTTPREQUEST(&quot;/prod/3.0/locations/&quot; + BranchId + &quot;/facilities?limit=100&quot;, &quot;GET&quot;, &quot;&quot;, hKey, hToken, hBranch);
    IF !fresp.IsSuccess
        # no connection: StatusCode 0 and no ReasonPhrase
        IF !ISNULL(fresp.ReasonPhrase)
            msg := &quot; &quot; + fresp.ReasonPhrase;
        END
        ADDERROR(1, &quot;Glofox API: HTTP &quot; + TOSTRING(fresp.StatusCode, &quot;F0&quot;) + msg);
        RETURN(0);
    END
    IF ISNULL(PARSEJSON(fresp.Content, &quot;$.data&quot;, TRUE))
        msg := PARSEJSON(fresp.Content, &quot;$.message&quot;, TRUE);
        ADDERROR(2, &quot;Glofox API: &quot; + IF(ISNULL(msg), &quot;unexpected answer&quot;, msg));
        RETURN(0);
    END
    IF ISNULL(PARSEJSON(fresp.Content, &quot;$.data[?(@._id == &apos;&quot; + FacilityId + &quot;&apos;)].name&quot;, TRUE))
        msg := &quot;&quot;;
        fid := PARSEJSON(fresp.Content, &quot;$.data[0]._id&quot;, TRUE);
        WHILE !ISNULL(fid)
            msg := msg + IF(i = 0, &quot;: &quot;, &quot;; &quot;) + PARSEJSON(fresp.Content, &quot;$.data[&quot; + TOSTRING(i, &quot;F0&quot;) + &quot;].name&quot;, TRUE) + &quot; = &quot; + fid;
            i := i + 1;
            fid := PARSEJSON(fresp.Content, &quot;$.data[&quot; + TOSTRING(i, &quot;F0&quot;) + &quot;]._id&quot;, TRUE);
        LOOP
        ADDERROR(3, &quot;Facility &apos;&quot; + FacilityId + &quot;&apos; is not in this branch. Facilities&quot; + msg);
        RETURN(0);
    END
    facilityCheck := 60;
END
facilityCheck := facilityCheck - 1;

VAR path := &quot;/prod/2.2/branches/&quot; + BranchId + &quot;/bookings?status=BOOKED&amp;limit=50&amp;time_finish_start_date=&quot; + TOSTRING(nowLocal, &quot;F0&quot;) + &quot;&amp;time_start_end_date=&quot; + TOSTRING(nowLocal + 86400, &quot;F0&quot;) + &quot;&amp;page=&quot;;
VAR room := &quot;$.data[?(@.model_id == &apos;&quot; + FacilityId + &quot;&apos; &amp;&amp; @.status == &apos;BOOKED&apos; &amp;&amp; &quot;;
VAR newBooked := 0;
VAR newPrepare := 0;
VAR nextText := &quot;&quot;;
VAR candidate := &quot;&quot;;
VAR total := 0;
VAR page := 1;
VAR pages := 1;
VAR nextStart := now;

# limit=50: a booking is about 1 kB (0.9 kB measured) and SENDHTTPREQUEST accepts at most 128 kB; at most 10 pages =
# 500 bookings of the whole branch (class bookings included)
VAR resp := SENDHTTPREQUEST(path + &quot;1&quot;, &quot;GET&quot;, &quot;&quot;, hKey, hToken, hBranch);
WHILE page &lt;= pages
    IF page &gt; 1
        resp := SENDHTTPREQUEST(path + TOSTRING(page, &quot;F0&quot;), &quot;GET&quot;, &quot;&quot;, hKey, hToken, hBranch);
    END
    IF !resp.IsSuccess
        IF !ISNULL(resp.ReasonPhrase)
            msg := &quot; &quot; + resp.ReasonPhrase;
        END
        ADDERROR(1, &quot;Glofox API: HTTP &quot; + TOSTRING(resp.StatusCode, &quot;F0&quot;) + msg);
        RETURN(0);
    END
    # a wrong API token or BranchId answers HTTP 200 with success:false and a message, and no meta
    total := PARSEJSON(resp.Content, &quot;$.meta.totalCount&quot;, TRUE);
    IF ISNULL(total)
        msg := PARSEJSON(resp.Content, &quot;$.message&quot;, TRUE);
        ADDERROR(2, &quot;Glofox API: &quot; + IF(ISNULL(msg), &quot;unexpected answer&quot;, msg));
        RETURN(0);
    END
    pages := MIN(10, CEIL(total / 50));
    IF !ISNULL(PARSEJSON(resp.Content, room + &quot;@.time_start &lt;= &apos;&quot; + nowText + &quot;&apos; &amp;&amp; @.time_finish &gt; &apos;&quot; + nowText + &quot;&apos;)]._id&quot;, TRUE))
        newBooked := 1;
    END
    IF !ISNULL(PARSEJSON(resp.Content, room + &quot;@.time_start &lt;= &apos;&quot; + leadText + &quot;&apos; &amp;&amp; @.time_finish &gt; &apos;&quot; + nowText + &quot;&apos;)]._id&quot;, TRUE))
        newPrepare := 1;
    END
    # earliest booking that has not started: take any later than now, then ask for an earlier one until none is left
    candidate := PARSEJSON(resp.Content, room + &quot;@.time_start &gt; &apos;&quot; + nowText + &quot;&apos;&quot; + IF(nextText = &quot;&quot;, &quot;&quot;, &quot; &amp;&amp; @.time_start &lt; &apos;&quot; + nextText + &quot;&apos;&quot;) + &quot;)].time_start&quot;, TRUE);
    WHILE !ISNULL(candidate)
        nextText := candidate;
        candidate := PARSEJSON(resp.Content, room + &quot;@.time_start &gt; &apos;&quot; + nowText + &quot;&apos; &amp;&amp; @.time_start &lt; &apos;&quot; + nextText + &quot;&apos;)].time_start&quot;, TRUE);
    LOOP
    page := page + 1;
LOOP
IF total &gt; 500
    ADDWARNING(4, &quot;More than 500 bookings of the branch in the next 24 hours; only the first 500 were read, a booking of this room beyond them is not seen&quot;);
END

failCount := 0;
bookedNow := newBooked;
prepareRoom := newPrepare;
IF nextText = &quot;&quot;
    minsToNext := 1440;
ELSE
    nextStart := DATETIME(nextText, &quot;yyyy-MM-dd HH:mm:ss&quot;);
    minsToNext := MIN(1440, FLOOR((nextStart.TICKS - now.TICKS) / 60000));
END</ReadScriptPacketParser>
      </DeviceProperties>
      <Devices>
        <Device>
          <Name>Booked now</Name>
          <Id>-2</Id>
          <Model>PacketParserReedContact</Model>
          <DeviceProperties>
            <DeviceType>0</DeviceType>
            <InternalPollInterval>60000</InternalPollInterval>
            <OnStateIconId>87</OnStateIconId>
            <OffStateIconId>88</OffStateIconId>
            <OnStateName>Booked</OnStateName>
            <OffStateName>Free</OffStateName>
            <ReadStateScriptPacketParser>RETURN(bookedNow);</ReadStateScriptPacketParser>
          </DeviceProperties>
        </Device>
        <Device>
          <Name>Minutes to next booking</Name>
          <Id>-3</Id>
          <Model>PacketParserVariable</Model>
          <DeviceProperties>
            <DeviceType>0</DeviceType>
            <InternalPollInterval>60000</InternalPollInterval>
            <IsReadOnly>True</IsReadOnly>
            <ShouldDoPeriodicWrite>False</ShouldDoPeriodicWrite>
            <ReadStateScriptPacketParser>RETURN(minsToNext);</ReadStateScriptPacketParser>
          </DeviceProperties>
        </Device>
        <Device>
          <Name>Prepare room</Name>
          <Id>-4</Id>
          <Model>PacketParserReedContact</Model>
          <DeviceProperties>
            <DeviceType>0</DeviceType>
            <InternalPollInterval>60000</InternalPollInterval>
            <OnStateIconId>39</OnStateIconId>
            <OffStateIconId>256</OffStateIconId>
            <OnStateName>Prepare</OnStateName>
            <OffStateName>Idle</OffStateName>
            <ReadStateScriptPacketParser>RETURN(prepareRoom);</ReadStateScriptPacketParser>
          </DeviceProperties>
        </Device>
      </Devices>
    </Module>
  </Template>
</Templates>
