One-command installer + guided setup: role detection, self-flying sign test v2

- install.lua: single entry point -- detects role from peripherals
  (flight sensors = autopilot, monitor = cockpit), installs, cleans
  legacy/removed files, then hands off straight into setup/standby.
  Ship state (sides/calib/target/settings) is never shipped; existing
  profiles can be kept or reset on re-install.
- Sign test v2: fully self-driving -- staircase power search (learns
  hover), closed-loop climb, both-direction verification with auto-
  escalating nudge, controlled descent. Never freefalls, also not on
  Ctrl+T or abort.
- Setup wizard (ap setup): wiring pulse-walk + sign test in one guided
  flow; offered automatically on first boot without a profile.
- New launcher 'ap' (stabilizeV2 kept as case-safe identical aliases;
  removes Windows-host case-collision recursion risk).
- build.sh: repo is source of truth; LC_ALL=C sorted manifests;
  optional --from-world/--to-world dev sync with exclude lists.
- Removed legacy v1 files and per-ship state from the repo/manifest
  (26 -> 17 files in the autopilot role).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 23:21:54 +02:00
parent 71e750bbe0
commit 54963e5b42
24 changed files with 617 additions and 1135 deletions

11
.gitignore vendored
View File

@@ -1,3 +1,14 @@
# CC autopilot repo
*.log
*.old
.DS_Store
# per-skip tilstand: maales av setup-wizarden paa skipet, hoerer ALDRI hjemme i repoet
# (shipper vi den, arver et nytt skip forrige skips wiring/pitch-fortegn -> feil vei)
files/*/calib.txt
files/*/sides.txt
files/*/target.txt
files/*/settings.txt
files/*/scan.txt
files/*/pid_gains.txt
files/*/.installed_*

View File

@@ -3,48 +3,80 @@
Altitude/pitch autopilot for a hot-air airship, delivered as an in-game installer
so it runs on any Minecraft server regardless of the computer's id.
## Install on a server
## Install — ONE command, both PCs
On the CC computer, run one command. Pick the role for that machine:
Run the **same** command on each CC computer:
```
-- machine-room PC (full autopilot, boots to standby):
wget run https://gt.zyon.no/Stian/airship-autopilot/raw/branch/main/install.lua autopilot
-- pilot-house PC (control console):
wget run https://gt.zyon.no/Stian/airship-autopilot/raw/branch/main/install.lua cockpit
wget run https://gt.zyon.no/Stian/airship-autopilot/raw/branch/main/install.lua
```
Omit the role to get a menu. Re-run any time to update to the latest code.
The installer detects which PC it is on (flight sensors → `autopilot`,
monitor → `cockpit`), installs the right role, and continues **straight into
setup** — no reboot, no role names to remember. Re-run the same command any
time to update; it also cleans out files that no longer belong and asks
whether to keep or reset the ship profile.
(Advanced: append `autopilot` or `cockpit` to override detection.)
Requires HTTP enabled on the server (default on). If it is off, set
`[http] enabled = true` in `computercraft-server.toml` and restart the server.
## First-time setup (per ship)
The installer ships **code only** — everything ship-specific (which redstone
side drives which balloon, pitch sign, hover level) is measured on the ship by
the **setup wizard**, which the install flow takes you straight into:
1. **Wiring** — the wizard pulses each free side; you say which burner fired.
2. **Sign test** — the ship lifts off by itself, finds its own lift power,
pushes the front balloon, verifies the response in both directions
(auto-escalating the push if the response is weak), then descends and holds
hover. No config editing, no freefall. Takes 13 minutes.
After that the ship is ready: `FLY` from the menu or `ENGAGE` from the cockpit
console. Pitch and altitude keep self-learning in flight. The wizard can be
re-run any time: `ap setup`, press `S` in standby, or menu choice 1.
Balloon orientation does **not** need to match any particular side of the
computer: sensors are found by type, wiring is mapped by the wizard, and the
pitch sign is measured — the only physical requirement is both balloons on the
fore/aft line (the axis the gimbal reports as pitch).
## Roles
| Role | Source PC | Boots | Purpose |
|-------------|-----------|-----------------|------------------------------------------|
| `autopilot` | 0 | `stabilizeV2 standby` | v3 observer autopilot (13 modules in `autopilot/`) |
| `cockpit` | 4 | `cockpit` | pilot control console |
| Role | Boots | Purpose |
|-------------|----------------------|---------------------------------------------|
| `autopilot` | `ap boot` → wizard/standby | v3 observer autopilot (13 modules in `autopilot/`) |
| `cockpit` | `cockpit` | pilot control console (monitor + modem) |
Boot chain (autopilot): `startup.lua` -> `stabilizeV2` -> `autopilot/main.lua`.
Menu / wiring / sign-test: `Ctrl+T` then `stabilizeV2` for the menu.
Boot chain (autopilot): `startup.lua` `ap boot` `autopilot/main.lua`.
Menu any time: `Ctrl+T`, then `ap`. (`stabilizeV2` remains as an alias.)
Ship state lives on the computer only: `sides.txt`, `calib.txt`, `target.txt`,
`settings.txt`. Reset it via menu `p``R`, or when re-running the installer.
## Updating the code
The `files/` tree mirrors the live singleplayer world. After editing the Lua
locally, regenerate the manifests and push:
The repo is the source of truth for the installer. After editing the Lua:
```
./build.sh
./build.sh # regenerate manifests
git commit -am "sync autopilot"
git push
```
Then re-run the `wget run` command in-game to pull the update.
`build.sh` reads from the "Modda wii" world by default; pass a different
`computercraft/computer` path as arg 1 if the world moves.
Syncing with the singleplayer dev world (optional):
```
./build.sh --from-world # world -> files/ (code only; state/legacy filtered)
./build.sh --to-world # files/ -> world (code only; never touches state)
```
Default world path is the "Modda wii" save; pass a different
`computercraft/computer` path as arg 2 if the world moves.
## Notes

View File

@@ -1,39 +1,80 @@
#!/usr/bin/env bash
# build.sh -- mirror the live CC autopilot from the singleplayer world into this
# repo and regenerate the per-role manifests that install.lua consumes.
# build.sh -- regenerer per-rolle-manifester fra files/ (REPOET er kilden installeren serverer).
# Vanlig flyt etter kodeendring: ./build.sh && git commit -am "sync" && git push
#
# Run this whenever you change the Lua locally and want the server installer to
# pick it up: ./build.sh && git commit -am "sync" && git push
# Valgfri synk mot singleplayer-verdenen (dev-miljoeet):
# ./build.sh --from-world [computer-dir] # verden -> files/ (kun kode; state/legacy filtreres)
# ./build.sh --to-world [computer-dir] # files/ -> verden (kun kode; roerer aldri state)
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SRC_DEFAULT="/home/stian/.local/share/ModrinthApp/profiles/Modda wii/saves/New World/computercraft/computer"
SRC="${1:-$SRC_DEFAULT}"
WORLD_DEFAULT="/home/stian/.local/share/ModrinthApp/profiles/Modda wii/saves/New World/computercraft/computer"
# role name -> source computer id in the world
# rolle -> computer-id i verdenen
declare -A ROLE_SRC=(
[autopilot]=0 # maskinrom: startup -> stabilizeV2 -> autopilot/main
[autopilot]=0 # maskinrom: startup -> ap boot -> wizard/standby
[cockpit]=4 # pilot-hus: startup -> cockpit
)
if [ ! -d "$SRC" ]; then
echo "error: world computer dir not found: $SRC" >&2
echo "pass the path as arg 1 if the world moved." >&2
exit 1
fi
# per-skip tilstand + utdaterte verktoey: shippes ALDRI (installeren rydder dem ogsaa)
EXCLUDES=(calib.txt sides.txt target.txt settings.txt scan.txt pid_gains.txt
stabilize.lua diag.lua probe.lua scan.lua manifest)
# rolle-spesifikt: cockpit-koden hoerer ikke hjemme paa autopilot-PCen
declare -A ROLE_EXCLUDES=(
[autopilot]="cockpit.lua pilot_startup.lua"
[cockpit]=""
)
for role in "${!ROLE_SRC[@]}"; do
is_excluded() { # $1=rolle $2=relativ sti
local base x; base="$(basename "$2")"
case "$base" in *.log|*.old|.installed_*) return 0;; esac
for x in "${EXCLUDES[@]}"; do [ "$base" = "$x" ] && return 0; done
for x in ${ROLE_EXCLUDES[$1]}; do [ "$base" = "$x" ] && return 0; done
return 1
}
mode="${1:-manifests}"
world="${2:-$WORLD_DEFAULT}"
case "$mode" in
manifests) ;;
--from-world|--to-world)
if [ ! -d "$world" ]; then
echo "error: world computer dir not found: $world" >&2
echo "pass the path as arg 2 if the world moved." >&2
exit 1
fi
for role in "${!ROLE_SRC[@]}"; do
id="${ROLE_SRC[$role]}"
dst="$REPO/files/$role"
if [ ! -d "$SRC/$id" ]; then
echo "warn: computer $id (role $role) missing in world, skipping" >&2
src="$world/$id"; dst="$REPO/files/$role"
if [ "$mode" = "--from-world" ]; then from="$src"; to="$dst"; else from="$dst"; to="$src"; fi
if [ ! -d "$from" ]; then
echo "warn: source dir missing for role '$role' ($from), skipping" >&2
continue
fi
rm -rf "$dst"
cp -r "$SRC/$id" "$dst"
# Manifest = newline-separated relative paths, sorted, excluding the manifest itself.
( cd "$dst" && find . -type f ! -name manifest | sed 's|^\./||' | sort ) > "$dst/manifest"
echo "role '$role' <- computer $id : $(wc -l < "$dst/manifest") files"
done
while IFS= read -r rel; do
if ! is_excluded "$role" "$rel"; then
mkdir -p "$to/$(dirname "$rel")"
cp "$from/$rel" "$to/$rel"
fi
done < <(cd "$from" && find . -type f | sed 's|^\./||')
echo "synced role '$role' ($mode)"
done
;;
*)
echo "usage: ./build.sh [--from-world|--to-world] [computer-dir]" >&2
exit 1
;;
esac
echo "done. files/ and manifests regenerated."
# manifester: nylinje-separerte relative stier. LC_ALL=C sort er VIKTIG: locale-avhengig
# (case-insensitiv) sortering kan stokke om case-varianter og gi udefinert install-rekkefoelge.
for role in "${!ROLE_SRC[@]}"; do
dst="$REPO/files/$role"
[ -d "$dst" ] || continue
while IFS= read -r rel; do
is_excluded "$role" "$rel" || printf '%s\n' "$rel"
done < <(cd "$dst" && find . -type f ! -name manifest | sed 's|^\./||' | LC_ALL=C sort) > "$dst/manifest"
echo "role '$role': $(wc -l < "$dst/manifest") files"
done
echo "done. manifests regenerated."

10
files/autopilot/ap.lua Normal file
View File

@@ -0,0 +1,10 @@
-- ap.lua -- launcher for v3 observer-autopiloten i autopilot/.
-- ap -> meny
-- ap setup -> foerstegangs-wizard (wiring + sign test)
-- ap fly -> full lukket sloyfe
-- ap standby -> vent paa ENGAGE fra pilot-konsollen
-- ap boot -> oppstartsvalg (wizard hvis profil mangler, ellers standby)
-- ap monitor / manual / cal / observe / signtest / wiring
local args = { ... }
local unpack = table.unpack or unpack
shell.run("autopilot/main", unpack(args))

View File

@@ -235,6 +235,7 @@ function M.run(cfg, sensors, drive)
end
drive(calib.uHoverFront, calib.uHoverRear) -- hold hover ved utgang
calib.signMeasured = true -- fortegnet er maalt/bekreftet i flukt -> profilen er komplett
return calib
end

View File

@@ -108,12 +108,11 @@ return {
hoverTrim = 0.0, -- self-learned loft-trim (redstone, warm-start); fly-loopen oppdaterer
},
-- ============ SIGNTEST (§13.2) ============
-- ============ SIGNTEST (§13.2, selvgaaende) ============
-- Loft-nivaa SOEKES automatisk (trapp fra bunnen) og nudgen trappes opp ved svak respons --
-- ingen av disse skal trenge redigering for et nytt skip.
signtest = {
liftLevel = 5, -- felles nivaa for aa loefte skipet luftbaaret (i responsivt omraade)
airborneRise = 3.0, -- blokker stigning som regnes som luftbaaret
liftTimeout = 15.0, -- s maks paa loft-fasen
nudge = 2, -- differanse (front opp / bak ned) under maalingen
nudge = 2, -- START-differanse (front opp / bak ned); testen oeker selv ved svak respons
settleTime = 1.0, -- (legacy: brukt av full kalibrering 'cal' Stage 3)
nudgeTime = 3.0, -- (legacy: brukt av full kalibrering 'cal' Stage 3)
-- SETTLE-BASERT maaling: lagrer FOERST naar pitch-RATEN har roet seg, ikke paa fast timer.

View File

@@ -1,8 +1,11 @@
-- main.lua -- loop + tilstandsmaskin for v3 observer-autopiloten.
-- All UI-tekst er ENGELSK; kommentarer er norske (husstil).
-- Modus (argument eller meny):
-- boot oppstart: setup-wizard-prompt hvis profil mangler, ellers standby
-- setup foerstegangs-wizard: wiring + sign test i ett (alt skip-spesifikt)
-- standby lytter etter ENGAGE fra pilot-konsollen (default etter boot)
-- monitor Lag 1: les sensorer+estimator, logg+HUD, styrer INGENTING
-- signtest §13.2: nudge front -> anbefal pitchSign
-- signtest §13.2: selvgaaende -- loefter, maaler pitch-fortegn, lander igjen
-- manual Lag 2: faste nivaaer (piltaster), finn hover
-- cal Lag 3: full kalibrering (selv-laerende)
-- observe Lag 4: observer ÅPEN SLØYFE -- valider at v_hat sporer v_meas
@@ -87,6 +90,13 @@ local function loadSides()
end
local function saveSides(t) local f = fs.open(SIDES_FILE, "w"); f.write(textutils.serialise(t)); f.close() end
-- komplett skip-profil = wiring valgt + pitch-fortegn faktisk MAALT paa DETTE skipet
-- (signMeasured settes av sign test / full kalibrering -- aldri av en shipped default)
local function profileComplete()
if not (fs.exists(SIDES_FILE) and fs.exists(CALIB_FILE)) then return false end
return loadCalib().signMeasured == true
end
-- live brukerinnstillinger (justeres fra pilot-konsollen), persistert. Syntetiske noekler -> cfg.
local SETTINGS_FILE = "settings.txt"
local function saveSettings(t) local f = fs.open(SETTINGS_FILE, "w"); f.write(textutils.serialise(t)); f.close() end
@@ -135,79 +145,189 @@ local function runMonitor()
end
end
-- ============================ SIGN TEST (§13.2) ============================
-- Maaler pitch-RESPONSEN paa en nudge (mer front) for aa finne pitchSign. Bruker pitch-RATEN, ikke
-- ============================ SIGN TEST (§13.2, v2 -- SELVGAAENDE) ============================
-- Maaler pitch-responsen paa en differanse-nudge for aa finne pitchSign. Bruker pitch-RATEN, ikke
-- vinkelen: open-loop under et vedvarende moment er skipet en naer-integrator -> VINKELEN settler
-- aldri, men RATEN gjor det. Vi venter til raten har roet seg (lag-bevisst: minHold >= loft-
-- etterslepet) FOER vi lagrer. Baseline-raten fanger asymmetri-driften; dr = endring pga nudge.
-- aldri, men RATEN gjor det. Hele testen er selvgaaende -- INGEN config-redigering:
-- 1/4 trapper seg luftbaaren fra bunnen (laerer ~hover paa kjoepet, ingen fast liftLevel)
-- 2/4 klatrer kontrollert til trygg maalehoyde (fart-P-hold rundt laert hover)
-- 3/4 baseline -> nudge front -> MOTSATT nudge (verifisering); svak respons -> auto sterkere dytt
-- 4/4 lagrer, senker skipet kontrollert tilbake og HOLDER hover -- ALDRI frittfall
-- Driver (fore,aft) til pitch-raten er stabil (lite spenn i et ~1.5 s vindu) ELLER timeout.
-- Returnerer snitt pitchRate over vinduet + om den faktisk stabiliserte seg.
local function holdTilRateStable(est, fore, aft, h0, label, minHold, maxWait)
local t0, win, WINSEC = now(), {}, 1.5
while now() - t0 < maxWait do
driveValves(fore, aft)
est:update(now(), sensors.readAltitude() or h0, sensors.readPitch() or 0)
win[#win + 1] = { t = now(), pr = est.pitchRate }
while #win > 1 and (now() - win[1].t) > WINSEC do table.remove(win, 1) end
local sum, lo, hi = 0, math.huge, -math.huge
for _, e in ipairs(win) do sum = sum + e.pr; lo = math.min(lo, e.pr); hi = math.max(hi, e.pr) end
local n = #win
local avg = sum / n
local span = (n > 1) and (hi - lo) or math.huge
term.clear(); term.setCursorPos(1, 1); print("SIGN TEST: " .. label)
print(string.format(" fore %d aft %d", fore, aft))
print(string.format(" pitch %+.1f pRate %+.2f", est.pitch, est.pitchRate))
print(string.format(" window avg %+.2f span %.2f deg/s", avg, span))
if math.abs(est.pitch) > cfg.signtest.maxTiltAbort then return avg, true end -- ikke roter for langt
if (now() - t0) >= minHold and n >= 5 and span < cfg.signtest.rateCalm then return avg, true end
sleep(cfg.dt)
end
local sum = 0; for _, e in ipairs(win) do sum = sum + e.pr end
return (#win > 0) and (sum / #win) or est.pitchRate, false
end
local signtestAirborne = false -- for runMode-cleanup: hold hover hvis luftbaaren, ellers alt av
local function runSigntest()
local s, est = cfg.signtest, Estimator.new(cfg)
local h0 = sensors.readAltitude(); if not h0 then print("No altitude sensor."); return end
print("SIGN TEST. CTRL+T for emergency stop."); print("1) Lifting airborne..."); sleep(0.5)
local liftLevel, airborne, tLift = math.min(cfg.maxSignal, s.liftLevel), false, now()
while now() - tLift < s.liftTimeout do
driveValves(liftLevel, liftLevel)
local h = sensors.readAltitude() or h0
est:update(now(), h, sensors.readPitch() or 0)
term.clear(); term.setCursorPos(1, 1); print("SIGN TEST: lifting...")
print(string.format(" height %.2f (+%.2f) vVel %.2f", h, h - h0, est.verticalVelocity))
if h - h0 > s.airborneRise then airborne = true; break end
local log = Logger.new("signtest.log", "# signtest: t, phase, fore, aft, height, pitch, vVel, pitchRate")
signtestAirborne = false
local h0 = sensors.readAltitude()
if not h0 then print("No altitude sensor."); return false end
local t0 = now()
local hover = nil -- laeres i trappa (1/4), trimmes videre av fart-holdet
local levelDiff = 0 -- treg nivellering under nedstigning (naar fortegnet er kjent)
local function tick(fore, aft, phase)
local f, a = driveValves(fore, aft)
local h, p = sensors.readAltitude(), sensors.readPitch()
if h and p then est:update(now(), h, p) end
log:row({ now() - t0, phase, f, a, est.altitude, est.pitch, est.verticalVelocity, est.pitchRate })
sleep(cfg.dt)
end
if not airborne then allOff(); print("Did not get airborne -- raise liftLevel / check sides."); return end
local climbOk = est.verticalVelocity > 0
-- baseline-rate (balansert) -- vent til raten roer seg (fanger evt. asymmetri-drift)
local r0 = holdTilRateStable(est, liftLevel, liftLevel, h0, "baseline (balanced)", s.baseMinHold, s.baseMaxWait)
-- nudge (mer front) -- HOLD til loftet (~5 s) har uttrykt seg OG raten er stabil
local r1, settled = holdTilRateStable(est, liftLevel + s.nudge, math.max(0, liftLevel - s.nudge),
h0, "nudge (more front)", s.nudgeMinHold, s.nudgeMaxWait)
allOff()
local dr = r1 - r0 -- endring i stabilisert pitch-RATE pga mer front = robust, lag-tolerant signal
term.clear(); term.setCursorPos(1, 1); print("==== SIGN TEST RESULT ====")
print(string.format("rate baseline %+.2f -> nudged %+.2f (dr %+.2f deg/s)", r0, r1, dr))
print(string.format("rate settled: %s", settled and "yes" or "no (timed out -- direction still used)"))
print(string.format("climb sign: %s", climbOk and "OK (vVel>0)" or "WRONG -- altitude inverted!"))
if math.abs(dr) < s.drMin then
print("WEAK response -- NOT saving. Raise signtest.nudge or re-run.")
else
local c = loadCalib()
c.pitchSign = utils.sign(dr)
c.pitchGain = 1.0 -- robust default; integral-trimmen gjor magnituden ukritisk
c.alphaPitch = 1.0
c.pitchTrim = 0 -- nullstill laert trim ved ny fortegns-maaling
saveCalib(c)
print(string.format(">>> Saved pitchSign=%d (pitchGain=1.0; integral learns the rest)", c.pitchSign))
local function hud(step, title, lines)
term.clear(); term.setCursorPos(1, 1)
print("==== SIGN TEST " .. step .. " ====")
print(title)
for _, l in ipairs(lines or {}) do print(l) end
print("")
print(string.format("height +%.1f pitch %+.1f vVel %+.2f",
est.altitude - h0, est.pitch, est.verticalVelocity))
print("CTRL+T = abort (holds lift, no freefall)")
end
-- fart-P-hold rundt laert hover (+ treg hover-trim), med valgfri differanse (fore - aft)
local function velTick(vDes, diff, phase)
hover = clamp(hover + 0.05 * (vDes - est.verticalVelocity) * cfg.dt, cfg.absFloor, cfg.maxSignal - 0.5)
local u = hover + clamp(1.2 * (vDes - est.verticalVelocity), -1.5, 1.5)
local f, a = mixer.preserveDifferenceClamp(u + diff, u - diff, cfg.absFloor, cfg.maxSignal)
tick(f, a, phase)
end
-- kontrollert nedstigning + hover-hold -- den ENESTE maaten vi forlater lufta paa
local function descendAndHold(pSign)
local tD = now()
while est.altitude > h0 + 3 and now() - tD < 120 do
if pSign ~= 0 then
levelDiff = clamp(levelDiff - pSign * 0.2 * est.pitch * cfg.dt, -1.5, 1.5)
end
velTick(-0.4, levelDiff, "descend")
hud("4/4", "Coming back down...", {
string.format("descending to +3 hover ~%.1f", hover),
})
end
local tS = now()
while now() - tS < 3.0 do velTick(0, levelDiff, "settle") end
driveValves(hover, hover) -- bli staaende paa laert hover (aldri null = stup)
end
-- felles avbrudd: luftbaaren -> kontrollert ned foerst; paa bakken -> bare av
local function bail(msg)
if signtestAirborne then descendAndHold(0) else allOff() end
term.clear(); term.setCursorPos(1, 1)
print("==== SIGN TEST: STOPPED ====")
print(msg)
if signtestAirborne then print(""); print("Ship is holding hover near the ground.") end
print(""); print("[Enter]"); read()
return false
end
local function abortMsg(code)
if code == "tilt" then return string.format("Tilted past %.0f deg -- aborted for safety.", s.maxTiltAbort) end
if code == "alt" then return "Drifted too far up -- aborted." end
if code == "low" then return "Got too close to the ground -- aborted." end
return "Aborted."
end
-- ---- 1/4: trappe seg luftbaaren (ingen fast liftLevel -- vi SOEKER effekten) ----
local cmd, stepT, riseHold, tLift = cfg.absFloor, now(), 0, now()
while true do
tick(cmd, cmd, "lift")
hud("1/4", "Lifting off (auto power search)...", {
string.format("power %.1f / %d", cmd, cfg.maxSignal),
})
if est.verticalVelocity > 0.25 then riseHold = riseHold + cfg.dt else riseHold = 0 end
if riseHold >= 1.0 and est.altitude - h0 > 1.5 then break end
if now() - stepT >= 4.0 then
if cmd >= cfg.maxSignal then
return bail("No lift even at max power.\nCheck wiring (menu: Wiring) and that the\nburners have fuel.")
end
cmd = math.min(cfg.maxSignal, cmd + 0.5); stepT = now()
end
if now() - tLift > 120 then return bail("Timed out lifting off.") end
end
hover = math.max(cfg.absFloor, cmd - 0.25) -- kryssingen er ~her: foerste hover-estimat
signtestAirborne = true
-- ---- 2/4: kontrollert klatring til trygg maalehoyde (hover-trimmen konvergerer underveis) ----
local tC = now()
while est.altitude < h0 + 8 do
velTick(0.4, 0, "climb")
hud("2/4", "Climbing to measuring height...", {
string.format("target +8 hover ~%.1f", hover),
})
if math.abs(est.pitch) > s.maxTiltAbort then return bail(abortMsg("tilt")) end
if now() - tC > 60 then return bail("Could not reach measuring height.") end
end
-- rate-stabil maaling: hold hoyden (vDes=0) med gitt differanse til pitch-raten roer seg.
-- Lag-bevisst: minHold >= loft-etterslepet foer vi godtar. Timeout -> bruk snittet likevel.
local function measureRate(diff, step, title, minHold, maxWait)
local tS, win = now(), {}
local avg = 0
while now() - tS < maxWait do
velTick(0, diff, title)
win[#win + 1] = { t = now(), pr = est.pitchRate }
while #win > 1 and (now() - win[1].t) > 1.5 do table.remove(win, 1) end
local sum, lo, hi = 0, math.huge, -math.huge
for _, e in ipairs(win) do sum = sum + e.pr; lo = math.min(lo, e.pr); hi = math.max(hi, e.pr) end
avg = sum / #win
local span = (#win > 1) and (hi - lo) or math.huge
hud(step, title, {
string.format("diff %+.0f rate avg %+.2f span %.2f", diff, avg, span),
string.format("stable when span < %.1f deg/s", s.rateCalm),
})
if math.abs(est.pitch) > s.maxTiltAbort then return nil, "tilt" end
if est.altitude - h0 > 35 then return nil, "alt" end
if est.altitude - h0 < 2 then return nil, "low" end
if now() - tS >= minHold and #win >= 5 and span < s.rateCalm then return avg end
end
return avg -- timeout: retningen er som regel riktig selv om roen aldri kom
end
-- ---- 3/4: baseline -> nudge -> MOTSATT nudge, med automatisk opptrapping ved svak respons ----
local nudge, sign, verified = s.nudge, 0, false
local maxNudge = math.max(s.nudge, math.floor(cfg.maxSignal / 2) + 1)
for attempt = 1, 3 do
local tag = string.format(" (try %d, push %d)", attempt, nudge)
local r0, e0 = measureRate(0, "3/4", "Baseline (balanced)" .. tag, s.baseMinHold, s.baseMaxWait)
if not r0 then return bail(abortMsg(e0)) end
local r1, e1 = measureRate(nudge, "3/4", "Push: more FRONT" .. tag, s.nudgeMinHold, s.nudgeMaxWait)
if not r1 then return bail(abortMsg(e1)) end
local r2, e2 = measureRate(-nudge, "3/4", "Verify: more AFT" .. tag, s.nudgeMinHold, s.nudgeMaxWait)
if not r2 then return bail(abortMsg(e2)) end
local dF, dR = r1 - r0, r2 - r0 -- rate-endring ved mer front / mer bak
if math.abs(dF) >= s.drMin and utils.sign(dR) == -utils.sign(dF) and math.abs(dR) >= s.drMin * 0.5 then
sign, verified = utils.sign(dF), true
break
end
if math.abs(dF) >= s.drMin then sign = utils.sign(dF) end -- kandidat, men uverifisert
nudge = math.min(maxNudge, nudge + 1) -- auto: proev sterkere dytt
end
if sign == 0 then
return bail("No measurable pitch response, even with\na stronger push. Are both balloons on\nthe same fore/aft line? Re-check wiring.")
end
-- ---- lagre FOER nedstigningen (Ctrl+T etterpaa mister ingenting) ----
local c = loadCalib()
c.pitchSign, c.pitchGain, c.alphaPitch = sign, 1.0, 1.0
c.pitchTrim = 0 -- nullstill laert trim ved ny fortegns-maaling
c.uHoverFront, c.uHoverRear = hover, hover -- grov men EKTE hover-maaling ('cal' kan forfine)
c.hoverTrim = 0 -- ny baseline -> gammel loft-trim er ugyldig
c.signMeasured = true -- profilen er komplett (wizard/standby sjekker denne)
saveCalib(c)
-- ---- 4/4: kontrollert ned + hold hover (nivellerer med det nymaalte fortegnet) ----
descendAndHold(sign)
term.clear(); term.setCursorPos(1, 1)
print("==== SIGN TEST: SUCCESS ====")
print(string.format("pitchSign %+d %s", sign,
verified and "(verified both directions)" or "(UNVERIFIED -- reverse push unclear)"))
print(string.format("hover ~%.1f (saved as baseline)", hover))
print("")
print("Ship is holding hover. Ready to FLY.")
print(""); print("[Enter]"); read()
return true
end
-- ============================ WIRING SETUP ============================
@@ -245,13 +365,16 @@ local function runWiring()
else
local level = math.min(cfg.maxSignal, 8)
for _, side in ipairs(linkSides) do
while true do -- [r] pulser samme side igjen -- lett aa gaa glipp av et blaff
term.clear(); term.setCursorPos(1, 1); print("==== WIRING SETUP: pulsing ====")
print(string.format("Pulsing '%s' at %d -- WATCH THE BURNERS...", side, level))
redstone.setAnalogOutput(side, level); sleep(3.0); redstone.setAnalogOutput(side, 0)
write(string.format("Which balloon fired on '%s'? [f]ront / [a]ft / [n]one: ", side))
local a = read()
if a == "f" or a == "F" then fore = side
elseif a == "a" or a == "A" then aft = side end
write(string.format("'%s' fired: [f]ront / [a]ft / [n]one / [r]=again: ", side))
local a = read():lower()
if a == "f" then fore = side; break
elseif a == "a" then aft = side; break
elseif a ~= "r" then break end
end
end
end
allSidesOff()
@@ -268,6 +391,7 @@ local function runWiring()
print(""); print(">>> Saved to sides.txt and applied.")
end
print(""); print("[Enter]"); read()
return fore ~= nil
end
-- ============================ MANUAL (Lag 2) ============================
@@ -378,12 +502,13 @@ local function runFly()
if not fs.exists(CALIB_FILE) then
term.clear(); term.setCursorPos(1, 1)
print("No profile for this ship yet.")
print("Set it up first (in order):")
print(" menu 0 Wiring setup (which side = front/aft valve)")
print(" menu 2 Sign test (measures + saves pitch sign)")
print("")
print("Then run FLY -- pitch & altitude self-learn from there.")
print("(Full 'cal' is optional/legacy; controllers no longer need lambda/g.)")
print("Run the setup wizard first:")
print(" ap setup (or menu choice 1)")
print("")
print("It maps the wiring and measures the pitch")
print("sign in one short guided auto-flight.")
print("Pitch & altitude self-learn from there.")
print(""); print("[Enter]"); read(); return
end
@@ -500,15 +625,65 @@ local function runFly()
end
end
-- ============================ SETUP-WIZARD (foerstegang / nytt skip) ============================
-- ETT stoppested for alt skip-spesifikt: wiring (hvilken side driver hvilken ballong) + sign test
-- (kort selvgaaende flytur som maaler pitch-fortegn og ~hover). Etterpaa er skipet klart til FLY.
local function runSetup()
signtestAirborne = false -- nullstill foer wizarden (ellers kan cleanup arve forrige kjoering)
term.clear(); term.setCursorPos(1, 1)
print("==== SHIP SETUP WIZARD ====")
print("")
print("Two steps, fully guided:")
print(" 1) Wiring -- map front/aft burner sides")
print(" 2) Sign test -- short auto-flight that")
print(" measures pitch response + hover")
print("")
print("Ship must be ON THE GROUND, open sky above.")
print("")
write("[Enter] = start, q = cancel: ")
if read():lower() == "q" then return end
if not runWiring() then
print("Wiring was not saved -- wizard stopped."); print("[Enter]"); read(); return
end
term.clear(); term.setCursorPos(1, 1)
print("==== STEP 2: SIGN TEST ====")
print("")
print("The ship now lifts off BY ITSELF, gives")
print("the front balloon a small push, verifies")
print("the response both ways, and comes back")
print("down to hover. Takes 1-3 minutes.")
print("")
write("[Enter] = lift off, q = cancel: ")
if read():lower() == "q" then return end
runSigntest()
term.clear(); term.setCursorPos(1, 1)
if profileComplete() then
print("==== SETUP COMPLETE ====")
print("")
print("Profile saved. The ship is ready:")
print(" - FLY from the menu, or")
print(" - standby + ENGAGE from the cockpit")
else
print("==== SETUP INCOMPLETE ====")
print("")
print("Sign test did not finish -- run the")
print("wizard again (menu choice 1).")
end
print(""); print("[Enter]"); read()
end
-- ============================ STANDBY (pilot-fjernstart) ============================
-- Lytter etter ENGAGE fra pilot-konsollen og starter fly. Kringkaster IDLE-heartbeat slik at
-- konsollen vet at maskinrommet er online og klart. Kjor denne (eller sett den som startup) saa
-- piloten kan starte/stoppe fly selv: stabilizeV2 standby
-- konsollen vet at maskinrommet er online og klart. Default etter boot; [S] aapner setup-wizarden.
local function awaitCmd(secs)
local timer = os.startTimer(secs)
while true do
local e1, e2, e3, e4 = os.pullEvent()
if e1 == "timer" and e2 == timer then return nil
elseif e1 == "char" and (e2 == "s" or e2 == "S") then return "setup"
elseif e1 == "rednet_message" and e4 == link.CMD and type(e3) == "table" then
if e3.cmd == "engage" or e3.cmd == "stop" then return e3.cmd end
if e3.cmd == "set" then applySetting(e3.key, e3.value, true) end -- juster innstillinger i standby
@@ -519,40 +694,72 @@ end
local function runStandby()
link.open()
print("STANDBY -- waiting for ENGAGE from pilot console. CTRL+T for menu.")
while true do
local h = sensors.readAltitude() or 0
local p = sensors.readPitch() or 0
local ready = fs.exists(CALIB_FILE)
local flyable = fs.exists(CALIB_FILE) -- kan fly (evt. eldre profil uten maalt-flagg)
local complete = profileComplete() -- wiring + faktisk MAALT pitch-fortegn
link.publishStatus({ state = "IDLE", height = h, target = loadTarget() or h, pitch = p,
vVel = 0, hold = false, ready = ready, fore = 0, aft = 0, uSum = 0, iTrim = 0, hTrim = 0,
vVel = 0, hold = false, ready = flyable, fore = 0, aft = 0, uSum = 0, iTrim = 0, hTrim = 0,
climbSpeed = cfg.vMaxUp, holdBand = cfg.altDeadband, maxPower = cfg.maxSignal })
term.clear(); term.setCursorPos(1, 1)
print("==== STANDBY (pilot remote) ====")
print(ready and "Profile: READY" or "Profile: MISSING -> run Wiring (0) + Sign test (2)")
if complete then print("Profile: READY")
elseif flyable then print("Profile: LEGACY ([S] = re-run setup)")
else print("Profile: NOT SET UP -> press [S]") end
print(string.format("Height %.1f Pitch %+.1f", h, p))
print("Waiting for ENGAGE from the pilot console...")
print("CTRL+T -> quit to menu (for setup).")
print("[S] setup wizard CTRL+T then 'ap' = menu")
local cmd = awaitCmd(0.5)
if cmd == "engage" then
if fs.exists(CALIB_FILE) then
if cmd == "setup" then
runSetup()
elseif cmd == "engage" then
if flyable then
runFly() -- blokkerer til pilot STOP / FAULT / CTRL+T
local c = loadCalib(); driveValves(c.uHoverFront, c.uHoverRear) -- hold hover etter stop
else
link.publishStatus({ state = "SETUP NEEDED", reason = "Wiring + Sign test on AP", ready = false })
link.publishStatus({ state = "SETUP NEEDED", reason = "press S on autopilot PC", ready = false })
sleep(2.0)
end
end
end
end
-- ============================ BOOT (startup-inngang) ============================
-- Foerste boot uten komplett profil -> tilby wizarden. Timeout -> standby, slik at en ubemannet
-- reboot (chunk-reload) aldri blir staaende og vente paa tastetrykk.
local function runBoot()
if not profileComplete() then
term.clear(); term.setCursorPos(1, 1)
print("==== AIRSHIP AUTOPILOT ====")
print("")
print(fs.exists(CALIB_FILE)
and "Ship profile: LEGACY (sign not measured)."
or "No ship profile on this computer yet.")
print("")
print("[Enter] = first-time setup wizard")
print("(any other key, or 10 s -> standby)")
local timer = os.startTimer(10)
while true do
local e = { os.pullEvent() }
if e[1] == "timer" and e[2] == timer then break
elseif e[1] == "key" then
if e[2] == keys.enter then runSetup() end
break
end
end
end
runStandby()
end
-- ============================ PROFIL / MENY ============================
local function showProfile()
local c = loadCalib()
print("==== PROFILE ====")
print(string.format("uHoverFront %.2f uHoverRear %.2f", c.uHoverFront, c.uHoverRear))
print(string.format("lambda %.3f g %.3f", c.lambda, c.g))
print(string.format("pitchSign %d pitchGain %.3f", c.pitchSign, c.pitchGain))
print(string.format("pitchSign %d (%s) pitchGain %.3f", c.pitchSign,
c.signMeasured and "measured" or "DEFAULT -- run setup", c.pitchGain))
print(string.format("wiring: fore=%s aft=%s%s", cfg.foreSide, tostring(cfg.aftSide),
fs.exists(SIDES_FILE) and "" or " (config default)"))
print("calib.txt: " .. (fs.exists(CALIB_FILE) and "saved" or "none (defaults)"))
@@ -563,36 +770,47 @@ end
local function menu()
while true do
term.clear(); term.setCursorPos(1, 1)
print("===== AIRSHIP AUTOPILOT v3 =====\n")
print(" 1) Monitor (Layer 1)")
print(" 2) Sign test")
print(" 3) Manual drive (Layer 2)")
print(" 4) Calibrate (Layer 3)")
print(" 5) Observe open-loop (Layer 4)")
print(" 6) FLY / hold (Layer 5-8)")
print(" 7) Set target altitude")
print(" 8) Show / reset profile")
print(" 0) Wiring setup (front/aft sides)")
print(" 9) Exit\n")
print(sensors.present() and "Sensors: OK" or "Sensors: MISSING - check wiring")
print(fs.exists(CALIB_FILE) and "Calib: saved" or "Calib: none (will auto-cal on fly)")
print("===== AIRSHIP AUTOPILOT v3 =====")
print(" Setup")
print(" 1) Setup wizard (wiring + sign test)")
print(" 2) Wiring only 3) Sign test only")
print(" Fly")
print(" 4) FLY / hold 5) Standby (pilot)")
print(" 6) Set target altitude")
print(" Tools")
print(" 7) Monitor 8) Manual 9) Full calibrate")
print(" o) Observe p) Profile/reset q) Quit")
print("")
local prof = profileComplete() and "Profile: OK"
or (fs.exists(CALIB_FILE) and "Profile: LEGACY (1 = re-setup)" or "Profile: NOT SET UP -> 1")
print((sensors.present() and "Sensors: OK " or "SENSORS MISSING! ") .. prof)
print(string.format("Wiring: fore=%s aft=%s", cfg.foreSide, tostring(cfg.aftSide)))
print(""); write("Choice: ")
local k = read()
if k == "1" then return "monitor" elseif k == "2" then return "signtest"
elseif k == "3" then return "manual" elseif k == "4" then return "cal"
elseif k == "5" then return "observe" elseif k == "6" then return "fly"
elseif k == "0" then return "wiring"
elseif k == "7" then
write("Choice: ")
local k = read():lower()
if k == "1" then return "setup"
elseif k == "2" then return "wiring"
elseif k == "3" then return "signtest"
elseif k == "4" then return "fly"
elseif k == "5" then return "standby"
elseif k == "6" then
write("Target (empty = auto): "); local s = read()
if s == "" then if fs.exists(TARGET_FILE) then fs.delete(TARGET_FILE) end; print("Target cleared.")
elseif tonumber(s) then saveTarget(tonumber(s)); print("Target = " .. s) else print("Invalid number.") end
print("[Enter]"); read()
elseif k == "8" then
elseif k == "7" then return "monitor"
elseif k == "8" then return "manual"
elseif k == "9" then return "cal"
elseif k == "o" then return "observe"
elseif k == "p" then
term.clear(); term.setCursorPos(1, 1); showProfile()
print("\nR = reset calib, Enter = back"); local a = read()
if a == "r" or a == "R" then if fs.exists(CALIB_FILE) then fs.delete(CALIB_FILE) end; print("Calib reset."); print("[Enter]"); read() end
elseif k == "9" then return "exit" end
print("\nR = reset SHIP PROFILE (new ship), Enter = back"); local a = read():lower()
if a == "r" then
for _, f in ipairs({ CALIB_FILE, SIDES_FILE, TARGET_FILE, SETTINGS_FILE }) do
if fs.exists(f) then fs.delete(f) end
end
print("Ship profile reset -- setup wizard on next boot."); print("[Enter]"); read()
end
elseif k == "q" then return "exit" end
end
end
@@ -614,22 +832,36 @@ if not sensors.present() then print("Sensors missing (altitude_sensor + gimbal_s
-- kjor EN modus + opprydding. Returnerer (ok, err) saa menyloopen kan avslutte ved CTRL+T.
local function runMode(mode, a2, a3)
local ok, err = pcall(function()
if mode == "monitor" then runMonitor()
if mode == "boot" then runBoot()
elseif mode == "setup" then runSetup()
elseif mode == "standby" then runStandby()
elseif mode == "monitor" then runMonitor()
elseif mode == "signtest" then runSigntest()
elseif mode == "wiring" then runWiring()
elseif mode == "manual" then runManual(a2, a3)
elseif mode == "cal" then runCal()
elseif mode == "observe" then runObserve()
elseif mode == "fly" then runFly()
elseif mode == "standby" then runStandby()
else print("Unknown mode: " .. tostring(mode)) end
else print("Unknown mode: " .. tostring(mode))
print("Valid: setup wiring signtest fly standby monitor manual cal observe boot") end
end)
-- opprydding: aktive driv-modus -> hold hover (aldri etterlat drivende uten beskjed);
-- monitor styrer ingenting (la utgangene staa).
if mode == "signtest" or mode == "manual" or mode == "wiring" then
if mode == "manual" or mode == "wiring" then
allOff()
elseif mode == "cal" or mode == "fly" or mode == "observe" or mode == "standby" then
elseif mode == "signtest" or mode == "setup" then
-- Ctrl+T midt i sign-testen: luftbaaren -> hold hover (ALDRI frittfall); paa bakken -> alt av
if signtestAirborne then
local c = loadCalib(); driveValves(c.uHoverFront, c.uHoverRear)
else
allOff()
end
elseif mode == "cal" or mode == "fly" or mode == "observe" or mode == "standby" or mode == "boot" then
if fs.exists(CALIB_FILE) then
local c = loadCalib(); driveValves(c.uHoverFront, c.uHoverRear) -- hold, ikke null (aldri stup)
else
allOff() -- aldri floeyet under vaar kontroll -> skipet staar paa bakken; ikke varm brennere
end
end
if not ok and err ~= "Terminated" then print("ERROR: " .. tostring(err)); print("[Enter]"); read() end
return ok, err

View File

@@ -1,11 +0,0 @@
{
pitchGain = 1,
hoverTrim = 1.7889825868927072,
uHoverFront = 3,
lambda = 0.33333333333333331,
g = 1,
pitchTrim = 1.345751507649297,
alphaPitch = 1,
pitchSign = -1,
uHoverRear = 3,
}

View File

@@ -1,171 +0,0 @@
-- cockpit.lua -- pilot-hus konsoll. Status + full live-styring av autopiloten over rednet.
-- To faner: STATUS (fly/standby-styring) og SETTINGS (live brukerinnstillinger). UI = English.
-- ADAPTIV layout: kompakt paa 1x1-monitor, full paa storre.
local progDir = fs.getDir(shell.getRunningProgram())
if package and package.path and not string.find(package.path, progDir, 1, true) then
package.path = progDir .. "/?.lua;" .. package.path
end
local link = require("link")
local mon = peripheral.find("monitor")
if not mon then print("No monitor found. Attach a monitor (Advanced recommended)."); return end
if not link.open() then print("No modem found. Attach a wireless modem."); return end
mon.setTextScale(0.5)
local W, H = mon.getSize()
local C = { bg = colors.black, title = colors.cyan, label = colors.lightGray, val = colors.white,
ok = colors.lime, warn = colors.orange, fault = colors.red, btn = colors.gray, btnTxt = colors.white,
engage = colors.green, stop = colors.red, tabOn = colors.blue, active = colors.green }
-- live-justerbare brukerinnstillinger (syntetiske noekler -> autopiloten mapper til cfg)
local SETTINGS = {
{ key = "climbSpeed", short = "Climb", step = 0.1, fmt = "%.1f", unit = "b/s", def = 0.8 },
{ key = "holdBand", short = "Band", step = 1, fmt = "%.0f", unit = "blk", def = 3 },
{ key = "maxPower", short = "Power", step = 1, fmt = "%.0f", unit = "", def = 6 },
}
local page = "status"
local status, lastRx = nil, -999
local buttons = {}
local function now() return os.clock() end
local function online() return (now() - lastRx) < 2.0 end
local function button(x1, y1, x2, y2, label, action, col)
buttons[#buttons + 1] = { x1 = x1, y1 = y1, x2 = x2, y2 = y2, action = action }
mon.setBackgroundColor(col or C.btn); mon.setTextColor(C.btnTxt)
for y = y1, y2 do mon.setCursorPos(x1, y); mon.write(string.rep(" ", x2 - x1 + 1)) end
mon.setCursorPos(x1 + math.max(0, math.floor((x2 - x1 + 1 - #label) / 2)), y1 + math.floor((y2 - y1) / 2))
mon.write(label); mon.setBackgroundColor(C.bg)
end
local function center(y, text, color)
mon.setTextColor(color or C.val)
mon.setCursorPos(math.max(1, math.floor((W - #text) / 2) + 1), y); mon.write(text:sub(1, W))
end
local function putline(y, text, color)
mon.setTextColor(color or C.val); mon.setCursorPos(1, y); mon.write(text:sub(1, W))
end
local function drawTabs()
local hw = math.floor(W / 2)
button(1, 1, hw, 1, "STATUS", function() page = "status" end, page == "status" and C.tabOn or C.btn)
button(hw + 1, 1, W, 1, "SETUP", function() page = "settings" end, page == "settings" and C.tabOn or C.btn)
end
local function curVal(p)
if status and status[p.key] ~= nil then return status[p.key] end
return p.def
end
local function drawStatus()
local compact = W < 28
local bRowH = (H >= 16) and 2 or 1
local landTop = H - bRowH + 1
local tgtTop = landTop - bRowH
local top, statusMax = 2, tgtTop - 1
if not online() then
center(math.floor((top + statusMax) / 2), "OFFLINE", C.fault)
if not compact then center(math.floor((top + statusMax) / 2) + 1, "no link to machine room", C.warn) end
return
end
local s = status
local inFlight = s.state ~= "IDLE" and s.state ~= "SETUP NEEDED"
local stateColor = C.fault
if s.state == "FLYING" then stateColor = C.ok
elseif s.state == "IDLE" then stateColor = (s.ready and C.title or C.warn) end
local pcolor = (math.abs(s.pitch or 0) < 3) and C.ok or C.warn
-- bygg status-linjer
local lines = {}
lines[#lines + 1] = { tostring(s.state) .. (s.hold and " HOLD" or ""), stateColor }
if s.reason and s.reason ~= "" then lines[#lines + 1] = { tostring(s.reason), C.fault } end
if compact then
lines[#lines + 1] = { string.format("%.0f > %.0f", s.height or 0, s.target or 0), C.val }
lines[#lines + 1] = { string.format("PITCH %+.1f", s.pitch or 0), pcolor }
if inFlight then lines[#lines + 1] = { string.format("VSPD %+.2f", s.vVel or 0), C.val } end
if s.state == "IDLE" then lines[#lines + 1] = { s.ready and "ready -> ENGAGE" or "needs setup", s.ready and C.ok or C.warn } end
else
if s.state == "IDLE" then lines[#lines + 1] = { s.ready and "ready -> ENGAGE" or "needs setup (Wiring+Sign)", s.ready and C.ok or C.warn } end
lines[#lines + 1] = { string.format("ALT %.1f > %.0f", s.height or 0, s.target or 0), C.val }
lines[#lines + 1] = { string.format("PITCH %+.1f deg", s.pitch or 0), pcolor }
if inFlight then
lines[#lines + 1] = { string.format("VSPD %+.2f b/s", s.vVel or 0), C.val }
lines[#lines + 1] = { string.format("OUT f%d a%d lift %.1f", s.fore or 0, s.aft or 0, s.uSum or 0), C.val }
end
end
-- render: kompakt = sentrert + luftig (fyll den ledige plassen); full = venstrejustert top-down
if compact then
local avail = statusMax - top + 1
local gap = (avail >= 2 * #lines - 1) and 1 or 0
local blockH = #lines + (#lines - 1) * gap
local startY = top + math.max(0, math.floor((avail - blockH) / 2))
for i, L in ipairs(lines) do
local y = startY + (i - 1) * (1 + gap)
if y >= top and y <= statusMax then center(y, L[1], L[2]) end
end
else
for i, L in ipairs(lines) do local y = top + i - 1; if y <= statusMax then putline(y, L[1], L[2]) end end
end
-- knapper (HOLD/LAND lyser solid naar den modusen er aktiv)
if inFlight then
local bw = math.floor(W / 4)
button(1, tgtTop, bw, tgtTop + bRowH - 1, "-5", function() link.sendCommand({ cmd = "adjust", delta = -5 }) end)
button(bw + 1, tgtTop, 2 * bw, tgtTop + bRowH - 1, "-1", function() link.sendCommand({ cmd = "adjust", delta = -1 }) end)
button(2 * bw + 1, tgtTop, 3 * bw, tgtTop + bRowH - 1, "+1", function() link.sendCommand({ cmd = "adjust", delta = 1 }) end)
button(3 * bw + 1, tgtTop, W, tgtTop + bRowH - 1, "+5", function() link.sendCommand({ cmd = "adjust", delta = 5 }) end)
local tw = math.floor(W / 3)
button(1, landTop, tw, H, "HOLD", function() link.sendCommand({ cmd = "hold" }) end, (s.mode == "hold") and C.active or C.btn)
button(tw + 1, landTop, 2 * tw, H, "LAND", function() link.sendCommand({ cmd = "land" }) end, (s.mode == "land") and C.active or C.btn)
button(2 * tw + 1, landTop, W, H, "STOP", function() link.sendCommand({ cmd = "stop" }) end, C.stop)
else
button(1, tgtTop, W, H, "ENGAGE FLY", function() link.sendCommand({ cmd = "engage" }) end, C.engage)
end
end
local function drawSettings()
local n = #SETTINGS
local areaTop = 2
local blockH = math.max(1, math.floor((H - areaTop + 1) / n))
local bw = math.max(3, math.floor(W / 5))
for i, p in ipairs(SETTINGS) do
local y1 = areaTop + (i - 1) * blockH
local y2 = (i == n) and H or (y1 + blockH - 1)
local cur = curVal(p)
button(1, y1, bw, y2, "-", function() link.sendCommand({ cmd = "set", key = p.key, value = cur - p.step }) end)
button(W - bw + 1, y1, W, y2, "+", function() link.sendCommand({ cmd = "set", key = p.key, value = cur + p.step }) end)
local txt = string.format("%s %s%s", p.short, string.format(p.fmt, cur), p.unit)
mon.setTextColor(C.val)
mon.setCursorPos(bw + 1 + math.max(0, math.floor((W - 2 * bw - #txt) / 2)), math.floor((y1 + y2) / 2))
mon.write(txt)
end
end
local function draw()
mon.setBackgroundColor(C.bg); mon.clear()
buttons = {}
drawTabs()
if page == "settings" then drawSettings() else drawStatus() end
end
local function handleTouch(x, y)
for _, b in ipairs(buttons) do
if x >= b.x1 and x <= b.x2 and y >= b.y1 and y <= b.y2 then b.action(); draw(); return end
end
end
draw()
local redraw = os.startTimer(0.5)
while true do
local e = { os.pullEvent() }
if e[1] == "rednet_message" and e[4] == link.STATUS then
status = e[3]; lastRx = now()
elseif e[1] == "monitor_touch" then
handleTouch(e[3], e[4])
elseif e[1] == "timer" and e[2] == redraw then
redraw = os.startTimer(0.5); draw()
end
end

View File

@@ -1,50 +0,0 @@
-- diag.lua
-- Utforsker alle peripheraler og proever aa kalle hver metode (uten argumenter)
-- for aa se hvilke data som er tilgjengelige -- spesielt fra contraption diagram-blokken.
-- Skriver til skjerm OG diag.log.
local OUT = "diag.log"
if fs.exists(OUT) then fs.delete(OUT) end
local file = fs.open(OUT, "w")
local function emit(s)
print(s)
file.writeLine(s)
end
local function dump(v)
if type(v) == "table" then
return textutils.serialise(v):gsub("%s+", " ")
end
return tostring(v)
end
local names = peripheral.getNames()
emit("=== PERIPHERAL-UTFORSKER ===")
emit("Fant " .. #names .. " peripheral(er)")
emit("")
for _, name in ipairs(names) do
local ptype = peripheral.getType(name)
emit("##########################################")
emit("Navn: " .. name .. " Type: " .. tostring(ptype))
emit("------------------------------------------")
local methods = peripheral.getMethods(name)
table.sort(methods)
for _, m in ipairs(methods) do
-- proev aa kalle metoden uten argumenter; mange er gettere
local ok, res = pcall(function() return peripheral.call(name, m) end)
if ok then
emit(string.format(" %s() = %s", m, dump(res)))
else
-- feilet (trenger nok argumenter) -- bare noter signaturen
emit(string.format(" %s() -> [krever arg / feil: %s]", m, tostring(res):gsub("%s+"," ")))
end
end
emit("")
end
emit("=== FERDIG -> " .. OUT .. " ===")
file.close()

View File

@@ -1,3 +1,4 @@
ap.lua
autopilot/altitude.lua
autopilot/calibration.lua
autopilot/config.lua
@@ -11,17 +12,6 @@ autopilot/pitch.lua
autopilot/safety.lua
autopilot/sensors.lua
autopilot/utils.lua
calib.txt
cockpit.lua
diag.lua
pid_gains.txt
pilot_startup.lua
probe.lua
scan.lua
scan.txt
sides.txt
stabilize.lua
stabilizev2.lua
stabilizeV2.lua
stabilizev2.lua
startup.lua
target.txt

View File

@@ -1,16 +0,0 @@
{
A_KI = 0.04,
P_KI = 0.05,
A_KD = 2.2,
P_KD = 0.7,
hoverMap = {},
altI = -0.27424191075575,
kCouple = -0.099857552664046,
lag = 5.4,
A_KP = 0.22,
P_KP = 0.22,
TRIM = 1.5,
pitchI = 20.305881842314,
K_PRATE = 0.08,
K_RATE = 1.2,
}

View File

@@ -1,2 +0,0 @@
-- startup for pilot-hus PC: auto-run cockpit
shell.run("cockpit")

View File

@@ -1,30 +0,0 @@
-- probe.lua -- list every peripheral this computer can see, with its methods.
-- Drop a computer next to a block (or wire it up) and run `probe`. UI text in English.
local function dump(name)
print(name .. " -> type: " .. tostring(peripheral.getType(name)))
local methods = peripheral.getMethods(name)
if methods and #methods > 0 then
table.sort(methods)
for _, m in ipairs(methods) do print(" ." .. m) end
else
print(" (no methods)")
end
end
print("=== DIRECT SIDES ===")
local any = false
for _, side in ipairs({ "top", "bottom", "left", "right", "front", "back" }) do
if peripheral.isPresent(side) then any = true; dump(side) else print(side .. " -> (nothing)") end
end
print("")
print("=== ALL NAMES (incl. wired modem network) ===")
local names = peripheral.getNames()
if #names == 0 then print("(none)") end
for _, n in ipairs(names) do dump(n) end
if not any and #names == 0 then
print("")
print(">>> Nothing exposes a peripheral here.")
end

View File

@@ -1,45 +0,0 @@
-- scan.lua
-- Oppdager peripheraler paa det monterte luftskipet.
-- Skriver til skjerm OG til scan.txt slik at resultatet kan deles.
-- Kjor paa CC-computeren NAR skipet er montert/lever.
local OUT = "scan.txt"
local file = fs.open(OUT, "w")
local function emit(line)
print(line)
file.writeLine(line)
end
emit("=== PERIPHERAL-SCAN ===")
emit("")
local names = peripheral.getNames()
if #names == 0 then
emit("Ingen peripheraler funnet.")
emit("Sjekk at skipet er montert og at computeren sitter paa contraptionen.")
file.close()
return
end
for _, name in ipairs(names) do
local ptype = peripheral.getType(name)
emit("------------------------------------------")
emit("Navn: " .. tostring(name))
emit("Type: " .. tostring(ptype))
emit("Metoder:")
local methods = peripheral.getMethods(name)
table.sort(methods)
for _, m in ipairs(methods) do
emit(" " .. m)
end
emit("")
end
emit("==========================================")
emit("Totalt " .. #names .. " peripheral(er).")
emit("Lagret til " .. OUT)
file.close()

View File

@@ -1,27 +0,0 @@
=== PERIPHERAL-SCAN ===
------------------------------------------
Navn: bottom
Type: create:fluid_tank
Metoder:
pullFluid
pushFluid
tanks
------------------------------------------
Navn: top
Type: gimbal_sensor
Metoder:
getAngles
getAnglesRad
------------------------------------------
Navn: right
Type: altitude_sensor
Metoder:
getAirPressure
getHeight
==========================================
Totalt 3 peripheral(er).
Lagret til scan.txt

View File

@@ -1,4 +0,0 @@
{
aft = "back",
fore = "right",
}

View File

@@ -1,571 +0,0 @@
-- stabilize.lua -- GENERELT selvkalibrerende hoyde+pitch PID for ballong-luftskip.
--
-- Ingen ballong-spesifikke tall hardkodet:
-- * svevegass laeres av hoyde-integralet
-- * trim-skjevhet (volum/CoM) laeres av pitch-integralet
-- * pitch-fortegn auto-detekteres ved oppstart
-- Det eneste skip-spesifikke er hvilken redstone-side hver ventil sitter paa.
--
-- Sensorer (auto-funnet paa type): altitude_sensor (getHeight), gimbal_sensor (getAngles)
-- Aktuator: redstone analog 0..MAX_SIGNAL -> ventil (mer = mer gass = mer loft)
--
-- BRUK:
-- stabilize cal -> logger sensoravlesninger til cal.log (styrer ingenting)
-- stabilize test -> kjente ventil-monstre -> test.log
-- stabilize -> kjorer regulatoren -> run.log
-- ============================ KONFIG ============================
-- Skip-spesifikt: hvilke redstone-sider mater ventilene ("left"/"right"/"front"/"back")
local FORE_SIDE = "right"
local AFT_SIDE = "back" -- nil hvis bare EN ventil (da deaktiveres pitch-styring)
local TARGET_PITCH = 0 -- onsket pitch (vannrett)
local TAKEOFF_OFFSET = 5 -- mal settes saa mange blokker over startposisjon
-- Start-gains (dempet; auto-tuneren justerer og lagrer dem videre)
local A_KP, A_KI, A_KD = 0.22, 0.04, 2.2 -- hoyde
local P_KP, P_KI, P_KD = 0.22, 0.05, 0.70 -- pitch
local AUTOTUNE = true -- maaler egen svingning og justerer gains
local TUNE_INTERVAL = 12 -- sekunder mellom hver justering (KI laerer trim-skjevheten)
local MAX_SIGNAL = 7 -- maks redstone. Hold i responsivt omraade (loftet ditt topper ~5).
local MIN_SIGNAL = 1 -- gulv paa TOTAL-loft (collective) -> mykt fall, aldri fritt fall.
local LAG = 2.0 -- sek: tregheten i loft-respons. Regulatoren forutser saa langt fram.
local SLEW = 2 -- maks signal-endring per tick (demper voldsomme hopp)
local HOVER_SEED = 4.5 -- start-gjetning paa svevegass (naer ekte hover; kartet finjusterer)
-- Pitch-fortegn: 0 = auto-detekter ved oppstart, ellers +1/-1 manuelt
local PITCH_SIGN = 0
local PITCH_INDEX = 1 -- hvilket tall fra getAngles er pitch
-- Rock-solid hold (Tier 1) + place-and-forget (Tier 2)
local DEADBAND_H = 1.5 -- blokker: innenfor dette holdes hoyden rolig (ingen twitch)
local DEADBAND_P = 2.0 -- grader: innenfor dette holdes pitch rolig
local DERIV_FILTER = 0.3 -- lavpass paa avledning (0..1; lavere = mykere)
local SETTLE_TIME = 3.0 -- sek innenfor dodband for "STABIL"
-- KASKADE-regulering for hoyde (hindrer fartsoppbygging -> oversving)
local MAX_RATE = 2.0 -- blokker/sek: maks klatre-/synkerate
local K_POS = 0.4 -- posisjonsfeil -> onsket rate (5 blokker feil -> maks rate)
local K_RATE = 1.2 -- rate-feil -> gass (auto-tunes)
-- Grasios bevegelse + pitch-kaskade + robusthet
local MAX_ACCEL = 1.0 -- blokker/sek^2: myk ease-in/ut paa rate-endring
local MAX_PITCH_RATE = 4.0 -- grader/sek: maks pitch-endringsrate (grasios attityde)
local K_POS_P = 0.5 -- pitch-feil -> onsket pitch-rate
local K_PRATE = 0.08 -- pitch-rate-feil -> differanse (auto-tunes), mildt mot lag
local MAX_DIFF = 4.0 -- maks differanse: pitch kan ALDRI pumpe all gass til en ende
local GLITCH_H = 25 -- avvis hoyde-hopp storre enn dette per tick (sensor-glitch)
local DT = 0.2
-- ===============================================================
-- ---- selvlaert modell (Fase C/D): persisteres i profilen ----
local BIN_SIZE = 10 -- blokker per kart-bin
local hoverMap = {} -- hoyde-bin -> svevegass (laert kurve)
local kCouple = 0 -- collective->pitch koblingskoeffisient (laert)
local TRIM = 0 -- statisk balanse-differanse (maalt i balance-modus)
local altS = peripheral.find("altitude_sensor")
local gimS = peripheral.find("gimbal_sensor")
-- hoverFF: interpoler laert svevegass for en hoyde (fallback = HOVER_SEED)
local function hoverFF(h)
local b = h / BIN_SIZE
local lo, hi = math.floor(b), math.ceil(b)
local vlo, vhi = hoverMap[lo], hoverMap[hi]
if vlo and vhi then
if lo == hi then return vlo end
return vlo + (vhi - vlo) * (b - lo)
elseif vlo then return vlo
elseif vhi then return vhi end
local best, bestd
for k, v in pairs(hoverMap) do
local d = math.abs(k - b)
if not bestd or d < bestd then bestd, best = d, v end
end
return best or HOVER_SEED
end
-- learnHover: oppdater kart-bin med glidende snitt naar skipet er stabilt
local function learnHover(h, c)
local b = math.floor(h / BIN_SIZE + 0.5)
hoverMap[b] = hoverMap[b] and (hoverMap[b]*0.9 + c*0.1) or c
end
local function mapCount()
local n = 0; for _ in pairs(hoverMap) do n = n + 1 end; return n
end
local function logline(path, text)
local f = fs.open(path, "a"); if f then f.writeLine(text); f.close() end
end
local function angles()
local r = { gimS.getAngles() }
if type(r[1]) == "table" then r = r[1] end
return r
end
local function pitchRaw() return angles()[PITCH_INDEX] or 0 end
local function clampSig(x)
x = math.floor(x + 0.5)
return math.max(0, math.min(MAX_SIGNAL, x))
end
-- slew-begrenset utgang (mot forrige verdi)
local curFore, curAft = 0, 0
local function driveValves(fore, aft)
fore = clampSig(fore); aft = clampSig(aft)
if fore - curFore > SLEW then fore = curFore + SLEW end
if fore - curFore < -SLEW then fore = curFore - SLEW end
if aft - curAft > SLEW then aft = curAft + SLEW end
if aft - curAft < -SLEW then aft = curAft - SLEW end
curFore, curAft = fore, aft
redstone.setAnalogOutput(FORE_SIDE, fore)
if AFT_SIDE then redstone.setAnalogOutput(AFT_SIDE, aft) end
return fore, aft
end
local function allOff()
redstone.setAnalogOutput(FORE_SIDE, 0)
if AFT_SIDE then redstone.setAnalogOutput(AFT_SIDE, 0) end
curFore, curAft = 0, 0
end
-- ---- persistens: lagrede gains overlever omstart ----
local GAINS_FILE = "pid_gains.txt"
-- Lagrer gains (respons), arbeidspunkt OG laert modell (kart/kobling/lag).
local function saveState(altI, pitchI)
local f = fs.open(GAINS_FILE, "w")
f.write(textutils.serialise({A_KP=A_KP, A_KI=A_KI, A_KD=A_KD, P_KP=P_KP, P_KI=P_KI, P_KD=P_KD,
K_RATE=K_RATE, K_PRATE=K_PRATE, altI=altI, pitchI=pitchI, hoverMap=hoverMap, kCouple=kCouple, lag=LAG, TRIM=TRIM}))
f.close()
end
local function loadState()
if not fs.exists(GAINS_FILE) then return nil end
local f = fs.open(GAINS_FILE, "r"); local s = f.readAll(); f.close()
local g = textutils.unserialise(s)
if type(g) ~= "table" then return nil end
A_KP, A_KI, A_KD = g.A_KP or A_KP, g.A_KI or A_KI, g.A_KD or A_KD
P_KP, P_KI, P_KD = g.P_KP or P_KP, g.P_KI or P_KI, g.P_KD or P_KD
-- sikkerhet: kapp lagrede gains saa en korrupt profil ikke kan laste inn vanvittige verdier
A_KP, A_KD = math.min(A_KP, 0.6), math.min(A_KD, 3.0)
P_KP, P_KD = math.min(P_KP, 0.5), math.min(P_KD, 2.0)
K_RATE = math.min(g.K_RATE or K_RATE, 4.0)
K_PRATE = math.min(g.K_PRATE or K_PRATE, 1.0)
if type(g.hoverMap) == "table" then
for k, v in pairs(g.hoverMap) do hoverMap[tonumber(k) or k] = v end
end
kCouple = g.kCouple or kCouple
LAG = g.lag or LAG
TRIM = g.TRIM or TRIM
return g
end
-- ---- malhoyde: egen fil saa den overlever reload (place-and-forget) ----
local TARGET_FILE = "target.txt"
local function saveTarget(h)
local f = fs.open(TARGET_FILE, "w"); f.write(tostring(h)); f.close()
end
local function loadTarget()
if not fs.exists(TARGET_FILE) then return nil end
local f = fs.open(TARGET_FILE, "r"); local s = f.readAll(); f.close()
return tonumber(s)
end
-- ---- analyse av et feilvindu: amplitude (topp-til-topp) + null-kryssinger ----
local function analyze(win)
local mn, mx, cross = math.huge, -math.huge, 0
for i, v in ipairs(win) do
if v < mn then mn = v end
if v > mx then mx = v end
if i > 1 and ((win[i-1] < 0) ~= (v < 0)) then cross = cross + 1 end
end
return (mx - mn), cross
end
-- ============================ MENY (UI) ============================
local function pause() print(""); print("[ Enter for menu ]"); read() end
local function menu()
while true do
term.clear(); term.setCursorPos(1,1)
print("====== BALLOON AUTOPILOT ======")
print("")
print(" 1) Start stabilization")
print(" 2) Set target altitude")
print(" 3) Balance check (level the ship)")
print(" 4) Calibrate sensors")
print(" 5) Actuator test")
print(" 6) Reset profile (delete learned data)")
print(" 7) Show saved profile")
print(" 8) Exit")
print("")
local sensorsOk = peripheral.find("altitude_sensor") and peripheral.find("gimbal_sensor")
print(sensorsOk and "Sensors: OK" or "Sensors: MISSING - check wiring")
print(fs.exists(GAINS_FILE) and "Profile: saved" or "Profile: none (fresh)")
local tH = loadTarget()
print("Target: " .. (tH and string.format("%.1f", tH) or "where I start"))
print("")
write("Choice: ")
local k = read()
if k == "1" then return "run"
elseif k == "2" then
write("Target altitude (empty = where I start): ")
local s = read()
if s == "" then
if fs.exists(TARGET_FILE) then fs.delete(TARGET_FILE) end
print("Target cleared - holds where it starts.")
elseif tonumber(s) then
saveTarget(tonumber(s)); print("Target altitude set to " .. s)
else print("Invalid number.") end
pause()
elseif k == "3" then return "balance"
elseif k == "4" then return "cal"
elseif k == "5" then return "test"
elseif k == "6" then
if fs.exists(GAINS_FILE) then fs.delete(GAINS_FILE) end
print("Profile reset - fresh calibration next start."); pause()
elseif k == "7" then
if fs.exists(GAINS_FILE) then
local f = fs.open(GAINS_FILE, "r"); print(f.readAll()); f.close()
else print("No saved profile yet.") end
pause()
elseif k == "8" then return "exit"
end
end
end
-- ============================ AUTO-SIGN ============================
-- Maaler hvilken vei pitch beveger seg av positiv differanse + dodtid (lag).
-- Krever at skipet kan tilte (helst i lufta). Faller tilbake til +1 hvis ingen respons.
local function detectPitchSign()
if not AFT_SIDE then return 1 end
print("Auto-sign: measuring lag, lifting, nudging...")
local h0 = altS.getHeight()
local stepLvl = math.min(MAX_SIGNAL, 5)
local lvl = stepLvl
local t0 = os.clock()
local lagMeasured = nil
while os.clock() - t0 < 12 do
driveValves(stepLvl, stepLvl)
local rise = altS.getHeight() - h0
if rise > 1 and not lagMeasured then lagMeasured = os.clock() - t0 end
if rise > 2 then break end -- luftbaaren
sleep(0.2)
end
if lagMeasured then
LAG = math.max(0.5, math.min(6, lagMeasured))
print(string.format("Measured lag = %.1f s", LAG))
end
sleep(0.5)
local p0 = pitchRaw()
local t1 = os.clock()
while os.clock() - t1 < 3 do
driveValves(math.min(MAX_SIGNAL, lvl + 3), math.max(0, lvl - 3))
sleep(0.2)
end
local p1 = pitchRaw()
allOff()
local dp = p1 - p0
if math.abs(dp) < 1.0 then
print("Weak response -- using +1. Override PITCH_SIGN if wrong.")
return 1
end
local s = dp >= 0 and 1 or -1 -- sign(dp): gir negativ tilbakekobling
print(string.format("dpitch %.1f -> PITCH_SIGN = %d", dp, s))
sleep(1)
return s
end
-- modus fra argument, ellers vis meny
local mode = ({...})[1]
if not mode then mode = menu() end
if mode == "exit" then return end
-- alle modus trenger sensorene
if not (altS and gimS) then
print("Sensors missing (altitude_sensor + gimbal_sensor). Check wiring.")
return
end
-- ============================ AUTO-BALANCE (BRUTE-FORCE SWEEP) ============================
-- Sveiper TRIM gjennom hele omraadet, maaler pitch ved hver verdi, velger den flateste.
-- Trenger INGEN fortegn-deteksjon -> enkelt og robust. Lagres som permanent TRIM.
if mode == "balance" then
local g0 = loadState() -- behold eksisterende laerte data
local ground = altS.getHeight()
local target = ground + 10
local AIRBORNE = ground + 4 -- under dette = staar paa bakken (ugyldig)
local SWEEP_MAX, STEP = 6, 0.5
local MAXWAIT, WIN, SETTLE = 18, 14, 1.2
-- 1) LOFT skipet til luftbaaren (begge ventiler likt) FOR vi maaler noe
print("AUTO-BALANCE - lifting ship first...")
local liftOk = false
local tLift = os.clock()
while os.clock() - tLift < 18 do
local h = altS.getHeight()
local g = clampSig(HOVER_SEED + 0.7*(target - h))
driveValves(g, g)
if h > ground + 6 then liftOk = true; break end
term.clear(); term.setCursorPos(1,1)
print("AUTO-BALANCE")
print(string.format("lifting... %.1f / %.0f", h, target))
sleep(0.2)
end
if not liftOk then
allOff(); print("Could not get airborne - balloons too weak?"); return
end
-- 2) SVEIP trim, men KUN gyldig naar skipet faktisk svever (h >= AIRBORNE)
local best, bestAbs = nil, math.huge
local ok, err = pcall(function()
local t = -SWEEP_MAX
while t <= SWEEP_MAX + 0.001 do
local hist, tStart, settledP = {}, os.clock(), nil
while os.clock() - tStart < MAXWAIT do
local h = altS.getHeight()
local coll = clampSig(HOVER_SEED + 0.7*(target - h)) -- hold hoyden
local fore, aft = driveValves(coll + t, coll - t)
local p = pitchRaw()
hist[#hist+1] = p; if #hist > WIN then table.remove(hist, 1) end
local mn, mx = math.huge, -math.huge
for _, v in ipairs(hist) do mn = math.min(mn, v); mx = math.max(mx, v) end
local spread = (#hist >= WIN) and (mx - mn) or 999
if spread < SETTLE and h >= AIRBORNE then settledP = (mn + mx)/2 end -- gyldig KUN luftbaaren
term.clear(); term.setCursorPos(1,1)
print("==== AUTO-BALANCE SWEEP ====")
print(string.format(" TRIM %+.1f pitch %+.1f", t, p))
print(string.format(" fore %d/aft %d height %.1f", fore, aft, h))
print(" " .. (h < AIRBORNE and "SINKING - invalid trim" or (settledP and "settled" or "settling...")))
print(string.format(" best: %s", best and string.format("TRIM %+.1f (|p| %.1f)", best, bestAbs) or "none yet"))
if settledP then break end
sleep(0.2)
end
-- bare gyldig hvis den satte seg MENS luftbaaren
if settledP and math.abs(settledP) < bestAbs then
best, bestAbs = t, math.abs(settledP)
TRIM = best; saveState(g0 and g0.altI or 0, g0 and g0.pitchI or 0) -- lagre lopende
end
t = t + STEP
end
end)
allOff()
if best then
TRIM = best; saveState(g0 and g0.altI or 0, g0 and g0.pitchI or 0)
print(string.format("Best TRIM = %+.2f (|pitch| %.1f). Saved.", best, bestAbs))
else
print("No valid (airborne) trim found.")
end
if not ok and err ~= "Terminated" then error(err, 0) end
return
end
-- ============================ CAL ============================
if mode == "cal" then
local LOG = "cal.log"; if fs.exists(LOG) then fs.delete(LOG) end
logline(LOG, "# cal: t, height, airpressure, a1, a2, a3")
print("CALIBRATION -> cal.log (controls nothing). CTRL+T to stop.")
while true do
local h = altS.getHeight()
local press = altS.getAirPressure and altS.getAirPressure() or 0
local a = angles()
logline(LOG, string.format("%.2f, %.2f, %.2f, %.3f, %.3f, %.3f",
os.clock(), h, press or 0, a[1] or 0, a[2] or 0, a[3] or 0))
term.clear(); term.setCursorPos(1,1)
print(string.format("Height %.1f Pressure %.2f", h, press or 0))
print(string.format("Angles %.2f / %.2f / %.2f", a[1] or 0, a[2] or 0, a[3] or 0))
sleep(0.3)
end
end
-- ============================ TEST ============================
if mode == "test" then
local LOG = "test.log"; if fs.exists(LOG) then fs.delete(LOG) end
logline(LOG, "# test: t, phase, fore, aft, height, pitch")
local steps = {
{"balanced", 6, 6, 8}, {"more_FRONT", 10, 3, 8},
{"balanced", 6, 6, 6}, {"more_AFT", 3, 10, 8}, {"off", 0, 0, 4},
}
print("TEST MODE -> test.log. CTRL+T to stop.")
local ok, err = pcall(function()
for _, s in ipairs(steps) do
local phase, fore, aft, secs = s[1], s[2], s[3], s[4]
local t0 = os.clock()
while os.clock() - t0 < secs do
driveValves(fore, aft)
local h, p = altS.getHeight(), pitchRaw()
logline(LOG, string.format("%.2f, %s, %d, %d, %.2f, %.2f", os.clock(), phase, fore, aft, h, p))
term.clear(); term.setCursorPos(1,1)
print("Phase: "..phase); print(string.format("fore %d aft %d", fore, aft))
print(string.format("Height %.1f Pitch %.1f", h, p))
sleep(0.2)
end
end
end)
allOff(); print("Valves 0. Test done -> test.log.")
if not ok and err ~= "Terminated" then error(err, 0) end
return
end
-- ============================ REGULERING ============================
local loaded = loadState()
if loaded then print(string.format("Loaded profile (%d map pts, lag %.1f, kC %.2f)",
mapCount(), LAG, kCouple)) end
local sign = PITCH_SIGN
if sign == 0 then sign = detectPitchSign() end -- kan over-skrive lag med fersk maling
-- Malhoyde: lagret (place-and-forget) ELLER der skipet starter
local TARGET_FINAL = loadTarget() or (altS.getHeight() + TAKEOFF_OFFSET)
print(string.format("Target = %.1f (sign %d)", TARGET_FINAL, sign))
local LOG = "run.log"; if fs.exists(LOG) then fs.delete(LOG) end
logline(LOG, string.format("# run: target_h=%.1f sign=%d A(%.2f,%.3f,%.2f) P(%.2f,%.3f,%.2f) max=%d",
TARGET_FINAL, sign, A_KP, A_KI, A_KD, P_KP, P_KI, P_KD, MAX_SIGNAL))
logline(LOG, "# t, height, pitch, eH, eP, collective, diff, fore, aft")
-- Integralet trimmer nå bare RESTEN (hoverFF gir grunngassen) -> start nær 0
local altI = (loaded and loaded.altI) or 0
local pitchI = (loaded and loaded.pitchI) or 0
local lastH = altS.getHeight()
local lastP = pitchRaw()
local fVspd, fPr = 0, 0 -- filtrerte avledninger (lavpass)
local cmdRate = 0 -- akselerasjons-begrenset onsket rate (grasios bevegelse)
local badCount = 0 -- online fortegns-vakt
local winH, winP = {}, {} -- feilvinduer for auto-tuning
local lastTune = os.clock()
local tuneMsg = "waiting..."
local settledFor = 0 -- sek innenfor dodband
print("Self-calibrating PID active -> run.log. CTRL+T to stop.")
local ok, err = pcall(function()
while true do
-- glitch-vern: avvis urealistiske sensor-hopp (robusthet)
local rawH = altS.getHeight()
local height = (lastH and math.abs(rawH - lastH) > GLITCH_H) and lastH or rawH
local pitch = pitchRaw()
-- HOYDE KASKADE: posisjon -> begrenset+myk onsket rate -> gass
local vspd = (height - lastH) / DT; lastH = height
fVspd = DERIV_FILTER*vspd + (1-DERIV_FILTER)*fVspd -- lavpass paa stigerate
local eH = TARGET_FINAL - height
local inH = math.abs(eH) < DEADBAND_H
local ff = hoverFF(TARGET_FINAL) -- laert svevegass for malet
local desRate = math.max(-MAX_RATE, math.min(MAX_RATE, K_POS*eH)) -- ytre: begrenset rate
-- akselerasjons-grense: ramp cmdRate mot desRate -> myk ease-in/ease-out
local dr = math.max(-MAX_ACCEL*DT, math.min(MAX_ACCEL*DT, desRate - cmdRate))
cmdRate = cmdRate + dr
local rateErr = cmdRate - fVspd -- indre: hold raten
local rawColl = ff + A_KI*altI + K_RATE*rateErr
local collective = math.max(0, math.min(MAX_SIGNAL, rawColl)) -- KLEM til ventilomraade
local excess = collective - ff -- klatrekraft (for avkobling)
-- integral paa RATE-feil (IKKE posisjon!) -> trimmer FF-gass, kan ikke vinde opp paa stor eH
local satLow, satHigh = rawColl <= 0, rawColl >= MAX_SIGNAL
if (not satLow and not satHigh) or (satLow and rateErr > 0) or (satHigh and rateErr < 0) then
altI = math.max(-150, math.min(150, altI + rateErr*DT))
end
-- PITCH KASKADE: pitch-feil -> begrenset onsket pitch-rate -> differanse (grasios attityde)
local diff, eP = 0, 0
local inP = true
if AFT_SIDE then
local pr = (pitch - lastP) / DT; lastP = pitch
fPr = DERIV_FILTER*pr + (1-DERIV_FILTER)*fPr
eP = TARGET_PITCH - pitch
inP = math.abs(eP) < DEADBAND_P
local desPRate = math.max(-MAX_PITCH_RATE, math.min(MAX_PITCH_RATE, K_POS_P*eP))
local pRateErr = desPRate - fPr
-- avkobling (kCouple*excess) motvirker dreiemoment fra gass-endring + laert trim
-- dynamisk pitch-korreksjon klemmes (mot bang-bang), saa legges statisk TRIM oppaa
local dyn = sign * (K_PRATE*pRateErr + P_KI*pitchI + kCouple*excess)
dyn = math.max(-MAX_DIFF, math.min(MAX_DIFF, dyn))
diff = TRIM + dyn -- per-ventil clampSig i driveValves binder sluttverdien
-- laer decoupling: RIKTIG fortegn (gradient) -> kansellerer klatre-pitchen
if math.abs(excess) > 0.5 then
kCouple = math.max(-2, math.min(2, kCouple + 0.002 * eP * excess))
end
local fSat = (collective+diff) <= 0 or (collective+diff) >= MAX_SIGNAL
local aSat = (collective-diff) <= 0 or (collective-diff) >= MAX_SIGNAL
if not inP and not (fSat or aSat) then pitchI = math.max(-200, math.min(200, pitchI + sign*eP*DT)) end
if math.abs(pitch) > 25 then badCount = badCount + 1 else badCount = 0 end
if badCount >= 20 then
sign = -sign; pitchI = 0; badCount = 0
print("Pitch diverging -> flipping sign to " .. sign)
end
end
-- settle-deteksjon: innenfor dodband paa begge akser + naa malet
local atTarget = math.abs(TARGET_FINAL - height) < DEADBAND_H
if inH and inP and atTarget then settledFor = settledFor + DT else settledFor = 0 end
local settled = settledFor >= SETTLE_TIME
-- LAER svevegass-kart naar stabilt -> kartet overtar, integralet lekkes mot 0
if inH and inP and math.abs(fVspd) < 0.4 then
learnHover(height, collective)
altI = altI * 0.97
end
-- gulv paa TOTAL-loft (ikke per ventil): kan synke til mal + beholder pitch-differanse
collective = math.max(MIN_SIGNAL, collective)
local fore, aft = driveValves(collective + diff, collective - diff)
-- ---- AUTO-TUNING: KUN naar nær malet (hold) -> reise-transienter lurer den ikke ----
local tuneNear = math.abs(TARGET_FINAL - height) < 5
and (not AFT_SIDE or math.abs(TARGET_PITCH - pitch) < 8)
if AUTOTUNE and not tuneNear then
winH, winP = {}, {}; lastTune = os.clock(); tuneMsg = "traveling (no tune)"
elseif AUTOTUNE then
winH[#winH+1] = eH
winP[#winP+1] = (TARGET_PITCH - pitch)
if os.clock() - lastTune >= TUNE_INTERVAL then
local ampH, crH = analyze(winH)
if crH >= 4 and ampH > 4 then -- oscillating -> damp inner rate gain
K_RATE = math.max(0.2, K_RATE*0.82)
tuneMsg = "alt: damping"
elseif ampH > 5 and crH < 2 then -- genuinely sluggish near target -> mild sharpen
K_RATE = math.min(4.0, K_RATE*1.08)
tuneMsg = "alt: sharpening"
else tuneMsg = "alt: ok" end
if AFT_SIDE then
local ampP, crP = analyze(winP)
if crP >= 4 and ampP > 6 then
K_PRATE = math.max(0.03, K_PRATE*0.82)
tuneMsg = tuneMsg .. " | pitch: damping"
elseif ampP > 7 and crP < 2 then
K_PRATE = math.min(0.15, K_PRATE*1.05) -- pitch: hold mildt, svingning er verre enn treghet
tuneMsg = tuneMsg .. " | pitch: sharpening"
else tuneMsg = tuneMsg .. " | pitch: ok" end
end
saveState(altI, pitchI)
logline(LOG, string.format("# TUNE %s -> A(%.3f,%.3f,%.2f) P(%.3f,%.3f,%.2f)",
tuneMsg, A_KP, A_KI, A_KD, P_KP, P_KI, P_KD))
winH, winP = {}, {}
lastTune = os.clock()
end
end
logline(LOG, string.format("%.2f, %.2f, %.2f, %.2f, %.2f, %.2f, %.2f, %d, %d",
os.clock(), height, pitch, eH, TARGET_PITCH - pitch, collective, diff, fore, aft))
term.clear(); term.setCursorPos(1,1)
local maxed = collective >= MAX_SIGNAL-0.5 and math.abs(eH) > 5 and math.abs(fVspd) < 0.3
print(settled and "*** STABLE ***"
or (maxed and "!! MAX LIFT - target unreachable")
or (math.abs(eH) > DEADBAND_H and ">> moving..." or ".. holding .."))
print(string.format("Height %6.1f / %.0f (e %.1f)", height, TARGET_FINAL, TARGET_FINAL-height))
print(string.format("Pitch %6.1f / %.0f (e %.1f)", pitch, TARGET_PITCH, TARGET_PITCH-pitch))
print(string.format("fore %2d aft %2d %s", fore, aft, (inH and inP) and "[hold]" or ""))
print(string.format("model: ff~%.1f kR %.2f kPR %.2f kC %.2f", ff, K_RATE, K_PRATE, kCouple))
print(string.format("rate %.1f/%.1f | map %d | trim~%.1f", fVspd, cmdRate, mapCount(), sign*P_KI*pitchI))
print("tune: " .. tuneMsg)
sleep(DT)
end
end)
allOff()
saveState(altI, pitchI) -- lagre nyeste arbeidspunkt + gains
print("Valves set to 0. Operating point saved. Exited.")
if not ok and err ~= "Terminated" then error(err, 0) end

View File

@@ -1,9 +1,6 @@
-- stabilizeV2.lua -- launcher for v3 observer-autopiloten i autopilot/.
-- Videresender argumenter:
-- stabilizeV2 -> meny
-- stabilizeV2 monitor -> Lag 1
-- stabilizeV2 signtest -> §13.2
-- stabilizeV2 manual 4 3 -> Lag 2 (fore=4, aft=3)
-- alias: videresender til 'ap' (beholdt for gammel muskelhukommelse).
-- VIKTIG: begge case-variantene (stabilizeV2/stabilizev2) har IDENTISK innhold,
-- saa et case-insensitivt filsystem (Windows-hostet server) kan aldri gi rekursjon.
local args = { ... }
local unpack = table.unpack or unpack
shell.run("autopilot/main", unpack(args))
shell.run("ap", unpack(args))

View File

@@ -1,5 +1,6 @@
-- stabilizev2.lua -- lowercase alias for stabilizeV2 (CC is case-sensitive on Linux).
-- Forwards all args to the real launcher so either case works.
-- alias: videresender til 'ap' (beholdt for gammel muskelhukommelse).
-- VIKTIG: begge case-variantene (stabilizeV2/stabilizev2) har IDENTISK innhold,
-- saa et case-insensitivt filsystem (Windows-hostet server) kan aldri gi rekursjon.
local args = { ... }
local unpack = table.unpack or unpack
shell.run("stabilizeV2", unpack(args))
shell.run("ap", unpack(args))

View File

@@ -1,3 +1,3 @@
-- Maskinrom autopilot: STANDBY er default paa -- staar klar og lytter etter ENGAGE
-- fra pilot-konsollen. CTRL+T -> kjor "stabilizeV2" for menyen (wiring/sign test/setup).
shell.run("stabilizeV2", "standby")
-- Maskinrom-PC: boot -> setup-wizard-prompt hvis skipet mangler profil, ellers standby
-- (lytter etter ENGAGE fra pilot-konsollen). CTRL+T og kjor "ap" for menyen.
shell.run("ap", "boot")

View File

@@ -1 +0,0 @@
100

View File

@@ -87,7 +87,7 @@ local function drawStatus()
if inFlight then lines[#lines + 1] = { string.format("VSPD %+.2f", s.vVel or 0), C.val } end
if s.state == "IDLE" then lines[#lines + 1] = { s.ready and "ready -> ENGAGE" or "needs setup", s.ready and C.ok or C.warn } end
else
if s.state == "IDLE" then lines[#lines + 1] = { s.ready and "ready -> ENGAGE" or "needs setup (Wiring+Sign)", s.ready and C.ok or C.warn } end
if s.state == "IDLE" then lines[#lines + 1] = { s.ready and "ready -> ENGAGE" or "needs setup (wizard on AP)", s.ready and C.ok or C.warn } end
lines[#lines + 1] = { string.format("ALT %.1f > %.0f", s.height or 0, s.target or 0), C.val }
lines[#lines + 1] = { string.format("PITCH %+.1f deg", s.pitch or 0), pcolor }
if inFlight then

View File

@@ -1,19 +1,35 @@
-- install.lua -- role-based installer for the Create: Aeronautics airship autopilot.
-- Fetches every file for the chosen role over HTTP and writes it to this computer,
-- so it works on any server regardless of the computer's id. Re-run to update.
-- install.lua -- ETT entry-point for hele autopiloten. Samme kommando paa begge PC-er:
--
-- Usage on a CC computer:
-- wget run https://gt.zyon.no/Stian/airship-autopilot/raw/branch/main/install.lua
-- wget run https://gt.zyon.no/Stian/airship-autopilot/raw/branch/main/install.lua autopilot
-- wget run https://gt.zyon.no/Stian/airship-autopilot/raw/branch/main/install.lua cockpit
--
-- Installeren DETEKTERER selv hvilken PC den staar paa (flysensorer = autopilot,
-- monitor = cockpit), installerer riktig rolle, og gaar RETT videre i setup/oppstart --
-- ingen reboot, ingen rollenavn aa huske. Re-run samme kommando for aa oppdatere.
-- Eksplisitt rolle som argument overstyrer deteksjonen (avansert):
-- wget run .../install.lua autopilot | cockpit
--
-- Roles:
-- autopilot machine-room PC: full v3 observer autopilot (boots to standby)
-- cockpit pilot-house PC: control console
-- autopilot machine-room PC: full v3 observer autopilot (setup wizard / standby)
-- cockpit pilot-house PC: control console (monitor + modem)
--
-- Per-skip TILSTAND (sides/calib/target/settings) shippes ALDRI av installeren -- den maales
-- av setup-wizarden PAA skipet. Ved re-install kan profilen beholdes eller nullstilles.
-- Filer som er fjernet fra manifestet siden sist (eller kjente legacy-filer) ryddes bort.
local BASE = "https://gt.zyon.no/Stian/airship-autopilot/raw/branch/main"
local ROLES = { "autopilot", "cockpit" }
-- per-skip tilstand: roeres aldri automatisk, men tilbys nullstilt ved install
local STATE_FILES = { "sides.txt", "calib.txt", "target.txt", "settings.txt" }
-- kjente filer fra eldre versjoner som ikke lenger hoerer til rollen (engangs-rydding;
-- videre oppdateringer ryddes automatisk via installasjons-loggen .installed_<role>)
local LEGACY = {
autopilot = { "stabilize.lua", "diag.lua", "probe.lua", "scan.lua", "scan.txt",
"pid_gains.txt", "cockpit.lua", "pilot_startup.lua" },
cockpit = {},
}
local args = { ... }
-- ---- helpers ---------------------------------------------------------------
@@ -41,9 +57,17 @@ local function writeFile(path, data)
f.close()
end
local function inList(list, x)
for _, v in ipairs(list) do if v == x then return true end end
return false
end
local function chooseRole()
print("Select role to install:")
for i, r in ipairs(ROLES) do print((" %d) %s"):format(i, r)) end
print(" 1) autopilot (machine room -- has the")
print(" altitude + gimbal sensors)")
print(" 2) cockpit (pilot house -- has a")
print(" monitor + modem)")
write("> ")
local sel = read()
local n = tonumber(sel)
@@ -52,6 +76,18 @@ local function chooseRole()
die("Unknown role: " .. tostring(sel))
end
-- gjett rollen ut fra hva som er koblet til: flysensorer er det sterkeste signalet
-- (maskinrommet kan ogsaa ha monitor), deretter monitor -> cockpit
local function detectRole()
if peripheral.find("altitude_sensor") or peripheral.find("gimbal_sensor") then
return "autopilot", "flight sensors attached"
end
if peripheral.find("monitor") then
return "cockpit", "monitor attached, no flight sensors"
end
return nil
end
-- ---- preflight -------------------------------------------------------------
if not http then
@@ -61,12 +97,23 @@ if not http then
end
local role = args[1]
if not role then
role = chooseRole()
else
if role then
local ok = false
for _, r in ipairs(ROLES) do if r == role then ok = true end end
if not ok then die("Unknown role '" .. role .. "'. Valid: " .. table.concat(ROLES, ", ")) end
else
local guess, why = detectRole()
if guess then
print("This looks like the " ..
(guess == "autopilot" and "MACHINE-ROOM" or "PILOT-HOUSE") .. " PC")
print(" (" .. why .. ")")
write("Install '" .. guess .. "'? [Y/n] ")
role = (read():lower() == "n") and chooseRole() or guess
else
print("Could not auto-detect this PC's role")
print("(no flight sensors or monitor attached).")
role = chooseRole()
end
end
-- ---- fetch manifest --------------------------------------------------------
@@ -108,8 +155,57 @@ if #fail > 0 then
die("Install incomplete -- re-run after fixing the errors above.")
end
-- ---- rydd: legacy + filer fjernet fra manifestet siden forrige install ------
local removed = 0
for _, f in ipairs(LEGACY[role] or {}) do
if fs.exists(f) then fs.delete(f); removed = removed + 1; print("removed legacy: " .. f) end
end
local RECORD = ".installed_" .. role
if fs.exists(RECORD) then
local rf = fs.open(RECORD, "r"); local prev = rf.readAll(); rf.close()
for line in (prev or ""):gmatch("[^\r\n]+") do
local p = line:match("^%s*(.-)%s*$")
if p ~= "" and not inList(files, p) and not inList(STATE_FILES, p) and fs.exists(p) then
fs.delete(p); removed = removed + 1; print("removed obsolete: " .. p)
end
end
end
writeFile(RECORD, table.concat(files, "\n") .. "\n")
if removed > 0 then print(("Cleaned up %d old file(s)."):format(removed)) end
-- ---- skip-profil: behold eller nullstill (ETTER vellykket install, foer boot) ----
-- (kun autopilot-rollen har skip-tilstand; sletter aldri noe hvis nedlastingen feilet)
if role == "autopilot" then
local found = {}
for _, f in ipairs(STATE_FILES) do if fs.exists(f) then found[#found + 1] = f end end
if #found > 0 then
print("Existing ship profile found:")
print(" " .. table.concat(found, ", "))
write("Keep it? (n = new/rewired ship) [Y/n] ")
if read():lower() == "n" then
for _, f in ipairs(found) do fs.delete(f) end
print("Profile reset -- the setup wizard takes over.")
end
end
end
-- ---- ferdig: gaa RETT videre (ingen reboot noedvendig) ----------------------
-- startup.lua er installert, saa alt auto-starter ved fremtidige reboots; naa
-- fortsetter vi direkte inn i samme flyt som boot ville gitt.
term.setTextColor(colors.green)
print("Done. Reboot to auto-start (startup.lua).")
print("Done. (auto-starts on every reboot)")
term.setTextColor(colors.white)
write("Reboot now? [y/N] ")
if read():lower() == "y" then os.reboot() end
print("")
if role == "autopilot" then
print("Continuing into setup/standby...")
sleep(1.0)
shell.run("ap", "boot")
else
print("Starting the pilot console...")
sleep(1.0)
shell.run("cockpit")
end