Free cloud gaming sounds like a gift to a handheld. A data center renders the frames, your Steam Deck decodes a video stream, and the battery should last longer than it does running the game locally. That is the pitch. Almost nobody measures it. This tutorial builds a small Python logger that does, and it ends with a table that tells you, for your games and your Wi-Fi, how Steam Deck battery life changes when you stream instead of play on the device.
The context matters this month. Valve shipped SteamOS 3.9.5 as the current stable build, and Steam Deck HQ reported that the 3.9 line improved idle power use. Idle is not where cloud sessions live, though. A stream keeps the Wi-Fi radio busy and the video decoder running for the whole session, so the only honest answer comes from watts logged under the same conditions. If you have not set up the service yet, start with the guide to GeForce NOW on the Steam Deck and come back with a working login.
You will finish with a logger that runs as a background service, three tiny launcher shortcuts that switch labels from Game Mode, and a report command that prints average watts, estimated runtime and gigabytes per hour for each way of playing. The whole project is one standard-library Python file. I ran its parsing and report logic against a synthetic battery directory before publishing, so the code below does what it says. The hardware readings, of course, are yours to collect.
Don’t miss new tech stories on Google
Add Tech Insider once in the Google app and our stories appear in your news suggestions.
Add Now
Why free cloud gaming changes Steam Deck battery life in two directions
Local play and streamed play spend power in different places. When a game runs on the Deck, the APU does the rendering, the memory runs hot, and the fan spins up. When the same game streams from GeForce NOW or Xbox Cloud Gaming, the APU shrinks to a video decoder plus an input forwarder. That should cut draw. Two things push the other way. The Wi-Fi radio stays in a high-power state for the entire session, and the screen sits at full brightness because streamed games rarely let you dim the picture without hurting readability.
So the result depends on how hard your local game works the chip. A demanding title that pins the APU near its limit gives the stream a big head start. A light 2D game that already sips power can come out worse in the cloud, because the radio and decoder cost more than the game itself ever did. Rather than argue about it, we measure. The method below gives you a number per game and per service.
There is a second reason to measure. Free tiers come with strings. The GeForce NOW free tier is time-limited and can queue, and Microsoft has moved Xbox Cloud Gaming to monthly hour allowances by plan. Every minute you burn on a test run is a minute you do not get to play. A good test protocol is short, repeatable and honest about what it costs, and that is how this one is built. For a wider tour of the free options on other devices, the free cloud gaming setup guide covers four services end to end.
What the logger actually measures
The battery gauge in the Deck exposes voltage, current and sometimes power through the Linux power-supply files. The script multiplies current by voltage when no direct power file exists, samples every five seconds, and writes one CSV row per sample with a label, the charge state, the battery percentage and the Wi-Fi download rate. The download rate does double duty. It shows how much data a streaming minute really uses, and it lets the report throw away queue-wait samples that would otherwise make the cloud look better than it is.
Prerequisites and versions
You need a Steam Deck and a few free accounts. Nothing here requires a paid subscription, although a paid Xbox plan makes the Xbox run easier. Check each line before you start, because a mismatch in the first row is the most common reason the later steps fail.
| Item | Version or requirement | Why it matters |
|---|---|---|
| Steam Deck | Steam Deck OLED (Valve’s newest model) or the original LCD model | The script reads the battery files that both models expose through the Linux power-supply interface |
| SteamOS | Stable channel, 3.9.5 or newer | Keeps firmware and power-management changes constant between runs |
| Python | 3.8 or newer (the script uses statistics.fmean) | Ships with SteamOS in Desktop Mode, no install needed |
| GeForce NOW | Free account, app launched from the Steam library | Provides the free-tier streaming run |
| Xbox Cloud Gaming | Microsoft account, Microsoft Edge from the Discover store (Flatpak, latest version) | Provides the browser-based streaming run |
| Network | 5 GHz Wi-Fi with the Deck in the same room as the router | Keeps radio power draw and signal strength comparable across runs |
| Second device (optional) | Any machine with an SSH client | Lets you read the CSV without typing on the Deck |
| Time | About 60 minutes of hands-on work plus 25-minute test windows | Each run needs a stable stretch of play |
A few practical notes. Keep the charger unplugged during every measurement window, because the fuel gauge reports charge current when power flows in and the report ignores those samples. If you normally play docked, the Steam Deck docking station walkthrough explains why dock-powered sessions need a different test. Also pick one Steam Deck Verified game that you own locally and that streams well. The same title must run in both modes, otherwise you compare games instead of delivery methods.
One news item is worth a sentence. Several outlets, including PC Guide, reported on November 3 that the Steam Deck store page started redirecting to the Steam homepage. Valve has not explained the change, and nothing confirms a discontinuation. Your existing Deck keeps working, and so does everything in this tutorial.
What free cloud gaming means on a Steam Deck this month
The word free covers very different deals, and the test design depends on them. Here is the state of play as of November 6, 2026. The table is deliberately qualitative. Limits and prices move quickly, so read the live numbers in each service before you budget test time.
| Service | How it reaches the Deck | What you can test for $0 | Main catch |
|---|---|---|---|
| GeForce NOW | Native app route, check NVIDIA’s download page for the current Steam Deck steps | A free membership, sitting below two paid tiers in NVIDIA’s current lineup | Free sessions are time-limited and can queue, so you must restart and re-queue for long tests |
| Xbox Cloud Gaming | Browser route through Microsoft Edge | Game Pass trial offers, or the ad-supported free preview that Xbox Insiders could test from October 21 | Cloud hours are now capped per Game Pass plan, so every test run draws from your monthly allowance |
| Amazon Luna | No official Game Mode support confirmed | Not verified, plan details are unconfirmed this month | Excluded from this test until Amazon publishes a Steam Deck route |
| Local play | Native or Proton game on the device | Free, you own the game | The baseline that every cloud run is compared against |
For the full picture of how GeForce NOW, Luna and Xbox compare on library and quality, the three-way cloud gaming comparison goes deeper than this tutorial needs. Here the question is narrower: what do the first two cost your battery? Luna sits out because the sheet of facts I work from shows no confirmed Steam Deck route, and a test needs a route you can reproduce. The harness handles any extra service you label yourself, so adding Luna later takes one new marker shortcut.
Why the Xbox allowance shapes the test
Microsoft announced on September 3 that Game Pass cloud gaming would move from uncapped streaming to monthly allowances by plan, with extra time sold separately. Coverage of the October 21 Insider preview, including Polygon’s report on the pay-as-you-go prices, put the introductory packs at 10 hours for $3.99 and up. Treat those as reported figures and check the Xbox Store for yours. The practical effect is simple. Two 20-minute Xbox runs cost you 40 minutes of allowance, and the protocol keeps cloud windows short for exactly that reason.
Test design: labels, controls and the questions the data must answer
A battery test is only as good as its controls. Three rules keep the data usable. First, change one thing per run. Second, hold brightness, volume, frame limit, refresh rate, Wi-Fi band and Bluetooth state constant. Third, never compare a single run to another single run. Repeat each condition twice and treat a large gap between the two repeats as a sign that something drifted.
The labels below are the vocabulary of the whole project. They also map to the launcher shortcuts you build in Step 7, so choose names you can read on a small screen.
| Label | Where the game runs | Fixed settings | Window |
|---|---|---|---|
| idle | Nothing, Steam home screen | Same brightness, Wi-Fi connected | 10 minutes |
| local | On the Deck, same game every time | Frame limit and refresh rate noted | 25 minutes, two repeats |
| gfn-free | GeForce NOW free tier | Same game, same stream settings | One free session, two repeats |
| xcloud-game | Xbox Cloud Gaming in Edge, Game Mode shortcut | Same game, same window size | 20 minutes, two repeats |
| xcloud-desktop | Xbox Cloud Gaming in Edge, Desktop Mode | Same game | 20 minutes, one run |
The idle label looks optional, but it anchors everything. If your idle draw changes by more than a few percent between sessions, the brightness slider moved or a background download woke up, and you should fix that before trusting any cloud number. The two Xbox labels exist because Desktop Mode runs a full Plasma session while Game Mode runs Gamescope, and the two draw differently even when the game is identical. Microsoft documents the Desktop Mode route, the Game Mode shortcut is a community workaround, and the labels keep the evidence separate.
The questions the data must answer are practical. Does streaming extend or shorten a session for this game? How many gigabytes does an hour cost on your connection, which matters if you tether to a phone? And does the free tier’s short session cap make the whole exercise pointless for long sessions? For a broader look at how this handheld stacks up against a laptop running the same service, see the GeForce NOW versus Steam Deck versus gaming laptop comparison.
Finally, decide your pass-fail rule for Steam Deck battery life before you look at results. A reasonable one: if the cloud average is at least ten percent lower than local for your main game and the stream uses a data volume you can live with, cloud play wins for that title. Writing the rule down first keeps you from rationalizing a mixed result.
Steps 1 to 3: Prepare the Deck
Step 1: Update SteamOS and record the build
Open Settings, then System, then Software Update, and install anything pending on the stable channel. Do this a day ahead, not right before testing, since a fresh update can trigger background shader and library work that skews the first hour. Then open Desktop Mode, start Konsole and record the build in your notes.
grep -E 'VERSION_ID|BUILD_ID' /etc/os-release
uname -r
Write the output at the top of a notes file. If a later SteamOS update lands between your local and cloud runs, you will know to repeat the baseline. Valve’s current stable release as of this writing is 3.9.5, so seeing that or a newer number means you are on the same footing as this article.
Step 2: Freeze every variable you can
Open the Quick Access Menu with the three-dot button and set values you will not touch again. Pick a brightness, set a frame limit and a refresh rate, and write all three down. Turn Bluetooth off unless you test with a wireless controller on purpose. Close every game and app in the Steam library. Pause Steam downloads, because a download running over Wi-Fi contaminates the network columns and adds CPU load.
- Brightness: manual, not adaptive. Auto brightness changes the screen’s share of total draw mid-run.
- Frame limit and refresh rate: match them to what the stream delivers. A 60 Hz stream on a 90 Hz panel wastes nothing in the stream, but it changes local-run draw.
- Performance overlay: leave it off during measured windows. It costs a little power and a little attention.
- Charger: unplugged and out of reach so you do not reconnect it by habit.
Step 3: Confirm Python and enable SSH
Python ships with SteamOS in Desktop Mode. Confirm the version, then set a password and start the SSH server so you can read results from another computer. Typing on the Deck is slow, and SSH lets you tail the CSV from a laptop while the Deck lies still on the table.
python3 --version
passwd
sudo systemctl enable --now sshd
ip -4 addr show | grep -E 'inet ' | grep -v 127.0.0.1
The last command prints the Deck’s address. From your laptop, ssh deck@that-address should now work. If passwd complains that the account has no password, set one first. SteamOS does not enable SSH by default, so skipping the systemctl line is the usual cause of connection refusals.
Steps 4 to 6: Build and self-test the sampler
Step 4: Probe the battery files
Linux power-supply drivers differ. Some expose a power file, some expose current and voltage, and some expose charge in amp-hours instead of energy in watt-hours. Before you write a line of Python, look at what your Deck exposes.
ls /sys/class/power_supply/
for d in /sys/class/power_supply/*; do echo "$d: $(cat $d/type)"; done
grep -H . /sys/class/power_supply/BAT*/{status,capacity,voltage_now,current_now} 2>/dev/null
You are looking for the directory whose type is Battery. The script finds it automatically, but seeing the raw values now helps you spot a gauge that reports zero current. Note the units: voltage and current appear in microvolts and microamps, so multiplying them yields units of ten to the minus twelve watts. The script divides accordingly.
Step 5: Create the project and write the script
Make the project folder and save the full script from the “Complete working project” section below as ~/deckpower/deckpower.py. The core of it is two small functions. The first converts whatever the driver exposes into watts. The second reads the Wi-Fi counters twice and turns the byte difference into kilobits per second.
def watts(bat):
power = num(bat, "power_now")
if power:
return abs(power) / 1e6
amps, volts = num(bat, "current_now"), num(bat, "voltage_now")
if amps is not None and volts is not None:
return abs(amps) * abs(volts) / 1e12
return None
Using abs() handles drivers that report discharge as negative current. The status column then separates discharging from charging, so a stray charger connection shows up in the data as charging rows that the report discards. Run python3 ~/deckpower/deckpower.py check on the Deck. It prints the battery directory, each file it can read, the instantaneous watts and the full-charge energy. If watts now prints a number between roughly 3 and 30 with the screen on, you are in business.
Step 6: Self-test with a fake battery
Never trust a logger you have not fed known input. The script honors three environment variables that redirect it to a synthetic battery directory, so you can verify the arithmetic on any Linux machine. Here a fake pack reports a fixed voltage and 1.5 A, which should log 11.70 W. Then the current doubles, and the report should show the second label drawing exactly twice as much.
mkdir -p mock/ps/BAT1 && cd mock
echo Battery > ps/BAT1/type
echo Discharging > ps/BAT1/status
echo 80 > ps/BAT1/capacity
echo 7800000 > ps/BAT1/voltage_now
echo 1500000 > ps/BAT1/current_now
echo 50000000 > ps/BAT1/energy_full
export DECKPOWER_SYSFS=$PWD/ps DECKPOWER_HOME=$PWD/home
python3 ../deckpower.py label local
python3 ../deckpower.py log --interval 0.05 --count 40
python3 ../deckpower.py label gfn-free
echo 3000000 > ps/BAT1/current_now
python3 ../deckpower.py log --interval 0.05 --count 40
python3 ../deckpower.py report --skip 5 --baseline local
The output below is from that self-test. The 11.70 W figure and the doubled figure for the second label are inputs I chose for the fake battery, not Steam Deck measurements, and the download columns read zero because the test has no network traffic. What matters is the shape: one row per label, a runtime estimate from the 50 Wh fake pack, and a percentage against the baseline.
label samples minutes avg W p95 W est. hours down Mbps GB/hour vs local
local 35 0.0 11.70 11.70 4.27 0.0 0.00 +0.0%
gfn-free 35 0.0 23.40 23.40 2.14 0.0 0.00 +100.0%
battery full-charge energy used for estimates: 50.0 Wh
If your numbers match, the pipeline works. Delete the mock folder and unset the three variables with unset DECKPOWER_SYSFS DECKPOWER_HOME before moving on, or your real logger will write into the fake directory. That mistake is common enough that it appears in the pitfalls list later.
Steps 7 and 8: Switch labels from Game Mode and run the logger in the background
Step 7: Build label marker shortcuts
Here is the awkward part. A streaming test runs in Game Mode, and Game Mode has no terminal. The fix is a marker: a tiny shell script that writes a label and exits, added to the Steam library as a non-Steam game. Launching it takes two seconds, and every sample after that carries the new label. Create the folder and the shared script in Desktop Mode.
mkdir -p ~/deckpower/bin
cat > ~/deckpower/bin/mark.sh <<'EOF'
#!/bin/sh
python3 "$HOME/deckpower/deckpower.py" label "$1"
EOF
chmod +x ~/deckpower/bin/mark.sh
for name in idle local gfn-free xcloud-game; do
printf '#!/bin/sh\nexec "$HOME/deckpower/bin/mark.sh" %s\n' "$name" > ~/deckpower/bin/mark-$name.sh
chmod +x ~/deckpower/bin/mark-$name.sh
done
ls ~/deckpower/bin
Now open Steam in Desktop Mode, choose Games, then Add a Non-Steam Game to My Library, click Browse, switch the file filter to all files and pick each mark-*.sh script. Rename the entries to something like DP mark local so they sort together. The first time you launch one in Game Mode, the screen flickers and returns to the library. That is the script finishing, not a crash.
Step 8: Run the logger as a user service that survives mode switches
Switching between Desktop Mode and Game Mode ends the Plasma session, and plain background processes die with it. A systemd user service plus lingering keeps the logger alive across switches. Write the unit file, enable it and turn lingering on for the deck user.
mkdir -p ~/.config/systemd/user
cat > ~/.config/systemd/user/deckpower.service <<'EOF'
[Unit]
Description=Steam Deck battery logger
[Service]
ExecStart=/usr/bin/python3 %h/deckpower/deckpower.py log --interval 5
Restart=on-failure
[Install]
WantedBy=default.target
EOF
systemctl --user daemon-reload
systemctl --user enable --now deckpower.service
sudo loginctl enable-linger deck
systemctl --user status deckpower.service --no-pager
tail -n 3 ~/deckpower/samples.csv
You should see the service active and a few CSV rows labeled unlabeled. Launch DP mark idle from Game Mode and check the file again from your laptop over SSH. The label column should flip to idle within five seconds. The logger itself costs almost nothing, since it wakes once every five seconds to read three small files. Even so, its draw is part of every run equally, so it cancels out in comparisons.
Step 9: Record the local baseline
Charge the Deck to full, unplug it, and let the fan and temperature settle for a few minutes. Launch the idle marker, leave the Deck on the home screen for ten minutes and note the average later. Then launch DP mark local, start your chosen game and play the same scene for 25 minutes. Pick something repeatable: a looping level, a fixed route, or a training mode, not an open-world wander that changes load every minute.
Why 25 minutes? The report drops the first 24 samples of each segment, which is two minutes at the default five-second interval, to ignore warm-up. The remaining 23 minutes give about 270 samples, which is plenty for a stable average. Longer windows help only if you suspect thermal throttling, and thermal drift is one reason to keep the room temperature steady.
- Play the same scene both times. A boss fight and a menu screen are different workloads.
- Do not touch brightness, volume or the Quick Access Menu during the window. Menu overlays change the compositor load.
- Let the Deck rest for ten minutes between runs so the chip returns to a similar starting temperature.
- Repeat the local run once. If the two averages differ by more than about five percent, find the cause before continuing.
Write down the battery percentage at the start and end of each run. The logger records it as well, but a second record helps when you doubt a number. Percentage readings are coarse, so use them only as a sanity check on the watt-based estimate.
Steps 10 and 11: Run the free cloud sessions
Step 10: GeForce NOW on the free tier
Launch DP mark gfn-free first, then start GeForce NOW from your Steam library. According to NVIDIA’s download page (updated October 8), the app can be opened from the Steam Library in Gaming Mode, and its FAQ lists the Steam Deck among supported devices. Sign in with your free account and pick the same game you used for the baseline. If you need the full account and store-linking walkthrough, the GeForce NOW free tier guide covers it step by step.
Free members may wait in a queue and may see an ad before play. Both periods are on the clock in the CSV, but they should not count in the average. That is what the report’s --min-rx filter does: it discards non-baseline samples whose download rate sits below a threshold, which removes the queue screen and menu browsing. Start with 5000 kilobits per second and adjust after you see your real stream rate in the report.
Play the same 25-minute scene if the session allows it. If the free-tier cap ends the session sooner, record what you have and run it again. Two short runs of the same scene beat one long run, and you learn how the cap interacts with real play. Because of the cap, the day pass is a handy complement: the GeForce NOW day pass test shows how to compare the free session against a paid one without committing to a plan.
Step 11: Xbox Cloud Gaming in Edge
Microsoft’s Xbox support article, updated October 30, describes Steam Deck support as beta and says setup needs Desktop Mode and Microsoft Edge. Install Edge from the Discover store, sign in at xbox.com/play, and confirm that your account can stream before you start the clock. That run gets the xcloud-desktop label.
For the Game Mode run, add Edge as a non-Steam game with a kiosk launch. This is a community workaround rather than an official feature, so treat results as a separate condition. In the shortcut properties set the target to /usr/bin/flatpak and paste the launch options below.
run --branch=stable --arch=x86_64 --command=/app/bin/edge com.microsoft.Edge --window-size=1280,800 --force-device-scale-factor=1.25 --kiosk "
Launch DP mark xcloud-game before the Edge shortcut. Use the same game and the same scene, and keep each window to 20 minutes. Check your allowance first. With the monthly caps now in place, a run that drains your last hour is a real cost, and the Insider free ad-supported preview is the lowest-risk way to try the route if you hold no plan. If a stream refuses to start, the Xbox Cloud Gaming troubleshooting guide lists the usual account and region causes.
One more moving part: Microsoft announced on November 5 a faster, simpler Xbox experience for PC, web and cloud, rolling out to more users over the following weeks. If the page layout shifts between your two Xbox runs, note the date, because a new interface can change how hard the browser works.
When all runs are done, launch DP mark idle once more. That ends the last measured segment cleanly and gives you a closing idle sample to compare against the opening one.
Steps 12 and 13: Read the report and shut the logger down
Step 12: Generate the report
Return to Desktop Mode, or stay on your laptop over SSH, and run the report with your baseline label and the queue filter. Add --capacity-wh only if the script says the gauge exposes no full-charge value.
python3 ~/deckpower/deckpower.py report --baseline local --min-rx 5000
Each row summarizes one label. The average and p95 watts describe typical and peak draw. The estimated hours column divides the battery’s full-charge energy by the average draw, so it predicts Steam Deck battery life for that style of play, not a guarantee. The down-Mbps and GB-per-hour columns show the data cost of each stream. Use the table below to turn the numbers into a decision.
| What the report shows | Likely meaning | What to do |
|---|---|---|
| Cloud average at least 10% below local, modest GB per hour | Streaming offloads real work from the APU | Use cloud for long sessions of that game, keep local for short ones |
| Cloud and local within about 5% | Decoder and radio cost roughly cancel the saved rendering | Choose by image quality and library, not battery |
| Cloud average above local | The game is light, or Wi-Fi is working hard | Play locally, or move closer to the router and rerun |
| Two repeats differ by more than 5% | Something drifted between runs | Check brightness, background downloads, room temperature |
| Estimated hours look implausibly high | The gauge reports low current or energy_full is off | Compare against the percentage drop and use –capacity-wh |
A last caution on reading the runtime column. The estimate uses the battery’s present full-charge energy from the driver, which shrinks as the pack ages. An older Deck gives lower hours than a new one even at the same draw, so compare your results with your own baseline rather than with strangers’ numbers online.
Step 13: Stop the logger and archive the data
A logger that runs forever is a small drain forever. Once testing is done, copy the CSV somewhere safe and shut the service down.
cp ~/deckpower/samples.csv ~/deckpower/samples-$(date +%F).csv
systemctl --user disable --now deckpower.service
sudo loginctl disable-linger deck
rm ~/deckpower/label
Keeping dated copies lets you rerun the report after future SteamOS updates and see whether a new build changed anything. Start a fresh samples.csv next time so old segments do not mix with new ones.
Complete working project: deckpower.py and the files around it
This is the whole project. The tree has one Python file, one systemd unit, one shared marker script and a handful of generated launchers. The Python file below is the exact version used in the self-test, including the queue filter.
~/deckpower/
deckpower.py the logger, labeler and reporter
bin/mark.sh shared label writer
bin/mark-idle.sh launcher stubs, one per label
bin/mark-local.sh
bin/mark-gfn-free.sh
bin/mark-xcloud-game.sh
samples.csv created by the logger
label current label, written by the markers
~/.config/systemd/user/deckpower.service
#!/usr/bin/env python3
"""deckpower: log Steam Deck battery draw per labeled session and compare them."""
import argparse
import csv
import os
import pathlib
import statistics
import sys
import time
SYSFS = pathlib.Path(os.environ.get("DECKPOWER_SYSFS", "/sys/class/power_supply"))
NETDEV = pathlib.Path(os.environ.get("DECKPOWER_NETDEV", "/proc/net/dev"))
HOME = pathlib.Path(os.environ.get("DECKPOWER_HOME", pathlib.Path.home() / "deckpower"))
LOG = HOME / "samples.csv"
LABEL = HOME / "label"
FIELDS = ["ts", "label", "status", "pct", "watts", "rx_kbps", "tx_kbps"]
def read(path):
try:
return pathlib.Path(path).read_text().strip()
except OSError:
return None
def num(base, name):
raw = read(base / name)
try:
return float(raw)
except (TypeError, ValueError):
return None
def find_battery():
for entry in sorted(SYSFS.glob("*")):
if read(entry / "type") == "Battery":
return entry
sys.exit(f"No battery found under {SYSFS}")
def watts(bat):
power = num(bat, "power_now")
if power:
return abs(power) / 1e6
amps, volts = num(bat, "current_now"), num(bat, "voltage_now")
if amps is not None and volts is not None:
return abs(amps) * abs(volts) / 1e12
return None
def full_wh(bat):
energy = num(bat, "energy_full")
if energy:
return energy / 1e6
charge, volts = num(bat, "charge_full"), num(bat, "voltage_now")
if charge and volts:
return charge * volts / 1e12
return None
def default_iface():
for line in NETDEV.read_text().splitlines()[2:]:
name = line.split(":")[0].strip()
if name.startswith("wl"):
return name
return None
def net_bytes(iface):
for line in NETDEV.read_text().splitlines()[2:]:
name, _, rest = line.partition(":")
if name.strip() == iface:
cols = rest.split()
return int(cols[0]), int(cols[8])
return None
def current_label():
return read(LABEL) or "unlabeled"
def cmd_check(args):
bat = find_battery()
print(f"battery dir : {bat}")
for name in ("status", "capacity", "power_now", "current_now", "voltage_now",
"energy_now", "energy_full", "charge_now", "charge_full"):
print(f" {name:12}: {read(bat / name)}")
print(f"watts now : {watts(bat)}")
print(f"full Wh : {full_wh(bat)}")
print(f"wifi iface : {default_iface()}")
def cmd_log(args):
HOME.mkdir(parents=True, exist_ok=True)
bat = find_battery()
iface = args.iface or default_iface()
fresh = not LOG.exists()
last_net, last_t = (net_bytes(iface) if iface else None), time.time()
taken = 0
with open(LOG, "a", newline="") as handle:
out = csv.writer(handle)
if fresh:
out.writerow(FIELDS)
while True:
time.sleep(args.interval)
now = time.time()
rx = tx = ""
if iface:
cur = net_bytes(iface)
span = max(now - last_t, 0.001)
if cur and last_net:
rx = round((cur[0] - last_net[0]) * 8 / 1000 / span, 1)
tx = round((cur[1] - last_net[1]) * 8 / 1000 / span, 1)
last_net, last_t = cur, now
w = watts(bat)
out.writerow([int(now), current_label(), read(bat / "status"),
read(bat / "capacity"), "" if w is None else round(w, 3), rx, tx])
handle.flush()
taken += 1
if args.count and taken >= args.count:
break
def cmd_label(args):
HOME.mkdir(parents=True, exist_ok=True)
tmp = LABEL.with_suffix(".tmp")
tmp.write_text(args.name + "\n")
tmp.replace(LABEL)
print(f"label -> {args.name}")
def percentile(values, pct):
if len(values) < 2:
return values[0] if values else 0.0
cuts = statistics.quantiles(values, n=100, method="inclusive")
return cuts[min(max(int(pct) - 1, 0), 98)]
def segments(rows):
seg, prev = [], None
for row in rows:
if prev is not None and (row["label"] != prev["label"] or int(row["ts"]) - int(prev["ts"]) > 60):
yield seg
seg = []
seg.append(row)
prev = row
if seg:
yield seg
def cmd_report(args):
with open(LOG, newline="") as handle:
rows = list(csv.DictReader(handle))
capacity = args.capacity_wh or full_wh(find_battery())
if not capacity:
sys.exit("Pass --capacity-wh, the battery file did not expose a full-charge value")
groups = {}
for seg in segments(rows):
usable = [r for r in seg if r["status"] == "Discharging" and r["watts"] != ""]
if args.min_rx and seg[0]["label"] != args.baseline:
usable = [r for r in usable if r["rx_kbps"] != "" and float(r["rx_kbps"]) >= args.min_rx]
usable = usable[args.skip:]
if usable:
groups.setdefault(usable[0]["label"], []).extend(usable)
if not groups:
sys.exit("No discharging samples left after trimming, lower --skip or log longer")
table = []
for label, items in groups.items():
w = [float(r["watts"]) for r in items]
rx = [float(r["rx_kbps"]) for r in items if r["rx_kbps"] != ""]
minutes = (int(items[-1]["ts"]) - int(items[0]["ts"])) / 60 if len(items) > 1 else 0
mean = statistics.fmean(w)
down_mbps = statistics.fmean(rx) / 1000 if rx else 0.0
table.append({
"label": label, "n": len(w), "min": minutes, "mean": mean,
"p95": percentile(w, 95), "hours": capacity / mean,
"mbps": down_mbps, "gb_per_hour": down_mbps * 3600 / 8 / 1000,
})
table.sort(key=lambda r: r["mean"])
base = next((r for r in table if r["label"] == args.baseline), None)
head = f"{'label':18}{'samples':>8}{'minutes':>9}{'avg W':>8}{'p95 W':>8}{'est. hours':>12}{'down Mbps':>11}{'GB/hour':>9}"
if base:
head += f"{'vs ' + args.baseline:>16}"
print(head)
for r in table:
line = (f"{r['label']:18}{r['n']:>8}{r['min']:>9.1f}{r['mean']:>8.2f}{r['p95']:>8.2f}"
f"{r['hours']:>12.2f}{r['mbps']:>11.1f}{r['gb_per_hour']:>9.2f}")
if base:
line += f"{(r['mean'] / base['mean'] - 1) * 100:>+15.1f}%"
print(line)
print(f"\nbattery full-charge energy used for estimates: {capacity:.1f} Wh")
def build_parser():
ap = argparse.ArgumentParser(description=__doc__)
sub = ap.add_subparsers(dest="cmd", required=True)
sub.add_parser("check", help="probe battery files and Wi-Fi interface").set_defaults(fn=cmd_check)
lg = sub.add_parser("log", help="sample forever, label comes from ~/deckpower/label")
lg.add_argument("--interval", type=float, default=5.0)
lg.add_argument("--iface")
lg.add_argument("--count", type=int, default=0, help="stop after N samples (testing)")
lg.set_defaults(fn=cmd_log)
lb = sub.add_parser("label", help="switch the label applied to new samples")
lb.add_argument("name")
lb.set_defaults(fn=cmd_label)
rp = sub.add_parser("report", help="summarize samples per label")
rp.add_argument("--baseline", default="local")
rp.add_argument("--skip", type=int, default=24, help="warm-up samples to drop per segment")
rp.add_argument("--min-rx", type=float, default=0.0, help="drop non-baseline samples below this download kbps (queue waits)")
rp.add_argument("--capacity-wh", type=float)
rp.set_defaults(fn=cmd_report)
return ap
if __name__ == "__main__":
args = build_parser().parse_args()
args.fn(args)
A few design choices are worth explaining. The label lives in a file, not in memory, because the logger and the marker scripts run as separate processes. Writes go through a temporary file and a rename, so the logger never reads half a label. The reporter splits samples into segments whenever the label changes or the gap between rows exceeds 60 seconds, which stops two separate sessions with the same label from being treated as one continuous run. And every statistic uses the standard library alone, so there is nothing to install on a read-only root filesystem.
If you want the same harness on another Linux handheld, point DECKPOWER_SYSFS at its battery directory and run check. Any device with a standard battery node should work.
Common pitfalls that ruin battery comparisons
- Leaving the charger in range. One accidental plug-in turns a run into charging samples. The report discards them, but you lose the window and must repeat it.
- Forgetting to unset the mock variables. If
DECKPOWER_SYSFSstill points at the fake battery, the real service logs 11.70 W forever. Check the first rows of the CSV after Step 8. - Counting queue time as streaming. A free-tier queue screen draws little, so including it flatters the cloud result. Use
--min-rx. - Comparing different games or scenes. A cloud run of one game against a local run of another measures the games, not the delivery. Pick one title and one scene.
- Letting Steam download in the background. A shader pre-cache or update during a window adds CPU and Wi-Fi load to whichever label is active.
- Skipping the idle anchor. Without it you cannot tell whether a gap between runs came from the method or from drifting settings.
- Trusting a single run. One run per condition cannot tell signal from noise. Repeat each condition.
The most expensive pitfall is the third, because it makes the cloud look better than it is and you will not notice. If your gfn-free row shows a suspiciously low average, rerun the report with a higher --min-rx and see whether the number climbs.
Troubleshooting: 10 problems and fixes
- “No battery found” on start. The type file under
/sys/class/power_supply/does not read Battery. Run the ls loop from Step 4 and check what the Deck exposes. If the directory is empty, you may be inside a container or a restricted shell. - Watts column is empty. The driver exposes neither power nor current plus voltage. Look for
energy_nowand compute watts from its change over time, or compare percentages. Open an issue upstream if your Deck revision lacks both. - Watts read zero while playing. Some gauges update only when the reading changes sharply. Try a shorter interval, or confirm the unit with a USB-C power meter on a docked run.
- Label stays “unlabeled”. The marker script is not executable, or Python is missing from its path. Run
~/deckpower/bin/mark.sh localfrom Konsole and read the error. - Logger stops after switching to Game Mode. Lingering is off. Run
sudo loginctl enable-linger deckand restart the service. - Marker script missing from the Steam file picker. The picker filters for executables by default. Switch the filter to all files, then select the script.
- GeForce NOW is not in the Steam library. Install it first and follow the app steps in the setup guide linked in the introduction. If a launch fails with a network error, the GeForce NOW connection error walkthrough covers the common causes.
- Edge opens a black screen in Game Mode. The kiosk flags or the Flatpak path are off. Test the same command in Konsole, fix typos, then paste it back into the shortcut.
- Report says no discharging samples. The charger was connected, or
--skipis larger than the segment. Lower--skipor log longer. - Estimated hours look wrong. The gauge’s full-charge energy may be stale. Pass
--capacity-whwith a figure you trust and compare percentage drop against watts.
If none of these match, run check again and read every field. Most logger bugs are visible there.
Advanced tips for sharper battery data
- Validate the gauge with an external meter. A USB-C power meter between the dock and the Deck gives an independent reading for a docked run. If the meter and the logger disagree widely, trust neither until you find out why.
- Sample faster for spiky workloads. A one-second interval catches short bursts. Keep the default for long runs to hold the file small.
- Add more labels without code. A new service needs only a new marker stub and a label name. Steam Link or Remote Play from a PC is a natural extra condition, and Valve’s recent client betas touched streaming on the OLED model, so retest after updates.
- Plot the CSV. A few lines of matplotlib on your laptop turn watts over time into a chart that exposes thermal sag or queue spikes at a glance.
- Test frame limits against streaming. A 40 Hz local run with a 40 fps cap may beat a 60 fps stream. Add a label per setting and let the data speak.
- Watch the data meter. Multiply the GB-per-hour column by your session length before you tether to a phone. A heavy stream can empty a small mobile plan quickly.
Once you have a few titles measured, a pattern in your Steam Deck battery life usually appears: heavy games favor the stream and light games favor local play. If you want a broader view of the same trade, the general cloud gaming setup guide is a reasonable next read, and the gaming section collects the rest of the cloud and handheld coverage.
Frequently asked questions
Does cloud gaming save Steam Deck battery life?
It depends on the game. Streaming offloads rendering but keeps the Wi-Fi radio and video decoder busy for the whole session. Demanding games tend to benefit and light games may not. The logger in this tutorial tells you which side your game falls on.
Is GeForce NOW free on the Steam Deck?
NVIDIA lists a free membership alongside two paid tiers. Free sessions are time-limited and can queue, so check the limits in the app and NVIDIA’s current Steam Deck instructions before planning a long session.
Is Xbox Cloud Gaming still unlimited with Game Pass?
No. Microsoft announced monthly cloud-hour allowances by plan and paid extra hours, starting this month. Check the current numbers on the Xbox site before you budget test time.
Does Amazon Luna work on the Steam Deck?
This article found no official Game Mode support for Luna as of November 6, 2026. You can still add a label and test a browser route yourself, but treat it as unverified.
Will the logger drain the battery by itself?
Barely. It wakes every five seconds to read a few small files. Its cost appears in every run equally, so it does not bias the comparison.
Can I use this on other handhelds?
Yes, on any Linux device that exposes a standard battery node. Point the SYSFS variable at its directory, run the check command and confirm that watts print before you trust the logs.
How long should each test run?
Twenty to twenty-five minutes per window, repeated twice, is enough for a stable average. Shorter windows leave too little data once the warm-up samples are dropped.