24 Commits
Author SHA1 Message Date
zach bf5a8b8049 simplify dedupe
C++ / fmt (pull_request) Successful in 3s
JS/TS / fmt (pull_request) Successful in 16s
JS/TS / lint (pull_request) Successful in 19s
Python / fmt (pull_request) Successful in 34s
Python / lint (pull_request) Failing after 31s
Python / test (pull_request) Failing after 59s
Python / typecheck (pull_request) Failing after 1m7s
C++ / build (pull_request) Successful in 1m54s
Rust / fmt (pull_request) Successful in 49s
Rust / build (pull_request) Successful in 1m41s
Rust / clippy (pull_request) Successful in 1m33s
Python / buildcheck (pull_request) Successful in 2m38s
C++ / clang-tidy (pull_request) Successful in 3m31s
2026-07-13 22:10:41 +02:00
zach a9bbb21f54 improve device detection when plugging/unplugging
C++ / fmt (pull_request) Successful in 4s
JS/TS / fmt (pull_request) Successful in 19s
JS/TS / lint (pull_request) Successful in 19s
Python / fmt (pull_request) Successful in 36s
Python / lint (pull_request) Failing after 34s
Python / test (pull_request) Failing after 1m2s
Python / typecheck (pull_request) Failing after 1m11s
C++ / build (pull_request) Successful in 2m11s
Rust / fmt (pull_request) Successful in 52s
Rust / build (pull_request) Successful in 1m45s
Rust / clippy (pull_request) Successful in 1m35s
Python / buildcheck (pull_request) Successful in 2m44s
C++ / clang-tidy (pull_request) Successful in 3m37s
2026-07-13 19:31:40 +02:00
zach bb50cf756d fix: cmake nuitka command again
C++ / fmt (pull_request) Successful in 9s
JS/TS / fmt (pull_request) Successful in 13s
JS/TS / lint (pull_request) Successful in 17s
Python / fmt (pull_request) Successful in 43s
Python / lint (pull_request) Failing after 32s
Python / test (pull_request) Failing after 57s
Python / typecheck (pull_request) Failing after 1m0s
C++ / build (pull_request) Successful in 3m5s
Rust / fmt (pull_request) Successful in 41s
Rust / build (pull_request) Successful in 1m39s
Python / buildcheck (pull_request) Successful in 2m36s
Rust / clippy (pull_request) Successful in 1m21s
C++ / clang-tidy (pull_request) Successful in 4m49s
2026-07-13 15:03:16 +02:00
zach 57147dc7c1 fix: cmake nuitka command
C++ / fmt (pull_request) Successful in 4s
JS/TS / fmt (pull_request) Successful in 15s
JS/TS / lint (pull_request) Successful in 16s
Python / lint (pull_request) Failing after 32s
Python / fmt (pull_request) Successful in 36s
Python / test (pull_request) Failing after 1m3s
C++ / build (pull_request) Successful in 1m39s
Python / typecheck (pull_request) Failing after 1m9s
Rust / fmt (pull_request) Successful in 38s
Rust / build (pull_request) Successful in 1m33s
Python / buildcheck (pull_request) Successful in 2m48s
Rust / clippy (pull_request) Successful in 2m1s
C++ / clang-tidy (pull_request) Successful in 5m51s
2026-07-13 14:59:52 +02:00
zach 620fe001a4 backend now more robust, threaded writes use locks
C++ / fmt (pull_request) Successful in 4s
JS/TS / fmt (pull_request) Successful in 11s
JS/TS / lint (pull_request) Successful in 21s
Python / lint (pull_request) Failing after 31s
Python / fmt (pull_request) Successful in 38s
Python / test (pull_request) Failing after 1m0s
Python / typecheck (pull_request) Failing after 1m1s
C++ / build (pull_request) Successful in 2m0s
Rust / fmt (pull_request) Successful in 44s
Rust / build (pull_request) Successful in 1m40s
Rust / clippy (pull_request) Successful in 1m34s
Python / buildcheck (pull_request) Successful in 2m35s
C++ / clang-tidy (pull_request) Successful in 3m44s
2026-07-13 14:56:57 +02:00
zach 85320e9ea1 prevent writing battery json when there has been no change, filter out devices with unknown state
C++ / fmt (pull_request) Successful in 4s
JS/TS / fmt (pull_request) Successful in 11s
JS/TS / lint (pull_request) Successful in 15s
Python / fmt (pull_request) Successful in 29s
Python / lint (pull_request) Successful in 32s
Python / test (pull_request) Successful in 1m4s
Python / typecheck (pull_request) Failing after 1m10s
C++ / build (pull_request) Successful in 1m35s
Rust / fmt (pull_request) Successful in 36s
Rust / build (pull_request) Successful in 1m34s
Rust / clippy (pull_request) Successful in 1m28s
Python / buildcheck (pull_request) Successful in 2m35s
C++ / clang-tidy (pull_request) Successful in 3m32s
2026-07-13 01:33:55 +02:00
zach 259a4dde7a gitignore
C++ / fmt (pull_request) Successful in 8s
JS/TS / fmt (pull_request) Successful in 12s
JS/TS / lint (pull_request) Successful in 15s
Python / fmt (pull_request) Successful in 26s
Python / lint (pull_request) Successful in 31s
Python / test (pull_request) Successful in 54s
Python / typecheck (pull_request) Failing after 50s
C++ / build (pull_request) Successful in 3m19s
Rust / fmt (pull_request) Successful in 41s
Rust / build (pull_request) Successful in 1m43s
Python / buildcheck (pull_request) Successful in 2m35s
Rust / clippy (pull_request) Successful in 1m27s
C++ / clang-tidy (pull_request) Successful in 4m52s
2026-07-13 01:13:57 +02:00
zach d6230bef34 Fix cmake nuitka build command
C++ / fmt (pull_request) Successful in 5s
JS/TS / fmt (pull_request) Successful in 12s
JS/TS / lint (pull_request) Successful in 20s
Python / fmt (pull_request) Successful in 31s
Python / lint (pull_request) Successful in 33s
Python / test (pull_request) Successful in 1m0s
Python / typecheck (pull_request) Failing after 1m5s
C++ / build (pull_request) Successful in 1m39s
Rust / fmt (pull_request) Successful in 50s
Rust / build (pull_request) Successful in 1m41s
Python / buildcheck (pull_request) Successful in 2m35s
Rust / clippy (pull_request) Successful in 2m5s
C++ / clang-tidy (pull_request) Successful in 6m13s
2026-07-13 01:12:55 +02:00
zach 4ee9bb3f12 add support for all UPower peripherals
C++ / fmt (pull_request) Successful in 3s
JS/TS / fmt (pull_request) Successful in 17s
JS/TS / lint (pull_request) Successful in 16s
Python / fmt (pull_request) Successful in 34s
Python / lint (pull_request) Successful in 35s
Python / typecheck (pull_request) Failing after 1m5s
C++ / build (pull_request) Successful in 1m36s
Python / test (pull_request) Successful in 1m26s
Rust / fmt (pull_request) Successful in 48s
Rust / build (pull_request) Successful in 1m41s
Rust / clippy (pull_request) Successful in 1m37s
Python / buildcheck (pull_request) Successful in 2m41s
C++ / clang-tidy (pull_request) Successful in 3m32s
2026-07-13 00:48:05 +02:00
zach 8789d2d322 initial addition of battery display
JS/TS / fmt (pull_request) Successful in 7s
JS/TS / lint (pull_request) Successful in 9s
Python / fmt (pull_request) Successful in 15s
Python / lint (pull_request) Failing after 19s
Python / typecheck (pull_request) Failing after 1m5s
C++ / fmt (pull_request) Successful in 6s
Python / test (pull_request) Successful in 1m20s
Rust / fmt (pull_request) Successful in 1m42s
Python / buildcheck (pull_request) Successful in 2m23s
Rust / clippy (pull_request) Successful in 2m42s
Rust / build (pull_request) Successful in 2m53s
C++ / build (pull_request) Successful in 2m22s
C++ / clang-tidy (pull_request) Successful in 3m17s
2026-07-12 17:47:25 +02:00
zach 93c6ac251e Merge pull request 'pr and issue templates + typecheck ci python' (#138) from 135-issue-pr-templates into main
Reviewed-on: #138
Reviewed-by: zach <zach@brohn.se>
2026-07-10 18:19:23 +02:00
zach 1af6bf33ff Merge branch 'main' into 135-issue-pr-templates
C++ / fmt (pull_request) Successful in 4s
JS/TS / fmt (pull_request) Successful in 17s
JS/TS / lint (pull_request) Successful in 22s
Python / lint (pull_request) Successful in 31s
Python / fmt (pull_request) Successful in 34s
Python / test (pull_request) Successful in 59s
Python / typecheck (pull_request) Failing after 59s
C++ / build (pull_request) Successful in 2m4s
Rust / fmt (pull_request) Successful in 1m0s
Rust / build (pull_request) Successful in 1m53s
Python / buildcheck (pull_request) Successful in 2m41s
Rust / clippy (pull_request) Successful in 2m31s
C++ / clang-tidy (pull_request) Successful in 6m12s
2026-07-10 15:21:24 +02:00
zach 5eaebacbab remove unrelated directory 2026-07-10 15:00:02 +02:00
AramJonghu 4cb30647a9 hotfix(bugreport): adding optional input field; which init system?
C++ / fmt (pull_request) Successful in 4s
JS/TS / fmt (pull_request) Successful in 14s
JS/TS / lint (pull_request) Successful in 15s
Python / lint (pull_request) Successful in 32s
Python / fmt (pull_request) Successful in 35s
Python / test (pull_request) Successful in 1m8s
Python / typecheck (pull_request) Failing after 1m10s
C++ / build (pull_request) Successful in 1m41s
Rust / build (pull_request) Successful in 1m44s
Rust / fmt (pull_request) Successful in 1m5s
Python / buildcheck (pull_request) Successful in 2m39s
Rust / clippy (pull_request) Successful in 2m3s
C++ / clang-tidy (pull_request) Successful in 3m42s
2026-07-10 12:07:27 +02:00
AramJonghu b3820ce1e0 Merge branch 'main' into 135-issue-pr-templates
C++ / fmt (pull_request) Successful in 5s
JS/TS / fmt (pull_request) Successful in 15s
JS/TS / lint (pull_request) Successful in 17s
Python / fmt (pull_request) Successful in 35s
Python / lint (pull_request) Successful in 32s
Python / typecheck (pull_request) Failing after 55s
Python / test (pull_request) Successful in 1m1s
C++ / build (pull_request) Successful in 1m53s
Rust / fmt (pull_request) Successful in 1m13s
Rust / build (pull_request) Successful in 1m48s
Python / buildcheck (pull_request) Successful in 2m43s
Rust / clippy (pull_request) Successful in 2m9s
C++ / clang-tidy (pull_request) Successful in 3m48s
2026-07-10 11:58:35 +02:00
zach d1e3897d02 fix: logo colors 2026-07-10 11:44:01 +02:00
zach 46e24f8543 add initial logo to about page + assets and readme 2026-07-10 11:32:09 +02:00
AramJonghu 1ff7c36723 Merge branch 'main' into 135-issue-pr-templates
C++ / fmt (pull_request) Successful in 11s
JS/TS / fmt (pull_request) Successful in 17s
JS/TS / lint (pull_request) Successful in 17s
Python / fmt (pull_request) Successful in 30s
Python / lint (pull_request) Successful in 32s
Python / test (pull_request) Successful in 57s
Python / typecheck (pull_request) Failing after 52s
C++ / build (pull_request) Successful in 3m4s
Rust / fmt (pull_request) Successful in 1m1s
Rust / build (pull_request) Successful in 1m49s
Python / buildcheck (pull_request) Successful in 2m34s
Rust / clippy (pull_request) Successful in 1m41s
C++ / clang-tidy (pull_request) Successful in 4m41s
2026-07-10 01:32:39 +02:00
AramJonghu f75fef3f55 chore(template): checklist adjustment
C++ / fmt (pull_request) Successful in 8s
JS/TS / fmt (pull_request) Successful in 14s
JS/TS / lint (pull_request) Successful in 22s
Python / lint (pull_request) Successful in 32s
Python / fmt (pull_request) Successful in 35s
Python / test (pull_request) Successful in 1m4s
Python / typecheck (pull_request) Failing after 1m3s
C++ / build (pull_request) Successful in 1m55s
Rust / build (pull_request) Successful in 1m54s
Rust / fmt (pull_request) Successful in 1m16s
Python / buildcheck (pull_request) Successful in 2m46s
Rust / clippy (pull_request) Successful in 2m40s
C++ / clang-tidy (pull_request) Successful in 5m36s
2026-07-10 01:16:21 +02:00
AramJonghu 246ee67fb3 update(typecheck): added typecheck in workflow and pyproject
C++ / fmt (pull_request) Successful in 5s
JS/TS / fmt (pull_request) Successful in 16s
JS/TS / lint (pull_request) Successful in 17s
Python / fmt (pull_request) Successful in 34s
Python / lint (pull_request) Successful in 31s
Python / test (pull_request) Successful in 59s
Python / typecheck (pull_request) Failing after 53s
C++ / build (pull_request) Successful in 1m56s
Rust / fmt (pull_request) Successful in 1m18s
Rust / build (pull_request) Successful in 1m50s
Rust / clippy (pull_request) Successful in 2m7s
Python / buildcheck (pull_request) Successful in 2m42s
C++ / clang-tidy (pull_request) Successful in 3m49s
2026-07-10 01:09:08 +02:00
AramJonghu 5b5f38a48d feat(templates): Added template yaml files 2026-07-10 01:09:01 +02:00
zach 1266166676 fix: text input field bounds 2026-07-09 14:20:06 +02:00
AramJonghu 8d4b910e23 Merge branch 'main' into 135-issue-pr-templates
C++ / fmt (pull_request) Successful in 9s
JS/TS / fmt (pull_request) Successful in 16s
JS/TS / lint (pull_request) Successful in 20s
Python / fmt (pull_request) Successful in 31s
Python / lint (pull_request) Successful in 29s
Python / test (pull_request) Successful in 1m0s
C++ / build (pull_request) Successful in 3m25s
Rust / fmt (pull_request) Successful in 1m12s
Python / buildcheck (pull_request) Successful in 2m51s
Rust / build (pull_request) Successful in 2m14s
Rust / clippy (pull_request) Successful in 1m52s
C++ / clang-tidy (pull_request) Successful in 6m7s
2026-07-08 22:14:09 +02:00
AramJonghu 5e78b3b492 init(new branch): New branch
Lint & Format (JS/TS) / lint-format (pull_request) Successful in 13s
Python / lint-format (pull_request) Successful in 23s
Python / test (pull_request) Successful in 1m3s
Lint & Format (Rust) / lint-format (pull_request) Successful in 2m36s
C++ / build (pull_request) Successful in 6m21s
2026-07-04 04:54:28 +02:00
20 changed files with 1425 additions and 438 deletions
+108
View File
@@ -0,0 +1,108 @@
name: Bug Report
about: Something broke
title: "bug: "
labels:
- bug
body:
- type: textarea
id: description
attributes:
label: What happened
placeholder: Describe the bug
validations:
required: true
- type: textarea
id: expected
attributes:
label: What should happen
placeholder: Expected behavior
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
placeholder: |
1.
2.
3.
render: shell
validations:
required: true
- type: dropdown
id: os
attributes:
label: OS
options:
- NixOS
- Arch
- Debian / Ubuntu
- Fedora
- Alpine
- Artix
- Gentoo
- Other
validations:
required: false
- type: input
id: other-os
attributes:
label: Other OS (if "Other" above)
placeholder: e.g. Void, Gentoo, FreeBSD
validations:
required: false
- type: dropdown
id: init
attributes:
label: Init system
description: Relevant for UWSM configuration
options:
- systemd
- non-systemd (openrc / runit / s6 / ...)
validations:
required: false
- type: input
id: other-init
attributes:
label: Other init system (if "non-systemd" above)
placeholder: e.g. openrc, runit, s6, dinit
validations:
required: false
- type: input
id: qt-version
attributes:
label: Qt version
placeholder: 6.11.1
validations:
required: false
- type: input
id: commit
attributes:
label: Branch / commit
placeholder: main / abc1234
validations:
required: false
- type: textarea
id: logs
attributes:
label: Logs / backtrace / screenshots
render: shell
validations:
required: false
- type: textarea
id: workaround
attributes:
label: Found a workaround?
validations:
required: false
+51
View File
@@ -0,0 +1,51 @@
name: Feature Request
about: Idea or improvement
title: "feat: "
labels:
- enhancement
body:
- type: textarea
id: problem
attributes:
label: What's missing / annoying
placeholder: The use case or pain point
validations:
required: true
- type: textarea
id: solution
attributes:
label: How should it work
placeholder: Describe the desired behavior
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Other approaches considered
validations:
required: false
- type: dropdown
id: scope
attributes:
label: Area
options:
- Core / Daemon
- UI / Components
- Plugin system
- CMake / Build
- CI / Tooling
- Documentation
- Other
validations:
required: false
- type: textarea
id: context
attributes:
label: Mockups / references / notes
validations:
required: false
+59
View File
@@ -0,0 +1,59 @@
name: Pull Request
about: Submit changes
title: ""
labels: []
body:
- type: textarea
id: description
attributes:
label: What & why
placeholder: Describe the change and motivation
validations:
required: true
- type: input
id: issue
attributes:
label: Related issue
placeholder: "#123"
validations:
required: false
- type: dropdown
id: type
attributes:
label: Type
options:
- Bug fix
- New feature
- Refactor / style
- CI / build / tooling
- Documentation
validations:
required: true
- type: checkboxes
id: tested
attributes:
label: Tested
options:
- label: Build succeeds
- label: Existing tests pass
- label: Manually verified
- type: textarea
id: breaking
attributes:
label: Breaking changes?
placeholder: API/ABI breakage? Migration path?
- type: checkboxes
id: checklist
attributes:
label: Checklist
options:
- label: CI passes (format, lint, typecheck)
- label: Clean commits
- label: No new warnings
- label: Docs updated if needed
+23
View File
@@ -81,6 +81,29 @@ jobs:
cd cli
python -m pytest tests/ -v
typecheck:
runs-on: alpine
container: node:26-alpine
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install tools
run: |
apk add --no-cache \
git \
python3 \
py3-pip
python3 -m venv .venv
. .venv/bin/activate
pip install --no-cache-dir basedpyright
- name: Type check
run: |
. .venv/bin/activate
basedpyright
buildcheck:
runs-on: alpine
container: node:26-alpine
+3
View File
@@ -15,3 +15,6 @@ dist/
**/target/
**/test-plugins/
**/Charts/
network-dev/
**/zshell.build/
**/zshell.dist/
+2
View File
@@ -93,6 +93,8 @@ if("shell" IN_LIST ENABLE_MODULES)
${NUITKA_EXECUTABLE}
--standalone
--include-data-dir=${CMAKE_SOURCE_DIR}/cli/src/zshell/assets=zshell/assets
--include-package=gi.overrides
--include-package-data=solaar
--output-dir=${ZSHELL_CLI_BUILD_DIR}
--output-filename=zshell-cli
${CMAKE_SOURCE_DIR}/cli/src/zshell/
+35 -30
View File
@@ -21,8 +21,6 @@ TextFieldBase {
property bool isError
property string leadingIcon
readonly property int leadingOffset: leadingIcon ? leadingIconLoader.width + leadingIconLoader.anchors.leftMargin : 0
readonly property real outlineGap: placeholder.width * root.smallFontScale + Appearance.spacing.extraSmall * 2
property real outlineGapScale: activeFocus || text ? 1 : 0
property int radius: Appearance.rounding.small
readonly property real smallFontScale: smallFontSize / font.pointSize
property int smallFontSize: Appearance.font.size.small
@@ -57,11 +55,6 @@ TextFieldBase {
onClicked: root.focus = true
}
}
Behavior on outlineGapScale {
Anim {
type: Anim.DefaultEffects
}
}
onEditingFinished: {
if (!valid)
@@ -103,7 +96,7 @@ TextFieldBase {
when: root.type === CustomTextField.Outlined && (root.activeFocus || root.text)
PropertyChanges {
placeholder.anchors.leftMargin: -(1 - root.smallFontScale) * placeholder.width / 2 + root.horizontalPadding + -Appearance.spacing.extraSmall
placeholder.anchors.leftMargin: -(1 - root.smallFontScale) * placeholder.width / 2 + root.horizontalPadding
placeholder.scale: root.smallFontScale
}
@@ -136,7 +129,7 @@ TextFieldBase {
AnchorAnim {
duration: Appearance.anim.durations.expressiveEffects
easing: Appearance.anim.curves.expressiveDefaultEffects
easing.bezierCurve: Appearance.anim.curves.expressiveEffects
}
}
}
@@ -146,12 +139,11 @@ TextFieldBase {
active: root.leadingIcon
anchors.left: parent.left
anchors.leftMargin: Appearance.padding.larger
anchors.leftMargin: Appearance.padding.normal
anchors.verticalCenter: parent.verticalCenter
sourceComponent: MaterialIcon {
color: DynamicColors.palette.m3onSurfaceVariant
font.pointSize: Appearance.font.size.large
text: root.leadingIcon
}
}
@@ -161,12 +153,11 @@ TextFieldBase {
active: root.trailingIcon
anchors.right: parent.right
anchors.rightMargin: Appearance.padding.larger
anchors.rightMargin: Appearance.padding.normal
anchors.verticalCenter: parent.verticalCenter
sourceComponent: MaterialIcon {
color: DynamicColors.palette.m3onSurfaceVariant
font.pointSize: Appearance.font.size.large
text: root.trailingIcon
}
}
@@ -201,12 +192,24 @@ TextFieldBase {
preferredRendererType: Shape.CurveRenderer
ShapePath {
id: path
readonly property real inset: strokeWidth / 2
readonly property real outlineGap: placeholder.width * root.smallFontScale + Appearance.spacing.small * 2
property real outlineGapScale: root.activeFocus || root.text ? 1 : 0
capStyle: ShapePath.RoundCap
fillColor: "transparent"
startX: root.horizontalPadding - root.clampedRadius + root.outlineGap * (1 - root.outlineGapScale) / 2 + root.outlineGap * root.outlineGapScale
startX: path.inset + root.horizontalPadding - root.clampedRadius + path.outlineGap * (1 - path.outlineGapScale) / 2 + path.outlineGap * path.outlineGapScale
startY: path.inset
strokeColor: root.isError ? DynamicColors.palette.m3error : (root.activeFocus ? DynamicColors.palette.m3primary : DynamicColors.palette.m3outline)
strokeWidth: root.activeFocus ? 2 : 1
Behavior on outlineGapScale {
Anim {
type: Anim.DefaultEffects
}
}
Behavior on strokeColor {
CAnim {
}
@@ -217,54 +220,56 @@ TextFieldBase {
}
PathLine {
x: bg.width - root.clampedRadius
x: bg.width - path.inset - root.clampedRadius
y: path.inset
}
PathArc {
radiusX: root.clampedRadius
radiusY: root.clampedRadius
x: bg.width
y: root.clampedRadius
x: bg.width - path.inset
y: path.inset + root.clampedRadius
}
PathLine {
x: bg.width
y: bg.height - root.clampedRadius
x: bg.width - path.inset
y: bg.height - path.inset - root.clampedRadius
}
PathArc {
radiusX: root.clampedRadius
radiusY: root.clampedRadius
x: bg.width - root.clampedRadius
y: bg.height
x: bg.width - path.inset - root.clampedRadius
y: bg.height - path.inset
}
PathLine {
x: root.clampedRadius
y: bg.height
x: path.inset + root.clampedRadius
y: bg.height - path.inset
}
PathArc {
radiusX: root.clampedRadius
radiusY: root.clampedRadius
x: 0
y: bg.height - root.clampedRadius
x: path.inset
y: bg.height - path.inset - root.clampedRadius
}
PathLine {
x: 0
y: root.clampedRadius
x: path.inset
y: path.inset + root.clampedRadius
}
PathArc {
radiusX: root.clampedRadius
radiusY: root.clampedRadius
x: root.clampedRadius
y: 0
x: path.inset + root.clampedRadius
y: path.inset
}
PathLine {
x: root.horizontalPadding - root.clampedRadius + root.outlineGap * (1 - root.outlineGapScale) / 2
x: path.inset + root.horizontalPadding - root.clampedRadius / 2 + path.outlineGap * (1 - path.outlineGapScale) / 2
y: path.inset
}
}
}
+41 -12
View File
@@ -1,34 +1,63 @@
pragma Singleton
import Quickshell
import Quickshell.Io
import Quickshell.Services.UPower
import qs.Config
import qs.Paths
import qs.Helpers
Singleton {
id: root
readonly property var colors: {
if (deviceState === UPowerDeviceState.Charging || deviceState === UPowerDeviceState.FullyCharged)
readonly property list<var> allPeripherals: [...logiDevices, ...upowerDevices.filter(d => !Battery.logiDevices.some(l => l.nativePath === d.nativePath))]
readonly property real currentPerc: UPower.displayDevice.percentage
readonly property var deviceState: UPower.displayDevice.state
readonly property string deviceStateString: UPowerDeviceState.toString(deviceState)
readonly property bool isLaptop: UPower.displayDevice.isLaptopBattery
readonly property alias logiDevices: adapter.devices
readonly property var lowestPeripheral: allPeripherals.reduce((lowest, current) => current.percentage < lowest.percentage ? current : lowest)
readonly property bool onBattery: UPower.onBattery
readonly property bool ready: UPower.displayDevice.ready
readonly property real timeToEmpty: UPower.displayDevice.timeToEmpty
readonly property real timeToFull: UPower.displayDevice.timeToFull
readonly property list<UPowerDevice> upowerDevices: UPower.devices.values.filter(d => d.state !== UPowerDeviceState.Unknown)
function getColors(percentage: real, state: string): var {
if (state === "charging" || state === "full" || state === "recharging")
return {
fg: DynamicColors.swapRG(DynamicColors.palette.m3error),
bg: DynamicColors.swapRG(DynamicColors.palette.m3onError)
};
else if (currentPerc <= 0.2)
else if (percentage <= 0.2)
return {
fg: DynamicColors.palette.m3error,
bg: DynamicColors.palette.m3onError
};
else
return {
fg: DynamicColors.palette.m3onSurface,
bg: DynamicColors.palette.m3surface
fg: DynamicColors.palette.m3tertiary,
bg: DynamicColors.palette.m3onTertiary
};
}
readonly property real currentPerc: UPower.displayDevice.percentage
readonly property var deviceState: UPower.displayDevice.state
readonly property bool isLaptop: UPower.displayDevice.isLaptopBattery
readonly property bool onBattery: UPower.onBattery
readonly property bool ready: UPower.displayDevice.ready
readonly property real timeToEmpty: UPower.displayDevice.timeToEmpty
readonly property real timeToFull: UPower.displayDevice.timeToFull
function startDaemon(): void {
Quickshell.execDetached(["zshell-cli", "battery", "daemon"]);
}
FileView {
id: fileView
path: `${Paths.cache}/battery.json`
watchChanges: true
onFileChanged: reload()
JsonAdapter {
id: adapter
property list<var> devices: []
property real updated: 0.0
}
}
}
+49
View File
@@ -0,0 +1,49 @@
import QtQuick
import QtQuick.Shapes
import qs.Config
import qs.Components
Item {
id: root
readonly property real designHeight: 80.419373
readonly property real designWidth: 98.057144
implicitHeight: designHeight
implicitWidth: designWidth
Shape {
id: shape
anchors.centerIn: parent
height: root.designHeight
preferredRendererType: Shape.CurveRenderer
scale: Math.min(root.implicitHeight / height, root.implicitWidth / width)
width: root.designWidth
ShapePath {
strokeColor: "transparent"
fillGradient: LinearGradient {
x1: 0
x2: shape.width
y1: 0
y2: shape.height
GradientStop {
color: DynamicColors.palette.m3primary
position: 0.0
}
GradientStop {
color: DynamicColors.palette.m3tertiary
position: 1.0
}
}
PathSvg {
path: "m 51.981821,0.026059 c -1.39501,-0.04986 -2.77607,-0.02697 -4.1305,0.05839 -9.029544,0.569111 -16.873893,3.910515 -19.747638,6.917924 -4.597996,4.811856 -9.516732,6.095236 -9.516732,6.095236 -4.063345,1.817811 -10.372493,1.390096 -10.372493,1.390096 -1.283162,6.736594 -5.560032,10.051544 -7.377844,12.724804 -1.817812,2.67325 -0.106971,4.38371 -0.106971,4.38371 7.805897,9.40985 5.132504,15.93288 5.132504,15.93288 3.421763,5.13265 5.132503,8.87491 5.132503,8.87491 0.855441,17.21575 24.059513,19.14095 24.059513,19.14095 8.019764,4.38413 21.813738,6.09496 33.041398,3.95635 11.22766,-2.1386 22.13426,-8.66133 25.44909,-12.40389 3.31484,-3.74255 4.70506,-4.70528 4.4912,-7.80625 -0.21386,-3.10098 -8.34057,-6.73654 -8.34057,-6.73654 4.70492,-26.73253 -10.5857,-43.627569 -25.12819,-49.829514 -4.09007,-1.744297 -8.40025,-2.549484 -12.58527,-2.69906 z m 0.0522,6.437333 c 2.11073,-0.04742 4.33196,0.144541 6.65179,0.645956 18.55865,4.011316 31.22355,22.883281 24.70082,47.477201 0,0 0.42763,1.06939 6.62957,4.38423 0,0 -6.09519,11.86918 -29.40596,14.97015 0,0 -16.039134,2.13849 -27.908379,-8.55452 0,0 -4.491338,2.24573 -10.372493,0.53485 0,0 6.094988,-3.3149 4.063317,-8.44755 -2.031672,-5.13264 -10.051071,-6.84383 -11.441162,-7.69927 -1.390091,-0.85544 -2.673221,-2.24534 -2.673221,-2.24534 0,0 8.12664,-1.49702 9.516731,-8.23361 1.390092,-6.7366 -2.994298,-11.01376 -4.812109,-12.29692 -1.817812,-1.28317 -3.421899,-2.5665 -4.491199,-4.59816 0,0 6.415801,-1.069 9.623702,-3.31453 0,0 2.566413,-0.42817 2.994133,9.83712 0,0 3.40241,0.98386 2.780192,-10.05106 0,0 9.369134,-12.076644 24.144268,-12.408547 z m 1.44952,11.296987 c -7.539424,-0.42396 -14.807124,3.94504 -19.819474,11.69747 -8.019756,12.40389 -4.597866,33.04123 15.505004,39.45703 20.10286,6.41581 34.21755,-8.76794 34.21755,-8.76794 0,0 -6.20221,-5.34688 -7.5923,-16.14682 -1.39009,-10.79994 -8.87492,-19.99568 -8.87492,-19.99568 -4.29057,-4.00988 -8.91222,-5.98969 -13.43587,-6.24406 z m 0.43874,3.91759 c 5.90958,0.2395 10.57783,4.70548 11.60704,7.352 1.49702,3.84948 2.88726,10.26522 -3.74241,13.58005 1e-5,-1.81781 1.06895,-2.13837 -0.96273,-4.91856 1.81781,-1.17623 2.99392,-3.10096 1.06918,-5.45342 -1.71088,0.53465 -0.85529,1.38993 -3.7424,-0.42788 -2.88711,-1.81781 -0.85514,-3.10083 -3.31453,-5.1325 0,0 -1.06951,-0.42772 -1.92495,0 -0.85544,0.42772 0.96245,2.88717 -2.56625,4.27726 -3.528694,1.39009 -2.673344,-0.21375 -2.994134,-2.45928 0,0 -4.49095,4.27695 -1.06919,10.37197 3.421764,6.09501 9.730474,-0.10662 7.805734,-1.92443 -1.92474,-1.81782 -2.13858,-2.1386 -4.06332,-1.39009 0,0 0.21415,-3.7426 4.91908,-1.49707 4.70492,2.24553 3.84939,13.90048 -5.77432,12.61732 -9.623714,-1.28316 -13.366163,-15.07666 -3.849384,-22.13405 2.973994,-2.20543 5.916404,-2.97018 8.602584,-2.86132 z m -44.322368,1.76475 c 0.177759,-0.002 0.290475,0.19502 0.325562,0.66869 0.21386,2.88712 7.806121,5.98809 7.485331,12.40389 -0.32079,6.41581 -6.309176,5.77433 -6.309176,5.77433 0.962372,-5.34651 -7.591784,-13.36663 -7.591784,-13.36663 2.592219,-0.26816 5.184343,-5.46781 6.090067,-5.48028 z m 60.847968,11.36158 c 0,0 3.10099,17.10864 5.56039,18.81952 0,0 -4.7046,-3.20747 -7.37785,-1.60352 -2.67325,1.60395 -2.67322,5.23955 -2.67322,6.84351 0,0 -3.31509,-4.27761 -7.5923,-2.4598 -4.2772,1.81781 -11.013604,-2.13842 -11.869044,-5.02553 0,0 9.623444,2.45961 14.435294,-3.52847 0,0 8.98208,-0.85568 9.51673,-13.04571 z"
}
}
}
}
+8
View File
@@ -44,6 +44,14 @@ PageBase {
spacing: Appearance.spacing.small
width: parent.width - Appearance.padding.largeIncreased * 2
Logo {
Layout.alignment: Qt.AlignHCenter
Layout.preferredHeight: implicitHeight
Layout.preferredWidth: implicitWidth
implicitHeight: designHeight * 2
implicitWidth: designWidth * 2
}
CustomText {
Layout.alignment: Qt.AlignHCenter
Layout.topMargin: Appearance.spacing.small
+84
View File
@@ -0,0 +1,84 @@
import QtQuick
import qs.Config
import qs.Components
import qs.Helpers
Row {
id: root
property real batHeight: Appearance.padding.smaller * 2
property real batWidth: Appearance.padding.larger * 2
required property string devState
property real nubHeight: Appearance.padding.small
property real nubWidth: 2
required property real percentage
property real radius: Appearance.rounding.smallest / 2
spacing: 1
CustomRect {
id: track
anchors.verticalCenter: parent.verticalCenter
color: Battery.getColors(root.percentage, root.devState).bg
height: root.batHeight
radius: root.radius
width: root.batWidth
CustomText {
color: Battery.getColors(root.percentage, root.devState).fg
font.pointSize: Appearance.font.size.larger / 1.5
font.weight: 800
height: track.height
horizontalAlignment: Text.AlignHCenter
text: Math.round(root.percentage * 100)
verticalAlignment: Text.AlignVCenter
width: track.width
}
Item {
clip: true
width: parent.width * root.percentage
anchors {
bottom: parent.bottom
left: parent.left
top: parent.top
}
CustomRect {
id: fill
color: Battery.getColors(root.percentage, root.devState).fg
height: track.height
radius: track.radius
width: track.width
CustomText {
id: batteryLabel
clip: true
color: Battery.getColors(root.percentage, root.devState).bg
font.pointSize: Appearance.font.size.larger / 1.5
font.weight: 800
height: track.height
horizontalAlignment: Text.AlignHCenter
text: Math.round(root.percentage * 100)
verticalAlignment: Text.AlignVCenter
width: track.width
}
}
}
}
CustomRect {
id: nub
anchors.verticalCenter: parent.verticalCenter
bottomRightRadius: 20
color: root.percentage < 0.99 ? track.color : fill.color
height: root.nubHeight
topRightRadius: 20
width: root.nubWidth
}
}
+27
View File
@@ -1,7 +1,9 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import Quickshell.Services.UPower
import qs.Modules.SysTray.Common
import qs.Config
import qs.Helpers
import qs.Components
@@ -21,6 +23,31 @@ Column {
}
}
Repeater {
model: Battery.allPeripherals
RowLayout {
id: layout
required property var modelData
anchors.left: parent.left
anchors.leftMargin: Appearance.padding.normal
anchors.right: parent.right
anchors.rightMargin: Appearance.padding.normal
CustomText {
Layout.fillWidth: true
text: layout.modelData.model
}
BatteryIcon {
devState: layout.modelData.state
percentage: layout.modelData.percentage
}
}
}
CustomText {
function formatSeconds(s: int, fallback: string): string {
const day = Math.floor(s / 86400);
+19 -76
View File
@@ -1,6 +1,7 @@
import Quickshell.Services.UPower
import QtQuick
import QtQuick.Layouts
import qs.Modules.SysTray.Common
import qs.Components
import qs.Config
import qs.Helpers
@@ -8,12 +9,13 @@ import qs.Helpers
Item {
id: root
readonly property Item currentItem: batteryIconLoader?.item ?? peripheralIconLoader?.item ?? upowerIconLoader.item
readonly property bool shouldBeActive: Config.bar.tray.showPower
Layout.preferredHeight: shouldBeActive ? implicitHeight : 0
Layout.preferredWidth: shouldBeActive ? implicitWidth : 0
implicitHeight: Battery.isLaptop ? batteryIconLoader.item.implicitHeight : upowerIconLoader.item.implicitHeight
implicitWidth: Battery.isLaptop ? batteryIconLoader.item.implicitWidth : upowerIconLoader.item.implicitWidth
implicitHeight: currentItem?.implicitHeight ?? 0
implicitWidth: currentItem?.implicitWidth ?? 0
opacity: shouldBeActive ? 1 : 0
scale: shouldBeActive ? 1 : 0
visible: opacity > 0
@@ -35,95 +37,36 @@ Item {
}
}
Component.onCompleted: Battery.startDaemon()
Loader {
id: batteryIconLoader
active: Battery.isLaptop
anchors.centerIn: parent
sourceComponent: Row {
id: batteryIcon
sourceComponent: BatteryIcon {
devState: Battery.deviceStateString
percentage: Battery.currentPerc
}
}
property real batHeight: 16
property real batWidth: 30
property real nubHeight: 6
property real nubWidth: 2
property real radius: Appearance.rounding.smallest / 2
Loader {
id: peripheralIconLoader
spacing: 1
active: Battery.lowestPeripheral.isPresent && !batteryIconLoader.active
anchors.centerIn: parent
CustomRect {
id: track
anchors.verticalCenter: parent.verticalCenter
color: Battery.colors.bg
height: batteryIcon.batHeight
radius: batteryIcon.radius
width: batteryIcon.batWidth
CustomText {
color: Battery.colors.fg
font.pointSize: Appearance.font.size.larger / 1.5
font.weight: 800
height: track.height
horizontalAlignment: Text.AlignHCenter
text: Math.round(Battery.currentPerc * 100)
verticalAlignment: Text.AlignVCenter
width: track.width
}
Item {
clip: true
width: parent.width * Battery.currentPerc
anchors {
bottom: parent.bottom
left: parent.left
top: parent.top
}
CustomRect {
id: fill
color: Battery.colors.fg
height: track.height
radius: track.radius
width: track.width
CustomText {
id: batteryLabel
clip: true
color: Battery.colors.bg
font.pointSize: 7.5
font.weight: 800
height: track.height
horizontalAlignment: Text.AlignHCenter
text: Math.round(Battery.currentPerc * 100)
verticalAlignment: Text.AlignVCenter
width: track.width
}
}
}
}
CustomRect {
id: nub
anchors.verticalCenter: parent.verticalCenter
bottomRightRadius: 20
color: Battery.currentPerc < 0.99 ? track.color : fill.color
height: batteryIcon.nubHeight
topRightRadius: 20
width: batteryIcon.nubWidth
}
sourceComponent: BatteryIcon {
devState: Battery.lowestPeripheral.state
percentage: Battery.lowestPeripheral.percentage
}
}
Loader {
id: upowerIconLoader
active: !Battery.isLaptop
active: !batteryIconLoader.active && !peripheralIconLoader.active
anchors.centerIn: parent
sourceComponent: MaterialIcon {
+13 -9
View File
@@ -1,5 +1,5 @@
<div align="center">
<img src="/images/shell.png" alt="ZShell screenshot">
<img src="./assets/logo.svg" alt="ZShell logo" width="480">
</div>
# ZShell
@@ -8,6 +8,10 @@ ZShell is a Hyprland-focused desktop shell built with Quickshell + Qt6/QML.
It includes a configurable top bar, launcher, notifications daemon + sidebar, wallpaper and dynamic color pipeline, lock screen, OSD widgets, dashboard, dock, desktop icons, and a separate greetd-compatible greeter.
<div align="center">
<img src="/images/shell.png" alt="ZShell screenshot">
</div>
## Highlights
- Multi-window shell layout driven by `Drawers/Windows.qml` and panel/interactions wrappers
@@ -218,14 +222,14 @@ Action-driven flows (`>` prefix by default) include calculator, wallpaper picker
### `shell` — daemon management
| Command | Description |
|---------|-------------|
| `start` | Start the shell daemon (pass `--no-daemon` to run in foreground) |
| `kill` | Kill the running shell daemon |
| `restart` | Kill then restart the daemon |
| `lock` | Lock the session via IPC |
| `show` | Show the shell window via IPC |
| `log` | Print daemon logs |
| Command | Description |
| --------- | ---------------------------------------------------------------- |
| `start` | Start the shell daemon (pass `--no-daemon` to run in foreground) |
| `kill` | Kill the running shell daemon |
| `restart` | Kill then restart the daemon |
| `lock` | Lock the session via IPC |
| `show` | Show the shell window via IPC |
| `log` | Print daemon logs |
### `scheme` — color scheme generation
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

+68
View File
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="370.60901"
height="370.60901"
viewBox="0 0 98.056968 98.056968"
version="1.1"
id="svg1"
xml:space="preserve"
inkscape:export-filename="logo.svg"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
showborder="false"><inkscape:page
x="0"
y="0"
width="98.056969"
height="98.056969"
id="page2"
margin="0"
bleed="0" /></sodipodi:namedview><defs
id="defs1"><style
id="theme-style">
.grad-stop-1 { stop-color: #9898ff; stop-opacity: 1; }
.grad-stop-2 { stop-color: #ff9ac3; stop-opacity: 1; }
@media (prefers-color-scheme: light) {
.grad-stop-1 { stop-color: #000080; stop-opacity: 1; }
.grad-stop-2 { stop-color: #800037; stop-opacity: 1; }
}
</style><linearGradient
id="linearGradient7"
inkscape:collect="always"><stop
class="grad-stop-1"
offset="0.00635612"
id="stop7" /><stop
class="grad-stop-2"
offset="1"
id="stop8" /></linearGradient><linearGradient
inkscape:collect="always"
xlink:href="#linearGradient7"
id="linearGradient8"
x1="52.200813"
y1="75.440575"
x2="150.25795"
y2="155.85995"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(-8.4478442e-5,8.8187943)" /></defs><g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-52.200813,-75.440577)"><path
id="path2"
style="fill:url(#linearGradient8);stroke-width:0.264583"
d="m 104.18255,84.28543 c -1.39501,-0.04986 -2.77607,-0.02697 -4.1305,0.05839 -9.029544,0.569111 -16.873893,3.910515 -19.747638,6.917924 -4.597996,4.811856 -9.516732,6.095236 -9.516732,6.095236 -4.063345,1.817811 -10.372493,1.390096 -10.372493,1.390096 -1.283162,6.736594 -5.560032,10.051544 -7.377844,12.724804 -1.817812,2.67325 -0.106971,4.38371 -0.106971,4.38371 7.805897,9.40985 5.132504,15.93288 5.132504,15.93288 3.421763,5.13265 5.132503,8.87491 5.132503,8.87491 0.855441,17.21575 24.059513,19.14095 24.059513,19.14095 8.019764,4.38413 21.813738,6.09496 33.041398,3.95635 11.22766,-2.1386 22.13426,-8.66133 25.44909,-12.40389 3.31484,-3.74255 4.70506,-4.70528 4.4912,-7.80625 -0.21386,-3.10098 -8.34057,-6.73654 -8.34057,-6.73654 4.70492,-26.73253 -10.5857,-43.627569 -25.12819,-49.829514 -4.09007,-1.744297 -8.40025,-2.549484 -12.58527,-2.69906 z m 0.0522,6.437333 c 2.11073,-0.04742 4.33196,0.144541 6.65179,0.645956 18.55865,4.011316 31.22355,22.883281 24.70082,47.477201 0,0 0.42763,1.06939 6.62957,4.38423 0,0 -6.09519,11.86918 -29.40596,14.97015 0,0 -16.039134,2.13849 -27.908379,-8.55452 0,0 -4.491338,2.24573 -10.372493,0.53485 0,0 6.094988,-3.3149 4.063317,-8.44755 -2.031672,-5.13264 -10.051071,-6.84383 -11.441162,-7.69927 -1.390091,-0.85544 -2.673221,-2.24534 -2.673221,-2.24534 0,0 8.12664,-1.49702 9.516731,-8.23361 1.390092,-6.7366 -2.994298,-11.01376 -4.812109,-12.29692 -1.817812,-1.28317 -3.421899,-2.5665 -4.491199,-4.59816 0,0 6.415801,-1.069 9.623702,-3.31453 0,0 2.566413,-0.42817 2.994133,9.83712 0,0 3.40241,0.98386 2.780192,-10.05106 0,0 9.369134,-12.076644 24.144268,-12.408547 z m 1.44952,11.296987 c -7.539424,-0.42396 -14.807124,3.94504 -19.819474,11.69747 -8.019756,12.40389 -4.597866,33.04123 15.505004,39.45703 20.10286,6.41581 34.21755,-8.76794 34.21755,-8.76794 0,0 -6.20221,-5.34688 -7.5923,-16.14682 -1.39009,-10.79994 -8.87492,-19.99568 -8.87492,-19.99568 -4.29057,-4.00988 -8.91222,-5.98969 -13.43587,-6.24406 z m 0.43874,3.91759 c 5.90958,0.2395 10.57783,4.70548 11.60704,7.352 1.49702,3.84948 2.88726,10.26522 -3.74241,13.58005 1e-5,-1.81781 1.06895,-2.13837 -0.96273,-4.91856 1.81781,-1.17623 2.99392,-3.10096 1.06918,-5.45342 -1.71088,0.53465 -0.85529,1.38993 -3.7424,-0.42788 -2.88711,-1.81781 -0.85514,-3.10083 -3.31453,-5.1325 0,0 -1.06951,-0.42772 -1.92495,0 -0.85544,0.42772 0.96245,2.88717 -2.56625,4.27726 -3.528694,1.39009 -2.673344,-0.21375 -2.994134,-2.45928 0,0 -4.49095,4.27695 -1.06919,10.37197 3.421764,6.09501 9.730474,-0.10662 7.805734,-1.92443 -1.92474,-1.81782 -2.13858,-2.1386 -4.06332,-1.39009 0,0 0.21415,-3.7426 4.91908,-1.49707 4.70492,2.24553 3.84939,13.90048 -5.77432,12.61732 -9.623714,-1.28316 -13.366163,-15.07666 -3.849384,-22.13405 2.973994,-2.20543 5.916404,-2.97018 8.602584,-2.86132 z m -44.322368,1.76475 c 0.177759,-0.002 0.290475,0.19502 0.325562,0.66869 0.21386,2.88712 7.806121,5.98809 7.485331,12.40389 -0.32079,6.41581 -6.309176,5.77433 -6.309176,5.77433 0.962372,-5.34651 -7.591784,-13.36663 -7.591784,-13.36663 2.592219,-0.26816 5.184343,-5.46781 6.090067,-5.48028 z m 60.847968,11.36158 c 0,0 3.10099,17.10864 5.56039,18.81952 0,0 -4.7046,-3.20747 -7.37785,-1.60352 -2.67325,1.60395 -2.67322,5.23955 -2.67322,6.84351 0,0 -3.31509,-4.27761 -7.5923,-2.4598 -4.2772,1.81781 -11.013604,-2.13842 -11.869044,-5.02553 0,0 9.623444,2.45961 14.435294,-3.52847 0,0 8.98208,-0.85568 9.51673,-13.04571 z" /></g></svg>

After

Width:  |  Height:  |  Size: 5.5 KiB

+9 -1
View File
@@ -8,7 +8,14 @@ import typer
from typer._completion_classes import completion_init
from typer._completion_shared import _get_shell_name, install
from zshell.subcommands import record, scheme, screenshot, shell, wallpaper
from zshell.subcommands import (
battery,
record,
scheme,
screenshot,
shell,
wallpaper,
)
app = typer.Typer(name="zshell-cli", add_completion=False)
@@ -17,6 +24,7 @@ app.add_typer(scheme.app, name="scheme")
app.add_typer(screenshot.app, name="screenshot")
app.add_typer(wallpaper.app, name="wallpaper")
app.add_typer(record.app, name="record")
app.add_typer(battery.app, name="battery")
def _completion_installed() -> bool:
+822
View File
@@ -0,0 +1,822 @@
from __future__ import annotations
import contextlib
import fcntl
import json
import logging
import os
import re
import signal
import struct
import tempfile
import threading
import time
from collections.abc import Callable
from dataclasses import asdict, dataclass
from pathlib import Path
import typer
from logitech_receiver import hidpp10, hidpp20
from logitech_receiver.common import Battery, BatteryStatus, Notification
from logitech_receiver.hidpp10_constants import Registers
from logitech_receiver.hidpp20_constants import SupportedFeature
app = typer.Typer(
help="Read live battery status from Logitech HID++ peripherals."
)
logger = logging.getLogger("zshell.battery")
DEFAULT_OUTPUT = Path.home() / ".cache" / "zshell" / "battery.json"
_RUNTIME_DIR = Path(
os.environ.get("XDG_RUNTIME_DIR") or (Path.home() / ".cache" / "zshell")
)
DEFAULT_LOCK = _RUNTIME_DIR / "zshell-battery-daemon.lock"
def _acquire_singleton_lock(path: Path) -> int | None:
path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(path, os.O_CREAT | os.O_RDWR, 0o644)
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
os.close(fd)
return None
os.ftruncate(fd, 0)
os.write(fd, str(os.getpid()).encode())
return fd
@dataclass
class DeviceBattery:
model: str
nativePath: str | None
serial: str | None
type: str | None
wired: bool
percentage: float | None
state: str | None
charging: bool
isPresent: bool
_SYSFS_POWER_SUPPLY = Path("/sys/class/power_supply")
def _normalize_serial(s: str) -> str:
return re.sub(r"[^0-9A-Za-z]", "", s).upper()
def _find_upower_native_path(serial: str | None) -> str | None:
if not serial or not _SYSFS_POWER_SUPPLY.is_dir():
return None
target = _normalize_serial(serial)
for entry in _SYSFS_POWER_SUPPLY.glob("hidpp_battery_*"):
try:
raw = (entry / "serial_number").read_text().strip()
except OSError:
logger.debug(
"couldn't read serial_number under %s", entry, exc_info=True
)
continue
if _normalize_serial(raw) == target:
return entry.name
return None
def _process_battery_notification(dev, n) -> bool:
if not getattr(dev, "isDevice", False):
return False
if int(n.sub_id) & 0x80:
return False
if n.sub_id == Notification.NO_OPERATION:
return False
if getattr(dev, "protocol", None) is not None and dev.protocol < 2.0:
if n.sub_id in (Registers.BATTERY_STATUS, Registers.BATTERY_CHARGE):
if n.data[-1:] != b"\x00":
return False
data = bytes([n.address]) + n.data
dev.set_battery_info(hidpp10.parse_battery_status(n.sub_id, data))
return True
return False
features = getattr(dev, "features", None)
if not features:
return False
try:
feature = features.get_feature(n.sub_id)
except Exception:
return False
if feature == SupportedFeature.BATTERY_STATUS:
if n.address == 0x00:
dev.set_battery_info(hidpp20.decipher_battery_status(n.data)[1])
return True
return False
if feature == SupportedFeature.BATTERY_VOLTAGE:
if n.address == 0x00:
dev.set_battery_info(hidpp20.decipher_battery_voltage(n.data)[1])
return True
return False
if feature == SupportedFeature.UNIFIED_BATTERY:
if n.address == 0x00:
dev.set_battery_info(hidpp20.decipher_battery_unified(n.data)[1])
return True
return False
if feature == SupportedFeature.ADC_MEASUREMENT:
if n.address == 0x00:
result = hidpp20.decipher_adc_measurement(n.data)
if result:
dev.set_battery_info(result[1])
return True
return False
if feature == SupportedFeature.CENTURION_BATTERY_SOC:
dev.set_battery_info(hidpp20.decipher_battery_centurion(n.data)[1])
return True
if feature == SupportedFeature.SOLAR_DASHBOARD:
if n.data[5:9] == b"GOOD":
charge, lux, adc = struct.unpack("!BHH", n.data[:5])
status = BatteryStatus.DISCHARGING
if n.address == 0x10 and lux > 200:
status = BatteryStatus.RECHARGING
dev.set_battery_info(Battery(charge, None, status, None, lux))
return True
return False
return False
def _import_listener_deps():
try:
from logitech_receiver import base, device, receiver
from logitech_receiver.listener import EventsListener
except ImportError as e:
raise RuntimeError(
"logitech_receiver isn't importable -- install it with `pip install solaar`."
) from e
except ValueError as e:
raise RuntimeError(
"logitech_receiver failed to import because a GTK3 typelib is missing "
f"({e}). Install the same GTK3 + PyGObject packages Solaar's GUI needs "
"(e.g. python3-gi + gir1.2-gtk-3.0 on Debian/Ubuntu, or the gtk3/"
"python3-gobject equivalents on your distro)."
) from e
class _BatteryEventsListener(EventsListener):
def __init__(self, receiver_or_device, on_change):
super().__init__(receiver_or_device, self._handle_notification)
self._on_change = on_change
def has_started(self):
if not self.receiver.isDevice:
try:
self.receiver.notification_flags = (
self.receiver.enable_connection_notifications()
)
self.receiver.notify_devices()
except Exception:
logger.exception(
"failed enabling notifications for %s", self.receiver
)
def _handle_notification(self, n):
if self.receiver.isDevice:
try:
_process_battery_notification(self.receiver, n)
except Exception:
logger.exception("processing %s for %s", n, self.receiver)
self._on_change(self.receiver)
return
if n.devnumber == 0xFF:
try:
_process_battery_notification(self.receiver, n)
except Exception:
logger.exception("processing receiver notification %s", n)
self._on_change(self.receiver)
return
if not (0 < n.devnumber <= 16):
logger.warning(
"unexpected device number %s in %s", n.devnumber, n
)
return
try:
dev = self.receiver[n.devnumber]
except Exception:
logger.exception(
"resolving device number %s on %s",
n.devnumber,
self.receiver,
)
return
if not dev:
logger.warning(
"%s: received %s for invalid device %d",
self.receiver,
n,
n.devnumber,
)
return
try:
_process_battery_notification(dev, n)
except Exception:
logger.exception("processing %s for %s", n, dev)
self._on_change(dev)
return base, device, receiver, _BatteryEventsListener
def _start_hotplug_watcher(on_hotplug: Callable[[], None]):
try:
import pyudev
except ImportError:
logger.warning(
"pyudev isn't importable -- hotplugged devices will only be "
"noticed on the next --rescan-interval tick, not instantly. "
"Install it with `pip install pyudev` (it's a core Solaar "
"dependency, so it's normally already present)."
)
return None
debounce_lock = threading.Lock()
debounce_state = {"timer": None}
DEBOUNCE_SECONDS = 0.5
def _fire():
with debounce_lock:
debounce_state["timer"] = None
on_hotplug()
def _handle_event(_device):
if _device.action not in ("add", "remove"):
return
with debounce_lock:
existing = debounce_state["timer"]
if existing is not None:
existing.cancel()
timer = threading.Timer(DEBOUNCE_SECONDS, _fire)
timer.daemon = True
debounce_state["timer"] = timer
timer.start()
context = pyudev.Context()
monitor = pyudev.Monitor.from_netlink(context)
monitor.filter_by(subsystem="hidraw")
observer = pyudev.MonitorObserver(monitor, callback=_handle_event)
observer.start()
return observer
class _ListenerRegistry:
def __init__(self, on_change):
self._on_change = on_change
self._entries = {}
self._last_change: dict[int, float] = {}
self._lock = threading.Lock()
self._rescan_lock = threading.Lock()
self._min_rescan_interval = 1.0
self._last_rescan_time = 0.0
self._hidpp_io_lock = threading.RLock()
def _touch_and_notify(self, dev_or_receiver, *args, **kwargs):
with self._lock:
self._last_change[id(dev_or_receiver)] = time.time()
self._on_change(dev_or_receiver, *args, **kwargs)
def last_change(self, dev) -> float:
with self._lock:
return self._last_change.get(id(dev), 0.0)
def rescan(self):
now = time.monotonic()
if now - self._last_rescan_time < self._min_rescan_interval:
return
if not self._rescan_lock.acquire(blocking=False):
return
try:
self._last_rescan_time = now
with self._hidpp_io_lock:
base, device, receiver, ListenerClass = _import_listener_deps()
with self._lock:
dead = [
p
for p, (_obj, listener) in self._entries.items()
if not listener._active
]
for p in dead:
dying_obj, _dying_listener = self._entries[p]
if dying_obj.isDevice:
ident = getattr(
dying_obj, "unitId", None
) or getattr(dying_obj, "serial", None)
info = getattr(dying_obj, "battery_info", None)
if ident and info is not None:
for (
other_path,
(other_obj, _other_listener),
) in self._entries.items():
if other_path == p or other_obj.isDevice:
continue
for child in other_obj:
child_ident = getattr(
child, "unitId", None
) or getattr(child, "serial", None)
if child_ident == ident:
child.set_battery_info(info)
break
del self._entries[p]
known_paths = set(self._entries.keys())
with self._lock:
id_to_entry = {}
for path, (obj, listener) in self._entries.items():
if obj.isDevice:
ident = obj.unitId or obj.serial
if ident:
id_to_entry[ident] = (path, obj, listener)
for dev_info in base.receivers_and_devices():
if dev_info.path in known_paths:
continue
try:
if dev_info.isDevice:
obj = device.create_device(base, dev_info)
else:
obj = receiver.create_receiver(base, dev_info)
except OSError as e:
if e.errno == 13:
logger.error(
"permission denied opening %s -- check the Solaar udev rule "
"(rules.d/42-logitech-unify-permissions.rules) is installed",
dev_info.path,
)
else:
logger.exception("failed opening %s", dev_info)
continue
except Exception:
logger.exception("failed opening %s", dev_info)
continue
if obj is None:
continue
listener = ListenerClass(obj, self._touch_and_notify)
if obj.isDevice:
ident = None
try:
if obj.protocol >= 2.0:
obj.get_ids()
ident = obj.unitId or obj.serial
except Exception:
pass
if ident:
with self._lock:
existing = id_to_entry.get(ident)
if existing:
old_path, old_obj, old_listener = existing
if old_path != dev_info.path:
logger.info(
"replacing device %s (old path %s) with new path %s",
ident,
old_path,
dev_info.path,
)
if (
getattr(obj, "battery_info", None)
is None
and getattr(
old_obj, "battery_info", None
)
is not None
):
obj.set_battery_info(
old_obj.battery_info
)
old_listener.stop()
del self._entries[old_path]
self._entries[dev_info.path] = (
obj,
listener,
)
self._last_change[id(obj)] = time.time()
to_join = old_listener
break
else:
pass
else:
if (
getattr(obj, "battery_info", None)
is None
):
for (
other_path,
(other_obj, _other_listener),
) in self._entries.items():
if other_obj.isDevice:
continue
for child in other_obj:
child_ident = getattr(
child, "unitId", None
) or getattr(
child, "serial", None
)
if child_ident == ident:
obj.set_battery_info(
child.battery_info
)
break
if (
getattr(
obj,
"battery_info",
None,
)
is not None
):
break
with self._lock:
if dev_info.path not in self._entries:
self._entries[dev_info.path] = (obj, listener)
self._last_change[id(obj)] = time.time()
listener.start()
logger.info(
"listening on %s (%s)",
dev_info.path,
"device" if dev_info.isDevice else "receiver",
)
if "to_join" in locals():
try:
to_join.join(timeout=1.0)
except Exception:
logger.exception("error joining replaced listener")
del to_join
finally:
self._rescan_lock.release()
def known_devices(self):
with self._lock:
entries = list(self._entries.values())
result = []
seen_idents = set()
for obj, _listener in entries:
if obj.isDevice:
ident = obj.unitId or obj.serial
if ident and ident in seen_idents:
continue
if ident:
seen_idents.add(ident)
result.append(obj)
else:
for child in obj:
ident = child.unitId or child.serial
if ident and ident in seen_idents:
continue
if ident:
seen_idents.add(ident)
result.append(child)
return result
def stop(self):
with self._lock:
entries = list(self._entries.values())
for _obj, listener in entries:
try:
listener.stop()
except Exception:
logger.exception("error stopping listener")
for _obj, listener in entries:
listener.join(timeout=2.0)
def _snapshot(registry: _ListenerRegistry) -> list[DeviceBattery]:
results: list[DeviceBattery] = []
with registry._hidpp_io_lock:
for dev in registry.known_devices():
info = dev.battery_info
if info is None:
try:
if dev.ping():
info = dev.battery()
except Exception:
logger.debug(
"initial battery() failed for %s", dev, exc_info=True
)
info = None
entry = _to_device_battery(dev, info)
if entry is not None:
results.append(entry)
return results
def _clean_json(path: Path) -> None:
_write_json_atomic(path, [])
def _iter_open_devices():
try:
from logitech_receiver import base, device, receiver
except ImportError as e:
raise RuntimeError(
"logitech_receiver isn't importable -- install it with `pip install solaar`."
) from e
except ValueError as e:
raise RuntimeError(
"logitech_receiver failed to import because a GTK3 typelib is missing "
f"({e}). Install the same GTK3 + PyGObject packages Solaar's GUI needs "
"(e.g. python3-gi + gir1.2-gtk-3.0 on Debian/Ubuntu, or the gtk3/"
"python3-gobject equivalents on your distro)."
) from e
for dev_info in base.receivers_and_devices():
try:
if dev_info.isDevice:
d = device.create_device(base, dev_info)
else:
d = receiver.create_receiver(base, dev_info)
except OSError as e:
if e.errno == 13:
logger.error(
"permission denied opening %s -- check the Solaar udev rule "
"(rules.d/42-logitech-unify-permissions.rules) is installed "
"and you're in the right group",
dev_info.path,
)
else:
logger.exception("failed opening %s", dev_info)
continue
except Exception:
logger.exception("failed opening %s", dev_info)
continue
if d is None:
continue
if d.isDevice:
yield d
else:
yield from d
def _to_device_battery(dev, battery) -> DeviceBattery | None:
if battery is None:
return None
percentage = battery.level / 100 if isinstance(battery.level, int) else None
state = battery.status.name.lower() if battery.status is not None else None
serial = (
getattr(dev, "serial", None) or getattr(dev, "unitId", None) or None
)
return DeviceBattery(
model=dev.name or dev.codename or "Unknown device",
nativePath=_find_upower_native_path(serial),
serial=serial,
type=str(dev.kind) if dev.kind is not None else None,
wired=dev.receiver is None,
percentage=percentage,
state=state,
charging=battery.charging(),
isPresent=battery.ok(),
)
def _read_battery(dev) -> DeviceBattery | None:
try:
if not dev.ping():
return None
except Exception:
logger.debug("ping failed for %s", dev, exc_info=True)
return None
try:
battery = dev.battery()
except Exception:
logger.debug("battery() failed for %s", dev, exc_info=True)
return None
return _to_device_battery(dev, battery)
def poll_once() -> list[DeviceBattery]:
results: list[DeviceBattery] = []
seen_idents = set()
for dev in _iter_open_devices():
try:
info = _read_battery(dev)
if info is not None:
entry = _to_device_battery(dev, info)
if entry:
ident = entry.serial or (getattr(dev, "unitId", None))
if ident and ident in seen_idents:
continue
if ident:
seen_idents.add(ident)
results.append(entry)
finally:
with contextlib.suppress(Exception):
dev.close()
return results
def _write_json_atomic(path: Path, results: list[DeviceBattery]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"updated": time.time(),
"devices": [asdict(r) for r in results],
}
fd, tmp_path = tempfile.mkstemp(
dir=path.parent, prefix=".battery-", suffix=".tmp"
)
try:
with os.fdopen(fd, "w") as f:
json.dump(payload, f, indent=2)
os.replace(tmp_path, path)
except Exception:
Path(tmp_path).unlink(missing_ok=True)
raise
@app.command()
def daemon(
rescan_interval: float = typer.Option(
60.0,
"--rescan-interval",
"-i",
help=(
"Seconds between fallback rescans. This is NOT a battery poll "
"interval -- battery updates are event-driven and written as "
"soon as a device reports a change. New/removed devices "
"(e.g. plugging a keyboard in to charge) are normally noticed "
"within milliseconds via a udev hotplug watcher; this interval "
"is only a safety net in case a udev event is ever missed."
),
),
out: Path = typer.Option(
DEFAULT_OUTPUT,
"--out",
"-o",
help="Where to write the JSON status file.",
),
lock_file: Path = typer.Option(
DEFAULT_LOCK,
"--lock-file",
help="Path used to ensure only one daemon runs at a time.",
),
verbose: bool = typer.Option(
False,
"--verbose",
"-v",
help="Log every listener start and battery-change write to stderr.",
),
):
lock_fd = _acquire_singleton_lock(lock_file)
if lock_fd is None:
try:
holder_pid = lock_file.read_text().strip()
except OSError:
holder_pid = "unknown"
typer.echo(
f"error: a battery daemon is already running (pid {holder_pid}, lock: {lock_file})",
err=True,
)
raise typer.Exit(code=1)
if verbose:
logging.basicConfig(
level=logging.INFO, format="%(asctime)s %(name)s: %(message)s"
)
write_lock = threading.Lock()
last_payload = None
pending_write_timer: list[threading.Timer | None] = [None]
pending_write_lock = threading.Lock()
WRITE_SETTLE_SECONDS = 0.3
write_seq_lock = threading.Lock()
write_seq = 0
last_written_seq = 0
def write_if_changed():
nonlocal last_payload, write_seq, last_written_seq
with write_seq_lock:
write_seq += 1
my_seq = write_seq
results = _snapshot(registry)
with write_lock:
with write_seq_lock:
if my_seq <= last_written_seq:
return
last_written_seq = my_seq
payload = [asdict(r) for r in results]
if payload != last_payload:
_write_json_atomic(out, results)
last_payload = payload
logger.info(
"battery status changed, wrote %d device(s)", len(results)
)
def write_if_changed_coalesced():
with pending_write_lock:
existing = pending_write_timer[0]
if existing is not None:
existing.cancel()
timer = threading.Timer(WRITE_SETTLE_SECONDS, write_if_changed)
timer.daemon = True
pending_write_timer[0] = timer
timer.start()
def on_change(_device_or_receiver, alert=None, reason=None):
write_if_changed_coalesced()
registry = _ListenerRegistry(on_change)
def rescan_and_write():
try:
registry.rescan()
except Exception:
logger.exception("rescan failed")
write_if_changed_coalesced()
try:
registry.rescan()
except RuntimeError as e:
typer.echo(f"error: {e}", err=True)
raise typer.Exit(code=1) from None
write_if_changed()
hotplug_observer = _start_hotplug_watcher(rescan_and_write)
running = True
def _stop(signum, frame):
nonlocal running
running = False
signal.signal(signal.SIGINT, _stop)
signal.signal(signal.SIGTERM, _stop)
typer.echo(
f"Listening for battery events, writing to {out} (lock: {lock_file})"
)
try:
while running:
remaining = rescan_interval
while running and remaining > 0:
step = min(0.2, remaining)
time.sleep(step)
remaining -= step
if running:
rescan_and_write()
finally:
if hotplug_observer is not None:
with contextlib.suppress(Exception):
hotplug_observer.stop()
with contextlib.suppress(Exception):
_clean_json(out)
registry.stop()
typer.echo("Stopped.")
if __name__ == "__main__":
app()
-310
View File
@@ -1,310 +0,0 @@
#!/bin/bash
# =============================================================================
# wifi-sim.sh — Virtual WiFi environment for development & testing
#
# Creates 3 dummy networks using mac80211_hwsim + hostapd:
# - FreeWifi (open, strong signal, ch 1)
# - OfficeNetwork (open, medium signal, ch 6)
# - HomeNetwork (WPA2, weak signal, ch 11)
#
# Usage:
# sudo ./wifi-sim.sh start
# sudo ./wifi-sim.sh stop
# sudo ./wifi-sim.sh status
# =============================================================================
set -euo pipefail
# --- User configuration ------------------------------------------------------
SSID_STRONG="FreeWifi" # Open network, strong signal
SSID_MEDIUM="OfficeNetwork" # Open network, medium signal
SSID_SECURE="HomeNetwork" # WPA2 network, weak signal
WIFI_PASSWORD="testpassword123"
# TX power levels in mBm (millibel-milliwatts). Higher = stronger signal.
# These affect the RSSI reported to scanning clients.
TXPOWER_STRONG=3000 # 30 dBm
TXPOWER_MEDIUM=1500 # 15 dBm
TXPOWER_WEAK=100 # 1 dBm
RUN_DIR="/tmp/wifi-sim"
# --- Helpers -----------------------------------------------------------------
die() {
echo "ERROR: $*" >&2
exit 1
}
info() { echo "[*] $*"; }
ok() { echo "[+] $*"; }
require_cmds() {
local missing=()
for cmd in "$@"; do
command -v "$cmd" &>/dev/null || missing+=("$cmd")
done
if [[ ${#missing[@]} -gt 0 ]]; then
die "Missing required tools: ${missing[*]}
Install with: sudo apt install hostapd wpasupplicant iw"
fi
}
get_phy_for_iface() {
iw dev "$1" info | awk '/wiphy/{print "phy"$2}'
}
# --- Start -------------------------------------------------------------------
cmd_start() {
[[ $EUID -ne 0 ]] && die "Please run as root: sudo $0 start"
require_cmds hostapd iw ip
if [[ -d "$RUN_DIR" && -f "$RUN_DIR/interfaces" ]]; then
die "wifi-sim appears to already be running. Run 'sudo $0 stop' first."
fi
mkdir -p "$RUN_DIR"
# Snapshot existing wlan interfaces so we can identify the new ones
info "Snapshotting existing interfaces..."
BEFORE=($(iw dev 2>/dev/null | awk '/Interface/{print $2}' | grep -v '^hwsim' || true))
# Load the kernel module with 4 radios (3 APs + 1 client)
info "Loading mac80211_hwsim (4 radios)..."
if lsmod | grep -q mac80211_hwsim; then
die "mac80211_hwsim is already loaded. Run 'sudo $0 stop' or 'sudo rmmod mac80211_hwsim' first."
fi
modprobe mac80211_hwsim radios=4
sleep 1 # Give the kernel a moment to register all interfaces
# Find the interfaces created by our modprobe
AFTER=($(iw dev 2>/dev/null | awk '/Interface/{print $2}' | grep -v '^hwsim'))
NEW_IFACES=()
for iface in "${AFTER[@]}"; do
[[ ! " ${BEFORE[*]:-} " =~ " $iface " ]] && NEW_IFACES+=("$iface")
done
if [[ ${#NEW_IFACES[@]} -lt 4 ]]; then
rmmod mac80211_hwsim 2>/dev/null || true
die "Expected 4 new wlan interfaces, found ${#NEW_IFACES[@]}. Check 'dmesg' for errors."
fi
AP1="${NEW_IFACES[0]}" # Will host SSID_STRONG
AP2="${NEW_IFACES[1]}" # Will host SSID_MEDIUM
AP3="${NEW_IFACES[2]}" # Will host SSID_SECURE
CLIENT="${NEW_IFACES[3]}" # Client-side interface for your code to use
info "Interfaces assigned: AP1=$AP1 AP2=$AP2 AP3=$AP3 Client=$CLIENT"
echo "$AP1 $AP2 $AP3 $CLIENT" >"$RUN_DIR/interfaces"
# Prevent NetworkManager from fighting us over these interfaces
if command -v nmcli &>/dev/null && nmcli general status &>/dev/null 2>&1; then
info "Marking interfaces as unmanaged in NetworkManager..."
for iface in "$AP1" "$AP2" "$AP3"; do
nmcli device set "$iface" managed no 2>/dev/null || true
done
nmcli device set "$CLIENT" managed yes
fi
# Set TX power on each physical radio to simulate signal strength differences.
# mac80211_hwsim factors TX power into the RSSI reported to scanning clients.
# Note: for precise RSSI control, consider 'wmediumd' (a wireless medium daemon).
info "Setting TX power levels for signal strength simulation..."
PHY1=$(get_phy_for_iface "$AP1")
PHY2=$(get_phy_for_iface "$AP2")
PHY3=$(get_phy_for_iface "$AP3")
iw phy "$PHY1" set txpower fixed $TXPOWER_STRONG
iw phy "$PHY2" set txpower fixed $TXPOWER_MEDIUM
iw phy "$PHY3" set txpower fixed $TXPOWER_WEAK
# --- hostapd config: AP1 (open, strong, channel 1) -----------------------
cat >"$RUN_DIR/ap1.conf" <<EOF
interface=$AP1
driver=nl80211
ssid=$SSID_STRONG
hw_mode=g
channel=1
auth_algs=1
wmm_enabled=0
ignore_broadcast_ssid=0
EOF
# --- hostapd config: AP2 (open, medium, channel 6) -----------------------
cat >"$RUN_DIR/ap2.conf" <<EOF
interface=$AP2
driver=nl80211
ssid=$SSID_MEDIUM
hw_mode=g
channel=6
auth_algs=1
wmm_enabled=0
ignore_broadcast_ssid=0
EOF
# --- hostapd config: AP3 (WPA2, weak, channel 11) ------------------------
cat >"$RUN_DIR/ap3.conf" <<EOF
interface=$AP3
driver=nl80211
ssid=$SSID_SECURE
hw_mode=g
channel=11
auth_algs=1
wpa=2
wpa_passphrase=$WIFI_PASSWORD
wpa_key_mgmt=WPA-PSK
rsn_pairwise=CCMP
wmm_enabled=0
ignore_broadcast_ssid=0
EOF
# Start a hostapd instance for each AP
info "Starting hostapd instances..."
hostapd -B -P "$RUN_DIR/ap1.pid" "$RUN_DIR/ap1.conf" \
>"$RUN_DIR/ap1.log" 2>&1 ||
{
cat "$RUN_DIR/ap1.log"
die "hostapd failed for AP1. See log above."
}
hostapd -B -P "$RUN_DIR/ap2.pid" "$RUN_DIR/ap2.conf" \
>"$RUN_DIR/ap2.log" 2>&1 ||
{
cat "$RUN_DIR/ap2.log"
die "hostapd failed for AP2. See log above."
}
hostapd -B -P "$RUN_DIR/ap3.pid" "$RUN_DIR/ap3.conf" \
>"$RUN_DIR/ap3.log" 2>&1 ||
{
cat "$RUN_DIR/ap3.log"
die "hostapd failed for AP3. See log above."
}
# Bring up the client interface so it's ready to scan/connect
ip link set "$CLIENT" up
# --- Summary -------------------------------------------------------------
ok "Virtual WiFi environment is up!"
echo ""
echo " Networks:"
echo " ┌──────────────────┬──────────┬────────┬─────────────┐"
echo " │ SSID │ Security │ Signal │ Interface │"
echo " ├──────────────────┼──────────┼────────┼─────────────┤"
printf " │ %-16s │ %-8s │ %-6s │ %-11s │\n" "$SSID_STRONG" "Open" "Strong" "$AP1"
printf " │ %-16s │ %-8s │ %-6s │ %-11s │\n" "$SSID_MEDIUM" "Open" "Medium" "$AP2"
printf " │ %-16s │ %-8s │ %-6s │ %-11s │\n" "$SSID_SECURE" "WPA2" "Weak" "$AP3"
echo " └──────────────────┴──────────┴────────┴─────────────┘"
echo ""
echo " WPA2 password : $WIFI_PASSWORD"
echo " Client iface : $CLIENT (use this in your code)"
echo ""
echo " Useful commands:"
echo " Scan for networks : sudo iw dev $CLIENT scan | grep -E 'SSID|signal'"
echo " Connect (open) : sudo iw dev $CLIENT connect \"$SSID_STRONG\""
echo " Connect (WPA2) : sudo wpa_supplicant -Dnl80211 -i$CLIENT \\"
echo " -c <(wpa_passphrase \"$SSID_SECURE\" \"$WIFI_PASSWORD\")"
echo " Stop simulation : sudo $0 stop"
echo ""
echo " Logs: $RUN_DIR/ap{1,2,3}.log"
}
# --- Stop --------------------------------------------------------------------
cmd_stop() {
[[ $EUID -ne 0 ]] && die "Please run as root: sudo $0 stop"
info "Stopping virtual WiFi environment..."
local had_something=false
# Kill hostapd processes via PID files
for ap in ap1 ap2 ap3; do
PID_FILE="$RUN_DIR/$ap.pid"
if [[ -f "$PID_FILE" ]]; then
PID=$(cat "$PID_FILE")
if kill "$PID" 2>/dev/null; then
info "Stopped hostapd for $ap (PID $PID)"
fi
rm -f "$PID_FILE"
had_something=true
fi
done
# Re-enable NetworkManager management for these interfaces
if [[ -f "$RUN_DIR/interfaces" ]]; then
read -r AP1 AP2 AP3 CLIENT <"$RUN_DIR/interfaces"
if command -v nmcli &>/dev/null; then
for iface in "$AP1" "$AP2" "$AP3" "$CLIENT"; do
nmcli device set "$iface" managed yes 2>/dev/null || true
done
fi
fi
# Unload the kernel module (also destroys all virtual interfaces)
if lsmod | grep -q mac80211_hwsim; then
info "Unloading mac80211_hwsim..."
rmmod mac80211_hwsim && had_something=true
fi
# Clean up runtime directory
rm -rf "$RUN_DIR"
if $had_something; then
ok "Virtual WiFi environment stopped."
else
echo "Nothing was running."
fi
}
# --- Status ------------------------------------------------------------------
cmd_status() {
if [[ ! -f "$RUN_DIR/interfaces" ]]; then
echo "wifi-sim is not running."
return
fi
read -r AP1 AP2 AP3 CLIENT <"$RUN_DIR/interfaces"
echo "wifi-sim is running."
echo ""
echo " Interfaces:"
for iface in "$AP1" "$AP2" "$AP3" "$CLIENT"; do
STATE=$(ip link show "$iface" 2>/dev/null | awk '/state/{print $9}' || echo "unknown")
CHANNEL=$(iw dev "$iface" info 2>/dev/null | awk '/channel/{print $2}' || echo "?")
printf " %-10s state=%-4s channel=%s\n" "$iface" "$STATE" "$CHANNEL"
done
echo ""
echo " Processes:"
for ap in ap1 ap2 ap3; do
PID_FILE="$RUN_DIR/$ap.pid"
if [[ -f "$PID_FILE" ]]; then
PID=$(cat "$PID_FILE")
if kill -0 "$PID" 2>/dev/null; then
echo " hostapd ($ap) PID=$PID running"
else
echo " hostapd ($ap) PID=$PID DEAD (check $RUN_DIR/$ap.log)"
fi
fi
done
}
# --- Entry point -------------------------------------------------------------
case "${1:-help}" in
start) cmd_start ;;
stop) cmd_stop ;;
status) cmd_status ;;
*)
echo "Usage: sudo $0 {start|stop|status}"
echo ""
echo " start Load mac80211_hwsim and bring up 3 virtual APs"
echo " stop Tear down all virtual interfaces and unload module"
echo " status Show current state of interfaces and processes"
echo ""
echo "Networks created on start:"
printf " %-16s open, strong signal\n" "$SSID_STRONG"
printf " %-16s open, medium signal\n" "$SSID_MEDIUM"
printf " %-16s WPA2, weak signal (password: $WIFI_PASSWORD)\n" "$SSID_SECURE"
;;
esac
+4
View File
@@ -54,6 +54,10 @@ select = [
"RUF",
]
[tool.basedpyright]
typeCheckingMode = "basic"
include = ["cli/src"]
[tool.pytest.ini_options]
testpaths = ["cli/tests"]
pythonpath = ["cli/src"]