homebridge-unifi-protect 6.6.0 → 6.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,7 +29,7 @@
29
29
  <div id="deviceInfo">
30
30
  <table class="table table-sm table-borderless">
31
31
  <tr class="align-center">
32
- <td colspan="2" class="m-0 p-2 text-center font-weight-bold">Feature options are applied in prioritized order, from global to device-specific options:<br><i>Global options</i> (lowest priority) &rarr; <i>Protect controller options</i> &rarr; <i>Protect device options</i> (highest priority)</td>
32
+ <td id="headerInfo" colspan="2" class="m-0 p-2 text-center font-weight-bold"></td>
33
33
  </tr>
34
34
  <tr class="align-top">
35
35
  <td rowspan="3" class="w-25">
@@ -50,7 +50,7 @@
50
50
  </td>
51
51
  <td>
52
52
  <table class="table table-sm table-borderless border-bottom m-0 p-0" id="deviceStatsTable" style="display: none;">
53
- <tr>
53
+ <tr id="deviceStatsHeader">
54
54
  <th style="width: 30%;" class="m-0 p-0"><B>Model<B></th>
55
55
  <th style="width: 25%;" class="m-0 p-0"><B>IP Address</B></th>
56
56
  <th style="width: 20%;" class="m-0 p-0"><B>MAC Address</B></th>
@@ -234,6 +234,17 @@
234
234
  // Start with a clean slate.
235
235
  controllersTable.innerHTML = "";
236
236
 
237
+ // We haven't configured anything yet - we're done.
238
+ if(!currentConfig[0]?.controllers?.length) {
239
+
240
+ document.getElementById("headerInfo").innerHTML = "Please configure a UniFi Protect controller to access in the main settings tab before configuring feature options."
241
+ homebridge.hideSpinner();
242
+ return;
243
+ }
244
+
245
+ // Initialize our informational header.
246
+ document.getElementById("headerInfo").innerHTML = "Feature options are applied in prioritized order, from global to device-specific options:<br><i class=\"text-warning\">Global options</i> (lowest priority) &rarr; <i class=\"text-success\">Protect controller options</i> &rarr; <i class=\"text-info\">Protect device options</i> (highest priority)"
247
+
237
248
  // Enumerate our global options.
238
249
  const trGlobal = document.createElement("tr");
239
250
 
@@ -342,7 +353,12 @@
342
353
 
343
354
  devicesTable.innerHTML = "";
344
355
 
345
- document.getElementById("device_model").innerHTML = "Unable to connect to the Protect controller. Check your settings for this controller in the settings tab."
356
+ document.getElementById("device_model").innerHTML = "Unable to connect to the Protect controller. Check your settings for this controller in the main settings tab to verify they are correct."
357
+ document.getElementById("device_model").colSpan = 3;
358
+ document.getElementById("device_model").style.fontWeight = "bold";
359
+ document.getElementById("device_model").classList.add("text-center");
360
+ document.getElementById("deviceStatsHeader").style.display = "none";
361
+
346
362
  document.getElementById("device_mac").innerHTML = "";
347
363
  document.getElementById("device_address").innerHTML = "";
348
364
  document.getElementById("device_online").innerHTML = "";
@@ -355,6 +371,9 @@
355
371
  const modelKeys = [...new Set(ufpDevices.map(x => x.modelKey))];
356
372
  const deviceList = [];
357
373
 
374
+ // The first entry returned by getDevices is always the controller.
375
+ const nvr = ufpDevices[0];
376
+
358
377
  // Start with a clean slate.
359
378
  devicesTable.innerHTML = "";
360
379
 
@@ -366,7 +385,7 @@
366
385
  // If it's a controller, we handle that case differently.
367
386
  if((key === "nvr") && devices.length) {
368
387
 
369
- // Change the name of the controler that we show users once we've connected with the controller.
388
+ // Change the name of the controller that we show users once we've connected with the controller.
370
389
  controllerList.map(x => (x.name === controller.address) ? x.childNodes[0].nodeValue = devices[0].name : true);
371
390
  continue;
372
391
  }
@@ -401,7 +420,7 @@
401
420
  const label = document.createElement("label");
402
421
 
403
422
  label.name = device.id;
404
- label.appendChild(document.createTextNode(device.name));
423
+ label.appendChild(document.createTextNode(device.name ?? device.marketName));
405
424
  label.style.cursor = "pointer";
406
425
  label.classList.add("mx-2", "my-0", "p-0", "w-100");
407
426
 
@@ -437,6 +456,13 @@
437
456
  // Initialize our feature option configuration.
438
457
  updateConfigOptions(currentConfig[0].options ?? [])
439
458
 
459
+ // Is this feature option set explicitly?
460
+ const isOptionSet = (featureOption, deviceMac) => {
461
+
462
+ const optionRegex = new RegExp("^(Enable|Disable)\." + featureOption + (!deviceMac ? "" : "\." + deviceMac) + "$", "gi");
463
+ return optionsList.filter(x => x.match(optionRegex)).length ? true : false;
464
+ };
465
+
440
466
  // Is a feature option globally enabled?
441
467
  const isGlobalOptionEnabled = (featureOption, defaultValue) => {
442
468
 
@@ -448,9 +474,14 @@
448
474
  );
449
475
  };
450
476
 
451
- // Is a feature option enabled at the device or controller level.
477
+ // Is a feature option enabled at the device or global level. It does not traverse the scoping hierarchy.
452
478
  const isDeviceOptionEnabled = (featureOption, mac, defaultValue) => {
453
479
 
480
+ if(!mac) {
481
+
482
+ return isGlobalOptionEnabled(featureOption, defaultValue);
483
+ }
484
+
454
485
  featureOption = featureOption.toUpperCase();
455
486
  mac = mac.toUpperCase();
456
487
 
@@ -460,6 +491,92 @@
460
491
  );
461
492
  };
462
493
 
494
+ // Is a feature option enabled at the device or global level. It does traverse the scoping hierarchy.
495
+ const isOptionEnabled = (featureOption, deviceMac, defaultValue) => {
496
+
497
+ if(deviceMac) {
498
+
499
+ // Device level check.
500
+ if(isDeviceOptionEnabled(featureOption, deviceMac, defaultValue) !== defaultValue) {
501
+
502
+ return !defaultValue;
503
+ }
504
+
505
+ // Controller level check.
506
+ if(isDeviceOptionEnabled(featureOption, nvr.mac, defaultValue) !== defaultValue) {
507
+
508
+ return !defaultValue;
509
+ }
510
+ }
511
+
512
+ // Global check.
513
+ if(isGlobalOptionEnabled(featureOption, defaultValue) !== defaultValue) {
514
+
515
+ return !defaultValue;
516
+ }
517
+
518
+ // Return the default.
519
+ return defaultValue;
520
+ };
521
+
522
+ // Return the scope level of a feature option.
523
+ const optionScope = (featureOption, deviceMac, defaultValue) => {
524
+
525
+ // Scope priority is always: device, NVR, global.
526
+
527
+ if(deviceMac) {
528
+
529
+ // Let's see if we've set it at the device-level.
530
+ if((isDeviceOptionEnabled(featureOption, deviceMac, defaultValue) !== defaultValue) || isOptionSet(featureOption, deviceMac)) {
531
+
532
+ return "device";
533
+ }
534
+
535
+ // Now let's test the controller level.
536
+ if((isDeviceOptionEnabled(featureOption, nvr.mac, defaultValue) !== defaultValue) || isOptionSet(featureOption, nvr.mac)) {
537
+
538
+ return "nvr";
539
+ }
540
+ }
541
+
542
+ // Finally, let's test the global level.
543
+ if((isGlobalOptionEnabled(featureOption, defaultValue) !== defaultValue) || isOptionSet(featureOption)) {
544
+
545
+ return "global";
546
+ }
547
+
548
+ // Option isn't set to a non-default value.
549
+ return "none";
550
+ };
551
+
552
+ // Return the color hinting for a given option's scope.
553
+ const optionScopeColor = (featureOption, deviceMac, defaultValue) => {
554
+
555
+ switch(optionScope(featureOption, deviceMac, defaultValue)) {
556
+
557
+ case "device":
558
+
559
+ return "text-info";
560
+ break;
561
+
562
+ case "nvr":
563
+
564
+ return "text-success";
565
+ break;
566
+
567
+ case "global":
568
+
569
+ return deviceMac ? "text-warning" : "text-info";
570
+ break;
571
+
572
+ default:
573
+
574
+ break;
575
+ }
576
+
577
+ return null;
578
+ };
579
+
463
580
  // Show feature option information for a specific device, controller, or globally.
464
581
  const showDeviceInfo = async (deviceId) => {
465
582
 
@@ -474,6 +591,10 @@
474
591
  // Ensure we have a controller or device. The only time this won't be the case is when we're looking at global options.
475
592
  if(ufpDevice) {
476
593
 
594
+ document.getElementById("deviceStatsHeader").style.display = "";
595
+ document.getElementById("device_model").classList.remove("text-center");
596
+ document.getElementById("device_model").colSpan = 1;
597
+ document.getElementById("device_model").style.fontWeight = "normal";
477
598
  document.getElementById("device_model").innerHTML = ufpDevice.marketName ?? ufpDevice.type;
478
599
  document.getElementById("device_mac").innerHTML = ufpDevice.mac;
479
600
  document.getElementById("device_address").innerHTML = ufpDevice.host ?? (ufpDevice.modelKey === "sensor" ? "Bluetooth Device" : "None");
@@ -484,6 +605,10 @@
484
605
 
485
606
  document.getElementById("deviceStatsTable").style.display = "none";
486
607
 
608
+ document.getElementById("deviceStatsHeader").style.display = "";
609
+ document.getElementById("device_model").classList.remove("text-center");
610
+ document.getElementById("device_model").colSpan = 1;
611
+ document.getElementById("device_model").style.fontWeight = "normal";
487
612
  document.getElementById("device_model").innerHTML = "N/A"
488
613
  document.getElementById("device_mac").innerHTML = "N/A";
489
614
  document.getElementById("device_address").innerHTML = "N/A";
@@ -532,6 +657,7 @@
532
657
  // Finally, add the table head to the table.
533
658
  optionTable.appendChild(thead);
534
659
 
660
+ // Now enumerate all the feature options for a given device.
535
661
  for(const option of optionsDevice[category.name]) {
536
662
 
537
663
  // Only show feature options that are valid for this device.
@@ -553,24 +679,126 @@
553
679
 
554
680
  const featureOption = category.name + (option.name.length ? ("." + option.name): "");
555
681
  checkbox.type = "checkbox";
682
+ checkbox.readOnly = false;
556
683
  checkbox.id = featureOption;
557
684
  checkbox.name = featureOption;
558
685
  checkbox.value = featureOption + (!ufpDevice ? "" : ("." + ufpDevice.mac));
559
- checkbox.checked = !ufpDevice ? isGlobalOptionEnabled(featureOption, option.default) :
560
- isDeviceOptionEnabled(featureOption, ufpDevice.mac, option.default);
686
+
687
+ switch(optionScope(featureOption, ufpDevice?.mac, option.default)) {
688
+
689
+ case "global":
690
+ case "nvr":
691
+
692
+ // If we're looking at the global scope, show the option value. Otherwise, we show that we're inheriting a value from the scope above.
693
+ if(!ufpDevice) {
694
+
695
+ checkbox.checked = isGlobalOptionEnabled(featureOption, option.default);
696
+
697
+ if(checkbox.checked) {
698
+
699
+ checkbox.indeterminate = false;
700
+ }
701
+
702
+ } else {
703
+
704
+ checkbox.readOnly = checkbox.indeterminate = true;
705
+ }
706
+
707
+ break;
708
+
709
+ case "device":
710
+ case "none":
711
+ default:
712
+ checkbox.checked = isDeviceOptionEnabled(featureOption, ufpDevice?.mac, option.default);
713
+ break;
714
+ }
561
715
 
562
716
  checkbox.defaultChecked = option.default;
563
717
  checkbox.classList.add("mx-2");
564
718
 
719
+ // Add the checkbox to the table cell.
720
+ tdCheckbox.appendChild(checkbox);
721
+
722
+ // Add the checkbox to the table row.
723
+ trX.appendChild(tdCheckbox);
724
+
725
+ const tdLabel = document.createElement("td");
726
+ tdLabel.classList.add("w-100");
727
+
728
+ // Create a label for the checkbox with our option description.
729
+ const labelDescription = document.createElement("label");
730
+ labelDescription.for = checkbox.id;
731
+ labelDescription.style.cursor = "pointer";
732
+ labelDescription.classList.add("user-select-none", "my-0", "py-0");
733
+
734
+ // Highlight options for the user that are different than our defaults.
735
+ const scopeColor = optionScopeColor(featureOption, ufpDevice?.mac, option.default);
736
+
737
+ if(scopeColor) {
738
+
739
+ labelDescription.classList.add(scopeColor);
740
+ }
741
+
565
742
  // Add or remove the setting from our configuration when we've changed our state.
566
743
  checkbox.addEventListener("change", async () => {
567
744
 
568
745
  // Find the option in our list and delete it if it exists.
569
- const optionRegex = new RegExp("^(Enable|Disable)\." + checkbox.id + (!ufpDevice ? "" : ("\." + ufpDevice.mac)) + "$", "gi")
746
+ const optionRegex = new RegExp("^(Enable|Disable)\." + checkbox.id + (!ufpDevice ? "" : ("\." + ufpDevice.mac)) + "$", "gi");
570
747
  const newOptions = configOptions.filter(x => !x.match(optionRegex));
571
748
 
572
- // The setting is different from the default, highlight it for the user, and add it to our configuration.
573
- if(checkbox.checked !== option.default) {
749
+ // Figure out if we've got the option set upstream.
750
+ let upstreamOption = false;
751
+
752
+ // We explicitly want to check for the scope of the feature option above where we are now, so we can appropriately determine what we should show.
753
+ switch(optionScope(checkbox.id, (ufpDevice && (ufpDevice.mac !== nvr.mac)) ? nvr.mac : null, option.default)) {
754
+
755
+ case "device":
756
+ case "nvr":
757
+
758
+ if(ufpDevice.mac !== nvr.mac) {
759
+
760
+ upstreamOption = true;
761
+ }
762
+
763
+ break;
764
+
765
+ case "global":
766
+
767
+ if(ufpDevice) {
768
+
769
+ upstreamOption = true;
770
+ }
771
+
772
+ break;
773
+
774
+ default:
775
+
776
+ break;
777
+ }
778
+
779
+ if(checkbox.readOnly) {
780
+
781
+ // We're truly unchecked. We need this because a checkbox can be in both an unchecked and indeterminate simultaneously,
782
+ // so we use the readOnly property to let us know that we've just cycled from an indeterminate state.
783
+ checkbox.checked = checkbox.readOnly = false;
784
+ } else if(!checkbox.checked) {
785
+
786
+ // If we have an upstream option configured, we reveal a third state to show inheritance of that option and allow the user to select it.
787
+ if(upstreamOption) {
788
+
789
+ // We want to set the readOnly property as well, since it will survive a user interaction when they click the checkbox to clear out the
790
+ // indeterminate state. This allows us to effectively cycle between three states.
791
+ checkbox.readOnly = checkbox.indeterminate = true;
792
+ }
793
+
794
+ } else if(checkbox.checked) {
795
+
796
+ // We've explicitly checked this option.
797
+ checkbox.readOnly = checkbox.indeterminate = false;
798
+ }
799
+
800
+ // The setting is different from the default, highlight it for the user, accounting for upstream scope, and add it to our configuration.
801
+ if(!checkbox.indeterminate && ((checkbox.checked !== option.default) || upstreamOption)) {
574
802
 
575
803
  labelDescription.classList.add("text-info");
576
804
  newOptions.push((checkbox.checked ? "Enable." : "Disable.") + checkbox.value);
@@ -584,28 +812,18 @@
584
812
  currentConfig[0].options = newOptions;
585
813
  updateConfigOptions(newOptions);
586
814
  await homebridge.updatePluginConfig(currentConfig);
587
- });
588
815
 
589
- // Add the checkbox to the table cell.
590
- tdCheckbox.appendChild(checkbox);
816
+ // If we've reset to defaults, make sure our color coding for scope is reflected.
817
+ if((checkbox.checked === option.default) || checkbox.indeterminate) {
591
818
 
592
- // Add the checkbox to the table row.
593
- trX.appendChild(tdCheckbox);
819
+ const scopeColor = optionScopeColor(featureOption, ufpDevice?.mac, option.default);
594
820
 
595
- const tdLabel = document.createElement("td");
596
- tdLabel.classList.add("w-100");
821
+ if(scopeColor) {
597
822
 
598
- // Create a label for the checkbox with our option description.
599
- const labelDescription = document.createElement("label");
600
- labelDescription.for = checkbox.id;
601
- labelDescription.style.cursor = "pointer";
602
- labelDescription.classList.add("user-select-none", "my-0", "py-0");
603
-
604
- // Highlight options for the user that are different than our defaults.
605
- if(checkbox.checked !== checkbox.defaultChecked) {
606
-
607
- labelDescription.classList.add("text-info");
608
- }
823
+ labelDescription.classList.add(scopeColor);
824
+ }
825
+ }
826
+ });
609
827
 
610
828
  // Add the actual description for the option after the checkbox.
611
829
  labelDescription.appendChild(document.createTextNode(option.description));
@@ -50,40 +50,40 @@ class PluginUiServer extends HomebridgePluginUiServer {
50
50
 
51
51
  bootstrap.cameras.sort((a, b) => {
52
52
 
53
- const aCase = a.name.toLowerCase();
54
- const bCase = b.name.toLowerCase();
53
+ const aCase = (a.name ?? a.marketName).toLowerCase();
54
+ const bCase = (b.name ?? b.marketName).toLowerCase();
55
55
 
56
56
  return aCase > bCase ? 1 : (bCase > aCase ? -1 : 0);
57
57
  });
58
58
 
59
59
  bootstrap.chimes.sort((a, b) => {
60
60
 
61
- const aCase = a.name.toLowerCase();
62
- const bCase = b.name.toLowerCase();
61
+ const aCase = (a.name ?? a.marketName).toLowerCase();
62
+ const bCase = (b.name ?? b.marketName).toLowerCase();
63
63
 
64
64
  return aCase > bCase ? 1 : (bCase > aCase ? -1 : 0);
65
65
  });
66
66
 
67
67
  bootstrap.lights.sort((a, b) => {
68
68
 
69
- const aCase = a.name.toLowerCase();
70
- const bCase = b.name.toLowerCase();
69
+ const aCase = (a.name ?? a.marketName).toLowerCase();
70
+ const bCase = (b.name ?? b.marketName).toLowerCase();
71
71
 
72
72
  return aCase > bCase ? 1 : (bCase > aCase ? -1 : 0);
73
73
  });
74
74
 
75
75
  bootstrap.sensors.sort((a, b) => {
76
76
 
77
- const aCase = a.name.toLowerCase();
78
- const bCase = b.name.toLowerCase();
77
+ const aCase = (a.name ?? a.marketName).toLowerCase();
78
+ const bCase = (b.name ?? b.marketName).toLowerCase();
79
79
 
80
80
  return aCase > bCase ? 1 : (bCase > aCase ? -1 : 0);
81
81
  });
82
82
 
83
83
  bootstrap.viewers.sort((a, b) => {
84
84
 
85
- const aCase = a.name.toLowerCase();
86
- const bCase = b.name.toLowerCase();
85
+ const aCase = (a.name ?? a.marketName).toLowerCase();
86
+ const bCase = (b.name ?? b.marketName).toLowerCase();
87
87
 
88
88
  return aCase > bCase ? 1 : (bCase > aCase ? -1 : 0);
89
89
  });
@@ -129,6 +129,7 @@ class PluginUiServer extends HomebridgePluginUiServer {
129
129
 
130
130
  // Compatibility for older versions of the Homebridge UI.
131
131
  this.onRequest("/getCachedAccessories", async () => {
132
+
132
133
  try {
133
134
 
134
135
  // Define the plugin and create the array to return.
@@ -159,6 +160,7 @@ class PluginUiServer extends HomebridgePluginUiServer {
159
160
  return [];
160
161
  }
161
162
  });
163
+
162
164
  this.ready();
163
165
  }
164
166
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-unifi-protect",
3
- "version": "6.6.0",
3
+ "version": "6.8.1",
4
4
  "displayName": "Homebridge UniFi Protect",
5
5
  "description": "Homebridge UniFi Protect plugin providing complete HomeKit integration for the UniFi Protect ecosystem with full support for most features including autoconfiguration, motion detection, multiple controllers, and realtime updates.",
6
6
  "author": {
@@ -68,18 +68,18 @@
68
68
  "@homebridge/plugin-ui-utils": "^0.0.19",
69
69
  "ffmpeg-for-homebridge": "^0.1.4",
70
70
  "mqtt": "4.3.7",
71
- "unifi-protect": "^4.2.3",
71
+ "unifi-protect": "^4.2.4",
72
72
  "ws": "^8.13.0"
73
73
  },
74
74
  "devDependencies": {
75
- "@types/node": "^20.1.4",
75
+ "@types/node": "^20.2.5",
76
76
  "@types/ws": "^8.5.4",
77
- "@typescript-eslint/eslint-plugin": "^5.59.5",
78
- "@typescript-eslint/parser": "^5.59.5",
79
- "eslint": "^8.40.0",
77
+ "@typescript-eslint/eslint-plugin": "^5.59.8",
78
+ "@typescript-eslint/parser": "^5.59.8",
79
+ "eslint": "^8.42.0",
80
80
  "homebridge": "=1.6.1",
81
81
  "nodemon": "^2.0.22",
82
- "rimraf": "^5.0.0",
83
- "typescript": "^5.0.4"
82
+ "rimraf": "^5.0.1",
83
+ "typescript": "^5.1.3"
84
84
  }
85
85
  }