中級者向け No.13

ネットワークスキャナー

LAN内のデバイスをスキャンしてIPアドレス・MACアドレス・ホスト名・開放ポートを一覧表示するツール。socketとsubprocessの活用を学びます。

🎯 難易度: ★★☆ 📦 ライブラリ: tkinter(標準ライブラリ) ⏱️ 制作時間: 30〜90分

1. アプリ概要

LAN内のデバイスをスキャンしてIPアドレス・MACアドレス・ホスト名・開放ポートを一覧表示するツール。socketとsubprocessの活用を学びます。

使用ライブラリは tkinter(標準ライブラリ) で、難易度は ★★☆ です。

コードを読む前に実行することをおすすめします。動いている挙動を先に把握しておくと、解説で出てくる関数や処理がどこに対応するかが頭に入りやすくなります。

応用のヒントは、機能を 1 つ増やす・見た目を整える・例外を一つでも丁寧に扱う、のいずれかから始めるのがおすすめです。

ネットワークスキャナー 実行画面(Windows)。「IPアドレス」と「ホスト名」の間に「MACアドレス」列が並んでいます。IPレンジ欄・下部のホスト情報・ツール欄は環境情報のためマスク処理しています
実行画面(Windows・MACアドレス列の追加後に撮影/一部をマスク処理)。スキャン前の状態のため、MAC 欄にはまだ値が入っていません
ネットワークスキャナー 実行画面(Linux Mint)。「IPアドレス」と「ホスト名」の間に「MACアドレス」列が並んでいます。IPレンジ欄と下部のホスト入力欄は環境情報のためマスク処理しています
実行画面(Linux Mint・MACアドレス列の追加後に撮影/一部をマスク処理)

2. 機能一覧

  • ループで生成するボタン群(それぞれ _ping_single()・_resolve_host()・_traceroute() を実行)
  • 「▶ スキャン開始」ボタン(_toggle_scan())で操作
  • ttk.Treeview による表形式の一覧表示
  • messagebox.showerror()・messagebox.showinfo() によるダイアログ通知
  • ウィンドウはタイトル「ネットワークスキャナー」・サイズ 820x580 で起動

3. 事前準備・環境

ℹ️
動作確認環境

Python 3.11 以上(Ubuntu で 3.14・3.15 の起動を確認) / Windows 11(実機)・Linux Mint 22.3(仮想マシン)で起動・画面表示を確認(macOS は未検証)

2026年10月3日に、Python 3.15 に備えてコードを修正しました(内容は下の注記)。修正後のコードを同じ日に、Windows 11(実機・Python 3.12.10)と Linux Mint 22.3(仮想マシン・Python 3.12.3)で起動し、画面が表示されることを確認しました。スキャン・Ping・Traceroute のボタンは押しておらず、全機能の網羅テストではありません。修正した文字コードの処理は、別に Windows 11 のコマンドライン上で確かめています(下の注記)。macOS は標準ライブラリの範囲で動作する想定ですが、未検証です。

  • Python 3.11 以上(Ubuntu で 3.14・3.15 の起動を確認)
  • OS: Windows 11(実機で起動・画面表示を確認)・Linux Mint 22.3(仮想マシンで起動・画面表示を確認)
💡
Python 3.15 に備えて、コマンド出力の文字コードを指定しました(2026年10月3日)

MAC アドレスを調べる arp と、経路を調べる tracert(Linux・macOS では traceroute)の出力を読む2か所に、encoding="cp932" if platform.system() == "Windows" else "utf-8" を足しました。tracert 側には errors="replace" も足しています(arp 側には前から付いていました)。Python 3.15(2026年10月3日時点で正式版は未リリース・日付未発表)から UTF-8 モードが既定になり、text=True だけで受けたコマンド出力は UTF-8 として読まれます。日本語版 Windows のコマンド出力は Shift_JIS 系(cp932)が一般的なため、指定が無いと日本語の部分が文字化けしたり、Traceroute の画面に結果の代わりに「エラー: …」と出たりするおそれがあります。

当サイトでは2つの確認をしました。1つ目は Linux 上での比較です。日本語版 Windows に近い文字コード(ja_JP.SJIS ロケール)を設定し、同じ読み方をする最小限のコードを Python 3.14 と 3.15.0rc2 で動かしました。3.14 は読めて、3.15.0rc2 は UnicodeDecodeError か文字化けになりました。2つ目は Windows 11 実機で、Python 3.14.7 を UTF-8 モード(-X utf8。Python 3.15 の既定と同じ設定)で実行し、自分の PC(127.0.0.1)あての arp と tracert の出力を読んだものです。文字コードの指定なし(修正前の書き方)では arp が文字化けし、tracert は UnicodeDecodeError になりました。encoding="cp932" と errors="replace"(修正後の書き方)では、どちらも日本語を正しく読めました(2026年10月3日)。Windows での確認は Python 3.15 そのものではなく 3.14.7 の UTF-8 モードでのもので、アプリ画面の Traceroute ボタンからの実行でもありません。アプリ全体を日本語版 Windows と Python 3.15 で動かした結果ではありません。3.15 の文字コードの変更点はPython 3.15 の変更点の解説にまとめています。

4. 完全なソースコード

💡
コードのコピー方法

右上の「コピー」ボタンをクリックするとコードをクリップボードにコピーできます。

✅ 追加インストール不要(標準ライブラリのみ使用)
app13.py
import tkinter as tk
from tkinter import ttk, messagebox
import socket
import subprocess
import threading
import ipaddress
import platform
import re
from datetime import datetime


class App13:
    """ネットワークスキャナー"""

    # MAC アドレス(aa-bb-cc-dd-ee-ff / aa:bb:cc:dd:ee:ff の両形式)
    MAC_RE = re.compile(r"([0-9A-Fa-f]{2}([-:])[0-9A-Fa-f]{2}(?:\2[0-9A-Fa-f]{2}){4})")

    def __init__(self, root):
        self.root = root
        self.root.title("ネットワークスキャナー")
        self.root.geometry("820x580")
        self.root.configure(bg="#f8f9fc")
        self.scanning = False
        self._build_ui()
        self._detect_local_ip()

    def _build_ui(self):
        title_frame = tk.Frame(self.root, bg="#e65100", pady=10)
        title_frame.pack(fill=tk.X)
        tk.Label(title_frame, text="🔍 ネットワークスキャナー",
                 font=("Noto Sans JP", 15, "bold"),
                 bg="#e65100", fg="white").pack(side=tk.LEFT, padx=12)

        # スキャン設定
        cfg_frame = ttk.LabelFrame(self.root, text="スキャン設定", padding=10)
        cfg_frame.pack(fill=tk.X, padx=8, pady=6)

        tk.Label(cfg_frame, text="IPレンジ:").grid(row=0, column=0, sticky="w")
        self.range_var = tk.StringVar(value="192.168.1.0/24")
        ttk.Entry(cfg_frame, textvariable=self.range_var,
                  width=22, font=("Courier New", 11)).grid(
            row=0, column=1, padx=8, sticky="w")

        tk.Label(cfg_frame, text="タイムアウト(ms):").grid(
            row=0, column=2, sticky="w", padx=(16, 4))
        self.timeout_var = tk.IntVar(value=300)
        ttk.Spinbox(cfg_frame, from_=50, to=2000,
                    textvariable=self.timeout_var, width=6).grid(row=0, column=3)

        tk.Label(cfg_frame, text="スレッド数:").grid(
            row=0, column=4, sticky="w", padx=(16, 4))
        self.threads_var = tk.IntVar(value=50)
        ttk.Spinbox(cfg_frame, from_=1, to=200,
                    textvariable=self.threads_var, width=6).grid(row=0, column=5)

        self.port_var = tk.BooleanVar(value=False)
        ttk.Checkbutton(cfg_frame, text="ポートスキャン",
                        variable=self.port_var).grid(row=0, column=6, padx=16)

        self.scan_btn = ttk.Button(cfg_frame, text="▶ スキャン開始",
                                   command=self._toggle_scan)
        self.scan_btn.grid(row=0, column=7, padx=8)

        # プログレス
        self.progress_var = tk.IntVar(value=0)
        self.progress = ttk.Progressbar(self.root, variable=self.progress_var,
                                         maximum=100)
        self.progress.pack(fill=tk.X, padx=8)

        # 結果テーブル
        result_frame = ttk.LabelFrame(self.root, text="スキャン結果", padding=4)
        result_frame.pack(fill=tk.BOTH, expand=True, padx=8, pady=6)
        cols = ("ip", "mac", "hostname", "ping", "ports", "status")
        self.tree = ttk.Treeview(result_frame, columns=cols,
                                  show="headings", selectmode="browse")
        for c, h, w in [("ip", "IPアドレス", 120), ("mac", "MACアドレス", 140),
                         ("hostname", "ホスト名", 160),
                         ("ping", "応答(ms)", 70), ("ports", "開放ポート", 170),
                         ("status", "状態", 70)]:
            self.tree.heading(c, text=h)
            self.tree.column(c, width=w, minwidth=40)
        v_sb = ttk.Scrollbar(result_frame, command=self.tree.yview)
        self.tree.configure(yscrollcommand=v_sb.set)
        v_sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.tree.pack(fill=tk.BOTH, expand=True)
        self.tree.tag_configure("online", foreground="#27ae60")
        self.tree.tag_configure("offline", foreground="#bbb")

        # 下部: ローカル情報 + ツール
        bottom = tk.Frame(self.root, bg="#f8f9fc")
        bottom.pack(fill=tk.X, padx=8, pady=4)
        self.local_info_var = tk.StringVar(value="")
        tk.Label(bottom, textvariable=self.local_info_var,
                 bg="#f8f9fc", font=("Courier New", 10),
                 fg="#555").pack(side=tk.LEFT)

        tool_frame = tk.Frame(bottom, bg="#f8f9fc")
        tool_frame.pack(side=tk.RIGHT)
        tk.Label(tool_frame, text="ツール: ", bg="#f8f9fc").pack(side=tk.LEFT)
        self.tool_ip_var = tk.StringVar()
        ttk.Entry(tool_frame, textvariable=self.tool_ip_var, width=16).pack(side=tk.LEFT, padx=4)
        for text, cmd in [("Ping", self._ping_single),
                           ("ホスト名解決", self._resolve_host),
                           ("Traceroute", self._traceroute)]:
            ttk.Button(tool_frame, text=text, command=cmd).pack(side=tk.LEFT, padx=2)

        self.status_var = tk.StringVar(value="スキャン待機中")
        tk.Label(self.root, textvariable=self.status_var,
                 bg="#dde", font=("Arial", 9), anchor="w", padx=8
                 ).pack(fill=tk.X, side=tk.BOTTOM)

    def _detect_local_ip(self):
        try:
            s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
            s.connect(("8.8.8.8", 80))
            local_ip = s.getsockname()[0]
            s.close()
            # デフォルトレンジをセット
            parts = local_ip.rsplit(".", 1)
            self.range_var.set(f"{parts[0]}.0/24")
            hostname = socket.gethostname()
            self.local_info_var.set(
                f"ホスト: {hostname}  ローカルIP: {local_ip}")
            self.tool_ip_var.set(local_ip)
        except Exception:
            pass

    def _toggle_scan(self):
        if self.scanning:
            self.scanning = False
            self.scan_btn.config(text="▶ スキャン開始")
        else:
            self._start_scan()

    def _start_scan(self):
        try:
            network = ipaddress.ip_network(self.range_var.get().strip(),
                                           strict=False)
        except ValueError as e:
            messagebox.showerror("エラー", f"無効なIPレンジ: {e}")
            return
        self.tree.delete(*self.tree.get_children())
        self.scanning = True
        self.scan_btn.config(text="⏹ 停止")
        hosts = list(network.hosts())
        self.progress_var.set(0)
        threading.Thread(target=self._scan_range,
                         args=(hosts,), daemon=True).start()

    def _scan_range(self, hosts):
        total = len(hosts)
        found = 0
        semaphore = threading.Semaphore(self.threads_var.get())
        results = []
        lock = threading.Lock()

        def scan_host(ip):
            nonlocal found
            with semaphore:
                if not self.scanning:
                    return
                ip_str = str(ip)
                online, ms = self._ping(ip_str)
                if online:
                    mac = self._get_mac(ip_str)
                    hostname = self._get_hostname(ip_str)
                    ports = self._scan_ports(ip_str) if self.port_var.get() else ""
                    with lock:
                        found += 1
                        results.append((ip_str, mac, hostname, ms, ports))
                        self.root.after(0, self._add_result,
                                        ip_str, mac, hostname, ms, ports)

        threads = []
        for i, ip in enumerate(hosts):
            if not self.scanning:
                break
            t = threading.Thread(target=scan_host, args=(ip,), daemon=True)
            t.start()
            threads.append(t)
            pct = int((i + 1) / total * 100)
            self.root.after(0, self.progress_var.set, pct)
            self.root.after(0, self.status_var.set,
                            f"スキャン中: {i+1}/{total}  発見: {found}")

        for t in threads:
            t.join(timeout=2)

        self.root.after(0, self._scan_done, found)

    def _ping(self, ip):
        """pingを実行して(alive, ms)を返す"""
        timeout_s = self.timeout_var.get() / 1000
        try:
            param = "-n" if platform.system().lower() == "windows" else "-c"
            cmd = ["ping", param, "1", "-w" if platform.system().lower() == "windows"
                   else "-W", str(self.timeout_var.get()), ip]
            import time
            t0 = time.time()
            result = subprocess.run(cmd, capture_output=True,
                                     timeout=timeout_s + 1)
            ms = int((time.time() - t0) * 1000)
            alive = result.returncode == 0
            return alive, ms
        except Exception:
            # socketで接続チェック
            try:
                import time
                t0 = time.time()
                s = socket.create_connection((ip, 80),
                                              timeout=timeout_s)
                s.close()
                ms = int((time.time() - t0) * 1000)
                return True, ms
            except Exception:
                return False, 0

    def _get_mac(self, ip):
        """ARP テーブルから MAC アドレスを引く(ping 直後なら載っている)。取れなければ "-" を返す"""
        if platform.system().lower() == "windows":
            cmds = [["arp", "-a", ip]]
        else:
            cmds = [["ip", "neigh", "show", ip], ["arp", "-a", ip]]
        for cmd in cmds:
            try:
                out = subprocess.run(cmd, capture_output=True, text=True,
                                     encoding="cp932" if platform.system() == "Windows" else "utf-8",
                                     errors="replace", timeout=3).stdout
            except Exception:
                continue        # コマンドが無い / タイムアウト → 次の手段へ
            mac = self._parse_mac(out, ip)
            if mac != "-":
                return mac
        return "-"

    @staticmethod
    def _parse_mac(output, ip):
        """arp -a / ip neigh の出力テキストから ip の行の MAC を取り出す(無ければ "-")"""
        # 192.168.1.1 と 192.168.1.10 を区別するため前後が数字・ドットでない行だけ見る
        ip_pat = re.compile(r"(?<![\d.])" + re.escape(ip) + r"(?![\d.])")
        for line in output.splitlines():
            if not ip_pat.search(line):
                continue
            m = App13.MAC_RE.search(line)
            if m:
                return m.group(1).replace("-", ":").lower()
        return "-"

    def _get_hostname(self, ip):
        try:
            return socket.gethostbyaddr(ip)[0]
        except Exception:
            return ""

    def _scan_ports(self, ip):
        common_ports = [21, 22, 23, 25, 53, 80, 110, 143, 443,
                        445, 3306, 3389, 5432, 8080, 8443]
        open_ports = []
        for port in common_ports:
            try:
                s = socket.create_connection((ip, port), timeout=0.3)
                s.close()
                open_ports.append(port)
            except Exception:
                pass
        return ", ".join(str(p) for p in open_ports)

    def _add_result(self, ip, mac, hostname, ms, ports):
        self.tree.insert("", "end",
                         values=(ip, mac, hostname, f"{ms} ms", ports, "オンライン"),
                         tags=("online",))

    def _scan_done(self, found):
        self.scanning = False
        self.scan_btn.config(text="▶ スキャン開始")
        self.progress_var.set(100)
        self.status_var.set(
            f"スキャン完了  {found} 台発見  "
            f"({datetime.now().strftime('%H:%M:%S')})")

    def _ping_single(self):
        ip = self.tool_ip_var.get().strip()
        if not ip:
            return
        threading.Thread(target=self._do_ping_single,
                         args=(ip,), daemon=True).start()

    def _do_ping_single(self, ip):
        alive, ms = self._ping(ip)
        msg = f"{ip}: {'応答あり' if alive else '応答なし'} ({ms} ms)"
        self.root.after(0, lambda: messagebox.showinfo("Ping結果", msg))

    def _resolve_host(self):
        target = self.tool_ip_var.get().strip()
        if not target:
            return
        try:
            result = socket.gethostbyaddr(target)
            messagebox.showinfo("ホスト名解決",
                                f"ホスト名: {result[0]}\nエイリアス: {result[1]}\nIP: {result[2]}")
        except Exception as e:
            messagebox.showinfo("ホスト名解決", f"解決できませんでした: {e}")

    def _traceroute(self):
        ip = self.tool_ip_var.get().strip()
        if not ip:
            return
        win = tk.Toplevel(self.root)
        win.title(f"Traceroute — {ip}")
        win.geometry("500x300")
        txt = tk.Text(win, font=("Courier New", 10), bg="#1e1e1e",
                      fg="#ccc")
        txt.pack(fill=tk.BOTH, expand=True)

        def run():
            cmd = (["tracert", ip] if platform.system().lower() == "windows"
                   else ["traceroute", ip])
            try:
                proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,
                                         stderr=subprocess.STDOUT,
                                         text=True,
                                         encoding="cp932" if platform.system() == "Windows" else "utf-8",
                                         errors="replace")
                for line in proc.stdout:
                    win.after(0, txt.insert, tk.END, line)
            except Exception as e:
                win.after(0, txt.insert, tk.END, f"エラー: {e}")

        threading.Thread(target=run, daemon=True).start()


if __name__ == "__main__":
    root = tk.Tk()
    app = App13(root)
    root.mainloop()

5. コード解説

ネットワークスキャナーのコードを、実際に書かれている実装に沿って解説します。

クラス設計とコンストラクタ

App13 クラスにアプリの全機能をまとめています(メソッド17個・全335行)。__init__ ではタイトル「ネットワークスキャナー」とウィンドウサイズ 820x580 を設定します。あわせて状態を保持する変数(self.scanning)を初期化し、そのあと _build_ui() → _detect_local_ip() の順に呼び出して初期状態を作ります。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

ウィジェット構成

画面は tk.Frame×3・tk.Label×7・ttk.LabelFrame×2・ttk.Entry×2・ttk.Spinbox×2・ttk.Checkbutton・ttk.Button(ループで生成)・ttk.Progressbar・ttk.Treeview・ttk.Scrollbar・tk.Toplevel・tk.Text で構成しています。見出し付きの枠(LabelFrame)は「スキャン設定」「スキャン結果」のラベルでエリアを区切っています。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

イベント処理とボタンの接続

「▶ スキャン開始」ボタン(_toggle_scan())を command= で接続しています。ボタンはラベルと処理をペアで並べたデータをループで回して生成し、それぞれ _ping_single()・_resolve_host()・_traceroute() を command= に渡しています。ボタンを増やしたいときはこのペアを1行足すだけで済みます。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

クラス定数によるデータ定義

クラスの先頭でデータを定数として定義しています:MAC_RE = re.compile('([0-9A-Fa-f]{2}([-:])[0-9A-Fa-f]{2}(?:\\2[0-9A-Fa-f]{2}){4})')。ロジックの中に直接書かず定数にまとめておくと、内容を変えたいときに1か所を書き換えるだけで済みます。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

例外処理

try-except で Exception・ValueError を捕捉しています。あわせて messagebox.showerror()・messagebox.showinfo() のダイアログで、ユーザーへの通知・確認を行います。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

6. ステップバイステップガイド

このアプリをゼロから自分で作る手順を解説します。コードをコピーするだけでなく、実際に手順を追って自分で書いてみましょう。

  1. 1
    ファイルを作成する

    新しいファイルを作成して app13.py と保存します。使うのは datetime・ipaddress・platform・re・socket・subprocess・threading・time・tkinter だけなので、追加インストールは不要です。

  2. 2
    クラスの骨格を作る

    App13 クラスを定義し、__init__ と、末尾の root = tk.Tk() ~ mainloop() の最小構成を書いて、まず空のウィンドウが出ることを確認します。

  3. 3
    ウィンドウを設定する

    title("ネットワークスキャナー")・geometry("820x580")・configure(bg="#f8f9fc") をコンストラクタで設定します。

  4. 4
    画面部品を並べる

    _build_ui() の中で、tk.Frame×3・tk.Label×7・ttk.LabelFrame×2・ttk.Entry×2・ttk.Spinbox×2・ttk.Checkbutton・ttk.Button(ループで生成)・ttk.Progressbar・ttk.Treeview・ttk.Scrollbar・tk.Toplevel・tk.Text を作って配置します(掲載コードと同じ並び順で書くとレイアウトが一致します)。まず表示だけ確認しましょう。

  5. 5
    イベントを接続する

    「2. 機能一覧」で挙げた各ボタンを command= で対応するメソッド(_toggle_scan())につなぎます。ループ生成のボタンは、ラベルと処理(_ping_single()・_resolve_host()・_traceroute())をペアで並べたデータをループで回し、command= に渡して接続します。

  6. 6
    中心になるメソッドを実装する

    アプリの本体である _add_result()(4行)・_scan_done()(7行)・_traceroute()(26行)など を実装します。

  7. 7
    動作確認する

    python app13.py で起動し、各ボタンが反応することを確認します。

7. カスタマイズアイデア

基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。少しずつ機能を追加することで、Pythonのスキルが飛躍的に向上します。

💡 ダークモードを追加する

bg色・fg色を辞書で管理し、ボタン1つでダークモード・ライトモードを切り替えられるようにしましょう。

💡 結果をファイルに保存する

このコードに保存処理はないため、表示中の数値や結果はアプリを閉じると消えます。テキストファイルへ書き出して、次回起動時に読み込む機能を追加してみましょう。

💡 入力履歴機能

以前の入力値を覚えておいてComboboxのドロップダウンで再選択できる履歴機能を追加しましょう。

8. よくある問題と解決法

❌ 文字のフォントが崩れる・見た目が違う

原因:このコードは font=("Noto Sans JP", ...) のようにフォント名を直接指定しています。環境にこのフォントが入っていないと、OS が代わりのフォントで表示するため、見本と見た目が変わることがあります。

解決法:動作には支障ありません。気になる場合は font 引数の名前を自分の OS に入っているフォントへ書き換えるか、font 引数を省略してください。

❌ ウィンドウの大きさが画面に合わない

原因:geometry("820x580") の固定サイズで起動するためです。

解決法:この数値を書き換えて起動サイズを調整してください。なお、このコードにはウィンドウサイズを固定する設定(resizable の指定)が無いため、ウィンドウの端をドラッグしたサイズ変更は既定どおり可能です。

9. 練習問題

アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください(課題3は定型の発展課題です)。スキャンは自分が管理している機器・ネットワークにだけ実行してください。会社や公共 Wi-Fi、他人の回線に向けると規約違反や不正アクセスとみなされることがあります。MAC アドレスは機器ごとに固有の識別子なので、スキャン結果のスクリーンショットを人に見せるときは IP レンジと合わせて MAC 列も伏せてください。試すときは 192.168.1.0/29 のような狭いレンジにすると、すぐ終わって確かめやすくなります。

  1. 課題1:進み具合のバーを「調べ終わった数」で進める

    いまの _scan_range() は、スレッドを起こした数だけ progress_var を進めます。_ping() の結果を待たずに数えるので、バーはすぐ 100% になり、そのあとも一覧に結果が増え続けます。調べ終わった台数で進むようにしてください。

    期待結果:狭いレンジでスキャンすると、バーが 100% に届くタイミングと「スキャン完了」の表示がほぼそろう。

    合格条件

    • 一覧に結果が増えている間は、バーが 100% に達していない
    • 帯に出る「スキャン中: n/total」の n も、調べ終わった数で増える
    • 「⏹ 停止」を押すとその場でスキャンが止まり、ボタンの表示が「▶ スキャン開始」に戻る

    ヒント①(どこを触るか): 触るのは _scan_range() の2か所です。内側の scan_host() に調べ終わった数を数える変数を足し、for i, ip in enumerate(hosts): の中でやっている self.progress_var.set の呼び出しをそちらへ移します。
    ヒント②(使うもの): 数え方の手本は同じ関数の found です。nonlocal で宣言し、with lock: の中で1ずつ足せば取りこぼしません。画面の更新は self.root.after(0, self.progress_var.set, pct) の形で、必ずメインスレッドに渡します。
    つまずきやすい点: 応答が無かったホストは if online: のブロックに入りません。数える処理をこの中へ置くと、バーがほとんど進まないままになります。self.scanning が False になると scan_host() は途中で戻るため、停止したあとの合計は台数に届きません。

  2. 課題2:応答(ms) に ping が返した実測値を出す

    いまの _ping() は subprocess.run() の前後で経過時間を測っています。この値には ping コマンドの起動時間も含まれるため、コマンドの出力に並ぶ数字より大きめに出ます。出力から実測値を取り出して表示してください。

    期待結果:手元のルーターを「Ping」ツールで叩くと、表示される ms がコマンドの出力に近い値になる。

    合格条件

    • 数字を取り出せなかったときは、これまでどおり測った経過時間を出す
    • 応答が無いホストは、これまでどおり一覧に出ない
    • 一覧の「応答(ms)」列にも、同じ取り出し方の値が並ぶ

    ヒント①(どこを触るか): 触るのは _ping() の1か所です。subprocess.run(cmd, capture_output=True, ...) の戻り値に出力が入っているので、ms = int((time.time() - t0) * 1000) の前に取り出す処理を足します。re はファイル先頭(8行目)で import 済みで、クラス定数 MAC_RE でも使っています。追加の import は要りません。
    ヒント②(使うもの): 出力は result.stdout にバイト列で入ります。文字列へ直したうえで、re.search() に ms の直前の数字を拾うパターンを渡します。表記は環境ごとに違い、Windows の日本語版は「時間 =1ms」・英語版は time=1ms・Linux や macOS は time=1.23 ms の形です。
    つまずきやすい点: Windows の ping は UTF-8 で出力しないことがあります(日本語版は CP932)。decode() に errors="ignore" を付けるか、subprocess.run(..., text=True, encoding="cp932", errors="ignore") のように読み方を指定してください。1ms 未満は <1ms と表示されるため、= の後ろだけを探すと取りこぼします。

  3. 課題3:保存機能の追加

    入力値や計算結果をファイルに保存する機能を追加しましょう。jsonやcsvモジュールを使います。

🚀
次に挑戦するアプリ

このアプリをマスターしたら、次のアプリにも挑戦してみましょう。

🐛
エラーが出て動かないときは

写経中に赤いエラー文が出たら、Pythonエラー一覧&解決法(英語メッセージ逆引き)で原因と直し方をすぐ確認できます。

📖
次のレベルへ進む参考書

このレベルのアプリが作れたら、入門書の次の「作るための本」へ進む時期です。実践におすすめのPython本(当サイトの参考書ランキング総合3〜4位)で自動化とコードの書き方の2冊を、その直後の用途別専門書の節でデータ分析・Web・AIの本を比較しています。