Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions ios/HouseScan/Contract/ResultPresentation+Reading.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,26 @@ import HouseScanKit
// answer means the engine's results and the UI demo's samples go through the same rules.

extension ResultPresentation {
/// This answer for a wall neither side of which was walked (`wallNotMeasured`, #76): the
/// server still placed a spot on the tapped wall line, but nothing confirmed that line, so
/// the spot, the nearest spot, the cable route, the clearances and the checks and requests
/// made at them are dropped. The rules' notice and hash stay.
func withWallNotMeasured() -> ResultPresentation {
var shown = self
shown.wallNotMeasured = true
shown.summary = ""
shown.spot = nil
shown.nearestSpot = nil
shown.nearestFailingCheck = nil
shown.cableRoute = []
shown.cableLength = nil
shown.checks = []
shown.clearances = []
shown.missing = []
shown.unseenEnd = nil
return shown
}

/// True when there is a spot and the meter working-space check at it, if the server ran one,
/// passed.
var spotIsClean: Bool {
Expand Down
18 changes: 18 additions & 0 deletions ios/HouseScan/Contract/ScanContract.swift
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,11 @@ struct ResultPresentation: Equatable, Sendable {
/// Where the scan stopped on a side it didn't finish, nearer the meter than the spot, so a
/// closer spot may lie past it (`PlacementResult.closerUnseenEnd`).
var unseenEnd: UnseenEnd? = nil
/// True when neither side of the meter was walked: "Can't get there" ended both before the
/// homeowner walked either (`WalkRefusals`, #76). The wall line then comes from the meter tap
/// alone, so the screen says the wall couldn't be measured and shows no spot, no route and no
/// "See it on your wall" (`withWallNotMeasured`).
var wallNotMeasured = false
/// True when no server answered and the result is the offline sample used by tests and
/// demos. The UI must say so on screen.
var isSample: Bool
Expand Down Expand Up @@ -808,6 +813,14 @@ final class ScanViewState {
/// offer (a question or a mark is up, both ends are marked, or the walk is doing something
/// else). "Wall ends here" shows only while it is set.
var endPreview: EndPreview?
/// "Can't get there" came again on a walk card within `WalkRefusals.repeatWindow` of the one
/// that last ended a side: the walk asks "End the scan here?" instead of ending this side too
/// (#82). Answered by `answerEndScan`.
var endScanQuestion = false
/// Set with `endScanQuestion` when ending the open sides now would leave ends closer than a
/// battery is wide (`WalkRefusals.endsTooClose`): "Done with this wall" would refuse them and
/// the walk would go on, so the question offers "Start over" instead of "Yes, end here".
var endScanTooShort = false
/// True after "Done with this wall" was refused because the ends were closer together than
/// `WallFrame.minWallLength`; the ends were cleared. False again once an end is marked.
var wallTooShort = false
Expand Down Expand Up @@ -906,6 +919,11 @@ protocol ScanActions: AnyObject {
/// Leave the walk for the feature review (allowed once both ends are marked). Ends closer
/// together than `WallFrame.minWallLength` are cleared instead, and `wallTooShort` is set.
func finishWalk()
/// The answer to `ScanViewState.endScanQuestion`. "Yes, end here" (`end`) ends every side
/// without an end where "Can't get there" would (`ScanViewState.endPreview`, unexplored) and
/// finishes the walk with what was walked, as `finishWalk` does. "Keep walking" dismisses the
/// question, and the next "Can't get there" ends its side without asking.
func answerEndScan(_ end: Bool)
/// Features confirmed; the engine runs the gap check, then uploads.
func confirmFeatures()
/// "I can't get there": the gap is recorded for installer review. On a request the finished
Expand Down
8 changes: 8 additions & 0 deletions ios/HouseScan/Runtime/Autopilot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,14 @@ final class Autopilot {
}
await pause(hold)
engine.cannotAccessArea()
if engine.state.endScanQuestion {
// Soon after the other side's: the walk asks whether to end the scan (#82). This
// homeowner walked both sides, so keeps walking and ends this side as before.
log("asked to end the scan on the \(side.rawValue); keeping walking")
await pause(hold)
engine.answerEndScan(false)
engine.cannotAccessArea()
}
let end = side == .left ? engine.coverage?.leftEnd : engine.coverage?.rightEnd
log("can't get there on the \(side.rawValue) with the phone at s=\(side.walk.sign * along(index)): end at s=\(end ?? .nan)")
}
Expand Down
50 changes: 48 additions & 2 deletions ios/HouseScan/Runtime/ScanEngine+Actions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -430,8 +430,22 @@ extension ScanEngine: ScanActions {
case .gapRequest:
skipCurrentGap()
case .wallWalk:
guard coverage != nil else { return }
guard coverage != nil, !state.endScanQuestion else { return }
let task = ScanEngine.name(state.guidance)
if isWalkTask, state.endQuestion == nil, state.marking == nil, !state.overheadQuestion,
state.nextWallConfirm == nil, walkRefusals.asksToEndScan(at: ScanEngine.refusalClock) {
// A second "Can't get there" on a walk card soon after the last ended a side:
// the homeowner may be trying to stop, so ask before ending this side too (#82).
// The task stays unresolved until the answer.
RuntimeLog.engine.info("cannot access area again during \(task, privacy: .public): asking to end the scan")
if let map = coverage {
// With nothing walked the ends would land too close to finish, and "Yes, end
// here" would only put the homeowner back on the walk.
state.endScanTooShort = WalkRefusals.endsTooClose(left: map.leftEnd ?? walkedEnd(.left), right: map.rightEnd ?? walkedEnd(.right))
}
state.endScanQuestion = true
return
}
switch state.guidance {
case .aimAtGround, .aimAtWall, .seeBehind, .walk, .markEnd, .tiltUp, .markNextWall:
resolveGuidance(.cannotReach)
Expand Down Expand Up @@ -473,6 +487,37 @@ extension ScanEngine: ScanActions {
}
}

func answerEndScan(_ end: Bool) {
guard state.phase == .wallWalk, state.endScanQuestion else { return }
state.endScanQuestion = false
state.endScanTooShort = false
guard end else {
RuntimeLog.engine.info("end the scan here? keep walking")
walkRefusals.keepWalking()
return
}
RuntimeLog.engine.info("end the scan here? yes")
// The walk task the second "Can't get there" answered is met as refused.
if isWalkTask { resolveGuidance(.cannotReach) }
// Each side without an end ends where "Can't get there" would put it (`WalkedEnd`).
for side in [WallSide.left, .right] where (side == .left ? coverage?.leftEnd : coverage?.rightEnd) == nil {
endWalkCannotGoOn(side)
}
walkRefusals.keepWalking()
// Ends closer than a battery is wide are refused here as after "Done with this wall",
// and the walk goes on with the card saying so.
finishWalk()
}

/// The walk asks to walk a side or to mark its end: the steps whose "Can't get there" ends
/// the wall on that side (`endWalkCannotGoOn`).
private var isWalkTask: Bool {
switch state.guidance {
case .walk, .markEnd: true
default: false
}
}

func retryUpload() {
guard state.phase == .uploading else { return }
if case .failed = state.upload { startUpload() }
Expand All @@ -497,7 +542,8 @@ extension ScanEngine: ScanActions {
}

func showAR() {
guard state.phase == .result, state.spatialResultAvailable else { return }
// A wall neither side of which was walked has no spot to show (#76).
guard state.phase == .result, state.spatialResultAvailable, state.result?.wallNotMeasured != true else { return }
go(.resultAR)
}

Expand Down
14 changes: 12 additions & 2 deletions ios/HouseScan/Runtime/ScanEngine+Ends.swift
Original file line number Diff line number Diff line change
Expand Up @@ -115,13 +115,23 @@ extension ScanEngine {
}

/// "Can't get there" while the walk asks to walk `side` or to mark its end: the end goes where
/// the preview showed, as an unexplored end.
/// the preview showed, as an unexplored end. `walkRefusals` notes when, and whether the side
/// had been walked (#82, #76).
func endWalkCannotGoOn(_ side: WallSide) {
guard let s = walkedEnd(side) else { return }
guard let s = walkedEnd(side), let map = coverage else { return }
logEnd("can't get there", side: side, at: s)
let walked = map.walkedFarthest(side.walk)
setEnd(side, at: s, kind: .unexplored)
walkRefusals.ended(side.walk, at: s, walked: walked, time: ScanEngine.refusalClock)
if walkRefusals.wasRefused(side.walk, end: s) {
RuntimeLog.engine.info("the \(side.rawValue, privacy: .public) side ended before it was walked (\(walked) m walked, end at s=\(s))")
}
}

/// Seconds for `WalkRefusals`: the time since boot, which a replay's frame clock doesn't
/// hold back while its playback is paused.
static var refusalClock: TimeInterval { ProcessInfo.processInfo.systemUptime }

/// Where the end went, and why (#71): the phone's place, the kept-photo cap, whether the cap
/// decided it, tracking, how many meters of cells the strip showed past the end, and what the
/// old rule, the unbroken covered reach, would have said.
Expand Down
7 changes: 6 additions & 1 deletion ios/HouseScan/Runtime/ScanEngine+Export.swift
Original file line number Diff line number Diff line change
Expand Up @@ -193,10 +193,15 @@ extension ScanEngine {
// its span (and a facing item its out_ft, which a walk can reach), a past_end item its
// side. Otherwise the button would do nothing. A request the homeowner already skipped
// or answered with something overhead stays with the installer.
return Self.presentation(of: result, isSample: isSample, wall: sceneWall) { [self, gapPlanner, coverage, skippedGaps] item in
let shown = Self.presentation(of: result, isSample: isSample, wall: sceneWall) { [self, gapPlanner, coverage, skippedGaps] item in
gapPlanner.plan(for: item, leftEnd: coverage?.leftEnd, rightEnd: coverage?.rightEnd, limitEnds: coverage?.limitEnds ?? [])
.map { plan in !skippedGaps.contains { plan.asksForSameView(as: $0) } && captureCanSettle(plan) } ?? false
}
// "Can't get there" ended both sides before either was walked (#76): the server placed
// the spot on the meter tap's wall line alone, which nothing confirmed.
guard walkRefusals.neitherSideWalked(leftEnd: coverage?.leftEnd, rightEnd: coverage?.rightEnd) else { return shown }
RuntimeLog.engine.info("result: neither side of the meter was walked; showing no spot")
return shown.withWallNotMeasured()
}

/// `presentation(of:isSample:)` without an engine, for the UI demo: `wall` is the scan's wall
Expand Down
9 changes: 9 additions & 0 deletions ios/HouseScan/Runtime/ScanEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ final class ScanEngine {
didSet { if nextWallSide == nil { pendingNextWall = nil } }
}
var nextWallRefusal: NextWallRefusal?
/// "Can't get there" on the walk's card: when it last ended a side, and which ends it set
/// before the homeowner walked that side (#82, #76). Reset with the wall.
var walkRefusals = WalkRefusals()
/// The wall marked as the next one, waiting for "Is this the next wall?" (#70); cleared with
/// `nextWallSide`. Published as `ScanViewState.nextWallConfirm`.
var pendingNextWall: PendingNextWall? {
Expand Down Expand Up @@ -274,6 +277,9 @@ final class ScanEngine {
breakWalkedPath(because: "the walk paused (\(state.phase.rawValue) -> \(phase.rawValue))")
}
if state.phase == .resultAR { hideResultInCamera() }
// "End the scan here?" belongs to the walk it was asked on.
state.endScanQuestion = false
state.endScanTooShort = false
let previous = state.phase
state.phase = phase
RuntimeLog.state.info("STATE=\(phase.rawValue, privacy: .public)")
Expand Down Expand Up @@ -1413,6 +1419,7 @@ final class ScanEngine {
state.wallTooShort = false
nextWallSide = nil
nextWallRefusal = nil
walkRefusals = WalkRefusals()
resetTiltUp()
resetSpotChecks()
// An answer describes a scan that no longer exists; the next upload brings a new one.
Expand Down Expand Up @@ -1448,6 +1455,7 @@ final class ScanEngine {
state.wallTooShort = false
nextWallSide = nil
nextWallRefusal = nil
walkRefusals = WalkRefusals()
resetTiltUp()
publishWall()
publishCoverage()
Expand Down Expand Up @@ -1969,6 +1977,7 @@ final class ScanEngine {
state.wallTooShort = false
nextWallSide = nil
nextWallRefusal = nil
walkRefusals = WalkRefusals()
resetTiltUp()
resetSpotChecks()
placement = nil
Expand Down
22 changes: 22 additions & 0 deletions ios/HouseScan/UI/Copy/ScanCopy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,22 @@ enum ScanCopy {
return Instruction(title: "What's at the \(side.rawValue) end?", detail: detail)
}

/// Asked when "Can't get there" comes again soon after it ended a side
/// (`ScanViewState.endScanQuestion`, #82): the homeowner may be trying to stop.
static let endScanQuestion = Instruction(
title: "End the scan here?",
detail: "We'll check the part of the wall you've walked."
)
/// The same question when too little of the wall was walked to check it
/// (`ScanViewState.endScanTooShort`): the way out is a new scan.
static let endScanTooShort = Instruction(
title: "End the scan here?",
detail: "You haven't walked enough of the wall to check it yet. Start over, or keep walking along the wall."
)
static let endScanYes = "Yes, end here"
static let endScanStartOver = "Start over"
static let keepWalking = "Keep walking"

/// With no server connected nothing is sent, and the words must not say it is.
/// `followUps` is how many views the finished check still wants from the camera.
static func upload(_ upload: UploadState, sample: Bool, followUps: Int = 0) -> Instruction {
Expand Down Expand Up @@ -576,6 +592,12 @@ enum ScanCopy {
static let scanAnotherWall = "Scan another wall"
static let details = "Details"

/// A result for a wall neither side of which was walked (`ResultPresentation.wallNotMeasured`,
/// #76): the spot would stand on the meter tap alone, so none is shown.
static let wallNotMeasured = "We couldn't measure your wall"
static let wallNotMeasuredDetail = "The scan stopped before you walked along the wall on either side of your meter, so we can't tell where a battery would fit. Scan again and walk a few steps each way."
static let scanAgain = "Scan again"

static let installerConfirms = "An installer confirms this on site."
static let rulesNotFinal = "The placement rules aren't final yet, so an installer reviews every result for now."
// The server's result covers where the battery goes, not the panel itself.
Expand Down
26 changes: 26 additions & 0 deletions ios/HouseScan/UI/Preview/DemoEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ final class DemoEngine: ScanActions {
private let passResult: Bool
/// `-uiDemoOverlap`: the sample's spot overlaps the meter's working space (#40).
private let overlapResult: Bool
/// `-uiDemoWallNotMeasured`: neither side of the meter was walked, so the result shows no
/// spot (#76).
private let wallNotMeasured: Bool
/// `-uiDemoResultFile <path>` (debug builds only): a server answer in a JSON file, read
/// through the engine's own mapping, in place of the hand-made samples.
private let resultFile: String?
Expand Down Expand Up @@ -66,6 +69,7 @@ final class DemoEngine: ScanActions {
offline = arguments.contains("-uiDemoOffline")
passResult = arguments.contains("-uiDemoPass")
overlapResult = arguments.contains("-uiDemoOverlap")
wallNotMeasured = arguments.contains("-uiDemoWallNotMeasured")
#if DEBUG
resultFile = value("-uiDemoResultFile")
#else
Expand Down Expand Up @@ -119,6 +123,12 @@ final class DemoEngine: ScanActions {
state.endQuestion = .left
}
}
if arguments.contains("-uiDemoEndScanQuestion") {
// "Can't get there" again soon after it ended the right side (#82).
state.endScanQuestion = true
// `-uiDemoEndScanTooShort`: too little walked to finish, so it offers "Start over".
state.endScanTooShort = arguments.contains("-uiDemoEndScanTooShort")
}
if arguments.contains("-uiDemoNextWall") {
state.wall?.rightEnd = demoRightEnd
reachedRight = demoRightEnd
Expand Down Expand Up @@ -553,6 +563,7 @@ final class DemoEngine: ScanActions {

/// The check's answer, before or after its follow-up view.
private var sample: ResultPresentation {
if wallNotMeasured { return Self.reviewSample.withWallNotMeasured() }
if passResult { return Self.passSample }
if overlapResult { return Self.overlapSample }
if let fileResult { return fileResult }
Expand Down Expand Up @@ -909,6 +920,18 @@ final class DemoEngine: ScanActions {
state.phase = .markFeatures
}

/// The demo never asks on its own (its "Can't get there" ends the side at once); the question
/// shows only with `-uiDemoEndScanQuestion`. "Yes, end here" finishes the walk with the demo's
/// ends.
func answerEndScan(_ end: Bool) {
guard state.endScanQuestion else { return }
state.endScanQuestion = false
state.endScanTooShort = false
guard end else { return }
finishedWalkState()
finishWalk()
}

func confirmFeatures() {
enterGap()
}
Expand Down Expand Up @@ -980,6 +1003,7 @@ final class DemoEngine: ScanActions {
}

func showAR() {
guard state.result?.wallNotMeasured != true else { return }
state.phase = .resultAR
}

Expand Down Expand Up @@ -1016,6 +1040,8 @@ final class DemoEngine: ScanActions {
tiltUpSettled = false
tiltUpTicks = 0
state.overheadQuestion = false
state.endScanQuestion = false
state.endScanTooShort = false
state.groundAnswer = nil
}

Expand Down
Loading
Loading