EST. 2026 — Notes from real server work SYSADMIN · SECURITY · AI · DIRECTADMIN Issue 01
THE SYSADMIN ALMANAC
Home / Network / Planning 2.4GHz WiFi Channels 1/6/11 and Multi-Floor Heatmaps
Network

Planning 2.4GHz WiFi Channels 1/6/11 and Multi-Floor Heatmaps

by Ton-Or ·23 Aug 2026·9 min read
Planning 2.4GHz WiFi Channels 1/6/11 and Multi-Floor Heatmaps

2.4GHz has only three non-overlapping channels. That is all. The rest is about placing those three channels in a three-dimensional space so people upstairs and downstairs do not have to compete for the same airtime.

I took on a WiFi installation in a residential building: seven APs per floor, five floors, thirty-five APs in total. On my first site visit, I scanned with my phone and found neighboring APs and the building's existing APs scattered across channels with no plan. Most were set to auto and left alone. When usage was heavy, speeds dropped throughout the building even though the incoming connection had plenty of capacity. So I sat down and replanned the channels for the entire building before touching a single device, then prepared per-AP configuration documents that the technicians could follow on site.

The building name, SSID, and password in this article are all examples. The RF values and scripts are the ones I actually used.

Why 2.4GHz Leaves Only Three Usable Channels

In Thailand, the 2.4GHz band allows channels up to 13, spaced 5 MHz apart. The problem is that an 802.11 signal with a 20 MHz channel width occupies several times the space between channels. Put simply, setting one AP to channel 1 and another to channel 3 is like having two people sitting on top of each other, with their chairs shifted just a little.

There is only one truly non-overlapping set: 1, 6, 11, spaced 25 MHz apart to accommodate the signal width.

Partial overlap

Channel 1 and channel 3 or 4

The receiver hears the other side's signal as noise, rather than a signal it can decode. WiFi's collision avoidance mechanism cannot work, so actual collisions occur, retransmissions are needed, and both sides slow down.

No overlap

Channel 1 and 6 or 11

The receivers cannot hear each other at all. Each transmits independently, both at full speed. This is the reason to stick to 1/6/11, not simply because it is customary.

The irony is that setting two APs to exactly the same channel works better than setting them three channels apart. On the same channel, they can hear each other clearly, so the listen-before-transmit mechanism works and they take turns transmitting. They slow down, but do not collide. With overlapping channels, they hear static, cannot avoid each other, collide, and have to retransmit.

Per-Floor Channel Plan: Rotate Channels to Avoid Vertical Reuse

Most people plan channels only horizontally, making sure adjacent APs on the same floor use different channels, then copy that plan to every floor. This is the most common mistake I see, because concrete floors do not block 2.4GHz signals as much as people expect. APs in the same position on different floors can hear each other clearly.

My approach is to plan the first floor carefully, then rotate the channel set one step on the next floor. After three floors, the pattern repeats. Floor 4 matches floor 1, and floor 5 matches floor 2. Three floors of separation is considered safe.

AP1AP2AP3Floor 1Ch 11Ch 6Ch 1Initial setFloor 2Ch 1Ch 11Ch 6Rotate one stepFloor 3Ch 6Ch 1Ch 11Rotate one moreFloor 4Ch 11Ch 6Ch 1= Same as floor 1Floor 5Ch 1Ch 11Ch 6= Same as floor 2AP1-AP3 from the article's CSV table. AP1 uses different channels on adjacent floors.
Rotate the channel set one step on each floor, repeating after three floors, so vertically aligned APs do not share a channel with those on adjacent floors.
Floor,AP1,AP2,AP3,AP4,AP5,AP6,AP7
1,11,6,1,11,6,11,1
2,1,11,6,1,11,1,6
3,6,1,11,6,1,6,11
4,11,6,1,11,6,11,1
5,1,11,6,1,11,1,6

I keep this table in a separate CSV file, rather than only in the documentation, because a script will read it later in the article.

Notice that on each floor I do not simply assign 1-6-11-1-6-11 in AP number order. I look at their actual positions on the plan to see which ones are adjacent. AP1 and AP7 in this example are in different wings of the building, so they can use the same channel without a problem. AP4 and AP5 are next to each other in the central hall and must never share a channel. So do not copy my table directly; check your own floor plan first.

35 APsAcross the building
20 MHzChannel width
8 dBmTx power
-60 dBmMin RSSI

RF Settings: Lock to 20 MHz, Lock Channels, Disable Auto

The key to a channel plan is that it means nothing unless the channels are locked. An AP set to auto will change channels when it reboots or encounters interference, and the plan for the entire building can fall apart within a week.

These are the settings I use on every AP's RF page.

Enable RF              : Enabled
Country/Region         : Thailand
Network Mode           : 11b/g/n
Channel                : ตามตารางต่อชั้นต่อจุด (1 / 6 / 11)
Channel Bandwidth      : 20 MHz
Lock Channel           : Enable          # ห้ามให้มันเปลี่ยนเอง
Isolate SSID           : Disable
APSD                   : Disable
Client Timeout Interval: 5 minutes
Do not enable 40 MHz on 2.4GHz

40 MHz takes up twice the channel space, which means the three available channels immediately become just one. In a building with multiple APs, it always makes things worse, without exception. The extra speed on paper is not worth having all the APs collide again. If you want wider bandwidth, use 5GHz instead, where there are plenty of channels.

Tx Power: Maximum Power Does Not Mean the Best Results

This goes against most people's instincts. I set Tx power to 8 dBm, several times lower than the device's maximum, and I keep it low deliberately.

The first reason is the imbalance in transmit power. An AP runs on mains power and has large antennas; a phone runs on a battery and has small antennas. If the AP transmits at maximum power, a phone can "hear" it clearly from very far away and stay connected instead of roaming. But when the phone replies, the AP cannot hear it. The result is a device showing full signal bars with no working internet connection, the problem I encounter most often in WiFi work.

The second reason is that increasing transmit power expands each AP's coverage into neighboring APs' areas, creating interference within the same building.

The third reason is that lower transmit power gives each AP a more distinct coverage boundary. Clients decide to move to a closer AP sooner, greatly reducing connection freezes when walking from one room to another.

Alongside low Tx power, I set the Min RSSI threshold to -60 dBm. This is the cutoff below which the AP will not accept a client's signal, forcing it to connect to a closer AP.

Beacon Interval    : 100 ms
Fragment Threshold : 2346
RTS Threshold      : 2347
DTIM Interval      : 1
Min RSSI Threshold : -60 dBm
Transmit Power     : 8 dBm
Lock Power         : Checked
Preamble           : Long Preamble

Beacon 100 ms is the standard value. Do not increase the interval, because it makes devices slower to discover the signal when entering the area. DTIM 1 means sending broadcast data at every beacon for the fastest response. It uses slightly more phone battery than DTIM 3, but I accept that tradeoff because these rooms have people actively using the network, not sensors that need to last a year. RTS 2347 disables RTS/CTS entirely. With densely deployed APs at low transmit power, hidden nodes are barely an issue, and enabling it only adds overhead.

I configure the same SSID on every AP in the building, with one name and one password, so clients can move between APs without logging in again.

SSID                   : SSID_A
Broadcast SSID         : Enable
Isolate Client         : Enable        # ลูกค้าคุยกันเองไม่ได้
WMF                    : Disable
Max. Number of Clients : 16
Security Mode          : WPA/WPA2-PSK (Mixed)
Encryption Algorithm   : AES
Key                    : <PASSWORD>
Key Update Interval    : 3600

Max clients 16 per AP is the number I like to use for residential installations. It is not because the hardware cannot handle more, but because beyond that, the quality per user drops noticeably. Setting a limit pushes extra clients toward neighboring APs with spare capacity, which is better than letting thirty devices crowd onto one AP.

I also use a consistent device naming pattern, BLDG-F{ชั้น}-AP{จุด}, such as BLDG-F3-AP5, and attach a sticker to each device showing its name and assigned channel. When a problem occurs, any technician can read it and immediately identify the AP in the plan.

Writing a Script to Label Floor Plans with Channels Using PIL

Once the plan is ready, the next problem is communication. The technicians doing the installation do not read CSV tables; they carry floor plans. So I wrote a script that places channel numbers at the installation points on a floor plan and outputs an image for each floor, ready to print and hand out.

The script uses only PIL, with no additional dependencies. It has three main parts: finding a font that supports Thai, storing AP positions as proportional coordinates from 0 to 1 rather than pixels because the supplied floor plans vary in size, and a function that rotates channels by floor.

from PIL import Image, ImageDraw, ImageFont
from pathlib import Path

def load_font(size: int, prefer_thai: bool = False) -> ImageFont.FreeTypeFont:
    thai_first = ['C:/Windows/Fonts/LeelawDBd.ttf', 'C:/Windows/Fonts/LeelawUI.ttf',
                  'C:/Windows/Fonts/THSarabunNew.ttf', 'C:/Windows/Fonts/tahoma.ttf',
                  '/usr/share/fonts/truetype/noto/NotoSansThai-Regular.ttf']
    latin_fallback = ['C:/Windows/Fonts/arialbd.ttf', 'C:/Windows/Fonts/arial.ttf',
                      '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf']
    candidates = (thai_first + latin_fallback) if prefer_thai else (latin_fallback + thai_first)
    for p in candidates:
        try:
            return ImageFont.truetype(p, size)
        except Exception:
            pass
    return ImageFont.load_default()

# ตำแหน่งจุดติดตั้งเป็นสัดส่วนของภาพ ไม่ผูกกับขนาดพิกเซล
positions = {1: (0.36, 0.74), 2: (0.41, 0.53), 3: (0.41, 0.33),
             4: (0.70, 0.27), 5: (0.72, 0.53), 6: (0.73, 0.77)}

order = ['Ch1', 'Ch6', 'Ch11']
floor1_channels = {1: 'Ch1', 2: 'Ch6', 3: 'Ch11', 4: 'Ch1', 5: 'Ch6', 6: 'Ch11'}

def rotate_channel(ch: str, offset: int) -> str:
    return order[(order.index(ch) + offset) % len(order)]

For the actual drawing, the trick is to draw the text twice in the same place. The first pass is black with a thick stroke of 4 to create an outline; the second is yellow with a stroke of 1 on top. This produces text that stays legible whether the plan underneath contains black lines or a white background. That matters because architectural plans contain both in the same image.

def annotate_floor(im_base, floor: int, out_dir: Path, base_name: str) -> Path:
    im = im_base.copy()
    draw = ImageDraw.Draw(im)
    W, H = im.size
    font = load_font(max(22, int(W * 0.03)))
    title_font = load_font(int(max(22, int(W * 0.03)) * 1.4), prefer_thai=True)

    offset = (floor - 1) % 3                       # ชั้น 1,4 = 0 / ชั้น 2,5 = 1 / ชั้น 3 = 2
    channel_by_ap = {ap: rotate_channel(ch, offset) for ap, ch in floor1_channels.items()}

    for ap, (nx, ny) in positions.items():
        x, y = int(nx * W), int(ny * H)
        pos = (x + int(0.012 * W), y - int(0.012 * H))
        text = channel_by_ap[ap]
        draw.text(pos, text, font=font, fill=(0, 0, 0, 255),
                  stroke_width=4, stroke_fill=(0, 0, 0, 255))       # ขอบดำหนา
        draw.text(pos, text, font=font, fill=(255, 255, 0, 255),
                  stroke_width=1, stroke_fill=(0, 0, 0, 255))       # ตัวเหลืองทับ

    title = f"ชั้น {floor}"
    m = int(0.02 * W)
    draw.text((m, m), title, font=title_font, fill=(0, 0, 0, 255),
              stroke_width=4, stroke_fill=(0, 0, 0, 255))
    draw.text((m, m), title, font=title_font, fill=(255, 255, 255, 255),
              stroke_width=1, stroke_fill=(0, 0, 0, 255))

    out_path = out_dir / f"{base_name}_floor{floor}_channels.png"
    im.save(out_path)
    return out_path

if __name__ == '__main__':
    in_path = Path('plan') / 'floorplan.png'
    im_base = Image.open(in_path).convert('RGBA')
    for floor in range(1, 6):
        print(' -', annotate_floor(im_base, floor, in_path.parent, in_path.stem).as_posix())

One run produces five floor plans with channel labels. Changing AP locations in the positions variable and rerunning takes less than a second. This is where writing the script pays off, because during planning I moved APs and swapped channels many times. If I had done it manually in a drawing program, I would probably have given up by the third round.

Planning workflowFloor planPNG from drawings→Place APsProportional coordinates→Assign channelsRotate 1/6/11→annotatePIL image output→Walkthrough surveyMeasured heatmap→Adjust and repeat

Creating a Measured Heatmap: Plans and Reality Never Match

The final step that must not be skipped is a walkthrough survey. A floor plan does not tell you which rooms have thick concrete walls, which have gypsum walls, or where metal cabinets or elevators block the signal. These can change the results by tens of dB.

The heatmap method I use does not require expensive tools. I use survey software that accepts a floor plan image and lets me click my current position while taking a measurement. I walk through the rooms and click as I go, and the software generates the color gradients. The rule is to cover every room and every hidden corner, rather than walking only along the central corridor, where the signal is always good anyway.

My criterion is that the signal at the far end of a room must be better than -67 dBm. Between -67 and -70 dBm is borderline: adequate for general use, but video calls will stutter. Below -70 dBm needs fixing. The fixes, in the order I try them, are to move the AP first, then add an AP if moving it is not possible. Increasing Tx power is always the last resort, because it fixes the downlink but does not fix the uplink from the phone.

  1. Walk through and scan before installation, noting which channels are occupied by signals from neighboring buildings. If neighbors already occupy channel 6 heavily, favor 1 and 11.
  2. Place APs on the floor plan and assign channels using the per-floor rotation principle. Generate floor plan images with channel labels using the script.
  3. Install according to the plan, configure each AP using the checklist, lock both the channel and transmit power, and attach a name sticker to the device.
  4. Survey every room on every floor, record the readings in a heatmap, and mark locations below -70 dBm.
  5. Fix only the weak spots, then resurvey those areas. There is no need to redo the entire plan.
  6. Keep the channel CSV file, floor plan images, and heatmaps as handover documentation so the next person making changes does not have to start from scratch.

Observations from Actual Installations

The point I most want to stress is that people often blame equipment for not being powerful enough, even though nine times out of ten the problem is channel planning and transmit power. Replacing APs with more expensive models while leaving them on auto gives the same results or worse, because expensive models often transmit at higher power, which means more interference between APs.

The other issue is documentation. On a job with no saved channel table, someone can reset an AP six months later and wipe out its planned settings, with nobody knowing what they used to be. That is why I treat the CSV file and floor plan images as part of the job, not an extra.

Still on my list is making the script read channels directly from the CSV file instead of rotating them with a formula in the code, and saving actual measurements to a file so PIL can render the heatmap itself without relying on survey software. Anyone adapting this approach to their own building can change the AP count and coordinates as needed. The per-floor rotation principle still applies. If I have left anything out or made a typo, I will come back and fix it later.

Hit something like this? Stuck somewhere?

Come trade notes on the LinuxThai board

→ Continue on the board

Comments 0

No comments yet — be the first