Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b432129798 | ||
|
|
344ca547fd | ||
|
|
182de0526b | ||
|
|
333063f350 | ||
|
|
ee974bbd73 | ||
|
|
00496b56e9 | ||
|
|
5b7897c872
|
||
|
|
dfc51aa0ec | ||
|
|
7e0e338159 | ||
|
|
303bb21bd1 | ||
|
|
58341902f7 | ||
|
|
f3a1a73231 | ||
|
|
590ac608cd | ||
|
|
039e64eea0 | ||
|
|
0b6120defc | ||
|
|
756f65e825 | ||
|
|
1de8b77c52 | ||
|
|
6b15b98c12 | ||
|
|
b0c04d2238 | ||
|
|
01a2d95a8a | ||
|
|
db844ef4f7 | ||
|
|
8ee9d59d7a | ||
|
|
e7631ef688 | ||
|
|
08dff3e60d | ||
|
|
b6214fb893 | ||
|
|
823af70caa | ||
|
|
334e5b03a8 | ||
|
|
273becf793 | ||
|
|
32e28f8797 | ||
|
|
b3ff39a365 | ||
|
|
84ac5eb479 | ||
|
|
31bc42982d | ||
|
|
7acdfca4f5
|
||
|
|
c3fd931fee | ||
|
|
373a30933b
|
||
|
|
43538a277f | ||
|
|
5ec16fa458 | ||
|
|
9ef0aeef1c | ||
|
|
46d722967a
|
||
|
|
fc246a5607
|
||
|
|
8cdeb2ad63 | ||
|
|
df0e535fb3
|
||
|
|
e85d4c2fe2
|
||
|
|
360e55633b
|
||
|
|
367967518e
|
||
|
|
d8859247c6 | ||
|
|
bf1b72a6b9 | ||
|
|
9545e708fd | ||
|
|
37121949e4 | ||
|
|
d70d8d7a24 | ||
|
|
6a715567dd | ||
|
|
a5f96324e6 | ||
|
|
a3e9581c4b | ||
|
|
3285960ee3 | ||
|
|
d532f935d6 | ||
|
|
3988a6d456 | ||
|
|
598be64306 | ||
|
|
7fcea718ec | ||
|
|
fd7d0a6807 | ||
|
|
e424b44cb2 | ||
|
|
dc703fca9e | ||
|
|
77c9d34d64 | ||
|
|
efd6ed6299 | ||
|
|
de8e8ba471 | ||
|
|
eaf611b203 | ||
|
|
e339b6fccf | ||
|
|
bd9b42d4a5 | ||
|
|
275ce48769 | ||
|
|
d201997587
|
||
|
|
250290a1eb | ||
|
|
052cfea7ad
|
||
|
|
d930b57196 | ||
|
|
eb29d56f4e | ||
|
|
eb2a7f88a4 | ||
|
|
8c3e4c15af | ||
|
|
c8b1f97872 | ||
|
|
b47fd21dd0 | ||
|
|
f009ed4b3d | ||
|
|
47126f4ae1
|
||
|
|
7d3177ba8a | ||
|
|
ff71623b90
|
||
|
|
ead985a741
|
||
|
|
ef1eeeed36
|
||
|
|
4a34e28a84
|
||
|
|
38ef50cee8
|
||
|
|
d45a637d00 | ||
|
|
1ec2b1a450 | ||
|
|
19e8cff6b6 | ||
|
|
62999cfed8
|
||
|
|
49524146e8
|
||
|
|
dd2ac3bc69
|
||
|
|
098dd31b58 | ||
|
|
8ecbf7dfa0 | ||
|
|
1b30de34cc | ||
|
|
65b2e561f4 | ||
|
|
6907af5c81 | ||
|
|
59b8e2fbbb | ||
|
|
5838d3a999 | ||
|
|
6d1e08711a | ||
|
|
67b91a766a | ||
|
|
f83cdbd804 | ||
|
|
9359568a33 | ||
|
|
8f46909ebf | ||
|
|
59638f6ab3 | ||
|
|
59aade8004 | ||
|
|
42255e2b41 | ||
|
|
217c7e8451 | ||
|
|
a99d611ea8 | ||
|
|
7b0c86a5a0 | ||
|
|
1bd338c7ee | ||
|
|
dfe5e28877 | ||
|
|
248a7c0462 | ||
|
|
db83d6a9df | ||
|
|
006ee7b060 | ||
|
|
8ae8c9996f | ||
|
|
9d6b2536dd | ||
|
|
4d903f5344 | ||
|
|
34c1c68943 | ||
|
|
f48b72c54e | ||
|
|
413f69eb6a | ||
|
|
69fe6dc7b1 | ||
|
|
80214bf42d | ||
|
|
62b6bbdcbc
|
@@ -0,0 +1,155 @@
|
||||
name: AUR Nightly (kst4contest-git)
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Nightly Runtime Artifacts"]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
AUR_SSH_DIR: /tmp/aur-ssh
|
||||
|
||||
jobs:
|
||||
update-aur-git:
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
container:
|
||||
image: archlinux:latest
|
||||
|
||||
steps:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pacman -Sy --noconfirm git openssh base-devel
|
||||
useradd -m builder
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Mark workspace as safe Git directory
|
||||
run: |
|
||||
git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
|
||||
- name: Compute pkgver
|
||||
id: ver
|
||||
run: |
|
||||
BASE=$(grep -m1 '<version>' pom.xml \
|
||||
| sed 's/.*<version>\(.*\)<\/version>.*/\1/' \
|
||||
| sed 's/[-.]nightly//')
|
||||
|
||||
GIT_PKGVER="${BASE}.r$(git rev-list --count HEAD).g$(git rev-parse --short HEAD)"
|
||||
|
||||
echo "Computed pkgver: ${GIT_PKGVER}"
|
||||
echo "pkgver=${GIT_PKGVER}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Update PKGBUILD
|
||||
env:
|
||||
PKGVER: ${{ steps.ver.outputs.pkgver }}
|
||||
run: |
|
||||
sed -i "s/^pkgver=.*/pkgver=${PKGVER}/" \
|
||||
packaging/aur/kst4contest-git/PKGBUILD
|
||||
|
||||
- name: Generate .SRCINFO
|
||||
run: |
|
||||
cp -r packaging/aur/kst4contest-git /tmp/kst4contest-git
|
||||
chown -R builder:builder /tmp/kst4contest-git
|
||||
|
||||
su builder -c \
|
||||
"cd /tmp/kst4contest-git && makepkg --printsrcinfo > .SRCINFO"
|
||||
|
||||
cp /tmp/kst4contest-git/.SRCINFO \
|
||||
packaging/aur/kst4contest-git/.SRCINFO
|
||||
|
||||
echo "Generated .SRCINFO:"
|
||||
cat packaging/aur/kst4contest-git/.SRCINFO
|
||||
|
||||
- name: Set up and verify AUR SSH
|
||||
env:
|
||||
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
mkdir -p "${AUR_SSH_DIR}"
|
||||
|
||||
printf '%s\n' "${AUR_SSH_PRIVATE_KEY}" \
|
||||
> "${AUR_SSH_DIR}/aur_ed25519"
|
||||
|
||||
sed -i 's/\r$//' "${AUR_SSH_DIR}/aur_ed25519"
|
||||
chmod 600 "${AUR_SSH_DIR}/aur_ed25519"
|
||||
|
||||
ssh-keygen -y \
|
||||
-f "${AUR_SSH_DIR}/aur_ed25519" \
|
||||
> /dev/null
|
||||
|
||||
ssh-keyscan \
|
||||
-T 10 \
|
||||
-t ed25519 \
|
||||
aur.archlinux.org \
|
||||
> "${AUR_SSH_DIR}/known_hosts"
|
||||
|
||||
if [ ! -s "${AUR_SSH_DIR}/known_hosts" ]; then
|
||||
echo "::error::No SSH host key was received from aur.archlinux.org."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Received AUR host-key fingerprint:"
|
||||
ssh-keygen -lf "${AUR_SSH_DIR}/known_hosts"
|
||||
|
||||
if ! ssh-keygen -lf "${AUR_SSH_DIR}/known_hosts" \
|
||||
| grep -Fq "SHA256:RFzBCUItH9LZS0cKB5UE6ceAYhBD5C8GeOBip8Z11+4"; then
|
||||
echo "::error::The AUR SSH host-key fingerprint does not match the official fingerprint."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '%s\n' \
|
||||
"Host aur.archlinux.org" \
|
||||
" HostName aur.archlinux.org" \
|
||||
" User aur" \
|
||||
" IdentityFile ${AUR_SSH_DIR}/aur_ed25519" \
|
||||
" IdentitiesOnly yes" \
|
||||
" StrictHostKeyChecking yes" \
|
||||
" UserKnownHostsFile ${AUR_SSH_DIR}/known_hosts" \
|
||||
> "${AUR_SSH_DIR}/config"
|
||||
|
||||
chmod 600 "${AUR_SSH_DIR}/config"
|
||||
chmod 600 "${AUR_SSH_DIR}/known_hosts"
|
||||
|
||||
- name: Push package metadata to AUR
|
||||
env:
|
||||
GIT_SSH_COMMAND: ssh -F /tmp/aur-ssh/config
|
||||
run: |
|
||||
git config --global user.email "philipp@wagnersnetz.de"
|
||||
git config --global user.name "Philipp Wagner"
|
||||
|
||||
AUR_DIR="/tmp/aur/kst4contest-git"
|
||||
|
||||
mkdir -p "$(dirname "${AUR_DIR}")"
|
||||
|
||||
git -c init.defaultBranch=master clone \
|
||||
"ssh://aur@aur.archlinux.org/kst4contest-git.git" \
|
||||
"${AUR_DIR}"
|
||||
|
||||
cp packaging/aur/kst4contest-git/PKGBUILD \
|
||||
"${AUR_DIR}/PKGBUILD"
|
||||
|
||||
cp packaging/aur/kst4contest-git/.SRCINFO \
|
||||
"${AUR_DIR}/.SRCINFO"
|
||||
|
||||
git -C "${AUR_DIR}" add PKGBUILD .SRCINFO
|
||||
|
||||
if git -C "${AUR_DIR}" diff --cached --quiet; then
|
||||
echo "kst4contest-git: package metadata is already current."
|
||||
else
|
||||
git -C "${AUR_DIR}" commit \
|
||||
-m "Update pkgver to ${{ steps.ver.outputs.pkgver }}"
|
||||
|
||||
git -C "${AUR_DIR}" push origin HEAD:master
|
||||
|
||||
echo "kst4contest-git: successfully pushed to AUR."
|
||||
fi
|
||||
@@ -0,0 +1,177 @@
|
||||
name: Publish AUR Packages
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Tagged Release Build"]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release tag (e.g. v1.41.1) — defaults to latest stable release"
|
||||
required: false
|
||||
default: ""
|
||||
dry_run:
|
||||
description: "Dry run — skip AUR push and repo commit (for testing)"
|
||||
type: choice
|
||||
options: ["false", "true"]
|
||||
default: "false"
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
publish-aur:
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: archlinux:latest
|
||||
|
||||
steps:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pacman -Sy --noconfirm git openssh curl base-devel nodejs
|
||||
useradd -m builder
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4.1.7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve release version
|
||||
id: ver
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
if [[ -n "${INPUT_VERSION}" ]]; then
|
||||
TAG="${INPUT_VERSION}"
|
||||
else
|
||||
TAG=$(curl -sf \
|
||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"https://api.github.com/repos/${{ github.repository }}/releases/latest" \
|
||||
| grep '"tag_name"' | sed 's/.*"tag_name": *"\([^"]*\)".*/\1/')
|
||||
fi
|
||||
PKGVER="${TAG#v}"
|
||||
echo "tag=${TAG} → pkgver=${PKGVER}"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "pkgver=${PKGVER}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Compute SHA256 checksums
|
||||
id: sha
|
||||
run: |
|
||||
TAG="${{ steps.ver.outputs.tag }}"
|
||||
REPO="${{ github.repository }}"
|
||||
|
||||
echo "Hashing pre-built Arch package..."
|
||||
SHA_BIN=$(curl -sfL \
|
||||
"https://github.com/${REPO}/releases/download/${TAG}/KST4Contest-${TAG}-archlinux-x86_64.pkg.tar.zst" \
|
||||
| sha256sum | awk '{print $1}')
|
||||
echo "bin: ${SHA_BIN}"
|
||||
|
||||
echo "Hashing source tarball..."
|
||||
SHA_SRC=$(curl -sfL \
|
||||
"https://github.com/${REPO}/archive/refs/tags/${TAG}.tar.gz" \
|
||||
| sha256sum | awk '{print $1}')
|
||||
echo "src: ${SHA_SRC}"
|
||||
|
||||
echo "bin=${SHA_BIN}" >> "$GITHUB_OUTPUT"
|
||||
echo "src=${SHA_SRC}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Update PKGBUILDs
|
||||
env:
|
||||
PKGVER: ${{ steps.ver.outputs.pkgver }}
|
||||
SHA_BIN: ${{ steps.sha.outputs.bin }}
|
||||
SHA_SRC: ${{ steps.sha.outputs.src }}
|
||||
run: |
|
||||
# kst4contest-bin
|
||||
sed -i "s/^pkgver=.*/pkgver=${PKGVER}/" packaging/aur/kst4contest-bin/PKGBUILD
|
||||
sed -i "s/^sha256sums=.*/sha256sums=('${SHA_BIN}')/" packaging/aur/kst4contest-bin/PKGBUILD
|
||||
|
||||
# kst4contest (source build)
|
||||
sed -i "s/^pkgver=.*/pkgver=${PKGVER}/" packaging/aur/kst4contest/PKGBUILD
|
||||
sed -i "s/^sha256sums=.*/sha256sums=('${SHA_SRC}')/" packaging/aur/kst4contest/PKGBUILD
|
||||
|
||||
# kst4contest-git: Basis aus pom.xml, Suffix aus git
|
||||
BASE_VER=$(grep -m1 '<version>' pom.xml \
|
||||
| sed 's/.*<version>\(.*\)<\/version>.*/\1/' | sed 's/[-.]nightly//')
|
||||
GIT_PKGVER="${BASE_VER}.r$(git rev-list --count HEAD).g$(git rev-parse --short HEAD)"
|
||||
sed -i "s/^pkgver=.*/pkgver=${GIT_PKGVER}/" packaging/aur/kst4contest-git/PKGBUILD
|
||||
|
||||
echo "=== Updated PKGBUILD versions ==="
|
||||
grep -H '^pkgver=\|^sha256sums=' packaging/aur/*/PKGBUILD
|
||||
|
||||
- name: Generate .SRCINFO files
|
||||
run: |
|
||||
for pkg in kst4contest-bin kst4contest kst4contest-git; do
|
||||
cp -r "packaging/aur/${pkg}" "/tmp/${pkg}"
|
||||
chown -R builder:builder "/tmp/${pkg}"
|
||||
su builder -c "cd /tmp/${pkg} && makepkg --printsrcinfo > .SRCINFO"
|
||||
cp "/tmp/${pkg}/.SRCINFO" "packaging/aur/${pkg}/.SRCINFO"
|
||||
echo "=== ${pkg}/.SRCINFO ==="
|
||||
cat "packaging/aur/${pkg}/.SRCINFO"
|
||||
done
|
||||
|
||||
- name: Commit updated PKGBUILDs to repo
|
||||
if: inputs.dry_run != 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git config user.name "github-actions[bot]"
|
||||
git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${{ github.repository }}.git"
|
||||
git add packaging/aur/
|
||||
git diff --cached --quiet && echo "No PKGBUILD changes to commit." && exit 0
|
||||
git commit -m "chore: update AUR packages to ${{ steps.ver.outputs.tag }} [skip ci]"
|
||||
git push
|
||||
|
||||
- name: Set up AUR SSH
|
||||
if: inputs.dry_run != 'true'
|
||||
env:
|
||||
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "${AUR_SSH_PRIVATE_KEY}" > ~/.ssh/aur_ed25519
|
||||
chmod 600 ~/.ssh/aur_ed25519
|
||||
ssh-keyscan -t ed25519 aur.archlinux.org >> ~/.ssh/known_hosts
|
||||
cat >> ~/.ssh/config << 'EOF'
|
||||
Host aur.archlinux.org
|
||||
IdentityFile ~/.ssh/aur_ed25519
|
||||
User aur
|
||||
EOF
|
||||
|
||||
- name: Push to AUR
|
||||
if: inputs.dry_run != 'true'
|
||||
env:
|
||||
TAG: ${{ steps.ver.outputs.tag }}
|
||||
run: |
|
||||
git config --global user.email "philipp@wagnersnetz.de"
|
||||
git config --global user.name "Philipp Wagner"
|
||||
|
||||
push_to_aur() {
|
||||
local pkg="$1"
|
||||
local msg="$2"
|
||||
local aur_dir="/tmp/aur/${pkg}"
|
||||
|
||||
git clone "ssh://aur@aur.archlinux.org/${pkg}.git" "${aur_dir}" 2>/dev/null || {
|
||||
mkdir -p "${aur_dir}"
|
||||
git -C "${aur_dir}" init
|
||||
git -C "${aur_dir}" remote add origin "ssh://aur@aur.archlinux.org/${pkg}.git"
|
||||
}
|
||||
|
||||
cp "packaging/aur/${pkg}/PKGBUILD" "${aur_dir}/"
|
||||
cp "packaging/aur/${pkg}/.SRCINFO" "${aur_dir}/"
|
||||
git -C "${aur_dir}" add PKGBUILD .SRCINFO
|
||||
git -C "${aur_dir}" diff --cached --quiet \
|
||||
&& echo "${pkg}: no changes, skipping push" && return 0
|
||||
git -C "${aur_dir}" commit -m "${msg}"
|
||||
git -C "${aur_dir}" push origin HEAD:master
|
||||
echo "${pkg}: pushed to AUR"
|
||||
}
|
||||
|
||||
push_to_aur kst4contest-bin "Update to ${TAG}"
|
||||
push_to_aur kst4contest "Update to ${TAG}"
|
||||
push_to_aur kst4contest-git \
|
||||
"Update pkgver to $(grep '^pkgver=' packaging/aur/kst4contest-git/PKGBUILD | cut -d= -f2)"
|
||||
@@ -4,8 +4,12 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
schedule:
|
||||
- cron: "20 2 * * *"
|
||||
paths:
|
||||
- "src/**"
|
||||
- "pom.xml"
|
||||
- "mvnw"
|
||||
- "mvnw.cmd"
|
||||
- ".github/workflows/nightly-artifacts.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
|
||||
@@ -435,12 +435,14 @@ jobs:
|
||||
FLATPAK_GPG_KEY_ID=$(gpg --list-secret-keys --with-colons | awk -F: '/^fpr/{print $10; exit}')
|
||||
echo "FLATPAK_GPG_KEY_ID=$FLATPAK_GPG_KEY_ID" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Determine OSTree branch
|
||||
- name: Determine OSTree branch and flatpakref filename
|
||||
run: |
|
||||
if [[ "${{ github.ref_name }}" == beta-* ]]; then
|
||||
echo "OSTREE_BRANCH=beta" >> "$GITHUB_ENV"
|
||||
echo "FLATPAKREF_NAME=de.x08.KST4Contest.beta.flatpakref" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "OSTREE_BRANCH=stable" >> "$GITHUB_ENV"
|
||||
echo "FLATPAKREF_NAME=de.x08.KST4Contest.flatpakref" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Build Flatpak repo
|
||||
@@ -454,7 +456,7 @@ jobs:
|
||||
REPO_NAME="${GITHUB_REPOSITORY#*/}"
|
||||
PAGES_URL="https://${GITHUB_REPOSITORY_OWNER}.github.io/${REPO_NAME}/"
|
||||
GPG_KEY_B64=$(gpg --export "$FLATPAK_GPG_KEY_ID" | base64 -w 0)
|
||||
cat > "dist/de.x08.KST4Contest.${OSTREE_BRANCH}.flatpakref" << EOF
|
||||
cat > "dist/${FLATPAKREF_NAME}" << EOF
|
||||
[Flatpak Ref]
|
||||
Name=de.x08.KST4Contest
|
||||
Branch=${OSTREE_BRANCH}
|
||||
@@ -469,7 +471,7 @@ jobs:
|
||||
uses: actions/upload-artifact@v4.3.4
|
||||
with:
|
||||
name: flatpakref
|
||||
path: dist/de.x08.KST4Contest.*.flatpakref
|
||||
path: dist/*.flatpakref
|
||||
|
||||
- name: Upload Flatpak OSTree repo
|
||||
uses: actions/upload-artifact@v4.3.4
|
||||
@@ -747,7 +749,7 @@ jobs:
|
||||
release-assets/debian/KST4Contest-${{ github.ref_name }}-debian-amd64.deb,
|
||||
release-assets/fedora/KST4Contest-${{ github.ref_name }}-fedora-x86_64.rpm,
|
||||
release-assets/archlinux/KST4Contest-${{ github.ref_name }}-archlinux-*.pkg.tar.zst,
|
||||
release-assets/flatpakref/de.x08.KST4Contest.flatpakref,
|
||||
release-assets/flatpakref/*.flatpakref,
|
||||
release-assets/macos/KST4Contest-${{ github.ref_name }}-macos-*.dmg,
|
||||
release-assets/docs/KST4Contest-${{ github.ref_name }}-manual-en.pdf,
|
||||
release-assets/docs/KST4Contest-${{ github.ref_name }}-manual-de.pdf
|
||||
|
||||
@@ -34,4 +34,13 @@ build/
|
||||
dist/
|
||||
|
||||
#zip files for local backups
|
||||
*.zip
|
||||
*.zip
|
||||
|
||||
# node Modules in website
|
||||
website/node_modules/
|
||||
|
||||
# built website output (rebuilt on the server)
|
||||
website/_site/
|
||||
|
||||
# Local secrets for act testing
|
||||
.secrets
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
# KST4Contest
|
||||
|
||||
KST4Contest (also known as pratiKST) is a Java-based chat client for ON4KST, focused on VHF/UHF/SHF contest operation.
|
||||
KST4Contest (also known as pratiKST) is a Java-based chat client for [ON4KST](http://www.on4kst.com/chat), focused on VHF/UHF/SHF contest operation.
|
||||
|
||||
## Website
|
||||
|
||||
The offical Website of KST4Contest is now instead of [do5amf.funkerportal.de](https://do5amf.funkerportal.de) the new website [here](https://kst4contest.hamradioonline.de) [https://kst4contest.hamradioonline.de](https://kst4contest.hamradioonline.de)
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -35,4 +39,4 @@ Wiki Publishing:
|
||||
|
||||
Builds:
|
||||
|
||||
[](https://github.com/praktimarc/kst4contest/actions/workflows/nightly-artifacts.yml)
|
||||
[](https://github.com/praktimarc/kst4contest/actions/workflows/nightly-artifacts.yml)
|
||||
|
||||
@@ -1,53 +1,25 @@
|
||||
# KST4Contest – Wiki
|
||||
# KST4Contest
|
||||
|
||||
**KST4Contest** (auch bekannt als *PraktiKST*) ist ein Java-basierter Chat-Client für den [ON4KST-Chat](http://www.on4kst.info/chat/), speziell entwickelt für den Contest-Betrieb auf den VHF/UHF/SHF-Bändern.
|
||||
KST4Contest is a desktop client for the [ON4KST Chat](https://www.on4kst.org/chat/login.php), developed for VHF, UHF and SHF contest operation. It combines chat, candidate selection, sked planning, aircraft scatter information and connections to logging and station software.
|
||||
|
||||
Entwickelt von **DO5AMF (Marc Fröhlich)**, Operator bei DM5M.
|
||||
KST4Contest ist ein Desktop-Client für den [ON4KST-Chat](https://www.on4kst.org/chat/login.php), der für den Contest-Betrieb auf den VHF-, UHF- und SHF-Bändern entwickelt wurde. Er verbindet Chat, Stationsauswahl, Sked-Planung, Aircraft-Scatter-Daten sowie die Anbindung an Log- und Stationssoftware.
|
||||
|
||||
Developed by / Entwickelt von **DO5AMF (Marc Fröhlich)**, operator at / Operator bei DM5M.
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Sprache / Language
|
||||
## Language / Sprache
|
||||
|
||||
| 🇩🇪 Deutsch | 🇬🇧 English |
|
||||
| Deutsch | English |
|
||||
|---|---|
|
||||
| [Startseite (Deutsch)](de-Home) | [Home (English)](en-Home) |
|
||||
| [Deutsches Handbuch](de-Home) | [English manual](en-Home) |
|
||||
|
||||
---
|
||||
|
||||
## 🇩🇪 Inhalt (Deutsch)
|
||||
## Project links / Projektlinks
|
||||
|
||||
| Seite | Inhalt |
|
||||
|---|---|
|
||||
| [Installation](de-Installation) | Download, Java-Voraussetzungen, Update |
|
||||
| [Konfiguration](de-Konfiguration) | Alle Einstellungen im Detail |
|
||||
| [Log-Synchronisation](de-Log-Synchronisation) | UCXLog, N1MM+, QARTest, DXLog.net, WinTest |
|
||||
| [AirScout-Integration](de-AirScout-Integration) | Flugzeug-Scatter-Erkennung |
|
||||
| [DX-Cluster-Server](de-DX-Cluster-Server) | Integrierter DX-Cluster für das Log-Programm |
|
||||
| [Funktionen](de-Funktionen) | Alle Features im Überblick |
|
||||
| [Makros und Variablen](de-Makros-und-Variablen) | Text-Snippets, Shortcuts, Variablen |
|
||||
| [Benutzeroberfläche](de-Benutzeroberflaeche) | UI-Erklärung und Bedienung |
|
||||
| [Changelog](de-Changelog) | Versionsgeschichte |
|
||||
|
||||
---
|
||||
|
||||
## 🇬🇧 Contents (English)
|
||||
|
||||
| Page | Contents |
|
||||
|---|---|
|
||||
| [Installation](en-Installation) | Download, Java requirements, updates |
|
||||
| [Configuration](en-Configuration) | All settings in detail |
|
||||
| [Log Synchronisation](en-Log-Sync) | UCXLog, N1MM+, QARTest, DXLog.net, WinTest |
|
||||
| [AirScout Integration](en-AirScout-Integration) | Aircraft scatter detection |
|
||||
| [DX Cluster Server](en-DX-Cluster-Server) | Built-in DX cluster for your logging software |
|
||||
| [Features](en-Features) | All features at a glance |
|
||||
| [Macros and Variables](en-Macros-and-Variables) | Text snippets, shortcuts, variables |
|
||||
| [User Interface](en-User-Interface) | UI explained and how to operate it |
|
||||
| [Changelog](en-Changelog) | Version history |
|
||||
|
||||
---
|
||||
|
||||
## Schnellinfo / Quick Info
|
||||
|
||||
- **Download**: https://github.com/praktimarc/kst4contest/releases
|
||||
- **GitHub**: https://github.com/praktimarc/kst4contest
|
||||
- **Kontakt / Contact**: praktimarc+kst4contest@gmail.com
|
||||
- [Project website / Projektwebseite](https://kst4contest.hamradioonline.de/)
|
||||
- [Download the current stable release / Aktuelle stabile Version herunterladen](https://github.com/praktimarc/kst4contest/releases/latest)
|
||||
- [Source code / Quellcode](https://github.com/praktimarc/kst4contest)
|
||||
- [Bug reports and feature requests / Fehler und Funktionswünsche](https://github.com/praktimarc/kst4contest/issues)
|
||||
- [Online manual / Online-Handbuch](https://kst4contest.hamradioonline.de/manual/)
|
||||
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 82 KiB |
@@ -37,6 +37,8 @@ Die zentrale Tabelle aller aktuell aktiven Chat-Nutzer. Spalten (je nach Konfigu
|
||||
|
||||
**Sortierung**: Klick auf Spaltenköpfe. QRB-Sortierung arbeitet numerisch (ab v1.22 korrigiert).
|
||||
|
||||
|
||||
Ein grün und fett dargestelltes Rufzeichen kennzeichnet eine aus einer gerichteten Nachricht hergeleitete Richtungsgelegenheit. Die Markierung bezieht sich auf den Absender der Nachricht und bleibt höchstens fünf Minuten sichtbar. Herleitung und Grenzen: [Richtungsgelegenheiten aus gerichteten Nachrichten](de-Funktionen#richtungsgelegenheiten-aus-gerichteten-nachrichten).
|
||||
### Sendfeld
|
||||
|
||||
Texteingabe für ausgehende Nachrichten. Nach Klick auf ein Rufzeichen in der Benutzerliste erhält das Sendfeld automatisch den Fokus – sofort tippen ohne Doppelklick (ab v1.22).
|
||||
@@ -51,22 +53,53 @@ Eingabefeld für die aktuelle Antennenrichtung. Wird für die geplante `MYQTF`-V
|
||||
|
||||
---
|
||||
|
||||
## Nachrichtentabellen
|
||||
|
||||
KST4Contest zeigt Nachrichtentexte bewusst einzeilig an. So bleiben auch bei hohem Chat-Aufkommen viele Einträge gleichzeitig sichtbar. Der Nachteil liegt auf der Hand: Bei einer schmalen **Message**-Spalte passt nicht jede Nachricht vollständig in die Zeile.
|
||||
|
||||
Ist ein Nachrichtentext breiter als die sichtbare Zelle, zeigt KST4Contest den vollständigen Inhalt als Tooltip an. Dazu die Maus kurz über die betreffende **Message**-Zelle halten. Passt der Text vollständig in die Spalte, wird kein zusätzlicher Volltext-Tooltip eingeblendet.
|
||||
|
||||
Webadressen mit `http://`, `https://` oder dem Präfix `www.` werden innerhalb des Nachrichtentextes als Links dargestellt. Ein Klick öffnet die Adresse im Standardbrowser des Betriebssystems. Andere Protokolle werden nicht als Link behandelt.
|
||||
|
||||

|
||||
|
||||
Damit muss der Divider nicht allein deshalb verschoben werden, um eine einzelne längere Nachricht zu lesen. Für einen dauerhaft breiteren Nachrichtenbereich kann er selbstverständlich weiterhin angepasst werden.
|
||||
|
||||
---
|
||||
|
||||
## Filter
|
||||
|
||||
Die Filter-Leiste (ab v1.21 als Flowpane für kleine Bildschirme):
|
||||
Die Filterleiste befindet sich oberhalb der Chatmember-Tabelle. Sie ist in mehrere logisch zusammengehörige Bereiche gegliedert:
|
||||
|
||||
- **Show only QTF**: Richtungsfilter aktivieren (Buttons N/NE/E/… oder Grad-Eingabe)
|
||||
- **Show only QRB [km] <=**: Entfernungsfilter aktivieren (Toggle-Button)
|
||||
- **Hide Worked [Band]**: Gearbeitete Stationen pro Band ausblenden (je ein Toggle pro Band)
|
||||
- **Hide NOT-QRV [Band]**: NOT-QRV-markierte Stationen pro Band ausblenden
|
||||
- **Show only QTF** begrenzt die Liste auf eine gewählte Antennenrichtung.
|
||||
- **Show only QRB [km] <=** setzt eine maximale Entfernung.
|
||||
- **Find** sucht nach einem Rufzeichen.
|
||||
- **Hide worked** und die Band-Schaltflächen blenden bereits gearbeitete beziehungsweise nicht verfügbare Stationen aus.
|
||||
- **Reachability**, **Only new grids**, **Tropo >=0dB**, **New bands** und **AS next 5m** unterstützen die Auswahl technisch interessanter Kandidaten.
|
||||
|
||||
Die Filterleiste besitzt keine feste Breite. QTF sowie die Worked- und Reachability-Filter nutzen zunächst den gesamten Platz ihrer jeweiligen Zeile. Wird der horizontale Divider nach rechts verschoben und die Chatmember-Ansicht dadurch schmaler, wechseln die Controls erst dann in die nächste Zeile, wenn ihre tatsächlich benötigte Breite nicht mehr zur Verfügung steht.
|
||||
|
||||

|
||||
|
||||
Im Klartext: Die Filter bestimmen weiterhin den Inhalt der Tabelle, aber nicht mehr die Mindestbreite der gesamten rechten Programmseite. In der normalen Ansicht bleibt die Leiste kompakt. Erst bei einer tatsächlich schmalen Ansicht benötigt sie mehr Höhe. Der Divider kann anschließend wieder nach links verschoben werden; die Controls ordnen sich unmittelbar neu an.
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
## Stationsinfo-Panel (Further Info)
|
||||
|
||||
Rechts unten: Zeigt alle Nachrichten einer ausgewählten Station (CQ-Nachrichten und PMs in einem Panel). Ein Nachrichtenfilter lässt sich über den Standard-Filter in den Preferences vorbelegen.
|
||||
Rechts unten werden die Nachrichten der ausgewählten Station zusammengeführt. Dazu gehören öffentliche Nachrichten, Privatnachrichten an die eigene Station und – soweit im Chat sichtbar – Privatnachrichten an andere Stationen.
|
||||
|
||||
Hier können auch **Sked-Erinnerungen** aktiviert werden.
|
||||
Der im Panel gewählte Filter bestimmt, welche dieser Nachrichten angezeigt werden. Unter **Settings → GUI** lässt sich festlegen, welcher Filter beim Öffnen einer Stationsinformation vorausgewählt ist:
|
||||
|
||||
- alle Nachrichten,
|
||||
- Privatnachrichten an die eigene Station,
|
||||
- Privatnachrichten an andere Stationen oder
|
||||
- öffentliche Nachrichten.
|
||||
|
||||
Die Einstellung verändert nur die Darstellung im Stationsinfo-Panel. Nachrichten werden dadurch weder verworfen noch aus den übrigen Nachrichtentabellen entfernt. Der Filter kann im Panel jederzeit für die aktuell betrachtete Station gewechselt werden.
|
||||
|
||||
Hier können auch **Sked-Erinnerungen / Wecker** für beide Skkedpartner aktiviert werden.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,75 +2,206 @@
|
||||
|
||||
> 🇬🇧 [English version](en-DX-Cluster-Server) | 🇩🇪 Du liest gerade die deutsche Version
|
||||
|
||||
Ab **Version 1.23** enthält KST4Contest einen integrierten DX-Cluster-Server. Dieser sendet Spots direkt an das Logprogramm, wenn eine Richtungs-Warnung ausgelöst wird.
|
||||
Seit Version 1.23 enthält KST4Contest einen lokalen DX-Cluster-Server. Er übergibt erkannte Richtungsgelegenheiten und die dazugehörige Frequenz an den DX-Cluster-Client eines Logprogramms.
|
||||
|
||||
*(Idee von OM0AAO, Viliam Petrik – danke!)*
|
||||
Die Idee dazu stammt von OM0AAO, Viliam Petrik. Vielen Dank!
|
||||
|
||||
---
|
||||
|
||||
## Wozu dient der integrierte DX-Cluster-Server?
|
||||
## Warum überhaupt ein eigener DX-Cluster-Server?
|
||||
|
||||
Wenn KST4Contest erkennt, dass eine Station aus der eigenen Richtung ein Sked anfragt und gleichzeitig eine QRG bekannt ist, wird **automatisch ein DX-Cluster-Spot generiert** und an den Cluster-Client des Logprogramms gesendet.
|
||||
Eine interessante Frequenz im Chat zu erkennen ist nur der erste Schritt. Im Contest muss diese Information dort ankommen, wo sie unmittelbar verwendet werden kann: im Logprogramm und dessen Bandmap.
|
||||
|
||||
Das Logprogramm zeigt den Spot in der Bandkarte an. Ein Klick auf den Spot stellt Frequenz und Mode des Transceivers direkt ein – ohne manuelles Eintippen.
|
||||
KST4Contest verbindet deshalb zwei bereits vorhandene Informationen:
|
||||
|
||||
1. Aus einer gerichteten Chat-Nachricht lässt sich abschätzen, in welche Richtung die sendende Station ihre Antenne wahrscheinlich ausgerichtet hat.
|
||||
2. Aus derselben oder einer vorherigen Nachricht kann eine Frequenz der Station bekannt sein.
|
||||
|
||||
Treffen beide Informationen zusammen, erzeugt KST4Contest einen lokalen DX-Cluster-Spot. Der Logger kann diesen Spot in seiner Bandmap anzeigen und – abhängig von seiner eigenen Konfiguration – den Transceiver nach einem Klick auf die entsprechende Frequenz einstellen.
|
||||
|
||||
Im Klartext: Die Information muss nicht erst im Chat gefunden, gelesen, gemerkt und anschließend erneut in den Logger eingetragen werden. Genau diese kleinen Unterbrechungen kosten im Contest überraschend viel Aufmerksamkeit.
|
||||
|
||||
---
|
||||
|
||||
## Einrichtung
|
||||
## Wie wird eine Richtungsgelegenheit hergeleitet?
|
||||
|
||||
### In KST4Contest
|
||||
Angenommen, Station A schreibt eine gerichtete Nachricht an Station B. KST4Contest verwendet die Richtung von A zu B als Näherung für die aktuelle Antennenrichtung von Station A. Anschließend wird geprüft, ob die eigene Station aus Sicht von A innerhalb des angenommenen Antennenkorridors liegt.
|
||||
|
||||
In den Preferences → **DX-Cluster-Server-Einstellungen**:
|
||||
Dafür werden zwei Richtungen verglichen:
|
||||
|
||||
1. **Port** des internen Servers eintragen (z. B. 7300 oder 8000 – muss mit dem Logprogramm übereinstimmen).
|
||||
2. **Spotter-Rufzeichen** eintragen – **unbedingt ein anderes Rufzeichen als das Contest-Rufzeichen verwenden!**
|
||||
- Grund: Logprogramme filtern Spots, die vom eigenen Rufzeichen stammen, als „gearbeitet" heraus. Wenn der Spotter dasselbe Rufzeichen hat, werden die Spots nicht angezeigt.
|
||||
3. **Angenommene MHz** eintragen: Bei Frequenzangaben wie „.205" im Chat muss KST4Contest entscheiden, ob 144.205, 432.205 oder 1296.205 gemeint ist. Bei Einband-Contests einfach die entsprechende Bandmitte eintragen. Vollständige Frequenzangaben wie „144.205" oder „1296.338" im Chat werden immer korrekt erkannt.
|
||||
- die Richtung von Station A zu Station B,
|
||||
- die Richtung von Station A zur eigenen Station.
|
||||
|
||||
### In UCXLog
|
||||
Der in den Station Settings konfigurierte Antennen-Öffnungswinkel ist der vollständige Winkel. Für die Prüfung wird jeweils die Hälfte links und rechts der Richtung A → B angesetzt. Bei `70°` sind das somit `±35°`.
|
||||
|
||||
- Verbindung zu einem DX-Cluster-Server konfigurieren:
|
||||
- Host: `127.0.0.1` (oder IP des KST4Contest-Computers)
|
||||
- Port: Wie in KST4Contest konfiguriert
|
||||
- Passwort: kann leer bleiben
|
||||
- Über die Schaltfläche **„Send a test message to your log"** kann die Verbindung getestet werden.
|
||||
Da ON4KST keine Antennendaten der fremden Station überträgt, verwendet KST4Contest den Öffnungswinkel der eigenen Antenne zugleich als Näherung für Station A. Das ist keine Messung der tatsächlichen Antennenrichtung, sondern eine bewusst einfache geometrische Annahme.
|
||||
|
||||
### In N1MM+
|
||||
Ein DX-Cluster-Spot wird nur erzeugt, wenn alle folgenden Bedingungen erfüllt sind:
|
||||
|
||||
Ähnliche Einstellungen:
|
||||
- Host: `127.0.0.1` (oder IP des KST4Contest-Computers)
|
||||
- Port: Wie in KST4Contest konfiguriert
|
||||
1. Eine gerichtete Nachricht wurde zwischen zwei anderen Stationen erkannt.
|
||||
2. Für Absender und Empfänger sind gültige Locator bekannt.
|
||||
3. Der Absender liegt innerhalb des konfigurierten maximalen QRB zur eigenen Station.
|
||||
4. Die eigene Station liegt aus Sicht des Absenders innerhalb des angenommenen Antennenkorridors.
|
||||
5. Für den Absender ist eine verwertbare Frequenz bekannt oder in der aktuellen Nachricht erkannt worden.
|
||||
6. Der lokale DX-Cluster-Server ist aktiviert.
|
||||
|
||||
Treffen die Bedingungen zu, wird der Spot unmittelbar beim Verarbeiten der Nachricht erzeugt. Die parallel angezeigte grüne Richtungsmarkierung bleibt dagegen fünf Minuten sichtbar und kann durch spätere Nachrichten verlängert oder vorzeitig entfernt werden.
|
||||
|
||||
Das Verfahren berücksichtigt weder Gelände noch aktuelle Ausbreitungsbedingungen und beweist keine tatsächliche Antennenstellung. Es erkennt eine plausible Gelegenheit. Die ausführliche Herleitung und ein Zahlenbeispiel stehen unter [Richtungsgelegenheiten aus gerichteten Nachrichten](de-Funktionen#richtungsgelegenheiten-aus-gerichteten-nachrichten).
|
||||
|
||||
---
|
||||
|
||||
## Funktionsweise
|
||||
## Welche Frequenz wird verwendet?
|
||||
|
||||
Ein Spot wird generiert, wenn **beide** Bedingungen erfüllt sind:
|
||||
Vollständige Frequenzangaben können direkt verarbeitet werden, beispielsweise:
|
||||
|
||||
1. Eine **Richtungs-Warnung** wurde ausgelöst (Station macht ein Sked in die eigene Richtung).
|
||||
2. **QRG der Station ist bekannt** (aus dem Chat ausgelesen oder manuell eingetragen).
|
||||
```text
|
||||
144.205
|
||||
432.088
|
||||
1296.338
|
||||
```
|
||||
|
||||
Der generierte Spot enthält:
|
||||
- Rufzeichen der Station
|
||||
- Frequenz
|
||||
- Spotterzeit
|
||||
Im Chat werden jedoch häufig nur relative Angaben geschrieben:
|
||||
|
||||
Das Logprogramm kann den Spot dann in der Bandkarte anzeigen und den TRX per Mausklick auf die Frequenz abstimmen.
|
||||
```text
|
||||
205
|
||||
.205
|
||||
338
|
||||
```
|
||||
|
||||
In diesem Fall fehlt das Band. KST4Contest ergänzt deshalb das unter **Fallback band in MHz** konfigurierte Bandpräfix.
|
||||
|
||||
Beispiel:
|
||||
|
||||
```text
|
||||
Fallback-Band: 144
|
||||
Chat-Frequenz: .205
|
||||
DX-Cluster-Frequenz: 144205.0 kHz
|
||||
```
|
||||
|
||||
Bei einem Einband-Contest ist diese Annahme normalerweise eindeutig. Bei gleichzeitigem Betrieb mehrerer Bänder kann sie falsch sein. Das Fallback-Band sollte deshalb zu dem Band passen, das in der betreffenden Chat-Kategorie überwiegend verwendet wird.
|
||||
|
||||
Die weitergehende Frage, ob sich das Band künftig aus der konkreten Arbeitsfrequenz oder dem Stationskontext ableiten lässt, wird nach Abschluss des Manuals gesondert geprüft.
|
||||
|
||||
---
|
||||
|
||||
## Multi-Computer-Setup
|
||||
## Einrichtung in KST4Contest
|
||||
|
||||
Wenn KST4Contest auf einem separaten Computer läuft (nicht auf dem Log-Computer):
|
||||
Öffne den Reiter **Notification** in den Preferences.
|
||||
|
||||
- Host im Logprogramm: IP des KST4Contest-Computers (nicht `127.0.0.1`)
|
||||
- Entspricht der Konfiguration der QSO-UDP-Broadcast-Pakete (siehe [Log-Synchronisation](de-Log-Synchronisation))
|
||||

|
||||
|
||||
Konfiguriere anschließend:
|
||||
|
||||
1. **Enable the local DX Cluster server …** aktivieren.
|
||||
2. Einen freien **TCP port** eintragen. Standard ist `8000`.
|
||||
3. Das passende **Fallback band in MHz** eintragen.
|
||||
4. Ein **Spotter callsign** festlegen.
|
||||
|
||||
Für das Spotter-Rufzeichen sollte nach Möglichkeit ein anderes Rufzeichen als das Contest-Rufzeichen verwendet werden. Einige Logger filtern Spots, die scheinbar von der eigenen Station stammen. Das Ergebnis wäre technisch korrekt erzeugt, aber in der Bandmap trotzdem unsichtbar – eine besonders unproduktive Art von Erfolg.
|
||||
|
||||
Änderungen am Aktivierungsstatus und am TCP-Port werden während einer laufenden Chat-Verbindung sofort angewendet. Bei einem Portwechsel werden vorhandene DX-Cluster-Verbindungen getrennt und müssen vom Logger neu aufgebaut werden.
|
||||
|
||||
Die Einstellungen werden erst mit **Save Settings** dauerhaft in der `preferences.xml` gespeichert.
|
||||
|
||||
---
|
||||
|
||||
## Einrichtung im Logprogramm
|
||||
|
||||
Das Logprogramm wird als DX-Cluster-Client mit KST4Contest verbunden.
|
||||
|
||||
| Einstellung | KST4Contest und Logger auf demselben Computer | Logger auf einem anderen Computer |
|
||||
|---|---|---|
|
||||
| Host | `127.0.0.1` | IP-Adresse des KST4Contest-Computers |
|
||||
| Port | In KST4Contest konfigurierter TCP-Port | In KST4Contest konfigurierter TCP-Port |
|
||||
| Login | Beliebiges Rufzeichen, falls der Logger eines verlangt | Beliebiges Rufzeichen, falls der Logger eines verlangt |
|
||||
| Passwort | Nicht erforderlich | Nicht erforderlich |
|
||||
|
||||
KST4Contest wertet den vom Logger gesendeten Login nicht zur Authentifizierung aus. Die Verbindung ist für ein lokales oder vertrauenswürdiges Stationsnetz vorgesehen.
|
||||
|
||||
Wenn der Logger auf einem anderen Computer läuft, muss dessen Verbindung durch die lokale Firewall des KST4Contest-Computers zugelassen werden. Der Port sollte nicht ohne weiteren Schutz aus dem Internet erreichbar sein.
|
||||
|
||||
Mehrere DX-Cluster-Clients können gleichzeitig verbunden werden. Ein erzeugter Spot wird an alle aktuell verbundenen Clients gesendet.
|
||||
|
||||
---
|
||||
|
||||
## Verbindung testen
|
||||
|
||||
Die Schaltfläche **Send test spot** erzeugt einen neutralen Testeintrag:
|
||||
|
||||
```text
|
||||
Spotted callsign: DO5AMF
|
||||
Comment: KST4CONTEST TEST
|
||||
Frequency: .300 des konfigurierten Fallback-Bandes
|
||||
```
|
||||
|
||||
Bei einem Fallback-Band von `144` erscheint der Spot daher auf ungefähr `144.300 MHz`.
|
||||
|
||||
Vor dem Test müssen drei Bedingungen erfüllt sein:
|
||||
|
||||
1. KST4Contest ist mit dem ON4KST-Chat verbunden.
|
||||
2. Der lokale DX-Cluster-Server ist aktiviert.
|
||||
3. Der DX-Cluster-Client des Logprogramms ist mit KST4Contest verbunden.
|
||||
|
||||
Fehlt die Client-Verbindung, zeigt KST4Contest eine entsprechende Meldung an. Ein erfolgreich ausgeführter Test bedeutet damit tatsächlich, dass mindestens ein Client den Spot erhalten hat.
|
||||
|
||||
---
|
||||
|
||||
## Inhalt eines erzeugten Spots
|
||||
|
||||
Ein Spot enthält:
|
||||
|
||||
- das konfigurierte Spotter-Rufzeichen,
|
||||
- die normalisierte Frequenz,
|
||||
- das Rufzeichen der erkannten Station,
|
||||
- den Locator,
|
||||
- Flugzeug-Scatter-Informationen, falls vorhanden,
|
||||
- die aktuelle UTC-Zeit.
|
||||
|
||||
Wenn für die Station aktuelle Aircraft-Scatter-Informationen vorliegen, kann KST4Contest diese als zusätzliche AP-Information in den Kommentar des Spots aufnehmen.
|
||||
|
||||
---
|
||||
|
||||
## Wenn kein Spot erscheint
|
||||
|
||||
### Der Testspot kommt nicht im Logger an
|
||||
|
||||
Prüfe:
|
||||
|
||||
- Ist KST4Contest mit dem Chat verbunden?
|
||||
- Ist der lokale DX-Cluster-Server aktiviert?
|
||||
- Verwendet der Logger denselben TCP-Port?
|
||||
- Verwendet der Logger bei lokalem Betrieb `127.0.0.1`?
|
||||
- Blockiert eine Firewall die Verbindung?
|
||||
- Ist im Logger das DX-Cluster-Fenster beziehungsweise die Bandmap aktiviert?
|
||||
|
||||
### Testspot funktioniert, aber reale Spots fehlen
|
||||
|
||||
Dann funktioniert die Verbindung grundsätzlich. Für die betreffende Chat-Situation war wahrscheinlich mindestens eine fachliche Bedingung nicht erfüllt:
|
||||
|
||||
- kein gerichteter Nachrichtenaustausch,
|
||||
- fehlender Locator,
|
||||
- Station außerhalb des maximalen QRB,
|
||||
- Richtung außerhalb des konfigurierten Öffnungswinkels,
|
||||
- keine erkannte Frequenz.
|
||||
|
||||
KST4Contest sendet absichtlich nicht jede gefundene Frequenz an den Logger. Andernfalls würde aus einer Arbeitserleichterung sehr schnell eine lokale Spot-Schleuder.
|
||||
|
||||
### Der Spot erscheint auf dem falschen Band
|
||||
|
||||
Prüfe zuerst das konfigurierte **Fallback band in MHz**. Es wird nur benötigt, wenn im Chat keine vollständige Frequenz angegeben wurde.
|
||||
|
||||
### Der Spot wird vom Logger ausgeblendet
|
||||
|
||||
Verwende ein Spotter-Rufzeichen, das nicht mit dem eigenen Contest-Rufzeichen identisch ist. Abhängig vom Logger können eigene Spots gefiltert oder besonders behandelt werden.
|
||||
|
||||
---
|
||||
|
||||
## Getestete Logprogramme
|
||||
|
||||
- **UCXLog** ✓
|
||||
- **N1MM+** ✓
|
||||
Die Schnittstelle wurde mit folgenden Logprogrammen verwendet:
|
||||
|
||||
Weitere Testergebnisse sind willkommen – bitte per E-Mail an DO5AMF melden.
|
||||
- UCXLog
|
||||
- N1MM+
|
||||
|
||||
Weitere Logger können funktionieren, wenn sie eine normale TCP-Verbindung zu einem DX-Cluster-Server unterstützen.
|
||||
|
||||
@@ -2,39 +2,71 @@
|
||||
|
||||
> 🇬🇧 [English version](en-Features) | 🇩🇪 Du liest gerade die deutsche Version
|
||||
|
||||
Übersicht aller Hauptfunktionen von KST4Contest.
|
||||
Dieses Kapitel beschreibt die wichtigsten Funktionen von KST4Contest, ihre Herleitung und die Grenzen der daraus gewonnenen Informationen.
|
||||
|
||||
---
|
||||
## Richtungsgelegenheiten aus gerichteten Nachrichten
|
||||
|
||||
## Sked-Richtungs-Hervorhebung
|
||||
Im ON4KST-Chat ist sichtbar, welche Station eine Nachricht an welche andere Station richtet. Eine tatsächliche Antennenrichtung wird dabei nicht übertragen. Für den Contestbetrieb lässt sich aus einer solchen Nachricht trotzdem eine brauchbare Annahme ableiten: Wer einen Sked anfragt, beantwortet oder vorbereitet, richtet seine Antenne normalerweise zumindest ungefähr auf die angesprochene Station.
|
||||
|
||||
Eine der Kernfunktionen: Wenn eine Station ein Sked in die **eigene Richtung** sendet, wird sie in der Benutzerliste **grün und fett** hervorgehoben.
|
||||
KST4Contest wertet deshalb gerichtete Nachrichten zwischen zwei anderen Stationen aus. Die Nachricht muss nicht ausdrücklich als Sked gekennzeichnet sein. Entscheidend sind der Absender, der Empfänger und deren Locator.
|
||||
|
||||
### Wie funktioniert das?
|
||||
### Wie wird die Richtung hergeleitet?
|
||||
|
||||
Die Berechnung basiert auf folgender Logik:
|
||||
Angenommen, Station A schreibt eine gerichtete Nachricht an Station B:
|
||||
|
||||
- Wenn Station A eine Sked-Anfrage an Station B sendet, wird angenommen, dass A ihre Antenne auf B ausrichtet.
|
||||
- Wenn die daraus resultierende Richtung von A zur eigenen Station innerhalb des halben Öffnungswinkels der eigenen Antenne liegt, wird A hervorgehoben.
|
||||
1. KST4Contest berechnet die Richtung von Station A zu Station B.
|
||||
2. Diese Richtung wird als wahrscheinliche Antennenrichtung von Station A verwendet.
|
||||
3. Anschließend wird die Richtung von Station A zur eigenen Station berechnet.
|
||||
4. Die Winkeldifferenz wird mit der Hälfte des konfigurierten Antennen-Öffnungswinkels verglichen.
|
||||
5. Zusätzlich muss Station A innerhalb des konfigurierten maximalen QRB liegen.
|
||||
|
||||
**Beispiel** (Öffnungswinkel 69°, Halbwinkel 34,5°):
|
||||
Ein eingetragener Öffnungswinkel von `70°` ergibt damit einen angenommenen Korridor von jeweils `35°` links und rechts der Richtung von Station A zu Station B.
|
||||
|
||||
| Situation | Ergebnis für DO5AMF in JN49 |
|
||||
| Beispiel | Ergebnis |
|
||||
|---|---|
|
||||
| Sked von F5FEN → DM5M | ✅ Hervorhebung (F5FEN zeigt Richtung DM5M, das liegt nahe JN49) |
|
||||
| Sked von DM5M → F5FEN | ✅ Hervorhebung (DM5M antwortet in Richtung F5FEN) |
|
||||
| F1DBN ist unbeteiligt | ❌ Keine Hervorhebung |
|
||||
| DO5AMF/P (anderer Standort) | ❌ Keine Hervorhebung für Sked-Antwort |
|
||||
| Richtung A → B: `120°`, Richtung A → eigene Station: `145°` | Winkeldifferenz `25°`: Richtungsgelegenheit erkannt |
|
||||
| Richtung A → B: `120°`, Richtung A → eigene Station: `165°` | Winkeldifferenz `45°`: außerhalb des angenommenen Korridors |
|
||||
| Locator von A oder B fehlt | Keine Richtungsberechnung möglich |
|
||||
| A liegt außerhalb des maximalen QRB | Keine Richtungsgelegenheit |
|
||||
|
||||
Die Berechnung berücksichtigt keine topografischen Wegberechnungen – das ist eine bewusste Vereinfachung. Möglicherweise wird das in einer späteren Version ergänzt.
|
||||
### Was wird in der Benutzerliste angezeigt?
|
||||
|
||||
> Konfiguration: [Konfiguration – Antennen-Öffnungswinkel](Konfiguration#antennen-öffnungswinkel-antenna-beamwidth)
|
||||
Wird eine Richtungsgelegenheit erkannt, erscheint das Rufzeichen des Absenders in der Benutzerliste grün und fett. Im Evening-Modus wird dafür ein helleres Grün verwendet. Der Empfänger der Nachricht wird nicht allein deshalb markiert; eine Antwort in Gegenrichtung wird als eigene Nachricht und damit als neuer Fall berechnet.
|
||||
|
||||

|
||||
|
||||
Im Bild sendete DF0GEB eine gerichtete Nachricht an DN9APW und bekam eine Antwort. KST4Contest erkannte die Richtungsgelegenheit und markierte DN9APW in der Benutzerliste.
|
||||
Zur Verdeutlichung ist die MAP eingeblendet. Ich stehe als Empfänger zwischen beiden Stationen und bekomme deswegen die Warnung.
|
||||
|
||||
Die Markierung bleibt fünf Minuten ab der letzten passenden Nachricht sichtbar. Eine weitere passende Nachricht derselben Station beginnt diesen Zeitraum erneut. Sendet die Station vorher eine gerichtete Nachricht, deren Richtung die Bedingungen nicht erfüllt, wird die Markierung unmittelbar entfernt.
|
||||
|
||||
Ist die einfache Soundausgabe aktiviert, gibt KST4Contest beim erstmaligen Erkennen der Richtungsgelegenheit zusätzlich einen kurzen Hinweis aus. Solange die Station bereits markiert ist, wird derselbe Hinweis nicht mit jeder weiteren passenden Nachricht wiederholt.
|
||||
|
||||
### Was bedeutet die Markierung – und was nicht?
|
||||
|
||||
Die Berechnung ist eine geometrische Herleitung. Sie beweist nicht, dass Station A ihre Antenne tatsächlich auf Station B ausgerichtet hat. Ebenso wenig berücksichtigt sie Gelände, aktuelle Ausbreitungsbedingungen, die reale Antennencharakteristik der fremden Station oder deren Rotatorposition.
|
||||
|
||||
ON4KST liefert keinen individuellen Öffnungswinkel für die fremde Station. KST4Contest verwendet deshalb den für die eigene Antenne konfigurierten Wert auch als Näherung für Station A. Ein zu großer Wert erzeugt entsprechend mehr mögliche Richtungsgelegenheiten, ein zu kleiner Wert kann brauchbare Situationen übersehen.
|
||||
|
||||
Im Klartext: Die grüne Markierung ist ein begründeter Hinweis auf eine mögliche Gelegenheit. Sie ist weder eine Ausbreitungsvorhersage noch eine Garantie für ein QSO.
|
||||
|
||||
Konfiguration:
|
||||
|
||||
- [Antennen-Öffnungswinkel](de-Konfiguration#antennen-öffnungswinkel-antenna-beamwidth)
|
||||
- [Standard-Maximum-QRB](de-Konfiguration#standard-maximum-qrb)
|
||||
|
||||
---
|
||||
|
||||
## Sked-Richtungs-Spots (Integrierter DX-Cluster)
|
||||
## Weitergabe als DX-Cluster-Spot
|
||||
|
||||
Ab **v1.23**: Richtungs-Warnungen werden als DX-Cluster-Spots an das Logprogramm weitergeleitet, wenn eine QRG bekannt ist. Details: [DX-Cluster-Server](de-DX-Cluster-Server).
|
||||
Seit Version 1.23 kann KST4Contest eine erkannte Richtungsgelegenheit an den DX-Cluster-Client eines Logprogramms weitergeben. Dafür muss der lokale DX-Cluster-Server aktiviert und für den Absender eine verwertbare Frequenz bekannt sein.
|
||||
|
||||
Die Frequenz kann bereits aus einer früheren Nachricht stammen oder erstmals in der aktuell auslösenden Nachricht stehen. In beiden Fällen steht sie der Spot-Prüfung zur Verfügung. KST4Contest überträgt damit nicht jede im Chat gefundene QRG, sondern nur Frequenzen, die mit einer geometrisch passenden gerichteten Nachricht zusammenfallen.
|
||||
|
||||
Die Fünf-Minuten-Markierung und der DX-Cluster-Spot beruhen auf derselben Richtungsberechnung, haben aber einen unterschiedlichen Lebenszyklus: Die Markierung bleibt vorübergehend in der Benutzerliste sichtbar. Der Spot wird unmittelbar beim Verarbeiten der passenden Nachricht erzeugt.
|
||||
|
||||
Einrichtung, Frequenzbehandlung und Grenzen: [Integrierter DX-Cluster-Server](de-DX-Cluster-Server).
|
||||
|
||||
---
|
||||
|
||||
@@ -109,6 +141,22 @@ KST4Contest erkennt solche Nachrichten, die das eigene Rufzeichen enthalten, und
|
||||
|
||||
---
|
||||
|
||||
## Automatische Antworten auf Privatnachrichten (ab v1.25)
|
||||
|
||||
Nicht jede im ON4KST-Chat eingeloggte Station nimmt am gerade laufenden Contest teil. Trotzdem werden Sked-Anfragen während größerer Contests teilweise unkoordiniert und in großer Zahl an erreichbare Rufzeichen verteilt. Ohne automatische Antwort müssten diese Stationen immer wieder von Hand erklären, dass sie nicht mitfunken oder keine Skeds fahren.
|
||||
|
||||
KST4Contest kann darauf mit einem vorher festgelegten Text reagieren. Die eingehende Privatnachricht bleibt dabei sichtbar; sie wird weder blockiert noch verworfen. Davon getrennt lässt sich eine QRG-Antwort aktivieren, die auf typische Fragen wie `qrg?`, `freq?` oder `pse qrg` reagiert.
|
||||
|
||||
Bei zwei gleichzeitig geöffneten Chat-Kategorien bleibt der Zusammenhang erhalten: Die Antwort wird in der Kategorie der eingegangenen Nachricht gesendet. Eine QRG-Anfrage erhält außerdem nur die QRG dieser Kategorie und nicht eine Liste aller konfigurierten Frequenzen.
|
||||
|
||||
Automatische Antworten benötigen Grenzen. KST4Contest versieht sie daher mit `[KST4C Automsg]`, ignoriert entsprechend gekennzeichnete Nachrichten bei der allgemeinen und QRG-bezogenen Antwort und begrenzt weitere Antworten an dieselbe Station in derselben Kategorie auf eine Nachricht innerhalb von zwei Minuten. Der Schutz gilt gemeinsam für beide Antwortarten.
|
||||
|
||||
Im Klartext: Die Funktion verhindert keine Massenanfragen. Sie verhindert aber, dass der Empfänger jede davon einzeln mit derselben Absage beantworten muss. Sie soll keine Unterhaltung simulieren und erst recht keine endlose Diskussion mit einem zweiten automatischen Client beginnen.
|
||||
|
||||
Konfiguration, erkannte QRG-Anfragen und genaue Kategorienzuordnung: [Konfiguration – Messagehandling Settings](de-Konfiguration#messagehandling-settings-ab-v125).
|
||||
|
||||
---
|
||||
|
||||
## Multi-Channel-Login (ab v1.26)
|
||||
|
||||
Gleichzeitiger Login in **zwei Chat-Kategorien** (z. B. 144 MHz und 432 MHz). Beide Chats werden parallel überwacht.
|
||||
@@ -149,9 +197,11 @@ Aktivierung: FurtherInfo-Panel der entsprechenden Station.
|
||||
|
||||
---
|
||||
|
||||
## QSO-Sniffer (ab v1.31)
|
||||
## QSO-Monitoring (ab v1.31)
|
||||
|
||||
Der QSO-Sniffer überwacht den Chat auf Nachrichten von einer konfigurierbaren Rufzeichen-Liste und leitet diese automatisch in das **PM-Fenster** weiter. So gehen keine relevanten Nachrichten im allgemeinen Chat-Rauschen unter.
|
||||
Für ausgewählte Rufzeichen kann KST4Contest gerichtete Nachrichten zusätzlich in der PM-Tabelle anzeigen. Dabei werden sowohl Nachrichten berücksichtigt, die das überwachte Rufzeichen sendet, als auch Nachrichten, die an dieses Rufzeichen gerichtet sind.
|
||||
|
||||
Die Nachricht bleibt gleichzeitig in ihrer ursprünglichen Tabelle erhalten und wird im PM-Fenster mit Absender und Empfänger als überwachte Kommunikation gekennzeichnet.
|
||||
|
||||
Konfiguration: [Konfiguration – Sniffer-Einstellungen](de-Konfiguration#sniffer-einstellungen-ab-v131)
|
||||
|
||||
@@ -220,8 +270,9 @@ So kann der Contest-Operator auf einem Blick sehen, welche Stationen wann und ü
|
||||
|
||||
## Intervall-Beacon
|
||||
|
||||
Automatische CQ-Meldungen im öffentlichen Kanal in konfigurierbarem Intervall. Empfohlene Verwendung mit der Variable `MYQRG` für aktuelle Frequenzangabe. Details: [Konfiguration – Beacon Settings](Konfiguration#beacon-settings-automatischer-beacon).
|
||||
KST4Contest kann wiederkehrende CQ-Nachrichten in den öffentlichen Chat senden. Beide Chat-Kategorien verwenden ein gemeinsames Intervall, besitzen aber jeweils einen eigenen Aktivierungsschalter und Nachrichtentext. Globale Variablen wie `MYQRG`, `SECONDQRG` oder `MYLOCATOR` werden unmittelbar vor jeder Aussendung aktualisiert.
|
||||
|
||||
Der Beacon ist für längeres CQ-Rufen auf einer festen Frequenz gedacht. Beim Absuchen oder häufigen Wechseln der QRG sollte er ausgeschaltet werden, damit keine inzwischen falsche Frequenz verbreitet wird. Details: [Konfiguration – Beacon Settings](de-Konfiguration#beacon-settings-automatischer-beacon).
|
||||
---
|
||||
|
||||
## Simplelogfile
|
||||
|
||||
@@ -1,10 +1,64 @@
|
||||
# KST4Contest – Wiki
|
||||
# KST4Contest – Handbuch
|
||||
|
||||
> 🇬🇧 [English version](en-Home) | 🇩🇪 Du liest gerade die deutsche Version
|
||||
> [English version](en-Home) | Du liest gerade die deutsche Version
|
||||
|
||||
**KST4Contest** (auch bekannt als *PraktiKST*) ist ein Java-basierter Chat-Client für den [ON4KST-Chat](http://www.on4kst.info/chat/), der speziell für den Contest-Betrieb auf den VHF/UHF/SHF-Bändern (144 MHz und aufwärts) entwickelt wurde.
|
||||
KST4Contest ist ein Desktop-Client für den [ON4KST-Chat](https://www.on4kst.org/chat/login.php), der für den Contest-Betrieb auf den VHF-, UHF- und SHF-Bändern entwickelt wurde. Er verbindet Chat, Stationsauswahl, Sked-Planung, Aircraft-Scatter-Daten und die Anbindung an weitere Programme in einer gemeinsamen Arbeitsoberfläche.
|
||||
|
||||
Entwickelt von **DO5AMF (Marc Fröhlich)**, Operator bei DM5M.
|
||||
Entwickelt wird KST4Contest von **DO5AMF (Marc Fröhlich)**, Operator bei DM5M und (seit Mai 2025) **DN9APW (Philipp Wagner)**. Der Quellcode ist öffentlich auf [GitHub](https://github.com/praktimarc/kst4contest) verfügbar.
|
||||
|
||||
---
|
||||
|
||||
## Wozu braucht man einen eigenen ON4KST-Client?
|
||||
|
||||
Der ON4KST-Chat liefert während eines Contests eine große Menge an Informationen: aktive Stationen, Locator, Frequenzen, Sked-Anfragen und Hinweise auf aktuelle Aktivität. Das eigentliche Problem besteht nicht darin, diese Daten zu sehen. Man muss daraus rechtzeitig die nächste sinnvolle Verbindung ableiten.
|
||||
|
||||
KST4Contest wertet die verfügbaren Informationen aus, ordnet sie und stellt sie in einem contestgerechten Arbeitsablauf dar. Dabei werden unter anderem Antennenrichtung, Entfernung, bekannte Bänder und Frequenzen, bereits gearbeitete Stationen, Chat-Aktivität und Aircraft-Scatter-Zeiten berücksichtigt.
|
||||
|
||||
Das Programm entscheidet jedoch nicht, welches QSO tatsächlich möglich ist. Der Prioritätsscore, die AP-Timeline und die verschiedenen Hervorhebungen sind Entscheidungshilfen. Die endgültige Beurteilung bleibt beim Operator – schon deshalb, weil auch ein recht überzeugender Computerbildschirm noch keine Funkverbindung herstellt.
|
||||
|
||||
---
|
||||
|
||||
## Was KST4Contest im Contest unterstützt
|
||||
|
||||
- **Chat beobachten und einordnen:** KST4Contest kann zwei ON4KST-Chat-Kategorien gleichzeitig darstellen. Nachrichten, Frequenzangaben und bekannte Bandaktivitäten werden den jeweiligen Stationen zugeordnet.
|
||||
|
||||
- **Relevante Stationen herausfiltern:** Richtungs-, Entfernungs-, Worked- und NOT-QRV-Filter reduzieren die Benutzerliste auf die Stationen, die für den aktuellen Betriebszustand tatsächlich interessant sind.
|
||||
|
||||
- **Kandidaten priorisieren:** Das Score-System bewertet aktive Chatmember anhand mehrerer bekannter Kriterien. Die derzeit relevantesten Kandidaten erscheinen zusätzlich in einer eigenen Prioritätsliste.
|
||||
|
||||
- **Skeds vorbereiten und im Blick behalten:** Sked-Erinnerungen, automatische Vorwarnungen und die AP-Timeline helfen dabei, vereinbarte Verbindungen nicht zwischen Chat, Log und laufendem CQ-Betrieb zu verlieren.
|
||||
|
||||
- **Aircraft Scatter einbeziehen:** Über die AirScout-Schnittstelle werden geeignete Flugzeuge und erwartete Reflexionszeiten in die Stationsbewertung und Zeitplanung übernommen.
|
||||
|
||||
- **Log und Funkstation anbinden:** KST4Contest synchronisiert gearbeitete Stationen und Frequenzinformationen mit unterstützten Logprogrammen. Zusätzlich stehen Schnittstellen zu Win-Test, PSTRotator und ein integrierter DX-Cluster-Server zur Verfügung.
|
||||
|
||||
- **Stationen und Funkwege darstellen:** Die Stationskarte zeigt aktive Chatmember, Locator-Felder, Antennenrichtungen und den Weg zur ausgewählten Station. Für ausgewählte Verbindungen kann außerdem ein Geländeprofil berechnet werden.
|
||||
|
||||
Die Funktionen greifen ineinander. Eine erkannte Frequenz kann beispielsweise einem aktiven Band zugeordnet werden, der Worked-Status stammt aus dem Log, Aircraft-Scatter-Daten ergänzen die zeitliche Bewertung und der daraus berechnete Score beeinflusst die Prioritätsliste. Fehlende oder veraltete Eingangsdaten können deshalb auch das Ergebnis beeinflussen.
|
||||
|
||||
---
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
Für die Anmeldung ist ein registrierter ON4KST-Account erforderlich. Registrierung und Login sind über die [offizielle ON4KST-Anmeldeseite](https://www.on4kst.org/chat/login.php) erreichbar.
|
||||
|
||||
Die offizielle Sprache im ON4KST-Chat ist Englisch. Das gilt auch für Nachrichten an Stationen aus dem eigenen Land. Übliche Amateurfunk-Abkürzungen wie `pse`, `agn`, `qrg`, `dir`, `rrr`, `tnx` oder `73` sind dabei normal und meistens erheblich schneller als ausformulierte Prosa.
|
||||
|
||||
Download, unterstützte Betriebssysteme und Installationswege sind im Kapitel [Installation](de-Installation) beschrieben.
|
||||
|
||||
---
|
||||
|
||||
## Versionsstand dieses Handbuchs
|
||||
|
||||
Dieses Handbuch beschreibt die stabile Version **v1.41.1**.
|
||||
|
||||
Funktionen oder Änderungen, die nur im aktuellen Entwicklungsstand enthalten sind, werden ausdrücklich als **Nightly / v1.42** gekennzeichnet. Fehlt eine solche Kennzeichnung, bezieht sich die Beschreibung auf die stabile Version.
|
||||
|
||||
- [Aktuelle stabile Version herunterladen](https://github.com/praktimarc/kst4contest/releases/latest)
|
||||
- [Nightly-Builds und automatisierte Builds](https://github.com/praktimarc/kst4contest/actions)
|
||||
- [Versionsgeschichte](de-Changelog)
|
||||
|
||||
Für einen Contest ist grundsätzlich die stabile Version zu empfehlen. Nightly-Builds enthalten neuere Korrekturen und Funktionen, können sich aber zwischen zwei Builds verändern. Sie sind sinnvoll, wenn eine bestimmte Änderung getestet werden soll – weniger sinnvoll ist der erste Versuch zehn Minuten vor Contestbeginn.
|
||||
|
||||
---
|
||||
|
||||
@@ -12,51 +66,52 @@ Entwickelt von **DO5AMF (Marc Fröhlich)**, Operator bei DM5M.
|
||||
|
||||
| Seite | Inhalt |
|
||||
|---|---|
|
||||
| [Installation](de-Installation) | Download, Java-Voraussetzungen, Update |
|
||||
| [Konfiguration](de-Konfiguration) | Alle Einstellungen im Detail |
|
||||
| [Log-Synchronisation](de-Log-Synchronisation) | UCXLog, N1MM+, QARTest, DXLog.net, WinTest |
|
||||
| [AirScout-Integration](de-AirScout-Integration) | Flugzeug-Scatter-Erkennung |
|
||||
| [DX-Cluster-Server](de-DX-Cluster-Server) | Integrierter DX-Cluster für das Log-Programm |
|
||||
| [Funktionen](de-Funktionen) | Alle Features im Überblick |
|
||||
| [Makros und Variablen](de-Makros-und-Variablen) | Text-Snippets, Shortcuts, Variablen |
|
||||
| [Benutzeroberfläche](de-Benutzeroberflaeche) | UI-Erklärung und Bedienung |
|
||||
| [Changelog](de-Changelog) | Versionsgeschichte |
|
||||
| [Installation](de-Installation) | ON4KST-Account, Download, Installation und Updates |
|
||||
| [Konfiguration](de-Konfiguration) | Login, Station, Bänder, Benutzeroberfläche und externe Schnittstellen |
|
||||
| [Log-Synchronisation](de-Log-Synchronisation) | Simplelogfile, UCXLog, N1MM+, QARTest, DXLog.net und Win-Test |
|
||||
| [AirScout-Integration](de-AirScout-Integration) | Verbindung zu AirScout und Auswertung von Aircraft-Scatter-Zeiten |
|
||||
| [DX-Cluster-Server](de-DX-Cluster-Server) | Übergabe erkannter Möglichkeiten an das Logprogramm |
|
||||
| [Funktionen](de-Funktionen) | Arbeitsweise, Herleitung und Grenzen der einzelnen Funktionen |
|
||||
| [Makros und Variablen](de-Makros-und-Variablen) | Wiederverwendbare Texte, Shortcuts und automatisch ersetzte Werte |
|
||||
| [Benutzeroberfläche](de-Benutzeroberflaeche) | Aufbau der Oberfläche und Bedienung im Contest |
|
||||
| [Changelog](de-Changelog) | Releases, Nightly-Änderungen und behobene Fehler |
|
||||
|
||||
---
|
||||
|
||||
## Was ist KST4Contest?
|
||||
## Kontakt und Support
|
||||
|
||||
Der ON4KST-Chat ist der De-facto-Standard für Skeds auf den 144-MHz-und-höher-Bändern. KST4Contest erweitert die Chat-Nutzung um contest-spezifische Funktionen:
|
||||
- **Download:** [Aktuelle stabile Version](https://github.com/praktimarc/kst4contest/releases/latest)
|
||||
- **Quellcode:** [praktimarc/kst4contest](https://github.com/praktimarc/kst4contest)
|
||||
- **Fehler und Funktionswünsche:** [GitHub Issues](https://github.com/praktimarc/kst4contest/issues)
|
||||
- **E-Mail:** praktimarc+kst4contest@gmail.com
|
||||
Bitte nur für Themen verwenden, die KST4Contest betreffen.
|
||||
|
||||
- **Worked-Markierung**: Bereits gearbeitete Stationen werden farblich markiert, direkt aus dem Logprogramm via UDP synchronisiert.
|
||||
- **Sked-Richtungs-Erkennung**: Wenn eine Station eine andere aus deiner Richtung anruft, wird sie grün und fett hervorgehoben.
|
||||
- **QRG-Erkennung**: KST4Contest liest Frequenzen automatisch aus dem Chat-Verkehr und zeigt sie in der Benutzerliste an.
|
||||
- **AirScout-Interface**: Anzeige reflektierbarer Flugzeuge direkt in der Benutzerliste.
|
||||
- **Integrierter DX-Cluster-Server**: Spots werden direkt an das Logprogramm gesendet.
|
||||
- **Dark Mode** (ab v1.26): Schont die Augen in der Nacht.
|
||||
- **Multi-Channel-Login** (ab v1.26): Gleichzeitig in zwei Chat-Kategorien einloggen.
|
||||
### Einen Fehler melden
|
||||
|
||||
---
|
||||
Ein Fehler lässt sich wesentlich schneller nachvollziehen, wenn die Meldung mindestens folgende Angaben enthält:
|
||||
|
||||
## Kontakt & Support
|
||||
1. verwendete KST4Contest-Version,
|
||||
2. Betriebssystem und Installationsart,
|
||||
3. genaue Schritte bis zum Fehler,
|
||||
4. erwartetes und tatsächlich beobachtetes Verhalten,
|
||||
5. gegebenenfalls einen Screenshot,
|
||||
6. die Fehler-Logdatei.
|
||||
|
||||
- **E-Mail**: praktimarc+kst4contest@gmail.com *(nur für kst4contest-Themen)*
|
||||
- **GitHub**: https://github.com/praktimarc/kst4contest
|
||||
- **Download**: https://github.com/praktimarc/kst4contest/releases/latest
|
||||
Die Fehler-Logdatei wird hier gespeichert:
|
||||
|
||||
### Fehler melden (Issue erstellen)
|
||||
|
||||
Beim Melden eines Fehlers bitte **immer die Logdatei anhängen**. KST4Contest schreibt automatisch eine Fehler-Logdatei (nur Fehlermeldungen, keine persönlichen Daten):
|
||||
|
||||
| Betriebssystem | Pfad zur Logdatei |
|
||||
| Betriebssystem | Pfad |
|
||||
|---|---|
|
||||
| Linux / macOS | `~/.praktiKST/kst4contest-errors.log` |
|
||||
| Windows | `C:\Users\<Benutzername>\.praktiKST\kst4contest-errors.log` |
|
||||
|
||||
Beim Erstellen eines Issues auf GitHub steht eine Vorlage bereit, die alle wichtigen Felder abfragt.
|
||||
Vor dem Hochladen sollte die Datei kurz geprüft werden. Ein Fehlerprotokoll enthält überwiegend technische Informationen, kann abhängig vom Fehler aber beispielsweise lokale Dateipfade oder weitere Kontextdaten enthalten.
|
||||
|
||||
In Datei- und Verzeichnisnamen wird teilweise noch der technische Name `praktiKST` verwendet. Gemeint ist dasselbe Programm.
|
||||
|
||||
---
|
||||
|
||||
## Danksagungen
|
||||
|
||||
Besonderer Dank gilt: Gianluca Costantino (IU3OAR), Alessandro Murador (IZ3VTH), Reczetár István (HA1FV), OM0AAO (Viliam Petrik, DX-Cluster-Idee), DC9DJ (Konrad Neitzel, Projektstruktur), DO5ALF (Andreas, Webmaster funkerportal.de), PE0WGA (Franz van Velzen, Tester) sowie allen weiteren Testern und Ideengebern.
|
||||
KST4Contest wurde durch Rückmeldungen aus dem praktischen Contest-Betrieb wesentlich verbessert.
|
||||
|
||||
Besonderer Dank gilt Gianluca Costantino (IU3OAR), Alessandro Murador (IZ3VTH), Reczetár István (HA1FV), Viliam Petrik (OM0AAO, Idee zum DX-Cluster), Konrad Neitzel (DC9DJ, Projektstruktur), Andreas (DO5ALF, Webmaster von funkerportal.de), Franz van Velzen (PE0WGA, Tester), DN9APW als neuem Entwickler im Team und Master über die CI/CD pipelines sowie allen weiteren Testern und Ideengebern.
|
||||
@@ -2,219 +2,422 @@
|
||||
|
||||
> 🇬🇧 [English version](en-Installation) | 🇩🇪 Du liest gerade die deutsche Version
|
||||
|
||||
## Voraussetzungen
|
||||
KST4Contest wird für Windows, Linux und macOS als fertiges Programmpaket bereitgestellt. Für die offiziellen Release-Pakete muss Java nicht separat installiert werden: Die benötigte Java-Laufzeitumgebung ist bereits enthalten.
|
||||
|
||||
Es wird eine Mindestauflösung von 1200px mal 720px empfohlen
|
||||
Benötigt werden damit im Wesentlichen ein unterstütztes Betriebssystem, eine Internetverbindung und ein ON4KST-Account. Das klingt zunächst überschaubar – und ist es normalerweise auch.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
### ON4KST-Account
|
||||
|
||||
Um den Chat zu nutzen, ist ein registrierter Account beim ON4KST-Chat-Dienst erforderlich:
|
||||
KST4Contest ist ein eigenständiger Client für den ON4KST-Chat, ersetzt aber nicht den dazugehörigen Benutzeraccount.
|
||||
|
||||
- Registrierung unter: http://www.on4kst.info/chat/register.php
|
||||
Falls noch kein Account vorhanden ist, kann er über die offizielle ON4KST-Seite angelegt werden:
|
||||
|
||||
### Verhaltensregeln im Chat
|
||||
- [ON4KST-Anmeldung und Registrierung](https://www.on4kst.org/chat/login.php)
|
||||
|
||||
Die offizielle Sprache im ON4KST-Chat ist **Englisch**. Auch bei Kommunikation mit Stationen aus dem eigenen Land bitte Englisch verwenden. Übliche HAM-Abkürzungen (agn, dir, pse, rrr, tnx, 73 …) sind gang und gäbe.
|
||||
ON4KST gibt Englisch als gemeinsame Sprache im Chat vor. Das gilt auch dann, wenn beide beteiligten Stationen dieselbe Muttersprache sprechen. Im Contestbetrieb werden häufig die üblichen Amateurfunk-Abkürzungen wie `pse`, `agn`, `qrg`, `dir`, `rrr`, `tnx` oder `73` verwendet.
|
||||
|
||||
### Persönliche Nachrichten
|
||||
Die eigentliche Bedienung des Chats und das Senden persönlicher Nachrichten werden im Kapitel zur Benutzeroberfläche beschrieben. Für die Installation ist zunächst nur wichtig, dass der Account funktioniert.
|
||||
|
||||
Um eine Privatnachricht an eine andere Station zu senden, immer folgendes Format verwenden:
|
||||
### Bildschirmgröße
|
||||
|
||||
```
|
||||
/CQ RUFZEICHEN Nachrichtentext
|
||||
```
|
||||
Eine nutzbare Bildschirmfläche von ungefähr **1200 × 720 Pixeln** oder mehr wird empfohlen.
|
||||
|
||||
Beispiel: `/CQ DL5ASG pse sked 144.205?`
|
||||
KST4Contest passt das Hauptfenster automatisch an die verfügbare Fläche des primären Bildschirms an. Das Programm kann deshalb auch auf kleineren Bildschirmen gestartet werden. Weniger Platz bleibt allerdings weniger Platz: In diesem Fall können nicht alle Tabellen, Filter und Zusatzinformationen gleichzeitig in sinnvoller Größe dargestellt werden.
|
||||
|
||||
Bei starkem Chat-Verkehr (5–6 Nachrichten pro Sekunde im Contest) gehen öffentliche Nachrichten, die an ein bestimmtes Rufzeichen gerichtet sind, leicht unter. KST4Contest fängt solche Nachrichten aber auch dann ab, wenn sie fälschlicherweise öffentlich gepostet werden (siehe [Funktionen – PM-Abfang](Funktionen#catching-personal-messages)).
|
||||
### Java
|
||||
|
||||
Für die fertigen Pakete aus den [GitHub Releases](https://github.com/praktimarc/kst4contest/releases/latest) ist keine separate Java-Installation erforderlich. Die benötigte Laufzeitumgebung wird zusammen mit KST4Contest ausgeliefert.
|
||||
|
||||
Eine Java-Entwicklungsumgebung wird nur benötigt, wenn KST4Contest selbst aus dem Quellcode gebaut werden soll. Bei den AUR-Paketen `kst4contest` und `kst4contest-git` werden Java 21 und Maven als Build-Abhängigkeiten durch den Paketmanager berücksichtigt.
|
||||
|
||||
---
|
||||
|
||||
## Stable, Beta oder Nightly?
|
||||
|
||||
KST4Contest wird in drei Entwicklungsständen bereitgestellt:
|
||||
|
||||
| Kanal | Geeignet für | Einordnung |
|
||||
|---|---|---|
|
||||
| **Stable** | Normaler Contestbetrieb | Veröffentlichte und für den regulären Einsatz vorgesehene Version |
|
||||
| **Beta** | Gezielte Tests vor einem Stable-Release | Vorabversion mit weitgehend festgelegtem Funktionsumfang |
|
||||
| **Nightly** | Frühe Tests neuer Funktionen | Aktueller Entwicklungsstand aus dem `main`-Branch |
|
||||
|
||||
Für den normalen Einsatz wird die **Stable-Version** empfohlen.
|
||||
|
||||
Beta- und Nightly-Versionen können Funktionen enthalten, die im Stable-Release noch nicht verfügbar sind. Sie können aber ebenso unfertige Bedienabläufe, geänderte Einstellungen oder neue Fehler enthalten. Das ist kein ungewöhnlicher Defekt im Veröffentlichungsverfahren, sondern der Zweck eines Entwicklungskanals.
|
||||
|
||||
Wenn in diesem Manual eine Funktion ausdrücklich mit **Nightly** gekennzeichnet ist, gehört sie noch nicht zwingend zum aktuellen Stable-Release.
|
||||
|
||||
---
|
||||
|
||||
## Download
|
||||
|
||||
### Windows
|
||||
Die aktuelle Stable-Version ist hier verfügbar:
|
||||
|
||||
Die aktuelle Version kann als ZIP-Datei heruntergeladen werden:
|
||||
- [Aktuelles KST4Contest-Release](https://github.com/praktimarc/kst4contest/releases/latest)
|
||||
- [Alle veröffentlichten Versionen](https://github.com/praktimarc/kst4contest/releases)
|
||||
|
||||
**https://github.com/praktimarc/kst4contest/releases/latest**
|
||||
Die Release-Seite enthält die Programmpakete sowie die deutschen und englischen PDF-Handbücher.
|
||||
|
||||
Der Dateiname hat das Format `praktiKST-v<Versionsnummer>-windows-x64.zip `.
|
||||
### Welches Paket wird benötigt?
|
||||
|
||||
### Linux
|
||||
|
||||
Mehrere Paketformate stehen auf der Releases-Seite zur Verfügung:
|
||||
|
||||
**https://github.com/praktimarc/kst4contest/releases/latest**
|
||||
|
||||
| Format | Dateiname | Geeignet für |
|
||||
| Betriebssystem | Paket | Typischer Dateiname |
|
||||
|---|---|---|
|
||||
| AppImage | `KST4Contest-v<Version>-linux-x86_64.AppImage` | Alle Distributionen |
|
||||
| Debian-Paket | `KST4Contest-v<Version>-debian-amd64.deb` | Debian, Ubuntu, Linux Mint, … |
|
||||
| RPM-Paket | `KST4Contest-v<Version>-fedora-x86_64.rpm` | Fedora, RHEL, openSUSE, … |
|
||||
| Arch-Paket | `KST4Contest-v<Version>-archlinux-x86_64.pkg.tar.zst` | Arch Linux, Manjaro, … |
|
||||
| Flatpak | `de.x08.KST4Contest.flatpakref` | Alle Distributionen mit Flatpak |
|
||||
|
||||
> **Empfehlung für Linux:** Die Flatpak-Installation ist der einfachste Weg, immer aktuell zu bleiben – `flatpak update` erledigt alle zukünftigen Updates automatisch. Das Repository ist GPG-signiert.
|
||||
|
||||
### macOS
|
||||
|
||||
> ⚠️ **Best-Effort-Support:** macOS-Builds werden als zusätzliche Option bereitgestellt, sind aber **nicht umfassend getestet**. Wir bauen und veröffentlichen macOS-Binaries mit jedem Release, können allerdings nicht alle Szenarien unter macOS testen. Bei Problemen freuen wir uns über eine Rückmeldung – wir versuchen unser Bestes, können aber nicht den gleichen Support-Umfang wie für Windows und Linux garantieren.
|
||||
|
||||
Die aktuelle Version kann als DMG-Disk-Image heruntergeladen werden (für Apple-Silicon- und Intel-Macs verfügbar):
|
||||
|
||||
**https://github.com/praktimarc/kst4contest/releases/latest**
|
||||
|
||||
Der Dateiname hat das Format `KST4Contest-v<Versionsnummer>-macos-<Architektur>.dmg`, wobei `<Architektur>` entweder `arm64` (Apple Silicon) oder `x86_64` (Intel) ist.
|
||||
| Windows x64 | ZIP-Paket | `praktiKST-v<Version>-windows-x64.zip` |
|
||||
| Linux x86_64 | AppImage | `KST4Contest-v<Version>-linux-x86_64.AppImage` |
|
||||
| Debian/Ubuntu amd64 | DEB-Paket | `KST4Contest-v<Version>-debian-amd64.deb` |
|
||||
| Fedora/RPM x86_64 | RPM-Paket | `KST4Contest-v<Version>-fedora-x86_64.rpm` |
|
||||
| Arch Linux x86_64 | Arch-Paket | `KST4Contest-v<Version>-archlinux-x86_64.pkg.tar.zst` |
|
||||
| Linux mit Flatpak | Flatpak-Referenz | `de.x08.KST4Contest.flatpakref` |
|
||||
| macOS Apple Silicon | DMG für ARM64 | `KST4Contest-v<Version>-macos-arm64.dmg` |
|
||||
| macOS Intel | DMG für x86_64 | `KST4Contest-v<Version>-macos-x86_64.dmg` |
|
||||
|
||||
Lade Programmpakete nur aus den offiziellen GitHub Releases, dem KST4Contest-Flatpak-Repository oder den verlinkten AUR-Paketen herunter. Dateien aus anderen Quellen können anders gebaut, veraltet oder verändert worden sein.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
## Installation unter Windows
|
||||
|
||||
### Windows
|
||||
KST4Contest wird unter Windows als ZIP-Paket bereitgestellt. Ein klassischer Installer ist nicht erforderlich.
|
||||
|
||||
1. ZIP-Datei herunterladen.
|
||||
2. ZIP-Datei in einen gewünschten Ordner entpacken.
|
||||
3. `praktiKST.exe` ausführen.
|
||||
1. Lade `praktiKST-v<Version>-windows-x64.zip` aus dem aktuellen Release herunter.
|
||||
2. Entpacke die ZIP-Datei vollständig in einen eigenen Ordner.
|
||||
3. Öffne den entpackten Ordner.
|
||||
4. Starte `praktiKST.exe`.
|
||||
|
||||
Die Einstellungen werden unter `%USERPROFILE%\.praktikst\preferences.xml` gespeichert.
|
||||
Starte das Programm nicht direkt aus der noch komprimierten ZIP-Datei. KST4Contest besteht aus mehreren Dateien und einer mitgelieferten Laufzeitumgebung. Windows kann diese Struktur nur zuverlässig verwenden, wenn das Archiv vorher vollständig entpackt wurde.
|
||||
|
||||
### Linux
|
||||
Die Programmeinstellungen befinden sich nicht im entpackten Programmordner, sondern im Benutzerverzeichnis:
|
||||
|
||||
Die Einstellungen werden immer unter `~/.praktikst/preferences.xml` gespeichert.
|
||||
```text
|
||||
%USERPROFILE%\.praktiKST\preferences.xml
|
||||
```
|
||||
|
||||
#### AppImage
|
||||
Dadurch können neue Programmversionen in einen anderen Ordner entpackt werden, ohne dass die vorhandenen Einstellungen verloren gehen.
|
||||
|
||||
1. AppImage herunterladen.
|
||||
2. Ausführbar machen: `chmod +x KST4Contest-v<Version>-linux-x86_64.AppImage`
|
||||
3. Starten.
|
||||
---
|
||||
|
||||
#### Debian / Ubuntu
|
||||
## Installation unter Linux
|
||||
|
||||
Unter Linux stehen mehrere Paketformate zur Verfügung. Welches davon sinnvoll ist, hängt weniger von KST4Contest als von der verwendeten Distribution und der gewünschten Update-Methode ab.
|
||||
|
||||
| Installationsart | Sinnvoll, wenn … |
|
||||
|---|---|
|
||||
| **Flatpak** | Updates zentral verwaltet werden sollen und Flatpak bereits verwendet wird |
|
||||
| **AppImage** | KST4Contest ohne Installation als einzelne portable Datei gestartet werden soll |
|
||||
| **DEB/RPM** | die Paketverwaltung der Distribution verwendet werden soll |
|
||||
| **Arch-Paket/AUR** | Arch Linux, Manjaro oder EndeavourOS eingesetzt wird |
|
||||
|
||||
### Flatpak
|
||||
|
||||
Flatpak ist für die meisten Linux-Anwender der einfachste Weg, KST4Contest installiert und aktualisierbar zu halten. Das KST4Contest-Repository ist GPG-signiert und enthält die Kanäle Stable, Beta und Nightly.
|
||||
|
||||
#### Stable über die Release-Datei installieren
|
||||
|
||||
Lade `de.x08.KST4Contest.flatpakref` aus dem aktuellen Release herunter und öffne die Datei mit der Softwareverwaltung der verwendeten Desktop-Umgebung.
|
||||
|
||||
Alternativ kann sie im Terminal installiert werden:
|
||||
|
||||
```bash
|
||||
flatpak install ./de.x08.KST4Contest.flatpakref
|
||||
```
|
||||
|
||||
#### KST4Contest-Repository hinzufügen
|
||||
|
||||
Das Repository muss nur einmal hinzugefügt werden:
|
||||
|
||||
```bash
|
||||
flatpak remote-add --if-not-exists kst4contest \
|
||||
https://praktimarc.github.io/kst4contest/kst4contest.flatpakrepo
|
||||
```
|
||||
|
||||
Anschließend kann die Stable-Version installiert werden:
|
||||
|
||||
```bash
|
||||
flatpak install kst4contest de.x08.KST4Contest//stable
|
||||
```
|
||||
|
||||
#### Beta installieren
|
||||
|
||||
```bash
|
||||
flatpak install kst4contest de.x08.KST4Contest//beta
|
||||
```
|
||||
|
||||
#### Nightly installieren
|
||||
|
||||
```bash
|
||||
flatpak install kst4contest de.x08.KST4Contest//nightly
|
||||
```
|
||||
|
||||
KST4Contest verwendet für alle drei Kanäle dieselbe App-ID. Eine parallele Installation mehrerer KST4Contest-Kanäle ist deshalb nicht vorgesehen.
|
||||
|
||||
Zum Wechsel von Stable auf Nightly beispielsweise:
|
||||
|
||||
```bash
|
||||
flatpak uninstall de.x08.KST4Contest//stable
|
||||
flatpak install kst4contest de.x08.KST4Contest//nightly
|
||||
```
|
||||
|
||||
Die persönlichen Einstellungen im Verzeichnis `~/.praktiKST` werden dabei nicht automatisch gelöscht.
|
||||
|
||||
Installierte Flatpak-Anwendungen können mit folgendem Befehl aktualisiert werden:
|
||||
|
||||
```bash
|
||||
flatpak update de.x08.KST4Contest
|
||||
```
|
||||
|
||||
Je nach Desktop-Umgebung kann die grafische Softwareverwaltung verfügbare Flatpak-Updates ebenfalls anzeigen oder automatisch installieren. Der Befehl `flatpak update` selbst führt das Update aus; er verspricht nicht, irgendwann von allein vorbeizukommen.
|
||||
|
||||
### AppImage
|
||||
|
||||
Das AppImage benötigt keine klassische Installation.
|
||||
|
||||
1. Lade `KST4Contest-v<Version>-linux-x86_64.AppImage` herunter.
|
||||
2. Öffne ein Terminal im Download-Verzeichnis.
|
||||
3. Mache die Datei ausführbar:
|
||||
|
||||
```bash
|
||||
chmod +x KST4Contest-v<Version>-linux-x86_64.AppImage
|
||||
```
|
||||
|
||||
4. Starte KST4Contest:
|
||||
|
||||
```bash
|
||||
./KST4Contest-v<Version>-linux-x86_64.AppImage
|
||||
```
|
||||
|
||||
Das AppImage kann anschließend an einen anderen Ort verschoben werden, beispielsweise nach `~/Applications`.
|
||||
|
||||
### Debian und Ubuntu
|
||||
|
||||
Installiere das DEB-Paket mit:
|
||||
|
||||
```bash
|
||||
sudo apt install ./KST4Contest-v<Version>-debian-amd64.deb
|
||||
```
|
||||
|
||||
Oder die `.deb`-Datei im Dateimanager doppelklicken.
|
||||
Alternativ kann die Datei in einer grafischen Paketverwaltung geöffnet werden.
|
||||
|
||||
#### Fedora / RHEL
|
||||
### Fedora und kompatible RPM-Systeme
|
||||
|
||||
Installiere das RPM-Paket mit:
|
||||
|
||||
```bash
|
||||
sudo dnf install ./KST4Contest-v<Version>-fedora-x86_64.rpm
|
||||
```
|
||||
|
||||
#### Arch Linux
|
||||
### Arch Linux: fertiges Release-Paket
|
||||
|
||||
Das aus dem GitHub Release heruntergeladene Arch-Paket kann direkt installiert werden:
|
||||
|
||||
```bash
|
||||
sudo pacman -U KST4Contest-v<Version>-archlinux-x86_64.pkg.tar.zst
|
||||
```
|
||||
|
||||
#### Flatpak
|
||||
### Arch Linux: Installation über den AUR
|
||||
|
||||
Im AUR stehen drei Varianten bereit:
|
||||
|
||||
| Paket | Inhalt |
|
||||
|---|---|
|
||||
| [`kst4contest-bin`](https://aur.archlinux.org/packages/kst4contest-bin) | Vorgefertigtes Paket des aktuellen Stable-Releases |
|
||||
| [`kst4contest`](https://aur.archlinux.org/packages/kst4contest) | Stable-Release, das lokal aus dem Quellcode gebaut wird |
|
||||
| [`kst4contest-git`](https://aur.archlinux.org/packages/kst4contest-git) | Aktueller Entwicklungsstand aus dem `main`-Branch |
|
||||
|
||||
Für die meisten Anwender ist `kst4contest-bin` die naheliegende Variante:
|
||||
|
||||
Die Datei `de.x08.KST4Contest.flatpakref` aus dem [aktuellen Release](https://github.com/praktimarc/kst4contest/releases/latest) herunterladen und öffnen, oder:
|
||||
```bash
|
||||
flatpak install de.x08.KST4Contest.flatpakref
|
||||
yay -S kst4contest-bin
|
||||
```
|
||||
|
||||
Oder den Remote manuell hinzufügen (empfohlen für Nightly/Beta-Zugriff):
|
||||
Stable aus dem Quellcode bauen:
|
||||
|
||||
```bash
|
||||
flatpak remote-add --if-not-exists kst4contest https://praktimarc.github.io/kst4contest/kst4contest.flatpakrepo
|
||||
flatpak install kst4contest de.x08.KST4Contest
|
||||
yay -S kst4contest
|
||||
```
|
||||
|
||||
#### Flatpak-Kanäle (nightly / beta / stable)
|
||||
Aktuellen Entwicklungsstand bauen:
|
||||
|
||||
Das Flatpak-Repository enthält drei Kanäle, die alle im selben Remote liegen:
|
||||
```bash
|
||||
yay -S kst4contest-git
|
||||
```
|
||||
|
||||
| Kanal | Inhalt | Wird gebaut bei |
|
||||
Die drei Pakete stellen dieselbe Anwendung bereit und sind deshalb als gegenseitige Konflikte definiert. Installiere nur eine Variante gleichzeitig.
|
||||
|
||||
AUR-Updates werden berücksichtigt, wenn der verwendete AUR-Helper nach Paketaktualisierungen sucht, beispielsweise mit:
|
||||
|
||||
```bash
|
||||
yay -Syu
|
||||
```
|
||||
|
||||
Sie erfolgen nicht allein deshalb automatisch, weil das Paket aus dem AUR stammt.
|
||||
|
||||
---
|
||||
|
||||
## Installation unter macOS
|
||||
|
||||
> **Best-Effort-Support:** Die macOS-Pakete werden zusammen mit den übrigen Releases gebaut, aber nicht in demselben Umfang getestet wie die Windows- und Linux-Versionen. Rückmeldungen sind willkommen; eine vollständig geprüfte Unterstützung aller macOS-Versionen und Hardwarevarianten kann derzeit jedoch nicht zugesagt werden.
|
||||
|
||||
Für Apple-Silicon-Macs wird das Paket mit `arm64` benötigt. Für Intel-Macs ist das Paket mit `x86_64` vorgesehen.
|
||||
|
||||
1. Lade die passende DMG-Datei herunter.
|
||||
2. Öffne die DMG-Datei.
|
||||
3. Ziehe `KST4Contest.app` in den Ordner **Programme**.
|
||||
4. Starte KST4Contest aus dem Programme-Ordner oder über das Launchpad.
|
||||
|
||||
Die Anwendung ist derzeit nicht von Apple notarisiert. macOS kann den ersten Start deshalb blockieren.
|
||||
|
||||
Falls die Anwendung aus dem offiziellen GitHub Release stammt:
|
||||
|
||||
1. Öffne den Programme-Ordner im Finder.
|
||||
2. Klicke mit der rechten Maustaste oder mit gedrückter Ctrl-Taste auf `KST4Contest.app`.
|
||||
3. Wähle **Öffnen**.
|
||||
4. Bestätige den Start im angezeigten Dialog.
|
||||
|
||||
Alternativ kann macOS unter **Systemeinstellungen → Datenschutz & Sicherheit** die Schaltfläche **Trotzdem öffnen** anbieten.
|
||||
|
||||
---
|
||||
|
||||
## Wo werden die Einstellungen gespeichert?
|
||||
|
||||
KST4Contest speichert seine Einstellungen und weitere lokale Arbeitsdateien im Benutzerverzeichnis. Der Programmordner und das Datenverzeichnis sind voneinander getrennt.
|
||||
|
||||
| Betriebssystem | Datenverzeichnis | Einstellungsdatei |
|
||||
|---|---|---|
|
||||
| `stable` | Aktuelle stabile Version | Jedem normalen Release-Tag |
|
||||
| `beta` | Vorab-Version | Tags mit `beta-` Präfix |
|
||||
| `nightly` | Entwicklungsstand (main-Branch) | Täglich / jedem Push auf main |
|
||||
| Windows | `%USERPROFILE%\.praktiKST\` | `%USERPROFILE%\.praktiKST\preferences.xml` |
|
||||
| Linux | `~/.praktiKST/` | `~/.praktiKST/preferences.xml` |
|
||||
| macOS | `~/.praktiKST/` | `~/.praktiKST/preferences.xml` |
|
||||
|
||||
Remote einmalig hinzufügen, dann den gewünschten Kanal installieren:
|
||||
Beachte die Schreibweise `.praktiKST` mit großem `KST`. Unter Linux und macOS wird zwischen Groß- und Kleinschreibung unterschieden. `.praktikst` wäre dort schlicht ein anderes Verzeichnis.
|
||||
|
||||
Ein Programmupdate entfernt dieses Verzeichnis nicht. Trotzdem ist es sinnvoll, vor größeren Versionswechseln oder umfangreichen Änderungen an der Konfiguration eine Sicherung davon anzulegen.
|
||||
|
||||
---
|
||||
|
||||
## Updates
|
||||
|
||||
KST4Contest prüft beim Start, ob ein neueres Stable-Release verfügbar ist. Wenn eine neuere Version gefunden wird, erscheint ein Informationsfenster mit:
|
||||
|
||||
- der installierten Version,
|
||||
- der aktuellen Stable-Version,
|
||||
- einer kurzen Übersicht wesentlicher Änderungen,
|
||||
- dem Changelog,
|
||||
- bekannten Problemen,
|
||||
- einem Link zur passenden GitHub-Release-Seite.
|
||||
|
||||

|
||||
|
||||
Der Update-Checker installiert nichts selbst. Er informiert über die neue Version und öffnet die plattformneutrale Release-Seite. Dort muss das passende Paket für Windows, Linux oder macOS ausgewählt werden.
|
||||
|
||||
### Windows aktualisieren
|
||||
|
||||
1. Beende KST4Contest.
|
||||
2. Lade das neue Windows-ZIP herunter.
|
||||
3. Entpacke es in einen neuen oder leeren Ordner.
|
||||
4. Starte die neue Version.
|
||||
5. Prüfe, ob die bisherigen Einstellungen geladen wurden.
|
||||
6. Entferne den alten Programmordner erst danach.
|
||||
|
||||
Die Einstellungen bleiben erhalten, weil sie unter `%USERPROFILE%\.praktiKST` und nicht im Programmordner gespeichert werden.
|
||||
|
||||
### AppImage aktualisieren
|
||||
|
||||
1. Lade das neue AppImage herunter.
|
||||
2. Mache es ausführbar.
|
||||
3. Starte die neue Datei.
|
||||
4. Entferne das bisherige AppImage erst, wenn die neue Version funktioniert.
|
||||
|
||||
### Debian und Ubuntu aktualisieren
|
||||
|
||||
```bash
|
||||
flatpak remote-add --if-not-exists kst4contest https://praktimarc.github.io/kst4contest/kst4contest.flatpakrepo
|
||||
|
||||
# Stable (Standard)
|
||||
flatpak install kst4contest de.x08.KST4Contest
|
||||
|
||||
# Beta
|
||||
flatpak install kst4contest de.x08.KST4Contest//beta
|
||||
|
||||
# Nightly
|
||||
flatpak install kst4contest de.x08.KST4Contest//nightly
|
||||
sudo apt install ./KST4Contest-v<Version>-debian-amd64.deb
|
||||
```
|
||||
|
||||
> **Hinweis:** Flatpak erlaubt nur eine installierte Version pro App-ID gleichzeitig. Um den Kanal zu wechseln, zuerst die aktuelle Version deinstallieren:
|
||||
> ```bash
|
||||
> flatpak uninstall de.x08.KST4Contest
|
||||
> flatpak install kst4contest de.x08.KST4Contest//nightly
|
||||
> ```
|
||||
### Fedora aktualisieren
|
||||
|
||||
Updates erfolgen wie gewohnt mit `flatpak update`.
|
||||
```bash
|
||||
sudo dnf upgrade ./KST4Contest-v<Version>-fedora-x86_64.rpm
|
||||
```
|
||||
|
||||
Die `flatpakref`-Dateien für Beta und Stable liegen jeweils im entsprechenden [GitHub-Release](https://github.com/praktimarc/kst4contest/releases). Das Nightly-`flatpakref` (`de.x08.KST4Contest.nightly.flatpakref`) ist als Artifact im [GitHub Actions](https://github.com/praktimarc/kst4contest/actions)-Tab verfügbar (wird 14 Tage aufbewahrt) – der Remote-Weg oben ist für Nightly daher meist einfacher.
|
||||
### Arch-Paket aktualisieren
|
||||
|
||||
### macOS
|
||||
1. DMG-Datei für die eigene Architektur herunterladen (Apple Silicon oder Intel).
|
||||
2. DMG-Datei öffnen.
|
||||
3. `KST4Contest.app` in den **Programme**-Ordner ziehen.
|
||||
4. Beim ersten Start zeigt macOS ggf. eine Warnung, da die App nicht notarisiert ist. Zum Öffnen:
|
||||
- Rechtsklick (oder Ctrl-Klick) auf `KST4Contest.app` im Finder → **Öffnen** wählen.
|
||||
- Alternativ: **Systemeinstellungen → Datenschutz & Sicherheit** → **Trotzdem öffnen** klicken.
|
||||
5. KST4Contest aus dem Programme-Ordner oder dem Launchpad starten.
|
||||
```bash
|
||||
sudo pacman -U KST4Contest-v<Version>-archlinux-x86_64.pkg.tar.zst
|
||||
```
|
||||
|
||||
Die Einstellungen werden unter `~/.praktikst/preferences.xml` gespeichert.
|
||||
### AUR-Paket aktualisieren
|
||||
|
||||
Aktualisiere das installierte Paket über den verwendeten AUR-Helper, beispielsweise:
|
||||
|
||||
```bash
|
||||
yay -Syu
|
||||
```
|
||||
|
||||
### Flatpak aktualisieren
|
||||
|
||||
```bash
|
||||
flatpak update de.x08.KST4Contest
|
||||
```
|
||||
|
||||
### macOS aktualisieren
|
||||
|
||||
1. Lade die neue DMG-Datei für die vorhandene Architektur herunter.
|
||||
2. Beende KST4Contest.
|
||||
3. Öffne die DMG-Datei.
|
||||
4. Ersetze `KST4Contest.app` im Programme-Ordner.
|
||||
5. Starte die neue Version und prüfe die vorhandenen Einstellungen.
|
||||
|
||||
Die Konfiguration unter `~/.praktiKST` bleibt davon unberührt.
|
||||
|
||||
---
|
||||
|
||||
## Update
|
||||
## Probleme beim ersten Start
|
||||
|
||||
KST4Contest enthält einen **automatischen Update-Hinweis-Dienst**: Sobald eine neue Version verfügbar ist, erscheint beim Start ein Fenster mit:
|
||||
- der Information, dass eine neue Version vorliegt,
|
||||
- einem Changelog,
|
||||
- dem Download-Link zur neuen Version.
|
||||
### Windows meldet eine unbekannte Anwendung
|
||||
|
||||

|
||||
KST4Contest ist derzeit nicht mit einem kommerziellen Windows-Code-Signing-Zertifikat signiert. Windows oder ein zusätzlich installiertes Sicherheitsprodukt kann deshalb vor einer unbekannten oder selten heruntergeladenen Anwendung warnen.
|
||||
|
||||
### Update-Prozess
|
||||
Prüfe in diesem Fall zuerst:
|
||||
|
||||
#### Windows
|
||||
- Stammt die Datei aus dem [offiziellen KST4Contest-Release](https://github.com/praktimarc/kst4contest/releases/latest)?
|
||||
- Passt der Dateiname zum veröffentlichten Release?
|
||||
- Wurde die Datei vollständig heruntergeladen?
|
||||
- Nennt das Sicherheitsprogramm einen konkreten Erkennungsnamen oder nur eine allgemeine Reputationswarnung?
|
||||
|
||||
Derzeit gibt es nur einen Weg zum Aktualisieren:
|
||||
Ein Warnhinweis ist allein weder ein sicherer Beweis für Schadsoftware noch automatisch ein Fehlalarm. Wenn die Herkunft der Datei unklar ist, sollte sie nicht gestartet oder aus der Quarantäne wiederhergestellt werden.
|
||||
|
||||
1. Den alten Ordner löschen.
|
||||
2. Das neue ZIP entpacken.
|
||||
Einige Anwender haben insbesondere Quarantäne-Meldungen von Norton 360 gemeldet. Wenn sich eine Meldung reproduzieren lässt, erstelle bitte einen [GitHub-Issue](https://github.com/praktimarc/kst4contest/issues) und nenne:
|
||||
|
||||
Die Einstellungsdatei (`preferences.xml`) bleibt erhalten, da sie im Benutzerordner gespeichert ist – nicht im Programmordner.
|
||||
- die KST4Contest-Version,
|
||||
- den vollständigen Dateinamen,
|
||||
- das verwendete Sicherheitsprodukt und dessen Version,
|
||||
- den angezeigten Erkennungsnamen,
|
||||
- möglichst einen Screenshot der Meldung.
|
||||
|
||||
#### Linux
|
||||
### Das AppImage startet nicht
|
||||
|
||||
- **AppImage**: Neues AppImage herunterladen, ausführbar machen (`chmod +x`), altes optional löschen.
|
||||
- **Debian/Ubuntu**: `sudo apt install ./KST4Contest-v<Version>-debian-amd64.deb`
|
||||
- **Fedora/RHEL**: `sudo dnf upgrade ./KST4Contest-v<Version>-fedora-x86_64.rpm`
|
||||
- **Arch Linux**: `sudo pacman -U KST4Contest-v<Version>-archlinux-x86_64.pkg.tar.zst`
|
||||
- **Flatpak (Repository)**: `flatpak update` – aktualisiert alle Flatpak-Apps einschließlich KST4Contest.
|
||||
Prüfe zuerst das Ausführungsrecht:
|
||||
|
||||
#### macOS
|
||||
```bash
|
||||
chmod +x KST4Contest-v<Version>-linux-x86_64.AppImage
|
||||
```
|
||||
|
||||
1. Neue DMG-Datei herunterladen.
|
||||
2. DMG öffnen.
|
||||
3. Die neue `KST4Contest.app` in den **Programme**-Ordner ziehen und die alte Version ersetzen.
|
||||
Starte die Datei anschließend aus einem Terminal. Fehlermeldungen sind dort meist aussagekräftiger als ein Doppelklick, der lediglich nichts Sichtbares tut.
|
||||
|
||||
### macOS blockiert die Anwendung
|
||||
|
||||
---
|
||||
Verwende die unter [Installation unter macOS](#installation-unter-macos) beschriebene Funktion **Öffnen** im Kontextmenü. Prüfe vorher, ob die DMG-Datei aus dem offiziellen GitHub Release stammt.
|
||||
|
||||
## Bekannte Probleme beim Start
|
||||
### Das Problem bleibt bestehen
|
||||
|
||||
### Norton 360
|
||||
Prüfe zunächst, ob das Problem bereits unter [GitHub Issues](https://github.com/praktimarc/kst4contest/issues) beschrieben wurde. Falls nicht, erstelle einen neuen Issue mit:
|
||||
|
||||
Norton 360 stuft `praktiKST.exe` als gefährlich ein (Fehlalarm). Es muss eine Ausnahme für die Datei eingerichtet werden:
|
||||
- Betriebssystem und Version,
|
||||
- verwendeter KST4Contest-Version,
|
||||
- Installationsart,
|
||||
- genauer Fehlermeldung,
|
||||
- den Schritten, mit denen sich das Problem reproduzieren lässt.
|
||||
|
||||
1. Norton 360 öffnen.
|
||||
2. Sicherheit → Verlauf → Das entsprechende Ereignis suchen.
|
||||
3. „Wiederherstellen & Ausnahme hinzufügen" wählen.
|
||||
|
||||
*(Gemeldet von PE0WGA, Franz van Velzen – danke!)*
|
||||
„Geht nicht“ beschreibt den Zustand meistens korrekt, hilft bei der Fehlersuche aber nur begrenzt.
|
||||
@@ -14,7 +14,7 @@ Nach dem ersten Start öffnet sich das **Einstellungsfenster** – dieses ist de
|
||||
|
||||
### Login und Chat-Kategorien
|
||||
|
||||
Hier werden die Zugangsdaten für den ON4KST-Chat eingetragen (Rufzeichen und Passwort).
|
||||
Hier werden die Zugangsdaten für den ON4KST-Chat eingetragen (Rufzeichen und Passwort).
|
||||
Zudem wird die **primäre Chat-Kategorie** (z. B. IARU Region 1 VHF/Microwave) ausgewählt.
|
||||
|
||||
Mit der Option für einen **zweiten Chat** (Multi-Channel-Login) kann man sich gleichzeitig in eine weitere Kategorie (z. B. UHF/SHF) einloggen. Beide Chats werden dann parallel überwacht. Hier kann optional auch ein abweichender Login-Name für den zweiten Chat vergeben werden (nützlich für Opposite Station Multi-Callsign Logging).
|
||||
@@ -29,13 +29,25 @@ Eigenes Rufzeichen und Maidenhead-Locator (6-stellig, z. B. `JN49IJ`) eintragen.
|
||||
|
||||
### Antennen-Öffnungswinkel (Antenna Beamwidth)
|
||||
|
||||
Einen realistischen Wert für den Öffnungswinkel der eigenen Antenne eintragen (in Grad). Dieser Wert wird für die [Sked-Richtungs-Hervorhebung](Funktionen#sked-richtungs-hervorhebung) verwendet. Ein Testwert von 50° hat sich bewährt; DM5M nutzt Quads mit 69°.
|
||||
Trage den vollständigen horizontalen Öffnungswinkel der eigenen Antenne in Grad ein. KST4Contest verwendet jeweils die Hälfte dieses Werts links und rechts der gewählten beziehungsweise hergeleiteten Antennenrichtung. Ein eingetragener Wert von `70°` entspricht daher einem Korridor von `±35°`.
|
||||
|
||||
> **Keinesfalls** Fantasy-Werte eintragen – die Richtungsberechnungen werden sonst unbrauchbar.
|
||||
Der Wert wird an mehreren Stellen verwendet:
|
||||
|
||||
- für den QTF-Filter der Benutzerliste,
|
||||
- für die Darstellung des eigenen Antennenkorridors,
|
||||
- als angenommener Öffnungswinkel einer fremden Station bei der [Herleitung von Richtungsgelegenheiten](de-Funktionen#richtungsgelegenheiten-aus-gerichteten-nachrichten).
|
||||
|
||||
Der letzte Punkt ist bewusst eine Näherung. ON4KST überträgt weder die verwendete Antenne noch deren Öffnungswinkel. KST4Contest verwendet deshalb den eigenen Wert als praktikable Annahme für die Gegenstation.
|
||||
|
||||
Wähle einen realistischen Wert. Ein zu großer Öffnungswinkel erzeugt viele geometrische Treffer, die praktisch kaum noch eine Aussage haben. Ein zu kleiner Wert kann dagegen brauchbare Richtungsgelegenheiten ausblenden.
|
||||
|
||||
### Standard-Maximum-QRB
|
||||
|
||||
Maximale Entfernung (in km), für die Richtungs-Warnungen ausgelöst werden sollen. Realistischer Wert für DM5M: 900 km. Stationen, die weiter entfernt sind, werden für Highlighting-Zwecke ignoriert.
|
||||
Trage die maximale Entfernung in Kilometern ein, innerhalb der KST4Contest Richtungsgelegenheiten berücksichtigen soll. Maßgeblich ist die Entfernung zwischen der eigenen Station und dem Absender der gerichteten Nachricht – nicht die Entfernung zwischen Absender und Empfänger.
|
||||
|
||||
Liegt der Absender weiter entfernt, wird die Situation auch dann nicht hervorgehoben und nicht als Richtungsgelegenheit an den lokalen DX-Cluster-Server weitergegeben, wenn der berechnete Winkel passen würde.
|
||||
|
||||
Der Wert sollte zum eigenen Stationsaufbau und zum vorgesehenen Contestbetrieb passen. Ein unnötig großer Bereich erzeugt Hinweise für Stationen, die praktisch nicht mehr zum Arbeitsbereich gehören; ein zu kleiner Bereich blendet mögliche Kandidaten bereits vor der Richtungsbewertung aus.
|
||||
|
||||
---
|
||||
|
||||
@@ -87,11 +99,77 @@ Konfiguration der Schnittstelle zu AirScout für die Flugzeug-Scatter-Erkennung.
|
||||
|
||||
## Notification Settings (Benachrichtigungen)
|
||||
|
||||
Drei Benachrichtigungstypen stehen zur Wahl:
|
||||

|
||||
|
||||
1. **Einfache Sounds**: TADA-Sound für eingehende Nachrichten, Tick für Sked-Richtungserkennung usw.
|
||||
2. **CW-Ansage**: Das Rufzeichen einer Station, die eine Privatnachricht sendet, wird als CW-Signal ausgegeben.
|
||||
3. **Phonetische Ansage**: Das Rufzeichen wird phonetisch ausgesprochen.
|
||||
Im Reiter **Notification** werden nicht nur akustische Hinweise konfiguriert. Hier befinden sich auch die Einstellungen für den lokalen DX-Cluster-Server, den Band-Upgrade-Hinweis und das QSO-Monitoring.
|
||||
|
||||
### Akustische Hinweise
|
||||
|
||||
Die drei Audiofunktionen arbeiten unabhängig voneinander:
|
||||
|
||||
- **Play notification sounds …** aktiviert kurze Hinweistöne für neue Privatnachrichten, erkannte Richtungsgelegenheiten, Sked-Erinnerungen und Band-Upgrade-Hinweise.
|
||||
- **Spell the sender's callsign in CW …** gibt das Rufzeichen des Absenders einer neuen Privatnachricht als CW-Signal aus.
|
||||
- **Speak the sender's callsign phonetically …** spricht das Rufzeichen des Absenders phonetisch aus.
|
||||
|
||||
CW- und Sprachausgabe können gleichzeitig aktiviert werden. Das ist technisch möglich, im Contest aber nicht zwingend hilfreich. In der Praxis sollte nur die Ausgabe eingeschaltet werden, die im eigenen Stationsbetrieb tatsächlich wahrgenommen werden kann, ohne den Operator dauerhaft zu beschäftigen.
|
||||
|
||||
### Local DX Cluster output
|
||||
|
||||
KST4Contest kann erkannte Richtungsgelegenheiten als DX-Cluster-Spots an ein Logprogramm weitergeben. Der praktische Nutzen liegt auf der Hand: Eine im Chat erkannte Frequenz erscheint direkt in der Bandmap des Logprogramms und muss nicht erst von Hand übertragen werden.
|
||||
|
||||
Die Checkbox **Enable the local DX Cluster server …** startet beziehungsweise beendet den lokalen TCP-Server. Bei einer laufenden Chat-Verbindung wird die Änderung sofort wirksam.
|
||||
|
||||
Folgende Einstellungen sind erforderlich:
|
||||
|
||||
- **TCP port**: Port, auf dem KST4Contest Verbindungen von DX-Cluster-Clients annimmt. Der Standardwert ist `8000`. Wird der Port während einer laufenden Verbindung geändert, startet KST4Contest den Server auf dem neuen Port neu. Der Logger muss sich anschließend ebenfalls mit dem neuen Port verbinden.
|
||||
- **Fallback band in MHz**: Bandpräfix für relative Frequenzangaben. Aus `205` oder `.205` wird bei einem Fallback-Band von `144` die Frequenz `144.205 MHz`. Vollständige Angaben wie `432.205` oder `1296.338` benötigen diesen Fallback nicht.
|
||||
- **Spotter callsign**: Rufzeichen, das im erzeugten DX-Cluster-Spot als Spotter erscheint. Hier sollte ein anderes Rufzeichen als das im Contest verwendete Stationsrufzeichen eingetragen werden. Einige Logprogramme filtern Spots des eigenen Rufzeichens oder behandeln sie anders als fremde Spots.
|
||||
- **Send test spot**: Sendet einen Testspot für `DL0TEST` auf `.300` des eingestellten Fallback-Bandes. Der Test funktioniert nur, wenn KST4Contest mit dem Chat verbunden, der lokale DX-Cluster-Server aktiviert und mindestens ein DX-Cluster-Client verbunden ist.
|
||||
|
||||
KST4Contest erzeugt nicht bei jeder im Chat gefundenen Frequenz automatisch einen Spot. Ein Spot entsteht nur dann, wenn eine gerichtete Nachricht zwischen zwei Stationen auf eine für die eigene Station interessante Antennenrichtung schließen lässt und für den Absender eine nutzbare Frequenz bekannt ist.
|
||||
|
||||
Die vollständige Herleitung und die Einrichtung des Logprogramms sind im Kapitel [Integrierter DX-Cluster-Server](de-DX-Cluster-Server) beschrieben.
|
||||
|
||||
### Band-Upgrade-Hinweis nach einem Logeintrag
|
||||
|
||||
Nach einem über UCXLog oder Win-Test empfangenen Logeintrag kann KST4Contest prüfen, ob die gerade gearbeitete Station auf einem weiteren gemeinsamen, aber noch nicht gearbeiteten Band aktiv ist.
|
||||
|
||||
Dafür werden drei Informationen miteinander verglichen:
|
||||
|
||||
1. die in den Stationseinstellungen aktivierten eigenen Bänder,
|
||||
2. die innerhalb der letzten 30 Minuten erkannten Bänder der Gegenstation,
|
||||
3. die bereits pro Band gespeicherten Worked-Markierungen.
|
||||
|
||||
Bleibt danach mindestens ein gemeinsames, noch nicht gearbeitetes Band übrig, erscheint im Hauptfenster ein blinkender **BAND+**-Hinweis. Ist die allgemeine Soundausgabe aktiviert, wird zusätzlich ein Hinweiston abgespielt.
|
||||
|
||||
Die beiden Optionen haben unterschiedliche Aufgaben:
|
||||
|
||||
- **Blink + sound …** aktiviert den eigentlichen Band-Upgrade-Hinweis.
|
||||
- **Priority boost …** erhöht zusätzlich die Priorität entsprechender Stationen, damit sie in den Kandidatenlisten besser sichtbar bleiben. Der Boost garantiert keinen bestimmten Listenplatz; er ist nur ein zusätzlicher Faktor innerhalb der gesamten Prioritätsberechnung.
|
||||
|
||||
Der Hinweis setzt eine Log-Synchronisation mit Bandinformation voraus. Der einfache dateibasierte Callsign-Interpreter kann nur Rufzeichen erkennen und liefert deshalb keine ausreichende Grundlage für diese Prüfung.
|
||||
|
||||
### Sniffer-Einstellungen (ab v1.31)
|
||||
|
||||
Das QSO-Monitoring ist für Stationen gedacht, deren Kommunikation man gezielt verfolgen möchte. Das kann beispielsweise eine seltene Station, eine DXpedition oder eine andere Station des eigenen Contest-Teams sein.
|
||||
|
||||
Für jedes eingetragene Rufzeichen zeigt KST4Contest Nachrichten zusätzlich in der PM-Tabelle an, wenn das Rufzeichen entweder Absender oder Empfänger der Nachricht ist. Die ursprüngliche Nachricht wird dabei nicht aus ihrer normalen Tabelle entfernt.
|
||||
|
||||
Überwachte Nachrichten werden in der PM-Tabelle eindeutig gekennzeichnet:
|
||||
|
||||
```text
|
||||
Sniffed: (SENDER > RECEIVER) Nachrichtentext
|
||||
```
|
||||
|
||||
So wird sichtbar, dass die Nachricht nicht an die eigene Station gerichtet war.
|
||||
|
||||
Rufzeichen werden folgendermaßen verwaltet:
|
||||
|
||||
1. Mit **Add monitored callsign** ein neues Rufzeichen hinzufügen.
|
||||
2. Ein vorhandenes Rufzeichen per Doppelklick bearbeiten und die Änderung mit `Enter` übernehmen.
|
||||
3. Zum Entfernen den Inhalt einer Tabellenzelle löschen und mit `Enter` bestätigen.
|
||||
|
||||
Doppelte oder syntaktisch ungültige Rufzeichen werden nicht übernommen. Die Liste wird mit **Save Settings** in der `preferences.xml` gespeichert und beim nächsten Programmstart wiederhergestellt.
|
||||
|
||||
---
|
||||
|
||||
@@ -117,19 +195,111 @@ Wenn in der Benutzerliste ein Rufzeichen ausgewählt ist, wird der Snippet als D
|
||||
|
||||
## Beacon Settings (Automatischer Beacon)
|
||||
|
||||
Konfiguration eines automatischen Intervall-Beacons im öffentlichen Chat-Kanal. Empfohlen: Variable `MYQRG` im Text verwenden, damit die aktuelle Frequenz immer aktuell ist. Intervall und Text sind frei konfigurierbar.
|
||||

|
||||
|
||||
> **Tipp**: Beacon beim CQ-Rufen aktivieren und im Einstellungsfenster schnell deaktivieren, wenn kein CQ gerufen wird.
|
||||
Ein Beacon sendet in regelmäßigen Abständen eine öffentliche CQ-Nachricht. Er ist für Betriebssituationen gedacht, in denen die eigene Station über längere Zeit auf einer festen Frequenz ruft. Andere Stationen erhalten dadurch eine aktuelle QRG-Information, ohne dass der Operator denselben Text wiederholt von Hand in den Chat schreiben muss.
|
||||
|
||||
KST4Contest verwendet einen gemeinsamen Timer für beide Chat-Kategorien. Aktivierung und Nachrichtentext werden trotzdem getrennt konfiguriert:
|
||||
|
||||
- **Enable CQ beacon** aktiviert den Beacon der betreffenden Kategorie.
|
||||
- **Beacon message** enthält den öffentlichen Nachrichtentext dieser Kategorie.
|
||||
- **Shared beacon interval** legt das gemeinsame Intervall für beide Kategorien fest.
|
||||
|
||||
Sind beide Beacons aktiviert, werden sie beim selben Timer-Lauf nacheinander in ihren jeweiligen Kategorien gesendet. Der zweite Beacon wird nur berücksichtigt, wenn auch der zweite Chat aktiviert und verbunden ist.
|
||||
|
||||
### Intervall und Timer-Verhalten
|
||||
|
||||
Das Intervall wird in ganzen Minuten angegeben. Der kleinste zulässige Wert ist eine Minute.
|
||||
|
||||
Nach dem Aufbau der Chat-Verbindung prüft KST4Contest die Beacons erstmals nach ungefähr zehn Sekunden. Anschließend gilt das eingestellte Intervall. Wird der Wert während einer laufenden Verbindung geändert, beginnt der Countdown mit dem neuen Intervall erneut. Die Änderung selbst löst keine sofortige Nachricht aus.
|
||||
|
||||
### Nachrichtentext und Variablen
|
||||
|
||||
Ein Beacon darf nach der Variablenauflösung höchstens 120 Zeichen enthalten. KST4Contest prüft deshalb nicht nur das eingetragene Template, sondern den tatsächlich zu sendenden Text.
|
||||
|
||||
Im Beacon können alle [globalen Variablen](de-Makros-und-Variablen#variablen-im-beacon) verwendet werden, beispielsweise:
|
||||
|
||||
```text
|
||||
calling cq at MYQRG, ant MYQTF deg, loc MYLOCATOR
|
||||
```
|
||||
|
||||
Die Variablen werden bei jedem Timer-Lauf neu aufgelöst. Ändert die Logsoftware zwischenzeitlich die in `MYQRG` gespeicherte Frequenz, verwendet bereits der nächste Beacon den neuen Wert.
|
||||
|
||||
Stationsbezogene Variablen wie `QRZNAME`, `FIRSTAP` oder `SECONDAP` benötigen dagegen eine ausgewählte Gegenstation. Da ein öffentlicher Beacon keine Gegenstation adressiert, werden diese Variablen im Beacon nicht aufgelöst.
|
||||
|
||||
### Wann sollte der Beacon ausgeschaltet werden?
|
||||
|
||||
Der Beacon ist nur dann hilfreich, wenn seine QRG-Angabe zum tatsächlichen Betrieb passt. Bleibt er beim Absuchen oder häufigen Wechseln von Frequenzen aktiviert, können andere Stationen auf einer inzwischen falschen Frequenz nach der eigenen Station suchen.
|
||||
|
||||
Im Klartext: Solange auf einer festen QRG CQ gerufen wird, spart der Beacon Arbeit. Beim „Schleichen“ über das Band sollte er ausgeschaltet werden.
|
||||
|
||||
Änderungen wirken während der laufenden Verbindung. Damit Aktivierung, Texte und Intervall auch nach dem nächsten Programmstart erhalten bleiben, anschließend **Save Settings** verwenden.
|
||||
---
|
||||
|
||||
## Messagehandling Settings (ab v1.25)
|
||||
|
||||
Neuer Einstellungsbereich mit folgenden Optionen:
|
||||

|
||||
|
||||
- **Auto-Antwort auf alle eingehenden Nachrichten**: Automatische Antwort auf Privatnachrichten konfigurierbar.
|
||||
- **Auto-Antwort mit eigener CQ-QRG**: Wenn jemand nach der eigenen QRG fragt, antwortet KST4Contest automatisch mit dem Inhalt der `MYQRG`-Variable.
|
||||
- **Standard-Filter für das Userinfo-Fenster**: Voreingestellter Nachrichtenfilter für das Stationsinfo-Fenster konfigurierbar *(für Gianluca :-) )*.
|
||||
Die wichtigste Anwendung der allgemeinen automatischen Antwort betrifft Stationen, die zwar im ON4KST-Chat eingeloggt sind, den laufenden Contest aber nicht mitfunken. Gerade während größerer Contests werden Sked-Anfragen teilweise unkoordiniert und in großer Zahl an eingeloggte Stationen verteilt, ohne vorher zu prüfen, ob sie überhaupt teilnehmen. Die Empfänger müssten sonst immer wieder dieselbe Absage schreiben.
|
||||
|
||||
KST4Contest kann darauf mit einem vorher festgelegten Text reagieren. Davon getrennt steht eine gezielte QRG-Auskunft zur Verfügung. Beide Funktionen können unabhängig voneinander aktiviert werden.
|
||||
|
||||
### Allgemeine automatische Antwort
|
||||
|
||||
**Enable automatic reply to all private messages** beantwortet eingehende Privatnachrichten mit dem Text im Feld rechts daneben. Es gibt einen gemeinsamen Text für beide Chat-Kategorien. Die beim Eingeben verwendete Groß- und Kleinschreibung bleibt erhalten.
|
||||
|
||||
Eine zweckmäßige Nachricht ist beispielsweise:
|
||||
|
||||
```text
|
||||
Sri, I am not taking part in this contest. No skeds.
|
||||
```
|
||||
|
||||
Die eingegangene Privatnachricht bleibt sichtbar. Die Funktion blockiert oder verwirft keine Anfrage, sondern erspart lediglich die wiederholte manuelle Antwort.
|
||||
|
||||
Die Antwort wird in derselben Chat-Kategorie gesendet, in der die Privatnachricht eingegangen ist. Das ist bei einem parallelen Login in zwei Kategorien entscheidend: Eine Nachricht aus dem Microwave-Chat darf nicht versehentlich im VHF/UHF-Chat beantwortet werden.
|
||||
|
||||
### Automatische QRG-Antwort
|
||||
|
||||
**Enable automatic QRG replies** reagiert auf typische QRG-Anfragen. Die Erkennung unterscheidet nicht zwischen Groß- und Kleinschreibung und sucht nach folgenden Textbestandteilen:
|
||||
|
||||
```text
|
||||
ur qrg?
|
||||
your qrg?
|
||||
qrg?
|
||||
freq?
|
||||
pse qrg
|
||||
```
|
||||
|
||||
Die Antwort enthält nur die QRG der Kategorie, in der die Anfrage eingegangen ist:
|
||||
|
||||
| Eingegangene Privatnachricht | Verwendete QRG |
|
||||
|---|---|
|
||||
| Hauptkategorie | aktuelle QRG der Hauptkategorie |
|
||||
| zweite Chat-Kategorie | aktuelle QRG der zweiten Kategorie |
|
||||
|
||||
Die Werte stammen aus denselben QRG-Feldern, die auch von `MYQRG` und `SECONDQRG` verwendet werden. Die Haupt-QRG kann manuell eingetragen oder durch die [TRX-Synchronisation](#trx-sync-einstellungen) aktualisiert werden. Für die zweite Kategorie wird der dort konfigurierte beziehungsweise manuell eingetragene Wert verwendet.
|
||||
|
||||
Sind die allgemeine und die QRG-bezogene Antwort gleichzeitig aktiviert, hat die QRG-Antwort Vorrang. Eine erkannte QRG-Anfrage erzeugt daher nicht zusätzlich den allgemeinen Antworttext.
|
||||
|
||||
### Schutz vor wiederholten Antworten
|
||||
|
||||
Jede automatisch erzeugte Nachricht trägt das feste Präfix:
|
||||
|
||||
```text
|
||||
[KST4C Automsg]
|
||||
```
|
||||
|
||||
Die allgemeine und die QRG-bezogene Antwort reagieren nicht auf Nachrichten, die dieses Präfix bereits enthalten. Dadurch beantworten sich zwei entsprechend arbeitende Clients nicht gegenseitig in einer Schleife.
|
||||
|
||||
Zusätzlich gilt eine gemeinsame Sperrzeit von zwei Minuten für beide Antwortarten. Die Sperre wird getrennt je Rufzeichen und Chat-Kategorie geführt. Hat eine Station gerade in der Hauptkategorie eine automatische Antwort erhalten, kann sie deshalb weiterhin eine Antwort in der zweiten Kategorie erhalten. Weitere Nachrichten derselben Station in derselben Kategorie lösen während der folgenden zwei Minuten dagegen keine neue automatische Antwort aus.
|
||||
|
||||
Die Sperrzeit beginnt nur, wenn KST4Contest tatsächlich eine Antwort sendet.
|
||||
|
||||
> **Hinweis**: Der Antworttext sollte den tatsächlichen Status eindeutig benennen. Wer den Contest nur beobachtet und keine Skeds fahren möchte, sollte genau das mitteilen. Eine vage Nachricht erzeugt im Zweifel nur die nächste Rückfrage – und damit exakt die Arbeit, welche die Funktion vermeiden soll.
|
||||
|
||||
Änderungen wirken während der laufenden Verbindung. Damit Aktivierung und Text nach dem nächsten Programmstart erhalten bleiben, anschließend **Save Settings** verwenden.
|
||||
|
||||
Weitere Hintergründe: [Automatische Antworten auf Privatnachrichten](de-Funktionen#automatische-antworten-auf-privatnachrichten-ab-v125).
|
||||
|
||||
---
|
||||
|
||||
@@ -163,17 +333,6 @@ Einstellungen:
|
||||
|
||||
---
|
||||
|
||||
## Sniffer-Einstellungen (ab v1.31)
|
||||
|
||||
Der QSO-Sniffer filtert Chat-Nachrichten von konfigurierbaren Rufzeichen und leitet sie ins PM-Fenster weiter.
|
||||
|
||||
Einstellungen:
|
||||
- **Rufzeichen-Liste**: Kommagetrennte Liste von Rufzeichen, deren Nachrichten immer in das PM-Fenster weitergeleitet werden sollen.
|
||||
|
||||
Anwendungsfall: Wichtige Stationen (z. B. DX-Peditionen oder feste Verbündete im Contest) im Auge behalten, ohne den Haupt-Chat ständig zu beobachten.
|
||||
|
||||
---
|
||||
|
||||
## Worked Station Database Settings (Gearbeitete-Stationen-Datenbank)
|
||||
|
||||
Die interne Worked-Datenbank enthält:
|
||||
|
||||
@@ -138,18 +138,34 @@ Wird durch die aktuelle Antennenrichtung in Worten ersetzt (z. B. `north`, `nort
|
||||
|
||||
---
|
||||
|
||||
## Variablen im Beacon
|
||||
## Variablen im Beacon
|
||||
|
||||
Alle Variablen können auch im **automatischen Beacon** (Intervall-Nachrichten) verwendet werden. Empfohlene Beacon-Konfiguration:
|
||||
Ein öffentlicher Beacon besitzt keine ausgewählte Gegenstation. Deshalb können hier ausschließlich Variablen verwendet werden, die nur von der eigenen Station und ihrer aktuellen Konfiguration abhängen:
|
||||
|
||||
| Variable | Wert im Beacon |
|
||||
|---|---|
|
||||
| `MYQRG` | aktuelle QRG der ersten Chat-Kategorie |
|
||||
| `MYQRGSHORT` | auf sieben Zeichen gekürzte QRG der ersten Kategorie |
|
||||
| `SECONDQRG` | aktuelle QRG der zweiten Chat-Kategorie |
|
||||
| `MYLOCATOR` | eigener vollständiger Locator |
|
||||
| `MYLOCATORSHORT` | eigener vierstelliger Locator |
|
||||
| `MYCALL` | eigenes Rufzeichen |
|
||||
| `MYQTF` | aktuelle Antennenrichtung |
|
||||
|
||||
`QRZNAME`, `FIRSTAP` und `SECONDAP` benötigen eine ausgewählte Station. In einem öffentlichen Beacon werden sie daher nicht aufgelöst.
|
||||
|
||||
Eine zweckmäßige Konfiguration ist beispielsweise:
|
||||
|
||||
```
|
||||
calling cq at MYQRG, loc MYLOCATOR, GL all!
|
||||
```
|
||||
|
||||
Da KST4Contest QRG-Daten automatisch aus Chat-Nachrichten ausliest: Wenn andere Stationen ebenfalls KST4Contest nutzen, sehen sie die eigene QRG sofort in der QRG-Spalte der Benutzerliste.
|
||||
Die globalen Variablen werden bei jedem Timer-Lauf neu ausgewertet. Dadurch kann eine vom Logprogramm aktualisierte QRG bereits in der nächsten Beacon-Nachricht erscheinen.
|
||||
|
||||
Der vollständig aufgelöste Nachrichtentext darf höchstens 120 Zeichen enthalten. Weitere Angaben zum gemeinsamen Intervall und zum Verhalten beider Chat-Kategorien stehen unter [Konfiguration – Beacon Settings](de-Konfiguration#beacon-settings-automatischer-beacon).
|
||||
|
||||
---
|
||||
|
||||
## Beispiel-Workflow mit Makros im Contest
|
||||
|
||||
1. Station in der Benutzerliste auswählen → Rufzeichen ist nun vorausgewählt.
|
||||
|
||||
|
After Width: | Height: | Size: 961 KiB |
@@ -1,62 +1,117 @@
|
||||
# KST4Contest – Wiki
|
||||
# KST4Contest – User Manual
|
||||
|
||||
> 🇬🇧 You are reading the English version | 🇩🇪 [Deutsche Version](de-Home)
|
||||
> You are reading the English version | [Deutsche Version](de-Home)
|
||||
|
||||
**KST4Contest** (also known as *PraktiKST*) is a Java-based chat client for the [ON4KST Chat](http://www.on4kst.info/chat/), specifically designed for contest operation on the VHF/UHF/SHF bands (144 MHz and above).
|
||||
KST4Contest is a desktop client for the [ON4KST Chat](https://www.on4kst.org/chat/login.php), developed for VHF, UHF and SHF contest operation. It brings chat, candidate selection, sked planning, aircraft scatter data and external station software together in a single operating interface.
|
||||
|
||||
Developed by **DO5AMF (Marc Fröhlich)**, operator at DM5M.
|
||||
KST4Contest is developed by **DO5AMF (Marc Fröhlich)**, operator at DM5M and (since May 2025) **DN9APW (Philipp Wagner)**. The source code is publicly available on [GitHub](https://github.com/praktimarc/kst4contest).
|
||||
|
||||
---
|
||||
|
||||
## Quick Navigation
|
||||
## Why use a dedicated ON4KST client?
|
||||
|
||||
During a contest, the ON4KST Chat provides a considerable amount of information: active stations, locators, frequencies, sked requests and indications of current activity. The actual problem is not seeing this data. It is turning it into the next useful contact in time.
|
||||
|
||||
KST4Contest evaluates the available information, puts it into context and presents it as part of a contest-oriented workflow. This includes antenna direction, distance, known bands and frequencies, worked status, chat activity and aircraft scatter timing.
|
||||
|
||||
The program does not decide which QSO is actually possible. Priority scores, the AP timeline and visual highlights are decision aids. The final judgement remains with the operator – not least because even a very convincing computer screen cannot complete a radio contact.
|
||||
|
||||
---
|
||||
|
||||
## How KST4Contest supports contest operation
|
||||
|
||||
- **Observe and organise chat activity:** KST4Contest can display two ON4KST chat categories at the same time. Messages, frequency information and known band activity are assigned to the corresponding stations.
|
||||
|
||||
- **Reduce the station list:** Direction, distance, worked and NOT-QRV filters help limit the user list to stations that are relevant to the current operating situation.
|
||||
|
||||
- **Prioritise candidates:** The score system evaluates active chat members using several known criteria. The currently most relevant candidates are also shown in a separate priority list.
|
||||
|
||||
- **Prepare skeds and keep them visible:** Sked reminders, automatic advance messages and the AP timeline help prevent scheduled contacts from disappearing somewhere between chat, logging and ongoing CQ operation.
|
||||
|
||||
- **Include aircraft scatter information:** The AirScout interface brings suitable aircraft and expected reflection times into candidate evaluation and sked planning.
|
||||
|
||||
- **Connect the logger and station equipment:** KST4Contest synchronises worked stations and frequency information with supported logging software. It also provides interfaces for Win-Test, PSTRotator and a built-in DX Cluster server.
|
||||
|
||||
- **Display stations and radio paths:** The station map shows active chat members, locator squares, antenna directions and the path to the selected station. A terrain profile can also be calculated for selected paths.
|
||||
|
||||
These functions share information. A detected frequency can indicate an active band, worked status comes from the logger, aircraft scatter data adds timing information and the resulting score affects the priority list. Missing or outdated input data can therefore affect the result as well.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
A registered ON4KST account is required. Registration and login are available from the [official ON4KST login page](https://www.on4kst.org/chat/login.php).
|
||||
|
||||
English is the official language of the ON4KST Chat. This also applies when communicating with stations from your own country. Common amateur radio abbreviations such as `pse`, `agn`, `qrg`, `dir`, `rrr`, `tnx` and `73` are normal and usually considerably faster than carefully written prose.
|
||||
|
||||
Downloads, supported operating systems and installation methods are described in [Installation](en-Installation).
|
||||
|
||||
---
|
||||
|
||||
## Manual version
|
||||
|
||||
This manual describes the stable release **v1.41.1**.
|
||||
|
||||
Features or changes that are only available in the current development version are explicitly marked as **Nightly / v1.42**. If no such label is present, the description refers to the stable release.
|
||||
|
||||
- [Download the current stable release](https://github.com/praktimarc/kst4contest/releases/latest)
|
||||
- [Nightly and automated builds](https://github.com/praktimarc/kst4contest/actions)
|
||||
- [Version history](en-Changelog)
|
||||
|
||||
For contest operation, the stable release is generally the appropriate choice. Nightly builds contain newer fixes and features, but may change between builds. They are useful when a particular change needs testing. Trying one for the first time ten minutes before a contest is less useful.
|
||||
|
||||
---
|
||||
|
||||
## Quick navigation
|
||||
|
||||
| Page | Contents |
|
||||
|---|---|
|
||||
| [Installation](en-Installation) | Download, Java requirements, update |
|
||||
| [Configuration](en-Configuration) | All settings in detail |
|
||||
| [Log Synchronisation](en-Log-Sync) | UCXLog, N1MM+, QARTest, DXLog.net, WinTest |
|
||||
| [AirScout Integration](en-AirScout-Integration) | Aircraft scatter detection |
|
||||
| [DX Cluster Server](en-DX-Cluster-Server) | Built-in DX cluster for your logging software |
|
||||
| [Features](en-Features) | All features at a glance |
|
||||
| [Macros and Variables](en-Macros-and-Variables) | Text snippets, shortcuts, variables |
|
||||
| [User Interface](en-User-Interface) | UI explanation and operation |
|
||||
| [Changelog](en-Changelog) | Version history |
|
||||
| [Installation](en-Installation) | ON4KST account, downloads, installation and updates |
|
||||
| [Configuration](en-Configuration) | Login, station, bands, user interface and external connections |
|
||||
| [Log Synchronisation](en-Log-Sync) | Simplelogfile, UCXLog, N1MM+, QARTest, DXLog.net and Win-Test |
|
||||
| [AirScout Integration](en-AirScout-Integration) | Connecting AirScout and evaluating aircraft scatter timing |
|
||||
| [DX Cluster Server](en-DX-Cluster-Server) | Passing detected opportunities to logging software |
|
||||
| [Features](en-Features) | Operation, reasoning and limitations of individual functions |
|
||||
| [Macros and Variables](en-Macros-and-Variables) | Reusable messages, shortcuts and automatically substituted values |
|
||||
| [User Interface](en-User-Interface) | Interface layout and operation during a contest |
|
||||
| [Changelog](en-Changelog) | Releases, Nightly changes and resolved issues |
|
||||
|
||||
---
|
||||
|
||||
## What is KST4Contest?
|
||||
## Contact and support
|
||||
|
||||
The ON4KST Chat is the de-facto standard for skeds on the 144 MHz and higher bands. KST4Contest enhances the chat experience with contest-specific features:
|
||||
- **Download:** [Current stable release](https://github.com/praktimarc/kst4contest/releases/latest)
|
||||
- **Source code:** [praktimarc/kst4contest](https://github.com/praktimarc/kst4contest)
|
||||
- **Bug reports and feature requests:** [GitHub Issues](https://github.com/praktimarc/kst4contest/issues)
|
||||
- **Email:** praktimarc+kst4contest@gmail.com
|
||||
Please use this address only for KST4Contest-related topics.
|
||||
|
||||
- **Worked marking**: Stations already worked are highlighted visually, synchronised directly from your logging software via UDP.
|
||||
- **Sked direction detection**: When a station calls another one from your direction, it is highlighted green and bold.
|
||||
- **QRG detection**: KST4Contest automatically reads frequencies from the chat traffic and shows them in the user list.
|
||||
- **AirScout interface**: Reflectable aircraft are shown directly in the user list.
|
||||
- **Built-in DX cluster server**: Spots are sent directly to your logging software.
|
||||
- **Dark mode** (from v1.26): Easy on the eyes during night-time operation.
|
||||
- **Multi-channel login** (from v1.26): Simultaneously logged into two chat categories.
|
||||
### Reporting a bug
|
||||
|
||||
---
|
||||
A problem is considerably easier to reproduce when the report contains at least:
|
||||
|
||||
## Contact & Support
|
||||
1. the KST4Contest version,
|
||||
2. the operating system and installation method,
|
||||
3. the exact steps leading to the problem,
|
||||
4. the expected and observed behaviour,
|
||||
5. a screenshot where appropriate,
|
||||
6. the error log file.
|
||||
|
||||
- **Email**: praktimarc+kst4contest@gmail.com *(for kst4contest topics only)*
|
||||
- **GitHub**: https://github.com/praktimarc/kst4contest
|
||||
- **Download**: https://github.com/praktimarc/kst4contest/releases/latest
|
||||
The error log is stored at:
|
||||
|
||||
### Reporting a bug (creating an issue)
|
||||
|
||||
When reporting a bug, please **always attach the log file**. KST4Contest automatically writes an error log (error messages only, no personal data):
|
||||
|
||||
| Operating system | Log file location |
|
||||
| Operating system | Path |
|
||||
|---|---|
|
||||
| Linux / macOS | `~/.praktiKST/kst4contest-errors.log` |
|
||||
| Windows | `C:\Users\<YourName>\.praktiKST\kst4contest-errors.log` |
|
||||
|
||||
When opening an issue on GitHub, a template is provided that guides you through all relevant fields.
|
||||
Please inspect the file briefly before uploading it. An error log mainly contains technical information, but depending on the problem it may also include local file paths or other contextual data.
|
||||
|
||||
Some file and directory names still use the technical name `praktiKST`. They refer to the same program.
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
Special thanks to: Gianluca Costantino (IU3OAR), Alessandro Murador (IZ3VTH), Reczetár István (HA1FV), OM0AAO (Viliam Petrik, DX cluster idea), DC9DJ (Konrad Neitzel, project structure), DO5ALF (Andreas, webmaster funkerportal.de), PE0WGA (Franz van Velzen, tester) and all other testers and contributors.
|
||||
KST4Contest has benefited considerably from feedback gathered during actual contest operation.
|
||||
|
||||
Special thanks go to Gianluca Costantino (IU3OAR), Alessandro Murador (IZ3VTH), Reczetár István (HA1FV), Viliam Petrik (OM0AAO, DX Cluster idea), Konrad Neitzel (DC9DJ, project structure), Andreas (DO5ALF, webmaster of funkerportal.de), Franz van Velzen (PE0WGA, testing), and all other testers and contributors.
|
||||
@@ -2,219 +2,422 @@
|
||||
|
||||
> 🇬🇧 You are reading the English version | 🇩🇪 [Deutsche Version](de-Installation)
|
||||
|
||||
## Prerequisites
|
||||
KST4Contest is distributed as a ready-to-run application package for Windows, Linux and macOS. The official release packages do not require a separate Java installation: the required Java runtime is already included.
|
||||
|
||||
An resolution of 1200px by 720px is recommended
|
||||
In practical terms, you need a supported operating system, an internet connection and an ON4KST account. That sounds manageable—and usually it is.
|
||||
|
||||
### ON4KST Account
|
||||
## Requirements
|
||||
|
||||
To use the chat, a registered account with the ON4KST chat service is required:
|
||||
### ON4KST account
|
||||
|
||||
- Register at: http://www.on4kst.info/chat/register.php
|
||||
KST4Contest is an independent client for the ON4KST chat, but it does not replace the corresponding user account.
|
||||
|
||||
### Chat Etiquette
|
||||
If you do not have an account yet, you can create one on the official ON4KST website:
|
||||
|
||||
The official language in the ON4KST Chat is **English**. Please use English even when communicating with stations from your own country. Common HAM abbreviations (agn, dir, pse, rrr, tnx, 73 …) are widely used and understood.
|
||||
- [ON4KST login and registration](https://www.on4kst.org/chat/login.php)
|
||||
|
||||
### Personal Messages
|
||||
ON4KST specifies English as the common language for the chat. This also applies when both stations happen to share another language. During contests, the usual amateur radio abbreviations such as `pse`, `agn`, `qrg`, `dir`, `rrr`, `tnx` and `73` are widely used.
|
||||
|
||||
To send a private message to another station, always use the following format:
|
||||
Using the chat and sending personal messages are covered in the user interface chapter. For the installation, the only thing that matters at this point is that the account works.
|
||||
|
||||
```
|
||||
/CQ CALLSIGN message text
|
||||
```
|
||||
### Screen size
|
||||
|
||||
Example: `/CQ DL5ASG pse sked 144.205?`
|
||||
A usable screen area of approximately **1200 × 720 pixels** or more is recommended.
|
||||
|
||||
During heavy chat traffic (5–6 messages per second in a contest), public messages directed at a specific callsign are easily missed. However, KST4Contest also catches such messages if they are accidentally posted publicly (see [Features – PM Catching](Features#catching-personal-messages)).
|
||||
KST4Contest automatically adapts the main window to the available area of the primary screen. The application can therefore also be started on smaller displays. Less space is still less space, however: not all tables, filters and additional information can then be displayed at a useful size at the same time.
|
||||
|
||||
### Java
|
||||
|
||||
The ready-made packages from the [GitHub Releases](https://github.com/praktimarc/kst4contest/releases/latest) do not require a separate Java installation. The required runtime is distributed with KST4Contest.
|
||||
|
||||
A Java development environment is only required if you want to build KST4Contest from source. For the AUR packages `kst4contest` and `kst4contest-git`, Java 21 and Maven are handled as build dependencies by the package manager.
|
||||
|
||||
---
|
||||
|
||||
## Stable, Beta or Nightly?
|
||||
|
||||
KST4Contest is distributed through three development channels:
|
||||
|
||||
| Channel | Intended use | Classification |
|
||||
|---|---|---|
|
||||
| **Stable** | Normal contest operation | Published version intended for regular use |
|
||||
| **Beta** | Focused testing before a Stable release | Pre-release version with a largely defined feature set |
|
||||
| **Nightly** | Early testing of new features | Current development state from the `main` branch |
|
||||
|
||||
The **Stable version** is recommended for normal operation.
|
||||
|
||||
Beta and Nightly builds may contain features that are not yet available in the Stable release. They may also contain unfinished workflows, changed settings or new defects. That is not an unusual failure of the release process; it is the purpose of a development channel.
|
||||
|
||||
If a function in this manual is explicitly marked as **Nightly**, it is not necessarily part of the current Stable release yet.
|
||||
|
||||
---
|
||||
|
||||
## Download
|
||||
|
||||
### Windows
|
||||
The current Stable version is available here:
|
||||
|
||||
The latest version can be downloaded as a ZIP file:
|
||||
- [Latest KST4Contest release](https://github.com/praktimarc/kst4contest/releases/latest)
|
||||
- [All published releases](https://github.com/praktimarc/kst4contest/releases)
|
||||
|
||||
**https://github.com/praktimarc/kst4contest/releases/latest**
|
||||
The release page contains the application packages as well as the English and German PDF manuals.
|
||||
|
||||
The filename has the format `praktiKST-v<version_number>-windows-x64.zip`.
|
||||
### Which package do I need?
|
||||
|
||||
### Linux
|
||||
|
||||
Multiple package formats are available from the releases page:
|
||||
|
||||
**https://github.com/praktimarc/kst4contest/releases/latest**
|
||||
|
||||
| Format | Filename | Suitable for |
|
||||
| Operating system | Package | Typical filename |
|
||||
|---|---|---|
|
||||
| AppImage | `KST4Contest-v<version>-linux-x86_64.AppImage` | All distributions |
|
||||
| Debian package | `KST4Contest-v<version>-debian-amd64.deb` | Debian, Ubuntu, Linux Mint, … |
|
||||
| RPM package | `KST4Contest-v<version>-fedora-x86_64.rpm` | Fedora, RHEL, openSUSE, … |
|
||||
| Arch package | `KST4Contest-v<version>-archlinux-x86_64.pkg.tar.zst` | Arch Linux, Manjaro, … |
|
||||
| Flatpak | `de.x08.KST4Contest.flatpakref` | All distributions with Flatpak |
|
||||
|
||||
> **Recommended for Linux:** The Flatpak installation is the easiest way to stay up to date — `flatpak update` handles all future updates automatically. The repository is GPG-signed for security.
|
||||
|
||||
### macOS
|
||||
|
||||
> ⚠️ **Best-Effort Support:** macOS builds are provided as a convenience but are **not fully tested**. We build and release macOS binaries with every release, but we cannot test every scenario on macOS. If you encounter issues, please report them – we will do our best to address them, but cannot guarantee the same level of support as for Windows and Linux.
|
||||
|
||||
The latest version can be downloaded as a DMG disk image (available for both Apple Silicon and Intel Macs):
|
||||
|
||||
**https://github.com/praktimarc/kst4contest/releases/latest**
|
||||
|
||||
The filename has the format `KST4Contest-v<version_number>-macos-<arch>.dmg`, where `<arch>` is `arm64` (Apple Silicon) or `x86_64` (Intel).
|
||||
| Windows x64 | ZIP package | `praktiKST-v<version>-windows-x64.zip` |
|
||||
| Linux x86_64 | AppImage | `KST4Contest-v<version>-linux-x86_64.AppImage` |
|
||||
| Debian/Ubuntu amd64 | DEB package | `KST4Contest-v<version>-debian-amd64.deb` |
|
||||
| Fedora/RPM x86_64 | RPM package | `KST4Contest-v<version>-fedora-x86_64.rpm` |
|
||||
| Arch Linux x86_64 | Arch package | `KST4Contest-v<version>-archlinux-x86_64.pkg.tar.zst` |
|
||||
| Linux with Flatpak | Flatpak reference | `de.x08.KST4Contest.flatpakref` |
|
||||
| macOS Apple Silicon | DMG for ARM64 | `KST4Contest-v<version>-macos-arm64.dmg` |
|
||||
| macOS Intel | DMG for x86_64 | `KST4Contest-v<version>-macos-x86_64.dmg` |
|
||||
|
||||
Only download application packages from the official GitHub Releases, the KST4Contest Flatpak repository or the linked AUR packages. Files from other sources may have been built differently, may be outdated or may have been modified.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
## Installing on Windows
|
||||
|
||||
### Windows
|
||||
KST4Contest is distributed as a ZIP package for Windows. A conventional installer is not required.
|
||||
|
||||
1. Download the ZIP file.
|
||||
2. Unzip the ZIP file into a folder of your choice.
|
||||
3. Run `praktiKST.exe`.
|
||||
1. Download `praktiKST-v<version>-windows-x64.zip` from the latest release.
|
||||
2. Extract the ZIP file completely into a dedicated folder.
|
||||
3. Open the extracted folder.
|
||||
4. Start `praktiKST.exe`.
|
||||
|
||||
Settings are stored at `%USERPROFILE%\.praktikst\preferences.xml`.
|
||||
Do not start the application directly from the compressed ZIP file. KST4Contest consists of several files and a bundled runtime. Windows can only use this structure reliably after the archive has been extracted completely.
|
||||
|
||||
### Linux
|
||||
The application settings are not stored in the extracted program directory. They are stored in your user profile:
|
||||
|
||||
Settings are always stored at `~/.praktikst/preferences.xml`.
|
||||
```text
|
||||
%USERPROFILE%\.praktiKST\preferences.xml
|
||||
```
|
||||
|
||||
#### AppImage
|
||||
This allows a new application version to be extracted into a different directory without losing the existing settings.
|
||||
|
||||
1. Download the AppImage.
|
||||
2. Make it executable: `chmod +x KST4Contest-v<version>-linux-x86_64.AppImage`
|
||||
3. Run it.
|
||||
---
|
||||
|
||||
#### Debian / Ubuntu
|
||||
## Installing on Linux
|
||||
|
||||
Several Linux package formats are available. The appropriate choice depends less on KST4Contest itself than on your distribution and the way you prefer to manage updates.
|
||||
|
||||
| Installation method | Useful when … |
|
||||
|---|---|
|
||||
| **Flatpak** | updates should be managed centrally and Flatpak is already in use |
|
||||
| **AppImage** | KST4Contest should run as a portable file without package installation |
|
||||
| **DEB/RPM** | the distribution’s native package manager should be used |
|
||||
| **Arch package/AUR** | Arch Linux, Manjaro or EndeavourOS is being used |
|
||||
|
||||
### Flatpak
|
||||
|
||||
For most Linux users, Flatpak is the simplest way to keep KST4Contest installed and manageable through the system’s update tools. The KST4Contest repository is GPG-signed and contains the Stable, Beta and Nightly channels.
|
||||
|
||||
#### Installing Stable from the release file
|
||||
|
||||
Download `de.x08.KST4Contest.flatpakref` from the latest release and open it with the software manager provided by your desktop environment.
|
||||
|
||||
Alternatively, install it from a terminal:
|
||||
|
||||
```bash
|
||||
flatpak install ./de.x08.KST4Contest.flatpakref
|
||||
```
|
||||
|
||||
#### Adding the KST4Contest repository
|
||||
|
||||
The repository only needs to be added once:
|
||||
|
||||
```bash
|
||||
flatpak remote-add --if-not-exists kst4contest \
|
||||
https://praktimarc.github.io/kst4contest/kst4contest.flatpakrepo
|
||||
```
|
||||
|
||||
You can then install the Stable version:
|
||||
|
||||
```bash
|
||||
flatpak install kst4contest de.x08.KST4Contest//stable
|
||||
```
|
||||
|
||||
#### Installing Beta
|
||||
|
||||
```bash
|
||||
flatpak install kst4contest de.x08.KST4Contest//beta
|
||||
```
|
||||
|
||||
#### Installing Nightly
|
||||
|
||||
```bash
|
||||
flatpak install kst4contest de.x08.KST4Contest//nightly
|
||||
```
|
||||
|
||||
All three KST4Contest channels use the same application ID. Installing multiple KST4Contest channels in parallel is therefore not supported.
|
||||
|
||||
For example, to switch from Stable to Nightly:
|
||||
|
||||
```bash
|
||||
flatpak uninstall de.x08.KST4Contest//stable
|
||||
flatpak install kst4contest de.x08.KST4Contest//nightly
|
||||
```
|
||||
|
||||
This does not automatically remove the personal settings stored in `~/.praktiKST`.
|
||||
|
||||
Installed Flatpak applications can be updated with:
|
||||
|
||||
```bash
|
||||
flatpak update de.x08.KST4Contest
|
||||
```
|
||||
|
||||
Depending on the desktop environment, the graphical software manager may also display or automatically install available Flatpak updates. The `flatpak update` command itself performs an update; it does not promise to turn up and run by itself one day.
|
||||
|
||||
### AppImage
|
||||
|
||||
The AppImage does not require a conventional installation.
|
||||
|
||||
1. Download `KST4Contest-v<version>-linux-x86_64.AppImage`.
|
||||
2. Open a terminal in the download directory.
|
||||
3. Make the file executable:
|
||||
|
||||
```bash
|
||||
chmod +x KST4Contest-v<version>-linux-x86_64.AppImage
|
||||
```
|
||||
|
||||
4. Start KST4Contest:
|
||||
|
||||
```bash
|
||||
./KST4Contest-v<version>-linux-x86_64.AppImage
|
||||
```
|
||||
|
||||
The AppImage can then be moved to another location, such as `~/Applications`.
|
||||
|
||||
### Debian and Ubuntu
|
||||
|
||||
Install the DEB package with:
|
||||
|
||||
```bash
|
||||
sudo apt install ./KST4Contest-v<version>-debian-amd64.deb
|
||||
```
|
||||
|
||||
Or double-click the `.deb` file in your file manager.
|
||||
Alternatively, open the file with a graphical package manager.
|
||||
|
||||
#### Fedora / RHEL
|
||||
### Fedora and compatible RPM systems
|
||||
|
||||
Install the RPM package with:
|
||||
|
||||
```bash
|
||||
sudo dnf install ./KST4Contest-v<version>-fedora-x86_64.rpm
|
||||
```
|
||||
|
||||
#### Arch Linux
|
||||
### Arch Linux: installing the release package
|
||||
|
||||
The Arch package downloaded from the GitHub Release can be installed directly:
|
||||
|
||||
```bash
|
||||
sudo pacman -U KST4Contest-v<version>-archlinux-x86_64.pkg.tar.zst
|
||||
```
|
||||
|
||||
#### Flatpak
|
||||
### Arch Linux: installing from the AUR
|
||||
|
||||
Download `de.x08.KST4Contest.flatpakref` from the [latest release](https://github.com/praktimarc/kst4contest/releases/latest) and open it, or run:
|
||||
```bash
|
||||
flatpak install de.x08.KST4Contest.flatpakref
|
||||
```
|
||||
Three variants are available in the AUR:
|
||||
|
||||
Or add the remote manually (recommended for nightly/beta access):
|
||||
```bash
|
||||
flatpak remote-add --if-not-exists kst4contest https://praktimarc.github.io/kst4contest/kst4contest.flatpakrepo
|
||||
flatpak install kst4contest de.x08.KST4Contest
|
||||
```
|
||||
| Package | Content |
|
||||
|---|---|
|
||||
| [`kst4contest-bin`](https://aur.archlinux.org/packages/kst4contest-bin) | Pre-built package from the current Stable release |
|
||||
| [`kst4contest`](https://aur.archlinux.org/packages/kst4contest) | Stable release built locally from source |
|
||||
| [`kst4contest-git`](https://aur.archlinux.org/packages/kst4contest-git) | Current development state from the `main` branch |
|
||||
|
||||
#### Flatpak Channels (nightly / beta / stable)
|
||||
|
||||
The Flatpak repository provides three channels, all served from the same remote:
|
||||
|
||||
| Channel | Content | Built on |
|
||||
|---|---|---|
|
||||
| `stable` | Current stable version | Every normal release tag |
|
||||
| `beta` | Pre-release version | Tags prefixed with `beta-` |
|
||||
| `nightly` | Development build (main branch) | Every push to main / daily |
|
||||
|
||||
Add the remote once, then install the channel you want:
|
||||
For most users, `kst4contest-bin` is the straightforward option:
|
||||
|
||||
```bash
|
||||
flatpak remote-add --if-not-exists kst4contest https://praktimarc.github.io/kst4contest/kst4contest.flatpakrepo
|
||||
|
||||
# Stable (default)
|
||||
flatpak install kst4contest de.x08.KST4Contest
|
||||
|
||||
# Beta
|
||||
flatpak install kst4contest de.x08.KST4Contest//beta
|
||||
|
||||
# Nightly
|
||||
flatpak install kst4contest de.x08.KST4Contest//nightly
|
||||
yay -S kst4contest-bin
|
||||
```
|
||||
|
||||
> **Note:** Flatpak only allows one installed version per App-ID at a time. To switch channels, uninstall the current version first:
|
||||
> ```bash
|
||||
> flatpak uninstall de.x08.KST4Contest
|
||||
> flatpak install kst4contest de.x08.KST4Contest//nightly
|
||||
> ```
|
||||
To build the Stable release from source:
|
||||
|
||||
Updates work as usual with `flatpak update`.
|
||||
```bash
|
||||
yay -S kst4contest
|
||||
```
|
||||
|
||||
The `flatpakref` files for beta and stable are attached to their respective [GitHub Releases](https://github.com/praktimarc/kst4contest/releases). The nightly `flatpakref` (`de.x08.KST4Contest.nightly.flatpakref`) is available as an artifact on the [GitHub Actions](https://github.com/praktimarc/kst4contest/actions) tab (kept for 14 days) — for nightly builds, the manual remote approach above is usually more convenient.
|
||||
To build the current development state:
|
||||
|
||||
### macOS
|
||||
1. Download the DMG file for your architecture (Apple Silicon or Intel).
|
||||
```bash
|
||||
yay -S kst4contest-git
|
||||
```
|
||||
|
||||
All three packages provide the same application and are therefore defined as conflicting with one another. Install only one variant at a time.
|
||||
|
||||
AUR updates are included when the selected AUR helper checks for package updates, for example:
|
||||
|
||||
```bash
|
||||
yay -Syu
|
||||
```
|
||||
|
||||
They do not happen automatically merely because the package came from the AUR.
|
||||
|
||||
---
|
||||
|
||||
## Installing on macOS
|
||||
|
||||
> **Best-effort support:** The macOS packages are built together with the other releases, but they are not tested to the same extent as the Windows and Linux versions. Feedback is welcome; fully tested support for every macOS version and hardware variant cannot currently be guaranteed.
|
||||
|
||||
Apple Silicon Macs require the package marked `arm64`. Intel Macs require the package marked `x86_64`.
|
||||
|
||||
1. Download the appropriate DMG file.
|
||||
2. Open the DMG file.
|
||||
3. Drag `KST4Contest.app` into your **Applications** folder.
|
||||
4. On first launch, macOS may show a warning because the app is not notarised. To open it:
|
||||
- Right-click (or Control-click) on `KST4Contest.app` in Finder and choose **Open**.
|
||||
- Alternatively, go to **System Settings → Privacy & Security** and click **Open Anyway**.
|
||||
5. Run KST4Contest from your Applications folder or Launchpad.
|
||||
3. Drag `KST4Contest.app` into the **Applications** folder.
|
||||
4. Start KST4Contest from the Applications folder or Launchpad.
|
||||
|
||||
Settings are stored at `~/.praktikst/preferences.xml`.
|
||||
The application is not currently notarized by Apple. macOS may therefore block the first launch.
|
||||
|
||||
If the application came from the official GitHub Release:
|
||||
|
||||
1. Open the Applications folder in Finder.
|
||||
2. Right-click or Control-click `KST4Contest.app`.
|
||||
3. Select **Open**.
|
||||
4. Confirm the launch in the dialog that appears.
|
||||
|
||||
Alternatively, macOS may provide an **Open Anyway** button under **System Settings → Privacy & Security**.
|
||||
|
||||
---
|
||||
|
||||
## Updating
|
||||
## Where are the settings stored?
|
||||
|
||||
KST4Contest includes an **automatic update notification service**: as soon as a new version is available, a window will appear at startup with:
|
||||
- information that a new version is available,
|
||||
- a changelog,
|
||||
- the download link for the new version.
|
||||
KST4Contest stores its settings and other local working files in the user’s home directory. The application directory and the data directory are separate.
|
||||
|
||||

|
||||
| Operating system | Data directory | Settings file |
|
||||
|---|---|---|
|
||||
| Windows | `%USERPROFILE%\.praktiKST\` | `%USERPROFILE%\.praktiKST\preferences.xml` |
|
||||
| Linux | `~/.praktiKST/` | `~/.praktiKST/preferences.xml` |
|
||||
| macOS | `~/.praktiKST/` | `~/.praktiKST/preferences.xml` |
|
||||
|
||||
### Update Process
|
||||
|
||||
#### Windows
|
||||
|
||||
Currently, there is only one way to update:
|
||||
|
||||
1. Delete the old folder.
|
||||
2. Unzip the new ZIP file.
|
||||
|
||||
The settings file (`preferences.xml`) is preserved because it is stored in the user folder, not the program folder.
|
||||
|
||||
#### Linux
|
||||
|
||||
- **AppImage**: Download the new AppImage, make it executable (`chmod +x`), optionally delete the old one.
|
||||
- **Debian/Ubuntu**: `sudo apt install ./KST4Contest-v<version>-debian-amd64.deb`
|
||||
- **Fedora/RHEL**: `sudo dnf upgrade ./KST4Contest-v<version>-fedora-x86_64.rpm`
|
||||
- **Arch Linux**: `sudo pacman -U KST4Contest-v<version>-archlinux-x86_64.pkg.tar.zst`
|
||||
- **Flatpak (repository)**: `flatpak update` – updates all Flatpak apps including KST4Contest.
|
||||
|
||||
#### macOS
|
||||
|
||||
1. Download the new DMG file.
|
||||
2. Open the DMG.
|
||||
3. Drag the new `KST4Contest.app` into your **Applications** folder, replacing the old version.
|
||||
Note the spelling `.praktiKST` with an uppercase `KST`. Linux and macOS distinguish between uppercase and lowercase letters. `.praktikst` would simply be a different directory.
|
||||
|
||||
An application update does not remove this directory. Even so, creating a backup before a major version change or extensive configuration work is sensible.
|
||||
|
||||
---
|
||||
|
||||
## Known Issues at Startup
|
||||
## Updates
|
||||
|
||||
### Norton 360
|
||||
When KST4Contest starts, it checks whether a newer Stable release is available. If it finds one, it displays an information window containing:
|
||||
|
||||
Norton 360 classifies `praktiKST.exe` as dangerous (false positive). An exception must be created for the file:
|
||||
- the installed version,
|
||||
- the latest Stable version,
|
||||
- a short overview of the main changes,
|
||||
- the changelog,
|
||||
- known issues,
|
||||
- a link to the corresponding GitHub Release page.
|
||||
|
||||
1. Open Norton 360.
|
||||
2. Security → History → Find the corresponding event.
|
||||
3. Select "Restore & Add Exception".
|
||||

|
||||
|
||||
*(Reported by PE0WGA, Franz van Velzen – thank you!)*
|
||||
The update checker does not install anything. It reports the new version and opens the platform-neutral release page. From there, you must select the appropriate package for Windows, Linux or macOS.
|
||||
|
||||
### Updating Windows
|
||||
|
||||
1. Close KST4Contest.
|
||||
2. Download the new Windows ZIP package.
|
||||
3. Extract it into a new or empty directory.
|
||||
4. Start the new version.
|
||||
5. Check that the existing settings have been loaded.
|
||||
6. Remove the old application directory only after that.
|
||||
|
||||
The settings are preserved because they are stored under `%USERPROFILE%\.praktiKST`, not in the application directory.
|
||||
|
||||
### Updating an AppImage
|
||||
|
||||
1. Download the new AppImage.
|
||||
2. Make it executable.
|
||||
3. Start the new file.
|
||||
4. Remove the previous AppImage only after the new version works.
|
||||
|
||||
### Updating Debian and Ubuntu
|
||||
|
||||
```bash
|
||||
sudo apt install ./KST4Contest-v<version>-debian-amd64.deb
|
||||
```
|
||||
|
||||
### Updating Fedora
|
||||
|
||||
```bash
|
||||
sudo dnf upgrade ./KST4Contest-v<version>-fedora-x86_64.rpm
|
||||
```
|
||||
|
||||
### Updating the Arch package
|
||||
|
||||
```bash
|
||||
sudo pacman -U KST4Contest-v<version>-archlinux-x86_64.pkg.tar.zst
|
||||
```
|
||||
|
||||
### Updating an AUR package
|
||||
|
||||
Update the installed package through the selected AUR helper, for example:
|
||||
|
||||
```bash
|
||||
yay -Syu
|
||||
```
|
||||
|
||||
### Updating Flatpak
|
||||
|
||||
```bash
|
||||
flatpak update de.x08.KST4Contest
|
||||
```
|
||||
|
||||
### Updating macOS
|
||||
|
||||
1. Download the new DMG file for the appropriate architecture.
|
||||
2. Close KST4Contest.
|
||||
3. Open the DMG file.
|
||||
4. Replace `KST4Contest.app` in the Applications folder.
|
||||
5. Start the new version and check the existing settings.
|
||||
|
||||
The configuration under `~/.praktiKST` is not affected.
|
||||
|
||||
---
|
||||
|
||||
## Problems during the first launch
|
||||
|
||||
### Windows reports an unknown application
|
||||
|
||||
KST4Contest is not currently signed with a commercial Windows code-signing certificate. Windows or an additional security product may therefore warn about an unknown or rarely downloaded application.
|
||||
|
||||
Check the following first:
|
||||
|
||||
- Did the file come from the [official KST4Contest release](https://github.com/praktimarc/kst4contest/releases/latest)?
|
||||
- Does the filename match the published release?
|
||||
- Was the file downloaded completely?
|
||||
- Does the security product report a specific detection name or only a general reputation warning?
|
||||
|
||||
A warning alone is neither reliable proof of malware nor automatically a false positive. If the origin of the file is unclear, do not run it or restore it from quarantine.
|
||||
|
||||
Some users have reported quarantine messages from Norton 360 in particular. If the warning can be reproduced, please create a [GitHub issue](https://github.com/praktimarc/kst4contest/issues) and include:
|
||||
|
||||
- the KST4Contest version,
|
||||
- the complete filename,
|
||||
- the security product and its version,
|
||||
- the reported detection name,
|
||||
- a screenshot of the warning, if possible.
|
||||
|
||||
### The AppImage does not start
|
||||
|
||||
Check the executable permission first:
|
||||
|
||||
```bash
|
||||
chmod +x KST4Contest-v<version>-linux-x86_64.AppImage
|
||||
```
|
||||
|
||||
Then start the file from a terminal. Error messages shown there are usually more useful than a double-click that simply produces no visible result.
|
||||
|
||||
### macOS blocks the application
|
||||
|
||||
Use the **Open** function described under [Installing on macOS](#installing-on-macos). Before doing so, verify that the DMG file came from the official GitHub Release.
|
||||
|
||||
### The problem remains
|
||||
|
||||
First check whether the problem has already been reported under [GitHub Issues](https://github.com/praktimarc/kst4contest/issues). If it has not, create a new issue containing:
|
||||
|
||||
- the operating system and version,
|
||||
- the KST4Contest version,
|
||||
- the installation method,
|
||||
- the exact error message,
|
||||
- the steps required to reproduce the problem.
|
||||
|
||||
“It does not work” usually describes the situation accurately, but it is of limited value during diagnosis.
|
||||
|
After Width: | Height: | Size: 324 KiB |
|
After Width: | Height: | Size: 389 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 48 KiB |
@@ -0,0 +1,16 @@
|
||||
pkgbase = kst4contest-bin
|
||||
pkgdesc = ON4KST Chat Client for VHF/UHF contest operation (pre-built)
|
||||
pkgver = 1.41.1
|
||||
pkgrel = 1
|
||||
url = https://github.com/praktimarc/kst4contest
|
||||
arch = x86_64
|
||||
license = GPL-3.0-only
|
||||
depends = gst-plugins-base
|
||||
depends = gst-plugins-good
|
||||
provides = kst4contest
|
||||
conflicts = kst4contest
|
||||
conflicts = kst4contest-git
|
||||
source = KST4Contest-v1.41.1-archlinux-x86_64.pkg.tar.zst::https://github.com/praktimarc/kst4contest/releases/download/v1.41.1/KST4Contest-v1.41.1-archlinux-x86_64.pkg.tar.zst
|
||||
sha256sums = 8e9a53ff832920c9ef2733635b90c5a4ffcd57a2958aaa251e92bd031142c614
|
||||
|
||||
pkgname = kst4contest-bin
|
||||
@@ -0,0 +1,17 @@
|
||||
# Maintainer: Philipp Wagner <philipp@wagnersnetz.de>
|
||||
pkgname=kst4contest-bin
|
||||
pkgver=1.41.1
|
||||
pkgrel=1
|
||||
pkgdesc="ON4KST Chat Client for VHF/UHF contest operation (pre-built)"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/praktimarc/kst4contest"
|
||||
license=('GPL-3.0-only')
|
||||
depends=('gst-plugins-base' 'gst-plugins-good')
|
||||
provides=('kst4contest')
|
||||
conflicts=('kst4contest' 'kst4contest-git')
|
||||
source=("KST4Contest-v${pkgver}-archlinux-${CARCH}.pkg.tar.zst::https://github.com/praktimarc/kst4contest/releases/download/v${pkgver}/KST4Contest-v${pkgver}-archlinux-${CARCH}.pkg.tar.zst")
|
||||
sha256sums=('8e9a53ff832920c9ef2733635b90c5a4ffcd57a2958aaa251e92bd031142c614')
|
||||
|
||||
package() {
|
||||
cp -a "${srcdir}/usr" "${pkgdir}/"
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
pkgbase = kst4contest-git
|
||||
pkgdesc = ON4KST Chat Client for VHF/UHF contest operation (git)
|
||||
pkgver = 1.42.0.r145.gd885924
|
||||
pkgrel = 1
|
||||
url = https://github.com/praktimarc/kst4contest
|
||||
arch = x86_64
|
||||
license = GPL-3.0-only
|
||||
makedepends = java-environment=21
|
||||
makedepends = maven
|
||||
makedepends = git
|
||||
depends = gst-plugins-base
|
||||
depends = gst-plugins-good
|
||||
provides = kst4contest
|
||||
conflicts = kst4contest
|
||||
conflicts = kst4contest-bin
|
||||
source = kst4contest::git+https://github.com/praktimarc/kst4contest.git
|
||||
sha256sums = SKIP
|
||||
|
||||
pkgname = kst4contest-git
|
||||
@@ -0,0 +1,76 @@
|
||||
# Maintainer: Philipp Wagner <philipp@wagnersnetz.de>
|
||||
pkgname=kst4contest-git
|
||||
pkgver=1.42.0.r145.gd885924
|
||||
pkgrel=1
|
||||
pkgdesc="ON4KST Chat Client for VHF/UHF contest operation (git)"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/praktimarc/kst4contest"
|
||||
license=('GPL-3.0-only')
|
||||
depends=('gst-plugins-base' 'gst-plugins-good')
|
||||
makedepends=('java-environment=21' 'maven' 'git')
|
||||
provides=('kst4contest')
|
||||
conflicts=('kst4contest' 'kst4contest-bin')
|
||||
source=("kst4contest::git+https://github.com/praktimarc/kst4contest.git")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
pkgver() {
|
||||
cd "${srcdir}/kst4contest"
|
||||
BASE=$(grep -m1 '<version>' pom.xml \
|
||||
| sed 's/.*<version>\(.*\)<\/version>.*/\1/' | sed 's/[-.]nightly//')
|
||||
printf '%s.r%s.g%s' "${BASE}" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
||||
}
|
||||
|
||||
build() {
|
||||
cd "${srcdir}/kst4contest"
|
||||
|
||||
export JAVA_HOME=$(find /usr/lib/jvm -maxdepth 1 -name 'java-21-*' -type d | head -n 1)
|
||||
export PATH="${JAVA_HOME}/bin:${PATH}"
|
||||
|
||||
mvn -B -DskipTests package dependency:copy-dependencies \
|
||||
-DincludeScope=runtime \
|
||||
-DoutputDirectory=target/dist-libs
|
||||
|
||||
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
|
||||
|
||||
mkdir -p dist
|
||||
jpackage \
|
||||
--type app-image \
|
||||
--name KST4Contest \
|
||||
--input target/dist-libs \
|
||||
--main-jar app.jar \
|
||||
--main-class kst4contest.view.Kst4ContestApplication \
|
||||
--module-path target/dist-libs \
|
||||
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec \
|
||||
--dest dist
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "${srcdir}/kst4contest"
|
||||
|
||||
install -dm755 "${pkgdir}/usr/lib/KST4Contest"
|
||||
cp -a dist/KST4Contest/. "${pkgdir}/usr/lib/KST4Contest/"
|
||||
|
||||
install -dm755 "${pkgdir}/usr/bin"
|
||||
printf '#!/bin/sh\nexec /usr/lib/KST4Contest/bin/KST4Contest "$@"\n' \
|
||||
> "${pkgdir}/usr/bin/KST4Contest"
|
||||
chmod 755 "${pkgdir}/usr/bin/KST4Contest"
|
||||
|
||||
install -dm755 "${pkgdir}/usr/share/applications"
|
||||
cat > "${pkgdir}/usr/share/applications/KST4Contest.desktop" << 'EOF'
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=KST4Contest
|
||||
Comment=ON4KST Chat Client for VHF/UHF contest operation
|
||||
Exec=KST4Contest
|
||||
Icon=KST4Contest
|
||||
Categories=Network;HamRadio;
|
||||
Terminal=false
|
||||
EOF
|
||||
|
||||
if [[ -f "${pkgdir}/usr/lib/KST4Contest/lib/KST4Contest.png" ]]; then
|
||||
install -Dm644 "${pkgdir}/usr/lib/KST4Contest/lib/KST4Contest.png" \
|
||||
"${pkgdir}/usr/share/icons/hicolor/256x256/apps/KST4Contest.png"
|
||||
fi
|
||||
|
||||
install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
pkgbase = kst4contest
|
||||
pkgdesc = ON4KST Chat Client for VHF/UHF contest operation
|
||||
pkgver = 1.41.1
|
||||
pkgrel = 1
|
||||
url = https://github.com/praktimarc/kst4contest
|
||||
arch = x86_64
|
||||
license = GPL-3.0-only
|
||||
makedepends = java-environment=21
|
||||
makedepends = maven
|
||||
depends = gst-plugins-base
|
||||
depends = gst-plugins-good
|
||||
provides = kst4contest
|
||||
conflicts = kst4contest-bin
|
||||
conflicts = kst4contest-git
|
||||
source = kst4contest-1.41.1.tar.gz::https://github.com/praktimarc/kst4contest/archive/refs/tags/v1.41.1.tar.gz
|
||||
sha256sums = e96207a2d3fee19d35e34717f5312beb28bb087c040164e352337e749ce53b8d
|
||||
|
||||
pkgname = kst4contest
|
||||
@@ -0,0 +1,69 @@
|
||||
# Maintainer: Philipp Wagner <philipp@wagnersnetz.de>
|
||||
pkgname=kst4contest
|
||||
pkgver=1.41.1
|
||||
pkgrel=1
|
||||
pkgdesc="ON4KST Chat Client for VHF/UHF contest operation"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/praktimarc/kst4contest"
|
||||
license=('GPL-3.0-only')
|
||||
depends=('gst-plugins-base' 'gst-plugins-good')
|
||||
makedepends=('java-environment=21' 'maven')
|
||||
provides=('kst4contest')
|
||||
conflicts=('kst4contest-bin' 'kst4contest-git')
|
||||
source=("${pkgname}-${pkgver}.tar.gz::https://github.com/praktimarc/kst4contest/archive/refs/tags/v${pkgver}.tar.gz")
|
||||
sha256sums=('e96207a2d3fee19d35e34717f5312beb28bb087c040164e352337e749ce53b8d')
|
||||
|
||||
build() {
|
||||
cd "${srcdir}/kst4contest-${pkgver}"
|
||||
|
||||
export JAVA_HOME=$(find /usr/lib/jvm -maxdepth 1 -name 'java-21-*' -type d | head -n 1)
|
||||
export PATH="${JAVA_HOME}/bin:${PATH}"
|
||||
|
||||
mvn -B -DskipTests package dependency:copy-dependencies \
|
||||
-DincludeScope=runtime \
|
||||
-DoutputDirectory=target/dist-libs
|
||||
|
||||
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
|
||||
|
||||
mkdir -p dist
|
||||
jpackage \
|
||||
--type app-image \
|
||||
--name KST4Contest \
|
||||
--input target/dist-libs \
|
||||
--main-jar app.jar \
|
||||
--main-class kst4contest.view.Kst4ContestApplication \
|
||||
--module-path target/dist-libs \
|
||||
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec \
|
||||
--dest dist
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "${srcdir}/kst4contest-${pkgver}"
|
||||
|
||||
install -dm755 "${pkgdir}/usr/lib/KST4Contest"
|
||||
cp -a dist/KST4Contest/. "${pkgdir}/usr/lib/KST4Contest/"
|
||||
|
||||
install -dm755 "${pkgdir}/usr/bin"
|
||||
printf '#!/bin/sh\nexec /usr/lib/KST4Contest/bin/KST4Contest "$@"\n' \
|
||||
> "${pkgdir}/usr/bin/KST4Contest"
|
||||
chmod 755 "${pkgdir}/usr/bin/KST4Contest"
|
||||
|
||||
install -dm755 "${pkgdir}/usr/share/applications"
|
||||
cat > "${pkgdir}/usr/share/applications/KST4Contest.desktop" << 'EOF'
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=KST4Contest
|
||||
Comment=ON4KST Chat Client for VHF/UHF contest operation
|
||||
Exec=KST4Contest
|
||||
Icon=KST4Contest
|
||||
Categories=Network;HamRadio;
|
||||
Terminal=false
|
||||
EOF
|
||||
|
||||
if [[ -f "${pkgdir}/usr/lib/KST4Contest/lib/KST4Contest.png" ]]; then
|
||||
install -Dm644 "${pkgdir}/usr/lib/KST4Contest/lib/KST4Contest.png" \
|
||||
"${pkgdir}/usr/share/icons/hicolor/256x256/apps/KST4Contest.png"
|
||||
fi
|
||||
|
||||
install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<groupId>de.x08</groupId>
|
||||
<artifactId>praktiKST</artifactId>
|
||||
<version>1.41.0-nightly</version>
|
||||
<version>1.42.0-nightly</version>
|
||||
|
||||
<name>praktiKST</name>
|
||||
|
||||
@@ -150,20 +150,32 @@
|
||||
<version>${jetbrains.annotations.version}</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
|
||||
<!--
|
||||
<dependency>
|
||||
<groupId>xml-apis</groupId>
|
||||
<artifactId>xml-apis</artifactId>
|
||||
<version>2.0.2</version>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter-api</artifactId>
|
||||
<version>5.12.2</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-core</artifactId>
|
||||
<version>5.17.0</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>javax.xml.parsers</groupId>
|
||||
<artifactId>jaxp-api</artifactId>
|
||||
<version>1.4.5</version>
|
||||
</dependency>
|
||||
-->
|
||||
<!--
|
||||
<dependency>
|
||||
<groupId>xml-apis</groupId>
|
||||
<artifactId>xml-apis</artifactId>
|
||||
<version>2.0.2</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>javax.xml.parsers</groupId>
|
||||
<artifactId>jaxp-api</artifactId>
|
||||
<version>1.4.5</version>
|
||||
</dependency>
|
||||
-->
|
||||
|
||||
</dependencies>
|
||||
|
||||
|
||||
@@ -18,11 +18,18 @@ public class ApplicationConstants {
|
||||
public static final String APPLICATION_NAME = "praktiKST";
|
||||
|
||||
/**
|
||||
* Name of file to store preferences in.
|
||||
* Version shown to the user and used for semantic version comparison.
|
||||
*/
|
||||
public static final double APPLICATION_CURRENTVERSIONNUMBER = 1.41;
|
||||
public static final String APPLICATION_CURRENT_VERSION = "1.42";
|
||||
|
||||
public static final String VERSIONINFOURLFORUPDATES_KST4CONTEST = "https://do5amf.funkerportal.de/kst4ContestVersionInfo.xml";
|
||||
/**
|
||||
* Legacy numeric representation used only while older update feeds and
|
||||
* application versions still exist.
|
||||
*/
|
||||
@Deprecated
|
||||
public static final double APPLICATION_CURRENTVERSIONNUMBER = 1.42;
|
||||
|
||||
public static final String VERSIONINFOURLFORUPDATES_KST4CONTEST = "https://kst4contest.hamradioonline.de/kst4ContestVersionInfo.xml";
|
||||
public static final String VERSIONINFDOWNLOADEDLOCALFILE = "kst4ContestVersionInfo.xml";
|
||||
|
||||
public static final String STYLECSSFILE_DEFAULT_DAYLIGHT = "KST4ContestDefaultDay.css";
|
||||
@@ -36,7 +43,7 @@ public class ApplicationConstants {
|
||||
|
||||
public static final String DISCONNECT_RDR_POISONPILL = "UNKNOWN: KST4C KILL POISONPILL_KILLTHREAD=: " + sessionRuntimeUniqueId; //whereever a (blocking) udp or tcp reader in an infinite loop gets this message, it will break this loop
|
||||
|
||||
public static final String AUTOANSWER_PREFIX = "[KST4C Automsg] "; // hard-coded marker (user can't remove it)
|
||||
public static final String AUTOANSWER_PREFIX = "[KST4C Automsg]"; // hard-coded marker (user cannot remove it)
|
||||
|
||||
/**
|
||||
* UI message retention limits.
|
||||
|
||||
@@ -1,150 +1,198 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.DatagramPacket;
|
||||
import java.net.DatagramSocket;
|
||||
import java.net.InetAddress;
|
||||
import java.net.NoRouteToHostException;
|
||||
import java.net.SocketException;
|
||||
import java.net.UnknownHostException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.TimerTask;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import javafx.collections.ObservableList;
|
||||
import kst4contest.locatorUtils.Location;
|
||||
import kst4contest.model.ChatMember;
|
||||
|
||||
|
||||
/**
|
||||
* Sends periodical path requests and an AirScout watchlist for the currently
|
||||
* active ON4KST stations.
|
||||
*/
|
||||
public class AirScoutPeriodicalAPReflectionInquirerTask extends TimerTask {
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(AirScoutPeriodicalAPReflectionInquirerTask.class.getName());
|
||||
private ChatController client;
|
||||
private static final Logger LOGGER = Logger.getLogger(
|
||||
AirScoutPeriodicalAPReflectionInquirerTask.class.getName()
|
||||
);
|
||||
|
||||
public AirScoutPeriodicalAPReflectionInquirerTask(ChatController client) {
|
||||
private static final String BROADCAST_ADDRESS = "255.255.255.255";
|
||||
|
||||
private final ChatController client;
|
||||
|
||||
public AirScoutPeriodicalAPReflectionInquirerTask(
|
||||
ChatController client
|
||||
) {
|
||||
this.client = client;
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
|
||||
Thread.currentThread().setName("AirscoutPeriodicalReflectionInquirierTask");
|
||||
Thread.currentThread().setName(
|
||||
"AirscoutPeriodicalReflectionInquirerTask"
|
||||
);
|
||||
|
||||
String KSTClientsNameForQuery = this.client.getChatPreferences().getAirScout_asClientNameString();
|
||||
String ASServerNameStringForAnswer = this.client.getChatPreferences().getAirScout_asServerNameString();
|
||||
|
||||
//TODO: Manage prefixes kst and as via preferences file and instance
|
||||
//TODO: Check if locator is changeable via the preferences object, need to be correct if it changes
|
||||
DatagramSocket dsocket;
|
||||
|
||||
// String prefix_asSetpath ="ASSETPATH: \"KST\" \"AS\" "; //working original
|
||||
// String prefix_asWatchList = "ASWATCHLIST: \"KST\" \"AS\" "; //working original
|
||||
|
||||
String prefix_asSetpath ="ASSETPATH: \"" + this.client.getChatPreferences().getAirScout_asClientNameString() + "\" \"" + this.client.getChatPreferences().getAirScout_asServerNameString() + "\" ";
|
||||
String prefix_asWatchList = "ASWATCHLIST: \""+ this.client.getChatPreferences().getAirScout_asClientNameString()+ "\" \"" + this.client.getChatPreferences().getAirScout_asServerNameString() + "\" ";
|
||||
|
||||
String bandString = "1440000"; //TODO: this must variable in case of higher bands! ... default: 1440000
|
||||
// String myCallAndMyLocString = this.client.getChatPreferences().getStn_loginCallSign() + "," + this.client.getChatPreferences().getStn_loginLocatorMainCat(); //before fix 1.266
|
||||
|
||||
|
||||
String ownCallSign = this.client.getChatPreferences().getStn_loginCallSign();
|
||||
try {
|
||||
if (this.client.getChatPreferences().getStn_loginCallSign().contains("-")) {
|
||||
ownCallSign = this.client.getChatPreferences().getStn_loginCallSign().split("-")[0];
|
||||
} else {
|
||||
ownCallSign = this.client.getChatPreferences().getStn_loginCallSign();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.log(Level.SEVERE, "[ASPERIODICAL] Error parsing callsign", e);
|
||||
}
|
||||
String myCallAndMyLocString = ownCallSign + "," + this.client.getChatPreferences().getStn_loginLocatorMainCat(); //bugfix, Airscout do not process 9A1W-2 but 9A1W like formatted calls
|
||||
|
||||
|
||||
String suffix = ""; //"FOREIGNCALL,FOREIGNLOC " -- dont forget the space at the end!!!
|
||||
String asWatchListString = prefix_asWatchList + bandString + "," + myCallAndMyLocString;
|
||||
String asWatchListStringSuffix = asWatchListString;
|
||||
|
||||
String host = "255.255.255.255";
|
||||
// int port = 9872;
|
||||
|
||||
int port = client.getChatPreferences().getAirScout_asCommunicationPort();
|
||||
// byte[] message = "ASSETPATH: \"KST\" \"AS\" 1440000,DO5AMF,JN49GL,OK1MZM,JN89IW ".getBytes(); Original, ging
|
||||
InetAddress address;
|
||||
|
||||
|
||||
/**
|
||||
* Iterate over chatmemberlist and asking airscout for plane reflection information
|
||||
* To avoid a concurrentmodifyexception, we have to convert the original list to an array at first
|
||||
* since the iterator brakes if the list changing during the iteration time
|
||||
/*
|
||||
* Keep the scheduled task installed so that AirScout can be enabled at
|
||||
* runtime, but do not send anything while the integration is disabled.
|
||||
*/
|
||||
ObservableList<ChatMember> praktiKSTActiveUserList = this.client.getLst_chatMemberList();
|
||||
|
||||
|
||||
ChatMember[] ary_threadSafeChatMemberArray = new ChatMember[praktiKSTActiveUserList.size()];
|
||||
praktiKSTActiveUserList.toArray(ary_threadSafeChatMemberArray);
|
||||
|
||||
for (ChatMember i : ary_threadSafeChatMemberArray) {
|
||||
if (!client.getChatPreferences().isAirScout_asUDPListenerEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (i.getQrb() < this.client.getChatPreferences().getStn_maxQRBDefault())
|
||||
//Here: check if maximum distance to the chatmember is reached, only ask AS if distance is lower!
|
||||
//this counts for AS request and Aswatchlist
|
||||
{
|
||||
suffix = i.getCallSign() + "," + i.getQra() + " ";
|
||||
String clientIdentifier =
|
||||
client.getChatPreferences().getAirScout_asClientNameString();
|
||||
String serverIdentifier =
|
||||
client.getChatPreferences().getAirScout_asServerNameString();
|
||||
String bandValue =
|
||||
client.getChatPreferences().getAirScout_asBandString();
|
||||
|
||||
String queryStringToAirScout = "";
|
||||
String ownCallSign = normalizeOwnCallSign(
|
||||
client.getChatPreferences().getStn_loginCallSign()
|
||||
);
|
||||
String ownLocator =
|
||||
client.getChatPreferences().getStn_loginLocatorMainCat();
|
||||
|
||||
queryStringToAirScout += prefix_asSetpath + bandString + "," + myCallAndMyLocString + "," + suffix;
|
||||
if (ownCallSign == null
|
||||
|| ownCallSign.isBlank()
|
||||
|| ownLocator == null
|
||||
|| ownLocator.isBlank()) {
|
||||
LOGGER.warning(
|
||||
"AirScout queries were skipped because the own callsign "
|
||||
+ "or locator is missing."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] queryStringToAirScoutMSG = queryStringToAirScout.getBytes();
|
||||
String setPathPrefix =
|
||||
"ASSETPATH: \"" + clientIdentifier
|
||||
+ "\" \"" + serverIdentifier + "\" ";
|
||||
|
||||
try {
|
||||
address = InetAddress.getByName("255.255.255.255");
|
||||
DatagramPacket packet = new DatagramPacket(queryStringToAirScoutMSG, queryStringToAirScoutMSG.length, address, port);
|
||||
dsocket = new DatagramSocket();
|
||||
dsocket.setBroadcast(true);
|
||||
dsocket.send(packet);
|
||||
dsocket.close();
|
||||
} catch (UnknownHostException e1) {
|
||||
LOGGER.log(Level.SEVERE, "[ASPERIODICAL] Unknown host", e1);
|
||||
} catch (NoRouteToHostException e) {
|
||||
LOGGER.log(Level.SEVERE, "[ASPERIODICAL] No route to host", e);
|
||||
} catch (SocketException e) {
|
||||
LOGGER.log(Level.SEVERE, "[ASPERIODICAL] Socket error", e);
|
||||
} catch (IOException e) {
|
||||
LOGGER.log(Level.SEVERE, "[ASPERIODICAL] IO error sending query", e);
|
||||
String watchListPrefix =
|
||||
"ASWATCHLIST: \"" + clientIdentifier
|
||||
+ "\" \"" + serverIdentifier + "\" ";
|
||||
|
||||
String ownStation = ownCallSign + "," + ownLocator;
|
||||
StringBuilder watchListMessage = new StringBuilder(
|
||||
watchListPrefix
|
||||
+ bandValue
|
||||
+ ","
|
||||
+ ownStation
|
||||
);
|
||||
|
||||
List<ChatMember> activeMembers = client.snapshotChatMembers();
|
||||
int port = client.getChatPreferences()
|
||||
.getAirScout_asCommunicationPort();
|
||||
|
||||
try (
|
||||
DatagramSocket socket = new DatagramSocket()
|
||||
) {
|
||||
socket.setBroadcast(true);
|
||||
InetAddress broadcastAddress =
|
||||
InetAddress.getByName(BROADCAST_ADDRESS);
|
||||
|
||||
for (ChatMember member : activeMembers) {
|
||||
if (!isUsableAirScoutTarget(member)) {
|
||||
continue;
|
||||
}
|
||||
// System.out.println("[ASUDPTask, info:] sent query " + queryStringToAirScout);
|
||||
|
||||
asWatchListStringSuffix += "," + i.getCallSign() + "," + i.getQra();
|
||||
if (member.getQrb()
|
||||
>= client.getChatPreferences().getStn_maxQRBDefault()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String targetStation =
|
||||
member.getCallSign() + "," + member.getQra();
|
||||
|
||||
String pathQuery =
|
||||
setPathPrefix
|
||||
+ bandValue
|
||||
+ ","
|
||||
+ ownStation
|
||||
+ ","
|
||||
+ targetStation
|
||||
+ " ";
|
||||
|
||||
sendPacket(
|
||||
socket,
|
||||
broadcastAddress,
|
||||
port,
|
||||
pathQuery
|
||||
);
|
||||
|
||||
watchListMessage
|
||||
.append(",")
|
||||
.append(targetStation);
|
||||
}
|
||||
|
||||
watchListMessage.append(" ");
|
||||
|
||||
sendPacket(
|
||||
socket,
|
||||
broadcastAddress,
|
||||
port,
|
||||
watchListMessage.toString()
|
||||
);
|
||||
} catch (IOException exception) {
|
||||
LOGGER.log(
|
||||
Level.WARNING,
|
||||
"Could not send the periodical AirScout queries.",
|
||||
exception
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* As next we will set the ASWatchlist. All stations in chat will be watched by airscout causing following code.\n\n
|
||||
* ASWATCHLIST: "KST" "AS" 4320000,DO5AMF,JN49GL,DF9QX,JO42HD,DG2KBC,JN58MI,DJ0PY,JO32MF,DL1YDI,JO42FA,DL6BF,JO32QI,F1NZC,JN15MR,F4TXU,JN23CX,F5GHP,IN96LE,F6HTJ,JN12KQ,G0GGG,IO81VE,G0JCC,IO82MA,G0JDL,JO02SI,G0MBL,JO01QH,G4AEP,IO91MB,G4CLA,IO92JL,G4DCV,IO91OF,G4LOH,IO70JC,G4MKF,IO91HJ,G4TRA,IO81WN,G8GXP,IO93FQ,G8VHI,IO92FM,GW0RHC,IO71UN,HA4ND,JN97MJ,I5/HB9SJV/P,JN52JS,IW2DAL,JN45NN,OK1FPR,JO80CE,OK6M,JN99CR,OV3T,JO46CM,OZ2M,JO65FR,PA0V,JO33II,PA2RU,JO32LT,PA3DOL,JO22MT,PA9R,JO22JK,PE1EVX,JO22MP,S51AT,JN75GW,SM7KOJ,JO66ND,SP9TTG,JO90KW�
|
||||
* The watchlist-String is bult by the for loop which builds the AP queries
|
||||
*/
|
||||
asWatchListStringSuffix += " ";
|
||||
|
||||
byte[] queryStringToAirScoutMSG = asWatchListStringSuffix.getBytes();
|
||||
|
||||
try {
|
||||
address = InetAddress.getByName("255.255.255.255");
|
||||
DatagramPacket packet = new DatagramPacket(queryStringToAirScoutMSG, queryStringToAirScoutMSG.length, address, port);
|
||||
dsocket = new DatagramSocket();
|
||||
dsocket.setBroadcast(true);
|
||||
dsocket.send(packet);
|
||||
dsocket.close();
|
||||
} catch (IOException e) {
|
||||
LOGGER.log(Level.SEVERE, "[ASPERIODICAL] IO error sending watchlist", e);
|
||||
}
|
||||
|
||||
// System.out.println("[ASUDPTask, info:] set watchlist: " + asWatchListStringSuffix);
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
/**
|
||||
* Removes the ON4KST login suffix because AirScout expects the actual
|
||||
* station callsign, for example 9A1W instead of 9A1W-2.
|
||||
*
|
||||
* @param callSign configured ON4KST login callsign
|
||||
* @return callsign without an ON4KST login suffix
|
||||
*/
|
||||
private String normalizeOwnCallSign(String callSign) {
|
||||
if (callSign == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String normalizedCallSign = callSign.trim();
|
||||
int suffixSeparator = normalizedCallSign.indexOf("-");
|
||||
|
||||
if (suffixSeparator > 0) {
|
||||
return normalizedCallSign.substring(0, suffixSeparator);
|
||||
}
|
||||
|
||||
return normalizedCallSign;
|
||||
}
|
||||
|
||||
private boolean isUsableAirScoutTarget(ChatMember member) {
|
||||
return member != null
|
||||
&& member.getCallSign() != null
|
||||
&& !member.getCallSign().isBlank()
|
||||
&& member.getQra() != null
|
||||
&& !member.getQra().isBlank();
|
||||
}
|
||||
|
||||
private void sendPacket(
|
||||
DatagramSocket socket,
|
||||
InetAddress address,
|
||||
int port,
|
||||
String message
|
||||
) throws IOException {
|
||||
byte[] payload = message.getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
DatagramPacket packet = new DatagramPacket(
|
||||
payload,
|
||||
payload.length,
|
||||
address,
|
||||
port
|
||||
);
|
||||
|
||||
socket.send(packet);
|
||||
}
|
||||
}
|
||||
@@ -1,121 +1,178 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.TimerTask;
|
||||
|
||||
import kst4contest.model.ChatMessage;
|
||||
import kst4contest.model.ThreadStateMessage;
|
||||
|
||||
/**
|
||||
* This class is for sending beacons intervalled to the public chat. Gets all
|
||||
* preferences and instances via the Chatpreferences-object of the
|
||||
* Chatcontroller.
|
||||
* <br/><br/>
|
||||
* The task will be runned out of the singleton ChatController instance in an
|
||||
* intervall as specified by the Chatpreferences-instance (typically as
|
||||
* configured in the xml file.
|
||||
*
|
||||
*
|
||||
* @author prakt
|
||||
* Sends the configured public-chat beacons for both active chat categories.
|
||||
*
|
||||
* <p>Both categories deliberately share one timer and therefore the same
|
||||
* interval. Their enable flags and message templates remain independent. Every
|
||||
* run reads the current preferences, resolves global message variables and
|
||||
* sends only the categories which are currently enabled.</p>
|
||||
*/
|
||||
public class BeaconTask extends TimerTask {
|
||||
|
||||
private ChatController chatController;
|
||||
private ThreadStatusCallback callBackToController;
|
||||
private String ThreadNickName = "MyBeacon";
|
||||
private static final String THREAD_NICKNAME = "MyBeacon";
|
||||
|
||||
public BeaconTask(ChatController client, ThreadStatusCallback callback) {
|
||||
this.callBackToController = callback;
|
||||
this.chatController = client;
|
||||
private final ChatController chatController;
|
||||
private final ThreadStatusCallback callbackToController;
|
||||
|
||||
/**
|
||||
* Creates one execution of the shared beacon timer.
|
||||
*
|
||||
* @param chatController controller providing preferences and the TX queue
|
||||
* @param callbackToController callback used by the thread-status display
|
||||
*/
|
||||
public BeaconTask(
|
||||
ChatController chatController,
|
||||
ThreadStatusCallback callbackToController
|
||||
) {
|
||||
this.chatController = chatController;
|
||||
this.callbackToController = callbackToController;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
Thread.currentThread().setName("BeaconTask");
|
||||
reportStatus(THREAD_NICKNAME, true, "initialized", false);
|
||||
|
||||
ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "initialized", false);
|
||||
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
|
||||
|
||||
Thread.currentThread().setName("BeaconTask");
|
||||
|
||||
|
||||
ChatMessage beaconMSG = new ChatMessage();
|
||||
|
||||
String replaceVariables = this.chatController.getChatPreferences().getBcn_beaconTextMainCat();
|
||||
|
||||
replaceVariables = replaceVariables.replaceAll("MYQRG", this.chatController.getChatPreferences().getMYQRGFirstCat().getValue());
|
||||
replaceVariables = replaceVariables.replaceAll("MYCALL", this.chatController.getChatPreferences().getStn_loginCallSign());
|
||||
replaceVariables = replaceVariables.replaceAll("MYLOCATOR", this.chatController.getChatPreferences().getStn_loginLocatorMainCat());
|
||||
replaceVariables = replaceVariables.replaceAll("MYQTF", this.chatController.getChatPreferences().getActualQTF().getValue() + "");
|
||||
replaceVariables = replaceVariables.replaceAll("SECONDQRG", this.chatController.getChatPreferences().getActualQTF().getValue() + "");
|
||||
|
||||
|
||||
beaconMSG.setMessageText(
|
||||
"MSG|" + this.chatController.getChatPreferences().getLoginChatCategoryMain().getCategoryNumber() + "|0|" + replaceVariables + "|0|");
|
||||
beaconMSG.setMessageDirectedToServer(true);
|
||||
|
||||
|
||||
|
||||
|
||||
ChatMessage beaconMSG2 = new ChatMessage();
|
||||
|
||||
String replaceVariables2 = this.chatController.getChatPreferences().getBcn_beaconTextSecondCat();
|
||||
|
||||
replaceVariables2 = replaceVariables2.replaceAll("MYQRG", this.chatController.getChatPreferences().getMYQRGFirstCat().getValue());
|
||||
replaceVariables2 = replaceVariables2.replaceAll("MYCALL", this.chatController.getChatPreferences().getStn_loginCallSign());
|
||||
replaceVariables2 = replaceVariables2.replaceAll("MYLOCATOR", this.chatController.getChatPreferences().getStn_loginLocatorMainCat());
|
||||
replaceVariables2 = replaceVariables2.replaceAll("MYQTF", this.chatController.getChatPreferences().getActualQTF().getValue() + "");
|
||||
replaceVariables2 = replaceVariables2.replaceAll("SECONDQRG", this.chatController.getChatPreferences().getMYQRGSecondCat().getValue() + "");
|
||||
|
||||
|
||||
beaconMSG2.setMessageText(
|
||||
"MSG|" + this.chatController.getChatPreferences().getLoginChatCategorySecond().getCategoryNumber() + "|0|" + replaceVariables + "|0|");
|
||||
beaconMSG2.setMessageDirectedToServer(true);
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* beacon 1st Chatcategory
|
||||
*/
|
||||
if (this.chatController.getChatPreferences().isBcn_beaconsEnabledMainCat() ) {
|
||||
|
||||
System.out.println(new Utils4KST().time_generateCurrentMMDDhhmmTimeString()
|
||||
+ " [BeaconTask, Info]: Sending CQ: " + beaconMSG.getMessageText());
|
||||
this.chatController.getMessageTXBus().add(beaconMSG);
|
||||
|
||||
threadStateMessage = new ThreadStateMessage(this.ThreadNickName + " 1", true, "on", false);
|
||||
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
|
||||
|
||||
} else {
|
||||
threadStateMessage = new ThreadStateMessage(this.ThreadNickName + " 1", false, "off", false);
|
||||
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* beacon 2nd Chatcategory
|
||||
*/
|
||||
if (this.chatController.getChatPreferences().isLoginToSecondChatEnabled()) { //only send if 2nd cat enabled
|
||||
|
||||
if (this.chatController.getChatPreferences().isBcn_beaconsEnabledSecondCat()) {
|
||||
|
||||
beaconMSG2.setMessageText(
|
||||
"MSG|" + this.chatController.getChatPreferences().getLoginChatCategorySecond().getCategoryNumber() + "|0|" + replaceVariables2 + "|0|");
|
||||
beaconMSG2.setMessageDirectedToServer(true);
|
||||
|
||||
System.out.println(new Utils4KST().time_generateCurrentMMDDhhmmTimeString()
|
||||
+ " [BeaconTask, Info]: Sending CQ 2nd Cat: " + beaconMSG2.getMessageText());
|
||||
this.chatController.getMessageTXBus().add(beaconMSG2);
|
||||
|
||||
threadStateMessage = new ThreadStateMessage(this.ThreadNickName + " 2", true, "on", false);
|
||||
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
|
||||
|
||||
} else {
|
||||
threadStateMessage = new ThreadStateMessage(this.ThreadNickName + " 2", false, "off", false);
|
||||
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
|
||||
}
|
||||
}
|
||||
MessageVariableResolver variableResolver =
|
||||
new MessageVariableResolver(chatController.getChatPreferences());
|
||||
|
||||
sendMainCategoryBeacon(variableResolver);
|
||||
sendSecondCategoryBeacon(variableResolver);
|
||||
}
|
||||
|
||||
}
|
||||
/**
|
||||
* Sends the main-category beacon if it is currently enabled.
|
||||
*/
|
||||
private void sendMainCategoryBeacon(MessageVariableResolver variableResolver) {
|
||||
if (!chatController.getChatPreferences().isBcn_beaconsEnabledMainCat()) {
|
||||
reportStatus(THREAD_NICKNAME + " 1", false, "off", false);
|
||||
return;
|
||||
}
|
||||
|
||||
String resolvedText = variableResolver.resolveGlobalVariables(
|
||||
chatController.getChatPreferences().getBcn_beaconTextMainCat()
|
||||
);
|
||||
ChatMessage beaconMessage = buildBeaconMessage(
|
||||
chatController.getChatPreferences()
|
||||
.getLoginChatCategoryMain()
|
||||
.getCategoryNumber(),
|
||||
resolvedText,
|
||||
"main category"
|
||||
);
|
||||
|
||||
if (beaconMessage == null) {
|
||||
reportStatus(THREAD_NICKNAME + " 1", false, "invalid text", true);
|
||||
return;
|
||||
}
|
||||
|
||||
System.out.println(
|
||||
new Utils4KST().time_generateCurrentMMDDhhmmTimeString()
|
||||
+ " [BeaconTask, Info]: Sending main-category CQ: "
|
||||
+ beaconMessage.getMessageText()
|
||||
);
|
||||
chatController.getMessageTXBus().add(beaconMessage);
|
||||
reportStatus(THREAD_NICKNAME + " 1", true, "on", false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the second-category beacon if the second login and its beacon are
|
||||
* currently enabled.
|
||||
*/
|
||||
private void sendSecondCategoryBeacon(
|
||||
MessageVariableResolver variableResolver
|
||||
) {
|
||||
if (!chatController.getChatPreferences().isLoginToSecondChatEnabled()
|
||||
|| !chatController.getChatPreferences()
|
||||
.isBcn_beaconsEnabledSecondCat()) {
|
||||
reportStatus(THREAD_NICKNAME + " 2", false, "off", false);
|
||||
return;
|
||||
}
|
||||
|
||||
String resolvedText = variableResolver.resolveGlobalVariables(
|
||||
chatController.getChatPreferences().getBcn_beaconTextSecondCat()
|
||||
);
|
||||
ChatMessage beaconMessage = buildBeaconMessage(
|
||||
chatController.getChatPreferences()
|
||||
.getLoginChatCategorySecond()
|
||||
.getCategoryNumber(),
|
||||
resolvedText,
|
||||
"second category"
|
||||
);
|
||||
|
||||
if (beaconMessage == null) {
|
||||
reportStatus(THREAD_NICKNAME + " 2", false, "invalid text", true);
|
||||
return;
|
||||
}
|
||||
|
||||
System.out.println(
|
||||
new Utils4KST().time_generateCurrentMMDDhhmmTimeString()
|
||||
+ " [BeaconTask, Info]: Sending second-category CQ: "
|
||||
+ beaconMessage.getMessageText()
|
||||
);
|
||||
chatController.getMessageTXBus().add(beaconMessage);
|
||||
reportStatus(THREAD_NICKNAME + " 2", true, "on", false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the server-directed message after validating the resolved payload.
|
||||
*
|
||||
* <p>The resolved text is checked rather than only the configured template
|
||||
* because inserted values can increase the final message length.</p>
|
||||
*
|
||||
* @param categoryNumber ON4KST category number
|
||||
* @param resolvedText fully resolved beacon payload
|
||||
* @param categoryDescription text used in diagnostic output
|
||||
* @return prepared message, or {@code null} if the payload is invalid
|
||||
*/
|
||||
private ChatMessage buildBeaconMessage(
|
||||
int categoryNumber,
|
||||
String resolvedText,
|
||||
String categoryDescription
|
||||
) {
|
||||
if (resolvedText == null
|
||||
|| resolvedText.length() > ChatController.MAX_BEACON_TEXT_LENGTH) {
|
||||
int actualLength = resolvedText == null ? 0 : resolvedText.length();
|
||||
System.out.println(
|
||||
"[BeaconTask, Warning]: Beacon for "
|
||||
+ categoryDescription
|
||||
+ " was not sent because the resolved text contains "
|
||||
+ actualLength
|
||||
+ " characters; maximum is "
|
||||
+ ChatController.MAX_BEACON_TEXT_LENGTH
|
||||
+ "."
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
ChatMessage beaconMessage = new ChatMessage();
|
||||
beaconMessage.setMessageText(
|
||||
"MSG|" + categoryNumber + "|0|" + resolvedText + "|0|"
|
||||
);
|
||||
beaconMessage.setMessageDirectedToServer(true);
|
||||
return beaconMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forwards one state update to the existing thread-status display.
|
||||
*/
|
||||
private void reportStatus(
|
||||
String threadName,
|
||||
boolean running,
|
||||
String information,
|
||||
boolean criticalState
|
||||
) {
|
||||
ThreadStateMessage stateMessage = new ThreadStateMessage(
|
||||
threadName,
|
||||
running,
|
||||
information,
|
||||
criticalState
|
||||
);
|
||||
callbackToController.onThreadStatus(THREAD_NICKNAME, stateMessage);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import kst4contest.logic.PriorityCalculator;
|
||||
import kst4contest.model.*;
|
||||
import kst4contest.test.MockKstServer;
|
||||
import kst4contest.utils.PlayAudioUtils;
|
||||
import kst4contest.locatorUtils.Location;
|
||||
import kst4contest.view.Kst4ContestApplication;
|
||||
|
||||
import java.io.*;
|
||||
@@ -31,6 +32,8 @@ import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Predicate;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -56,6 +59,10 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
private static final boolean DEBUG_BAND_UPGRADE_HINT = true; //for new band hint
|
||||
|
||||
|
||||
public static final int MIN_BEACON_INTERVAL_MINUTES = 1;
|
||||
public static final int MAX_BEACON_TEXT_LENGTH = 120;
|
||||
private static final long INITIAL_BEACON_DELAY_MILLIS = 10_000L;
|
||||
|
||||
private PstRotatorClient rotatorClient;
|
||||
private Consumer<Double> viewRotorCallback;
|
||||
|
||||
@@ -161,14 +168,16 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
|
||||
|
||||
|
||||
public void initRotor() {
|
||||
// Beispiel: PSTRotator läuft lokal auf Port 12060
|
||||
// Der Client wird automatisch auf 12061 hören.
|
||||
rotatorClient = new PstRotatorClient("127.0.0.1", 12000, this, this); //TODO: IP anpassen, Port auch aus den prefs holen, default 12000
|
||||
public void initRotor() {
|
||||
rotatorClient = new PstRotatorClient(
|
||||
chatPreferences.getStn_pstRotatorHost(),
|
||||
chatPreferences.getStn_pstRotatorPort(),
|
||||
this,
|
||||
this
|
||||
);
|
||||
|
||||
// Startet den Thread und das Polling
|
||||
rotatorClient.start();
|
||||
}
|
||||
rotatorClient.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* sets rotator to "AZ DEGREE" by button click <br/><br/>
|
||||
@@ -318,33 +327,29 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
|
||||
EnumSet<Band> offered = EnumSet.noneOf(Band.class);
|
||||
|
||||
synchronized (getLst_chatMemberList()) {
|
||||
for (ChatMember cm : getLst_chatMemberList()) {
|
||||
if (cm == null || cm.getCallSignRaw() == null) continue;
|
||||
if (!normalizeCallRaw(cm.getCallSignRaw()).equals(callRaw)) continue;
|
||||
for (ChatMember cm : findActiveChatMembersByRawCall(callRaw)) {
|
||||
if (cm == null || cm.getCallSignRaw() == null) continue;
|
||||
|
||||
Map<Band, ChatMember.ActiveFrequencyInfo> map = cm.getKnownActiveBands();
|
||||
if (map == null || map.isEmpty()) continue;
|
||||
Map<Band, ChatMember.ActiveFrequencyInfo> map = cm.getKnownActiveBands();
|
||||
if (map == null || map.isEmpty()) continue;
|
||||
|
||||
for (Map.Entry<Band, ChatMember.ActiveFrequencyInfo> e : map.entrySet()) {
|
||||
if (e.getKey() == null || e.getValue() == null) continue;
|
||||
|
||||
long age = nowMs - e.getValue().timestampEpoch;
|
||||
if (age >= 0 && age <= maxAgeMs) {
|
||||
offered.add(e.getKey());
|
||||
}
|
||||
|
||||
if (DEBUG_BAND_UPGRADE_HINT) {
|
||||
System.out.println("[BandUpgradeHint] history call=" + callRaw
|
||||
+ " band=" + e.getKey()
|
||||
+ " freq=" + e.getValue().frequency
|
||||
+ " ageMs=" + age);
|
||||
}
|
||||
for (Map.Entry<Band, ChatMember.ActiveFrequencyInfo> e : map.entrySet()) {
|
||||
if (e.getKey() == null || e.getValue() == null) continue;
|
||||
|
||||
long age = nowMs - e.getValue().timestampEpoch;
|
||||
if (age >= 0 && age <= maxAgeMs) {
|
||||
offered.add(e.getKey());
|
||||
}
|
||||
|
||||
if (DEBUG_BAND_UPGRADE_HINT) {
|
||||
System.out.println("[BandUpgradeHint] history call=" + callRaw
|
||||
+ " band=" + e.getKey()
|
||||
+ " freq=" + e.getValue().frequency
|
||||
+ " ageMs=" + age);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return offered;
|
||||
}
|
||||
|
||||
@@ -356,21 +361,19 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
|
||||
EnumSet<Band> worked = EnumSet.noneOf(Band.class);
|
||||
|
||||
synchronized (getLst_chatMemberList()) {
|
||||
for (ChatMember cm : getLst_chatMemberList()) {
|
||||
if (cm == null || cm.getCallSignRaw() == null) continue;
|
||||
if (!normalizeCallRaw(cm.getCallSignRaw()).equals(callRaw)) continue;
|
||||
for (ChatMember cm : findActiveChatMembersByRawCall(callRaw)) {
|
||||
if (cm == null || cm.getCallSignRaw() == null) continue;
|
||||
|
||||
if (cm.isWorked144()) worked.add(Band.B_144);
|
||||
if (cm.isWorked432()) worked.add(Band.B_432);
|
||||
if (cm.isWorked1240()) worked.add(Band.B_1296);
|
||||
if (cm.isWorked2300()) worked.add(Band.B_2320);
|
||||
if (cm.isWorked3400()) worked.add(Band.B_3400);
|
||||
if (cm.isWorked5600()) worked.add(Band.B_5760);
|
||||
if (cm.isWorked10G()) worked.add(Band.B_10G);
|
||||
if (cm.isWorked24G()) worked.add(Band.B_24G); // optional, only if your Band enum supports it
|
||||
}
|
||||
if (cm.isWorked144()) worked.add(Band.B_144);
|
||||
if (cm.isWorked432()) worked.add(Band.B_432);
|
||||
if (cm.isWorked1240()) worked.add(Band.B_1296);
|
||||
if (cm.isWorked2300()) worked.add(Band.B_2320);
|
||||
if (cm.isWorked3400()) worked.add(Band.B_3400);
|
||||
if (cm.isWorked5600()) worked.add(Band.B_5760);
|
||||
if (cm.isWorked10G()) worked.add(Band.B_10G);
|
||||
if (cm.isWorked24G()) worked.add(Band.B_24G);
|
||||
}
|
||||
|
||||
return worked;
|
||||
}
|
||||
|
||||
@@ -474,66 +477,106 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
* @param remoteChatMember with callsign of the foreign station
|
||||
*/
|
||||
|
||||
public void airScout_SendAsShowPathPacket(ChatMember remoteChatMember) {
|
||||
/**
|
||||
* Requests AirScout to display the path between the own station and the
|
||||
* selected remote station.
|
||||
*
|
||||
* The configured server identifier, client identifier, band and UDP port are
|
||||
* used for every request. This allows several AirScout servers or clients to
|
||||
* coexist in the same network.
|
||||
*
|
||||
* @param remoteChatMember selected remote station
|
||||
*/
|
||||
public void airScout_SendAsShowPathPacket(
|
||||
ChatMember remoteChatMember
|
||||
) {
|
||||
if (!chatPreferences.isAirScout_asUDPListenerEnabled()) {
|
||||
System.out.println(
|
||||
"[AirScout, info]: Show-path request ignored because "
|
||||
+ "the AirScout integration is disabled."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
DatagramSocket dsocket;
|
||||
if (remoteChatMember == null
|
||||
|| remoteChatMember.getCallSign() == null
|
||||
|| remoteChatMember.getCallSign().isBlank()
|
||||
|| remoteChatMember.getQra() == null
|
||||
|| remoteChatMember.getQra().isBlank()) {
|
||||
System.out.println(
|
||||
"[AirScout, warning]: Show-path request ignored because "
|
||||
+ "the selected station has no usable callsign or locator."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
String prefix_asSetpath ="ASSHOWPATH: \""+ this.getChatPreferences().getAirScout_asClientNameString()+ "\" \"" + this.getChatPreferences().getAirScout_asServerNameString() + "\" ";
|
||||
String ownCallSign = chatPreferences.getStn_loginCallSign();
|
||||
if (ownCallSign == null || ownCallSign.isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// String prefix_asSetpath ="ASSHOWPATH: \"KST\" \"AS\" "; Old hard coded
|
||||
String bandString = "1440000";
|
||||
// String myCallAndMyLocString = chatPreferences.getStn_loginCallSign() + "," + chatPreferences.getStn_loginLocatorMainCat(); // original b4 bugfix 1266
|
||||
String remoteCallAndLocString = remoteChatMember.getCallSign() +"," + remoteChatMember.getQra();
|
||||
int suffixSeparator = ownCallSign.indexOf("-");
|
||||
if (suffixSeparator > 0) {
|
||||
ownCallSign = ownCallSign.substring(0, suffixSeparator);
|
||||
}
|
||||
|
||||
String ownCallSign="";
|
||||
try {
|
||||
if (chatPreferences.getStn_loginCallSign().contains("-")) {
|
||||
ownCallSign = chatPreferences.getStn_loginCallSign().split("-")[0];
|
||||
} else {
|
||||
ownCallSign = chatPreferences.getStn_loginCallSign();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println("[ASPERIODICAL, Error]: " + e.getMessage());
|
||||
}
|
||||
String ownLocator =
|
||||
chatPreferences.getStn_loginLocatorMainCat();
|
||||
|
||||
String myCallAndMyLocString = ownCallSign + "," + chatPreferences.getStn_loginLocatorMainCat(); // original b4 bugfix 1266
|
||||
if (ownLocator == null || ownLocator.isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
String host = "255.255.255.255";
|
||||
// int port = 9872;
|
||||
int port = chatPreferences.getAirScout_asCommunicationPort();
|
||||
// System.out.println("<<<<<<<<<<<<<<<<<<<<ASPERI: " + port);
|
||||
String clientIdentifier =
|
||||
chatPreferences.getAirScout_asClientNameString();
|
||||
String serverIdentifier =
|
||||
chatPreferences.getAirScout_asServerNameString();
|
||||
String bandValue =
|
||||
chatPreferences.getAirScout_asBandString();
|
||||
int port =
|
||||
chatPreferences.getAirScout_asCommunicationPort();
|
||||
|
||||
// byte[] message = "ASSETPATH: \"KST\" \"AS\" 1440000,DO5AMF,JN49GL,OK1MZM,JN89IW ".getBytes(); Original, ging
|
||||
InetAddress address;
|
||||
String query =
|
||||
"ASSHOWPATH: \""
|
||||
+ clientIdentifier
|
||||
+ "\" \""
|
||||
+ serverIdentifier
|
||||
+ "\" "
|
||||
+ bandValue
|
||||
+ ","
|
||||
+ ownCallSign
|
||||
+ ","
|
||||
+ ownLocator
|
||||
+ ","
|
||||
+ remoteChatMember.getCallSign()
|
||||
+ ","
|
||||
+ remoteChatMember.getQra()
|
||||
+ " ";
|
||||
|
||||
String queryStringToAirScout = "";
|
||||
byte[] payload = query.getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
queryStringToAirScout += prefix_asSetpath + bandString + "," + myCallAndMyLocString + "," + remoteCallAndLocString+ "Å";
|
||||
try (
|
||||
DatagramSocket socket = new DatagramSocket()
|
||||
) {
|
||||
socket.setBroadcast(true);
|
||||
|
||||
byte[] queryStringToAirScoutMSG = queryStringToAirScout.getBytes();
|
||||
|
||||
try {
|
||||
address = InetAddress.getByName("255.255.255.255");
|
||||
DatagramPacket packet = new DatagramPacket(queryStringToAirScoutMSG, queryStringToAirScoutMSG.length, address, port);
|
||||
dsocket = new DatagramSocket();
|
||||
dsocket.setBroadcast(true);
|
||||
dsocket.send(packet);
|
||||
dsocket.close();
|
||||
} catch (UnknownHostException e1) {
|
||||
// TODO Auto-generated catch block
|
||||
e1.printStackTrace();
|
||||
} catch (NoRouteToHostException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
catch (SocketException e) {
|
||||
// TODO Auto-generated catch block
|
||||
e.printStackTrace();
|
||||
} catch (IOException e) {
|
||||
// TODO Auto-generated catch block
|
||||
e.printStackTrace();
|
||||
}
|
||||
InetAddress address =
|
||||
InetAddress.getByName("255.255.255.255");
|
||||
|
||||
DatagramPacket packet = new DatagramPacket(
|
||||
payload,
|
||||
payload.length,
|
||||
address,
|
||||
port
|
||||
);
|
||||
|
||||
socket.send(packet);
|
||||
} catch (IOException exception) {
|
||||
System.out.println(
|
||||
"[AirScout, error]: Could not send show-path request: "
|
||||
+ exception.getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -584,7 +627,7 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
|
||||
stopScoreScheduler();
|
||||
|
||||
this.dxClusterServer.stop();
|
||||
stopDxClusterServer();
|
||||
|
||||
this.setDisconnectionPerformedByUser(true);
|
||||
|
||||
@@ -645,7 +688,8 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
|
||||
// rotatorClient.
|
||||
|
||||
this.lst_chatMemberList.clear();;
|
||||
// this.lst_chatMemberList.clear();;
|
||||
this.clearActiveChatMembers();
|
||||
this.lst_clusterMemberList.clear();
|
||||
|
||||
this.setDisconnected(true);
|
||||
@@ -668,8 +712,7 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
// writeThread.interrupt();
|
||||
// readThread.interrupt();
|
||||
|
||||
beaconTimer.purge();
|
||||
beaconTimer.cancel();
|
||||
stopBeaconTimer();
|
||||
ASQueryTimer.purge();
|
||||
ASQueryTimer.cancel();
|
||||
socketCheckTimer.purge();
|
||||
@@ -693,8 +736,6 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
|
||||
dbHandler.closeDBConnection();
|
||||
|
||||
dxClusterServer.stop();
|
||||
|
||||
rotatorClient.stopRotor();
|
||||
rotatorClient.stop();
|
||||
|
||||
@@ -713,7 +754,7 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
}
|
||||
} else if (action.equals(ApplicationConstants.DISCSTRING_DISCONNECTONLY)){
|
||||
|
||||
this.lst_chatMemberList.clear();;
|
||||
this.clearActiveChatMembers();
|
||||
this.lst_clusterMemberList.clear();
|
||||
|
||||
|
||||
@@ -959,16 +1000,9 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
if (targetCallsignRaw == null || targetCallsignRaw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String normalizedTargetCall = normalizeCallRaw(targetCallsignRaw);
|
||||
synchronized (getLst_chatMemberList()) {
|
||||
for (ChatMember member : getLst_chatMemberList()) {
|
||||
if (member == null || member.getCallSignRaw() == null) continue;
|
||||
if (normalizeCallRaw(member.getCallSignRaw()).equals(normalizedTargetCall)) {
|
||||
return member;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
List<ChatMember> matchingMembers = findActiveChatMembersByRawCall(targetCallsignRaw);
|
||||
return matchingMembers.isEmpty() ? null : matchingMembers.get(0);
|
||||
}
|
||||
|
||||
private String resolveSkedTargetLocator(String targetCallsignRaw) {
|
||||
@@ -976,22 +1010,43 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
return null;
|
||||
}
|
||||
|
||||
String normalizedTargetCall = normalizeCallRaw(targetCallsignRaw);
|
||||
synchronized (getLst_chatMemberList()) {
|
||||
for (ChatMember member : getLst_chatMemberList()) {
|
||||
if (member == null || member.getCallSignRaw() == null) continue;
|
||||
if (!normalizeCallRaw(member.getCallSignRaw()).equals(normalizedTargetCall)) continue;
|
||||
|
||||
String locator = member.getQra();
|
||||
if (locator != null && !locator.isBlank()) {
|
||||
return locator.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
for (ChatMember member : findActiveChatMembersByRawCall(targetCallsignRaw)) {
|
||||
String locator = member.getQra();
|
||||
if (locator != null && !locator.isBlank()) {
|
||||
return locator.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// private ChatMember resolveSkedTargetMember(String targetCallsignRaw) {
|
||||
// if (targetCallsignRaw == null || targetCallsignRaw.isBlank()) {
|
||||
// return null;
|
||||
// }
|
||||
//
|
||||
// List<ChatMember> matchingMembers = findActiveChatMembersByRawCall(targetCallsignRaw);
|
||||
// return matchingMembers.isEmpty() ? null : matchingMembers.get(0);
|
||||
//
|
||||
// }
|
||||
//
|
||||
// private String resolveSkedTargetLocator(String targetCallsignRaw) {
|
||||
// if (targetCallsignRaw == null || targetCallsignRaw.isBlank()) {
|
||||
// return null;
|
||||
// }
|
||||
//
|
||||
// String normalizedTargetCall = normalizeCallRaw(targetCallsignRaw);
|
||||
//
|
||||
// for (ChatMember member : findActiveChatMembersByRawCall(targetCallsignRaw)) {
|
||||
// String locator = member.getQra();
|
||||
// if (locator != null && !locator.isBlank()) {
|
||||
// return locator.trim().toUpperCase(Locale.ROOT);
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// return null;
|
||||
// }
|
||||
|
||||
public StationMetricsService getStationMetricsService() {
|
||||
return stationMetricsService;
|
||||
}
|
||||
@@ -1014,10 +1069,14 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
return new HashMap<>(lastInboundCategoryByCallSignRaw);
|
||||
}
|
||||
|
||||
// public List<ChatMember> snapshotChatMembers() {
|
||||
// synchronized (getLst_chatMemberList()) {
|
||||
// return new ArrayList<>(getLst_chatMemberList());
|
||||
// }
|
||||
// }
|
||||
|
||||
public List<ChatMember> snapshotChatMembers() {
|
||||
synchronized (getLst_chatMemberList()) {
|
||||
return new ArrayList<>(getLst_chatMemberList());
|
||||
}
|
||||
return new ArrayList<>(activeChatMembersByCallAndCategory.values());
|
||||
}
|
||||
|
||||
public List<ContestSked> snapshotActiveSkeds() {
|
||||
@@ -1115,7 +1174,9 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
// mine
|
||||
private FilteredList<ChatMessage> lst_toOtherMessageList = new FilteredList<>(lst_globalChatMessageList);
|
||||
|
||||
private ObservableList<String> lstNotify_QSOSniffer_sniffedCallSignList = FXCollections.observableArrayList();
|
||||
// private ObservableList<String> lstNotify_QSOSniffer_sniffedCallSignList = FXCollections.observableArrayList();
|
||||
private ObservableList<String>
|
||||
lstNotify_QSOSniffer_sniffedCallSignList;
|
||||
/**
|
||||
* we do some trick here with the chatmemberlist to not make it neccessary to change all boolean properties if the
|
||||
* chatmember object to observables. We trigger the list for changes on an object which we change whenever a list
|
||||
@@ -1126,13 +1187,27 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
private ObservableList<ChatMember> chatMemberList = FXCollections.observableArrayList(); // List of active stations
|
||||
|
||||
|
||||
private ObservableList<ChatMember> lst_chatMemberList = FXCollections.synchronizedObservableList(chatMemberList); // List
|
||||
// of active stn in chat
|
||||
private FilteredList<ChatMember> lst_chatMemberListFiltered = new FilteredList<ChatMember>(chatMemberList);
|
||||
private SortedList<ChatMember> lst_chatMemberSortedFilteredList = new SortedList<ChatMember>(lst_chatMemberListFiltered);
|
||||
|
||||
private ObservableList<ChatMember> lst_chatMemberList = FXCollections.synchronizedObservableList(chatMemberList); // List
|
||||
private ObservableList<Predicate<ChatMember>> lst_chatMemberListFilterPredicates = FXCollections.observableArrayList();
|
||||
private ObservableList<ClusterMessage> lst_clusterMemberList = FXCollections.observableArrayList();
|
||||
|
||||
/*
|
||||
* Thread-safe active-member model.
|
||||
*
|
||||
* MessageBusManagementThread must not use the JavaFX TableView backing list as
|
||||
* its primary data model. Otherwise every add/remove fires FilteredList,
|
||||
* SortedList and TableView selection listeners on the MessageBus thread and JavaFX
|
||||
* can throw "Not on FX application thread" or internal listener NPEs.
|
||||
*
|
||||
* This map is the worker-thread safe source of truth. The ObservableList below
|
||||
* remains a UI mirror and is mutated only on the JavaFX application thread.
|
||||
*/
|
||||
private final java.util.concurrent.ConcurrentMap<String, ChatMember> activeChatMembersByCallAndCategory =
|
||||
new java.util.concurrent.ConcurrentHashMap<>();
|
||||
|
||||
/*
|
||||
* Message table update buffers.
|
||||
@@ -1161,6 +1236,254 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
// ******************************************************************************************************************************************
|
||||
|
||||
|
||||
/**
|
||||
* Executes UI-bound work on the JavaFX application thread.
|
||||
*
|
||||
* Worker threads may call this safely. If the caller is already on the FX
|
||||
* thread, the action is executed immediately to preserve ordering.
|
||||
*/
|
||||
private void runOnFxThread(Runnable action) {
|
||||
if (action == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Platform.isFxApplicationThread()) {
|
||||
action.run();
|
||||
} else {
|
||||
Platform.runLater(action);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the active-member key. The raw/base callsign alone is not enough
|
||||
* because the same station can be logged into multiple ON4KST categories at
|
||||
* the same time. Therefore the category number is part of the key.
|
||||
*/
|
||||
private String buildActiveChatMemberKey(String callSign, ChatCategory category) {
|
||||
String normalizedCallsign = ChatMember.normalizeCallSignToBaseCallSign(callSign);
|
||||
if (normalizedCallsign == null || normalizedCallsign.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
int categoryNumber = category == null ? -1 : category.getCategoryNumber();
|
||||
return normalizedCallsign.trim().toUpperCase(Locale.ROOT) + "|" + categoryNumber;
|
||||
}
|
||||
|
||||
private String buildActiveChatMemberKey(ChatMember member) {
|
||||
if (member == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String callSign = member.getCallSignRaw() != null ? member.getCallSignRaw() : member.getCallSign();
|
||||
return buildActiveChatMemberKey(callSign, member.getChatCategory());
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds or replaces an active chat member in the worker-thread model and mirrors
|
||||
* that change to the JavaFX list. This is the only supported path for ON4KST
|
||||
* user-enter events.
|
||||
*/
|
||||
public void addOrUpdateActiveChatMember(ChatMember member) {
|
||||
String key = buildActiveChatMemberKey(member);
|
||||
if (key == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeChatMembersByCallAndCategory.put(key, member);
|
||||
|
||||
runOnFxThread(() -> {
|
||||
int existingIndex = findChatMemberIndexInUiListByKey(key);
|
||||
if (existingIndex >= 0) {
|
||||
lst_chatMemberList.set(existingIndex, member);
|
||||
} else {
|
||||
lst_chatMemberList.add(member);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an active chat member from the worker-thread model and from the UI
|
||||
* mirror. Removal from the ObservableList is always performed on the FX thread
|
||||
* because the list is bound to FilteredList/SortedList/TableView.
|
||||
*/
|
||||
public boolean removeActiveChatMember(ChatMember member) {
|
||||
String key = buildActiveChatMemberKey(member);
|
||||
if (key == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ChatMember removedMember = activeChatMembersByCallAndCategory.remove(key);
|
||||
runOnFxThread(() -> lst_chatMemberList.removeIf(currentMember -> key.equals(buildActiveChatMemberKey(currentMember))));
|
||||
return removedMember != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the active-member model and the JavaFX UI mirror. Use this instead of
|
||||
* getLst_chatMemberList().clear() from timers or worker threads.
|
||||
*/
|
||||
public void clearActiveChatMembers() {
|
||||
activeChatMembersByCallAndCategory.clear();
|
||||
runOnFxThread(() -> lst_chatMemberList.clear());
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a member from the thread-safe active model. This avoids reading the
|
||||
* TableView backing list from MessageBusManagementThread.
|
||||
*/
|
||||
public ChatMember findActiveChatMember(ChatMember lookForThis) {
|
||||
String key = buildActiveChatMemberKey(lookForThis);
|
||||
return key == null ? null : activeChatMembersByCallAndCategory.get(key);
|
||||
}
|
||||
|
||||
public ChatMember findActiveChatMember(String callSign, ChatCategory category) {
|
||||
String key = buildActiveChatMemberKey(callSign, category);
|
||||
return key == null ? null : activeChatMembersByCallAndCategory.get(key);
|
||||
}
|
||||
|
||||
public int getActiveChatMemberCount() {
|
||||
return activeChatMembersByCallAndCategory.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all active category variants of a callsign. This is used when a QRG or
|
||||
* band hint should be propagated from one category instance to the same station
|
||||
* in another category.
|
||||
*/
|
||||
public List<ChatMember> findActiveChatMembersByRawCall(String callSignRaw) {
|
||||
String normalizedCallsign = ChatMember.normalizeCallSignToBaseCallSign(callSignRaw);
|
||||
if (normalizedCallsign == null || normalizedCallsign.isBlank()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
String normalizedKeyPart = normalizedCallsign.trim().toUpperCase(Locale.ROOT);
|
||||
List<ChatMember> matchingMembers = new ArrayList<>();
|
||||
|
||||
for (ChatMember member : activeChatMembersByCallAndCategory.values()) {
|
||||
if (member == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String memberCall = member.getCallSignRaw() != null ? member.getCallSignRaw() : member.getCallSign();
|
||||
String normalizedMemberCall = ChatMember.normalizeCallSignToBaseCallSign(memberCall);
|
||||
if (normalizedMemberCall != null && normalizedMemberCall.equalsIgnoreCase(normalizedKeyPart)) {
|
||||
matchingMembers.add(member);
|
||||
}
|
||||
}
|
||||
|
||||
return matchingMembers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates locator and derived direction values in the active model. The UI list
|
||||
* contains the same member object, but the user-list refresh is still triggered
|
||||
* on the JavaFX thread so TableView/map derived displays can repaint safely.
|
||||
*/
|
||||
public boolean updateActiveChatMemberLocator(ChatMember lookupMember, String newLocator) {
|
||||
ChatMember activeMember = findActiveChatMember(lookupMember);
|
||||
if (activeMember == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
activeMember.setQra(newLocator);
|
||||
activeMember.setQrb(new Location().getDistanceKmByTwoLocatorStrings(chatPreferences.getStn_loginLocatorMainCat(), newLocator));
|
||||
activeMember.setQTFdirection(new Location(chatPreferences.getStn_loginLocatorMainCat()).getBearing(new Location(newLocator)));
|
||||
fireUserListUpdate("Locator changed");
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean updateActiveChatMemberState(ChatMember lookupMember, int newState) {
|
||||
ChatMember activeMember = findActiveChatMember(lookupMember);
|
||||
if (activeMember == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
activeMember.setState(newState);
|
||||
fireUserListUpdate("User state changed");
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a UM3-style user-info update only to an already active chat member.
|
||||
*
|
||||
* ON4KST may send UM3 messages for stations that are not logged into the
|
||||
* current channel, for example when a user changes locator/profile data on the
|
||||
* website. Such stations must not be added to the visible chat member list,
|
||||
* because they cannot be addressed in the channel. Their full information will
|
||||
* be delivered again with UA0/UA5/UA2 when they actually join.
|
||||
*
|
||||
* @return true if an active member was found and updated, false if the UM3
|
||||
* information was intentionally ignored.
|
||||
*/
|
||||
public boolean updateActiveChatMemberInfoIfPresent(ChatMember updatedMember) {
|
||||
String key = buildActiveChatMemberKey(updatedMember);
|
||||
if (key == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ChatMember activeMember = activeChatMembersByCallAndCategory.get(key);
|
||||
if (activeMember == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
activeMember.setName(updatedMember.getName());
|
||||
activeMember.setQra(updatedMember.getQra());
|
||||
activeMember.setState(updatedMember.getState());
|
||||
activeMember.setLastActivity(updatedMember.getLastActivity());
|
||||
activeMember.setActivityTimeLastInEpoch(updatedMember.getActivityTimeLastInEpoch());
|
||||
activeMember.setQrb(updatedMember.getQrb());
|
||||
activeMember.setQTFdirection(updatedMember.getQTFdirection());
|
||||
fireUserListUpdate("User info updated");
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward-compatible wrapper for older call sites. Despite the historic name,
|
||||
* this method no longer adds unknown UM3 users to the active member model.
|
||||
*/
|
||||
public boolean updateOrAddActiveChatMemberInfo(ChatMember updatedMember) {
|
||||
return updateActiveChatMemberInfoIfPresent(updatedMember);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a newly detected QRG/band on all active category variants of a station.
|
||||
* The old StringProperty is still set for compatibility with existing TableView
|
||||
* columns and DXCluster code.
|
||||
*/
|
||||
public void applyDetectedFrequencyToActiveMembers(ChatMember sender, Band detectedBand, double detectedFrequencyMhz) {
|
||||
if (sender == null || detectedBand == null || detectedFrequencyMhz <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
String displayFrequency = String.valueOf(detectedFrequencyMhz);
|
||||
String rawCall = sender.getCallSignRaw() != null ? sender.getCallSignRaw() : sender.getCallSign();
|
||||
|
||||
for (ChatMember member : findActiveChatMembersByRawCall(rawCall)) {
|
||||
if (member == null) {
|
||||
continue;
|
||||
}
|
||||
member.addKnownFrequency(detectedBand, detectedFrequencyMhz);
|
||||
member.setFrequency(new SimpleStringProperty(displayFrequency));
|
||||
}
|
||||
|
||||
// Also cover dummy senders that are not in the active model, e.g. [n/a] senders.
|
||||
sender.addKnownFrequency(detectedBand, detectedFrequencyMhz);
|
||||
sender.setFrequency(new SimpleStringProperty(displayFrequency));
|
||||
fireUserListUpdate("Frequency detected");
|
||||
}
|
||||
|
||||
private int findChatMemberIndexInUiListByKey(String key) {
|
||||
if (key == null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
for (int i = 0; i < lst_chatMemberList.size(); i++) {
|
||||
if (key.equals(buildActiveChatMemberKey(lst_chatMemberList.get(i)))) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1585,6 +1908,9 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
chatPreferences = new ChatPreferences();
|
||||
chatPreferences.readPreferencesFromXmlFile();
|
||||
// this.statusListener = listener;
|
||||
lstNotify_QSOSniffer_sniffedCallSignList =
|
||||
chatPreferences
|
||||
.getLstNotify_QSOSniffer_sniffedCallSignList();
|
||||
|
||||
String dnsFromPrefs = chatPreferences.getStn_on4kstServersDns();
|
||||
if (dnsFromPrefs != null && !dnsFromPrefs.isEmpty()) {
|
||||
@@ -1725,7 +2051,7 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
|
||||
});
|
||||
|
||||
lstNotify_QSOSniffer_sniffedCallSignList.add("DF0GEB");
|
||||
// lstNotify_QSOSniffer_sniffedCallSignList.add("DF0GEB");
|
||||
|
||||
|
||||
lst_toMeMessageList.setPredicate(chatFilterPredicate); //sniffed callsign filter predicate is here!
|
||||
@@ -1868,6 +2194,38 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
return dxClusterServer;
|
||||
}
|
||||
|
||||
public synchronized void startDxClusterServerIfEnabled() {
|
||||
if (!chatPreferences.isNotify_dxClusterServerEnabled()
|
||||
|| dxClusterServer != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dxClusterServer = new DXClusterThreadPooledServer(
|
||||
chatPreferences.getNotify_dxclusterServerPort(),
|
||||
this,
|
||||
this
|
||||
);
|
||||
|
||||
Thread serverThread = new Thread(dxClusterServer);
|
||||
serverThread.setName("DXCluster-thread-pooled-server");
|
||||
serverThread.setDaemon(true);
|
||||
serverThread.start();
|
||||
}
|
||||
|
||||
public synchronized void stopDxClusterServer() {
|
||||
DXClusterThreadPooledServer serverToStop = dxClusterServer;
|
||||
dxClusterServer = null;
|
||||
|
||||
if (serverToStop != null) {
|
||||
serverToStop.stop();
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void restartDxClusterServerIfEnabled() {
|
||||
stopDxClusterServer();
|
||||
startDxClusterServerIfEnabled();
|
||||
}
|
||||
|
||||
// public void setChatMemberTable(Hashtable<String, ChatMember> chatMemberTable) {
|
||||
// this.chatMemberTable = chatMemberTable;
|
||||
// }
|
||||
@@ -1884,6 +2242,78 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
this.dbHandler = dbHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the shared beacon timer with the interval currently stored in the
|
||||
* preferences.
|
||||
*
|
||||
* <p>Both chat categories deliberately use the same timer. The task checks the
|
||||
* individual enable flag and text for each category every time it runs.</p>
|
||||
*
|
||||
* @param initialDelayMillis delay before the next beacon run
|
||||
*/
|
||||
private synchronized void scheduleBeaconTimer(long initialDelayMillis) {
|
||||
stopBeaconTimer();
|
||||
|
||||
int configuredInterval =
|
||||
chatPreferences.getBcn_beaconIntervalInMinutesMainCat();
|
||||
int effectiveInterval =
|
||||
Math.max(MIN_BEACON_INTERVAL_MINUTES, configuredInterval);
|
||||
|
||||
/*
|
||||
* Keep both legacy XML values synchronized. The second value remains in the
|
||||
* configuration for backward compatibility, but no longer represents an
|
||||
* independent timer.
|
||||
*/
|
||||
chatPreferences.setBcn_beaconIntervalInMinutesMainCat(effectiveInterval);
|
||||
chatPreferences.setBcn_beaconIntervalInMinutesSecondCat(effectiveInterval);
|
||||
|
||||
long intervalMillis = TimeUnit.MINUTES.toMillis(effectiveInterval);
|
||||
long safeInitialDelay = Math.max(0L, initialDelayMillis);
|
||||
|
||||
beaconTimer = new Timer("BeaconTimer");
|
||||
beaconTimer.schedule(
|
||||
new BeaconTask(this, this),
|
||||
safeInitialDelay,
|
||||
intervalMillis
|
||||
);
|
||||
|
||||
System.out.println("[ChatController, Info]: Shared beacon timer scheduled every "
|
||||
+ effectiveInterval + " minute(s).");
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a changed beacon interval while the chat connection is running.
|
||||
*
|
||||
* <p>The countdown starts again with the new interval. No beacon is sent
|
||||
* immediately merely because the setting was changed.</p>
|
||||
*/
|
||||
public synchronized void restartBeaconTimer() {
|
||||
if (!isConnectedAndLoggedIn()) {
|
||||
return;
|
||||
}
|
||||
|
||||
long intervalMillis = TimeUnit.MINUTES.toMillis(
|
||||
Math.max(
|
||||
MIN_BEACON_INTERVAL_MINUTES,
|
||||
chatPreferences.getBcn_beaconIntervalInMinutesMainCat()
|
||||
)
|
||||
);
|
||||
scheduleBeaconTimer(intervalMillis);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the shared beacon timer if it is currently running.
|
||||
*/
|
||||
private synchronized void stopBeaconTimer() {
|
||||
if (beaconTimer == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
beaconTimer.cancel();
|
||||
beaconTimer.purge();
|
||||
beaconTimer = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* execute is the main entry point where the application starts.
|
||||
* @throws InterruptedException
|
||||
@@ -1916,7 +2346,11 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
messageTXBus = new LinkedBlockingQueue<ChatMessage>();
|
||||
|
||||
// socket = new Socket(hostname, port);//socket for the on4kst chat server
|
||||
socket = new Socket(chatController.chatPreferences.getStn_on4kstServersDns(), port);//socket for the on4kst chat server
|
||||
socket = new Socket(
|
||||
chatPreferences.getStn_on4kstServersDns(),
|
||||
chatPreferences.getStn_on4kstServersPort()
|
||||
);//socket for the on4kst chat server
|
||||
|
||||
System.out.println("Connected to the chat server: " + socket.isConnected());
|
||||
|
||||
ByteBuffer buffer = ByteBuffer.allocate(1024);
|
||||
@@ -1947,7 +2381,11 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
messageProcessor.setName("messagebusManagementThread");
|
||||
messageProcessor.start();
|
||||
|
||||
airScoutUDPReaderThread = new ReadUDPbyAirScoutMessageThread(chatPreferences.getAirScout_asCommunicationPort(), this, this.getChatPreferences().getAirScout_asServerNameString(), this.getChatPreferences().getAirScout_asServerNameString(), this); //working original
|
||||
airScoutUDPReaderThread = new ReadUDPbyAirScoutMessageThread(
|
||||
chatPreferences.getAirScout_asCommunicationPort(),
|
||||
this,
|
||||
this
|
||||
);
|
||||
airScoutUDPReaderThread.setName("airscoutudpreaderThread");
|
||||
airScoutUDPReaderThread.start();
|
||||
|
||||
@@ -1966,8 +2404,7 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
/**
|
||||
* DX cluster service running config
|
||||
*/
|
||||
dxClusterServer = new DXClusterThreadPooledServer(this.getChatPreferences().getNotify_dxclusterServerPort(), this, this);
|
||||
new Thread(dxClusterServer).start();
|
||||
startDxClusterServerIfEnabled();
|
||||
|
||||
|
||||
this.setConnectedAndLoggedIn(true);
|
||||
@@ -1976,11 +2413,7 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
* The CQ-beacon-Task will be executed every time but checks for itself whether
|
||||
* CQ messages are enabled or not
|
||||
*/
|
||||
// Timer beaconTimer;
|
||||
beaconTimer = new Timer();
|
||||
beaconTimer.schedule(new BeaconTask(this, this), 10000,
|
||||
this.getChatPreferences().getBcn_beaconIntervalInMinutesMainCat() * 60000);
|
||||
// 60000 * intervalInMinutes = IntervalInMillis
|
||||
scheduleBeaconTimer(INITIAL_BEACON_DELAY_MILLIS);
|
||||
|
||||
/**
|
||||
* The AS querier task will be executed every time but checks for itself whether
|
||||
@@ -2011,7 +2444,7 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
socket.close();
|
||||
|
||||
chatController.setConnectedAndLoggedIn(false);
|
||||
chatController.getLst_chatMemberList().clear();
|
||||
chatController.clearActiveChatMembers();
|
||||
|
||||
System.out.println("[Chatcontroller, Warning: ] Socket closed or disconnected");
|
||||
|
||||
@@ -2256,7 +2689,7 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
|
||||
String loginString = "";
|
||||
loginString = "LOGINC|" + chatPreferences.getStn_loginCallSign() + "|" + chatPreferences.getStn_loginPassword()
|
||||
+ "|" + chatPreferences.getLoginChatCategoryMain().getCategoryNumber() + "|praktiKST v" + ApplicationConstants.APPLICATION_CURRENTVERSIONNUMBER
|
||||
+ "|" + chatPreferences.getLoginChatCategoryMain().getCategoryNumber() + "|praktiKST v" + ApplicationConstants.APPLICATION_CURRENT_VERSION
|
||||
+ "|25|0|1|" + "0" + "|0|";
|
||||
|
||||
// System.out.println(loginString);
|
||||
|
||||
@@ -1,238 +1,363 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import kst4contest.model.ChatMember;
|
||||
import kst4contest.model.ChatPreferences;
|
||||
import kst4contest.model.ThreadStateMessage;
|
||||
|
||||
import java.io.*;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.util.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
public class DXClusterThreadPooledServer implements Runnable{
|
||||
public class DXClusterThreadPooledServer implements Runnable {
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(DXClusterThreadPooledServer.class.getName());
|
||||
private List<Socket> clientSockets = Collections.synchronizedList(new ArrayList<>()); //list of all connected clients
|
||||
private static final Logger LOGGER =
|
||||
Logger.getLogger(DXClusterThreadPooledServer.class.getName());
|
||||
private static final String THREAD_NICKNAME = "DXCluster-Server";
|
||||
|
||||
private ThreadStatusCallback callBackToController;
|
||||
private String ThreadNickName = "DXCluster-Server";
|
||||
ChatController chatController = null;
|
||||
protected int serverPort = 8080;
|
||||
protected ServerSocket serverSocket = null;
|
||||
protected boolean isStopped = false;
|
||||
protected Thread runningThread= null;
|
||||
protected ExecutorService threadPool =
|
||||
private final List<Socket> clientSockets =
|
||||
Collections.synchronizedList(new ArrayList<>());
|
||||
|
||||
private final ChatController chatController;
|
||||
private final ThreadStatusCallback callBackToController;
|
||||
private final int serverPort;
|
||||
|
||||
private final ExecutorService threadPool =
|
||||
Executors.newFixedThreadPool(10);
|
||||
Socket clientSocket;
|
||||
|
||||
public DXClusterThreadPooledServer(int port, ChatController chatController, ThreadStatusCallback callback){
|
||||
private final ScheduledExecutorService keepAliveExecutor =
|
||||
Executors.newSingleThreadScheduledExecutor();
|
||||
|
||||
private volatile boolean stopped;
|
||||
private ServerSocket serverSocket;
|
||||
|
||||
public DXClusterThreadPooledServer(
|
||||
int port,
|
||||
ChatController chatController,
|
||||
ThreadStatusCallback callback
|
||||
) {
|
||||
this.serverPort = port;
|
||||
this.chatController = chatController;
|
||||
this.callBackToController = callback;
|
||||
}
|
||||
|
||||
public void run(){
|
||||
@Override
|
||||
public void run() {
|
||||
Thread.currentThread().setName("DXCluster-thread-pooled-server");
|
||||
|
||||
ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "initialized", false);
|
||||
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
|
||||
try {
|
||||
serverSocket = new ServerSocket(serverPort);
|
||||
|
||||
synchronized(this){
|
||||
this.runningThread = Thread.currentThread();
|
||||
runningThread.setName("DXCluster-thread-pooled-server");
|
||||
}
|
||||
openServerSocket();
|
||||
while(! isStopped()){
|
||||
clientSocket = null;
|
||||
try {
|
||||
clientSocket = this.serverSocket.accept();
|
||||
|
||||
synchronized(clientSockets) {
|
||||
clientSockets.add(clientSocket); // add dx cluster client to the "clients list" for broadcasting
|
||||
}
|
||||
|
||||
} catch (IOException e) {
|
||||
if(isStopped()) {
|
||||
System.out.println("Server Stopped.") ;
|
||||
break;
|
||||
}
|
||||
throw new RuntimeException(
|
||||
"Error accepting client connection", e);
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
|
||||
DXClusterServerWorkerRunnable worker = new DXClusterServerWorkerRunnable(clientSocket, "Thread Pooled DXCluster Server ", chatController, clientSockets, chatController);
|
||||
callBackToController.onThreadStatus(
|
||||
THREAD_NICKNAME,
|
||||
new ThreadStateMessage(
|
||||
THREAD_NICKNAME,
|
||||
true,
|
||||
"Listening on TCP port " + serverPort,
|
||||
false
|
||||
)
|
||||
);
|
||||
|
||||
this.threadPool.execute(worker);
|
||||
keepAliveExecutor.scheduleAtFixedRate(
|
||||
this::sendKeepAlive,
|
||||
30,
|
||||
30,
|
||||
TimeUnit.SECONDS
|
||||
);
|
||||
|
||||
}
|
||||
this.threadPool.shutdown();
|
||||
System.out.println("Server Stopped.") ;
|
||||
}
|
||||
while (!stopped) {
|
||||
try {
|
||||
Socket clientSocket = serverSocket.accept();
|
||||
clientSockets.add(clientSocket);
|
||||
|
||||
private synchronized boolean isStopped() {
|
||||
return this.isStopped;
|
||||
}
|
||||
|
||||
public synchronized void stop(){
|
||||
this.isStopped = true;
|
||||
try {
|
||||
this.serverSocket.close();
|
||||
synchronized(clientSockets) {
|
||||
for (Socket socket : clientSockets) {
|
||||
socket.close(); // close all client connections
|
||||
threadPool.execute(
|
||||
new DXClusterServerWorkerRunnable(
|
||||
clientSocket,
|
||||
clientSockets
|
||||
)
|
||||
);
|
||||
} catch (IOException exception) {
|
||||
if (!stopped) {
|
||||
LOGGER.log(
|
||||
Level.SEVERE,
|
||||
"Error accepting DX Cluster client connection",
|
||||
exception
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("DXCCSERVER Error closing server", e);
|
||||
} catch (IOException exception) {
|
||||
if (!stopped) {
|
||||
LOGGER.log(
|
||||
Level.SEVERE,
|
||||
"Cannot open DX Cluster TCP port " + serverPort,
|
||||
exception
|
||||
);
|
||||
|
||||
callBackToController.onThreadStatus(
|
||||
THREAD_NICKNAME,
|
||||
new ThreadStateMessage(
|
||||
THREAD_NICKNAME,
|
||||
false,
|
||||
"Cannot open TCP port "
|
||||
+ serverPort
|
||||
+ ": "
|
||||
+ exception.getMessage(),
|
||||
true
|
||||
)
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
closeServerSocket();
|
||||
closeClientSockets();
|
||||
keepAliveExecutor.shutdownNow();
|
||||
threadPool.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
private void openServerSocket() {
|
||||
try {
|
||||
this.serverSocket = new ServerSocket(this.serverPort);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("DXCCSERVER Cannot open port ", e);
|
||||
public synchronized void stop() {
|
||||
stopped = true;
|
||||
closeServerSocket();
|
||||
closeClientSockets();
|
||||
keepAliveExecutor.shutdownNow();
|
||||
threadPool.shutdownNow();
|
||||
}
|
||||
|
||||
public boolean hasConnectedClients() {
|
||||
synchronized (clientSockets) {
|
||||
removeClosedClients();
|
||||
return !clientSockets.isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a DX cluster message to ALL connected log programs via telnet, returns true if sent
|
||||
* Sends one DX Cluster spot to all currently connected clients.
|
||||
*
|
||||
* @param aChatMember
|
||||
* @return boolean true if message had been sent
|
||||
* @return true if the spot was delivered to at least one client
|
||||
*/
|
||||
public boolean broadcastSingleDXClusterEntryToLoggers(ChatMember aChatMember) {
|
||||
synchronized(clientSockets) {
|
||||
public boolean broadcastSingleDXClusterEntryToLoggers(
|
||||
ChatMember chatMember
|
||||
) {
|
||||
final String clusterMessage;
|
||||
|
||||
System.out.println("DXClusterSrvr: broadcasting message to clients: " + clientSockets.size());
|
||||
try {
|
||||
String frequency = Utils4KST.normalizeFrequencyString(
|
||||
chatMember.getFrequency().getValue(),
|
||||
chatController
|
||||
.getChatPreferences()
|
||||
.getNotify_optionalFrequencyPrefix()
|
||||
);
|
||||
|
||||
try {
|
||||
clusterMessage =
|
||||
"DX de "
|
||||
+ chatController
|
||||
.getChatPreferences()
|
||||
.getNotify_DXCSrv_SpottersCallSign()
|
||||
.getValue()
|
||||
+ ": "
|
||||
+ frequency
|
||||
+ " "
|
||||
+ chatMember.getCallSign().toUpperCase()
|
||||
+ " "
|
||||
+ chatMember.getQra().toUpperCase()
|
||||
+ " "
|
||||
+ new Utils4KST()
|
||||
.time_generateCurrenthhmmZTimeStringForClusterMessage()
|
||||
+ ((char) 7)
|
||||
+ ((char) 7)
|
||||
+ "\r\n";
|
||||
} catch (Exception exception) {
|
||||
LOGGER.log(
|
||||
Level.SEVERE,
|
||||
"Cannot build DX Cluster message",
|
||||
exception
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
System.out.println("-------------> ORIGINALEE VAL: " + aChatMember.getFrequency().getValue());
|
||||
System.out.println("-------------> NORMALIZED VAL: " + Utils4KST.normalizeFrequencyString(aChatMember.getFrequency().getValue(), chatController.getChatPreferences().getNotify_optionalFrequencyPrefix()) + " ");
|
||||
} catch (Exception e) {
|
||||
LOGGER.log(Level.SEVERE, "DXCThPooledServer: Error accessing value in chatmember object", e);
|
||||
}
|
||||
int deliveredClients = 0;
|
||||
|
||||
for (Socket socket : clientSockets) {
|
||||
synchronized (clientSockets) {
|
||||
Iterator<Socket> iterator = clientSockets.iterator();
|
||||
|
||||
while (iterator.hasNext()) {
|
||||
Socket socket = iterator.next();
|
||||
|
||||
if (socket == null || socket.isClosed()) {
|
||||
iterator.remove();
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
OutputStream output = socket.getOutputStream();
|
||||
output.write(
|
||||
clusterMessage.getBytes(
|
||||
StandardCharsets.US_ASCII
|
||||
)
|
||||
);
|
||||
output.flush();
|
||||
deliveredClients++;
|
||||
} catch (IOException exception) {
|
||||
LOGGER.log(
|
||||
Level.WARNING,
|
||||
"DX Cluster client disconnected while sending a spot",
|
||||
exception
|
||||
);
|
||||
|
||||
OutputStream output = socket.getOutputStream();
|
||||
|
||||
String singleDXClusterMessage = "DX de ";
|
||||
|
||||
// singleDXClusterMessage += chatController.getChatPreferences().getLoginCallSign() + ": ";
|
||||
|
||||
|
||||
|
||||
|
||||
singleDXClusterMessage += this.chatController.getChatPreferences().getNotify_DXCSrv_SpottersCallSign().getValue() + ": ";
|
||||
singleDXClusterMessage += Utils4KST.normalizeFrequencyString(aChatMember.getFrequency().getValue(), chatController.getChatPreferences().getNotify_optionalFrequencyPrefix()) + " ";
|
||||
singleDXClusterMessage += aChatMember.getCallSign().toUpperCase() + " "; //we need such an amount of spaces for n1mm to work, otherwise bullshit happens
|
||||
singleDXClusterMessage += aChatMember.getQra().toUpperCase() + " ";
|
||||
singleDXClusterMessage += new Utils4KST().time_generateCurrenthhmmZTimeStringForClusterMessage() + ((char)7) + ((char)7) + "\r\n";
|
||||
|
||||
// singleDXClusterMessage += chatController.getChatPreferences().getLoginCallSign() + ": ";
|
||||
// singleDXClusterMessage += Utils4KST.normalizeFrequencyString(aChatMember.getFrequency().getValue(), chatController.getChatPreferences().getNotify_optionalFrequencyPrefix()) + " ";
|
||||
// singleDXClusterMessage += aChatMember.getCallSign().toUpperCase() + " ";
|
||||
// singleDXClusterMessage += aChatMember.getQra().toUpperCase() + " ";
|
||||
// singleDXClusterMessage += new Utils4KST().time_generateCurrenthhmmZTimeStringForClusterMessage() + ((char)7) + ((char)7) + "\r\n";
|
||||
|
||||
output.write((singleDXClusterMessage).getBytes());
|
||||
|
||||
ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "Last msg to " + clientSockets.size() + " Cluster Clients:\n" + singleDXClusterMessage, false);
|
||||
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
|
||||
|
||||
} catch (IOException e) {
|
||||
LOGGER.log(Level.SEVERE, "[DXClusterSrvr] broadcasting DXC-message to clients went wrong", e);
|
||||
return false;
|
||||
closeSocket(socket);
|
||||
iterator.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
return true; //if message had been sent, return true for "ok"
|
||||
|
||||
if (deliveredClients > 0) {
|
||||
callBackToController.onThreadStatus(
|
||||
THREAD_NICKNAME,
|
||||
new ThreadStateMessage(
|
||||
THREAD_NICKNAME,
|
||||
true,
|
||||
"Last spot sent to "
|
||||
+ deliveredClients
|
||||
+ " DX Cluster client(s):\n"
|
||||
+ clusterMessage,
|
||||
false
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return deliveredClients > 0;
|
||||
}
|
||||
|
||||
private void sendKeepAlive() {
|
||||
synchronized (clientSockets) {
|
||||
Iterator<Socket> iterator = clientSockets.iterator();
|
||||
|
||||
while (iterator.hasNext()) {
|
||||
Socket socket = iterator.next();
|
||||
|
||||
if (socket == null || socket.isClosed()) {
|
||||
iterator.remove();
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
OutputStream output = socket.getOutputStream();
|
||||
output.write(
|
||||
"\r\n".getBytes(StandardCharsets.US_ASCII)
|
||||
);
|
||||
output.flush();
|
||||
} catch (IOException exception) {
|
||||
closeSocket(socket);
|
||||
iterator.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void removeClosedClients() {
|
||||
clientSockets.removeIf(
|
||||
socket -> socket == null || socket.isClosed()
|
||||
);
|
||||
}
|
||||
|
||||
private synchronized void closeServerSocket() {
|
||||
if (serverSocket == null || serverSocket.isClosed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
serverSocket.close();
|
||||
} catch (IOException exception) {
|
||||
LOGGER.log(
|
||||
Level.WARNING,
|
||||
"Error closing DX Cluster server socket",
|
||||
exception
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void closeClientSockets() {
|
||||
synchronized (clientSockets) {
|
||||
for (Socket socket : clientSockets) {
|
||||
closeSocket(socket);
|
||||
}
|
||||
|
||||
clientSockets.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private static void closeSocket(Socket socket) {
|
||||
if (socket == null || socket.isClosed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
socket.close();
|
||||
} catch (IOException ignored) {
|
||||
// The connection is already unusable.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DXClusterServerWorkerRunnable implements Runnable{
|
||||
class DXClusterServerWorkerRunnable implements Runnable {
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(DXClusterServerWorkerRunnable.class.getName());
|
||||
protected Socket clientSocket = null;
|
||||
protected String serverText = null;
|
||||
private ChatController client = null;
|
||||
private List<Socket> dxClusterClientSocketsConnectedList;
|
||||
private ThreadStatusCallback callBackToController;
|
||||
private String ThreadNickName = "DXCluster-Server";
|
||||
private static final Logger LOGGER =
|
||||
Logger.getLogger(DXClusterServerWorkerRunnable.class.getName());
|
||||
|
||||
public DXClusterServerWorkerRunnable(Socket clientSocket, String serverText, ChatController chatController, List<Socket> clientSockets, ThreadStatusCallback callback) {
|
||||
private final Socket clientSocket;
|
||||
private final List<Socket> clientSockets;
|
||||
|
||||
DXClusterServerWorkerRunnable(
|
||||
Socket clientSocket,
|
||||
List<Socket> clientSockets
|
||||
) {
|
||||
this.clientSocket = clientSocket;
|
||||
this.serverText = serverText;
|
||||
this.client = chatController;
|
||||
this.dxClusterClientSocketsConnectedList = clientSockets;
|
||||
this.callBackToController = callback;
|
||||
|
||||
this.clientSockets = clientSockets;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
try {
|
||||
OutputStream output = clientSocket.getOutputStream();
|
||||
dxClusterClientSocketsConnectedList.add(clientSocket);
|
||||
output.write(
|
||||
"login: ".getBytes(StandardCharsets.US_ASCII)
|
||||
);
|
||||
output.flush();
|
||||
|
||||
Timer dXCkeepAliveTimer = new Timer();
|
||||
dXCkeepAliveTimer.schedule(new TimerTask() {
|
||||
System.out.println(
|
||||
"[DXClusterServer] New client connected: "
|
||||
+ clientSocket.getInetAddress()
|
||||
);
|
||||
} catch (IOException exception) {
|
||||
LOGGER.log(
|
||||
Level.WARNING,
|
||||
"Cannot initialise DX Cluster client connection",
|
||||
exception
|
||||
);
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
synchronized (clientSockets) {
|
||||
clientSockets.remove(clientSocket);
|
||||
}
|
||||
|
||||
StringBuilder connectedClients = new StringBuilder(); //only for statistics
|
||||
|
||||
for (Socket socket : dxClusterClientSocketsConnectedList) {
|
||||
|
||||
connectedClients.append(socket.getInetAddress()).append("\n");
|
||||
|
||||
try {
|
||||
OutputStream output = socket.getOutputStream();
|
||||
output.write(("\r\n").getBytes());
|
||||
|
||||
} catch (IOException e) {
|
||||
LOGGER.log(Level.SEVERE, "[DXClusterSrvr] keep-alive broadcast to client failed", e);
|
||||
dXCkeepAliveTimer.purge();
|
||||
|
||||
try {
|
||||
socket.close();
|
||||
} catch (IOException ex) {
|
||||
LOGGER.log(Level.SEVERE, "[DXClusterSrvr] error closing client socket", ex);
|
||||
}
|
||||
finally {
|
||||
this.cancel();
|
||||
}
|
||||
dxClusterClientSocketsConnectedList.remove(socket); //if socket is closed by client, remove it from the broadcast list and close it
|
||||
}
|
||||
}
|
||||
|
||||
// ThreadStateMessage threadStateMessage = new ThreadStateMessage(ThreadNickName, true, "Connected clients: " + connectedClients.toString(), false);
|
||||
// callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
|
||||
|
||||
}
|
||||
}, 30000, 30000);
|
||||
|
||||
|
||||
output.write(("login: ").getBytes()); //say hello to the client, it will answer with a callsign
|
||||
System.out.println("[DXClusterThreadPooledServer, Info:] New cluster client connected! "); //TODO: maybe integrate non blocking reader for client identification
|
||||
|
||||
} catch (IOException e) {
|
||||
LOGGER.log(Level.SEVERE, "[DXClusterSrvr] error in worker runnable", e);
|
||||
} finally {
|
||||
synchronized(dxClusterClientSocketsConnectedList) {
|
||||
dxClusterClientSocketsConnectedList.remove(clientSocket); // Entferne den Client nach Verarbeitung
|
||||
try {
|
||||
clientSocket.close();
|
||||
} catch (IOException ignored) {
|
||||
// The connection is already unusable.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import kst4contest.model.AirPlane;
|
||||
import kst4contest.model.AirPlaneReflectionInfo;
|
||||
import kst4contest.model.ChatMember;
|
||||
import kst4contest.model.ChatPreferences;
|
||||
|
||||
/**
|
||||
* Resolves variables used in operator messages, shortcuts, snippets and
|
||||
* beacons.
|
||||
*
|
||||
* <p>Global variables only depend on the local station configuration and can
|
||||
* therefore be used in every message context. Station variables additionally
|
||||
* require a selected remote station. Keeping both groups in one resolver
|
||||
* prevents the send field and the beacon tasks from implementing different
|
||||
* replacement rules.</p>
|
||||
*/
|
||||
public final class MessageVariableResolver {
|
||||
|
||||
private static final int SHORT_VALUE_LENGTH = 7;
|
||||
|
||||
private final ChatPreferences chatPreferences;
|
||||
|
||||
/**
|
||||
* Creates a resolver backed by the live application preferences.
|
||||
*
|
||||
* @param chatPreferences preferences that provide current station values
|
||||
*/
|
||||
public MessageVariableResolver(ChatPreferences chatPreferences) {
|
||||
this.chatPreferences = Objects.requireNonNull(chatPreferences, "chatPreferences");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves variables which do not require a selected remote station.
|
||||
*
|
||||
* <p>The replacement is literal rather than regular-expression based.
|
||||
* Callsigns, locators and frequencies are data, not regular expressions.</p>
|
||||
*
|
||||
* @param template text that may contain variables
|
||||
* @return text with all available global variables resolved, or {@code null}
|
||||
* when the supplied template is {@code null}
|
||||
*/
|
||||
public String resolveGlobalVariables(String template) {
|
||||
if (template == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String primaryFrequency = valueOrEmpty(chatPreferences.getMYQRGFirstCat().getValue());
|
||||
String secondaryFrequency = valueOrEmpty(chatPreferences.getMYQRGSecondCat().getValue());
|
||||
String ownLocator = valueOrEmpty(chatPreferences.getStn_loginLocatorMainCat());
|
||||
|
||||
String resolvedText = template;
|
||||
resolvedText = resolvedText.replace("MYQRGSHORT", abbreviate(primaryFrequency));
|
||||
resolvedText = resolvedText.replace("MYQRG", primaryFrequency);
|
||||
resolvedText = resolvedText.replace("SECONDQRG", secondaryFrequency);
|
||||
resolvedText = resolvedText.replace("MYLOCATORSHORT", abbreviateLocator(ownLocator));
|
||||
resolvedText = resolvedText.replace("MYLOCATOR", ownLocator);
|
||||
resolvedText = resolvedText.replace("MYCALL", valueOrEmpty(chatPreferences.getStn_loginCallSign()));
|
||||
resolvedText = resolvedText.replace("MYQTF", formatHeading(chatPreferences.getActualQTF().getValue().doubleValue()));
|
||||
return resolvedText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves global variables and variables derived from a selected station.
|
||||
*
|
||||
* <p>If no station is selected, station-specific placeholders remain visible.
|
||||
* This is intentional: silently removing {@code QRZNAME}, {@code FIRSTAP} or
|
||||
* {@code SECONDAP} could create a plausible-looking but incomplete message.</p>
|
||||
*
|
||||
* @param template text that may contain variables
|
||||
* @param selectedStation currently selected remote station, may be {@code null}
|
||||
* @return resolved message text
|
||||
*/
|
||||
public String resolveForSelectedStation(String template, ChatMember selectedStation) {
|
||||
String resolvedText = resolveGlobalVariables(template);
|
||||
|
||||
if (resolvedText == null || selectedStation == null) {
|
||||
return resolvedText;
|
||||
}
|
||||
|
||||
resolvedText = resolvedText.replace("QRZNAME", resolveStationName(selectedStation));
|
||||
resolvedText = resolvedText.replace("FIRSTAP", resolveFirstAirPlane(selectedStation));
|
||||
resolvedText = resolvedText.replace("SECONDAP", resolveSecondAirPlane(selectedStation));
|
||||
return resolvedText;
|
||||
}
|
||||
|
||||
private String resolveStationName(ChatMember selectedStation) {
|
||||
String stationName = valueOrEmpty(selectedStation.getName()).trim();
|
||||
|
||||
if (!stationName.isEmpty()) {
|
||||
return stationName;
|
||||
}
|
||||
|
||||
return valueOrEmpty(selectedStation.getCallSign());
|
||||
}
|
||||
|
||||
private String resolveFirstAirPlane(ChatMember selectedStation) {
|
||||
List<AirPlane> risingAirPlanes = getRisingAirPlanes(selectedStation);
|
||||
|
||||
if (risingAirPlanes.isEmpty()) {
|
||||
return "no ap available";
|
||||
}
|
||||
|
||||
AirPlane firstAirPlane = risingAirPlanes.get(0);
|
||||
return "a " + firstAirPlane.getPotencialDescriptionAsWord()
|
||||
+ " in " + firstAirPlane.getArrivingDurationMinutes() + " min";
|
||||
}
|
||||
|
||||
private String resolveSecondAirPlane(ChatMember selectedStation) {
|
||||
List<AirPlane> risingAirPlanes = getRisingAirPlanes(selectedStation);
|
||||
|
||||
if (risingAirPlanes.size() < 2) {
|
||||
return "";
|
||||
}
|
||||
|
||||
AirPlane secondAirPlane = risingAirPlanes.get(1);
|
||||
return "Next " + secondAirPlane.getPotencialDescriptionAsWord()
|
||||
+ " in " + secondAirPlane.getArrivingDurationMinutes() + " min";
|
||||
}
|
||||
|
||||
private List<AirPlane> getRisingAirPlanes(ChatMember selectedStation) {
|
||||
AirPlaneReflectionInfo reflectionInfo = selectedStation.getAirPlaneReflectInfo();
|
||||
|
||||
if (reflectionInfo == null || reflectionInfo.getRisingAirplanes() == null) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
return reflectionInfo.getRisingAirplanes();
|
||||
}
|
||||
|
||||
private String abbreviate(String value) {
|
||||
return value.substring(0, Math.min(value.length(), SHORT_VALUE_LENGTH));
|
||||
}
|
||||
|
||||
private String abbreviateLocator(String locator) {
|
||||
return locator.substring(0, Math.min(locator.length(), 4));
|
||||
}
|
||||
|
||||
private String formatHeading(double headingDegrees) {
|
||||
if (!Double.isFinite(headingDegrees)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return BigDecimal.valueOf(headingDegrees).stripTrailingZeros().toPlainString();
|
||||
}
|
||||
|
||||
private String valueOrEmpty(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.*;
|
||||
import java.util.ArrayList;
|
||||
import java.io.IOException;
|
||||
import java.net.DatagramPacket;
|
||||
import java.net.DatagramSocket;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.SocketException;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
@@ -21,188 +25,245 @@ import kst4contest.model.ThreadStateMessage;
|
||||
* @author www.codejava.net
|
||||
*/
|
||||
public class ReadUDPbyAirScoutMessageThread extends Thread {
|
||||
private BufferedReader reader;
|
||||
private Socket socket;
|
||||
private ChatController client;
|
||||
private int localPort;
|
||||
private String ASIdentificator, ChatClientIdentificator;
|
||||
private ThreadStatusCallback callBackToController;
|
||||
private String ThreadNickName = "AirScout msg";
|
||||
// public ReadUDPbyAirScoutMessageThread(int localPort) {
|
||||
// this.localPort = localPort;
|
||||
// }
|
||||
|
||||
public ReadUDPbyAirScoutMessageThread(int localPort, ChatController client, String ASIdentificator,
|
||||
String ChatClientIdentificator, ThreadStatusCallback callback) {
|
||||
private final ChatController client;
|
||||
private final int localPort;
|
||||
private final ThreadStatusCallback callBackToController;
|
||||
|
||||
this.callBackToController = callback;
|
||||
private final String threadNickName = "AirScout msg";
|
||||
|
||||
private DatagramSocket socket;
|
||||
|
||||
public ReadUDPbyAirScoutMessageThread(
|
||||
int localPort,
|
||||
ChatController client,
|
||||
ThreadStatusCallback callback
|
||||
) {
|
||||
this.localPort = localPort;
|
||||
this.client = client;
|
||||
this.ASIdentificator = ASIdentificator;
|
||||
this.ChatClientIdentificator = ChatClientIdentificator;
|
||||
this.callBackToController = callback;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void interrupt() {
|
||||
System.out.println("ReadUDP");
|
||||
super.interrupt();
|
||||
try {
|
||||
if (this.socket != null) {
|
||||
|
||||
this.socket.close();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// TODO Auto-generated catch block
|
||||
e.printStackTrace();
|
||||
|
||||
if (socket != null && !socket.isClosed()) {
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
|
||||
private void callThreadStateToUi (ThreadStateMessage threadStateMessage) {
|
||||
if (callBackToController != null) {
|
||||
//update the visual control of running thread
|
||||
callBackToController.onThreadStatus("AirScout", threadStateMessage);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Checks whether an AirScout response is addressed to the currently
|
||||
* configured server and client identifiers.
|
||||
*
|
||||
* Outgoing message:
|
||||
* ASSETPATH: "client" "server" ...
|
||||
*
|
||||
* Corresponding response:
|
||||
* ASNEAREST: "server" "client" ...
|
||||
*
|
||||
* The comparison is deliberately case-sensitive. In a setup with several
|
||||
* clients, KST-A and kst-a must not silently become the same destination.
|
||||
*
|
||||
* @param message received UDP message
|
||||
* @return true if the response belongs to this KST4Contest instance
|
||||
*/
|
||||
private boolean isMessageForConfiguredClient(String message) {
|
||||
if (message == null || !message.startsWith("ASNEAREST:")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
String[] quotedParts = message.split("\"");
|
||||
if (quotedParts.length < 4) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String receivedServerIdentifier = quotedParts[1].trim();
|
||||
String receivedClientIdentifier = quotedParts[3].trim();
|
||||
|
||||
String configuredServerIdentifier =
|
||||
client.getChatPreferences()
|
||||
.getAirScout_asServerNameString();
|
||||
String configuredClientIdentifier =
|
||||
client.getChatPreferences()
|
||||
.getAirScout_asClientNameString();
|
||||
|
||||
if (configuredServerIdentifier == null
|
||||
|| configuredClientIdentifier == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return configuredServerIdentifier.equals(
|
||||
receivedServerIdentifier
|
||||
) && configuredClientIdentifier.equals(
|
||||
receivedClientIdentifier
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
Thread.currentThread().setName("ReadUDPByAirScoutThread");
|
||||
|
||||
DatagramSocket socket = null;
|
||||
boolean running;
|
||||
byte[] buf = new byte[1777];
|
||||
DatagramPacket packet;
|
||||
// DatagramPacket packet = new DatagramPacket(buf, buf.length); //changed due to save memory
|
||||
packet = new DatagramPacket(buf, buf.length);
|
||||
Thread.currentThread().setName(
|
||||
"ReadUDPByAirScoutThread"
|
||||
);
|
||||
|
||||
try {
|
||||
|
||||
socket = new DatagramSocket(null);
|
||||
socket.setReuseAddress(true);
|
||||
socket.bind(new InetSocketAddress(localPort));
|
||||
socket.receive(packet);
|
||||
socket.setSoTimeout(3000);
|
||||
|
||||
} catch (SocketException e) {
|
||||
// TODO Auto-generated catch block
|
||||
e.printStackTrace();
|
||||
|
||||
} catch (IOException e) {
|
||||
// TODO Auto-generated catch block
|
||||
// e.printStackTrace();
|
||||
}
|
||||
|
||||
|
||||
while (true) {
|
||||
// packet = new DatagramPacket(buf, buf.length);
|
||||
// DatagramPacket packet = new DatagramPacket(SRPDefinitions.BYTE_BUFFER_MAX_LENGTH);
|
||||
try {
|
||||
if (this.client.isDisconnectionPerformedByUser()) {
|
||||
break;//TODO: what if it´s not the finally closage but a band channel change?
|
||||
while (!Thread.currentThread().isInterrupted()) {
|
||||
if (client.isDisconnectionPerformedByUser()) {
|
||||
break;
|
||||
}
|
||||
|
||||
socket.receive(packet);
|
||||
|
||||
byte[] buffer = new byte[1777];
|
||||
DatagramPacket packet = new DatagramPacket(
|
||||
buffer,
|
||||
buffer.length
|
||||
);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
} catch (SocketTimeoutException e2) {
|
||||
// this will catch the repeating Sockettimeoutexception...nothing to do
|
||||
// e2.printStackTrace();
|
||||
}
|
||||
catch (IOException e) {
|
||||
// TODO Auto-generated catch block
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
InetAddress address = packet.getAddress();
|
||||
int port = packet.getPort();
|
||||
// packet = new DatagramPacket(buf, buf.length, address, port);
|
||||
String received = new String(packet.getData(), packet.getOffset(), packet.getLength());
|
||||
received = received.trim();
|
||||
|
||||
if (received.contains(ApplicationConstants.DISCONNECT_RDR_POISONPILL)) {
|
||||
System.out.println("ReadUdpByASMsgTh, Info: got poison, now dieing....");
|
||||
try {
|
||||
terminateConnection();
|
||||
} catch (Exception e) {
|
||||
System.out.println("ASUDPRDR: catched error " + e.getMessage());
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
if (received.contains("ASSETPATH") || received.contains("ASWATCHLIST")) {
|
||||
// do nothing, that is your own message
|
||||
} else if (received.contains("ASNEAREST:")) { //answer by airscout
|
||||
|
||||
// processASUDPMessage(received); //TODO: 2025-11-Zeile deaktiviert. Fand hier Doppelberechnung statt?!
|
||||
|
||||
AirPlaneReflectionInfo apReflectInfoForChatMember;
|
||||
|
||||
apReflectInfoForChatMember = processASUDPMessage(received);
|
||||
if (!this.client.getLst_chatMemberList().isEmpty()) {
|
||||
|
||||
try {
|
||||
|
||||
// this.client.getLst_chatMemberList()
|
||||
// .get(this.client.checkListForChatMemberIndexByCallSign(
|
||||
// apReflectInfoForChatMember.getReceiver()))
|
||||
// .setAirPlaneReflectInfo(apReflectInfoForChatMember); // TODO: here we set the ap info at
|
||||
// // the central instance of
|
||||
// // chatmember list .... -1 is a
|
||||
// // problem!
|
||||
|
||||
ArrayList<Integer> addApInfoToThese = this.client.checkListForChatMemberIndexesByCallSign(apReflectInfoForChatMember.getReceiver());
|
||||
addApInfoToThese.forEach((integerIndex) -> {this.client.getLst_chatMemberList().get(integerIndex).setAirPlaneReflectInfo(apReflectInfoForChatMember); });
|
||||
|
||||
// AirScout availability strongly affects priority => request recompute the score of the chatmember
|
||||
this.client.getScoreService().requestRecompute("airscout-update");
|
||||
|
||||
/**
|
||||
* CK| MSGBUS BGFX Listactualizer Exception in thread "Thread-10"
|
||||
* java.util.ConcurrentModificationException at
|
||||
* java.base/java.util.AbstractList$Itr.checkForComodification(AbstractList.java:399)
|
||||
* at java.base/java.util.AbstractList$Itr.next(AbstractList.java:368) at
|
||||
* kst4contest.controller.ChatController.checkListForChatMemberIndexByCallSign(ChatController.java:173)
|
||||
* at
|
||||
* kst4contest.controller.ReadUDPbyAirScoutMessageThread.run(ReadUDPbyAirScoutMessageThread.java:93)
|
||||
*
|
||||
*/
|
||||
// System.out.println("[ReadUdpByASth, AP-Info catched: ] " + apReflectInfoForChatMember.toString());
|
||||
// }
|
||||
} catch (Exception e) {
|
||||
|
||||
System.out.println("ReadUdpByAsMsgTh, Warning:"
|
||||
+ apReflectInfoForChatMember.getReceiver().getCallSign()
|
||||
+ " is not in the Chatmemberlist or the Chatmemberlist is modified by another Thread");
|
||||
// TODO: handle exception
|
||||
}
|
||||
|
||||
// String[] newState = new String[3];
|
||||
// newState[0] = "On";
|
||||
// newState[1] = "received line";
|
||||
// newState[2] = apReflectInfoForChatMember.toString();
|
||||
// callThreadStateToUi(newState);
|
||||
ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "received line\n" + apReflectInfoForChatMember.toString(), false);
|
||||
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
|
||||
socket.receive(packet);
|
||||
} catch (SocketTimeoutException timeoutException) {
|
||||
/*
|
||||
* AirScout may remain silent for some time. Continue with a
|
||||
* new packet instead of processing the previous packet again.
|
||||
*/
|
||||
continue;
|
||||
}
|
||||
|
||||
}
|
||||
// packet = null; //reset packet
|
||||
buf = new byte[1777]; // reset buffer for future smaller packets
|
||||
String received = new String(
|
||||
packet.getData(),
|
||||
packet.getOffset(),
|
||||
packet.getLength()
|
||||
).trim();
|
||||
|
||||
if (received.contains(
|
||||
ApplicationConstants.DISCONNECT_RDR_POISONPILL
|
||||
)) {
|
||||
System.out.println(
|
||||
"[AirScout UDP, info]: Received shutdown packet."
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
/*
|
||||
* The socket remains bound so that AirScout can be enabled
|
||||
* without reconnecting. Disabled means that received data is
|
||||
* discarded and no station state is changed.
|
||||
*/
|
||||
if (!client.getChatPreferences()
|
||||
.isAirScout_asUDPListenerEnabled()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isMessageForConfiguredClient(received)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
processAirScoutResponse(received);
|
||||
}
|
||||
} catch (SocketException exception) {
|
||||
if (!Thread.currentThread().isInterrupted()) {
|
||||
System.out.println(
|
||||
"[AirScout UDP, error]: Could not use UDP port "
|
||||
+ localPort
|
||||
+ ": "
|
||||
+ exception.getMessage()
|
||||
);
|
||||
}
|
||||
} catch (IOException exception) {
|
||||
if (!Thread.currentThread().isInterrupted()) {
|
||||
System.out.println(
|
||||
"[AirScout UDP, error]: Communication failed: "
|
||||
+ exception.getMessage()
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (socket != null && !socket.isClosed()) {
|
||||
socket.close();
|
||||
}
|
||||
|
||||
socket = null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an AirScout response and applies it to every active category
|
||||
* instance of the reported station.
|
||||
*
|
||||
* @param received received ASNEAREST message
|
||||
*/
|
||||
private void processAirScoutResponse(String received) {
|
||||
try {
|
||||
AirPlaneReflectionInfo reflectionInfo =
|
||||
processASUDPMessage(received);
|
||||
|
||||
if (reflectionInfo == null
|
||||
|| reflectionInfo.getReceiver() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String receiverCallSign =
|
||||
reflectionInfo.getReceiver().getCallSignRaw();
|
||||
|
||||
if (receiverCallSign == null
|
||||
|| receiverCallSign.isBlank()) {
|
||||
receiverCallSign =
|
||||
reflectionInfo.getReceiver().getCallSign();
|
||||
}
|
||||
|
||||
if (receiverCallSign == null
|
||||
|| receiverCallSign.isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<ChatMember> matchingMembers =
|
||||
client.findActiveChatMembersByRawCall(
|
||||
receiverCallSign
|
||||
);
|
||||
|
||||
for (ChatMember matchingMember : matchingMembers) {
|
||||
matchingMember.setAirPlaneReflectInfo(
|
||||
reflectionInfo
|
||||
);
|
||||
}
|
||||
|
||||
if (!matchingMembers.isEmpty()) {
|
||||
client.getScoreService().requestRecompute(
|
||||
"airscout-update"
|
||||
);
|
||||
}
|
||||
|
||||
if (callBackToController != null) {
|
||||
ThreadStateMessage threadStateMessage =
|
||||
new ThreadStateMessage(
|
||||
threadNickName,
|
||||
true,
|
||||
"Received AirScout response\n"
|
||||
+ reflectionInfo,
|
||||
false
|
||||
);
|
||||
|
||||
callBackToController.onThreadStatus(
|
||||
threadNickName,
|
||||
threadStateMessage
|
||||
);
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
System.out.println(
|
||||
"[AirScout UDP, warning]: Could not process response: "
|
||||
+ exception.getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
public AirPlaneReflectionInfo processASUDPMessage(String udpStringToProcess) {
|
||||
|
||||
// System.out.println("RDUDPAS RECV: " + udpStringToProcess);
|
||||
|
||||
// TODO: filter messages which are directed to another client
|
||||
|
||||
|
||||
/*
|
||||
* Example mesage: ASNEAREST: "AS" "KST"
|
||||
@@ -304,11 +365,8 @@ public class ReadUDPbyAirScoutMessageThread extends Thread {
|
||||
}
|
||||
|
||||
public boolean terminateConnection() {
|
||||
|
||||
try {
|
||||
this.socket.close();
|
||||
} catch (Exception e) {
|
||||
System.out.println("udpbyas: catched " + e.getMessage());
|
||||
if (socket != null && !socket.isClosed()) {
|
||||
socket.close();
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -83,6 +83,9 @@ public class ReadUDPbyUCXMessageThread extends Thread {
|
||||
if (xmlStart < 0) {
|
||||
xmlStart = rawPacket.indexOf("<contactinfo");
|
||||
}
|
||||
if (xmlStart < 0) {
|
||||
xmlStart = rawPacket.indexOf("<contactreplace");
|
||||
}
|
||||
if (xmlStart < 0) {
|
||||
xmlStart = rawPacket.indexOf("<RadioInfo");
|
||||
}
|
||||
@@ -313,12 +316,26 @@ public class ReadUDPbyUCXMessageThread extends Thread {
|
||||
Document doc = db.parse(new InputSource(new StringReader(udpMsg)));
|
||||
|
||||
/**
|
||||
* case Log-QSO-Packet in ucxlog
|
||||
* case Log-QSO-Packet in ucxlog / DXLog and compatible
|
||||
*
|
||||
*/
|
||||
NodeList list = doc.getElementsByTagName("contactinfo");
|
||||
|
||||
if (list.getLength() == 0) {
|
||||
list = doc.getElementsByTagName("contactreplace");
|
||||
//DXlog will send contactreplace instead of contactinfo on clicking "broadcast whole logbook"
|
||||
}
|
||||
|
||||
if (list.getLength() != 0) {
|
||||
|
||||
/*
|
||||
* QSO and TRX information share the same UDP receiver. The log-sync
|
||||
* preference therefore controls processing, not ownership of the socket.
|
||||
*/
|
||||
if (!client.getChatPreferences().isLogsynch_ucxUDPWkdCallListenerEnabled()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
for (int temp = 0; temp < list.getLength(); temp++) {
|
||||
|
||||
Node node = list.item(temp);
|
||||
@@ -591,6 +608,15 @@ public class ReadUDPbyUCXMessageThread extends Thread {
|
||||
} else {
|
||||
list = doc.getElementsByTagName("RadioInfo");
|
||||
|
||||
/*
|
||||
* RadioInfo packets may arrive on the shared UDP port even when automatic
|
||||
* QRG synchronization is disabled. Ignore them unless TRX sync is enabled.
|
||||
*/
|
||||
if (list.getLength() != 0
|
||||
&& !client.getChatPreferences().isTrxSynch_ucxLogUDPListenerEnabled()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
for (int temp = 0; temp < list.getLength(); temp++) {
|
||||
|
||||
Node node = list.item(temp);
|
||||
|
||||
@@ -14,8 +14,8 @@ import java.util.TimerTask;
|
||||
* The task will be runned out of the singleton ChatController instance in an
|
||||
* intervall as specified by the Chatpreferences-instance (typically as
|
||||
* configured in the xml file.
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
* @author prakt
|
||||
*
|
||||
*/
|
||||
@@ -34,22 +34,20 @@ public class ScoreboardUpdateTask extends TimerTask {
|
||||
Thread.currentThread().setName("BeaconTask");
|
||||
|
||||
ChatMessage beaconMSG = new ChatMessage();
|
||||
|
||||
String replaceVariables = this.chatController.getChatPreferences().getBcn_beaconTextMainCat();
|
||||
// replaceVariables = bcn_beaconText;
|
||||
|
||||
replaceVariables = replaceVariables.replaceAll("MYQRG", this.chatController.getChatPreferences().getMYQRGFirstCat().getValue());
|
||||
replaceVariables = replaceVariables.replaceAll("MYCALL", this.chatController.getChatPreferences().getStn_loginCallSign());
|
||||
replaceVariables = replaceVariables.replaceAll("MYLOCATOR", this.chatController.getChatPreferences().getStn_loginLocatorMainCat());
|
||||
replaceVariables = replaceVariables.replaceAll("MYQTF", this.chatController.getChatPreferences().getActualQTF().getValue() + "");
|
||||
|
||||
|
||||
MessageVariableResolver variableResolver =
|
||||
new MessageVariableResolver(this.chatController.getChatPreferences());
|
||||
String replaceVariables = variableResolver.resolveGlobalVariables(
|
||||
this.chatController.getChatPreferences().getBcn_beaconTextMainCat()
|
||||
);
|
||||
|
||||
|
||||
beaconMSG.setMessageText(
|
||||
"MSG|" + this.chatController.getChatPreferences().getLoginChatCategoryMain().getCategoryNumber() + "|0|" + replaceVariables + "|0|");
|
||||
beaconMSG.setMessageDirectedToServer(true);
|
||||
|
||||
|
||||
// System.out.println("########### " + replaceVariables);
|
||||
|
||||
|
||||
if (this.chatController.getChatPreferences().isBcn_beaconsEnabledMainCat() ) {
|
||||
|
||||
System.out.println(new Utils4KST().time_generateCurrentMMDDhhmmTimeString()
|
||||
@@ -58,8 +56,8 @@ public class ScoreboardUpdateTask extends TimerTask {
|
||||
} else {
|
||||
//do nothing, CQ is disabled
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,16 @@ public class UpdateChecker {
|
||||
|
||||
Element element = (Element) node;
|
||||
updateInfos.setLatestVersionNumberOnServer(Double.parseDouble(element.getElementsByTagName("versionNumber").item(0).getTextContent()));
|
||||
|
||||
NodeList semanticVersionNodes =
|
||||
element.getElementsByTagName("semanticVersion");
|
||||
|
||||
if (semanticVersionNodes.getLength() > 0) {
|
||||
updateInfos.setLatestSemanticVersionOnServer(
|
||||
semanticVersionNodes.item(0).getTextContent()
|
||||
);
|
||||
}
|
||||
|
||||
updateInfos.setAdminMessage(element.getElementsByTagName("adminMessage").item(0).getTextContent());
|
||||
updateInfos.setMajorChanges(element.getElementsByTagName("majorChanges").item(0)
|
||||
.getTextContent());
|
||||
|
||||
@@ -26,6 +26,14 @@ public class UserActualizationTask extends TimerTask {
|
||||
|
||||
Thread.currentThread().setName("UserActualizationTask");
|
||||
|
||||
/*
|
||||
* File-based log synchronization is optional. Do not create, open or parse the
|
||||
* configured file while the feature is disabled.
|
||||
*/
|
||||
if (!client.getChatPreferences().isLogsynch_fileBasedWkdCallInterpreterEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// System.out.println("[Useract: ] Thread runned now");
|
||||
|
||||
// System.out.println("***********************Useract started");
|
||||
|
||||
@@ -26,9 +26,16 @@ public class ChatMember {
|
||||
String name;
|
||||
String callSignRaw; //without -2 or -70 etc.
|
||||
|
||||
/**
|
||||
* A directional opportunity inferred from a directed chat message remains
|
||||
* relevant for five minutes. The timestamp is used instead of a permanent
|
||||
* boolean so an old antenna-direction assumption cannot remain visible
|
||||
* indefinitely.
|
||||
*/
|
||||
static final long DIRECTION_OPPORTUNITY_VALIDITY_MILLIS = 5L * 60L * 1000L;
|
||||
|
||||
private volatile long directionOpportunityValidUntilEpochMs;
|
||||
|
||||
boolean isInAngleAndRange; //if he tries a sked in my dir, he is in range, will process that in the messages
|
||||
|
||||
// String frequency; // last known qrg of the station
|
||||
|
||||
@@ -108,12 +115,37 @@ public class ChatMember {
|
||||
this.lastFlagsChangeEpochMs = lastFlagsChangeEpochMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the most recently inferred directional opportunity is
|
||||
* still valid.
|
||||
*
|
||||
* @return {@code true} until the five-minute validity period has expired
|
||||
*/
|
||||
public boolean isInAngleAndRange() {
|
||||
return isInAngleAndRange;
|
||||
return isInAngleAndRangeAt(System.currentTimeMillis());
|
||||
}
|
||||
|
||||
/**
|
||||
* Time-aware variant used by the public getter and by unit tests.
|
||||
*
|
||||
* @param nowEpochMs time against which the validity is checked
|
||||
* @return {@code true} while the stored validity timestamp is still in the future
|
||||
*/
|
||||
boolean isInAngleAndRangeAt(long nowEpochMs) {
|
||||
return directionOpportunityValidUntilEpochMs > nowEpochMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a new five-minute validity period or removes the current
|
||||
* directional opportunity immediately.
|
||||
*
|
||||
* @param inAngleAndRange {@code true} for a newly detected opportunity;
|
||||
* {@code false} to clear it
|
||||
*/
|
||||
public void setInAngleAndRange(boolean inAngleAndRange) {
|
||||
isInAngleAndRange = inAngleAndRange;
|
||||
directionOpportunityValidUntilEpochMs = inAngleAndRange
|
||||
? System.currentTimeMillis() + DIRECTION_OPPORTUNITY_VALIDITY_MILLIS
|
||||
: 0L;
|
||||
}
|
||||
|
||||
public AirPlaneReflectionInfo getAirPlaneReflectInfo() {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package kst4contest.model;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class ChatMemberDirectionOpportunityTest {
|
||||
|
||||
@Test
|
||||
void directionalOpportunityExpiresAfterConfiguredValidityPeriod() {
|
||||
ChatMember member = new ChatMember();
|
||||
|
||||
member.setInAngleAndRange(true);
|
||||
long afterActivationEpochMs = System.currentTimeMillis();
|
||||
|
||||
assertTrue(member.isInAngleAndRangeAt(afterActivationEpochMs));
|
||||
assertFalse(member.isInAngleAndRangeAt(
|
||||
afterActivationEpochMs
|
||||
+ ChatMember.DIRECTION_OPPORTUNITY_VALIDITY_MILLIS
|
||||
+ 1L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void directionalOpportunityCanBeClearedImmediately() {
|
||||
ChatMember member = new ChatMember();
|
||||
|
||||
member.setInAngleAndRange(true);
|
||||
assertTrue(member.isInAngleAndRange());
|
||||
|
||||
member.setInAngleAndRange(false);
|
||||
assertFalse(member.isInAngleAndRange());
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@ public class ChatPreferences {
|
||||
* Reading must stay backwards compatible: missing/unknown tags should fall back to defaults.
|
||||
*/
|
||||
// private static final int CONFIG_VERSION = 2;
|
||||
public static final int CONFIG_VERSION = 3;
|
||||
public static final int CONFIG_VERSION = 4;
|
||||
|
||||
// Prefer writing tag names that mirror variable names (human readable). Keep legacy tags for compatibility.
|
||||
private static final String TAG_CONFIG_VERSION = "configVersion";
|
||||
@@ -159,6 +159,8 @@ public class ChatPreferences {
|
||||
int stn_on4kstServersPort = 23001;
|
||||
|
||||
boolean stn_pstRotatorEnabled = false;
|
||||
String stn_pstRotatorHost = "127.0.0.1";
|
||||
int stn_pstRotatorPort = 12000;
|
||||
|
||||
boolean stn_loginAFKState = false; //always start as here
|
||||
String stn_loginCallSign = "do5amf";
|
||||
@@ -238,9 +240,11 @@ public class ChatPreferences {
|
||||
/**
|
||||
* AirScout prefs
|
||||
*/
|
||||
boolean AirScout_asUDPListenerEnabled;
|
||||
String AirScout_asServerNameString, AirScout_asClientNameString, AirScout_asBandString;
|
||||
int AirScout_asCommunicationPort;
|
||||
boolean AirScout_asUDPListenerEnabled = true;
|
||||
String AirScout_asServerNameString = "AS";
|
||||
String AirScout_asClientNameString = "KST";
|
||||
String AirScout_asBandString = "1440000";
|
||||
int AirScout_asCommunicationPort = 9872;
|
||||
|
||||
/**
|
||||
* Notification prefs
|
||||
@@ -263,6 +267,9 @@ public class ChatPreferences {
|
||||
boolean notify_DXClusterServerTriggerBearing;
|
||||
boolean notify_DXClusterServerTriggerOnQRGDetect;
|
||||
|
||||
ObservableList<String>
|
||||
lstNotify_QSOSniffer_sniffedCallSignList =
|
||||
FXCollections.observableArrayList();
|
||||
// ObservableList<String> lstNotify_QSOSniffer_sniffedCallSignList = FXCollections.observableArrayList();
|
||||
ObservableList<String> lstNotify_QSOSniffer_sniffedWordsList = FXCollections.observableArrayList();
|
||||
ObservableList<String> lstNotify_QSOSniffer_sniffedPrefixLocList = FXCollections.observableArrayList();
|
||||
@@ -307,8 +314,8 @@ public class ChatPreferences {
|
||||
String messageHandling_unworkedStnRequesterBeaconsText;
|
||||
String messageHandling_beaconUnworkedstationsPrefix;
|
||||
|
||||
String messageHandling_autoAnswerTextMainCat = "Hi, sry I am not qrv, just testing new features of KST4Contest " + ApplicationConstants.APPLICATION_CURRENTVERSIONNUMBER;
|
||||
String messageHandling_autoAnswerTextSecondCat = "Hi, sry I am not qrv, just testing new features of KST4Contest " + ApplicationConstants.APPLICATION_CURRENTVERSIONNUMBER;
|
||||
String messageHandling_autoAnswerTextMainCat = "Hi, sry I am not qrv, just testing new features of KST4Contest " + ApplicationConstants.APPLICATION_CURRENT_VERSION;
|
||||
String messageHandling_autoAnswerTextSecondCat = "Hi, sry I am not qrv, just testing new features of KST4Contest " + ApplicationConstants.APPLICATION_CURRENT_VERSION;
|
||||
|
||||
boolean messageHandling_autoAnswerEnabled = false;
|
||||
boolean messageHandling_autoAnswerEnabledSecondCat = false;
|
||||
@@ -451,6 +458,11 @@ public class ChatPreferences {
|
||||
this.stn_on4kstServersDns = stn_on4kstServersDns;
|
||||
}
|
||||
|
||||
public ObservableList<String>
|
||||
getLstNotify_QSOSniffer_sniffedCallSignList() {
|
||||
return lstNotify_QSOSniffer_sniffedCallSignList;
|
||||
}
|
||||
|
||||
public ObservableList<String> getLstNotify_QSOSniffer_sniffedWordsList() {
|
||||
return lstNotify_QSOSniffer_sniffedWordsList;
|
||||
}
|
||||
@@ -761,6 +773,30 @@ public class ChatPreferences {
|
||||
this.stn_pstRotatorEnabled = stn_pstRotatorEnabled;
|
||||
}
|
||||
|
||||
public String getStn_pstRotatorHost() {
|
||||
return stn_pstRotatorHost;
|
||||
}
|
||||
|
||||
public void setStn_pstRotatorHost(String stn_pstRotatorHost) {
|
||||
if (stn_pstRotatorHost == null || stn_pstRotatorHost.isBlank()) {
|
||||
this.stn_pstRotatorHost = "127.0.0.1";
|
||||
} else {
|
||||
this.stn_pstRotatorHost = stn_pstRotatorHost.trim();
|
||||
}
|
||||
}
|
||||
|
||||
public int getStn_pstRotatorPort() {
|
||||
return stn_pstRotatorPort;
|
||||
}
|
||||
|
||||
public void setStn_pstRotatorPort(int stn_pstRotatorPort) {
|
||||
if (stn_pstRotatorPort < 1 || stn_pstRotatorPort > 65535) {
|
||||
this.stn_pstRotatorPort = 12000;
|
||||
} else {
|
||||
this.stn_pstRotatorPort = stn_pstRotatorPort;
|
||||
}
|
||||
}
|
||||
|
||||
public SimpleStringProperty getNotify_optionalFrequencyPrefix() {
|
||||
return notify_optionalFrequencyPrefix;
|
||||
}
|
||||
@@ -773,8 +809,16 @@ public class ChatPreferences {
|
||||
return notify_dxclusterServerPort;
|
||||
}
|
||||
|
||||
public void setNotify_dxclusterServerPort(int notify_dxclusterServerPort) {
|
||||
this.notify_dxclusterServerPort = notify_dxclusterServerPort;
|
||||
public void setNotify_dxclusterServerPort(
|
||||
int notify_dxclusterServerPort
|
||||
) {
|
||||
if (notify_dxclusterServerPort < 1
|
||||
|| notify_dxclusterServerPort > 65535) {
|
||||
this.notify_dxclusterServerPort = 8000;
|
||||
} else {
|
||||
this.notify_dxclusterServerPort =
|
||||
notify_dxclusterServerPort;
|
||||
}
|
||||
}
|
||||
|
||||
public double[] getGUIscn_ChatwindowMainSceneSizeHW() {
|
||||
@@ -895,35 +939,100 @@ public class ChatPreferences {
|
||||
return stn_loginCallSignRaw;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Normalizes an AirScout routing identifier.
|
||||
*
|
||||
* AirScout encloses the identifiers in quotation marks. Empty identifiers,
|
||||
* quotation marks and line breaks would therefore produce an invalid protocol
|
||||
* message and are replaced with the supplied default value.
|
||||
*
|
||||
* @param identifier configured identifier
|
||||
* @param defaultIdentifier fallback value
|
||||
* @return normalized identifier
|
||||
*/
|
||||
private String normalizeAirScoutIdentifier(
|
||||
String identifier,
|
||||
String defaultIdentifier
|
||||
) {
|
||||
if (identifier == null) {
|
||||
return defaultIdentifier;
|
||||
}
|
||||
|
||||
String normalizedIdentifier = identifier.trim();
|
||||
|
||||
if (normalizedIdentifier.isEmpty()
|
||||
|| normalizedIdentifier.contains("\"")
|
||||
|| normalizedIdentifier.contains("\r")
|
||||
|| normalizedIdentifier.contains("\n")) {
|
||||
return defaultIdentifier;
|
||||
}
|
||||
|
||||
return normalizedIdentifier;
|
||||
}
|
||||
|
||||
public String getAirScout_asBandString() {
|
||||
return AirScout_asBandString;
|
||||
}
|
||||
|
||||
public void setAirScout_asBandString(String airScout_asBandString) {
|
||||
AirScout_asBandString = airScout_asBandString;
|
||||
if (airScout_asBandString == null) {
|
||||
AirScout_asBandString = "1440000";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
long parsedBandValue = Long.parseLong(
|
||||
airScout_asBandString.trim()
|
||||
);
|
||||
|
||||
AirScout_asBandString = parsedBandValue > 0
|
||||
? Long.toString(parsedBandValue)
|
||||
: "1440000";
|
||||
} catch (NumberFormatException exception) {
|
||||
AirScout_asBandString = "1440000";
|
||||
}
|
||||
}
|
||||
|
||||
public String getAirScout_asServerNameString() {
|
||||
return AirScout_asServerNameString;
|
||||
}
|
||||
|
||||
public void setAirScout_asServerNameString(String airScout_asServerNameString) {
|
||||
AirScout_asServerNameString = airScout_asServerNameString;
|
||||
public void setAirScout_asServerNameString(
|
||||
String airScout_asServerNameString
|
||||
) {
|
||||
AirScout_asServerNameString = normalizeAirScoutIdentifier(
|
||||
airScout_asServerNameString,
|
||||
"AS"
|
||||
);
|
||||
}
|
||||
|
||||
public String getAirScout_asClientNameString() {
|
||||
return AirScout_asClientNameString;
|
||||
}
|
||||
|
||||
public void setAirScout_asClientNameString(String airScout_asClientNameString) {
|
||||
AirScout_asClientNameString = airScout_asClientNameString;
|
||||
public void setAirScout_asClientNameString(
|
||||
String airScout_asClientNameString
|
||||
) {
|
||||
AirScout_asClientNameString = normalizeAirScoutIdentifier(
|
||||
airScout_asClientNameString,
|
||||
"KST"
|
||||
);
|
||||
}
|
||||
|
||||
public int getAirScout_asCommunicationPort() {
|
||||
return AirScout_asCommunicationPort;
|
||||
}
|
||||
|
||||
public void setAirScout_asCommunicationPort(int airScout_asCommunicationPort) {
|
||||
public void setAirScout_asCommunicationPort(
|
||||
int airScout_asCommunicationPort
|
||||
) {
|
||||
if (airScout_asCommunicationPort < 1
|
||||
|| airScout_asCommunicationPort > 65535) {
|
||||
AirScout_asCommunicationPort = 9872;
|
||||
return;
|
||||
}
|
||||
|
||||
AirScout_asCommunicationPort = airScout_asCommunicationPort;
|
||||
}
|
||||
|
||||
@@ -931,10 +1040,13 @@ public class ChatPreferences {
|
||||
return AirScout_asUDPListenerEnabled;
|
||||
}
|
||||
|
||||
public void setAirScout_asUDPListenerEnabled(boolean airScout_asUDPListenerEnabled) {
|
||||
public void setAirScout_asUDPListenerEnabled(
|
||||
boolean airScout_asUDPListenerEnabled
|
||||
) {
|
||||
AirScout_asUDPListenerEnabled = airScout_asUDPListenerEnabled;
|
||||
}
|
||||
|
||||
|
||||
public String getChatState() {
|
||||
return chatState;
|
||||
}
|
||||
@@ -1418,6 +1530,13 @@ public class ChatPreferences {
|
||||
stn_pstRotatorEnabled.setTextContent(this.stn_pstRotatorEnabled + "");
|
||||
station.appendChild(stn_pstRotatorEnabled);
|
||||
|
||||
Element stn_pstRotatorHost = doc.createElement("stn_pstRotatorHost");
|
||||
stn_pstRotatorHost.setTextContent(this.stn_pstRotatorHost);
|
||||
station.appendChild(stn_pstRotatorHost);
|
||||
|
||||
Element stn_pstRotatorPort = doc.createElement("stn_pstRotatorPort");
|
||||
stn_pstRotatorPort.setTextContent(Integer.toString(this.stn_pstRotatorPort));
|
||||
station.appendChild(stn_pstRotatorPort);
|
||||
|
||||
|
||||
/**
|
||||
@@ -1634,6 +1753,18 @@ public class ChatPreferences {
|
||||
snifferWords.appendChild(temp);
|
||||
}
|
||||
|
||||
Element snifferCallSigns =
|
||||
doc.createElement("snifferCallSigns");
|
||||
rootElement.appendChild(snifferCallSigns);
|
||||
|
||||
for (String callSign
|
||||
: lstNotify_QSOSniffer_sniffedCallSignList) {
|
||||
Element temp = doc.createElement("callSign");
|
||||
temp.setTextContent(callSign);
|
||||
snifferCallSigns.appendChild(temp);
|
||||
}
|
||||
|
||||
|
||||
Element snifferPrefixes = doc.createElement("snifferPrefixes");
|
||||
rootElement.appendChild(snifferPrefixes);
|
||||
|
||||
@@ -2092,6 +2223,22 @@ public class ChatPreferences {
|
||||
|
||||
stn_pstRotatorEnabled = getBoolean(stationEl, stn_pstRotatorEnabled, "stn_pstRotatorEnabled");
|
||||
|
||||
setStn_pstRotatorHost(
|
||||
getText(
|
||||
stationEl,
|
||||
stn_pstRotatorHost,
|
||||
"stn_pstRotatorHost"
|
||||
)
|
||||
);
|
||||
|
||||
setStn_pstRotatorPort(
|
||||
getInt(
|
||||
stationEl,
|
||||
stn_pstRotatorPort,
|
||||
"stn_pstRotatorPort"
|
||||
)
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2216,7 +2363,13 @@ public class ChatPreferences {
|
||||
notify_dxClusterServerEnabled = getBoolean(notificationsEl, notify_dxClusterServerEnabled, "notify_dxClusterServerEnabled");
|
||||
notify_DXClusterServerTriggerBearing = getBoolean(notificationsEl, notify_DXClusterServerTriggerBearing, "notify_DXClusterServerTriggerBearing");
|
||||
notify_DXClusterServerTriggerOnQRGDetect = getBoolean(notificationsEl, notify_DXClusterServerTriggerOnQRGDetect, "notify_DXClusterServerTriggerOnQRGDetect");
|
||||
notify_dxclusterServerPort = getInt(notificationsEl, notify_dxclusterServerPort, "notify_dxclusterServerPort");
|
||||
setNotify_dxclusterServerPort(
|
||||
getInt(
|
||||
notificationsEl,
|
||||
notify_dxclusterServerPort,
|
||||
"notify_dxclusterServerPort"
|
||||
)
|
||||
);
|
||||
|
||||
String spotter = getText(notificationsEl, null, "notify_DXCSrv_SpottersCallSign");
|
||||
if (spotter != null) {
|
||||
@@ -2267,15 +2420,58 @@ public class ChatPreferences {
|
||||
|
||||
Element airScoutEl = getFirstElement(doc, "AirScoutQuerier");
|
||||
if (airScoutEl != null) {
|
||||
AirScout_asUDPListenerEnabled = getBoolean(airScoutEl, AirScout_asUDPListenerEnabled, "asQry_airScoutCommunicationEnabled");
|
||||
AirScout_asServerNameString = getText(airScoutEl, AirScout_asServerNameString, "asQry_airScoutServerName");
|
||||
AirScout_asClientNameString = getText(airScoutEl, AirScout_asClientNameString, "asQry_airScoutClientName");
|
||||
AirScout_asCommunicationPort = getInt(airScoutEl, AirScout_asCommunicationPort, "asQry_airScoutUDPPort");
|
||||
AirScout_asBandString = getText(airScoutEl, AirScout_asBandString, "asQry_airScoutBandValue");
|
||||
setAirScout_asUDPListenerEnabled(
|
||||
getBoolean(
|
||||
airScoutEl,
|
||||
AirScout_asUDPListenerEnabled,
|
||||
"asQry_airScoutCommunicationEnabled"
|
||||
)
|
||||
);
|
||||
|
||||
setAirScout_asServerNameString(
|
||||
getText(
|
||||
airScoutEl,
|
||||
AirScout_asServerNameString,
|
||||
"asQry_airScoutServerName"
|
||||
)
|
||||
);
|
||||
|
||||
setAirScout_asClientNameString(
|
||||
getText(
|
||||
airScoutEl,
|
||||
AirScout_asClientNameString,
|
||||
"asQry_airScoutClientName"
|
||||
)
|
||||
);
|
||||
|
||||
setAirScout_asCommunicationPort(
|
||||
getInt(
|
||||
airScoutEl,
|
||||
AirScout_asCommunicationPort,
|
||||
"asQry_airScoutUDPPort"
|
||||
)
|
||||
);
|
||||
|
||||
setAirScout_asBandString(
|
||||
getText(
|
||||
airScoutEl,
|
||||
AirScout_asBandString,
|
||||
"asQry_airScoutBandValue"
|
||||
)
|
||||
);
|
||||
|
||||
System.out.println(
|
||||
"[ChatPreferences, info]: AirScout querier enabled=" + AirScout_asUDPListenerEnabled
|
||||
+ ", band=" + AirScout_asBandString);
|
||||
"[ChatPreferences, info]: AirScout integration enabled="
|
||||
+ AirScout_asUDPListenerEnabled
|
||||
+ ", server identifier="
|
||||
+ AirScout_asServerNameString
|
||||
+ ", client identifier="
|
||||
+ AirScout_asClientNameString
|
||||
+ ", port="
|
||||
+ AirScout_asCommunicationPort
|
||||
+ ", band="
|
||||
+ AirScout_asBandString
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2325,6 +2521,45 @@ public class ChatPreferences {
|
||||
/**
|
||||
* Case QSO-sniffer lists (added later; older configs won't have them)
|
||||
*/
|
||||
|
||||
list = doc.getElementsByTagName("snifferCallSigns");
|
||||
|
||||
if (list != null && list.getLength() != 0) {
|
||||
lstNotify_QSOSniffer_sniffedCallSignList.clear();
|
||||
|
||||
for (int temp = 0; temp < list.getLength(); temp++) {
|
||||
Node node = list.item(temp);
|
||||
|
||||
if (node.getNodeType() == Node.ELEMENT_NODE) {
|
||||
NodeList children = node.getChildNodes();
|
||||
|
||||
for (int i = 0; i < children.getLength(); i++) {
|
||||
Node child = children.item(i);
|
||||
|
||||
if (child.getNodeType()
|
||||
== Node.ELEMENT_NODE) {
|
||||
String callSign =
|
||||
child.getTextContent();
|
||||
|
||||
if (callSign != null
|
||||
&& !callSign.isBlank()) {
|
||||
String normalizedCallSign =
|
||||
callSign
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
|
||||
if (!lstNotify_QSOSniffer_sniffedCallSignList
|
||||
.contains(normalizedCallSign)) {
|
||||
lstNotify_QSOSniffer_sniffedCallSignList
|
||||
.add(normalizedCallSign);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
list = doc.getElementsByTagName("snifferWords");
|
||||
if (list != null && list.getLength() != 0) {
|
||||
// reset to avoid duplicates when reloading
|
||||
@@ -2457,6 +2692,14 @@ public class ChatPreferences {
|
||||
messageHandling_autoAnswerEnabledSecondCat,
|
||||
"messageHandling_autoAnswerEnabledSecondCat",
|
||||
"autoAnswerEnabledSecondCat");
|
||||
|
||||
/*
|
||||
* The user interface intentionally exposes one shared generic auto-answer
|
||||
* setting for both chat categories. Keep the legacy second-category XML
|
||||
* fields synchronized so existing configuration files remain compatible.
|
||||
*/
|
||||
messageHandling_autoAnswerTextSecondCat = messageHandling_autoAnswerTextMainCat;
|
||||
messageHandling_autoAnswerEnabledSecondCat = messageHandling_autoAnswerEnabled;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@ package kst4contest.model;
|
||||
import java.util.ArrayList;
|
||||
|
||||
public class UpdateInformation {
|
||||
double latestVersionNumberOnServer = 1.26; //dummy value to prevent nullpointerexc
|
||||
// double latestVersionNumberOnServer = 1.26; //dummy value to prevent nullpointerexc
|
||||
double latestVersionNumberOnServer = 0.0;
|
||||
String latestSemanticVersionOnServer = "";
|
||||
|
||||
String adminMessage ="";
|
||||
String majorChanges ="";
|
||||
String latestVersionPathOnWebserver="";
|
||||
@@ -36,6 +39,27 @@ public class UpdateInformation {
|
||||
public void setLatestVersionNumberOnServer(double latestVersionNumberOnServer) {
|
||||
this.latestVersionNumberOnServer = latestVersionNumberOnServer;
|
||||
}
|
||||
public String getLatestSemanticVersionOnServer() {
|
||||
return latestSemanticVersionOnServer;
|
||||
}
|
||||
|
||||
public void setLatestSemanticVersionOnServer(String latestSemanticVersionOnServer) {
|
||||
this.latestSemanticVersionOnServer =
|
||||
latestSemanticVersionOnServer == null ? "" : latestSemanticVersionOnServer.trim();
|
||||
}
|
||||
|
||||
public boolean hasSemanticVersion() {
|
||||
return !latestSemanticVersionOnServer.isBlank();
|
||||
}
|
||||
|
||||
public String getLatestVersionForDisplay() {
|
||||
if (hasSemanticVersion()) {
|
||||
return latestSemanticVersionOnServer;
|
||||
}
|
||||
|
||||
return Double.toString(latestVersionNumberOnServer);
|
||||
}
|
||||
|
||||
|
||||
public String getAdminMessage() {
|
||||
return adminMessage;
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package kst4contest.test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import javafx.beans.property.SimpleDoubleProperty;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import kst4contest.controller.MessageVariableResolver;
|
||||
import kst4contest.model.AirPlane;
|
||||
import kst4contest.model.AirPlaneReflectionInfo;
|
||||
import kst4contest.model.ChatMember;
|
||||
import kst4contest.model.ChatPreferences;
|
||||
|
||||
class MessageVariableResolverTest {
|
||||
|
||||
private ChatPreferences chatPreferences;
|
||||
private MessageVariableResolver resolver;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
chatPreferences = mock(ChatPreferences.class);
|
||||
|
||||
when(chatPreferences.getMYQRGFirstCat()).thenReturn(new SimpleStringProperty("144.388.03"));
|
||||
when(chatPreferences.getMYQRGSecondCat()).thenReturn(new SimpleStringProperty("432.088.00"));
|
||||
when(chatPreferences.getStn_loginLocatorMainCat()).thenReturn("JO51IJ");
|
||||
when(chatPreferences.getStn_loginCallSign()).thenReturn("DO5AMF");
|
||||
when(chatPreferences.getActualQTF()).thenReturn(new SimpleDoubleProperty(135.0));
|
||||
|
||||
resolver = new MessageVariableResolver(chatPreferences);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvesAllGlobalVariablesInEveryMessageContext() {
|
||||
String template = "MYCALL MYQRGSHORT MYQRG SECONDQRG MYLOCATORSHORT MYLOCATOR MYQTF";
|
||||
|
||||
assertEquals(
|
||||
"DO5AMF 144.388 144.388.03 432.088.00 JO51 JO51IJ 135",
|
||||
resolver.resolveGlobalVariables(template)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shortValuesRemainSafeWhenTheConfiguredValueIsShorterThanExpected() {
|
||||
when(chatPreferences.getMYQRGFirstCat()).thenReturn(new SimpleStringProperty("144"));
|
||||
when(chatPreferences.getStn_loginLocatorMainCat()).thenReturn("JO5");
|
||||
|
||||
assertEquals("144 JO5", resolver.resolveGlobalVariables("MYQRGSHORT MYLOCATORSHORT"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvesSelectedStationNameAndTheFirstTwoAirPlanes() {
|
||||
ChatMember selectedStation = new ChatMember();
|
||||
selectedStation.setCallSign("DL0TEST");
|
||||
selectedStation.setName("Test Operator");
|
||||
|
||||
AirPlane firstAirPlane = new AirPlane();
|
||||
firstAirPlane.setPotential(100);
|
||||
firstAirPlane.setArrivingDurationMinutes(1);
|
||||
|
||||
AirPlane secondAirPlane = new AirPlane();
|
||||
secondAirPlane.setPotential(75);
|
||||
secondAirPlane.setArrivingDurationMinutes(9);
|
||||
|
||||
AirPlaneReflectionInfo reflectionInfo = new AirPlaneReflectionInfo();
|
||||
reflectionInfo.setRisingAirplanes(FXCollections.observableArrayList(firstAirPlane, secondAirPlane));
|
||||
selectedStation.setAirPlaneReflectInfo(reflectionInfo);
|
||||
|
||||
assertEquals(
|
||||
"Hi Test Operator, a very big AP in 1 min; Next big AP in 9 min",
|
||||
resolver.resolveForSelectedStation(
|
||||
"Hi QRZNAME, FIRSTAP; SECONDAP",
|
||||
selectedStation
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void usesCallsignWhenTheSelectedStationHasNoName() {
|
||||
ChatMember selectedStation = new ChatMember();
|
||||
selectedStation.setCallSign("DL0TEST");
|
||||
selectedStation.setName(" ");
|
||||
|
||||
assertEquals(
|
||||
"Hi DL0TEST",
|
||||
resolver.resolveForSelectedStation("Hi QRZNAME", selectedStation)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsStationVariablesVisibleWhenNoStationIsSelected() {
|
||||
assertEquals(
|
||||
"QRZNAME FIRSTAP SECONDAP",
|
||||
resolver.resolveForSelectedStation("QRZNAME FIRSTAP SECONDAP", null)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsUsefulFallbacksWhenNoAirPlaneIsAvailable() {
|
||||
ChatMember selectedStation = new ChatMember();
|
||||
selectedStation.setCallSign("DL0TEST");
|
||||
|
||||
assertEquals(
|
||||
"no ap available ",
|
||||
resolver.resolveForSelectedStation("FIRSTAP SECONDAP", selectedStation)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package kst4contest.utils;
|
||||
|
||||
public final class VersionUtils {
|
||||
|
||||
private VersionUtils() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares numeric release versions such as 1.41, 1.41.1 and 1.41.10.
|
||||
*
|
||||
* Pre-release and build suffixes are ignored because the update feed
|
||||
* currently publishes stable releases only.
|
||||
*/
|
||||
public static int compareStableVersions(String left, String right) {
|
||||
int[] leftParts = parseVersion(left);
|
||||
int[] rightParts = parseVersion(right);
|
||||
int partCount = Math.max(leftParts.length, rightParts.length);
|
||||
|
||||
for (int index = 0; index < partCount; index++) {
|
||||
int leftPart = index < leftParts.length ? leftParts[index] : 0;
|
||||
int rightPart = index < rightParts.length ? rightParts[index] : 0;
|
||||
|
||||
int comparison = Integer.compare(leftPart, rightPart);
|
||||
if (comparison != 0) {
|
||||
return comparison;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int[] parseVersion(String version) {
|
||||
if (version == null || version.isBlank()) {
|
||||
throw new IllegalArgumentException("Version must not be empty");
|
||||
}
|
||||
|
||||
String normalized = version.trim();
|
||||
|
||||
if (normalized.startsWith("v") || normalized.startsWith("V")) {
|
||||
normalized = normalized.substring(1);
|
||||
}
|
||||
|
||||
int hyphenIndex = normalized.indexOf('-');
|
||||
int plusIndex = normalized.indexOf('+');
|
||||
int suffixIndex;
|
||||
|
||||
if (hyphenIndex < 0) {
|
||||
suffixIndex = plusIndex;
|
||||
} else if (plusIndex < 0) {
|
||||
suffixIndex = hyphenIndex;
|
||||
} else {
|
||||
suffixIndex = Math.min(hyphenIndex, plusIndex);
|
||||
}
|
||||
|
||||
if (suffixIndex >= 0) {
|
||||
normalized = normalized.substring(0, suffixIndex);
|
||||
}
|
||||
|
||||
String[] textParts = normalized.split("\\.");
|
||||
int[] numericParts = new int[textParts.length];
|
||||
|
||||
for (int index = 0; index < textParts.length; index++) {
|
||||
numericParts[index] = Integer.parseInt(textParts[index]);
|
||||
}
|
||||
|
||||
return numericParts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.Hyperlink;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.TableCell;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.shape.Rectangle;
|
||||
import javafx.util.Duration;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Displays a single-line message text in a TableView.
|
||||
*
|
||||
* <p>The cell provides two functions that JavaFX's default text cell cannot
|
||||
* combine:
|
||||
* <ul>
|
||||
* <li>a tooltip containing the complete text, but only while the visible
|
||||
* cell is too narrow for that text;</li>
|
||||
* <li>clickable HTTP, HTTPS and www links inside otherwise normal text.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>The row type is generic because the same implementation is used by the
|
||||
* ChatMessage tables and the DX-cluster table.</p>
|
||||
*
|
||||
* @param <S> row type of the surrounding TableView
|
||||
*/
|
||||
public final class MessageTextTableCell<S> extends TableCell<S, String> {
|
||||
|
||||
private static final Pattern WEB_LINK_PATTERN = Pattern.compile(
|
||||
"(?i)\\b(?:https?://|www\\.)[^\\s<>\"']+"
|
||||
);
|
||||
|
||||
private static final String TRAILING_LINK_PUNCTUATION = ".,;:!?)]}";
|
||||
|
||||
private final Consumer<String> linkOpener;
|
||||
private final Predicate<String> highlightedTextPredicate;
|
||||
private final String normalStyleClass;
|
||||
private final String highlightedStyleClass;
|
||||
|
||||
private final HBox contentBox = new HBox(0);
|
||||
private final Rectangle contentClip = new Rectangle();
|
||||
private final Tooltip fullTextTooltip = new Tooltip();
|
||||
|
||||
private String displayedText = "";
|
||||
private boolean fullTextTooltipInstalled = false;
|
||||
|
||||
/**
|
||||
* Creates a normal message cell without additional text highlighting.
|
||||
*
|
||||
* @param linkOpener callback which opens a validated HTTP or HTTPS URL
|
||||
*/
|
||||
public MessageTextTableCell(Consumer<String> linkOpener) {
|
||||
this(linkOpener, text -> false, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a message cell with optional CSS highlighting.
|
||||
*
|
||||
* <p>KST4Contest uses this variant for public messages which contain the
|
||||
* operator's own callsign. Only the supplied CSS classes are added or
|
||||
* removed. The standard {@code table-cell} class and all other JavaFX
|
||||
* state remain untouched.</p>
|
||||
*
|
||||
* @param linkOpener callback which opens a validated URL
|
||||
* @param highlightedTextPredicate identifies highlighted messages
|
||||
* @param normalStyleClass CSS class for normal messages, may be null
|
||||
* @param highlightedStyleClass CSS class for highlighted messages, may be null
|
||||
*/
|
||||
public MessageTextTableCell(
|
||||
Consumer<String> linkOpener,
|
||||
Predicate<String> highlightedTextPredicate,
|
||||
String normalStyleClass,
|
||||
String highlightedStyleClass
|
||||
) {
|
||||
this.linkOpener = Objects.requireNonNull(linkOpener, "linkOpener");
|
||||
this.highlightedTextPredicate = highlightedTextPredicate == null
|
||||
? text -> false
|
||||
: highlightedTextPredicate;
|
||||
this.normalStyleClass = normalStyleClass;
|
||||
this.highlightedStyleClass = highlightedStyleClass;
|
||||
|
||||
contentBox.setAlignment(Pos.CENTER_LEFT);
|
||||
contentBox.setFillHeight(false);
|
||||
contentBox.setClip(contentClip);
|
||||
|
||||
fullTextTooltip.setWrapText(true);
|
||||
fullTextTooltip.setMaxWidth(800);
|
||||
fullTextTooltip.setShowDelay(Duration.millis(250));
|
||||
fullTextTooltip.setShowDuration(Duration.seconds(30));
|
||||
|
||||
setContentDisplay(javafx.scene.control.ContentDisplay.GRAPHIC_ONLY);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateItem(String item, boolean empty) {
|
||||
super.updateItem(item, empty);
|
||||
|
||||
removeOptionalStyleClasses();
|
||||
contentBox.getChildren().clear();
|
||||
|
||||
if (empty || item == null || item.isEmpty()) {
|
||||
displayedText = "";
|
||||
fullTextTooltip.setText("");
|
||||
setText(null);
|
||||
setGraphic(null);
|
||||
setClippedTooltipActive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
displayedText = item;
|
||||
fullTextTooltip.setText(item);
|
||||
applyOptionalStyleClass(item);
|
||||
buildContent(item);
|
||||
|
||||
setText(null);
|
||||
setGraphic(contentBox);
|
||||
updateClipAndTooltip();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void layoutChildren() {
|
||||
super.layoutChildren();
|
||||
updateClipAndTooltip();
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the displayed text into ordinary labels and clickable links.
|
||||
*/
|
||||
private void buildContent(String text) {
|
||||
Matcher matcher = WEB_LINK_PATTERN.matcher(text);
|
||||
int nextPlainTextStart = 0;
|
||||
|
||||
while (matcher.find()) {
|
||||
String rawMatch = matcher.group();
|
||||
String linkText = removeTrailingPunctuation(rawMatch);
|
||||
|
||||
if (linkText.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
appendPlainText(text.substring(nextPlainTextStart, matcher.start()));
|
||||
appendLink(linkText);
|
||||
|
||||
/*
|
||||
* Punctuation removed from the URL remains ordinary message text.
|
||||
* Starting here ensures it is included by the next substring.
|
||||
*/
|
||||
nextPlainTextStart = matcher.start() + linkText.length();
|
||||
}
|
||||
|
||||
appendPlainText(text.substring(nextPlainTextStart));
|
||||
}
|
||||
|
||||
private void appendPlainText(String text) {
|
||||
if (text == null || text.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Label textFragment = new Label(text);
|
||||
textFragment.setPadding(Insets.EMPTY);
|
||||
textFragment.setMinWidth(Region.USE_PREF_SIZE);
|
||||
textFragment.setMaxWidth(Region.USE_PREF_SIZE);
|
||||
textFragment.setMouseTransparent(true);
|
||||
textFragment.textFillProperty().bind(textFillProperty());
|
||||
textFragment.getStyleClass().add("message-text-fragment");
|
||||
|
||||
contentBox.getChildren().add(textFragment);
|
||||
}
|
||||
|
||||
private void appendLink(String linkText) {
|
||||
Hyperlink hyperlink = new Hyperlink(linkText);
|
||||
hyperlink.setPadding(Insets.EMPTY);
|
||||
hyperlink.setMinWidth(Region.USE_PREF_SIZE);
|
||||
hyperlink.setMaxWidth(Region.USE_PREF_SIZE);
|
||||
hyperlink.setFocusTraversable(false);
|
||||
hyperlink.getStyleClass().add("message-table-link");
|
||||
hyperlink.setOnAction(event -> {
|
||||
openLink(linkText);
|
||||
event.consume();
|
||||
});
|
||||
|
||||
contentBox.getChildren().add(hyperlink);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens only HTTP and HTTPS targets. A visible www address receives an
|
||||
* HTTPS scheme before it is passed to the application.
|
||||
*/
|
||||
private void openLink(String linkText) {
|
||||
try {
|
||||
String normalizedLink = linkText.toLowerCase(Locale.ROOT).startsWith("www.")
|
||||
? "https://" + linkText
|
||||
: linkText;
|
||||
|
||||
URI uri = URI.create(normalizedLink);
|
||||
String scheme = uri.getScheme();
|
||||
|
||||
if (scheme == null
|
||||
|| (!scheme.equalsIgnoreCase("http")
|
||||
&& !scheme.equalsIgnoreCase("https"))) {
|
||||
return;
|
||||
}
|
||||
|
||||
linkOpener.accept(uri.toASCIIString());
|
||||
} catch (RuntimeException exception) {
|
||||
System.out.println(
|
||||
"[MessageTextTableCell] Cannot open malformed link: "
|
||||
+ linkText
|
||||
+ " / "
|
||||
+ exception.getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes punctuation which commonly follows a link in normal prose.
|
||||
*/
|
||||
private String removeTrailingPunctuation(String rawLink) {
|
||||
String result = rawLink;
|
||||
|
||||
while (!result.isEmpty()
|
||||
&& TRAILING_LINK_PUNCTUATION.indexOf(
|
||||
result.charAt(result.length() - 1)
|
||||
) >= 0) {
|
||||
result = result.substring(0, result.length() - 1);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clips the one-line content and installs the full-text tooltip only if
|
||||
* the rendered nodes are wider than the usable cell area.
|
||||
*/
|
||||
private void updateClipAndTooltip() {
|
||||
if (displayedText.isEmpty() || getGraphic() == null) {
|
||||
setClippedTooltipActive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
double availableWidth = Math.max(
|
||||
0,
|
||||
getWidth() - snappedLeftInset() - snappedRightInset()
|
||||
);
|
||||
double availableHeight = Math.max(
|
||||
0,
|
||||
getHeight() - snappedTopInset() - snappedBottomInset()
|
||||
);
|
||||
|
||||
contentClip.setWidth(availableWidth);
|
||||
contentClip.setHeight(availableHeight);
|
||||
|
||||
boolean textIsClipped = contentBox.prefWidth(-1) > availableWidth + 1;
|
||||
setClippedTooltipActive(textIsClipped);
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs the tooltip on the actual graphic node below the mouse pointer.
|
||||
*
|
||||
* <p>Installing it on the TableCell itself is not reliable when the cell
|
||||
* displays an HBox containing labels and hyperlinks. The graphic node is
|
||||
* the effective mouse target. Installation is tracked explicitly so
|
||||
* repeated layout passes neither add duplicate handlers nor leave a
|
||||
* tooltip attached to a reused empty cell.</p>
|
||||
*/
|
||||
private void setClippedTooltipActive(boolean active) {
|
||||
/*
|
||||
* The tooltip is handled exclusively by the graphic node. Keeping a
|
||||
* second tooltip on the TableCell would allow two competing tooltip
|
||||
* targets for the same visible content.
|
||||
*/
|
||||
setTooltip(null);
|
||||
|
||||
if (active == fullTextTooltipInstalled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (active) {
|
||||
Tooltip.install(contentBox, fullTextTooltip);
|
||||
} else {
|
||||
fullTextTooltip.hide();
|
||||
Tooltip.uninstall(contentBox, fullTextTooltip);
|
||||
}
|
||||
|
||||
fullTextTooltipInstalled = active;
|
||||
}
|
||||
|
||||
private void applyOptionalStyleClass(String item) {
|
||||
boolean highlighted;
|
||||
|
||||
try {
|
||||
highlighted = highlightedTextPredicate.test(item);
|
||||
} catch (RuntimeException exception) {
|
||||
highlighted = false;
|
||||
}
|
||||
|
||||
String styleClass = highlighted
|
||||
? highlightedStyleClass
|
||||
: normalStyleClass;
|
||||
|
||||
if (styleClass != null
|
||||
&& !styleClass.isBlank()
|
||||
&& !getStyleClass().contains(styleClass)) {
|
||||
getStyleClass().add(styleClass);
|
||||
}
|
||||
}
|
||||
|
||||
private void removeOptionalStyleClasses() {
|
||||
if (normalStyleClass != null) {
|
||||
getStyleClass().remove(normalStyleClass);
|
||||
}
|
||||
if (highlightedStyleClass != null) {
|
||||
getStyleClass().remove(highlightedStyleClass);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ public record MapCallsignRawSnapshot(
|
||||
double longitudeDeg,
|
||||
String bandSummary,
|
||||
Map<String, String> lastKnownFrequenciesByBand,
|
||||
boolean offersSelectedBand,
|
||||
boolean warningToMyDirection,
|
||||
boolean worked,
|
||||
boolean selected,
|
||||
@@ -59,9 +60,10 @@ public record MapCallsignRawSnapshot(
|
||||
}
|
||||
|
||||
public String markerLabel() {
|
||||
return bandSummary.isBlank()
|
||||
String baseLabel = bandSummary.isBlank()
|
||||
? displayCallSign
|
||||
: displayCallSign + " (" + bandSummary + ")";
|
||||
return offersSelectedBand ? baseLabel + " B+" : baseLabel;
|
||||
}
|
||||
|
||||
public String detailFrequencyText() {
|
||||
|
||||
@@ -8,11 +8,13 @@ import kst4contest.model.ChatMember;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.EnumSet;
|
||||
import java.util.EnumMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Builds immutable map snapshots from the currently visible chat members.
|
||||
@@ -22,8 +24,11 @@ import java.util.Map;
|
||||
*/
|
||||
public final class MapCallsignRawSnapshotBuilder {
|
||||
|
||||
private static final Pattern TOKEN_SPLIT_PATTERN = Pattern.compile("[^A-Z0-9]+");
|
||||
|
||||
public List<MapCallsignRawSnapshot> buildSnapshots(Collection<ChatMember> visibleChatMembers,
|
||||
ChatMember selectedChatMember) {
|
||||
ChatMember selectedChatMember,
|
||||
EnumSet<Band> selectedBands) {
|
||||
|
||||
if (visibleChatMembers == null || visibleChatMembers.isEmpty()) {
|
||||
return List.of();
|
||||
@@ -69,7 +74,9 @@ public final class MapCallsignRawSnapshotBuilder {
|
||||
Location location = new Location(locator6);
|
||||
|
||||
LinkedHashMap<String, String> frequenciesByBand = collectLastKnownFrequenciesByBand(variants);
|
||||
String bandSummary = String.join(", ", frequenciesByBand.keySet());
|
||||
EnumSet<Band> sureBands = collectSureBands(variants, frequenciesByBand);
|
||||
String bandSummary = buildBandSummary(sureBands);
|
||||
boolean offersSelectedBand = hasAnySelectedBand(sureBands, selectedBands);
|
||||
|
||||
boolean warningToMyDirection = variants.stream().anyMatch(ChatMember::isInAngleAndRange);
|
||||
boolean worked = variants.stream().anyMatch(this::isWorkedAtAnyBand);
|
||||
@@ -92,6 +99,7 @@ public final class MapCallsignRawSnapshotBuilder {
|
||||
location.getLongitude().toDegrees(),
|
||||
bandSummary,
|
||||
frequenciesByBand,
|
||||
offersSelectedBand,
|
||||
warningToMyDirection,
|
||||
worked,
|
||||
selected,
|
||||
@@ -182,6 +190,101 @@ public final class MapCallsignRawSnapshotBuilder {
|
||||
return ordered;
|
||||
}
|
||||
|
||||
private EnumSet<Band> collectSureBands(List<ChatMember> variants,
|
||||
LinkedHashMap<String, String> frequenciesByBand) {
|
||||
EnumSet<Band> sureBands = EnumSet.noneOf(Band.class);
|
||||
|
||||
if (frequenciesByBand != null) {
|
||||
for (String label : frequenciesByBand.keySet()) {
|
||||
Band mappedBand = bandFromDisplayLabel(label);
|
||||
if (mappedBand != null) {
|
||||
sureBands.add(mappedBand);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (ChatMember variant : variants) {
|
||||
if (variant == null || variant.getName() == null || variant.getName().isBlank()) {
|
||||
continue;
|
||||
}
|
||||
sureBands.addAll(detectBandsFromStationName(variant.getName()));
|
||||
}
|
||||
|
||||
return sureBands;
|
||||
}
|
||||
|
||||
private EnumSet<Band> detectBandsFromStationName(String stationName) {
|
||||
EnumSet<Band> detectedBands = EnumSet.noneOf(Band.class);
|
||||
if (stationName == null || stationName.isBlank()) {
|
||||
return detectedBands;
|
||||
}
|
||||
|
||||
String normalized = stationName.toUpperCase(Locale.ROOT);
|
||||
String[] tokens = TOKEN_SPLIT_PATTERN.split(normalized);
|
||||
for (String token : tokens) {
|
||||
if (token == null || token.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (token) {
|
||||
case "2", "2M", "144", "144MHZ" -> detectedBands.add(Band.B_144);
|
||||
case "70", "70CM", "432", "432MHZ" -> detectedBands.add(Band.B_432);
|
||||
case "23", "23CM", "1296", "1296MHZ" -> detectedBands.add(Band.B_1296);
|
||||
case "13", "13CM", "2300", "2320", "2320MHZ" -> detectedBands.add(Band.B_2320);
|
||||
case "9", "9CM", "3400", "3400MHZ" -> detectedBands.add(Band.B_3400);
|
||||
case "6", "6CM", "5600", "5760", "5760MHZ" -> detectedBands.add(Band.B_5760);
|
||||
case "3", "3CM", "10G", "10GHZ", "10368", "10368MHZ" -> detectedBands.add(Band.B_10G);
|
||||
case "24G", "24GHZ", "24048", "24048MHZ" -> detectedBands.add(Band.B_24G);
|
||||
default -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return detectedBands;
|
||||
}
|
||||
|
||||
private String buildBandSummary(EnumSet<Band> sureBands) {
|
||||
if (sureBands == null || sureBands.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
List<String> labels = sureBands.stream()
|
||||
.sorted()
|
||||
.map(this::toBandDisplayLabel)
|
||||
.toList();
|
||||
return String.join(", ", labels);
|
||||
}
|
||||
|
||||
private boolean hasAnySelectedBand(EnumSet<Band> sureBands, EnumSet<Band> selectedBands) {
|
||||
if (sureBands == null || sureBands.isEmpty() || selectedBands == null || selectedBands.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
for (Band band : selectedBands) {
|
||||
if (sureBands.contains(band)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private Band bandFromDisplayLabel(String label) {
|
||||
if (label == null || label.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return switch (label) {
|
||||
case "144" -> Band.B_144;
|
||||
case "432" -> Band.B_432;
|
||||
case "1296" -> Band.B_1296;
|
||||
case "2320" -> Band.B_2320;
|
||||
case "3400" -> Band.B_3400;
|
||||
case "5760" -> Band.B_5760;
|
||||
case "10368" -> Band.B_10G;
|
||||
case "24048" -> Band.B_24G;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback for stations where the current displayed QRG exists but the
|
||||
* knownActiveBands history has not yet been filled.
|
||||
|
||||
@@ -13,8 +13,12 @@ import java.nio.charset.StandardCharsets;
|
||||
* - inspectPoint(x,y) returns what is under the cursor
|
||||
* - zoomIn()/zoomOut() are callable from Java
|
||||
* - grid / beam / connection use non-interactive panes
|
||||
* - JavaScript logs are forwarded to Java through javaMapBridge
|
||||
* - JavaScript errors are forwarded to Java through javaMapBridge
|
||||
* - setTheme(light|dark) aligns the map with the JavaFX application theme
|
||||
*
|
||||
* Important:
|
||||
* This version intentionally uses integer Leaflet zoom levels again.
|
||||
* Fractional zoom in JavaFX WebView caused unreliable marker positioning.
|
||||
*/
|
||||
public final class MapHtmlResources {
|
||||
|
||||
@@ -184,6 +188,111 @@ public final class MapHtmlResources {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.station-cluster-wrapper {
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.station-cluster-root {
|
||||
position: relative;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* Cluster design aligned with normal KST4Contest station markers:
|
||||
* - dark center like station-dot
|
||||
* - blue border for normal clustered stations
|
||||
* - yellow border when the cluster contains worked stations
|
||||
* - orange hover border similar to selected station state
|
||||
*/
|
||||
.station-cluster-bubble {
|
||||
position: absolute;
|
||||
left: -16px;
|
||||
top: -16px;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 0 7px;
|
||||
border-radius: 18px;
|
||||
background: #1d1d1d;
|
||||
color: #f4f7fa;
|
||||
border: 2px solid #4da6ff;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(0, 0, 0, 0.35),
|
||||
0 2px 7px rgba(0, 0, 0, 0.42);
|
||||
box-sizing: border-box;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
line-height: 28px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.station-cluster-bubble.medium {
|
||||
left: -18px;
|
||||
top: -18px;
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.station-cluster-bubble.large {
|
||||
left: -21px;
|
||||
top: -21px;
|
||||
min-width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 23px;
|
||||
font-size: 15px;
|
||||
line-height: 38px;
|
||||
}
|
||||
|
||||
.station-cluster-bubble.worked {
|
||||
border-color: #ffd24d;
|
||||
}
|
||||
|
||||
.station-cluster-bubble.warning {
|
||||
border-color: #00ff66;
|
||||
color: #00ff66;
|
||||
}
|
||||
|
||||
.station-cluster-root:hover .station-cluster-bubble {
|
||||
border-color: #ff9900;
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(255, 153, 0, 0.28),
|
||||
0 2px 9px rgba(0, 0, 0, 0.48);
|
||||
}
|
||||
|
||||
body.kst-theme-dark .station-cluster-bubble {
|
||||
background: #202428;
|
||||
color: #f1f3f5;
|
||||
border-color: #4da6ff;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.12),
|
||||
0 2px 8px rgba(0, 0, 0, 0.58);
|
||||
}
|
||||
|
||||
body.kst-theme-dark .station-cluster-bubble.worked {
|
||||
border-color: #ffd24d;
|
||||
}
|
||||
|
||||
body.kst-theme-dark .station-cluster-bubble.warning {
|
||||
border-color: #00ff66;
|
||||
color: #00ff66;
|
||||
}
|
||||
|
||||
body.kst-theme-dark .station-cluster-root:hover .station-cluster-bubble {
|
||||
border-color: #ff9900;
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(255, 153, 0, 0.32),
|
||||
0 2px 10px rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.maidenhead-grid-label-wrapper {
|
||||
background: transparent;
|
||||
border: none;
|
||||
@@ -225,9 +334,6 @@ public final class MapHtmlResources {
|
||||
<script>window._kstTileProxyPort=__TILE_PROXY_PORT__;</script>
|
||||
|
||||
<script>
|
||||
|
||||
|
||||
|
||||
window.kstMapApi = (function () {
|
||||
let map;
|
||||
let stationLayer;
|
||||
@@ -235,11 +341,77 @@ public final class MapHtmlResources {
|
||||
let beamLayer;
|
||||
let connectionLayer;
|
||||
let profileHoverMarker;
|
||||
|
||||
/*
|
||||
* Currently visible individual station markers.
|
||||
*
|
||||
* A callsign is present here only when it is rendered individually.
|
||||
* If the station is inside a cluster, stationsByCallsignRaw is used
|
||||
* for focus/zoom operations.
|
||||
*/
|
||||
let markersByCallsignRaw = {};
|
||||
|
||||
/*
|
||||
* All station data as received from Java.
|
||||
*/
|
||||
let stationData = [];
|
||||
let stationsByCallsignRaw = {};
|
||||
|
||||
let clustersById = {};
|
||||
let clusterSequence = 0;
|
||||
|
||||
let activeTheme = 'light';
|
||||
let invalidateNotifyTimer = 0;
|
||||
|
||||
/*
|
||||
* Integer zoom only.
|
||||
*
|
||||
* Fractional zoom is intentionally disabled because JavaFX WebView
|
||||
* and Leaflet marker positioning were unreliable at intermediate
|
||||
* zoom levels.
|
||||
*/
|
||||
const KST_ZOOM_STEP = 1;
|
||||
const KST_MIN_ZOOM = 3;
|
||||
const KST_MAX_ZOOM = 18;
|
||||
|
||||
/*
|
||||
* Enables verbose JavaScript map logging.
|
||||
*
|
||||
* Keep this false for normal operation. jsError() still reports
|
||||
* real errors.
|
||||
*/
|
||||
const KST_MAP_DEBUG = false;
|
||||
|
||||
/*
|
||||
* Cluster settings.
|
||||
*
|
||||
* With integer zoom, clustering is disabled at zoom >= 8.
|
||||
* At zoom 7 only very close stations are grouped.
|
||||
*/
|
||||
const KST_CLUSTER_DISABLE_ZOOM = 8;
|
||||
|
||||
/*
|
||||
* Do not cluster pairs immediately. Two nearby stations are still
|
||||
* readable and should remain individually clickable.
|
||||
*/
|
||||
const KST_CLUSTER_MIN_STATIONS = 3;
|
||||
|
||||
/*
|
||||
* Cluster cell sizes in screen pixels.
|
||||
*
|
||||
* Smaller cells make clustering less aggressive. Stations must be
|
||||
* closer together on screen before they are grouped.
|
||||
*/
|
||||
const KST_CLUSTER_CELL_SIZE_HIGH_ZOOM = 55;
|
||||
const KST_CLUSTER_CELL_SIZE_MEDIUM_ZOOM = 70;
|
||||
const KST_CLUSTER_CELL_SIZE_LOW_ZOOM = 95;
|
||||
const KST_CLUSTER_CELL_SIZE_VERY_LOW_ZOOM = 125;
|
||||
|
||||
function jsLog(message) {
|
||||
if (!KST_MAP_DEBUG) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (window.javaMapBridge) {
|
||||
window.javaMapBridge.onJsLog(String(message));
|
||||
@@ -358,6 +530,331 @@ public final class MapHtmlResources {
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
function getStationCallsignKey(station) {
|
||||
if (!station || station.callSignRaw === null || station.callSignRaw === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return String(station.callSignRaw);
|
||||
}
|
||||
|
||||
function isStationPositionValid(station) {
|
||||
if (!station) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isFinite(station.latitudeDeg) && isFinite(station.longitudeDeg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true for stations that must not be hidden inside a cluster.
|
||||
*
|
||||
* These stations remain individually visible even at low zoom levels
|
||||
* because they are operationally important during contest operation.
|
||||
*/
|
||||
function shouldRenderStationIndividually(station) {
|
||||
if (!station) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (station.selected) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (station.warningToMyDirection) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cluster grid size in screen pixels for the current zoom level.
|
||||
*
|
||||
* The cluster calculation is screen-based, not locator-based. This makes
|
||||
* the decision match the real visual problem: too many labels in the same
|
||||
* screen area.
|
||||
*/
|
||||
function getClusterCellSizePx() {
|
||||
if (!map) {
|
||||
return KST_CLUSTER_CELL_SIZE_MEDIUM_ZOOM;
|
||||
}
|
||||
|
||||
const zoom = Number(map.getZoom());
|
||||
|
||||
if (zoom >= 7) {
|
||||
return KST_CLUSTER_CELL_SIZE_HIGH_ZOOM;
|
||||
}
|
||||
|
||||
if (zoom >= 6) {
|
||||
return KST_CLUSTER_CELL_SIZE_MEDIUM_ZOOM;
|
||||
}
|
||||
|
||||
if (zoom >= 5) {
|
||||
return KST_CLUSTER_CELL_SIZE_LOW_ZOOM;
|
||||
}
|
||||
|
||||
return KST_CLUSTER_CELL_SIZE_VERY_LOW_ZOOM;
|
||||
}
|
||||
|
||||
function buildClusterMarkerHtml(clusterId, clusterStations) {
|
||||
const count = clusterStations ? clusterStations.length : 0;
|
||||
|
||||
let bubbleClasses = 'station-cluster-bubble';
|
||||
|
||||
if (count >= 20) {
|
||||
bubbleClasses += ' large';
|
||||
} else if (count >= 8) {
|
||||
bubbleClasses += ' medium';
|
||||
}
|
||||
|
||||
const containsWorkedStation = clusterStations
|
||||
&& clusterStations.some(station => station && station.worked);
|
||||
|
||||
const containsWarningStation = clusterStations
|
||||
&& clusterStations.some(station => station && station.warningToMyDirection);
|
||||
|
||||
if (containsWarningStation) {
|
||||
bubbleClasses += ' warning';
|
||||
} else if (containsWorkedStation) {
|
||||
bubbleClasses += ' worked';
|
||||
}
|
||||
|
||||
return '<div class="station-cluster-root" data-cluster-id="' + escapeHtml(clusterId) + '">'
|
||||
+ '<div class="' + bubbleClasses + '">' + count + '</div>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
function buildClusterTooltipHtml(clusterStations) {
|
||||
if (!clusterStations || clusterStations.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const callsigns = clusterStations
|
||||
.map(station => station.markerLabel || station.callSignRaw || station.callSign || '')
|
||||
.filter(value => value !== null && value !== undefined && String(value).trim() !== '')
|
||||
.map(value => String(value).trim())
|
||||
.sort();
|
||||
|
||||
const maxPreviewCount = 20;
|
||||
const preview = callsigns
|
||||
.slice(0, maxPreviewCount)
|
||||
.map(value => escapeHtml(value))
|
||||
.join('<br>');
|
||||
|
||||
const remainingCount = Math.max(0, callsigns.length - maxPreviewCount);
|
||||
|
||||
let html = '<b>' + clusterStations.length + ' stations</b>';
|
||||
|
||||
if (preview) {
|
||||
html += '<br>' + preview;
|
||||
}
|
||||
|
||||
if (remainingCount > 0) {
|
||||
html += '<br>+' + remainingCount + ' more';
|
||||
}
|
||||
|
||||
html += '<br><i>Click to zoom in</i>';
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function addStationMarker(station) {
|
||||
if (!stationLayer || !isStationPositionValid(station)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const marker = L.marker(
|
||||
[station.latitudeDeg, station.longitudeDeg],
|
||||
{
|
||||
interactive: true,
|
||||
keyboard: false,
|
||||
icon: L.divIcon({
|
||||
className: 'station-marker-wrapper',
|
||||
html: buildStationMarkerHtml(station),
|
||||
iconSize: [1, 1],
|
||||
iconAnchor: [0, 0]
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
marker.addTo(stationLayer);
|
||||
|
||||
const callSignKey = getStationCallsignKey(station);
|
||||
if (callSignKey) {
|
||||
markersByCallsignRaw[callSignKey] = marker;
|
||||
}
|
||||
}
|
||||
|
||||
function addClusterMarker(clusterStations) {
|
||||
if (!stationLayer || !clusterStations || clusterStations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let latSum = 0.0;
|
||||
let lonSum = 0.0;
|
||||
let validCount = 0;
|
||||
|
||||
clusterStations.forEach(station => {
|
||||
if (isStationPositionValid(station)) {
|
||||
latSum += Number(station.latitudeDeg);
|
||||
lonSum += Number(station.longitudeDeg);
|
||||
validCount++;
|
||||
}
|
||||
});
|
||||
|
||||
if (validCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const centerLat = latSum / validCount;
|
||||
const centerLon = lonSum / validCount;
|
||||
|
||||
const clusterId = 'cluster-' + (++clusterSequence);
|
||||
clustersById[clusterId] = clusterStations;
|
||||
|
||||
const clusterMarker = L.marker(
|
||||
[centerLat, centerLon],
|
||||
{
|
||||
interactive: true,
|
||||
keyboard: false,
|
||||
icon: L.divIcon({
|
||||
className: 'station-cluster-wrapper',
|
||||
html: buildClusterMarkerHtml(clusterId, clusterStations),
|
||||
iconSize: [1, 1],
|
||||
iconAnchor: [0, 0]
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
clusterMarker.on('click', function () {
|
||||
zoomToCluster(clusterStations);
|
||||
});
|
||||
|
||||
clusterMarker.bindTooltip(buildClusterTooltipHtml(clusterStations), {
|
||||
direction: 'top',
|
||||
sticky: true,
|
||||
opacity: 0.95
|
||||
});
|
||||
|
||||
clusterMarker.addTo(stationLayer);
|
||||
}
|
||||
|
||||
function renderAllStationsIndividually() {
|
||||
stationData.forEach(station => {
|
||||
addStationMarker(station);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders stations with simple screen-grid clustering.
|
||||
*
|
||||
* Important stations such as the currently selected station and warning
|
||||
* stations are rendered individually before clustering. They remain
|
||||
* visible even at low zoom levels.
|
||||
*/
|
||||
function renderClusteredStations() {
|
||||
const clusterCellSizePx = getClusterCellSizePx();
|
||||
const buckets = {};
|
||||
|
||||
stationData.forEach(station => {
|
||||
if (!isStationPositionValid(station)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldRenderStationIndividually(station)) {
|
||||
addStationMarker(station);
|
||||
return;
|
||||
}
|
||||
|
||||
const point = map.latLngToContainerPoint([station.latitudeDeg, station.longitudeDeg]);
|
||||
const cellX = Math.floor(point.x / clusterCellSizePx);
|
||||
const cellY = Math.floor(point.y / clusterCellSizePx);
|
||||
const key = cellX + ':' + cellY;
|
||||
|
||||
if (!buckets[key]) {
|
||||
buckets[key] = [];
|
||||
}
|
||||
|
||||
buckets[key].push(station);
|
||||
});
|
||||
|
||||
Object.keys(buckets).forEach(key => {
|
||||
const bucketStations = buckets[key];
|
||||
|
||||
if (bucketStations.length >= KST_CLUSTER_MIN_STATIONS) {
|
||||
addClusterMarker(bucketStations);
|
||||
} else {
|
||||
bucketStations.forEach(station => addStationMarker(station));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-renders station markers for the current zoom and viewport.
|
||||
*
|
||||
* This is called when stations arrive from Java and after zoom/move
|
||||
* events, because screen-grid clusters depend on the current viewport.
|
||||
*/
|
||||
function renderStationMarkers() {
|
||||
if (!map || !stationLayer) {
|
||||
return;
|
||||
}
|
||||
|
||||
stationLayer.clearLayers();
|
||||
markersByCallsignRaw = {};
|
||||
clustersById = {};
|
||||
clusterSequence = 0;
|
||||
|
||||
if (!stationData || stationData.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Number(map.getZoom()) >= KST_CLUSTER_DISABLE_ZOOM) {
|
||||
renderAllStationsIndividually();
|
||||
} else {
|
||||
renderClusteredStations();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zooms into a cluster.
|
||||
*
|
||||
* A cluster click does not select one station. It moves the map towards
|
||||
* the cluster center and increases zoom until individual stations become
|
||||
* visible.
|
||||
*/
|
||||
function zoomToCluster(clusterStations) {
|
||||
if (!map || !clusterStations || clusterStations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const latLngs = [];
|
||||
|
||||
clusterStations.forEach(station => {
|
||||
if (isStationPositionValid(station)) {
|
||||
latLngs.push(L.latLng(station.latitudeDeg, station.longitudeDeg));
|
||||
}
|
||||
});
|
||||
|
||||
if (latLngs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = L.latLngBounds(latLngs);
|
||||
const currentZoom = Number(map.getZoom());
|
||||
|
||||
const targetZoom = clampZoomToLeafletLimits(
|
||||
Math.min(KST_CLUSTER_DISABLE_ZOOM, currentZoom + 1)
|
||||
);
|
||||
|
||||
map.setView(bounds.getCenter(), targetZoom, {
|
||||
animate: false
|
||||
});
|
||||
|
||||
notifyViewport();
|
||||
}
|
||||
|
||||
function init() {
|
||||
if (map) {
|
||||
return true;
|
||||
@@ -372,6 +869,17 @@ public final class MapHtmlResources {
|
||||
|
||||
map = L.map('map', {
|
||||
zoomControl: true,
|
||||
minZoom: KST_MIN_ZOOM,
|
||||
maxZoom: KST_MAX_ZOOM,
|
||||
|
||||
/*
|
||||
* Integer zoom only.
|
||||
* This keeps station positions stable in JavaFX WebView.
|
||||
*/
|
||||
zoomSnap: 1,
|
||||
zoomDelta: 1,
|
||||
wheelPxPerZoomLevel: 120,
|
||||
|
||||
zoomAnimation: false,
|
||||
fadeAnimation: false,
|
||||
markerZoomAnimation: false,
|
||||
@@ -383,7 +891,8 @@ public final class MapHtmlResources {
|
||||
const tileLayer = L.tileLayer(
|
||||
'http://127.0.0.1:' + window._kstTileProxyPort + '/tiles/{s}/{z}/{x}/{y}.png',
|
||||
{
|
||||
maxZoom: 18,
|
||||
minZoom: KST_MIN_ZOOM,
|
||||
maxZoom: KST_MAX_ZOOM,
|
||||
updateWhenZooming: false,
|
||||
keepBuffer: 4,
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
@@ -419,12 +928,12 @@ public final class MapHtmlResources {
|
||||
connectionLayer = L.layerGroup().addTo(map);
|
||||
|
||||
map.on('zoomend', function () {
|
||||
jsLog('zoomend -> ' + map.getZoom());
|
||||
renderStationMarkers();
|
||||
notifyViewport();
|
||||
});
|
||||
|
||||
map.on('moveend', function () {
|
||||
jsLog('moveend');
|
||||
renderStationMarkers();
|
||||
notifyViewport();
|
||||
});
|
||||
|
||||
@@ -476,20 +985,39 @@ public final class MapHtmlResources {
|
||||
invalidateSize();
|
||||
}
|
||||
|
||||
function zoomIn() {
|
||||
/**
|
||||
* Keeps the zoom value inside the explicit KST zoom limits.
|
||||
*/
|
||||
function clampZoomToLeafletLimits(zoom) {
|
||||
return Math.max(KST_MIN_ZOOM, Math.min(KST_MAX_ZOOM, zoom));
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the zoom in stable integer steps.
|
||||
*/
|
||||
function changeZoomByKstStep(delta) {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
|
||||
map.setZoom(map.getZoom() + 1, { animate: false });
|
||||
const currentZoom = Number(map.getZoom());
|
||||
const nextZoom = clampZoomToLeafletLimits(currentZoom + delta);
|
||||
|
||||
if (nextZoom === currentZoom) {
|
||||
return;
|
||||
}
|
||||
|
||||
map.setZoom(nextZoom, {
|
||||
animate: false
|
||||
});
|
||||
}
|
||||
|
||||
function zoomIn() {
|
||||
changeZoomByKstStep(KST_ZOOM_STEP);
|
||||
}
|
||||
|
||||
function zoomOut() {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
|
||||
map.setZoom(map.getZoom() - 1, { animate: false });
|
||||
changeZoomByKstStep(-KST_ZOOM_STEP);
|
||||
}
|
||||
|
||||
function getViewportState() {
|
||||
@@ -522,6 +1050,12 @@ public final class MapHtmlResources {
|
||||
return 'station|' + callSignRaw + '|' + el.tagName + '|' + (el.className || '') + '|' + (el.textContent || '').trim();
|
||||
}
|
||||
|
||||
const clusterRoot = el.closest('.station-cluster-root');
|
||||
if (clusterRoot) {
|
||||
const clusterId = clusterRoot.getAttribute('data-cluster-id') || '';
|
||||
return 'cluster|' + clusterId + '|' + el.tagName + '|' + (el.className || '') + '|' + (el.textContent || '').trim();
|
||||
}
|
||||
|
||||
const zoomInButton = el.closest('.leaflet-control-zoom-in');
|
||||
if (zoomInButton) {
|
||||
return 'zoomIn||' + el.tagName + '|' + (el.className || '') + '|' + (el.textContent || '').trim();
|
||||
@@ -542,8 +1076,8 @@ public final class MapHtmlResources {
|
||||
if (!init()) {
|
||||
return;
|
||||
}
|
||||
jsLog('setHome lat=' + lat + ' lon=' + lon + ' zoom=' + zoom);
|
||||
map.setView([lat, lon], zoom);
|
||||
|
||||
map.setView([lat, lon], clampZoomToLeafletLimits(Math.round(Number(zoom))));
|
||||
}
|
||||
|
||||
function setStations(stationsJson) {
|
||||
@@ -551,30 +1085,17 @@ public final class MapHtmlResources {
|
||||
return;
|
||||
}
|
||||
|
||||
stationLayer.clearLayers();
|
||||
markersByCallsignRaw = {};
|
||||
stationData = JSON.parse(stationsJson);
|
||||
stationsByCallsignRaw = {};
|
||||
|
||||
const stations = JSON.parse(stationsJson);
|
||||
jsLog('setStations count=' + stations.length);
|
||||
|
||||
stations.forEach(station => {
|
||||
const marker = L.marker(
|
||||
[station.latitudeDeg, station.longitudeDeg],
|
||||
{
|
||||
interactive: true,
|
||||
keyboard: false,
|
||||
icon: L.divIcon({
|
||||
className: 'station-marker-wrapper',
|
||||
html: buildStationMarkerHtml(station),
|
||||
iconSize: [1, 1],
|
||||
iconAnchor: [0, 0]
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
marker.addTo(stationLayer);
|
||||
markersByCallsignRaw[station.callSignRaw] = marker;
|
||||
stationData.forEach(station => {
|
||||
const callSignKey = getStationCallsignKey(station);
|
||||
if (callSignKey) {
|
||||
stationsByCallsignRaw[callSignKey] = station;
|
||||
}
|
||||
});
|
||||
|
||||
renderStationMarkers();
|
||||
}
|
||||
|
||||
function setBeam(beamJson) {
|
||||
@@ -592,8 +1113,6 @@ public final class MapHtmlResources {
|
||||
return;
|
||||
}
|
||||
|
||||
jsLog('setBeam points=' + points.length);
|
||||
|
||||
const latLngs = points.map(point => [point.lat, point.lon]);
|
||||
|
||||
L.polygon(latLngs, {
|
||||
@@ -621,8 +1140,6 @@ public final class MapHtmlResources {
|
||||
return;
|
||||
}
|
||||
|
||||
jsLog('setConnection');
|
||||
|
||||
L.polyline(points.map(point => [point.lat, point.lon]), {
|
||||
pane: 'connectionPane',
|
||||
color: connectionColor(),
|
||||
@@ -671,7 +1188,6 @@ public final class MapHtmlResources {
|
||||
gridLayer.clearLayers();
|
||||
|
||||
const cells = JSON.parse(gridJson);
|
||||
jsLog('setGrid cells=' + cells.length);
|
||||
|
||||
cells.forEach(cell => {
|
||||
const rectangle = L.rectangle(
|
||||
@@ -712,48 +1228,60 @@ public final class MapHtmlResources {
|
||||
}
|
||||
|
||||
function focusCallsignRaw(callSignRaw) {
|
||||
if (!map || !callSignRaw) {
|
||||
return;
|
||||
}
|
||||
|
||||
const marker = markersByCallsignRaw[callSignRaw];
|
||||
if (!marker) {
|
||||
jsLog('focusCallsignRaw skipped for ' + callSignRaw);
|
||||
return;
|
||||
}
|
||||
|
||||
jsLog('focusCallsignRaw ' + callSignRaw);
|
||||
|
||||
map.panTo(marker.getLatLng(), {
|
||||
animate: false
|
||||
});
|
||||
|
||||
notifyViewport();
|
||||
}
|
||||
|
||||
|
||||
if (!map || !callSignRaw) {
|
||||
return;
|
||||
}
|
||||
|
||||
const marker = markersByCallsignRaw[callSignRaw];
|
||||
if (marker) {
|
||||
map.panTo(marker.getLatLng(), {
|
||||
animate: false
|
||||
});
|
||||
|
||||
notifyViewport();
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* If the station is currently hidden inside a cluster, there is no
|
||||
* individual marker. Use the raw station data and zoom in far enough
|
||||
* so clustering is disabled and the station becomes visible.
|
||||
*/
|
||||
const station = stationsByCallsignRaw[callSignRaw];
|
||||
if (station && isStationPositionValid(station)) {
|
||||
const focusZoom = clampZoomToLeafletLimits(
|
||||
Math.max(Number(map.getZoom()), KST_CLUSTER_DISABLE_ZOOM)
|
||||
);
|
||||
|
||||
map.setView([station.latitudeDeg, station.longitudeDeg], focusZoom, {
|
||||
animate: false
|
||||
});
|
||||
|
||||
notifyViewport();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
init: init,
|
||||
invalidateSize: invalidateSize,
|
||||
resize: resize,
|
||||
zoomIn: zoomIn,
|
||||
zoomOut: zoomOut,
|
||||
inspectPoint: inspectPoint,
|
||||
getViewportState: getViewportState,
|
||||
setHome: setHome,
|
||||
setStations: setStations,
|
||||
setBeam: setBeam,
|
||||
setConnection: setConnection,
|
||||
setProfileHoverPoint: setProfileHoverPoint,
|
||||
setGrid: setGrid,
|
||||
focusCallsignRaw: focusCallsignRaw,
|
||||
setTheme: setTheme
|
||||
};
|
||||
init: init,
|
||||
invalidateSize: invalidateSize,
|
||||
resize: resize,
|
||||
zoomIn: zoomIn,
|
||||
zoomOut: zoomOut,
|
||||
inspectPoint: inspectPoint,
|
||||
getViewportState: getViewportState,
|
||||
setHome: setHome,
|
||||
setStations: setStations,
|
||||
setBeam: setBeam,
|
||||
setConnection: setConnection,
|
||||
setProfileHoverPoint: setProfileHoverPoint,
|
||||
setGrid: setGrid,
|
||||
focusCallsignRaw: focusCallsignRaw,
|
||||
setTheme: setTheme
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""".replace("__TILE_PROXY_PORT__", String.valueOf(tileProxyPort));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import kst4contest.model.ChatPreferences;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
@@ -142,8 +143,13 @@ public final class StationMapBridge {
|
||||
private void refreshNow() {
|
||||
List<ChatMember> visibleChatMembers = new ArrayList<>(chatController.getLst_chatMemberSortedFilteredList());
|
||||
ChatMember selectedChatMember = chatController.getScoreService().getSelectedChatMember();
|
||||
EnumSet<Band> selectedBands = chatController.getReachabilityService().getEnabledStationBands();
|
||||
|
||||
List<MapCallsignRawSnapshot> snapshots = snapshotBuilder.buildSnapshots(visibleChatMembers, selectedChatMember);
|
||||
List<MapCallsignRawSnapshot> snapshots = snapshotBuilder.buildSnapshots(
|
||||
visibleChatMembers,
|
||||
selectedChatMember,
|
||||
selectedBands
|
||||
);
|
||||
|
||||
MapCallsignRawSnapshot selectedSnapshot = null;
|
||||
if (selectedChatMember != null && selectedChatMember.getCallSignRaw() != null) {
|
||||
|
||||
@@ -41,6 +41,14 @@ import javafx.scene.layout.ColumnConstraints;
|
||||
*/
|
||||
public final class StationMapView {
|
||||
|
||||
/**
|
||||
* Enables verbose map debug output.
|
||||
*
|
||||
* Keep this false for normal operation because scroll and viewport logs are very
|
||||
* noisy during map interaction.
|
||||
*/
|
||||
private static final boolean MAP_DEBUG_LOGGING = false;
|
||||
|
||||
private final PathProfileChart detailPathProfileChart = new PathProfileChart();
|
||||
private final Label detailPathModeValue = new Label("-");
|
||||
|
||||
@@ -287,12 +295,17 @@ public final class StationMapView {
|
||||
webView.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> handleWebViewClick(event));
|
||||
|
||||
webView.addEventHandler(ScrollEvent.SCROLL, event -> {
|
||||
System.out.println("[StationMap FX] SCROLL x=" + (int) event.getX()
|
||||
+ " y=" + (int) event.getY()
|
||||
+ " deltaY=" + event.getDeltaY());
|
||||
|
||||
if (MAP_DEBUG_LOGGING) {
|
||||
System.out.println("[StationMap FX] SCROLL x=" + (int) event.getX()
|
||||
+ " y=" + (int) event.getY()
|
||||
+ " deltaY=" + event.getDeltaY());
|
||||
}
|
||||
|
||||
InteractiveTarget target = inspectInteractiveTarget(event.getX(), event.getY());
|
||||
System.out.println("[StationMap FX] inspect scroll -> " + target);
|
||||
if (MAP_DEBUG_LOGGING) {
|
||||
System.out.println("[StationMap FX] inspect scroll -> " + target);
|
||||
}
|
||||
|
||||
if (event.getDeltaY() > 0) {
|
||||
executeMapScriptSafely("window.kstMapApi.zoomIn();");
|
||||
@@ -855,7 +868,11 @@ public final class StationMapView {
|
||||
detailLocatorValue.setText(selectedSnapshot.locator6());
|
||||
detailQrbValue.setText(String.format(Locale.US, "%.0f km", selectedSnapshot.qrbKm()));
|
||||
detailQtfValue.setText(String.format(Locale.US, "%.0f°", selectedSnapshot.qtfDeg()));
|
||||
detailBandsValue.setText(selectedSnapshot.bandSummary().isBlank() ? "-" : selectedSnapshot.bandSummary());
|
||||
String bandText = selectedSnapshot.bandSummary().isBlank() ? "-" : selectedSnapshot.bandSummary();
|
||||
if (selectedSnapshot.offersSelectedBand()) {
|
||||
bandText += " B+";
|
||||
}
|
||||
detailBandsValue.setText(bandText);
|
||||
detailFrequenciesArea.setText(selectedSnapshot.detailFrequencyText());
|
||||
detailAirplanesValue.setText(String.valueOf(selectedSnapshot.reachableAirplanes()));
|
||||
triggerClusterSpotButton.setDisable(false);
|
||||
|
||||
@@ -10,6 +10,8 @@ module praktiKST {
|
||||
requires java.net.http;
|
||||
requires java.desktop;
|
||||
requires jdk.crypto.ec;
|
||||
requires org.junit.jupiter.api;
|
||||
requires org.mockito;
|
||||
exports kst4contest.controller.interfaces;
|
||||
exports kst4contest.controller;
|
||||
exports kst4contest.locatorUtils;
|
||||
|
||||
@@ -198,4 +198,20 @@
|
||||
.table-cell-50PercentAP {
|
||||
-fx-text-fill: #fa9f66;
|
||||
-fx-font-weight: bold;
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Clickable web links inside message-table cells.
|
||||
* Text fragments keep the current table-cell color; links remain visible.
|
||||
*/
|
||||
.message-table-link {
|
||||
-fx-padding: 0;
|
||||
-fx-border-color: transparent;
|
||||
-fx-underline: true;
|
||||
-fx-text-fill: #005eb8;
|
||||
}
|
||||
|
||||
.message-table-link:visited {
|
||||
-fx-text-fill: #6b3fa0;
|
||||
}
|
||||
@@ -240,4 +240,19 @@
|
||||
.table-cell-50PercentAP {
|
||||
-fx-text-fill: #fa9f66;
|
||||
-fx-font-weight: bold;
|
||||
}
|
||||
|
||||
/*
|
||||
* Clickable web links inside message-table cells.
|
||||
* Brighter colors preserve contrast on the dark table background.
|
||||
*/
|
||||
.message-table-link {
|
||||
-fx-padding: 0;
|
||||
-fx-border-color: transparent;
|
||||
-fx-underline: true;
|
||||
-fx-text-fill: #73b7ff;
|
||||
}
|
||||
|
||||
.message-table-link:visited {
|
||||
-fx-text-fill: #c59cff;
|
||||
}
|
||||
@@ -650,6 +650,8 @@ svg.leaflet-image-layer.leaflet-interactive path {
|
||||
border-right-color: #fff;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Printing */
|
||||
|
||||
@media print {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package kst4contest.test;
|
||||
|
||||
import kst4contest.model.Band;
|
||||
import kst4contest.model.ChatMember;
|
||||
import kst4contest.view.map.MapCallsignRawSnapshot;
|
||||
import kst4contest.view.map.MapCallsignRawSnapshotBuilder;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class MapCallsignRawSnapshotBuilderTest {
|
||||
|
||||
@Test
|
||||
void marksSnapshotWhenNameAdvertisesSelectedBand() {
|
||||
ChatMember station = buildStation("DL1ABC", "QRV 2-70-23", "JN58TD", 1_000L);
|
||||
|
||||
MapCallsignRawSnapshotBuilder builder = new MapCallsignRawSnapshotBuilder();
|
||||
List<MapCallsignRawSnapshot> snapshots = builder.buildSnapshots(
|
||||
List.of(station),
|
||||
null,
|
||||
EnumSet.of(Band.B_1296)
|
||||
);
|
||||
|
||||
assertEquals(1, snapshots.size());
|
||||
assertTrue(snapshots.get(0).offersSelectedBand());
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsSnapshotUnmarkedWhenNoSelectedBandMatches() {
|
||||
ChatMember station = buildStation("DL1ABC", "QRV 2m only", "JN58TD", 1_000L);
|
||||
|
||||
MapCallsignRawSnapshotBuilder builder = new MapCallsignRawSnapshotBuilder();
|
||||
List<MapCallsignRawSnapshot> snapshots = builder.buildSnapshots(
|
||||
List.of(station),
|
||||
null,
|
||||
EnumSet.of(Band.B_2320)
|
||||
);
|
||||
|
||||
assertEquals(1, snapshots.size());
|
||||
assertFalse(snapshots.get(0).offersSelectedBand());
|
||||
}
|
||||
|
||||
private ChatMember buildStation(String callSign, String name, String locator, long activityEpoch) {
|
||||
ChatMember chatMember = new ChatMember();
|
||||
chatMember.setCallSign(callSign);
|
||||
chatMember.setName(name);
|
||||
chatMember.setQra(locator);
|
||||
chatMember.setActivityTimeLastInEpoch(activityEpoch);
|
||||
return chatMember;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const markdownIt = require("markdown-it");
|
||||
const markdownItAnchor = require("markdown-it-anchor");
|
||||
|
||||
const MANUAL_PAGE_ORDER = {
|
||||
de: [
|
||||
"home",
|
||||
"installation",
|
||||
"konfiguration",
|
||||
"log-synchronisation",
|
||||
"airscout-integration",
|
||||
"dx-cluster-server",
|
||||
"funktionen",
|
||||
"makros-und-variablen",
|
||||
"benutzeroberflaeche",
|
||||
"changelog"
|
||||
],
|
||||
en: [
|
||||
"home",
|
||||
"installation",
|
||||
"configuration",
|
||||
"log-sync",
|
||||
"airscout-integration",
|
||||
"dx-cluster-server",
|
||||
"features",
|
||||
"macros-and-variables",
|
||||
"user-interface",
|
||||
"changelog"
|
||||
]
|
||||
};
|
||||
|
||||
function markdownToPlainText(value) {
|
||||
return (value || "")
|
||||
.replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1")
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.replace(/[*_~`]/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function extractManualTitle(raw, fallbackTitle) {
|
||||
const heading = (raw || "").match(/^#\s+(.+)$/m);
|
||||
return heading ? markdownToPlainText(heading[1]) : fallbackTitle;
|
||||
}
|
||||
|
||||
function extractManualSummary(raw) {
|
||||
const blocks = (raw || "").split(/\r?\n\s*\r?\n/);
|
||||
|
||||
for (const block of blocks) {
|
||||
const trimmed = block.trim();
|
||||
|
||||
if (!trimmed
|
||||
|| /^(#|>|\||---|```|!\[)/.test(trimmed)
|
||||
|| /^[-*+]\s/.test(trimmed)
|
||||
|| /^\d+[.)]\s/.test(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return markdownToPlainText(trimmed);
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function getManualPageOrder(lang, slug) {
|
||||
const configuredOrder = MANUAL_PAGE_ORDER[lang] || [];
|
||||
const index = configuredOrder.indexOf(slug);
|
||||
|
||||
return index >= 0 ? index : 999;
|
||||
}
|
||||
|
||||
function rewriteManualLinks(content, lang) {
|
||||
return (content || "")
|
||||
|
||||
// Manual images are stored next to the Markdown sources in github_docs.
|
||||
// The website publishes them from one shared, language-neutral directory.
|
||||
.replace(
|
||||
/!\[([^\]]*)\]\((?!https?:\/\/|\/)([^)\s]+\.(?:png|jpe?g|gif|svg|webp))\)/gi,
|
||||
(match, altText, imagePath) => {
|
||||
const fileName = path.posix.basename(
|
||||
imagePath.replace(/\\/g, "/")
|
||||
);
|
||||
|
||||
return ``;
|
||||
}
|
||||
)
|
||||
|
||||
// GitHub-Wiki-Links im Stil [[de-Installation]] oder [[en-Installation]]
|
||||
.replace(/\[\[(en|de)-([^\]|]+)\]\]/g, (match, linkLang, slug) => {
|
||||
return `[${slug.replace(/-/g, " ")}](/manual/${linkLang}/${slug.toLowerCase()}/)`;
|
||||
})
|
||||
|
||||
// GitHub-Wiki-Links mit Label: [[Text|de-Installation]]
|
||||
.replace(/\[\[([^\]|]+)\|(en|de)-([^\]]+)\]\]/g, (match, label, linkLang, slug) => {
|
||||
return `[${label}](/manual/${linkLang}/${slug.toLowerCase()}/)`;
|
||||
})
|
||||
|
||||
// Markdown-Links auf de-/en-Dateien ohne .md
|
||||
.replace(/\]\((en|de)-([^)#]+)\)/g, (match, linkLang, slug) => {
|
||||
return `](/manual/${linkLang}/${slug.toLowerCase()}/)`;
|
||||
})
|
||||
|
||||
// Markdown-Links auf de-/en-Dateien mit .md
|
||||
.replace(/\]\((en|de)-([^)#]+)\.md\)/g, (match, linkLang, slug) => {
|
||||
return `](/manual/${linkLang}/${slug.toLowerCase()}/)`;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = function (eleventyConfig) {
|
||||
|
||||
eleventyConfig.addFilter("dateToIso", function (dateObj) {
|
||||
return new Date(dateObj).toISOString().split("T")[0];
|
||||
});
|
||||
|
||||
eleventyConfig.addFilter("dateToRfc822", function (dateObj) {
|
||||
return new Date(dateObj).toUTCString();
|
||||
});
|
||||
|
||||
eleventyConfig.addFilter("readableDate", function (dateObj) {
|
||||
return new Intl.DateTimeFormat("en", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric"
|
||||
}).format(dateObj);
|
||||
});
|
||||
|
||||
eleventyConfig.addCollection("sortedFeatures", function (collectionApi) {
|
||||
return collectionApi.getFilteredByTag("features").sort((a, b) => {
|
||||
return (a.data.order || 999) - (b.data.order || 999);
|
||||
});
|
||||
});
|
||||
|
||||
eleventyConfig.addCollection("featuredFeatures", function (collectionApi) {
|
||||
return collectionApi.getFilteredByTag("features")
|
||||
.filter(item => item.data.featured)
|
||||
.sort((a, b) => {
|
||||
return (a.data.order || 999) - (b.data.order || 999);
|
||||
});
|
||||
});
|
||||
|
||||
eleventyConfig.addFilter("whereTag", function(collection, tag) {
|
||||
return collection.filter(item => item.data.tags && item.data.tags.includes(tag));
|
||||
});
|
||||
|
||||
eleventyConfig.addPassthroughCopy({ "src/assets": "assets" });
|
||||
|
||||
eleventyConfig.addPassthroughCopy({
|
||||
"../github_docs/*.{png,jpg,jpeg,gif,svg,webp}": "manual/assets"
|
||||
});
|
||||
|
||||
const md = markdownIt({
|
||||
html: true,
|
||||
linkify: true,
|
||||
typographer: true
|
||||
}).use(markdownItAnchor, {
|
||||
permalink: markdownItAnchor.permalink.headerLink()
|
||||
});
|
||||
|
||||
eleventyConfig.addFilter("markdown", function (value) {
|
||||
return md.render(value || "");
|
||||
});
|
||||
|
||||
eleventyConfig.addCollection("manualPages", function () {
|
||||
const docsDir = path.join(__dirname, "..", "github_docs");
|
||||
|
||||
if (!fs.existsSync(docsDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs.readdirSync(docsDir)
|
||||
.filter(file => /^(en|de)-.+\.md$/.test(file))
|
||||
.map(file => {
|
||||
const fullPath = path.join(docsDir, file);
|
||||
const raw = fs.readFileSync(fullPath, "utf8");
|
||||
|
||||
const lang = file.startsWith("de-") ? "de" : "en";
|
||||
const slug = file
|
||||
.replace(/^en-/, "")
|
||||
.replace(/^de-/, "")
|
||||
.replace(/\.md$/, "")
|
||||
.toLowerCase();
|
||||
|
||||
const fallbackTitle = file
|
||||
.replace(/^en-/, "")
|
||||
.replace(/^de-/, "")
|
||||
.replace(/\.md$/, "")
|
||||
.replace(/-/g, " ");
|
||||
|
||||
const title = extractManualTitle(raw, fallbackTitle);
|
||||
|
||||
return {
|
||||
file,
|
||||
lang,
|
||||
slug,
|
||||
title,
|
||||
summary: extractManualSummary(raw),
|
||||
order: getManualPageOrder(lang, slug),
|
||||
content: rewriteManualLinks(raw, lang)
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
return a.lang.localeCompare(b.lang)
|
||||
|| a.order - b.order
|
||||
|| a.title.localeCompare(b.title);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
dir: {
|
||||
input: "src",
|
||||
output: "_site",
|
||||
includes: "_includes",
|
||||
layouts: "_layouts"
|
||||
},
|
||||
markdownTemplateEngine: "njk",
|
||||
htmlTemplateEngine: "njk"
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
# KST4Contest Website Architecture
|
||||
|
||||
## Goals
|
||||
|
||||
KST4Contest uses GitHub as the central source for code, documentation and releases.
|
||||
|
||||
The website is generated statically with Eleventy (11ty) and deployed to:
|
||||
|
||||
https://kst4contest.hamradioonline.de
|
||||
|
||||
## Documentation Source
|
||||
|
||||
The existing `github_docs/` directory remains the single source of truth for the manual.
|
||||
|
||||
It is used for:
|
||||
|
||||
- GitHub Wiki
|
||||
- PDF manual generation
|
||||
- Website manual pages
|
||||
|
||||
## Website Source
|
||||
|
||||
The `website/` directory contains the Eleventy project.
|
||||
|
||||
Manual content must not be duplicated inside `website/`.
|
||||
|
||||
Marketing, SEO, news, screenshots and download pages belong into `website/src/`.
|
||||
|
||||
## URL Structure
|
||||
|
||||
- `/`
|
||||
- `/features/`
|
||||
- `/download/`
|
||||
- `/screenshots/`
|
||||
- `/news/`
|
||||
- `/manual/`
|
||||
- `/manual/en/`
|
||||
- `/manual/de/`
|
||||
- `/faq/`
|
||||
|
||||
## Deployment
|
||||
|
||||
The generated static output is deployed to:
|
||||
|
||||
`/srv/www/kst4contest/current`
|
||||
|
||||
Nginx serves this directory.
|
||||
|
||||
## Build Strategy
|
||||
|
||||
GitHub Actions will later build the 11ty website and deploy the generated output to the server.
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "kst4contest-website",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "eleventy --serve",
|
||||
"build": "eleventy"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@11ty/eleventy": "^3.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"markdown-it": "^14.3.0",
|
||||
"markdown-it-anchor": "^9.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
version: Date.now()
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
const REPO = "praktimarc/kst4contest";
|
||||
const API = `https://api.github.com/repos/${REPO}`;
|
||||
const LATEST_RELEASE_URL = `https://github.com/${REPO}/releases/latest`;
|
||||
|
||||
async function fetchLatestReleaseTag() {
|
||||
const headers = { Accept: "application/vnd.github+json" };
|
||||
|
||||
if (process.env.GITHUB_TOKEN) {
|
||||
headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/releases/latest`, { headers });
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`GitHub API /releases/latest failed: ${res.status}`
|
||||
);
|
||||
}
|
||||
|
||||
const release = await res.json();
|
||||
|
||||
if (!release.tag_name) {
|
||||
throw new Error(
|
||||
"GitHub API response did not contain a release tag"
|
||||
);
|
||||
}
|
||||
|
||||
return release.tag_name;
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[downloads] Could not load the latest release tag. ` +
|
||||
`Download buttons will open the latest release page instead: ${err.message}`
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a direct link to an asset from the latest Stable release.
|
||||
*
|
||||
* If the GitHub API was unavailable during the build, the function deliberately
|
||||
* returns the generic latest-release page instead of using a hard-coded and
|
||||
* increasingly outdated fallback version.
|
||||
*/
|
||||
function createAssetUrl(latestTag, filenameTemplate) {
|
||||
if (!latestTag) {
|
||||
return LATEST_RELEASE_URL;
|
||||
}
|
||||
|
||||
const filename = filenameTemplate.replace(/\{tag\}/g, latestTag);
|
||||
|
||||
return `https://github.com/${REPO}/releases/download/${latestTag}/${filename}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the download list from the latest Stable GitHub release.
|
||||
*
|
||||
* The filenames must remain aligned with the artifacts produced by
|
||||
* .github/workflows/tagged-release.yml.
|
||||
*/
|
||||
module.exports = async function () {
|
||||
const latestTag = await fetchLatestReleaseTag();
|
||||
const assetUrl = (filenameTemplate) =>
|
||||
createAssetUrl(latestTag, filenameTemplate);
|
||||
|
||||
return [
|
||||
{
|
||||
os: "Windows",
|
||||
format: "ZIP x64",
|
||||
icon: "🪟",
|
||||
recommended: true,
|
||||
note: "Extract the complete archive, then start praktiKST.exe. No separate Java installation is required.",
|
||||
url: assetUrl("praktiKST-{tag}-windows-x64.zip")
|
||||
},
|
||||
{
|
||||
os: "Linux",
|
||||
format: "Flatpak (.flatpakref)",
|
||||
icon: "🐧",
|
||||
recommended: true,
|
||||
note: "Installs through Flatpak and can be updated through Flatpak or the desktop software centre.",
|
||||
url: assetUrl("de.x08.KST4Contest.flatpakref")
|
||||
},
|
||||
{
|
||||
os: "Linux",
|
||||
format: "AppImage x86_64",
|
||||
icon: "🐧",
|
||||
recommended: false,
|
||||
note: "Portable build without package installation. Make the downloaded file executable before the first launch.",
|
||||
url: assetUrl("KST4Contest-{tag}-linux-x86_64.AppImage")
|
||||
},
|
||||
{
|
||||
os: "Debian / Ubuntu",
|
||||
format: "DEB amd64",
|
||||
icon: "📦",
|
||||
recommended: false,
|
||||
note: "Native package for Debian, Ubuntu and distributions based on them.",
|
||||
url: assetUrl("KST4Contest-{tag}-debian-amd64.deb")
|
||||
},
|
||||
{
|
||||
os: "Fedora",
|
||||
format: "RPM x86_64",
|
||||
icon: "📦",
|
||||
recommended: false,
|
||||
note: "Native package built for Fedora and compatible RPM-based systems.",
|
||||
url: assetUrl("KST4Contest-{tag}-fedora-x86_64.rpm")
|
||||
},
|
||||
{
|
||||
os: "Arch Linux",
|
||||
format: "pkg.tar.zst",
|
||||
icon: "📦",
|
||||
recommended: false,
|
||||
note: "Release package for direct installation with pacman. AUR alternatives are described in the installation guide.",
|
||||
url: assetUrl(
|
||||
"KST4Contest-{tag}-archlinux-x86_64.pkg.tar.zst"
|
||||
)
|
||||
},
|
||||
{
|
||||
os: "macOS Apple Silicon",
|
||||
format: "DMG arm64",
|
||||
icon: "🍎",
|
||||
recommended: false,
|
||||
note: "Best-effort build for Apple Silicon Macs. The application is not currently notarized by Apple.",
|
||||
url: assetUrl("KST4Contest-{tag}-macos-arm64.dmg")
|
||||
},
|
||||
{
|
||||
os: "macOS Intel",
|
||||
format: "DMG x86_64",
|
||||
icon: "🍎",
|
||||
recommended: false,
|
||||
note: "Best-effort build for Intel Macs. The application is not currently notarized by Apple.",
|
||||
url: assetUrl("KST4Contest-{tag}-macos-x86_64.dmg")
|
||||
},
|
||||
{
|
||||
os: "Manual English",
|
||||
format: "PDF",
|
||||
icon: "📘",
|
||||
recommended: false,
|
||||
note: "English PDF manual included with the Stable release.",
|
||||
url: assetUrl("KST4Contest-{tag}-manual-en.pdf")
|
||||
},
|
||||
{
|
||||
os: "Manual German",
|
||||
format: "PDF",
|
||||
icon: "📘",
|
||||
recommended: false,
|
||||
note: "German PDF manual included with the Stable release.",
|
||||
url: assetUrl("KST4Contest-{tag}-manual-de.pdf")
|
||||
}
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
module.exports = [
|
||||
{ title: "Home", url: "/" },
|
||||
{ title: "Features", url: "/features/" },
|
||||
{ title: "Screenshots", url: "/screenshots/" },
|
||||
{ title: "Manual", url: "/manual/" },
|
||||
{ title: "News", url: "/news/" },
|
||||
{ title: "Roadmap", url: "/roadmap/" },
|
||||
{ title: "About", url: "/about/" },
|
||||
{ title: "FAQ", url: "/faq/" },
|
||||
{ title: "Support", url: "/support/"
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,84 @@
|
||||
const REPO = "praktimarc/kst4contest";
|
||||
const API = `https://api.github.com/repos/${REPO}`;
|
||||
|
||||
async function githubGet(path) {
|
||||
const headers = { Accept: "application/vnd.github+json" };
|
||||
if (process.env.GITHUB_TOKEN) {
|
||||
headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
|
||||
}
|
||||
const res = await fetch(`${API}${path}`, { headers });
|
||||
if (!res.ok) {
|
||||
throw new Error(`GitHub API ${path} failed: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Builds the roadmap from all GitHub issues tagged "enhancement", grouped by
|
||||
// their version milestone (open or already released). Issues without a
|
||||
// milestone are shown as planned but with no assigned version yet. Issues
|
||||
// closed as "not planned" are kept, struck through and labeled accordingly.
|
||||
// Falls back to an empty list if the API is unreachable (e.g. offline build)
|
||||
// so the site build never fails.
|
||||
module.exports = async function () {
|
||||
try {
|
||||
const issues = await githubGet(
|
||||
"/issues?labels=enhancement&state=all&per_page=100"
|
||||
);
|
||||
|
||||
const groups = new Map();
|
||||
|
||||
for (const issue of issues) {
|
||||
if (issue.pull_request) continue;
|
||||
|
||||
const milestone = issue.milestone;
|
||||
const key = milestone ? milestone.number : "unscheduled";
|
||||
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, {
|
||||
version: milestone ? milestone.title : null,
|
||||
description: milestone ? milestone.description : null,
|
||||
url: milestone ? milestone.html_url : null,
|
||||
released: milestone ? milestone.state === "closed" : false,
|
||||
dueOn: milestone && milestone.due_on ? new Date(milestone.due_on) : null,
|
||||
releasedOn: milestone && milestone.closed_at ? new Date(milestone.closed_at) : null,
|
||||
enhancements: []
|
||||
});
|
||||
}
|
||||
|
||||
const notPlanned = issue.state === "closed" && issue.state_reason === "not_planned";
|
||||
|
||||
groups.get(key).enhancements.push({
|
||||
title: issue.title,
|
||||
url: issue.html_url,
|
||||
done: issue.state === "closed" && !notPlanned,
|
||||
notPlanned
|
||||
});
|
||||
}
|
||||
|
||||
const versions = [...groups.values()].filter((v) => v.enhancements.length > 0);
|
||||
|
||||
// Order: upcoming dated versions first (soonest due date), then
|
||||
// upcoming undated versions, then already released versions (newest
|
||||
// first), unscheduled enhancements at the very end.
|
||||
versions.sort((a, b) => {
|
||||
if (!a.version) return 1;
|
||||
if (!b.version) return -1;
|
||||
if (a.released !== b.released) return a.released ? 1 : -1;
|
||||
|
||||
if (!a.released) {
|
||||
if (a.dueOn && b.dueOn) return a.dueOn - b.dueOn;
|
||||
if (a.dueOn) return -1;
|
||||
if (b.dueOn) return 1;
|
||||
return a.version.localeCompare(b.version);
|
||||
}
|
||||
|
||||
if (a.releasedOn && b.releasedOn) return b.releasedOn - a.releasedOn;
|
||||
return b.version.localeCompare(a.version);
|
||||
});
|
||||
|
||||
return versions;
|
||||
} catch (err) {
|
||||
console.warn(`[roadmap] Could not load GitHub issues: ${err.message}`);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
module.exports = [
|
||||
{
|
||||
id: "main-window",
|
||||
title: "Main Window",
|
||||
image: "/assets/screenshots/main-window.png",
|
||||
alt: "KST4Contest ON4KST contest chat client main window",
|
||||
caption: "Contest-oriented ON4KST chat workflow with candidate awareness."
|
||||
},
|
||||
{
|
||||
id: "priority-candidates",
|
||||
title: "Priority Candidates",
|
||||
image: "/assets/screenshots/priority-candidates.png",
|
||||
alt: "Priority candidate list in KST4Contest",
|
||||
caption: "Score-based candidate ranking for faster operator decisions."
|
||||
},
|
||||
{
|
||||
id: "timeline",
|
||||
title: "Timeline View",
|
||||
image: "/assets/screenshots/timeline.png",
|
||||
alt: "KST4Contest AP timeline view",
|
||||
caption: "Timeline support for AP windows and candidate timing."
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,382 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Historical changelog entries for releases not published as GitHub Releases.
|
||||
The CI pipeline prepends new entries from GitHub Releases on top of these. -->
|
||||
<history>
|
||||
<changeLog>
|
||||
<changedVersionNumber>1.41</changedVersionNumber>
|
||||
<date>2026-07-01</date>
|
||||
<description>Map view and path calculations</description>
|
||||
<added>
|
||||
Band and Frequency Detection per Chatmember, Band alert, Win-Test SKED Push via UDP,
|
||||
Win-Test Broadcast Defaults and SKed Workflow, Chat History at Startup, Station Map / Map View
|
||||
Local Leaflet /-TileProxy Backend, Maidenhead /-Grid Overlay, Path calculation, path profile,
|
||||
Reachability filters, NAC filters (grid sqaure marking)
|
||||
</added>
|
||||
<changed>
|
||||
button housing, it works now on smaller screens / plus: detecting maximum screen size
|
||||
</changed>
|
||||
<fixed>
|
||||
9A2HM reported that 23cm qsos with N1MM are not marked correctly, ist fixed
|
||||
</fixed>
|
||||
<removed></removed>
|
||||
</changeLog>
|
||||
<changeLog>
|
||||
<changedVersionNumber>1.40</changedVersionNumber>
|
||||
<date>2026-02-16</date>
|
||||
<description>Much changes and fixes</description>
|
||||
<added>
|
||||
- win-test support (not deactivable yet, beta)
|
||||
- QSO sniffer which filters changable callsigns lists messages to your pm window
|
||||
- Chatmember Score system. Each chatmember gets a score depending to antenna direction, activity time,
|
||||
activity msgcount, positive signals, active bands, active frequencies, his sked direction (degrees),
|
||||
self handed rate, sked-made-rate and some others
|
||||
- Chatmember Band and frequency recognition: now we list all active bands of each other chatmember
|
||||
- Band alert for logged stations
|
||||
- Sked reminder ALERT with automatic messages in 2+1 / 5+2+1 / 10+5+2+1 minutes
|
||||
- PSTRotator interface
|
||||
- AirPlane Timeline
|
||||
- Skedfail button in furtherinfo panel
|
||||
- Wintest logsynch interface can now be activated and deactivated in the preferences
|
||||
- load chat history on startup
|
||||
</added>
|
||||
<changed>
|
||||
- added save option: ServerDNS / Port
|
||||
- added save option: activate/deactivate pstRotator interface
|
||||
- added save option: activate/deactivate wintest interface
|
||||
- added save option: callsign sniffer
|
||||
- added save option: boolean GUI_darkModeActiveByDefault = false
|
||||
- added AP notes at the internal DX cluster spots
|
||||
- Generic Autoanswer fires maximum once in 45 seconds per rx callsign
|
||||
</changed>
|
||||
<fixed>
|
||||
- Userlist sort automatically on new member signon
|
||||
- Posonpill-messages now kills only exactly one Client instance (bugreport 9A2HM)
|
||||
- Crash of wtkst by disconnection of kst4Contest
|
||||
- wtKST crashes when kst4contest is running (many reporters)
|
||||
- wintest: logged per band excludes pre worked band (bugreport SM6VTZ)
|
||||
- PSTRotator SOCKET Close on DISCONNECT BUTTON works now
|
||||
- AirScout Map had not been updated (bugreport SM0RJV)
|
||||
- QTFDefault had been not saved correctly (bugreport SM0RJV)
|
||||
- Show path in AS button worked only with fixed server Strings (bugreport 9A2HM)
|
||||
- Version number had been displayed wrong
|
||||
- suffixes like /p, -2, etc. problem now fixed everywhere
|
||||
- Dark mode: passing for qrg fields fix
|
||||
</fixed>
|
||||
<removed>
|
||||
- No more date display. Not needed. Saving space, only time is important
|
||||
</removed>
|
||||
</changeLog>
|
||||
<changeLog>
|
||||
<changedVersionNumber>1.31</changedVersionNumber>
|
||||
<date>2025-12-13</date>
|
||||
<description>Much changes and a hotfix</description>
|
||||
<added>
|
||||
- win-test support (not deactivable yet, beta)
|
||||
- pstRotator support (not deactivable yet, beta)
|
||||
- airScout fix (calculations depends to cpu load now)
|
||||
- QSO sniffer
|
||||
</added>
|
||||
<changed>
|
||||
- DNS from www.on4kst.info to www.on4kst.org (hotfix, now configurable in preferences)
|
||||
</changed>
|
||||
<fixed>
|
||||
- Endless loop in error case lets freeze the client
|
||||
- PSTRotator SOCKET Close on DISCONNECT BUTTON
|
||||
- Dark mode: passing for qrg fields fix
|
||||
</fixed>
|
||||
<removed></removed>
|
||||
</changeLog>
|
||||
<changeLog>
|
||||
<changedVersionNumber>1.266</changedVersionNumber>
|
||||
<date>2025-10-03</date>
|
||||
<description>Airscout interface did not work with multi-signons</description>
|
||||
<added>nothing</added>
|
||||
<changed>nothing</changed>
|
||||
<fixed>
|
||||
Fixed by using raw callsign (without -2 suffix etc.) when communicating with AirScout.
|
||||
Thank you very much for testing this, Kreso 9A2HM!
|
||||
</fixed>
|
||||
<removed></removed>
|
||||
</changeLog>
|
||||
<changeLog>
|
||||
<changedVersionNumber>1.265</changedVersionNumber>
|
||||
<date>2025-09-28</date>
|
||||
<description>Direction buttons stay now colored, if activated</description>
|
||||
<added>nothing</added>
|
||||
<changed>nothing</changed>
|
||||
<fixed>The buttons stay now colored, if activated</fixed>
|
||||
<removed></removed>
|
||||
</changeLog>
|
||||
<changeLog>
|
||||
<changedVersionNumber>1.264</changedVersionNumber>
|
||||
<date>2025-08-02</date>
|
||||
<description>Changed the callsign recognition pattern for Simplelogfile</description>
|
||||
<added>nothing</added>
|
||||
<changed>nothing</changed>
|
||||
<fixed>
|
||||
Callsigns like S53CC, S51A etc. are now correctly marked as worked in SimpleLogFile.txt.
|
||||
(bugreport Boris, S53CC)
|
||||
</fixed>
|
||||
<removed></removed>
|
||||
</changeLog>
|
||||
<changeLog>
|
||||
<changedVersionNumber>1.263</changedVersionNumber>
|
||||
<date>2025-06-08</date>
|
||||
<description>Airscout communication and Loginname</description>
|
||||
<added>nothing</added>
|
||||
<changed>
|
||||
Only entries with QRB lower than max-QRB are sent to AirScout in 60s intervals (was 12s for all).
|
||||
</changed>
|
||||
<fixed>
|
||||
- AS communication message count hugely reduced
|
||||
- Name in chat is now saveable
|
||||
- Fixed AS client name (was hard-wired to "KST")
|
||||
73 / DO5AMF
|
||||
</fixed>
|
||||
<removed></removed>
|
||||
</changeLog>
|
||||
<changeLog>
|
||||
<changedVersionNumber>1.262</changedVersionNumber>
|
||||
<date>2025-05-21</date>
|
||||
<description>Freezes caused by getting messages before user login should be fixed now</description>
|
||||
<added>nothing</added>
|
||||
<changed>nothing</changed>
|
||||
<fixed>
|
||||
ON4KST delivers messages of stations not yet logged in. That caused a freeze at the message
|
||||
processing engine which is now fixed.
|
||||
</fixed>
|
||||
<removed></removed>
|
||||
</changeLog>
|
||||
<changeLog>
|
||||
<changedVersionNumber>1.26</changedVersionNumber>
|
||||
<date>2025-05</date>
|
||||
<description>Login to multiple Channels via single signon / spend some colors</description>
|
||||
<added>
|
||||
1. UI: Dark mode. Switch in "Window -> use dark mode"
|
||||
2. Usage of two Chatcategories at the same time
|
||||
3. opposite station multi-callsign login-tagging
|
||||
73 / DO5AMF
|
||||
</added>
|
||||
<changed>
|
||||
- Coloring mechanic of the software. Modify colors via css by yourself...
|
||||
</changed>
|
||||
<fixed>
|
||||
- Station tagging fixed completely
|
||||
</fixed>
|
||||
<removed></removed>
|
||||
</changeLog>
|
||||
<changeLog>
|
||||
<changedVersionNumber>1.251</changedVersionNumber>
|
||||
<date>2025-02</date>
|
||||
<description>BUGFIX of 1.25, tnx Steve Clements!</description>
|
||||
<added>
|
||||
- Steve spotted a problem in udp broadcast spot info reading, its now fixed!
|
||||
73 / DO5AMF
|
||||
</added>
|
||||
<changed></changed>
|
||||
<fixed>- Station tagging</fixed>
|
||||
<removed></removed>
|
||||
</changeLog>
|
||||
<changeLog>
|
||||
<changedVersionNumber>1.25</changedVersionNumber>
|
||||
<date>2025-02</date>
|
||||
<description>Wishlist-time</description>
|
||||
<added>
|
||||
- New configuration Tab: Messagehandling (auto-answering, CQ qrg auto-answer)
|
||||
- Coloured lines: new pm rows appear in red, fade rainbow-like to white over 30s (tnx Gianluca)
|
||||
73 / DO5AMF
|
||||
</added>
|
||||
<changed></changed>
|
||||
<fixed>
|
||||
- Users with suffixes like "-2 and -70" are now correctly marked as worked
|
||||
</fixed>
|
||||
<removed></removed>
|
||||
</changeLog>
|
||||
<changeLog>
|
||||
<changedVersionNumber>1.24</changedVersionNumber>
|
||||
<date>2024-11</date>
|
||||
<description>Wishlist-time</description>
|
||||
<added>
|
||||
- Button to show qrz.com profile of a selected station
|
||||
- Button to show qrzcq.com profile of a selected station
|
||||
</added>
|
||||
<changed></changed>
|
||||
<fixed></fixed>
|
||||
<removed></removed>
|
||||
</changeLog>
|
||||
<changeLog>
|
||||
<changedVersionNumber>1.23</changedVersionNumber>
|
||||
<date>2024-10</date>
|
||||
<description>DXCluster Server is now implemented</description>
|
||||
<added>
|
||||
- DXCluster Server (tnx OMAAO): KST4Contest now provides a DXCluster server to feed
|
||||
your log client with station-reachable warnings.
|
||||
</added>
|
||||
<changed></changed>
|
||||
<fixed></fixed>
|
||||
<removed></removed>
|
||||
</changeLog>
|
||||
<changeLog>
|
||||
<changedVersionNumber>1.22</changedVersionNumber>
|
||||
<date>2024-05</date>
|
||||
<description>Increase usability, fixed AS button</description>
|
||||
<added>- Variables (tnx OMAAO): MYLOCATORSHORT, MYQRGSHORT, QRZNAME</added>
|
||||
<changed>- Sendtext-field focus on callsign click</changed>
|
||||
<fixed>
|
||||
- Worked-station-filter (tnx Gianluca): filter is now live
|
||||
- Chatters list sorting by QRB (tnx Alessandro): fixed to numeric sort
|
||||
- Airscout showpath button now maximizes AS and shows the path
|
||||
</fixed>
|
||||
<removed></removed>
|
||||
</changeLog>
|
||||
<changeLog>
|
||||
<changedVersionNumber>1.21</changedVersionNumber>
|
||||
<date>2024-04</date>
|
||||
<description>Increase usability</description>
|
||||
<added></added>
|
||||
<changed>
|
||||
- Window sizes and divider positions are now stored and restored on next startup
|
||||
- Filters section is now a flowpane for lower resolutions
|
||||
</changed>
|
||||
<fixed></fixed>
|
||||
<removed></removed>
|
||||
</changeLog>
|
||||
<changeLog>
|
||||
<changedVersionNumber>1.2</changedVersionNumber>
|
||||
<date>2024-04</date>
|
||||
<description>Increase usability</description>
|
||||
<added>
|
||||
- Selectable bands
|
||||
- Unworkable tags for each callsign per band
|
||||
- QTF-Arrow on the "show path in AS" button
|
||||
</added>
|
||||
<changed></changed>
|
||||
<fixed></fixed>
|
||||
<removed></removed>
|
||||
</changeLog>
|
||||
<changeLog>
|
||||
<changedVersionNumber>1.1</changedVersionNumber>
|
||||
<date>2024-03</date>
|
||||
<description>Increased workflow</description>
|
||||
<added>
|
||||
- Reachable warning function
|
||||
- Variables FIRSTAP and SECONDAP for predefined texts
|
||||
- Marking new connected stations (bold) and away-state (brackets)
|
||||
- Activity-timer since last message of a chatter
|
||||
</added>
|
||||
<changed>- QRB/QTF info in pm-window</changed>
|
||||
<fixed>
|
||||
- Behaviour of messagefilter-radiobutton corrected (tnx Gian)
|
||||
- Error sending macros on selected stations in pm window (tnx Gian)
|
||||
</fixed>
|
||||
<removed>- boot sound</removed>
|
||||
</changeLog>
|
||||
<changeLog>
|
||||
<changedVersionNumber>1.0</changedVersionNumber>
|
||||
<date>2024-02</date>
|
||||
<description>First stable release</description>
|
||||
<added>
|
||||
- audio notification support
|
||||
- update-available warning
|
||||
- changelog displaying
|
||||
- known-bugs displaying
|
||||
- message filters for selected station
|
||||
- stationlist-filters (show by qtf / textstring, hide by qrb, qtf, workedstate)
|
||||
- 10 macros (textsnippets available by hitting strg+numberkey)
|
||||
</added>
|
||||
<changed></changed>
|
||||
<fixed></fixed>
|
||||
<removed></removed>
|
||||
</changeLog>
|
||||
<changeLog>
|
||||
<changedVersionNumber>0.9</changedVersionNumber>
|
||||
<date>2024-02</date>
|
||||
<description>Now the chat is disconnectable without closing it</description>
|
||||
<added></added>
|
||||
<changed></changed>
|
||||
<fixed>disconnect-button, Textsnippets in CQ Window, "/CQ" at click on own sendtexts,
|
||||
setname "qrg" works now</fixed>
|
||||
<removed></removed>
|
||||
</changeLog>
|
||||
<changeLog>
|
||||
<changedVersionNumber>0.8</changedVersionNumber>
|
||||
<date>2023-10</date>
|
||||
<description>savesettings function and disconnect function added. MYCALL-Highlighting fixed</description>
|
||||
<added>savesettings function, disconnect function</added>
|
||||
<changed></changed>
|
||||
<fixed>MYCALL-Highlighting</fixed>
|
||||
<removed></removed>
|
||||
</changeLog>
|
||||
<changeLog>
|
||||
<changedVersionNumber>0.7</changedVersionNumber>
|
||||
<date>2023-07</date>
|
||||
<description>Internal database for worked stations, shortcut and textsnippet editor</description>
|
||||
<added>database, shortcut-editor, textsnipped-editor</added>
|
||||
<changed></changed>
|
||||
<fixed></fixed>
|
||||
<removed></removed>
|
||||
</changeLog>
|
||||
<changeLog>
|
||||
<changedVersionNumber>0.6</changedVersionNumber>
|
||||
<date>2023-07</date>
|
||||
<description>Added periodically sent beacon function</description>
|
||||
<added>beacon to public channel</added>
|
||||
<changed></changed>
|
||||
<fixed></fixed>
|
||||
<removed></removed>
|
||||
</changeLog>
|
||||
<changeLog>
|
||||
<changedVersionNumber>0.5</changedVersionNumber>
|
||||
<date>2023-02</date>
|
||||
<description>Airscout interface, UDP interfaces for worked-station data and TRX info, shortcuts</description>
|
||||
<added>Airscout-interface, packetreceiver for ucxlog, n1mm, qartest</added>
|
||||
<changed></changed>
|
||||
<fixed></fixed>
|
||||
<removed></removed>
|
||||
</changeLog>
|
||||
<changeLog>
|
||||
<changedVersionNumber>0.4</changedVersionNumber>
|
||||
<date>2022-12</date>
|
||||
<description>Worked info by logfile via universal pattern matching logfile interpreter</description>
|
||||
<added>simple-logfile</added>
|
||||
<changed></changed>
|
||||
<fixed></fixed>
|
||||
<removed></removed>
|
||||
</changeLog>
|
||||
<changeLog>
|
||||
<changedVersionNumber>0.3</changedVersionNumber>
|
||||
<date>2022-10</date>
|
||||
<description>Added a GUI, chat was text based only</description>
|
||||
<added>Graphical user interface</added>
|
||||
<changed></changed>
|
||||
<fixed></fixed>
|
||||
<removed></removed>
|
||||
</changeLog>
|
||||
<changeLog>
|
||||
<changedVersionNumber>0.2</changedVersionNumber>
|
||||
<date>2022-10</date>
|
||||
<description>Pattern matching system for frequency information, frequency collection per chatmember</description>
|
||||
<added>frequency-extraction for every callsign</added>
|
||||
<changed></changed>
|
||||
<fixed></fixed>
|
||||
<removed></removed>
|
||||
</changeLog>
|
||||
<changeLog>
|
||||
<changedVersionNumber>0.15</changedVersionNumber>
|
||||
<date>2022-09</date>
|
||||
<description>Researched the Chatprotocol and developed the base communication with the chatserver</description>
|
||||
<added></added>
|
||||
<changed></changed>
|
||||
<fixed></fixed>
|
||||
<removed></removed>
|
||||
</changeLog>
|
||||
<changeLog>
|
||||
<changedVersionNumber>0.1</changedVersionNumber>
|
||||
<date>2022-09</date>
|
||||
<description>Primary architecture of the chat, message bus system for handling the message queues</description>
|
||||
<added></added>
|
||||
<changed></changed>
|
||||
<fixed></fixed>
|
||||
<removed></removed>
|
||||
</changeLog>
|
||||
</history>
|
||||
@@ -0,0 +1,131 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Static roadmap/bugsReported sections carried over from the previously
|
||||
hand-maintained kst4ContestVersionInfo.xml on do5amf.funkerportal.de.
|
||||
UpdateChecker.java does not parse these today; kept for parity/history. -->
|
||||
<legacy>
|
||||
<roadmap>
|
||||
<featurerequest>
|
||||
<featureDescription>Cyclic update of own name in chat to actual QRG</featureDescription>
|
||||
<requestDate>2024-02</requestDate>
|
||||
<state>open</state>
|
||||
</featurerequest>
|
||||
<featurerequest>
|
||||
<featureDescription>Acoustic warnings</featureDescription>
|
||||
<requestDate>2023-12</requestDate>
|
||||
<state>added</state>
|
||||
</featurerequest>
|
||||
<featurerequest>
|
||||
<featureDescription>Airscout support</featureDescription>
|
||||
<requestDate>2023-03</requestDate>
|
||||
<state>added</state>
|
||||
</featurerequest>
|
||||
<featurerequest>
|
||||
<featureDescription>cyclic beacon messages in public chat</featureDescription>
|
||||
<requestDate>2023-03</requestDate>
|
||||
<state>added</state>
|
||||
</featurerequest>
|
||||
</roadmap>
|
||||
<bugsReported>
|
||||
<bug>
|
||||
<bugdescription>
|
||||
stations like ol7c-7 and ol7c-2 will not be marked as worked if only "ol7c"
|
||||
is in the log. Need to interprete that. May ignore suffix starting with "-"</bugdescription>
|
||||
<state>solved</state>
|
||||
</bug>
|
||||
<bug>
|
||||
<bugdescription>
|
||||
worked callsigns table is updated only once after connected to chat
|
||||
Should be available directly after program started.
|
||||
But Reset works all time</bugdescription>
|
||||
<state>open</state>
|
||||
</bug>
|
||||
<bug>
|
||||
<bugdescription>feature request: language-specific strings, maybe based to an xml file
|
||||
</bugdescription>
|
||||
<state>open</state>
|
||||
</bug>
|
||||
<bug>
|
||||
<bugdescription>feature request: Macros
|
||||
</bugdescription>
|
||||
<state>added</state>
|
||||
</bug>
|
||||
<bug>
|
||||
<bugdescription>feature request:
|
||||
Automatic elimination of calls already connected from the log
|
||||
</bugdescription>
|
||||
<state>added</state>
|
||||
</bug>
|
||||
<bug>
|
||||
<bugdescription>feature request:
|
||||
Limitation of users beyond a certain QRB
|
||||
</bugdescription>
|
||||
<state>added</state>
|
||||
</bug>
|
||||
<bug>
|
||||
<bugdescription>feature request:
|
||||
User division for QTF perhaps settable based on the antenna lobe
|
||||
</bugdescription>
|
||||
<state>added</state>
|
||||
</bug>
|
||||
<bug>
|
||||
<bugdescription>feature request:
|
||||
Notification of new incoming users</bugdescription>
|
||||
<state>added</state>
|
||||
</bug>
|
||||
<bug>
|
||||
<bugdescription>feature request:
|
||||
Evidence of a call which was written but not responded to.
|
||||
</bugdescription>
|
||||
<state>added</state>
|
||||
</bug>
|
||||
<bug>
|
||||
<bugdescription>feature request:
|
||||
direction based reachable-warnings:
|
||||
if the chatclient detects a realised qso between two stations which both must have
|
||||
directed their beams to my station, there should be an instant warning to me for
|
||||
working at least one of the stations at the detected qrg</bugdescription>
|
||||
<state>open</state>
|
||||
</bug>
|
||||
<bug>
|
||||
<bugdescription>feature request:
|
||||
implement ASShowpath message to airacout;
|
||||
by doubleclicking a callsign in the chatmember list, the path and plane state should be
|
||||
provided by airscout </bugdescription>
|
||||
<state>open</state>
|
||||
</bug>
|
||||
<bug>
|
||||
<bugdescription>feature request:
|
||||
make chatlines copyable to the clipboard...</bugdescription>
|
||||
<state>open</state>
|
||||
</bug>
|
||||
<bug>
|
||||
<bugdescription>feature request:
|
||||
adding an activity detection for sensing if another chatmembers alive-state</bugdescription>
|
||||
<state>added</state>
|
||||
</bug>
|
||||
<bug>
|
||||
<bugdescription>On sql error close the connection to make a clean exit</bugdescription>
|
||||
<state>open</state>
|
||||
</bug>
|
||||
<bug>
|
||||
<bugdescription>SETNAME /MYQRG in options menu doesnt work</bugdescription>
|
||||
<state>solved</state>
|
||||
</bug>
|
||||
<bug>
|
||||
<bugdescription>Disconnect button does not work (DL5ZK)</bugdescription>
|
||||
<state>solved</state>
|
||||
</bug>
|
||||
<bug>
|
||||
<bugdescription>Password-wrong error not displayed (DO5SA)</bugdescription>
|
||||
<state>solved</state>
|
||||
</bug>
|
||||
<bug>
|
||||
<bugdescription>/CQ isnt written on own PMs</bugdescription>
|
||||
<state>solved</state>
|
||||
</bug>
|
||||
<bug>
|
||||
<bugdescription>rightclick-textsnippets arent available in cq-table</bugdescription>
|
||||
<state>solved</state>
|
||||
</bug>
|
||||
</bugsReported>
|
||||
</legacy>
|
||||
@@ -0,0 +1,209 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const REPO = "praktimarc/kst4contest";
|
||||
const API = `https://api.github.com/repos/${REPO}`;
|
||||
const LABEL_MAP = { enhancement: "added", bug: "fixed" };
|
||||
|
||||
async function githubGet(urlPath) {
|
||||
const headers = { Accept: "application/vnd.github+json" };
|
||||
if (process.env.GITHUB_TOKEN) {
|
||||
headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
|
||||
}
|
||||
const res = await fetch(`${API}${urlPath}`, { headers });
|
||||
if (!res.ok) {
|
||||
throw new Error(`GitHub API ${urlPath} failed: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// UpdateChecker.java parses <versionNumber> with Double.parseDouble() and
|
||||
// compares it against ApplicationConstants.APPLICATION_CURRENTVERSIONNUMBER,
|
||||
// which encodes patch releases by appending the patch digit(s) instead of a
|
||||
// third dot (e.g. tag v1.41.1 -> app version 1.411, v1.41.0 -> 1.41). See
|
||||
// commit 38ef50c ("...set to 1.41.1 for upcoming hotfix" -> 1.411).
|
||||
function toAppVersionNumber(tag) {
|
||||
const cleaned = tag.replace(/^v/, "");
|
||||
const [major, minor, ...rest] = cleaned.split(".");
|
||||
if (rest.length === 0) return cleaned;
|
||||
const patch = rest.join("");
|
||||
return /^0*$/.test(patch) ? `${major}.${minor}` : `${major}.${minor}${patch}`;
|
||||
}
|
||||
|
||||
function toSemanticVersion(tag) {
|
||||
return tag
|
||||
.replace(/^v/, "")
|
||||
.split("-")[0]
|
||||
.split("+")[0];
|
||||
}
|
||||
|
||||
function escapeXml(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
// Strips GitHub's auto-generated release-notes markdown noise (headings,
|
||||
// "Full Changelog" links, PR author attributions) down to plain text.
|
||||
function cleanReleaseBody(body) {
|
||||
const lines = [];
|
||||
for (let line of (body || "").split("\n")) {
|
||||
if (/^#{1,3}\s/.test(line)) continue;
|
||||
if (line.trim().startsWith("**Full Changelog**")) continue;
|
||||
line = line.replace(/\s+by @\S+ in https:\/\/\S+/, "");
|
||||
line = line.replace(/^\* /, "- ");
|
||||
if (line.trim()) lines.push(line);
|
||||
}
|
||||
return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
||||
}
|
||||
|
||||
// Groups closed issues (excluding PRs) in the (since, until] window by
|
||||
// label into "added" (enhancement) / "fixed" (bug) bullet lists.
|
||||
async function getIssuesForRelease(since, until) {
|
||||
const buckets = { added: [], fixed: [] };
|
||||
let page = 1;
|
||||
for (;;) {
|
||||
const items = await githubGet(`/issues?state=closed&per_page=100&page=${page}`);
|
||||
if (!items.length) break;
|
||||
for (const issue of items) {
|
||||
if (issue.pull_request) continue;
|
||||
const closed = issue.closed_at || "";
|
||||
if (!(closed > since && closed <= until)) continue;
|
||||
const labels = issue.labels.map((l) => l.name);
|
||||
const bucket = Object.keys(LABEL_MAP).find((l) => labels.includes(l));
|
||||
if (bucket) buckets[LABEL_MAP[bucket]].push(`- ${issue.title}`);
|
||||
}
|
||||
if (items.length < 100) break;
|
||||
page++;
|
||||
}
|
||||
return { added: buckets.added.join("\n"), fixed: buckets.fixed.join("\n") };
|
||||
}
|
||||
|
||||
// version-history.xml holds hand-written <changeLog> entries for releases
|
||||
// published before GitHub Releases existed; re-emitted verbatim here.
|
||||
function loadHistoryEntries() {
|
||||
const historyPath = path.join(__dirname, "version-history.xml");
|
||||
const xml = fs.readFileSync(historyPath, "utf-8");
|
||||
return [...xml.matchAll(/<changeLog>[\s\S]*?<\/changeLog>/g)].map((m) => m[0]);
|
||||
}
|
||||
|
||||
function historyEntryVersion(entryXml) {
|
||||
const m = entryXml.match(/<changedVersionNumber>([\s\S]*?)<\/changedVersionNumber>/);
|
||||
return m ? m[1].trim() : null;
|
||||
}
|
||||
|
||||
// Static <roadmap>/<bugsReported> sections carried over from the previous
|
||||
// hand-maintained XML on do5amf.funkerportal.de. UpdateChecker.java doesn't
|
||||
// parse these, kept only so the migration doesn't drop existing content.
|
||||
function loadLegacySections() {
|
||||
const legacyPath = path.join(__dirname, "version-legacy-sections.xml");
|
||||
const xml = fs.readFileSync(legacyPath, "utf-8");
|
||||
const roadmap = xml.match(/<roadmap>[\s\S]*?<\/roadmap>/);
|
||||
const bugsReported = xml.match(/<bugsReported>[\s\S]*?<\/bugsReported>/);
|
||||
return [roadmap ? roadmap[0] : null, bugsReported ? bugsReported[0] : null].filter(Boolean);
|
||||
}
|
||||
|
||||
// Builds kst4ContestVersionInfo.xml (the update-checker feed read by
|
||||
// UpdateChecker.java) from GitHub releases + closed issues, falling back to
|
||||
// version-history.xml for releases that predate GitHub Releases.
|
||||
module.exports = async function () {
|
||||
const fallback = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n<praktiKST></praktiKST>';
|
||||
|
||||
try {
|
||||
const historyEntries = loadHistoryEntries();
|
||||
|
||||
const rawReleases = await githubGet("/releases?per_page=100");
|
||||
const releases = rawReleases
|
||||
.map((r) => ({
|
||||
tagName: r.tag_name,
|
||||
publishedAt: r.published_at || "",
|
||||
name: r.name || r.tag_name,
|
||||
body: r.body || "",
|
||||
isPrerelease: r.prerelease
|
||||
}))
|
||||
.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1));
|
||||
|
||||
const stableReleases = releases.filter((r) => !r.isPrerelease);
|
||||
const stable = stableReleases[0];
|
||||
const ghVersions = new Set(stableReleases.map((r) => toAppVersionNumber(r.tagName)));
|
||||
|
||||
const parts = [];
|
||||
parts.push('<?xml version="1.0" encoding="UTF-8" standalone="no"?>');
|
||||
parts.push("<praktiKST>");
|
||||
|
||||
const latestTag = stable ? stable.tagName : null;
|
||||
|
||||
parts.push(" <latestVersion>");
|
||||
if (stable) {
|
||||
const latestIssues = await getIssuesForRelease(
|
||||
"1970-01-01T00:00:00Z",
|
||||
stable.publishedAt
|
||||
);
|
||||
|
||||
// Kept for application versions that still expect the old numeric format.
|
||||
parts.push(
|
||||
` <versionNumber>${escapeXml(toAppVersionNumber(latestTag))}</versionNumber>`
|
||||
);
|
||||
|
||||
// Used by current application versions for correct semantic comparison
|
||||
// and display, for example 1.41.1 instead of 1.411.
|
||||
parts.push(
|
||||
` <semanticVersion>${escapeXml(toSemanticVersion(latestTag))}</semanticVersion>`
|
||||
);
|
||||
|
||||
parts.push(" <adminMessage></adminMessage>");
|
||||
parts.push(
|
||||
` <majorChanges>${escapeXml(latestIssues.added.slice(0, 300))}</majorChanges>`
|
||||
);
|
||||
|
||||
// The release page is deliberately platform-neutral. The application
|
||||
// cannot know whether the user needs Windows, Linux or macOS packages.
|
||||
parts.push(
|
||||
` <latestVersionPathOnWebserver>https://github.com/${REPO}/releases/tag/${latestTag}</latestVersionPathOnWebserver>`
|
||||
);
|
||||
}
|
||||
parts.push(" </latestVersion>");
|
||||
|
||||
// UpdateChecker.parseUpdateXMLFile() never actually reads this field
|
||||
// (setNeedUpdateResourcesSinceLastVersion is never called); the
|
||||
// previous server always hardcoded "nothing" here, so we match that.
|
||||
parts.push(" <needUpdateSinceLastVersion>");
|
||||
parts.push(" <filename>nothing</filename>");
|
||||
parts.push(" </needUpdateSinceLastVersion>");
|
||||
|
||||
for (const section of loadLegacySections()) {
|
||||
parts.push(section);
|
||||
}
|
||||
|
||||
for (let i = 0; i < stableReleases.length; i++) {
|
||||
const rel = stableReleases[i];
|
||||
const until = rel.publishedAt;
|
||||
const since = stableReleases[i + 1] ? stableReleases[i + 1].publishedAt : "1970-01-01T00:00:00Z";
|
||||
const issues = await getIssuesForRelease(since, until);
|
||||
|
||||
parts.push(" <changeLog>");
|
||||
|
||||
parts.push(` <changedVersionNumber>${escapeXml(toSemanticVersion(rel.tagName))}</changedVersionNumber>`);
|
||||
parts.push(` <date>${escapeXml(until.slice(0, 10))}</date>`);
|
||||
parts.push(` <description>${escapeXml(rel.name)}</description>`);
|
||||
parts.push(` <added>${escapeXml(issues.added)}</added>`);
|
||||
parts.push(` <changed>${escapeXml(cleanReleaseBody(rel.body))}</changed>`);
|
||||
parts.push(` <fixed>${escapeXml(issues.fixed)}</fixed>`);
|
||||
parts.push(" <removed></removed>");
|
||||
parts.push(" </changeLog>");
|
||||
}
|
||||
|
||||
for (const entry of historyEntries) {
|
||||
const version = historyEntryVersion(entry);
|
||||
if (version && ghVersions.has(version)) continue;
|
||||
parts.push(entry);
|
||||
}
|
||||
|
||||
parts.push("</praktiKST>");
|
||||
return parts.join("\n");
|
||||
} catch (err) {
|
||||
console.warn(`[versionInfo] Could not generate version info XML, using empty fallback: ${err.message}`);
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{ title or "KST4Contest" }}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#08110b">
|
||||
<meta name="description" content="{{ description or 'KST4Contest connects ON4KST chat, candidate selection, sked planning and station software for VHF, UHF and SHF contest operation.' }}">
|
||||
<link rel="canonical" href="https://kst4contest.hamradioonline.de{{ page.url }}">
|
||||
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="{{ title or 'KST4Contest' }}">
|
||||
<meta property="og:description" content="{{ description or 'ON4KST chat, candidate selection, sked planning and station software in one VHF, UHF and SHF contest workflow.' }}">
|
||||
<meta property="og:url" content="https://kst4contest.hamradioonline.de{{ page.url }}">
|
||||
<meta property="og:site_name" content="KST4Contest">
|
||||
<meta property="og:locale" content="en_GB">
|
||||
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="{{ title or 'KST4Contest' }}">
|
||||
<meta name="twitter:description" content="{{ description or 'ON4KST chat, candidate selection, sked planning and station software in one VHF, UHF and SHF contest workflow.' }}">
|
||||
<meta name="twitter:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
|
||||
|
||||
<link rel="stylesheet" href="/assets/css/main.css?v={{ build.version }}">
|
||||
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="site-bg" aria-hidden="true"></div>
|
||||
|
||||
<header class="site-header">
|
||||
<a class="brand" href="/" aria-label="KST4Contest home">
|
||||
<span class="brand-mark" aria-hidden="true">◎</span>
|
||||
<span>
|
||||
<strong>KST4Contest</strong>
|
||||
<small>ON4KST Contest Client</small>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<nav class="site-nav" aria-label="Main navigation">
|
||||
{% for item in navigation %}
|
||||
<a href="{{ item.url }}">{{ item.title }}</a>
|
||||
{% endfor %}
|
||||
<a class="nav-cta" href="/download/">Download</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>{{ content | safe }}</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="footer-grid">
|
||||
<div>
|
||||
<strong>KST4Contest</strong>
|
||||
<p>ON4KST chat, candidate selection and sked planning for VHF, UHF and SHF contest operation.</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Project</strong>
|
||||
<p><a href="/features/">Features</a></p>
|
||||
<p><a href="/download/">Download</a></p>
|
||||
<p><a href="/manual/">Manual</a></p>
|
||||
<p><a href="/roadmap/">Roadmap</a></p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Community</strong>
|
||||
<p><a href="https://github.com/praktimarc/kst4contest">GitHub Repository</a></p>
|
||||
<p><a href="https://github.com/praktimarc/kst4contest/issues">Issues</a></p>
|
||||
<p><a href="/contact/">Contact</a></p>
|
||||
<p><a href="/support/">❤️ Support KST4Contest</a></p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Legal</strong>
|
||||
<p><a href="/legal-notice/">Legal Notice</a></p>
|
||||
<p><a href="/privacy/">Privacy Policy</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{% if heroFx %}
|
||||
<script src="/assets/js/hero-radio-fx.js?v={{ build.version }}" defer></script>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
layout: base.njk
|
||||
---
|
||||
|
||||
<section class="hero">
|
||||
<p class="badge">{{ category }} · since {{ since }}</p>
|
||||
<h1>{{ title }}</h1>
|
||||
<p class="lead">{{ summary }}</p>
|
||||
</section>
|
||||
|
||||
<section class="section narrow">
|
||||
<article class="card content-card">
|
||||
<div class="feature-icon large">{{ icon }}</div>
|
||||
|
||||
{{ content | safe }}
|
||||
|
||||
{% if tagsList %}
|
||||
<h2>Keywords</h2>
|
||||
<div class="tag-list">
|
||||
{% for tag in tagsList %}
|
||||
<span>{{ tag }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if related %}
|
||||
<h2>Related features</h2>
|
||||
<div class="actions">
|
||||
{% for slug in related %}
|
||||
{% for item in collections.sortedFeatures %}
|
||||
{% if item.fileSlug == slug %}
|
||||
<a class="button secondary" href="{{ item.url }}">{{ item.data.title }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "TechArticle",
|
||||
"headline": "{{ title }}",
|
||||
"description": "{{ summary }}",
|
||||
"about": "KST4Contest",
|
||||
"url": "https://kst4contest.hamradioonline.de{{ page.url }}",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Praktimarc"
|
||||
},
|
||||
"publisher": {
|
||||
"@type": "Organization",
|
||||
"name": "KST4Contest"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
layout: base.njk
|
||||
---
|
||||
|
||||
<section class="hero">
|
||||
<p class="badge">News · {{ date | readableDate }}</p>
|
||||
<h1>{{ title }}</h1>
|
||||
<p class="lead">{{ summary }}</p>
|
||||
</section>
|
||||
|
||||
<section class="section narrow">
|
||||
<article class="card content-card">
|
||||
{{ content | safe }}
|
||||
</article>
|
||||
</section>
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
layout: base.njk
|
||||
title: About KST4Contest
|
||||
description: About KST4Contest and its contest-oriented ON4KST workflow.
|
||||
---
|
||||
|
||||
<section class="hero">
|
||||
<p class="badge">About</p>
|
||||
<h1>Built for real contest operation.</h1>
|
||||
<p class="lead">
|
||||
KST4Contest was created to turn ON4KST chat activity into actionable contest workflow.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="section narrow">
|
||||
<article class="card content-card">
|
||||
<h2>Why KST4Contest exists</h2>
|
||||
<p>
|
||||
Generic chat clients display messages. KST4Contest goes further:
|
||||
it helps operators identify candidates, manage skeds, use AirScout timing,
|
||||
synchronize with loggers and reduce workload during VHF/UHF/SHF contests.
|
||||
</p>
|
||||
|
||||
<h2>Open source and practical</h2>
|
||||
<p>
|
||||
The project is open source and focused on practical contest station workflows.
|
||||
Features are designed around real operating pressure, not theoretical UI concepts.
|
||||
</p>
|
||||
|
||||
<h2>Community supported</h2>
|
||||
|
||||
<p>
|
||||
|
||||
KST4Contest is developed independently.
|
||||
|
||||
Community support helps finance hosting,
|
||||
online services,
|
||||
testing across multiple operating systems
|
||||
and future contest-oriented features.
|
||||
|
||||
</p>
|
||||
|
||||
</article>
|
||||
|
||||
</section>
|
||||
@@ -0,0 +1,807 @@
|
||||
:root {
|
||||
--bg: #050816;
|
||||
--bg2: #0b1224;
|
||||
--panel: rgba(15, 23, 42, 0.78);
|
||||
--panel2: rgba(30, 41, 59, 0.72);
|
||||
--border: rgba(148, 163, 184, 0.22);
|
||||
--text: #f8fafc;
|
||||
--muted: #aab6ca;
|
||||
--soft: #64748b;
|
||||
--accent: #38bdf8;
|
||||
--accent2: #a855f7;
|
||||
--accent3: #22c55e;
|
||||
--shadow: 0 24px 80px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at 14% 12%, rgba(168, 85, 247, 0.30), transparent 28%),
|
||||
radial-gradient(circle at 82% 16%, rgba(56, 189, 248, 0.24), transparent 30%),
|
||||
radial-gradient(circle at 50% 85%, rgba(34, 197, 94, 0.11), transparent 35%),
|
||||
linear-gradient(180deg, #050816 0%, #07111f 100%);
|
||||
color: var(--text);
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
line-height: 1.65;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: -20%;
|
||||
z-index: -3;
|
||||
background:
|
||||
radial-gradient(circle at 20% 20%, rgba(168, 85, 247, 0.22), transparent 28%),
|
||||
radial-gradient(circle at 78% 18%, rgba(56, 189, 248, 0.18), transparent 30%),
|
||||
radial-gradient(circle at 45% 80%, rgba(34, 197, 94, 0.10), transparent 26%);
|
||||
filter: blur(48px);
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
body::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -2;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,0.038) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.038) 1px, transparent 1px);
|
||||
background-size: 54px 54px;
|
||||
-webkit-mask-image: radial-gradient(circle at center, black 0%, black 46%, transparent 78%);
|
||||
mask-image: radial-gradient(circle at center, black 0%, black 46%, transparent 78%);
|
||||
}
|
||||
|
||||
.site-bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(5, 8, 22, 0.10), rgba(5, 8, 22, 0.84)),
|
||||
radial-gradient(circle at 8% 24%, rgba(168, 85, 247, 0.18), transparent 26%),
|
||||
radial-gradient(circle at 82% 20%, rgba(56, 189, 248, 0.16), transparent 30%);
|
||||
}
|
||||
|
||||
.site-bg::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at top, transparent 0%, rgba(5, 8, 22, 0.42) 72%),
|
||||
linear-gradient(90deg, rgba(5, 8, 22, 0.25), transparent 50%, rgba(5, 8, 22, 0.25));
|
||||
}
|
||||
|
||||
.site-bg::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
radial-gradient(circle at 1px 1px, rgba(255,255,255,0.10) 1px, transparent 0);
|
||||
background-size: 28px 28px;
|
||||
opacity: 0.11;
|
||||
-webkit-mask-image: radial-gradient(circle at 50% 22%, black 0%, transparent 68%);
|
||||
mask-image: radial-gradient(circle at 50% 22%, black 0%, transparent 68%);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.site-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
padding: 18px 6vw;
|
||||
background: rgba(5, 8, 22, 0.76);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
backdrop-filter: blur(18px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 14px;
|
||||
background:
|
||||
radial-gradient(circle at 30% 20%, white, transparent 22%),
|
||||
linear-gradient(135deg, var(--accent), var(--accent2));
|
||||
box-shadow:
|
||||
0 0 26px rgba(168, 85, 247, 0.45),
|
||||
0 0 50px rgba(56, 189, 248, 0.18);
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
display: block;
|
||||
font-size: 1.05rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.brand small {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.site-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.site-nav a {
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.site-nav a:hover {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-cta {
|
||||
padding: 9px 14px;
|
||||
border-radius: 999px;
|
||||
color: white !important;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||||
box-shadow: 0 0 22px rgba(56, 189, 248, 0.28);
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
padding: 92px 6vw 62px;
|
||||
max-width: 1180px;
|
||||
}
|
||||
|
||||
.hero::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -160px -12vw auto -12vw;
|
||||
height: 420px;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(circle at 24% 40%, rgba(168, 85, 247, 0.20), transparent 36%),
|
||||
radial-gradient(circle at 62% 30%, rgba(56, 189, 248, 0.14), transparent 40%);
|
||||
filter: blur(74px);
|
||||
}
|
||||
|
||||
.hero-split {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(360px, 0.9fr);
|
||||
gap: 42px;
|
||||
align-items: center;
|
||||
max-width: 1500px;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0 0 22px;
|
||||
font-size: clamp(3rem, 8vw, 6.5rem);
|
||||
line-height: 0.95;
|
||||
letter-spacing: -0.07em;
|
||||
background: linear-gradient(90deg, #ffffff 0%, #7dd3fc 40%, #a855f7 78%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.hero .lead,
|
||||
.hero > p,
|
||||
.section-heading p,
|
||||
.cta-panel p {
|
||||
color: var(--muted);
|
||||
font-size: 1.18rem;
|
||||
max-width: 850px;
|
||||
}
|
||||
|
||||
.badge,
|
||||
.eyebrow {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #e9d5ff;
|
||||
background: rgba(168, 85, 247, 0.13);
|
||||
border: 1px solid rgba(168, 85, 247, 0.34);
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 800;
|
||||
box-shadow: 0 0 22px rgba(168, 85, 247, 0.12);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
margin-top: 30px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 46px;
|
||||
padding: 12px 18px;
|
||||
border-radius: 14px;
|
||||
color: #020617;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||||
font-weight: 800;
|
||||
box-shadow:
|
||||
0 16px 44px rgba(56, 189, 248, 0.25),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
color: white;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
color: white;
|
||||
background: rgba(30, 41, 59, 0.85);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.button.ghost {
|
||||
color: white;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.hero-panel,
|
||||
.card,
|
||||
.cta-panel {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255,255,255,0.045), transparent 42%),
|
||||
var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 26px;
|
||||
box-shadow: var(--shadow);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.hero-panel {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transform: perspective(1200px) rotateY(-5deg) rotateX(2deg);
|
||||
}
|
||||
|
||||
.hero-panel::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(56, 189, 248, 0.45), transparent 35%, rgba(168, 85, 247, 0.38));
|
||||
opacity: 0.18;
|
||||
}
|
||||
|
||||
.terminal-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: rgba(2, 6, 23, 0.74);
|
||||
}
|
||||
|
||||
.terminal-bar span {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent2);
|
||||
}
|
||||
|
||||
.terminal-bar span:nth-child(2) {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.terminal-bar span:nth-child(3) {
|
||||
background: var(--accent3);
|
||||
}
|
||||
|
||||
.terminal-bar strong {
|
||||
margin-left: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.mock-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.mini-card {
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
background: rgba(15, 23, 42, 0.88);
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
}
|
||||
|
||||
.mini-card strong,
|
||||
.mini-card small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mini-card small {
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 52px 6vw;
|
||||
}
|
||||
|
||||
.section.narrow {
|
||||
max-width: 1040px;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
font-size: clamp(2rem, 4vw, 3.4rem);
|
||||
line-height: 1.05;
|
||||
margin: 12px 0 14px;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 24px;
|
||||
transition: transform 180ms ease, border-color 180ms ease, background 180ms ease, box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-4px);
|
||||
border-color: rgba(56, 189, 248, 0.45);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255,255,255,0.055), transparent 42%),
|
||||
rgba(15, 23, 42, 0.92);
|
||||
box-shadow:
|
||||
0 26px 90px rgba(0, 0, 0, 0.50),
|
||||
0 0 30px rgba(56, 189, 248, 0.08);
|
||||
}
|
||||
|
||||
.card h3,
|
||||
.card h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.card p {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.roadmap-version + .roadmap-version {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.roadmap-version ul {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.roadmap-version li {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.roadmap-done a {
|
||||
color: var(--muted);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.feature-icon.large {
|
||||
font-size: 4rem;
|
||||
}
|
||||
|
||||
.content-card {
|
||||
max-width: 980px;
|
||||
}
|
||||
|
||||
.tag-list {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tag-list span {
|
||||
padding: 7px 11px;
|
||||
border-radius: 999px;
|
||||
color: var(--muted);
|
||||
background: rgba(15, 23, 42, 0.85);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.download-card {
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.screenshot-placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 180px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 18px;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(56, 189, 248, 0.20), rgba(168, 85, 247, 0.20)),
|
||||
rgba(2, 6, 23, 0.7);
|
||||
border: 1px solid var(--border);
|
||||
color: white;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.cta-panel {
|
||||
padding: clamp(28px, 5vw, 60px);
|
||||
}
|
||||
|
||||
.manual-content {
|
||||
max-width: 980px;
|
||||
}
|
||||
|
||||
.manual-content h1,
|
||||
.manual-content h2,
|
||||
.manual-content h3 {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.manual-content pre {
|
||||
overflow-x: auto;
|
||||
padding: 16px;
|
||||
border-radius: 14px;
|
||||
background: #020617;
|
||||
}
|
||||
|
||||
.manual-content code {
|
||||
color: #bae6fd;
|
||||
}
|
||||
|
||||
.manual-content table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.manual-content th,
|
||||
.manual-content td {
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.22);
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
padding: 42px 6vw;
|
||||
color: var(--muted);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero-split {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.hero-panel {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.site-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.button,
|
||||
.card {
|
||||
transition: all 180ms ease;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.footer-grid strong {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.footer-grid p {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.footer-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
/* ================================================================
|
||||
KST4Contest brand recovery: green operator-console identity
|
||||
and full-screen interactive hero
|
||||
================================================================ */
|
||||
|
||||
:root {
|
||||
--bg: #050806;
|
||||
--bg2: #0b140f;
|
||||
--panel: rgba(15, 28, 20, 0.82);
|
||||
--panel2: rgba(24, 43, 31, 0.84);
|
||||
--border: rgba(125, 178, 134, 0.22);
|
||||
--text: #f4f7f3;
|
||||
--muted: #a9b8ad;
|
||||
--soft: #6e8173;
|
||||
--accent: #54d63d;
|
||||
--accent2: #8aef64;
|
||||
--accent3: #31b54a;
|
||||
--fx-plane: #79ff73;
|
||||
--fx-plane-muted: rgba(121, 255, 115, 0.42);
|
||||
--fx-beam: #52ff65;
|
||||
--fx-beam-glow: rgba(82, 255, 101, 0.28);
|
||||
--fx-reflection: #d2ffc8;
|
||||
--fx-trail: rgba(121, 255, 115, 0.17);
|
||||
--fx-grid: rgba(121, 255, 115, 0.055);
|
||||
}
|
||||
|
||||
body {
|
||||
background:
|
||||
radial-gradient(circle at 14% 12%, rgba(52, 165, 57, 0.20), transparent 30%),
|
||||
radial-gradient(circle at 82% 16%, rgba(95, 218, 82, 0.14), transparent 32%),
|
||||
radial-gradient(circle at 50% 85%, rgba(32, 126, 55, 0.12), transparent 36%),
|
||||
linear-gradient(180deg, #070b09 0%, #0b1410 100%);
|
||||
}
|
||||
|
||||
body::before {
|
||||
background:
|
||||
radial-gradient(circle at 20% 20%, rgba(60, 173, 61, 0.18), transparent 28%),
|
||||
radial-gradient(circle at 78% 18%, rgba(97, 222, 80, 0.13), transparent 30%),
|
||||
radial-gradient(circle at 45% 80%, rgba(34, 122, 53, 0.11), transparent 26%);
|
||||
}
|
||||
|
||||
body::after {
|
||||
background-image:
|
||||
linear-gradient(rgba(160,255,160,0.032) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(160,255,160,0.032) 1px, transparent 1px);
|
||||
}
|
||||
|
||||
.site-bg {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(5, 8, 6, 0.10), rgba(5, 8, 6, 0.84)),
|
||||
radial-gradient(circle at 8% 24%, rgba(52, 165, 57, 0.14), transparent 26%),
|
||||
radial-gradient(circle at 82% 20%, rgba(95, 218, 82, 0.12), transparent 30%);
|
||||
}
|
||||
|
||||
.site-header {
|
||||
background: rgba(5, 8, 7, 0.82);
|
||||
}
|
||||
|
||||
.brand-mark,
|
||||
.nav-cta,
|
||||
.button {
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
box-shadow:
|
||||
0 0 26px rgba(84, 214, 61, 0.42),
|
||||
0 0 50px rgba(49, 181, 74, 0.16);
|
||||
}
|
||||
|
||||
.nav-cta {
|
||||
color: #061008 !important;
|
||||
box-shadow: 0 0 22px rgba(84, 214, 61, 0.28);
|
||||
}
|
||||
|
||||
main {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hero.hero-split {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
min-height: calc(100vh - 73px);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(420px, 0.9fr) minmax(520px, 1.1fr);
|
||||
gap: clamp(40px, 5vw, 110px);
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
padding: clamp(56px, 7vh, 100px) clamp(28px, 6vw, 110px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero.hero-split > *:not(.hero-radio-fx):not(.hero-fx-vignette) {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.hero.hero-split h1 {
|
||||
font-size: clamp(3.4rem, 8vw, 7rem);
|
||||
background: linear-gradient(90deg, #ffffff 0%, #c9ffd0 28%, #77ec70 58%, #45bd4d 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.badge,
|
||||
.eyebrow {
|
||||
color: #caffc0;
|
||||
background: rgba(52, 165, 57, 0.12);
|
||||
border-color: rgba(105, 232, 88, 0.32);
|
||||
box-shadow: 0 0 22px rgba(82, 255, 101, 0.08);
|
||||
}
|
||||
|
||||
.button {
|
||||
color: #041007;
|
||||
box-shadow:
|
||||
0 16px 44px rgba(84, 214, 61, 0.22),
|
||||
inset 0 1px 0 rgba(255,255,255,0.35);
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
color: white;
|
||||
background: rgba(28, 43, 32, 0.90);
|
||||
}
|
||||
|
||||
.hero-panel {
|
||||
width: min(100%, 760px);
|
||||
justify-self: center;
|
||||
transform: perspective(1200px) rotateY(-4deg) rotateX(1.5deg);
|
||||
}
|
||||
|
||||
.hero-panel,
|
||||
.card,
|
||||
.cta-panel {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255,255,255,0.045), transparent 42%),
|
||||
rgba(15, 28, 20, 0.82);
|
||||
}
|
||||
|
||||
.mini-card {
|
||||
background: rgba(12, 25, 17, 0.88);
|
||||
border-color: rgba(148, 184, 155, 0.16);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: rgba(84, 214, 61, 0.42);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255,255,255,0.055), transparent 42%),
|
||||
rgba(12, 25, 17, 0.94);
|
||||
box-shadow:
|
||||
0 26px 90px rgba(0,0,0,0.50),
|
||||
0 0 30px rgba(84,214,61,0.07);
|
||||
}
|
||||
|
||||
.hero-radio-fx {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.hero-fx-vignette {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(90deg,
|
||||
rgba(7,11,9,0.92) 0%,
|
||||
rgba(7,11,9,0.70) 32%,
|
||||
rgba(7,11,9,0.17) 62%,
|
||||
rgba(7,11,9,0.38) 100%),
|
||||
linear-gradient(180deg,
|
||||
rgba(7,11,9,0.08),
|
||||
rgba(7,11,9,0.58));
|
||||
}
|
||||
|
||||
html[data-theme="light"] {
|
||||
--bg: #f4f4f2;
|
||||
--bg2: #ffffff;
|
||||
--panel: rgba(255,255,255,0.90);
|
||||
--panel2: rgba(242,242,239,0.94);
|
||||
--border: rgba(91,91,86,0.20);
|
||||
--text: #252522;
|
||||
--muted: #656560;
|
||||
--soft: #888881;
|
||||
--accent: #d33732;
|
||||
--accent2: #f25a51;
|
||||
--accent3: #ae2727;
|
||||
--fx-plane: #df3732;
|
||||
--fx-plane-muted: rgba(211,55,50,0.38);
|
||||
--fx-beam: #ef4039;
|
||||
--fx-beam-glow: rgba(239,64,57,0.22);
|
||||
--fx-reflection: #ff8d84;
|
||||
--fx-trail: rgba(211,55,50,0.15);
|
||||
--fx-grid: rgba(185,47,43,0.045);
|
||||
}
|
||||
|
||||
@media (min-width: 1600px) {
|
||||
.hero.hero-split {
|
||||
grid-template-columns: minmax(520px, 0.85fr) minmax(620px, 1.15fr);
|
||||
}
|
||||
|
||||
.hero-panel {
|
||||
width: min(100%, 820px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.hero.hero-split {
|
||||
min-height: auto;
|
||||
grid-template-columns: 1fr;
|
||||
padding-top: 64px;
|
||||
padding-bottom: 64px;
|
||||
}
|
||||
|
||||
.hero-panel {
|
||||
width: min(100%, 760px);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.hero-radio-fx {
|
||||
opacity: 0.50;
|
||||
}
|
||||
|
||||
.hero-fx-vignette {
|
||||
background: linear-gradient(180deg, rgba(7,11,9,0.84), rgba(7,11,9,0.34));
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hero-radio-fx {
|
||||
opacity: 0.28;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const canvas = document.querySelector("[data-hero-radio-fx]");
|
||||
if (!(canvas instanceof HTMLCanvasElement)) return;
|
||||
|
||||
const hero = canvas.closest(".hero");
|
||||
const ctx = canvas.getContext("2d", { alpha: true, desynchronized: true });
|
||||
if (!(hero instanceof HTMLElement) || !ctx) return;
|
||||
|
||||
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const coarsePointer = matchMedia("(pointer: coarse)");
|
||||
|
||||
const state = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
dpr: 1,
|
||||
running: false,
|
||||
visible: true,
|
||||
lastFrame: 0,
|
||||
spawnTime: 0,
|
||||
beamTime: 0,
|
||||
frameId: 0,
|
||||
pointer: { x: 0, y: 0, active: false },
|
||||
planes: [],
|
||||
beams: [],
|
||||
reflections: []
|
||||
};
|
||||
|
||||
const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
|
||||
const random = (min, max) => min + Math.random() * (max - min);
|
||||
|
||||
function css(name, fallback) {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
|
||||
}
|
||||
|
||||
function palette() {
|
||||
return {
|
||||
plane: css("--fx-plane", "#79ff73"),
|
||||
planeMuted: css("--fx-plane-muted", "rgba(121,255,115,.42)"),
|
||||
beam: css("--fx-beam", "#52ff65"),
|
||||
beamGlow: css("--fx-beam-glow", "rgba(82,255,101,.28)"),
|
||||
reflection: css("--fx-reflection", "#d2ffc8"),
|
||||
trail: css("--fx-trail", "rgba(121,255,115,.17)"),
|
||||
grid: css("--fx-grid", "rgba(121,255,115,.055)")
|
||||
};
|
||||
}
|
||||
|
||||
function roundedRect(x, y, width, height, radius) {
|
||||
const r = Math.min(radius, width / 2, height / 2);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.arcTo(x + width, y, x + width, y + height, r);
|
||||
ctx.arcTo(x + width, y + height, x, y + height, r);
|
||||
ctx.arcTo(x, y + height, x, y, r);
|
||||
ctx.arcTo(x, y, x + width, y, r);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
function resize() {
|
||||
const rect = hero.getBoundingClientRect();
|
||||
state.width = Math.max(1, Math.round(rect.width));
|
||||
state.height = Math.max(1, Math.round(rect.height));
|
||||
state.dpr = Math.min(devicePixelRatio || 1, 1.5);
|
||||
canvas.width = Math.round(state.width * state.dpr);
|
||||
canvas.height = Math.round(state.height * state.dpr);
|
||||
canvas.style.width = `${state.width}px`;
|
||||
canvas.style.height = `${state.height}px`;
|
||||
ctx.setTransform(state.dpr, 0, 0, state.dpr, 0, 0);
|
||||
|
||||
if (!state.pointer.active) {
|
||||
state.pointer.x = state.width * 0.55;
|
||||
state.pointer.y = state.height * 0.42;
|
||||
}
|
||||
}
|
||||
|
||||
function stations() {
|
||||
const targetX = state.width * 0.54;
|
||||
const targetY = state.height * 0.43;
|
||||
const left = { x: state.width * 0.075, y: state.height * 0.82, label: "JO51IJ" };
|
||||
const right = { x: state.width * 0.925, y: state.height * 0.80, label: "JN49FL" };
|
||||
left.angle = Math.atan2(targetY - left.y, targetX - left.x);
|
||||
right.angle = Math.atan2(targetY - right.y, targetX - right.x);
|
||||
return [left, right];
|
||||
}
|
||||
|
||||
function planeLimit() {
|
||||
if (coarsePointer.matches) return 5;
|
||||
return state.width < 900 ? 7 : 12;
|
||||
}
|
||||
|
||||
function createPlane() {
|
||||
const margin = 55;
|
||||
const edge = Math.floor(Math.random() * 4);
|
||||
let x;
|
||||
let y;
|
||||
|
||||
if (edge === 0) {
|
||||
x = random(0, state.width);
|
||||
y = -margin;
|
||||
} else if (edge === 1) {
|
||||
x = state.width + margin;
|
||||
y = random(0, state.height);
|
||||
} else if (edge === 2) {
|
||||
x = random(0, state.width);
|
||||
y = state.height + margin;
|
||||
} else {
|
||||
x = -margin;
|
||||
y = random(0, state.height);
|
||||
}
|
||||
|
||||
const tx = state.pointer.active ? state.pointer.x : state.width * random(0.38, 0.72);
|
||||
const ty = state.pointer.active ? state.pointer.y : state.height * random(0.20, 0.72);
|
||||
const angle = Math.atan2(ty - y, tx - x);
|
||||
const speed = random(18, 34);
|
||||
|
||||
return {
|
||||
x, y, angle,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
size: random(5.5, 9),
|
||||
age: 0,
|
||||
maxAge: random(12, 20),
|
||||
steering: random(0.65, 1.2),
|
||||
wobble: random(0, Math.PI * 2),
|
||||
wobbleSpeed: random(0.7, 1.4),
|
||||
hit: 0
|
||||
};
|
||||
}
|
||||
|
||||
function updatePlane(plane, dt) {
|
||||
plane.age += dt;
|
||||
plane.wobble += dt * plane.wobbleSpeed;
|
||||
|
||||
const tx = state.pointer.active ? state.pointer.x : state.width * 0.56;
|
||||
const ty = state.pointer.active ? state.pointer.y : state.height * 0.44;
|
||||
const desired = Math.atan2(ty - plane.y, tx - plane.x);
|
||||
let diff = desired - plane.angle;
|
||||
while (diff > Math.PI) diff -= Math.PI * 2;
|
||||
while (diff < -Math.PI) diff += Math.PI * 2;
|
||||
plane.angle += diff * plane.steering * dt;
|
||||
|
||||
const speed = Math.hypot(plane.vx, plane.vy);
|
||||
const wobble = Math.sin(plane.wobble) * 0.14;
|
||||
plane.vx = Math.cos(plane.angle + wobble) * speed;
|
||||
plane.vy = Math.sin(plane.angle + wobble) * speed;
|
||||
plane.x += plane.vx * dt;
|
||||
plane.y += plane.vy * dt;
|
||||
plane.hit = Math.max(0, plane.hit - dt * 1.8);
|
||||
}
|
||||
|
||||
function expired(plane) {
|
||||
const margin = 180;
|
||||
return plane.age > plane.maxAge || plane.x < -margin || plane.x > state.width + margin || plane.y < -margin || plane.y > state.height + margin;
|
||||
}
|
||||
|
||||
function beamTarget() {
|
||||
if (!state.planes.length) return null;
|
||||
const tx = state.pointer.active ? state.pointer.x : state.width * 0.56;
|
||||
const ty = state.pointer.active ? state.pointer.y : state.height * 0.44;
|
||||
const nearby = state.planes
|
||||
.map(plane => ({ plane, distance: Math.hypot(plane.x - tx, plane.y - ty) }))
|
||||
.filter(item => item.distance < 280)
|
||||
.sort((a, b) => a.distance - b.distance);
|
||||
return nearby[0]?.plane || state.planes[Math.floor(Math.random() * state.planes.length)];
|
||||
}
|
||||
|
||||
function createBeam(target) {
|
||||
const source = stations()
|
||||
.map(station => ({ station, distance: Math.hypot(target.x - station.x, target.y - station.y) }))
|
||||
.sort((a, b) => a.distance - b.distance)[0].station;
|
||||
|
||||
const angle = Math.atan2(target.y - source.y, target.x - source.x);
|
||||
const tip = 45;
|
||||
state.beams.push({
|
||||
startX: source.x + Math.cos(angle) * tip,
|
||||
startY: source.y + Math.sin(angle) * tip,
|
||||
endX: target.x,
|
||||
endY: target.y,
|
||||
age: 0,
|
||||
maxAge: 0.72
|
||||
});
|
||||
|
||||
target.hit = 1;
|
||||
const values = [50, 75, 100];
|
||||
state.reflections.push({
|
||||
x: target.x,
|
||||
y: target.y,
|
||||
age: 0,
|
||||
maxAge: 1.25,
|
||||
radius: random(8, 13),
|
||||
probability: values[Math.floor(Math.random() * values.length)]
|
||||
});
|
||||
}
|
||||
|
||||
function updateEffects(dt) {
|
||||
state.beams.forEach(beam => beam.age += dt);
|
||||
state.beams = state.beams.filter(beam => beam.age < beam.maxAge);
|
||||
state.reflections.forEach(r => {
|
||||
r.age += dt;
|
||||
r.radius += dt * 30;
|
||||
});
|
||||
state.reflections = state.reflections.filter(r => r.age < r.maxAge);
|
||||
}
|
||||
|
||||
function drawGrid(p) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = p.grid;
|
||||
ctx.lineWidth = 1;
|
||||
const spacing = 54;
|
||||
for (let x = spacing; x < state.width; x += spacing) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, state.height);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let y = spacing; y < state.height; y += spacing) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(state.width, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawRadar(p) {
|
||||
const x = state.pointer.active ? state.pointer.x : state.width * 0.56;
|
||||
const y = state.pointer.active ? state.pointer.y : state.height * 0.44;
|
||||
ctx.save();
|
||||
ctx.strokeStyle = p.planeMuted;
|
||||
ctx.lineWidth = 1;
|
||||
for (const radius of [24, 48, 82]) {
|
||||
ctx.globalAlpha = 0.24 - radius / 500;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.globalAlpha = 0.2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x - 100, y);
|
||||
ctx.lineTo(x + 100, y);
|
||||
ctx.moveTo(x, y - 100);
|
||||
ctx.lineTo(x, y + 100);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawYagi(station, p) {
|
||||
ctx.save();
|
||||
ctx.translate(station.x, station.y);
|
||||
ctx.rotate(station.angle);
|
||||
ctx.strokeStyle = p.plane;
|
||||
ctx.fillStyle = p.plane;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.lineCap = "round";
|
||||
ctx.shadowColor = p.beamGlow;
|
||||
ctx.shadowBlur = 9;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-30, 26);
|
||||
ctx.lineTo(-30, -5);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-32, 0);
|
||||
ctx.lineTo(43, 0);
|
||||
ctx.stroke();
|
||||
|
||||
for (const element of [
|
||||
{ x: -27, length: 34 },
|
||||
{ x: -12, length: 28 },
|
||||
{ x: 2, length: 22 },
|
||||
{ x: 14, length: 19 },
|
||||
{ x: 25, length: 16 },
|
||||
{ x: 35, length: 13 }
|
||||
]) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(element.x, -element.length / 2);
|
||||
ctx.lineTo(element.x, element.length / 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(-12, 0, 3.2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
ctx.save();
|
||||
ctx.font = "700 11px Inter, system-ui, sans-serif";
|
||||
const width = ctx.measureText(station.label).width + 14;
|
||||
const x = station.x - width / 2;
|
||||
const y = station.y + 32;
|
||||
ctx.fillStyle = "rgba(6,18,10,.86)";
|
||||
ctx.strokeStyle = p.planeMuted;
|
||||
roundedRect(x, y, width, 23, 6);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = p.plane;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(station.label, station.x, y + 11.5);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawPlane(plane, p) {
|
||||
ctx.save();
|
||||
ctx.translate(plane.x, plane.y);
|
||||
ctx.rotate(plane.angle);
|
||||
ctx.globalAlpha = clamp(Math.min(plane.age * 1.8, (plane.maxAge - plane.age) * 1.8), 0, 1);
|
||||
ctx.strokeStyle = p.trail;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-plane.size * 4.5, 0);
|
||||
ctx.lineTo(-plane.size * 1.4, 0);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = plane.hit > 0 ? p.reflection : p.plane;
|
||||
ctx.shadowColor = plane.hit > 0 ? p.reflection : p.beamGlow;
|
||||
ctx.shadowBlur = plane.hit > 0 ? 16 : 5;
|
||||
const s = plane.size;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(s * 1.65, 0);
|
||||
ctx.lineTo(-s * 0.7, s * 0.34);
|
||||
ctx.lineTo(-s * 0.15, s * 1.05);
|
||||
ctx.lineTo(-s * 0.65, s * 1.08);
|
||||
ctx.lineTo(-s * 1.35, s * 0.3);
|
||||
ctx.lineTo(-s * 1.7, s * 0.28);
|
||||
ctx.lineTo(-s * 1.12, 0);
|
||||
ctx.lineTo(-s * 1.7, -s * 0.28);
|
||||
ctx.lineTo(-s * 1.35, -s * 0.3);
|
||||
ctx.lineTo(-s * 0.65, -s * 1.08);
|
||||
ctx.lineTo(-s * 0.15, -s * 1.05);
|
||||
ctx.lineTo(-s * 0.7, -s * 0.34);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawBeam(beam, p) {
|
||||
const progress = beam.age / beam.maxAge;
|
||||
const alpha = Math.sin(progress * Math.PI);
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.strokeStyle = p.beamGlow;
|
||||
ctx.lineWidth = 8;
|
||||
ctx.shadowColor = p.beam;
|
||||
ctx.shadowBlur = 15;
|
||||
ctx.setLineDash([10, 6]);
|
||||
ctx.lineDashOffset = -progress * 36;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(beam.startX, beam.startY);
|
||||
ctx.lineTo(beam.endX, beam.endY);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
ctx.strokeStyle = p.beam;
|
||||
ctx.lineWidth = 1.8;
|
||||
ctx.shadowBlur = 5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(beam.startX, beam.startY);
|
||||
ctx.lineTo(beam.endX, beam.endY);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawReflection(r, p) {
|
||||
const progress = r.age / r.maxAge;
|
||||
const alpha = 1 - progress;
|
||||
ctx.save();
|
||||
ctx.translate(r.x, r.y);
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.strokeStyle = p.reflection;
|
||||
ctx.lineWidth = 1.6;
|
||||
ctx.shadowColor = p.beam;
|
||||
ctx.shadowBlur = 18;
|
||||
for (const scale of [1, 1.65]) {
|
||||
ctx.globalAlpha = alpha * (scale === 1 ? 1 : 0.55);
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, r.radius * scale, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.globalAlpha = alpha * 0.8;
|
||||
const cross = r.radius * 1.25;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-cross, 0);
|
||||
ctx.lineTo(cross, 0);
|
||||
ctx.moveTo(0, -cross);
|
||||
ctx.lineTo(0, cross);
|
||||
ctx.stroke();
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.fillStyle = p.reflection;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, 3.8, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
ctx.save();
|
||||
const label = `${r.probability}%`;
|
||||
ctx.font = "800 12px Inter, system-ui, sans-serif";
|
||||
const width = ctx.measureText(label).width + 16;
|
||||
const height = 25;
|
||||
const x = r.x + r.radius + 12;
|
||||
const y = r.y - r.radius - 17;
|
||||
ctx.globalAlpha = clamp(alpha * 1.25, 0, 1);
|
||||
ctx.fillStyle = "rgba(6,18,10,.91)";
|
||||
ctx.strokeStyle = p.plane;
|
||||
ctx.shadowColor = p.beamGlow;
|
||||
ctx.shadowBlur = 10;
|
||||
roundedRect(x, y, width, height, 6);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.fillStyle = p.reflection;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(label, x + width / 2, y + height / 2);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function draw(staticOnly = false) {
|
||||
const p = palette();
|
||||
ctx.clearRect(0, 0, state.width, state.height);
|
||||
drawGrid(p);
|
||||
drawRadar(p);
|
||||
stations().forEach(station => drawYagi(station, p));
|
||||
if (staticOnly) return;
|
||||
state.planes.forEach(plane => drawPlane(plane, p));
|
||||
state.beams.forEach(beam => drawBeam(beam, p));
|
||||
state.reflections.forEach(r => drawReflection(r, p));
|
||||
}
|
||||
|
||||
function animate(timestamp) {
|
||||
if (!state.running) return;
|
||||
const dt = clamp((timestamp - state.lastFrame) / 1000 || 0, 0, 0.05);
|
||||
state.lastFrame = timestamp;
|
||||
|
||||
if (!state.visible || document.hidden) {
|
||||
state.frameId = requestAnimationFrame(animate);
|
||||
return;
|
||||
}
|
||||
|
||||
state.spawnTime += dt;
|
||||
state.beamTime += dt;
|
||||
const spawnInterval = coarsePointer.matches ? 2.2 : 1.15;
|
||||
const beamInterval = coarsePointer.matches ? 3.3 : 1.8;
|
||||
|
||||
if (state.spawnTime >= spawnInterval && state.planes.length < planeLimit()) {
|
||||
state.spawnTime = 0;
|
||||
state.planes.push(createPlane());
|
||||
}
|
||||
|
||||
if (state.beamTime >= beamInterval && state.planes.length) {
|
||||
state.beamTime = 0;
|
||||
const target = beamTarget();
|
||||
if (target) createBeam(target);
|
||||
}
|
||||
|
||||
state.planes.forEach(plane => updatePlane(plane, dt));
|
||||
state.planes = state.planes.filter(plane => !expired(plane));
|
||||
updateEffects(dt);
|
||||
draw(false);
|
||||
state.frameId = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
function start() {
|
||||
cancelAnimationFrame(state.frameId);
|
||||
resize();
|
||||
if (reducedMotion.matches) {
|
||||
state.running = false;
|
||||
draw(true);
|
||||
return;
|
||||
}
|
||||
|
||||
state.running = true;
|
||||
state.lastFrame = performance.now();
|
||||
if (!state.planes.length) {
|
||||
const count = coarsePointer.matches ? 2 : 5;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const plane = createPlane();
|
||||
plane.age = Math.random() * 2;
|
||||
state.planes.push(plane);
|
||||
}
|
||||
}
|
||||
state.frameId = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
hero.addEventListener("pointermove", event => {
|
||||
const rect = hero.getBoundingClientRect();
|
||||
state.pointer.x = clamp(event.clientX - rect.left, 0, rect.width);
|
||||
state.pointer.y = clamp(event.clientY - rect.top, 0, rect.height);
|
||||
state.pointer.active = true;
|
||||
}, { passive: true });
|
||||
|
||||
hero.addEventListener("pointerleave", () => state.pointer.active = false);
|
||||
addEventListener("resize", resize, { passive: true });
|
||||
document.addEventListener("visibilitychange", () => state.lastFrame = performance.now());
|
||||
reducedMotion.addEventListener("change", start);
|
||||
|
||||
new ResizeObserver(() => {
|
||||
resize();
|
||||
if (reducedMotion.matches) draw(true);
|
||||
}).observe(hero);
|
||||
|
||||
new IntersectionObserver(entries => {
|
||||
state.visible = entries[0]?.isIntersecting ?? true;
|
||||
state.lastFrame = performance.now();
|
||||
}, { threshold: 0.05 }).observe(hero);
|
||||
|
||||
start();
|
||||
})();
|
||||
|
After Width: | Height: | Size: 2.3 MiB |
@@ -0,0 +1,33 @@
|
||||
---
|
||||
layout: base.njk
|
||||
title: Contact | KST4Contest
|
||||
description: Contact options for KST4Contest.
|
||||
---
|
||||
|
||||
<section class="hero">
|
||||
<p class="badge">Contact</p>
|
||||
<h1>Contact</h1>
|
||||
<p class="lead">Get in touch, report issues or contribute to KST4Contest.</p>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="grid">
|
||||
<article class="card">
|
||||
<h3>GitHub Issues</h3>
|
||||
<p>Report bugs, request features or discuss technical problems.</p>
|
||||
<a href="https://github.com/praktimarc/kst4contest/issues">Open issues →</a>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<h3>GitHub Repository</h3>
|
||||
<p>Browse source code, releases and documentation.</p>
|
||||
<a href="https://github.com/praktimarc/kst4contest">Open repository →</a>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<h3>Email</h3>
|
||||
<p>For legal or website-related contact.</p>
|
||||
<a href="https://groups.google.com/g/kst4contest">Join the mailing list →</a>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
layout: base.njk
|
||||
title: Download KST4Contest
|
||||
description: Download the latest stable KST4Contest packages for Windows, Linux and macOS. The required Java runtime is included.
|
||||
---
|
||||
|
||||
<section class="hero">
|
||||
<p class="badge">Stable release</p>
|
||||
<h1>Download KST4Contest</h1>
|
||||
<p class="lead">
|
||||
Choose the package for your operating system. Official builds are published through GitHub Releases,
|
||||
and the required Java runtime is already included.
|
||||
</p>
|
||||
|
||||
<div class="actions">
|
||||
<a class="button" href="https://github.com/praktimarc/kst4contest/releases/latest">Latest release on GitHub</a>
|
||||
<a class="button secondary" href="https://github.com/praktimarc/kst4contest/releases">All releases</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="section-heading">
|
||||
<p class="eyebrow">Packages by platform</p>
|
||||
<h2>Choose the format that fits your system</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
{% for item in downloads %}
|
||||
<article class="card download-card">
|
||||
<div class="feature-icon">{{ item.icon }}</div>
|
||||
|
||||
{% if item.recommended %}
|
||||
<p class="badge">Default choice</p>
|
||||
{% endif %}
|
||||
|
||||
<h3>{{ item.os }}</h3>
|
||||
<p><strong>{{ item.format }}</strong></p>
|
||||
<p>{{ item.note }}</p>
|
||||
|
||||
<a class="button secondary" href="{{ item.url }}">Download →</a>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="cta-panel">
|
||||
<p class="eyebrow">Choosing a package</p>
|
||||
<h2>Which file should I use?</h2>
|
||||
<p>
|
||||
Windows users normally need the x64 ZIP package. On Linux, Flatpak is the practical choice when
|
||||
updates should be managed through the desktop software centre or the Flatpak command line. The
|
||||
AppImage is useful when a portable file is preferred, while DEB, RPM and Arch packages integrate
|
||||
with their respective package managers.
|
||||
</p>
|
||||
<p>
|
||||
macOS packages are provided for Apple Silicon and Intel systems on a best-effort basis. They are
|
||||
not currently notarized by Apple. The installation guide covers the first launch, updates, the
|
||||
Stable, Beta and Nightly channels, and the location of the settings directory.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<a class="button" href="/manual/en/installation/">Read the installation guide</a>
|
||||
<a class="button secondary" href="/faq/">Open the FAQ</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="card content-card">
|
||||
<h2>Development and infrastructure</h2>
|
||||
<p>
|
||||
KST4Contest is developed independently. If it is useful in your contest workflow and you want to
|
||||
help cover continued development, testing and infrastructure costs, the support page explains
|
||||
what project contributions are used for.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<a class="button" href="/support/">Support the project</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
layout: base.njk
|
||||
title: KST4Contest FAQ
|
||||
description: Frequently asked questions about KST4Contest, ON4KST contest operation, AirScout integration and VHF/UHF/SHF contest workflow.
|
||||
---
|
||||
|
||||
<section class="hero">
|
||||
<h1>Frequently asked questions</h1>
|
||||
<p>
|
||||
Answers about KST4Contest, ON4KST contest operation, downloads,
|
||||
AirScout integration and supported platforms.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="card">
|
||||
<h2>What is KST4Contest?</h2>
|
||||
<p>
|
||||
KST4Contest is a contest-optimized ON4KST chat client for VHF, UHF and SHF ham radio contest operation.
|
||||
</p>
|
||||
|
||||
<h2>Is KST4Contest a replacement for wtKST, KST2Me or KSTChat?</h2>
|
||||
<p>
|
||||
KST4Contest follows a different approach. It is focused on contest workflow, priority candidates,
|
||||
sked handling, AirScout support, log synchronization and operator decision support.
|
||||
</p>
|
||||
|
||||
<h2>Which platforms are supported?</h2>
|
||||
<p>
|
||||
KST4Contest provides release artifacts for Windows, Linux and macOS through GitHub Releases.
|
||||
</p>
|
||||
|
||||
<h2>Does KST4Contest support AirScout?</h2>
|
||||
<p>
|
||||
Yes. KST4Contest is designed to integrate AirScout-related workflow information into contest operation.
|
||||
</p>
|
||||
|
||||
<h2>Where can I download KST4Contest?</h2>
|
||||
<p>
|
||||
Official builds are available on the GitHub Releases page.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
"mainEntity": [
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "What is KST4Contest?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "KST4Contest is a contest-optimized ON4KST chat client for VHF, UHF and SHF ham radio contest operation."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Is KST4Contest a replacement for wtKST, KST2Me or KSTChat?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "KST4Contest follows a different approach and focuses on contest workflow, priority candidates, sked handling, AirScout support, log synchronization and operator decision support."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Which platforms are supported?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "KST4Contest provides release artifacts for Windows, Linux and macOS through GitHub Releases."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
title: AirScout Integration
|
||||
icon: ✈️
|
||||
category: Aircraft Scatter
|
||||
since: "1.26"
|
||||
summary: Use AirScout AP predictions in candidate evaluation, the timeline and sked planning.
|
||||
description: KST4Contest receives aircraft scatter information from AirScout and relates AP timing to active ON4KST stations.
|
||||
tagsList:
|
||||
- AirScout
|
||||
- airplane scatter
|
||||
- aircraft scatter
|
||||
- VHF contest
|
||||
related:
|
||||
- timeline
|
||||
- priority-score
|
||||
- sked-reminder
|
||||
---
|
||||
|
||||
## Airplane scatter in the operator workflow
|
||||
|
||||
Aircraft reflections can create short but valuable contact opportunities.
|
||||
|
||||
KST4Contest helps combine ON4KST chat activity with AirScout-based propagation awareness.
|
||||
|
||||
## From candidate to sked decision
|
||||
|
||||
AirScout information can support the decision whether a candidate station should be called immediately, scheduled later or monitored for a better window.
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
title: Dual Chat Categories
|
||||
icon: 💬
|
||||
category: ON4KST Chat
|
||||
since: "1.26"
|
||||
summary: Log in to two ON4KST categories and handle both message streams in one station list and interface.
|
||||
description: KST4Contest combines two ON4KST chat categories while retaining the category required for messages and station context.
|
||||
tagsList:
|
||||
- ON4KST
|
||||
- dual chat
|
||||
- microwave contest
|
||||
related:
|
||||
- priority-score
|
||||
- log-sync
|
||||
---
|
||||
|
||||
## Two categories, one workflow
|
||||
|
||||
KST4Contest can operate with two ON4KST chat categories at once.
|
||||
|
||||
This is useful when contest activity spans VHF/UHF and microwave operation.
|
||||
|
||||
## Less window switching
|
||||
|
||||
The operator can keep more information visible without constantly changing tools.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
title: DXCluster Server
|
||||
icon: 📡
|
||||
category: Radio Workflow
|
||||
since: "1.23"
|
||||
summary: Send direction warnings as DX Cluster spots to compatible logging software when a usable QRG is known.
|
||||
description: The built-in DX Cluster server forwards KST4Contest direction warnings and known frequency information to compatible contest loggers.
|
||||
tagsList:
|
||||
- DXCluster
|
||||
- ham radio
|
||||
- contest
|
||||
related:
|
||||
- log-sync
|
||||
- priority-score
|
||||
---
|
||||
|
||||
|
||||
## More situational awareness
|
||||
|
||||
DXCluster information can support contest operators by adding another source of activity information.
|
||||
|
||||
## Integrated workflow
|
||||
|
||||
The goal is not to open another isolated tool, but to connect activity information with the wider KST4Contest workflow.
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"layout": "feature.njk",
|
||||
"tags": "features"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
layout: base.njk
|
||||
title: KST4Contest Features
|
||||
description: Contest-optimized ON4KST features for VHF, UHF and SHF operation.
|
||||
---
|
||||
|
||||
<section class="hero">
|
||||
<p class="badge">Features</p>
|
||||
<h1>Contest tools, not just chat windows.</h1>
|
||||
<p class="lead">
|
||||
KST4Contest combines ON4KST chat, candidate scoring, AirScout workflow,
|
||||
sked handling, log synchronization and operator decision support.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="grid">
|
||||
{% for feature in collections.features %}
|
||||
<article class="card feature-card">
|
||||
<div class="feature-icon">{{ feature.data.icon }}</div>
|
||||
<p class="eyebrow">{{ feature.data.category }}</p>
|
||||
<h3><a href="{{ feature.url }}">{{ feature.data.title }}</a></h3>
|
||||
<p>{{ feature.data.summary }}</p>
|
||||
<a href="{{ feature.url }}">Learn more →</a>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
title: Log Synchronization
|
||||
icon: 🔄
|
||||
category: Logger Integration
|
||||
since: "1.31"
|
||||
summary: Import worked stations and current frequencies from supported contest loggers so filters and band information follow the log.
|
||||
description: KST4Contest receives worked-station and, where supported, frequency data from UCXLog, N1MM+, QARTest, DXLog.net and Win-Test through file and UDP interfaces.
|
||||
tagsList:
|
||||
- Win-Test
|
||||
- UCXLog
|
||||
- contest logger
|
||||
related:
|
||||
- priority-score
|
||||
- dual-chat
|
||||
---
|
||||
|
||||
## Connect chat and logging
|
||||
|
||||
Contest operation is faster when chat information and log state are connected.
|
||||
|
||||
KST4Contest can use logger information to improve workflow awareness.
|
||||
|
||||
## Worked and band awareness
|
||||
|
||||
Log synchronization helps the client understand which stations and bands are relevant.
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
title: Macros and Variables
|
||||
icon: ⚡
|
||||
category: Operator Speed
|
||||
since: "1.0"
|
||||
summary: Build recurring chat messages from configurable text blocks and variables such as the current QRG or locator.
|
||||
description: KST4Contest macros combine reusable message text with values taken from the current station, frequency and operating context.
|
||||
tagsList:
|
||||
- macros
|
||||
- ON4KST
|
||||
- operator workflow
|
||||
related:
|
||||
- sked-reminder
|
||||
- dual-chat
|
||||
---
|
||||
|
||||
## Faster messages
|
||||
|
||||
Macros reduce repetitive typing during active contest operation.
|
||||
|
||||
## More consistent operation
|
||||
|
||||
Variables help keep messages structured and reduce manual errors.
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
title: Priority Score System
|
||||
icon: 🎯
|
||||
category: Contest Workflow
|
||||
since: "1.40"
|
||||
summary: Rank active stations from known context such as direction, activity, band, QRG, AP and sked information.
|
||||
description: The Priority Score System combines available contest information to populate a separate list of currently relevant candidates.
|
||||
tagsList:
|
||||
- ON4KST
|
||||
- VHF
|
||||
- UHF
|
||||
- SHF
|
||||
- contest
|
||||
- priority candidates
|
||||
related:
|
||||
- timeline
|
||||
- airscout
|
||||
- sked-reminder
|
||||
---
|
||||
|
||||
## Why it matters
|
||||
|
||||
During active VHF, UHF and microwave contests, relevant stations can disappear quickly in busy ON4KST chat traffic.
|
||||
|
||||
The Priority Score System helps operators focus on stations that are more likely to be useful during contest operation.
|
||||
|
||||
## Built for contest decisions
|
||||
|
||||
KST4Contest is not just a chat viewer. It evaluates contest-relevant context and helps turn chat activity into better operating decisions.
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
title: Sked Reminder
|
||||
icon: 🔔
|
||||
category: Sked Management
|
||||
since: "1.40"
|
||||
summary: Store planned contacts and issue configurable chat messages plus local alerts before the agreed time.
|
||||
description: Sked Reminder keeps scheduled contacts visible and provides automatic advance messages together with acoustic and visual operator alerts.
|
||||
tagsList:
|
||||
- sked
|
||||
- ON4KST
|
||||
- contest reminder
|
||||
related:
|
||||
- priority-score
|
||||
- airscout
|
||||
- timeline
|
||||
---
|
||||
|
||||
## Never lose important skeds
|
||||
|
||||
During active contests, it is easy to miss a planned contact while handling chat traffic, logging and band changes.
|
||||
|
||||
Sked Reminder keeps scheduled contacts visible.
|
||||
|
||||
## Built for real contest pressure
|
||||
|
||||
The workflow supports time-critical operation where missing a few minutes can mean missing a QSO.
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
title: Timeline View
|
||||
icon: ⏱️
|
||||
category: Contest Awareness
|
||||
since: "1.40"
|
||||
summary: Show AP-based priority candidates and scheduled contacts on a shared timeline.
|
||||
description: The Timeline View places upcoming aircraft scatter candidates and planned skeds into a common time-based overview.
|
||||
tagsList:
|
||||
- timeline
|
||||
- AP windows
|
||||
- airplane scatter
|
||||
related:
|
||||
- airscout
|
||||
- priority-score
|
||||
---
|
||||
|
||||
## Timing matters
|
||||
|
||||
Many VHF/UHF/SHF opportunities are short-lived.
|
||||
|
||||
The Timeline View helps operators see upcoming timing windows instead of keeping everything in mind manually.
|
||||
|
||||
## Designed for fast awareness
|
||||
|
||||
The timeline supports quick decisions during busy contest operation.
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
permalink: /feed.xml
|
||||
eleventyExcludeFromCollections: true
|
||||
---
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>KST4Contest News</title>
|
||||
<link>https://kst4contest.hamradioonline.de/</link>
|
||||
<description>News and release updates for KST4Contest.</description>
|
||||
<language>en</language>
|
||||
|
||||
{% for post in collections.news | reverse %}
|
||||
<item>
|
||||
<title>{{ post.data.title }}</title>
|
||||
<link>https://kst4contest.hamradioonline.de{{ post.url }}</link>
|
||||
<guid>https://kst4contest.hamradioonline.de{{ post.url }}</guid>
|
||||
<pubDate>{{ post.date | dateToRfc822 }}</pubDate>
|
||||
<description>{{ post.data.summary }}</description>
|
||||
</item>
|
||||
{% endfor %}
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -0,0 +1,168 @@
|
||||
---
|
||||
layout: base.njk
|
||||
title: KST4Contest – ON4KST Contest Client for VHF, UHF and SHF
|
||||
description: KST4Contest combines ON4KST chat, candidate prioritisation, sked planning, AirScout data and logger integration in one desktop client.
|
||||
heroFx: true
|
||||
---
|
||||
|
||||
<section class="hero hero-split">
|
||||
<canvas class="hero-radio-fx" data-hero-radio-fx aria-hidden="true"></canvas>
|
||||
<div class="hero-fx-vignette" aria-hidden="true"></div>
|
||||
|
||||
<div class="hero-copy">
|
||||
<p class="badge">ON4KST contest workflow for 144 MHz and above</p>
|
||||
<h1>KST4Contest</h1>
|
||||
|
||||
<p class="lead">
|
||||
ON4KST shows you the traffic. KST4Contest helps turn it into an operating
|
||||
decision by combining chat, candidate prioritisation, sked planning,
|
||||
AirScout data and logger integration in one desktop client.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The available information is filtered, related to active stations and
|
||||
presented in a contest-oriented workflow. The final decision still
|
||||
belongs to the operator.
|
||||
</p>
|
||||
|
||||
<div class="actions">
|
||||
<a class="button" href="/download/">Download KST4Contest</a>
|
||||
<a class="button secondary" href="/manual/">Read the manual</a>
|
||||
<a class="button ghost" href="https://github.com/praktimarc/kst4contest">View source code</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-panel">
|
||||
<div class="terminal-bar">
|
||||
<span></span><span></span><span></span>
|
||||
<strong>Functions sharing the same station context</strong>
|
||||
</div>
|
||||
|
||||
<div class="mock-grid">
|
||||
{% for feature in collections.sortedFeatures %}
|
||||
{% if loop.index <= 6 %}
|
||||
<div class="mini-card">
|
||||
<strong>{{ feature.data.icon }} {{ feature.data.title }}</strong>
|
||||
<small>{{ feature.data.summary }}</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="section-heading">
|
||||
<p class="eyebrow">The operating problem</p>
|
||||
<h2>The useful station is rarely the only station in the chat</h2>
|
||||
<p>
|
||||
During an active contest, messages, sked requests, frequency information
|
||||
and band changes arrive continuously. The task is to identify which part
|
||||
of that traffic matters now, which candidate should be monitored and
|
||||
which contact is better scheduled for later.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<article class="card">
|
||||
<h3>Observe</h3>
|
||||
<p>
|
||||
Messages, locators, detected frequencies and known band activity are
|
||||
assigned to stations across as many as two ON4KST chat categories.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<h3>Evaluate</h3>
|
||||
<p>
|
||||
Direction, distance, worked status, NOT-QRV information, chat activity,
|
||||
sked context and aircraft scatter data feed filters and candidate scores.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<h3>Act</h3>
|
||||
<p>
|
||||
Candidates can be contacted, scheduled or monitored. Skeds remain
|
||||
visible, while supported loggers and station interfaces receive the
|
||||
information required for the next operating step.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="section-heading">
|
||||
<p class="eyebrow">Functions</p>
|
||||
<h2>One workflow, shared context</h2>
|
||||
<p>
|
||||
The individual functions are not isolated tools. They use the same
|
||||
station, message, band and timing information, so that a change in one
|
||||
part of the workflow can also affect filters, priorities and reminders.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
{% for feature in collections.sortedFeatures %}
|
||||
<article class="card feature-card">
|
||||
<div class="feature-icon">{{ feature.data.icon }}</div>
|
||||
<p class="eyebrow">{{ feature.data.category }}</p>
|
||||
<h3><a href="{{ feature.url }}">{{ feature.data.title }}</a></h3>
|
||||
<p>{{ feature.data.summary }}</p>
|
||||
<a href="{{ feature.url }}">Read how it works →</a>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="cta-panel">
|
||||
<p class="eyebrow">Limits</p>
|
||||
<h2>A priority score is not a propagation forecast</h2>
|
||||
<p>
|
||||
KST4Contest can only evaluate the information it knows. Scores, AP
|
||||
windows, filters and path profiles support the operator's decision; they
|
||||
do not guarantee a contact. If the input data is incomplete or outdated,
|
||||
the result can be incomplete or outdated as well.
|
||||
</p>
|
||||
|
||||
<div class="actions">
|
||||
<a class="button" href="/features/">Explore the functions</a>
|
||||
<a class="button secondary" href="/manual/">Read the technical details</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="cta-panel">
|
||||
<p class="eyebrow">Project status</p>
|
||||
<h2>Stable for operation, Nightly for testing</h2>
|
||||
<p>
|
||||
KST4Contest is open-source software. The current stable release is the
|
||||
normal choice for contest operation. Nightly builds follow ongoing
|
||||
development and are useful when a particular fix or feature needs
|
||||
testing. A few minutes before a contest is not the ideal time to discover
|
||||
what changed.
|
||||
</p>
|
||||
|
||||
<div class="actions">
|
||||
<a class="button" href="/download/">Download the stable release</a>
|
||||
<a class="button secondary" href="/roadmap/">Development status</a>
|
||||
<a class="button ghost" href="/support/">Support development</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
"name": "KST4Contest",
|
||||
"applicationCategory": "CommunicationApplication",
|
||||
"operatingSystem": "Windows, Linux, macOS",
|
||||
"url": "https://kst4contest.hamradioonline.de/",
|
||||
"downloadUrl": "https://github.com/praktimarc/kst4contest/releases/latest",
|
||||
"description": "KST4Contest combines ON4KST chat, candidate prioritisation, sked planning, AirScout data and logger integration for VHF, UHF and SHF contest operation.",
|
||||
"license": "https://github.com/praktimarc/kst4contest/blob/main/LICENSE"
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
layout: base.njk
|
||||
title: Legal Notice | KST4Contest
|
||||
description: Legal notice for the KST4Contest website.
|
||||
---
|
||||
|
||||
<section class="hero">
|
||||
<p class="badge">Legal Notice</p>
|
||||
<h1>Legal Notice</h1>
|
||||
<p class="lead">Information according to § 5 DDG.</p>
|
||||
</section>
|
||||
|
||||
<section class="section narrow">
|
||||
<article class="card content-card">
|
||||
<h2>Provider</h2>
|
||||
<p>
|
||||
Marc Fröhlich<br>
|
||||
Rheingoldstr. 6<br>
|
||||
68199 Mannheim<br>
|
||||
Germany
|
||||
</p>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>
|
||||
Email: <a href="mailto:praktimarc+kst4contest@gmail.com">praktimarc+kst4contest@gmail.com</a>
|
||||
</p>
|
||||
|
||||
<h2>Responsible for content</h2>
|
||||
<p>
|
||||
Marc Fröhlich<br>
|
||||
Rheingoldstr. 6<br>
|
||||
68199 Mannheim<br>
|
||||
Germany
|
||||
</p>
|
||||
|
||||
<h2>Project</h2>
|
||||
<p>
|
||||
KST4Contest is an open-source ON4KST contest client.
|
||||
Source code and releases are available on
|
||||
<a href="https://github.com/praktimarc/kst4contest">GitHub</a>.
|
||||
</p>
|
||||
</article>
|
||||
</section>
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
pagination:
|
||||
data: collections.manualPages
|
||||
size: 1
|
||||
alias: manual
|
||||
permalink: "/manual/{{ manual.lang }}/{{ manual.slug }}/"
|
||||
layout: base.njk
|
||||
title: "{{ manual.title }} | KST4Contest Manual"
|
||||
description: "KST4Contest manual page: {{ manual.title }}"
|
||||
---
|
||||
|
||||
<section class="section">
|
||||
<div class="card manual-content">
|
||||
<p><a href="/manual/{{ manual.lang }}/">← Back to manual overview</a></p>
|
||||
<h1>{{ manual.title }}</h1>
|
||||
{{ manual.content | markdown | safe }}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
layout: base.njk
|
||||
title: KST4Contest Deutsches Handbuch
|
||||
description: Deutsches Benutzerhandbuch für KST4Contest.
|
||||
---
|
||||
|
||||
<section class="hero">
|
||||
<h1>Deutsches Handbuch</h1>
|
||||
<p>Das Handbuch wird zentral in GitHub gepflegt und als Wiki, PDF und Online-Dokumentation veröffentlicht.</p>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="grid">
|
||||
{% for manual in collections.manualPages %}
|
||||
{% if manual.lang == "de" %}
|
||||
<div class="card">
|
||||
<h3><a href="/manual/de/{{ manual.slug }}/">{{ manual.title }}</a></h3>
|
||||
{% if manual.summary %}
|
||||
<p>{{ manual.summary }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
layout: base.njk
|
||||
title: KST4Contest English Manual
|
||||
description: English user manual for KST4Contest.
|
||||
---
|
||||
|
||||
<section class="hero">
|
||||
<h1>English Manual</h1>
|
||||
<p>The manual is maintained centrally in GitHub and published as Wiki, PDF and online documentation.</p>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="grid">
|
||||
{% for manual in collections.manualPages %}
|
||||
{% if manual.lang == "en" %}
|
||||
<div class="card">
|
||||
<h3><a href="/manual/en/{{ manual.slug }}/">{{ manual.title }}</a></h3>
|
||||
{% if manual.summary %}
|
||||
<p>{{ manual.summary }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
layout: base.njk
|
||||
title: KST4Contest Manual
|
||||
description: Online manual for KST4Contest, the contest-optimized ON4KST chat client.
|
||||
---
|
||||
|
||||
<section class="hero">
|
||||
<h1>KST4Contest Manual</h1>
|
||||
<p>
|
||||
User documentation for installation, configuration, features,
|
||||
AirScout integration, log synchronization and contest workflow.
|
||||
</p>
|
||||
|
||||
<div class="actions">
|
||||
<a class="button" href="/manual/en/">English Manual</a>
|
||||
<a class="button secondary" href="/manual/de/">Deutsches Handbuch</a>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: Hotfix Version 1.41.1
|
||||
summary: Hotfix of a bug in Version 1.41
|
||||
date: 2026-07-08
|
||||
---
|
||||
|
||||
## Version 1.41.1
|
||||
We had a bug, where KST4Contest would clear out the text field, where you would send your messages after a period. It also happend to set your Cursor back to the Sending Field, when you were filtering for a station. We fixed this bug today. The new release is [here](https://github.com/praktimarc/kst4contest/releases/tag/v1.41.1). You can also download the artifacts as always via the [Downloads-Page](/download/).
|
||||
|
||||