diff --git a/src/pygui/ISC/HomePage.qml b/src/pygui/ISC/HomePage.qml
index 302b877..0570a72 100644
--- a/src/pygui/ISC/HomePage.qml
+++ b/src/pygui/ISC/HomePage.qml
@@ -33,6 +33,9 @@ Rectangle {
property string dataCompareFileB: ""
onSelectedTabIndexChanged: {
+ // Time-based, so re-check on every switch rather than trusting a
+ // value computed at some earlier point in a possibly long-running session.
+ root.reviewBlocked = licenseModel.reviewAccessBlocked()
if (selectedTabIndex === 2) {
file_browser.refreshFiles();
if (root.mapReviewFile !== "") {
@@ -68,6 +71,9 @@ Rectangle {
if (root.selectedTabIndex === 2) root.mapReviewFile = streamController.loadedFile
else if (root.selectedTabIndex === 3) root.graphReviewFile = streamController.loadedFile
}
+ function onLiveError(message) {
+ deviceController.appendActivityLog("Live stream error — stream stopped: " + message)
+ }
}
// Data tab's Run A/B picks survive it being destroyed/recreated by its
@@ -84,6 +90,24 @@ Rectangle {
property bool waferDetected: false
property bool waferLicensed: false
+ // Data/Graph/Map-review are gated on the 30-day trial once it's actually
+ // expired (ADR-0005 "Deferred" item — the trigger existed, nothing ever
+ // checked it). Status stays free: hardware actions are gated separately,
+ // per-wafer-serial, via waferLicensed above.
+ property bool reviewBlocked: false
+ onReviewBlockedChanged: {
+ // Property change signals only fire on an actual value change, so
+ // this logs once per none/temporary → expired transition, not on
+ // every recompute in onSelectedTabIndexChanged/onLicensesChanged.
+ if (reviewBlocked) {
+ deviceController.appendActivityLog(
+ "Review trial expired — load a wafer license to continue using Data/Map/Graph (About dialog)")
+ if (root.selectedTabIndex !== 0) {
+ root.selectedTabIndex = 0
+ }
+ }
+ }
+
Connections {
target: deviceController
function onDetectResult(result){
@@ -124,6 +148,7 @@ Rectangle {
root.selectedTabIndex = 0
}
root.waferLicensed = root.waferDetected && deviceController.waferLicensed()
+ root.reviewBlocked = licenseModel.reviewAccessBlocked()
}
}
Connections {
@@ -133,9 +158,9 @@ Rectangle {
result !== undefined &&
result.success === true)
if (root.memoryRead) {
- console.log("[P1.1] Memory read complete:", result.bytes, "bytes")
+ console.log("Memory read complete:", result.bytes, "bytes")
} else {
- console.log("[P1.1] Read failed:", result.error || "unknown")
+ console.log("Read failed:", result.error || "unknown")
}
}
}
@@ -271,6 +296,7 @@ Rectangle {
}
}
Component.onCompleted: {
+ root.reviewBlocked = licenseModel.reviewAccessBlocked()
if (isFirstLaunch) {
firstLaunchWizard.open()
}
@@ -342,10 +368,13 @@ Rectangle {
id: pillBtn
checked: root.selectedTabIndex === index
// MAP (index 2) is locked until a valid license .bin is installed.
+ // DATA/MAP/GRAPH (index !== 0) also lock once the 30-day review
+ // trial has actually expired with no permanent license (ADR-0005).
// Stays enabled (not `enabled: false`) so hover still fires and the
// tooltip can explain why — a disabled Item receives no mouse/hover
// events at all, which would silently kill the tooltip below.
- property bool locked: index === 2 && licenseModel.licenses.length === 0
+ property bool locked: (index === 2 && licenseModel.licenses.length === 0)
+ || (index !== 0 && root.reviewBlocked)
flat: true
Layout.fillWidth: true
Layout.fillHeight: true
@@ -411,9 +440,15 @@ Rectangle {
AppToolTip {
visible: pillBtn.hovered
- text: (index === 2 && licenseModel.licenses.length === 0)
- ? "MAP: No license loaded — load one in About to unlock"
- : "" + model.label + ": " + model.desc
+ // Trial-expired is checked first: it's the broader reason and
+ // applies identically to Data/Map/Graph, so when it's true it
+ // wins even for MAP — otherwise MAP's narrower "no license at
+ // all" case (trial not yet expired, still just unlicensed) shows.
+ text: (index !== 0 && root.reviewBlocked)
+ ? "" + model.label + ": Review trial expired — load a license in About to unlock"
+ : (index === 2 && licenseModel.licenses.length === 0)
+ ? "MAP: No license loaded — load one in About to unlock"
+ : "" + model.label + ": " + model.desc
}
onClicked: if (!pillBtn.locked) root.selectedTabIndex = index
diff --git a/src/pygui/backend/controllers/device_controller.py b/src/pygui/backend/controllers/device_controller.py
index 31ce017..22a98d0 100644
--- a/src/pygui/backend/controllers/device_controller.py
+++ b/src/pygui/backend/controllers/device_controller.py
@@ -303,6 +303,13 @@ class DeviceController(QObject):
self._activity_log = self._activity_log[-200:]
self.activityLogUpdated.emit("\n".join(self._activity_log))
+ @Slot(str)
+ @slot_error_boundary
+ def appendActivityLog(self, message: str) -> None:
+ """Public entry point for QML-side conditions (e.g. license/trial
+ state) to log into the same Activity Log as backend-triggered lines."""
+ self._append_log(message)
+
@Slot()
@slot_error_boundary
def clearActivityLog(self) -> None:
diff --git a/tests/test_license.py b/tests/test_license.py
index 2908cd4..0c8e597 100644
--- a/tests/test_license.py
+++ b/tests/test_license.py
@@ -267,3 +267,33 @@ def test_replay_trial_expiry(tmp_path, qapp):
def test_replay_trial_expired_without_marker(tmp_path, qapp):
model = LicenseModel(str(tmp_path))
assert model.replayTrialExpired() is True
+
+
+# ===== Review-access gate (ADR-0005 "Deferred" item) =====
+def test_review_access_not_blocked_before_trial_starts(tmp_path, qapp):
+ model = LicenseModel(str(tmp_path))
+ assert model.reviewAccessBlocked() is False
+
+
+def test_review_access_not_blocked_during_fresh_trial(tmp_path, qapp):
+ model = LicenseModel(str(tmp_path))
+ model.startReplayTrial()
+ assert model.reviewAccessBlocked() is False
+
+
+def test_review_access_blocked_once_trial_expired(tmp_path, qapp):
+ model = LicenseModel(str(tmp_path))
+ model.startReplayTrial()
+ old = time.time() - 31 * 86400
+ os.utime(model._trial_marker, (old, old))
+ assert model.reviewAccessBlocked() is True
+
+
+def test_review_access_not_blocked_with_permanent_license_even_if_trial_expired(tmp_path, qapp):
+ (tmp_path / "licenses").mkdir()
+ write_license(tmp_path / "licenses", level="01")
+ model = LicenseModel(str(tmp_path))
+ model.startReplayTrial()
+ old = time.time() - 31 * 86400
+ os.utime(model._trial_marker, (old, old))
+ assert model.reviewAccessBlocked() is False